5.1 KiB
5.1 KiB
id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week
| id | title | type | domain | stack | tags | level | prerequisites | est_hours | related | week | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| backend.postgresql.scenario.order-state-audit | 订单状态机:状态流转、乐观锁和审计日志 | scenario | backend |
|
|
intermediate |
|
2 |
|
|
订单状态机:状态流转、乐观锁和审计日志
业务背景与问题
一个 B2B 订单系统,订单状态有待支付、已支付、已发货、已完成、已取消、退款中、已退款。线上出现过几类问题:
- 用户点“取消”的同时支付回调到达,最后订单状态是“已取消”但钱已经扣了。
- 运营在后台把“已完成”的订单误改回“已发货”,没人知道是谁改的。
- 代码里到处是
UPDATE orders SET status = ?,什么状态能改成什么状态全靠各处的 if 判断,规则不一致。
约束
- 状态修改的并发主要来自:用户操作、支付 / 物流回调、运营后台、定时任务(超时取消)。
- 财务和客服要求:每一次状态变化都能查到“谁、什么时候、从什么状态改成什么状态、原因”。
- 不能引入太重的组件,先在数据库层解决。
方案设计
stateDiagram-v2
[*] --> pending
pending --> paid: 支付回调
pending --> cancelled: 用户取消 / 超时
paid --> shipped: 发货
paid --> refunding: 申请退款
refunding --> refunded: 退款成功
refunding --> paid: 退款驳回
shipped --> completed: 确认收货
- 状态流转表集中定义:一张“从哪个状态可以到哪个状态”的表,所有修改都走同一个
Transition函数,非法流转直接拒绝。 - 乐观锁:
orders表加version列;修改时WHERE id = ? AND status = ? AND version = ?,影响行数为 0 说明被别人抢先修改,返回“请刷新重试”或由调用方决定(例如支付回调遇到已取消的订单,进入“自动退款”流程)。 - 审计日志:
order_events表记录每次流转,和状态修改在同一个事务里提交,保证“状态改了就一定有记录”。 - 对外通知:状态变化后需要通知其他系统(发短信、更新搜索)时,把消息写进同一事务里的发件箱表,再由后台程序发送(见消息可靠性)。
关键代码
完整代码和并发测试见 examples/order-state:
tag, err := tx.Exec(ctx,
`UPDATE orders SET status = $1, version = version + 1, updated_at = now()
WHERE id = $2 AND status = $3 AND version = $4`, to, cur.ID, cur.Status, cur.Version)
if tag.RowsAffected() == 0 {
return cur, ErrConflict // 被别人抢先修改了
}
_, err = tx.Exec(ctx, `INSERT INTO order_events (order_id, from_status, to_status, actor, reason)
VALUES ($1, $2, $3, $4, $5)`, cur.ID, cur.Status, to, actor, reason)
// 两条语句同一个事务提交
并发测试:同一个订单同时执行“支付”和“取消”,结果一定是一个成功、一个冲突,审计日志只有一条。
取舍
| 方案 | 适用 | 代价 |
|---|---|---|
| 乐观锁(本方案) | 冲突不频繁的业务修改 | 冲突时需要重试或提示用户 |
悲观锁 SELECT ... FOR UPDATE |
冲突频繁、必须排队执行 | 持锁期间其他事务等待,事务内不能做慢操作 |
只靠 WHERE status = 旧状态 |
简单场景 | 无法区分“同状态下的两次修改”,版本号更通用 |
| 状态机框架 / 工作流引擎 | 流程复杂、经常变化 | 学习和运维成本 |
审计日志放数据库表而不是只写应用日志:可以按订单查询、可以和状态修改放进同一事务;数据量大时按月分区或归档。
易错点
- 先
SELECT状态判断、再UPDATE不带条件:两步之间别人可能已经改了。条件必须写进UPDATE的WHERE。 - 审计日志在事务外写:状态改成功了但日志写失败(或反过来),数据对不上。
- 事务里调用支付网关等外部接口:长事务持锁;外部调用放在事务外,用状态(如“退款中”)表示进行中。
- 冲突后盲目自动重试:要先重新读最新状态,再判断这次操作是否还有意义。
- 定时任务批量“超时取消”时,也必须走
Transition,不能直接批量UPDATE。
面试怎么讲
“订单状态曾经出现过并发问题,比如用户取消和支付回调同时到达。我把状态流转规则集中成一张表,所有修改都走一个函数:先校验流转是否合法,再用带版本号的条件 UPDATE 做乐观锁,影响行数为 0 就是冲突;同一个事务里写审计日志,保证每次变化都有记录。写了并发测试,同时发起支付和取消,验证一定是一个成功一个冲突。”
可能的追问:为什么不用悲观锁?支付成功但订单已取消怎么处理(自动退款流程,人工兜底)?审计表越来越大怎么办(分区、归档)?