Skip to content

第十四章:订单超时自动取消 ​

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),从状态机层面杜绝"支付中被取消"。

关键设计点 ​

无论用哪种方案,订单超时取消都要解决三个共性问题:

  1. 幂等性:取消操作可能被多个进程触发,必须保证「只取消一次」;
  2. 与支付竞态:订单可能在取消的瞬间刚好支付成功,要避免「取消了一个已支付的订单」或者「支付了一个已取消的订单」;
  3. 库存释放:取消后要释放锁定的库存,释放也必须幂等。

这三个问题在下面具体方案里会反复出现。

「取消 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」中间态**:

  1. 发起支付时:UPDATE ... SET status='PAYING' WHERE status='PENDING',先把状态从"待支付"推进到"支付中",这一步就"占住"了提单资格;
  2. 取消侧只认 PENDING:UPDATE ... SET status='CANCELLED' WHERE status='PENDING',一旦订单进入 PAYING,取消的 WHERE 就匹配不上,"支付中"的订单对取消线程天然不可见;
  3. 支付成功回调: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?因为它要覆盖的是另外两种"没有经过外层抢占"的调用路径:

  1. 单实例写法:第一节/第三节的 scan_and_cancel 是"先 zrangebyscore 取出、直接逐条 cancel_order",中间没有用 zrem 抢占。这时如果 cancel_order 内部不做 zrem,订单就会一直留在队列里,被下一轮反复扫到。
  2. 直接调用 / 手动补偿:某些路径(如人工触发、定时补偿)可能绕过外层抢占,直接调 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 的两个特性共同完成:

  1. 单命令原子性(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 一步到位,把"检查 + 删除"合并成不可分割的操作。

  2. 返回值区分「谁赢了」: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)。

第二招:主动对账(异步补偿)——这是最终兜底

耐心窗口只是"降低概率",回调依然可能因为网络抖动、第三方故障而迟到超过宽限。所以不能只靠"等",必须主动核查:

  1. 取消前先「查一次支付网关」:对于"可能已支付但状态还是 PENDING"的订单,取消前先调第三方主动查询接口(queryPaymentStatus)确认到底付没付。若查到"已支付",则改为走"支付成功补偿"流程,而不是取消;

  2. 延迟二次确认:对已经取消的订单,再延后一小段时间(如 30~60 秒)去主动查一次支付结果。若发现"其实已支付",则把订单回滚为已支付 + 补发权益,走退款或人工对账流程;

  3. 对账补偿任务:夜间/定时跑「我方订单状态 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"。

优点是实现简单。但它有几个致命缺陷:

  1. 事件可能丢失:Redis 的过期通知是「尽力而为」,不是可靠投递。Redis 主线程繁忙时,过期事件可能延迟甚至丢失;
  2. 不是精确到点:key 的过期删除本身依赖 Redis 的过期策略(惰性 + 定期),即使到了 TTL,key 也可能晚一会儿才被删除并触发事件;
  3. 只拿到 key:事件里只有 key 名字,订单详情还要再去数据库查;
  4. 默认关闭: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()阻塞式取出已到期元素
⑤ 三个必须注意的坑 ​
  1. 没有类似 MQ 的「手动 ACK」:take() 一取出,元素就从队列里移除了。如果 handle 处理到一半进程崩溃,这条订单就「丢失」了——所以 cancelOrderIfPending 内部要有补偿机制(如处理失败把订单重新 offer 回去,或用数据库对账兜底);
  2. 消费者不能只起一个:take() 是阻塞的,单线程吞吐有限。生产上要起多个消费者线程(或多个实例),但它们各自 take(),天然不会重复消费同一条;
  3. 最终正确性仍靠数据库:Redisson 延迟队列只负责「到点触发」,和 ZSet 方案一样,真正挡「取消 vs 支付」竞态的是 cancelOrderIfPending 里的条件更新,队列本身不背这口锅。

本质还是 ZSet 方案,只是把「score 存到期时间、定时扫描」这套机制封装好了;同样要自己处理支付竞态和幂等。

六、选型建议 ​

text
订单量小、追求简单、不想引组件 ──► 数据库定时轮询
订单量大、要精确可控、要幂等重试 ──► ZSet + 定时扫描(推荐)
已用 MQ、要解耦与可靠投递 ──► 消息队列延迟消息
只做提示、不强求可靠 ──► Redis key 过期事件

无论选哪种,「取消订单」都必须守住三条底线:

  1. 幂等:取消可能被触发多次,只能生效一次;
  2. 竞态:取消与支付可能同时发生,要用「条件更新」保证正确性;
  3. 库存释放:释放库存也要幂等,不能重复释放。

这三条是订单超时取消场景的「正确性红线」,任何方案都不能绕过。


小结 ​

  • 订单超时取消的本质,是用 Redis(或其他中间件)承担「超时调度」,用数据库承担「权威数据」,两者分工。
  • ZSet 方案是最经典做法:score 存到期时间戳,定时扫描 ZRANGEBYSCORE 取到期订单,用 ZREM 抢占保证多实例下不重复处理。
  • 其他方案各有适用场景:key 过期事件最简单但不可靠(notify-keyspace-events 须开 Ex),MQ 延迟消息最解耦但要引组件,数据库轮询最轻量但压力大。
  • 并发要分三类处理,缺一不可:
    1. 取消线程之间 → Redis ZREM 原子抢占;
    2. 取消 vs 支付 → 数据库「带 status='PENDING' 前置条件的 UPDATE」或乐观锁,谁先改成功谁赢;
    3. 库存释放 → 「占用式回补 + 条件更新」或「订单号幂等键」,保证只释放一次。
  • 所有方案都要守住幂等、支付竞态、库存释放幂等三条正确性红线。