Files
zhuangang/topics/backend/postgresql/scenarios/order-state-audit.md
T

5.1 KiB
Raw Blame History

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
postgresql
go
状态机
乐观锁
审计
并发
事务
intermediate
backend.postgresql.index-tx-mvcc
2
backend.postgresql.interview
backend.mq.reliability
2
3

订单状态机:状态流转、乐观锁和审计日志

业务背景与问题

一个 B2B 订单系统,订单状态有待支付、已支付、已发货、已完成、已取消、退款中、已退款。线上出现过几类问题:

  1. 用户点“取消”的同时支付回调到达,最后订单状态是“已取消”但钱已经扣了。
  2. 运营在后台把“已完成”的订单误改回“已发货”,没人知道是谁改的。
  3. 代码里到处是 UPDATE orders SET status = ?,什么状态能改成什么状态全靠各处的 if 判断,规则不一致。

约束

  • 状态修改的并发主要来自:用户操作、支付 / 物流回调、运营后台、定时任务(超时取消)。
  • 财务和客服要求:每一次状态变化都能查到“谁、什么时候、从什么状态改成什么状态、原因”。
  • 不能引入太重的组件,先在数据库层解决。

方案设计

stateDiagram-v2
  [*] --> pending
  pending --> paid: 支付回调
  pending --> cancelled: 用户取消 / 超时
  paid --> shipped: 发货
  paid --> refunding: 申请退款
  refunding --> refunded: 退款成功
  refunding --> paid: 退款驳回
  shipped --> completed: 确认收货
  1. 状态流转表集中定义:一张“从哪个状态可以到哪个状态”的表,所有修改都走同一个 Transition 函数,非法流转直接拒绝。
  2. 乐观锁:orders 表加 version 列;修改时 WHERE id = ? AND status = ? AND version = ?,影响行数为 0 说明被别人抢先修改,返回“请刷新重试”或由调用方决定(例如支付回调遇到已取消的订单,进入“自动退款”流程)。
  3. 审计日志:order_events 表记录每次流转,和状态修改在同一个事务里提交,保证“状态改了就一定有记录”。
  4. 对外通知:状态变化后需要通知其他系统(发短信、更新搜索)时,把消息写进同一事务里的发件箱表,再由后台程序发送(见消息可靠性)。

关键代码

完整代码和并发测试见 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 就是冲突;同一个事务里写审计日志,保证每次变化都有记录。写了并发测试,同时发起支付和取消,验证一定是一个成功一个冲突。”

可能的追问:为什么不用悲观锁?支付成功但订单已取消怎么处理(自动退款流程,人工兜底)?审计表越来越大怎么办(分区、归档)?