并发与一致性

Article / 并发与一致性

当 Redis 和 MySQL 各执一词

五类不一致的发现与修复、批量与锁的边界,以及一处被接受的风险。

SeatFlow 对账最终一致性Redisson定时任务

座位锁、超时释放、支付回调,每条链路的异常处理都留了同一句注释:失败交给对账。进程会在两条语句之间崩溃,Redis 会被清空,消息会丢。只要系统由两个存储组成,漂移就不是「会不会」的问题,而是「多久收敛」的问题。对账任务的职责,就是把「不确定的窗口」变成「有界的收敛时间」。

先划边界:对账只修视图

SeatFlow 里 MySQL 是订单事实,Redis 是座位视图。对账任务的权限被严格限制在修视图上:

  • 它可以把座位状态从任何值修复为与数据库订单一致的值;
  • 它可以取消「已过期仍待支付」的订单——这是唯一修改订单事实的动作,而且用的就是超时链路同一条代码;
  • 永远不能创建新订单、不能把取消的订单复活、不能把订单改成已支付

这条边界让对账成为一个收敛器而不是决策者:它的最坏结果是「某条状态晚了几分钟才一致」,永远不会是「凭空多出一个订单」。五类不一致因此可以安全地用同一套批量扫描逻辑处理。

五类不一致

#1 超时未取消。 延迟消息可能发送失败或消费重试耗尽,订单过期后一直停在待支付,座位被锁着。修复动作是复用 cancelAndRelease——与超时处理器完全相同的取消语义,包括 CAS、删除占用行与释放 Redis。数据库负责筛出 expire_time < now() - 1min 的候选(留一分钟缓冲,避免与刚过期的正常订单抢跑)。

#2 已支付座位非 2。 数据库里订单已支付,但 Redis 里对应座位不是已售——可能是 Redis 被清理过,可能是字段被误改。修复是把这些座位直接写为 2。这里有一段修正故事:最初的实现复用了 mark_sold 脚本,而那个脚本只认 1 → 2(见《座位是怎么锁住的》),于是状态为 0 或字段缺失的座位修不动;代码审查把问题定性为「对账 #2 应覆盖所有非已售状态」,修复为无条件的 HSET 2,并补了三个用例:状态置 0 修复、字段删除修复、连续两轮运行第二轮零计数。对账操作必须能处理「任何偏离」,而不是「几种常见偏离」。

#3 座位 Hash 丢失。 Redis 重启或被整体清理后,某个场次的座位键不存在。重建是两步:先从影厅模板生成全部可售(0),再把数据库里的有效占用叠加回去——待支付订单的座位置 1,已支付订单的座位置 2。重建出的状态与数据库订单严格一致,恢复出厂设置后再「重放历史」。

#4 锁定孤儿。 Redis 显示座位锁定,但数据库里没有任何有效订单占用它。典型来源是下单链路在「Lua 锁座成功、数据库提交之前」崩溃。修复是释放为 0,并且每场次用一把 Redisson 分布式锁串行化修复动作:

RLock lock = redissonClient.getLock("lock:reconcile:" + showId);
boolean locked = lock.tryLock(0, 30, TimeUnit.SECONDS);
if (!locked) {
    log.warn("reconcile lock busy, skipped show: showId={}", showId);
    return 0;                       // 拿不到就跳过本场,不等待
}

tryLock(0, 30s) 的语义是「立刻尝试,失败不等待」:对账是后台任务,没有实时性要求,抢不到锁就下一轮再来;30 秒租约防止进程崩溃后锁不释放;解锁前检查 isHeldByCurrentThread,避免误放别人的锁。

#5 已售孤儿。 座位是已售(2),但数据库里没有对应的已支付订单——最典型的来源是支付回调把座位置为已售之后、数据库提交之前进程崩溃(事务回滚,Redis 的写入无法回滚)。修复是释放为 0。这一条是代码审查后补的:没有它时,这种座位既不会被 #4 处理(状态不是 1),也不会被 #2 处理(订单不是已支付),会成为永久死座。补上之后,五类不一致覆盖了所有「单边写入残留」的组合。

flowchart TB
    A[runOnce 每轮执行] --> B["#1 超时未取消 → 复用取消链路"]
    A --> C["#2 已支付座位非 2 → 无条件置 2"]
    A --> D["#3 座位 Hash 丢失 → 模板重建 + 订单叠加"]
    A --> E["#4 锁定孤儿 → 释放, Redisson 按场串行"]
    A --> F["#5 已售孤儿 → 释放"]

