项目经验处理
订单取消和支付成功并发问题
这是一个非常经典且重要的分布式系统问题。订单取消和支付成功同时发生,本质上是一个资源竞争问题,核心在于如何保证两个并发操作对订单状态的修改满足业务的最终一致性(即一个订单最终只能有一种确定的状态)。
核心业务原则与状态机
首先,必须明确一个坚不可摧的业务规则:一个订单一旦支付成功,就绝不能被取消。 反之,一个订单如果先被成功取消,那么后续的支付也应该失败。
这通常通过订单状态机来实现:
- 初始状态:
待支付
- 可变为:
已支付
(支付操作) 或已取消
(取消操作) 已支付
和已取消
是两个终点状态,不能再相互转换。
我们的所有技术方案都是为了保护这个状态机的正确流转。
方案一:数据库悲观锁 (Pessimistic Locking) - 简单粗暴
适用场景: 单体架构,数据库压力不大,并发冲突相对较少的场景。
实现原理: 在修改订单状态前,先通过 SELECT ... FOR UPDATE
锁定数据库中的这条订单记录。这样其他事务在尝试修改这条记录时会被阻塞,直到锁被释放,从而保证了操作的串行化。
实现步骤(以支付和取消同时发生为例):
- 无论是支付回调逻辑还是取消逻辑,在开始处理时,首先开启一个数据库事务。
- 在事务中,使用
SELECT * FROM orders WHERE order_id = ? FOR UPDATE
查询并锁定订单记录。 - 检查订单的当前状态:
- 如果状态是
待支付
,则继续执行支付或取消操作,更新状态。 - 如果状态已经是
已支付
,则取消操作应识别到此状态,直接返回“取消失败,订单已支付”并结束逻辑。 - 如果状态已经是
已取消
,则支付操作应识别到此状态,直接返回“支付失败,订单已关闭”并结束逻辑。
- 如果状态是
- 提交事务,释放锁。
优点:
- 实现简单,直接利用数据库的能力。
- 强一致性保证。
缺点:
- 性能瓶颈:并发高时,大量请求会被阻塞,数据库连接池容易被耗尽的,响应时间变长。
- 不适合分布式系统:如果应用是集群部署,多个节点间的数据库连接是共享的,这个方法仍然有效,但数据库本身会成为单点瓶颈。
- 死锁风险:需要仔细控制业务的加锁顺序。
方案二:数据库乐观锁 (Optimistic Locking) - 推荐常用
适用场景: 绝大多数并发场景,特别是读多写少的情况。单体和服务化架构都适用。
实现原理: 不对数据加锁,而是通过一个版本号(version
)字段或时间戳来实现。在更新时,检查版本号是否和最初读取时一致,如果一致则更新成功并版本号+1,否则更新失败,意味着数据已被其他操作修改过。
实现步骤:
- 订单表增加一个
version
字段(或使用update_time
时间戳)。 - 支付或取消逻辑开始时,先查询出订单的当前状态和当前
version
(例如version=1
)。 - 根据业务逻辑计算下一个状态。
- 执行更新操作:
或UPDATE orders SET status = '已支付', version = version + 1 WHERE order_id = ? AND version = 1; -- 这里version是之前查出来的值
UPDATE orders SET status = '已取消', version = version + 1 WHERE order_id = ? AND version = 1;
- 检查更新语句的影响行数(affected rows):
- 如果影响行数为 1,说明更新成功,抢到了资源。
- 如果影响行数为 0,说明
WHERE
条件不成立(即version
已经变了),更新失败。此时可以重新查询订单的最新状态,并告知用户“操作失败,请重试”或根据最新状态进行后续处理(例如支付时发现订单已取消,则进行退款)。
优点:
- 性能比悲观锁好很多,避免了数据库锁的开销。
- 适用于分布式环境。
缺点:
- 需要处理更新失败的情况,业务逻辑稍复杂(通常需要重试或提示用户)。
- 如果冲突频率非常高,频繁的重试反而会降低性能。
方案三:状态机 + 数据库唯一约束 - 优雅幂等
适用场景: 作为辅助手段,与乐观锁结合使用,提供更强的幂等性和一致性保证。
实现原理: 利用数据库的唯一键约束,来防止订单状态被重复更新。例如,可以创建一张“订单状态变更流水表”。
- 创建订单状态流水表
order_status_log
:CREATE TABLE order_status_log (id BIGINT PRIMARY KEY AUTO_INCREMENT,order_id BIGINT NOT NULL,from_status VARCHAR(20),to_status VARCHAR(20) NOT NULL,create_time DATETIME,-- 唯一约束:一个订单的每次状态变更必须是唯一的UNIQUE KEY uk_order_id_status (order_id, from_status, to_status) );
- 在更新订单主表状态时,在同一数据库事务中,向流水表插入一条记录。
- 例如,从
待支付
变更为已支付
,则插入(order_id, ‘待支付’, ‘已支付’, now())
。
- 例如,从
- 如果两个操作同时发生,比如支付和取消都通过了乐观锁的版本检查,尝试更新主表和插入流水表。由于流水表的
UNIQUE KEY uk_order_id_status (order_id, from_status, to_status)
约束,第二个插入操作必然会失败,从而导致整个事务回滚。 - 最终只有一个操作能成功。
优点:
- 提供了极强的数据一致性保证,几乎不可能出现状态错乱。
- 流水表本身也具有审计和追溯的价值。
缺点:
- 业务逻辑更复杂,需要维护两张表。
- 依然依赖数据库事务。
方案四:消息队列 + 最终一致性 - 分布式解耦
适用场景: 大型分布式系统,业务解耦要求高,吞吐量大的场景。
实现原理: 将同步的、可能冲突的操作,通过消息队列串行化处理。支付成功和取消请求都先发送到消息队列,由一个消费者按顺序逐个处理,从而从根本上避免并发冲突。
实现步骤:
- 消息生产:
- 支付回调异步通知和用户取消请求,都不直接处理业务逻辑。
- 它们只负责校验基础参数,然后向一个特定主题的消息队列(如 RocketMQ/Kafka)发送一条消息。消息体包含订单ID和操作类型(e.g.,
{"orderId": 123, "event": "PAYMENT_SUCCESS"}
)。
- 消息消费:
- 创建一个顺序消息消费者,监听这个主题。确保同一个订单ID的消息被发送到同一个MessageQueue,并被同一个消费者顺序处理。
- 消费者接收到消息后:
a. 开启事务。
b. 查询订单(无需FOR UPDATE
,因为消息是顺序的)。
c. 检查订单状态机:
- 若当前状态允许执行目标操作(如状态是待支付
,目标是已支付
),则更新状态。
- 若不允许(如状态已是已取消
,目标是已支付
),则丢弃消息或记录日志(说明发生了冲突,但由消费者优雅处理了)。
d. 提交事务。
e. 消费成功,确认消息。
优点:
- 高吞吐量,性能好。
- 彻底解耦,支付和取消的发起方不需要等待业务处理完成。
- 通过顺序消息天然解决了并发问题。
缺点:
- 架构复杂,引入了消息队列组件。
- 一致性是最终一致性,处理会有毫秒级或秒级的延迟。
- 需要保证消息的可靠投递和不重复消费(幂等),通常消息队列本身能提供
Exactly-Once
或At-Least-Once
语义,消费端需要做幂等(方案二的乐观锁或方案三的状态流水正好可以用来做幂等)。
方案五:分布式锁 - 通用方案
适用场景: 分布式系统,需要对某个分布式资源进行互斥访问。
实现原理: 在执行业务逻辑前,先尝试获取一个基于订单ID的分布式锁(如 Redis 的 SET order_id:123 random_value NX EX 30
)。只有拿到锁的操作才能继续执行查询和更新订单状态的逻辑。
实现步骤:
- 支付或取消逻辑开始时,先尝试获取指定
order_id
的分布式锁。 - 获取成功,则继续执行数据库查询和更新逻辑(此时可以用简单的先查后改,因为锁保证了互斥)。
- 获取失败,则重试或直接返回“系统繁忙,请稍后再试”。
- 业务逻辑处理完成后,释放分布式锁。
优点:
- 通用性强,不依赖于数据库特性。
- 适用于任何需要互斥访问的分布式场景。
缺点:
- 引入新的组件(如Redis),增加了系统复杂性。
- 如果锁失效时间设置不当,可能导致锁提前释放(业务没做完)或死锁(业务做完锁没释放)。
- 性能开销比数据库乐观锁大。
总结与选型建议
方案 | 复杂度 | 一致性 | 性能 | 适用架构 | 核心思想 |
---|---|---|---|---|---|
悲观锁 | 低 | 强一致性 | 差 | 单体 | 先加锁,再操作 |
乐观锁 | 中 | 最终一致性 | 好 | 单体/分布式 | 无锁检测,失败重试 |
状态机+约束 | 中 | 强一致性 | 中 | 单体/分布式 | 利用数据库约束防冲突 |
消息队列 | 高 | 最终一致性 | 极好 | 分布式 | 异步化与串行化 |
分布式锁 | 高 | 最终一致性 | 中 | 分布式 | 外部组件实现互斥 |
给你的建议:
- 新手或初创项目:优先使用 方案二(乐观锁)。它在复杂性、性能和一致性上取得了很好的平衡,是处理这类问题最常用的手段。
- 中等规模项目:采用 方案二 + 方案三 组合。用乐观锁做更新,用状态流水表做幂等、审计和双重保险,非常稳健。
- 大型分布式系统:采用 方案四(消息队列)。将并发请求转为顺序处理,是解决高并发问题的终极方案之一,同时也能很好地解耦系统。
- 方案一(悲观锁) 尽量少用,除非你非常确定并发量不高。
- 方案五(分布式锁) 更适用于更广泛的分布式资源竞争场景,单纯为了订单状态这个问题,通常优先选择乐观锁或消息队列。
最终,选择哪种方案取决于你的业务规模、技术架构和团队对复杂性的承受能力。