bigywater.tech
分布式事务 · 20

订单状态机:状态流转与并发更新

订单从创建到完成要经过一串状态,多个服务、多种事件都在改它。怎么保证只发生合法的流转?支付回调和取消同时到怎么办?重复的回调怎么处理?流转日志能不能还原状态?状态机该放在应用里还是数据库里?这篇用真实的 MySQL 逐个验证。

25 / 30
朴素 UPDATE SET status = ? 接受的 30 条流转里,非法的有多少(带源状态条件的 0 条)
1000 → 0
并发的支付与取消 1000 单里,既收款又释放库存的矛盾单数:先读后写 → 条件 UPDATE
1500 → 500
每单 3 次重复的支付回调,收款副作用总数:盲写 → 条件 UPDATE 加幂等返回

流转表

实测 · 本机执行输出
  合法的流转 5 条;朴素写法接受了 30 条不同状态之间的流转(其中 25 条是非法的,例如 [('CANCELLED', 'CREATED'), ('CANCELLED', 'DELIVERED'), ('CANCELLED', 'PAID'), ('CANCELLED', 'REFUNDED')]);带源状态条件的写法接受了 5 条,其中非法的 0 条,漏掉的合法流转 0 条

把允许的来源状态放进 UPDATE 的 WHERE,非法流转直接影响 0 行。

并发流转

实测 · 本机执行输出
  写法                                             | 订单数  | 只收款 | 只释放库存 | 既收款又释放库存(矛盾) | 最终状态分布                         
  -----------------------------------------------+------+-----+-------+--------------+--------------------------------
  先读状态,是 CREATED 就改并触发副作用                        | 1000 | 0   | 0     | 1000         | {'PAID': 494, 'CANCELLED': 506}
  条件 UPDATE(WHERE status = CREATED),影响 1 行才触发副作用 | 1000 | 469 | 531   | 0            | {'CANCELLED': 531, 'PAID': 469}
  读后写:两个线程都读到 CREATED,各自改并各自触发副作用,订单最终只有一个状态,但“已收款”和“库存已释放”同时发生了;条件 UPDATE 让两个动作中只有一个能抢到流转,副作用只由抢到的那个触发

先读后写会让支付与取消同时成功、副作用矛盾;条件 UPDATE 只让抢到流转的一方触发副作用。

重复回调

实测 · 本机执行输出
  写法                        | 收到的回调 | 回复成功 | 回复报错 | 收款副作用总数(应为 500) | 被收了多次款的订单
  --------------------------+-------+------+------+-----------------+----------
  盲写 PAID + 每次都触发副作用        | 1500  | 1500 | 0    | 1500            | 500      
  条件 UPDATE + 已是 PAID 就幂等返回 | 1500  | 1500 | 0    | 500             | 0        

已是目标状态就直接成功返回,不再触发副作用:对调用方每次成功,对世界只改一次。

流转日志

实测 · 本机执行输出
  写法                       | 接受的流转 | 被拒绝的  | 日志重放 = 当前状态的订单 | seq 断档的订单 | 日志里的非法流转
  -------------------------+-------+-------+----------------+-----------+---------
  只有 version 乐观锁,不检查流转是否合法 | 20000 | 0     | 500 / 500      | 0         | 17098   
  version 乐观锁 + 检查流转合法     | 865   | 19135 | 500 / 500      | 0         | 0       
  version 保证每次流转看到的是最新状态、日志没有断档;“这个流转合法吗”是另一件事,要靠状态机本身来检查

同一个事务里追加事件,seq 用 version + 1;重放得到当前状态,但合法性需要另外检查。

数据库里的约束

实测 · 本机执行输出
  没有触发器:另一个写入方执行 UPDATE orders SET status = CREATED —— 受影响行数 1 ,订单现在是 CREATED (已送达的订单“回到”了待支付)
  有触发器:同一条 UPDATE 被数据库拒绝:OperationalError 1644 illegal order status transition
  同一状态再写一次(DELIVERED → DELIVERED)仍然允许,受影响行数 0
  合法的流转 CREATED → PAID 通过,订单现在是 PAID
  触发器的代价:对不改 status 的 UPDATE,中位数 84 µs(没有触发器)→ 85 µs(有触发器);流转检查在应用里做则每次多一次往返

别的写入方绕过应用时,只有触发器之类的数据库约束拦得住,代价很小。

分步演示

订单状态机
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇用一个 MySQL 26.7.0 实例(orders、order_events、effects 三张表),PyMySQL;并发抢占用线程 + 屏障;第二节“先读后写”里在读与写之间加了 0.5 ms 的延迟使竞争窗口足够宽;第四节的 2 万次随机尝试用固定种子
    • 没有验证:多个状态字段 / 子状态的组合爆炸;事件溯源(完全以事件为准)的实现;触发器在高并发、批量更新下的开销(只测了不改 status 的单行更新);状态机放在 ORM 或工作流引擎里的做法;分布式场景下流转与消息发布的原子性(见第 11 篇)
    京ICP备2026067502号-1京公网安备11010502063487号