批量、调度与观测

每一轮对账的每个检查项都限制在 100 个修复单位以内。这个数字有三重考虑:避免单轮产生长事务;避免一次对大 Hash 做大规模写入造成 Redis 阻塞;让单轮的运行时间可预期。调度用固定延迟的五分钟间隔,管理端另有手动触发端点,方便测试与运维。

修复过程是幂等的——重复运行不会造成二次伤害,因为每一类检查都是「对比当前状态与数据库事实,修掉差异」。修复结果按检查项打点到 seatflow.reconcile.fixed{check=...},五类各自可观测:

recordFixed("expiredCancelled", expiredCancelled);
recordFixed("paidSeatsFixed", paidSeatsFixed);
recordFixed("hashesRebuilt", hashesRebuilt);
recordFixed("orphanLocksReleased", orphanLocksReleased);
recordFixed("sold-orphan", soldOrphansReleased);

指标是零不代表万事大吉——但非零一定意味着某条链路出现过失败。对账指标更像是错误上的「迟到报警」,每一条修复记录背后都对应着一个曾经发生的异常。

一处被接受的风险

#4 的修复依据是「数据库此刻没有占用」,而在途的下单链路是「Redis 先锁、数据库后提交」。两条语句之间有一个窗口:座位刚被 Lua 锁定,订单还没提交,此刻对账扫到它——数据库里确实没有占用,于是把它释放为可售。释放之后,原订单提交成功并完成支付,座位值却已经被对账改回了 0

要不要给下单也加同一把锁?不加的理由是直接的:下单路径是这个系统吞吐的核心,给每次下单加一把按场次的分布式锁,等于把所有并发抢座串行化——与整个项目的设计目标冲突。于是这个问题被定性为被接受的残余风险,定价如下:

  • 释放不会造成重复售出:数据库的 uk_order_seat_show_seat 唯一索引是最终防线,两个订单只有一个能提交成功;
  • 永久错位会被 #2 修复:座位即使被释放又被支付成功,下一轮对账把它修回 2
  • 残余影响是「刚下单成功的座位短暂显示可售」,用户可能遇到一次重选,但不会出现两人同座。

同样的窗口存在于 #5 与支付回调之间。README 的已知限制里逐条写明,包括「对账读取与支付回调/下单提交之间仍有极小竞态窗口,由数据库唯一索引与 #2 修复兜底」。

测试:构造坏数据,而不是等待事故

对账的正确性没法靠正常流程测试——正常流程不会制造不一致。ReconcileIT 的做法是手工构造每一类坏数据

  • 改数据库把订单的 expire_time 拨到过去 → 断言被取消、座位释放;
  • 支付成功的订单把 Redis 座位改成 0、或删除字段 → 断言修复为 2,计数为 1;
  • 删除整个座位 Hash → 断言重建后自由座位为 0、已售座位为 2
  • 在 Redis 写入一个没有数据库占用的锁定座位 → 断言被释放;
  • 构造状态 2 但无已支付订单的座位 → 断言被释放。

还有一个容易忽略的测试细节:测试共享数据库,底噪会污染计数。 其他用例留下的残留会在第一轮 runOnce 里被顺手修掉,让「本轮修复数应为 1」的断言失败。所以每个用例开始时先跑一轮对账清场,再构造目标不一致,最后断言增量。这也是「二轮运行零计数」用例存在的意义:它把批处理任务最重要的性质——收敛后不动——固化成了测试。

局限与后续

  • 扫描无轮转游标:受影响的场次按 id 升序取前 100 个,售票中的场次超过 100 时,高 id 场次可能被持续跳过。当前演示规模可以接受,规模化后需要游标或轮转。
  • #3 重建两步非原子:初始化与叠加之间崩溃会留下一个「全部可售」的部分状态,下一轮对账会因为键存在而跳过重建。可以通过临时键 + rename 或单个 Lua 脚本做原子重建。
  • 调度装配无自动化覆盖:定时触发的路径靠配置保证(测试中关闭,避免与用例竞争),手动触发端点有测试覆盖。

对账不是「兜底逻辑」的堆砌,而是一个有独立边界、独立测试、独立指标的子系统。它接受两个存储在任意时刻可能不一致,然后把不一致的代价限制在一个有界的时间窗与一组可枚举的形态里。