主题
第十四章:订单超时自动取消
Q43:为什么订单超时自动取消这个场景,需要 Redis 参与而不是纯数据库轮询?
答:
订单超时自动取消是电商/交易系统的典型需求:用户下单后未支付,超时(如 15 分钟、30 分钟)后要自动把订单置为「已取消」,并释放锁定的库存。
这个场景如果只用数据库轮询,会有一系列问题:
| 问题 | 具体表现 |
|---|---|
| 轮询效率低 | 为了找到少数超时订单,要扫描全表或维护索引定时扫,开销大 |
| 定时扫描有延迟 | 定时任务有间隔,订单可能「超时了 1 分钟才被扫到」,取消不够及时 |
| 数据库压力大 | 高频扫描把压力打在数据库上,交易库本应服务核心写请求 |
| 精准性难保证 | 用定时任务判断「是否超时」需要额外的排序和过滤,边界情况容易算错 |
| 分布式下易重复取消 | 多个实例跑同一份扫描任务,可能重复处理同一订单,需要额外的幂等和抢占逻辑 |
Redis 参与的价值,在于用它的数据结构 + 过期机制,把「超时」这件事转交出去:
- ZSet:用「订单到期时间戳」作为 score,天然支持「按时序取出最先到期的订单」;
- key 过期事件:给订单设置 TTL,到期自动触发回调,不用自己轮询;
- 原子性:Redis 命令的单线程原子性,能保证「谁抢到处理权就谁取消」,避免重复处理。
一句话总结:数据库负责「权威数据」,Redis 负责「超时的触发与调度」,两者分工,而不是让数据库既存数据又做定时器。
场景状态机
text
下单成功 ──► 待支付 ──► 支付中 ──► 已支付
PENDING PENDING PAYING PAID
│
├──(用户发起支付)──► PAYING(支付中)
│ │
│ └──(支付成功回调)──► PAID
│
└──(超时未支付)──► 自动取消 ──► 释放库存
CANCELLED关键点:「待支付 PENDING」和「已支付 PAID」之间必须有一个「支付中 PAYING」的中间态。用户一旦发起支付,就要原子地把订单从 PENDING 改成 PAYING,这样"支付中"的订单对取消线程就不再可见(取消只针对 PENDING),从状态机层面杜绝"支付中被取消"。
关键设计点
无论用哪种方案,订单超时取消都要解决三个共性问题:
- 幂等性:取消操作可能被多个进程触发,必须保证「只取消一次」;
- 与支付竞态:订单可能在取消的瞬间刚好支付成功,要避免「取消了一个已支付的订单」或者「支付了一个已取消的订单」;
- 库存释放:取消后要释放锁定的库存,释放也必须幂等。
这三个问题在下面具体方案里会反复出现。
「取消 vs 支付」竞态为什么最危险
三个共性问题里,「取消 vs 支付」竞态是最致命的并发 bug,因为它会造成「钱扣了、订单却没了」的资金事故。用最坏的一条时序来看:
text
时间轴 订单状态
────────────────────────────────────────────────────────────
T1 取消线程:查订单状态 → 得到 "PENDING"(待支付) PENDING
T2 用户发起支付,订单进入支付中(本应 PENDING → PAYING) PAYING
T3 取消线程:基于 T1 的旧判断,执行「取消订单」 CANCELLED
T4 支付成功回调到达,尝试 PAYING → PAID CANCELLED(已无法更新为 PAID)
────────────────────────────────────────────────────────────
结果:用户钱已经付了(或正在付),但订单被取消,支付侧状态更新失败 → 资金事故根因:取消线程在 T1「读状态」和 T3「写状态」之间不是原子的,中间夹了一个支付操作。单纯靠「先查后改」(check-then-act)根本无法保证正确,必须让「更新」这一步带上前置条件(例如 WHERE status = 'PENDING'),用数据库的条件更新 / 乐观锁把「判断 + 修改」压成一次原子操作,才能从根本上消除竞态(具体写法见 Q44 第五节)。
但条件更新还不足以根治:上面 T2 揭示了一个更前置的问题——「用户发起支付」这个动作本身必须立刻反映到状态机里,否则"支付中"的订单在数据库里看起来仍是 PENDING,取消线程就会在 T3"合法"地取消它。因此正确做法是引入**「支付中 PAYING」中间态**:
- 发起支付时:
UPDATE ... SET status='PAYING' WHERE status='PENDING',先把状态从"待支付"推进到"支付中",这一步就"占住"了提单资格; - 取消侧只认 PENDING:
UPDATE ... SET status='CANCELLED' WHERE status='PENDING',一旦订单进入PAYING,取消的 WHERE 就匹配不上,"支付中"的订单对取消线程天然不可见; - 支付成功回调:
UPDATE ... SET status='PAID' WHERE status='PAYING',从支付中推进到已支付。
这样,「发起支付」「取消」两个动作都通过"带状态的原子 UPDATE"去抢占状态机,谁先改成功谁赢,不会出现"支付中被取消"。
为什么不能只靠 Redis 解决:Redis 的 ZREM 抢占只能保证「同一个订单只被一个取消线程处理」,但它管不到「取消线程」和「支付线程」这两个不同业务动作之间的竞争——支付走的是支付回调,根本不经过 ZSet。所以最终的正确性防线必须落在**数据库的状态条件更新(含 PAYING 中间态)**上,Redis 只负责触发和去重。
Q44:用 ZSet 实现订单超时自动取消,怎么做?
答:
ZSet 方案是订单超时取消最经典、最可控的实现方式。核心是:把「订单到期时间」作为 ZSet 的 score,用定时扫描把已经到期的订单取出来处理。
一、整体流程
text
1. 下单时
订单写入数据库,状态 = PENDING
同时 ZADD delay:order:cancel <到期时间戳> order:1001
2. 用户发起支付时
原子地把订单 PENDING → PAYING(支付中)
(此时取消线程不再有资格碰它,见下面「支付中不可取消」)
3. 后台定时任务(每 N 秒跑一次)
取当前时间 now
ZRANGEBYSCORE delay:order:cancel -inf now
→ 得到所有「已经到期」的订单
4. 对每个到期订单
检查数据库当前状态是否仍是「待支付 PENDING」
是 → 取消订单 + 释放库存
否(已支付 PAID / 支付中 PAYING / 已取消 CANCELLED)→ 跳过
5. 处理完后
ZREM delay:order:cancel order:1001 (从延迟队列移除)二、下单与入队
python
import time
def create_order(order_id: str, timeout_seconds: int = 15 * 60):
expire_at = int(time.time()) + timeout_seconds
# 1. 订单写入数据库,状态 PENDING
db.insert_order(order_id, status="PENDING")
# 2. 把订单的「到期时间戳」作为 score 写入 ZSet
redis_client.zadd("delay:order:cancel", {order_id: expire_at})这里 score 用的是绝对时间戳(Unix 秒),不是相对秒数,这样才能直接用 ZRANGEBYSCORE 判断哪些已经到期。
命令参数示意:
text
ZADD delay:order:cancel <到期时间戳(score)> <订单号(member)>
↑ key ↑ 排序依据,Unix 秒 ↑ 成员,一般是可反查订单的唯一 id- score 是「到期时间」:Redis 的 ZSet 用 score 排序,把「到期时间戳」当 score,就能靠
ZRANGEBYSCORE按时序取出「最先到期」的一批订单; - member 是「订单唯一标识」:通常是订单号,拿到 member 就能反查数据库里的订单详情。
Python 客户端 zadd 的参数(redis-py 4.x 之后)是一个映射(mapping)+ 可选参数:
python
# redis-py 4.x 全量签名
redis_client.zadd(
name="delay:order:cancel", # key
mapping={order_id: expire_at}, # {member: score}
nx=False, # 仅当 member 不存在时才添加(False=覆盖)
xx=False, # 仅当 member 已存在时才更新
ch=False, # 返回「被修改的成员数」而非「新增的成员数」
incr=False, # 对 score 做增量而非覆盖
)三、后台扫描与取消
python
def scan_and_cancel():
now = int(time.time())
# 取出所有 score <= now 的订单,即「已经到期」的订单
expired = redis_client.zrangebyscore("delay:order:cancel", "-inf", now)
for order_id in expired:
cancel_order(order_id)
return len(expired)命令参数示意:
text
ZRANGEBYSCORE delay:order:cancel -inf now
↑ key ↑ min ↑ max
含义:取出 key 里 score 满足 -inf <= score <= now 的成员,
即「到期时间 <= 当前时间」的所有已到期订单。-inf(负无穷)表示「不设下限」,now是当前 Unix 秒,等价于「所有已到期的订单」;- 完整语法还支持
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]:WITHSCORES把 score 一起返回,LIMIT用于分页取出,订单量大时避免一次取太多。
cancel_order 里要处理幂等和竞态:
python
def cancel_order(order_id: str):
# 1. 先检查数据库真实状态,只有「待支付 PENDING」才取消
status = db.get_order_status(order_id)
if status != "PENDING":
# 不是 PENDING:可能是已支付 PAID / 支付中 PAYING / 已取消 CANCELLED
# 直接清理 ZSet 记录即可,绝不能取消
redis_client.zrem("delay:order:cancel", order_id)
return
# 2. 用「条件更新」实现幂等取消:只有状态仍是 PENDING 才更新成功
affected = db.cancel_order_if_pending(order_id)
if affected:
# 3. 释放锁定的库存(也要幂等)
release_stock(order_id)
# 4. 无论是否真的取消,都从延迟队列移除
redis_client.zrem("delay:order:cancel", order_id)这里的两处 zrem 不是「抢占」,而是「清理」——别和第四节的 ZREM 抢占混为一谈。 cancel_order 是被"抢占成功"之后才被调用的,它内部的 zrem 只是把订单从延迟队列里摘掉。真正负责"谁有处理权"的 ZREM 抢占在调用它的外层(scan_and_cancel_distributed)里,两者是两个不同的环节:
| 环节 | 关键调用 | 作用 | 时机 |
|---|---|---|---|
| 抢占(外层) | zrangebyscore 取到期列表 → 逐条 zrem 抢处理权 | 保证同一订单只被一个实例处理 | 进入 cancel_order 之前 |
| 清理(内层) | cancel_order 结尾的 zrem | 把已处理的订单从队列摘掉,防止重复扫描 | cancel_order 处理完成之后 |
为什么 cancel_order 里还要有一句"无论是否真的取消都 zrem"?因为只要这个订单已经被扫描并处理过,它就不该继续留在队列里,否则下一轮 zrangebyscore 又会把它取出来、再次调用 cancel_order,造成无效重复。即便当时状态已经是 PAID(第 1 步早退)、没有真正取消,这个"已处理"的订单也必须被移出队列:
python
def cancel_order(order_id: str):
status = db.get_order_status(order_id)
if status == "PENDING":
affected = db.cancel_order_if_pending(order_id)
if affected:
release_stock(order_id)
# 关键:无论上面走没走"真正取消",都要执行一次 zrem,
# 把这个已处理的订单移出队列,避免下一轮又被扫到
redis_client.zrem("delay:order:cancel", order_id)至于那句 zrem 的返回值是否要判断:在做"清理"时通常不必关注返回值——无论删没删到(0 或 1),都要保证"这个订单处理完、不在队列里"这个结果,所以直接调用即可,不分支。真正需要看返回值的是外层的抢占:zrem(...) == 1 才继续 cancel_order,== 0 则说明被别人抢走了、直接跳过。
一个关键细节:cancel_order 里那句 zrem 在多数时候其实"删不到"——返回 0,这很正常。
回忆第四节的抢占流程:外层 scan_and_cancel_distributed 是先 zrem 抢占成功(此时 member 已被移出队列),才调用 cancel_order。所以当 cancel_order 执行到结尾的 zrem 时,这个订单的 member 往往已经不在 ZSet 里了,那次的 zrem 会返回 0。
那为什么还要写这句 zrem?因为它要覆盖的是另外两种"没有经过外层抢占"的调用路径:
- 单实例写法:第一节/第三节的
scan_and_cancel是"先zrangebyscore取出、直接逐条cancel_order",中间没有用zrem抢占。这时如果cancel_order内部不做zrem,订单就会一直留在队列里,被下一轮反复扫到。 - 直接调用 / 手动补偿:某些路径(如人工触发、定时补偿)可能绕过外层抢占,直接调
cancel_order,同样靠内部这句zrem兜底清理。
所以在"外层抢占 + 内层清理"的双重写法下,zrem 到底返回 1 还是 0 并不重要,重要的是**"处理完成后这个 member 一定不在队列里"这个最终状态被保证**;多删一次(返回 0)是幂等的、无害的。
可把这段逻辑收敛成一个更严谨的版本——让"内层清理"在每一条路径上都必然执行、且只执行一次:
python
def cancel_order(order_id: str):
# 先查状态,非 PENDING 直接跳到收尾清理
status = db.get_order_status(order_id)
if status == "PENDING":
# 条件更新:只有 PENDING 才真正取消,返回受影响行数
affected = db.cancel_order_if_pending(order_id)
if affected:
release_stock(order_id)
# 收尾:无条件清理队列(幂等;外层若已抢占过,此处多半返回 0,无害)
redis_client.zrem("delay:order:cancel", order_id)这里第 2 步是关键,cancel_order_if_pending 内部必须是一条带前置条件的 UPDATE(条件更新),而不是「先查、再改」两步。SQL 示意:
sql
UPDATE orders
SET status = 'CANCELLED', cancel_time = NOW()
WHERE id = #{orderId} AND status = 'PENDING';注意:这里的前置条件严格限定 status = 'PENDING',而不是 status != 'PAID'。这种写法天然排除了「支付中 PAYING」——只要订单在用户发起支付时已经变成 PAYING,这条取消 SQL 就匹配不上,从而"支付中"的订单绝不会被取消。
affected(受影响的行数)就是判断依据:返回0说明这条订单的status已经不是PENDING(被别人先支付了或已取消),本次跳过;返回1说明本次真正完成了取消;- 这一条 UPDATE 本身是数据库层面的原子操作,「判断
status='PENDING'」和「改成CANCELLED」在同一次写里完成,从根上消除了「先查后改」的竞态窗口; - 这就是
cancel_order_if_pending名字的由来:「只有当它仍是待支付时,才取消」。
ZREM 的参数与返回语义(抢占的基石):
text
ZREM delay:order:cancel <member>
↑ key ↑ 要移除的成员,可一次传多个
返回:本次实际被移除的成员数量(0 表示该成员本来就不存在)正是利用了「已存在的成员返回 1、不存在的成员返回 0」这个语义,ZREM 才能用来做原子抢占(见下一节)。
四、多实例下的竞争(关键)
如果后台定时任务部署了多个实例,多个实例会同时从 ZSet 里取到同一个超时订单,导致重复取消。解决办法是「取任务 + 抢占」要原子化。
最常用的方式是用 ZREM 抢占:谁 ZREM 成功,谁就获得了处理权,因为 ZREM 对不存在的成员返回 0。
「抢占」二字是怎么实现的? 本质是:一次原子的「取走」,把处理权唯一地授予第一个成功的实例。它靠 Redis 的两个特性共同完成:
单命令原子性(Redis 单线程执行):Redis 命令是单线程执行的,
ZREM会把这整个命令跑完,中间不穿插任何其他客户端的命令。也就是说,「检查这个成员在不在」和「把它删掉」发生在同一条命令、同一个执行块里,中间没有任何时间窗口给别的实例插进来。对比一下"非抢占"的写法就能看出差别:
python# ❌ 非原子:两步之间有空窗,两个实例会同时"看到存在",都去处理 if redis_client.zscore("delay:order:cancel", order_id) is not None: # 检查:都在 redis_client.zrem("delay:order:cancel", order_id) # 删除:都删 cancel_order(order_id) # 两个实例都会走到这里 → 重复取消上面"先查后删"是两步,两个实例可能都执行完
zscore(都判断"存在"),再各自zrem,于是都去cancel_order。而ZREM一步到位,把"检查 + 删除"合并成不可分割的操作。返回值区分「谁赢了」:
ZREM返回本次实际删除的成员数量——成员存在并被删除返回1,成员已不存在返回0。于是:text实例 A 执行 ZREM → 成员还在 → 删掉,返回 1 → A 是赢家,继续 cancel_order 实例 B 执行 ZREM → 成员已被 A 删了 → 返回 0 → B 什么也不做,跳过两个实例不管谁先谁后,只有返回值 1 的那个实例会真正处理,返回值 0 的自动放弃。
为什么必须原子? 如果"检查"和"删除"分成两步,那么"检查"和"据此决定谁处理"之间就存在时间差,检查结果会过期:两个实例都可能在检查阶段"看到成员存在",即便后面只有一个能删成功,那个"检查时看到存在"的实例仍会继续 cancel_order,造成重复处理。ZREM 把"判断存在与否"和"据此决定谁处理"压缩进一个原子操作,就彻底消灭了这个空隙。
一句话:「ZREM 抢占」= 利用 Redis 单线程下
ZREM的原子性,把「检查成员存在」与「删除成员」合并为一步,再用「返回 1 还是 0」区分谁删到了,从而把处理权唯一授予第一个成功的实例;返回 0 的实例自动放弃,就不会重复取消。
python
def scan_and_cancel_distributed():
now = int(time.time())
expired = redis_client.zrangebyscore("delay:order:cancel", "-inf", now)
for order_id in expired:
# 原子抢占:只有 ZREM 成功的实例才继续处理
grabbed = redis_client.zrem("delay:order:cancel", order_id)
if not grabbed:
# 别的实例已经抢到了,跳过
continue
cancel_order(order_id)这里把「检查 + 移除」合并成一次 ZREM,利用 Redis 单线程原子性保证同一订单只会被一个实例抢到。抢到后如果处理失败,需要把订单重新 ZADD 回去(重试),否则订单会「丢失」。
五、并发竞态的完整处理
上面第四节的 ZREM 抢占,解决的只是**「多个取消线程之间」的重复取消**。但订单取消场景其实有两类完全不同的并发,必须分开处理:
| 并发类型 | 谁和谁竞争 | 后果 | 正确解法 |
|---|---|---|---|
| 取消线程之间的竞争 | 多个定时任务实例 | 同一订单被重复取消 | Redis ZREM 原子抢占 |
| 取消线程 vs 支付回调 | 定时取消 vs 支付回调 | 钱扣了订单却没了 | 数据库条件更新 / 乐观锁 |
| 取消 vs 异步支付(回调未到) | 定时取消 vs 已扣款未回调 | 钱扣了订单却没了 | 耐心窗口 + 主动对账(见 ④) |
| 库存释放的竞争 | 取消 vs 取消(多实例) | 库存被重复释放 | 库存表带条件更新 + 幂等键 |
ZREM 只能解决第一行;数据库「条件更新」能挡住 ②「回调已到、状态已变」,但挡不住 ③「钱已付、回调还没到」的窗口——因为那一刻数据库里状态仍是 PENDING,条件更新会"合法"地取消成功。这个窗口必须用「耐心窗口 + 主动对账」兜底。库存释放也可能被多个进程各自触发。下面逐一补齐。
① 取消线程之间的竞争:ZREM 抢占
第四节已讲,核心是「谁 ZREM 成功谁处理」。
补充一个易被忽略的细节:ZRANGEBYSCORE(取到期列表)和 ZREM(抢占)之间不是原子的,两实例可能都取到同一个 order_id,但最终只有先执行 ZREM 的那个返回 1,另一个返回 0,所以结果仍然正确。真正要小心的不是这里,而是处理失败后的重试回填:
python
def scan_and_cancel_distributed():
now = int(time.time())
expired = redis_client.zrangebyscore("delay:order:cancel", "-inf", now)
for order_id in expired:
grabbed = redis_client.zrem("delay:order:cancel", order_id)
if not grabbed:
continue # 别的实例已抢到
try:
cancel_order(order_id)
except Exception:
# 处理失败:把订单重新放回队列,凭 score 到期时间继续被扫到
redis_client.zadd("delay:order:cancel", {order_id: now}) # 立即重新到期,尽快重试重试回填时 score 用 now(而非原来的到期时间),表示「已经到期、需要尽快重试」,否则还要等下一轮扫描间隔。
② 取消线程 vs 支付线程:引入 PAYING 中间态 + 数据库条件更新
这是最危险、也最容易写错的一类竞态(时序见 Q43)。正确写法是引入「支付中 PAYING」中间态,让「发起支付」「取消」「支付成功」三个动作都通过「带状态的 UPDATE」去抢占状态机,谁先改成功,谁就赢,而不是「先查后改」。
第一步:引入 PAYING 中间态,状态机从三态变四态
PENDING(待支付)──发起支付──► PAYING(支付中)──支付成功回调──► PAID(已支付)
│
└──超时未支付──► CANCELLED(已取消)三个动作各自是一条带前置条件的原子 UPDATE:
sql
-- ① 发起支付:只有「待支付」才能进入「支付中」,先把资格占住
UPDATE orders SET status = 'PAYING' WHERE id = ? AND status = 'PENDING';
-- ② 取消侧:只有「待支付」才能被取消(一旦进了 PAYING 就匹配不上,天然不可取消)
UPDATE orders SET status = 'CANCELLED' WHERE id = ? AND status = 'PENDING';
-- ③ 支付成功回调:只有「支付中」才能被推进为「已支付」
UPDATE orders SET status = 'PAID' WHERE id = ? AND status = 'PAYING';为什么必须引入 PAYING,而不能只靠"取消 WHERE status='PENDING'、支付 WHERE status='PENDING'"两态互斥?
因为"发起支付"和"支付成功"是两个线程、两个时刻的事:用户发起支付后,到支付结果回来之前,有一个"等待支付结果"的时间窗口。如果订单状态还停留在 PENDING 没变化,取消线程扫到它就会"合法"地取消;等真正支付成功回调到达时,WHERE status='PENDING' 已经匹配不上,无法把状态更新为 PAID——用户钱付了,订单却是取消态。
引入 PAYING 后,"发起支付"这个动作会立刻把订单从 PENDING 推成 PAYING,取消侧的 WHERE status='PENDING' 从此匹配不上,"支付中"的订单对取消线程天然不可见。这正是 PAYING 中间态的核心价值:把"支付动作已经开始"这个事实,第一时间写进状态机,避免被取消线程误杀。
为什么三条 UPDATE 能保证正确:每条都带「状态前置条件」,数据库会保证同一时刻只有一条能改成功(affected=1),其余 affected=0 直接放弃。于是:
- 若「发起支付」先成功(PENDING→PAYING),后续「取消」必然失败(affected=0),支付结果照常推进到 PAID;
- 若「取消」先成功(PENDING→CANCELLED),后续「发起支付」必然失败(affected=0),不会再进入支付。
无论怎么并发,订单最终只会是 CANCELLED 或 PAID 二者之一,绝不会出现「已支付又被取消」或「支付中却被取消」。
如果用「乐观锁 + 版本号」,写法等价但更通用:
sql
UPDATE orders
SET status = 'CANCELLED', version = version + 1
WHERE id = ? AND version = ?; -- 版本号对不上则 affected = 0,说明中途被改过二者的选择:
| 手段 | 适用 | 说明 |
|---|---|---|
| 状态条件更新 | 状态机清晰(订单状态流转) | 前置条件是业务状态,天然表达「只有待支付才能取消」 |
| 乐观锁(版本号) | 字段多、状态不单一、或跨系统 | 前置条件是版本号,通用但需要维护 version 列 |
订单场景通常直接用「状态条件更新」更直观,因为它把业务规则直接写进了 SQL 的 WHERE;而「引入 PAYING 中间态」是状态条件更新里针对"发起支付 vs 取消"竞态的必要补充。
提示:PAYING 中间态解决的是「取消 vs 发起支付」的窗口竞态;若支付已成功、但回调还在路上(本地仍是 PAYING/甚至更早),那属于 ④ 的「异步支付回调」范畴,需要再加耐心窗口 + 主动对账兜底。
③ 库存释放的并发幂等
取消成功后要释放「下单时锁定的库存」,这个释放动作也可能被多个进程重复执行。要做到只释放一次,常用两种做法:
做法 A:扣减动作改「回补 + 条件更新」
释放库存不是简单地 stock = stock + n,而是「只有当这笔占用还在时才回补」:
sql
-- 下单时:占用库存(若库存不足则失败)
UPDATE stock SET locked = locked + 1, available = available - 1
WHERE sku_id = ? AND available >= 1;
-- 取消时:释放库存(只有「已占用」才能释放,天然幂等)
UPDATE stock SET locked = locked - 1, available = available + 1
WHERE sku_id = ? AND locked >= 1;如果订单取消和库存释放的动作被重复触发,第二次执行时 locked 已经是释放后的值,无法再匹配「该笔占用」,从而天然幂等。但严格幂等还需要「按订单维度」记账,见做法 B。
做法 B:以「订单号 + 释放标记」做幂等键
库存流水单独记一张表,用「订单号」作为唯一约束去重:
text
释放库存流程:
1. 幂等判断:这笔订单是否已经释放过库存?
└─ 已释放 → 跳过
└─ 未释放 → 执行释放 + 写一条「释放流水(带唯一订单号)」更彻底的做法是把「取消订单 + 释放库存」放进同一个本地事务里,cancel_order_if_pending 和库存回补要么都成功、要么都回滚,避免「订单取消了但库存没释放」或反过来。库存释放的幂等,最终要落在「库存的变更以单据为准、每单只处理一次」这个原则上。
④ 取消 vs 异步支付回调的窗口竞态
这是比 ② 更难察觉、也更危险的一类竞态,也是「条件更新」最容易被误以为"已经解决"、实际上没解决的漏洞。
场景描述:用户的支付是异步的。用户在第三方(微信/支付宝/银行卡网关)已经扣款成功,但支付结果的回调还在路上、还没到我们的系统。此时订单在数据库里的状态字面上依然是 PENDING。
最坏时序:
text
时间轴 订单状态(DB)
──────────────────────────────────────────────────────────────────────
T1 用户在第三方完成支付,钱已扣,但回调尚未到达系统 PENDING
T2 取消线程扫到该订单,发现已超时 PENDING
T3 取消线程执行 UPDATE ... WHERE status='PENDING' PENDING → CANCELLED
T4 支付回调到达,通知系统"支付成功" CANCELLED(但钱已扣)
──────────────────────────────────────────────────────────────────────
结果:钱已经扣了,订单却是"已取消" → 资金损失 + 客户投诉 + 退款/人工对账成本为什么 ② 的条件更新在这里失效:条件更新 WHERE status='PENDING' 依赖"订单状态"来互斥。但在这个窗口里,支付动作还没有落到我们系统的状态字段上(没有产生任何数据库写入),所以数据库此刻真的是 PENDING,取消侧的 UPDATE 会"合法地"生效。用数据库状态做互斥,只能挡住"已经写进状态机"的竞争,挡不住"动作已发生、但还没写进来"的窗口。
正确处理:两招并用
第一招:给取消加一个「耐心窗口(grace period)」
不要把"到期时间点"当作"立即取消"的触发点,而是在到期后再宽限一小段(例如 1~5 分钟)才真正取消,给异步回调留出到达时间:
text
到期时间 实际取消时间
│ 宽限窗口 │
▼ ◄─────────────► ▼
超时点 超时点 + N 分钟实现上,延迟队列/扫描的 score 不是存"到期时间",而是存"到期时间 + 宽限":
python
def submit_delay_order(order_id, expire_at, grace_seconds=300):
# score 存「到期时间 + 宽限」,而非原始到期时间
cancel_after = expire_at + grace_seconds
redis_client.zadd("delay:order:cancel", {order_id: cancel_after})宽限时间内,即使回调晚到,也能赶在取消前把状态改成 PAID,取消线程自然取消失败(affected=0)。
第二招:主动对账(异步补偿)——这是最终兜底
耐心窗口只是"降低概率",回调依然可能因为网络抖动、第三方故障而迟到超过宽限。所以不能只靠"等",必须主动核查:
取消前先「查一次支付网关」:对于"可能已支付但状态还是 PENDING"的订单,取消前先调第三方主动查询接口(
queryPaymentStatus)确认到底付没付。若查到"已支付",则改为走"支付成功补偿"流程,而不是取消;延迟二次确认:对已经取消的订单,再延后一小段时间(如 30~60 秒)去主动查一次支付结果。若发现"其实已支付",则把订单回滚为已支付 + 补发权益,走退款或人工对账流程;
对账补偿任务:夜间/定时跑「我方订单状态 vs 第三方支付流水」的对账,把"我方已取消、第三方已扣款"的异常单捞出来人工处理。
python
def cancel_order_with_check(order_id):
# 1. 先向支付网关主动查询真实支付状态(而非只信本地 state)
pay_status = payment_gateway.query_status(order_id)
if pay_status == "PAID":
# 已支付但本地还是 PENDING:走支付成功补偿,而不是取消
compensate_paid(order_id)
return
# 2. 确认未支付,才用条件更新取消(仍保留 ② 的互斥防线)
affected = db.cancel_order_if_pending(order_id)
if affected:
release_stock(order_id)
redis_client.zrem("delay:order:cancel", order_id)核心结论:
- 数据库条件更新解决的是「状态已经变了」的竞态,解决不了「动作已发生、但状态还没变」的窗口竞态;
- 对付"异步支付、回调延迟"这个窗口,正确姿势是 「耐心窗口(给回调时间) + 主动对账(不信本地状态,主动查网关/做延迟确认/跑对账补偿)」;
- 订单资金场景永远不能只靠"本地状态推理事后结果",必须对"已支付但未回调"保留一个兜底的主动核实机制。
六、ZSet 方案的优缺点
| 优点 | 缺点 |
|---|---|
| 可控、可精确到秒 | 依赖一个常驻的后台定时任务,本身就是单点 |
| score 天然支持按到期时间排序 | 定时任务有扫描间隔,取消有时间上的细微延迟 |
| 处理逻辑清晰,易于幂等和重试 | 需要自己处理多实例竞争(ZREM 抢占) |
| 不依赖 key 过期事件订阅 | 相比 key 过期方案多了一个 ZSet 维护成本 |
| 订单信息可附带在 member 里 | 大量订单时 ZSet 本身也会变大,需关注规模 |
ZSet 方案适合「需要精确控制、订单量大、要做幂等重试」的场景,是生产中最常用的做法之一。
Q45:除了 ZSet,还有哪些实现订单超时取消的方式?如何选型?
答:
除了 ZSet,还有几种常见方案,它们在可靠性和复杂度上各有取舍。下面逐一说明。
方案对比总览
| 方案 | 核心机制 | 可靠性 | 复杂度 | 典型适用场景 |
|---|---|---|---|---|
| Redis key 过期事件 | 给订单设 TTL,到期触发 keyspace 通知 | 较低(事件可能丢失) | 低 | 对可靠性要求不高的通知型场景 |
| ZSet + 定时扫描 | score 存到期时间,定时取到期订单 | 高(可幂等重试) | 中 | 订单量大、需精确控制和重试 |
| 数据库定时任务轮询 | 定时扫描数据库里的待支付订单 | 高(数据在库里) | 低 | 订单量小、不想引入额外组件 |
| 消息队列延迟消息 | 下单发延迟消息,到期消费取消 | 高(消息可重试) | 中高 | 已经使用 RabbitMQ/Kafka 等 MQ 的架构 |
| Redisson 延迟队列 | 基于 Redis 的分布式延迟队列封装 | 中高 | 中 | 使用 Redisson 的 Java 项目 |
一、Redis key 过期事件(keyspace notifications)
给订单 key 设置 TTL,key 过期时 Redis 会发布过期事件,订阅者收到后执行取消:
text
下单:SET order:1001:ttl "PENDING" EX 900
订单 key 在 900 秒后过期 → __keyevent@0__:expired 事件
订阅者收到 order:1001 过期 → 检查是「待支付」则取消命令与配置示意:
text
SET key value EX seconds
↑ key ↑ value ↑ 过期秒数(EX=秒,PX=毫秒)开启过期通知需要配置(默认关闭是「致命缺陷 4」的根因):
text
redis.conf(或运行时 CONFIG SET):
notify-keyspace-events 的取值是一个字符组合,含义:
K = keyspace 事件(__keyspace@0__:key)
E = keyevent 事件(__keyevent@0__:expired)
x = 过期事件(须与 E 组合使用)
g / $ / l / s ... = 对应具体命令类型
要收到过期事件,至少需要 "Ex"。优点是实现简单。但它有几个致命缺陷:
- 事件可能丢失:Redis 的过期通知是「尽力而为」,不是可靠投递。Redis 主线程繁忙时,过期事件可能延迟甚至丢失;
- 不是精确到点:key 的过期删除本身依赖 Redis 的过期策略(惰性 + 定期),即使到了 TTL,key 也可能晚一会儿才被删除并触发事件;
- 只拿到 key:事件里只有 key 名字,订单详情还要再去数据库查;
- 默认关闭:
notify-keyspace-events默认未开启,需要配置Ex才能收到过期事件。
所以 key 过期事件方案只适合可靠性要求不高的提示型场景(如「订单即将过期提醒」),不适合作为「取消订单」这种关键动作的唯一依据。
二、ZSet + 定时扫描
见上一问(Q44)。可靠、可控,是生产最常用方案。核心是「score 存到期时间 + 定时扫描 + ZREM 抢占幂等」。
三、数据库定时任务轮询
不引入 Redis,直接在数据库里用定时任务扫描「待支付且超时」的订单:
sql
SELECT id FROM orders
WHERE status = 'PENDING' AND create_time < NOW() - INTERVAL 15 MINUTE优点是最简单、数据天然一致(订单就在库里)。缺点是订单量大时扫描压力大,且「多实例扫描」需要自己处理抢占。适合订单量小、不想增加复杂度的小系统。
四、消息队列延迟消息
下单时往 MQ 发一条「延迟 15 分钟」的消息,到期后消费者收到消息执行取消:
text
下单 → 发送延迟消息(delay 15min)
│
(15 分钟后)
▼
消费者收到消息 → 检查订单是否「待支付」→ 取消各 MQ 的延迟消息用法示意(都是「指定延迟/投递时间 + 消息体」):
text
RabbitMQ:借助延迟插件 x-delayed-message,
消息头带 x-delay = 900000(毫秒),到点才投递给队列。
RocketMQ:原生延迟消息,发送时指定 delayLevel(如 15 分钟档位),
producer.send(msg, 延时等级);消费端照常订阅。
Kafka:原生无延迟消息,通常用「延迟队列 + 定时重投」或
消费时判断「未到期则睡一会再消费(不推荐)」模拟。核心参数就是「延迟时长 + 订单号(消息体)」。到点消费者拿到订单号后,仍需走一遍「条件更新 + 库存幂等」的逻辑(不是拿到消息就直接置为取消,否则还是会有支付竞态)。
优点是可重试、可靠(取决于 MQ 本身),天然解耦。缺点是需要额外引入 MQ,且消费端同样要处理幂等和支付竞态。适合已经使用 RabbitMQ(死信队列/延迟插件)、RocketMQ(延迟消息)、Kafka 的架构。
五、Redisson 延迟队列(RDelayedQueue)
Redisson 封装了基于 Redis ZSet 的延迟队列,API 更友好,但仅限 Java 生态。下面是一套可直接落地的完整实现,分「配置、下单入队、消费取消」三部分。
① Redisson 连接配置
java
package com.example.order.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient() {
Config config = new Config();
// 单机模式;生产可用 useClusterServers() / useSentinelServers()
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setPassword("your-password") // 无密码可省略
.setConnectionPoolSize(64)
.setConnectionMinimumIdleSize(10);
return Redisson.create(config);
}
}② 下单入队:延迟 15 分钟后进队列
java
package com.example.order.service;
import org.redisson.api.RBlockingQueue;
import org.redisson.api.RDelayedQueue;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class DelayQueueProducer {
private final RBlockingQueue<String> blockingQueue;
private final RDelayedQueue<String> delayedQueue;
// 注入 RedissonClient,构造时把阻塞队列包装成延迟队列
public DelayQueueProducer(RedissonClient redissonClient) {
this.blockingQueue = redissonClient.getBlockingQueue("order:cancel");
this.delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
}
/**
* 下单成功、进入待支付状态后调用。
* @param orderId 订单号;15 分钟后自动进入 order:cancel 队列
*/
public void submit(String orderId) {
// offer(value, delay, timeUnit):延迟 15 分钟后元素才真正入队
delayedQueue.offer(orderId, 15, TimeUnit.MINUTES);
}
}③ 消费取消:阻塞取出到期订单,且严格处理幂等与支付竞态
java
package com.example.order.service;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBlockingQueue;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
@Slf4j
@Service
public class DelayQueueConsumer {
private final RBlockingQueue<String> blockingQueue;
private final OrderService orderService;
public DelayQueueConsumer(RedissonClient redissonClient,
OrderService orderService) {
this.blockingQueue = redissonClient.getBlockingQueue("order:cancel");
this.orderService = orderService;
}
/**
* 启动一个后台线程,持续阻塞消费到期订单。
* 生产环境建议放到独立线程池/独立进程,而不是依赖 @PostConstruct 裸线程。
*/
@PostConstruct
public void startConsume() {
Thread consumer = new Thread(this::loop, "order-cancel-consumer");
consumer.setDaemon(true);
consumer.start();
}
private void loop() {
// take() 阻塞等待:队列里没有到期元素时线程挂起,不空转
while (true) {
try {
String orderId = blockingQueue.take();
// take() 不会主动确认,取出即被移除;处理失败需自行补偿
handle(orderId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
log.error("取消订单处理异常", e);
}
}
}
private void handle(String orderId) {
// 关键:调用带「条件更新」的取消逻辑,而不是直接置为取消
// 见 Q44 第五节——只有 status='PENDING' 才能取消,消除支付竞态
orderService.cancelOrderIfPending(orderId);
}
}④ 关键 API 与参数说明
text
RDelayedQueue.offer(V value, long delay, TimeUnit timeUnit)
value —— 入队元素(这里是订单号订单号字符串)
delay —— 延迟时长
timeUnit —— delay 的时间单位(TimeUnit.MINUTES = 分钟)
内部实现:把 (member, 到期时间戳) 写入一个 ZSet(本质就是 ZADD)
RBlockingQueue.take()
阻塞式取出队首元素;队列空时线程挂起等待,不会空转 CPU
内部实现:轮询 ZSet 取出已到期元素后移入阻塞队列| 方法 | 作用 |
|---|---|
getBlockingQueue(name) | 获取/创建阻塞队列(需要被 delayedQueue 包装) |
getDelayedQueue(queue) | 把阻塞队列包装成带延迟能力的 RDelayedQueue |
offer(value, delay, unit) | 元素延迟 delay(unit 单位)后进入队列 |
take() | 阻塞式取出已到期元素 |
⑤ 三个必须注意的坑
- 没有类似 MQ 的「手动 ACK」:
take()一取出,元素就从队列里移除了。如果handle处理到一半进程崩溃,这条订单就「丢失」了——所以cancelOrderIfPending内部要有补偿机制(如处理失败把订单重新offer回去,或用数据库对账兜底); - 消费者不能只起一个:
take()是阻塞的,单线程吞吐有限。生产上要起多个消费者线程(或多个实例),但它们各自take(),天然不会重复消费同一条; - 最终正确性仍靠数据库:Redisson 延迟队列只负责「到点触发」,和 ZSet 方案一样,真正挡「取消 vs 支付」竞态的是
cancelOrderIfPending里的条件更新,队列本身不背这口锅。
本质还是 ZSet 方案,只是把「score 存到期时间、定时扫描」这套机制封装好了;同样要自己处理支付竞态和幂等。
六、选型建议
text
订单量小、追求简单、不想引组件 ──► 数据库定时轮询
订单量大、要精确可控、要幂等重试 ──► ZSet + 定时扫描(推荐)
已用 MQ、要解耦与可靠投递 ──► 消息队列延迟消息
只做提示、不强求可靠 ──► Redis key 过期事件无论选哪种,「取消订单」都必须守住三条底线:
- 幂等:取消可能被触发多次,只能生效一次;
- 竞态:取消与支付可能同时发生,要用「条件更新」保证正确性;
- 库存释放:释放库存也要幂等,不能重复释放。
这三条是订单超时取消场景的「正确性红线」,任何方案都不能绕过。
小结
- 订单超时取消的本质,是用 Redis(或其他中间件)承担「超时调度」,用数据库承担「权威数据」,两者分工。
- ZSet 方案是最经典做法:score 存到期时间戳,定时扫描
ZRANGEBYSCORE取到期订单,用ZREM抢占保证多实例下不重复处理。 - 其他方案各有适用场景:key 过期事件最简单但不可靠(
notify-keyspace-events须开Ex),MQ 延迟消息最解耦但要引组件,数据库轮询最轻量但压力大。 - 并发要分三类处理,缺一不可:
- 取消线程之间 → Redis
ZREM原子抢占; - 取消 vs 支付 → 数据库「带
status='PENDING'前置条件的 UPDATE」或乐观锁,谁先改成功谁赢; - 库存释放 → 「占用式回补 + 条件更新」或「订单号幂等键」,保证只释放一次。
- 取消线程之间 → Redis
- 所有方案都要守住幂等、支付竞态、库存释放幂等三条正确性红线。