订单从创建到完成要经过一串状态,多个服务、多种事件都在改它。怎么保证只发生合法的流转?支付回调和取消同时到怎么办?重复的回调怎么处理?流转日志能不能还原状态?状态机该放在应用里还是数据库里?这篇用真实的 MySQL 逐个验证。
合法的流转 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(有触发器);流转检查在应用里做则每次多一次往返
别的写入方绕过应用时,只有触发器之类的数据库约束拦得住,代价很小。