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

92 lines
5.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: backend.postgresql.scenario.order-state-audit
title: 订单状态机:状态流转、乐观锁和审计日志
type: scenario
domain: backend
stack: [postgresql, go]
tags: [状态机, 乐观锁, 审计, 并发, 事务]
level: intermediate
prerequisites: [backend.postgresql.index-tx-mvcc]
est_hours: 2
related: [backend.postgresql.interview, backend.mq.reliability]
week: [2, 3]
---
# 订单状态机:状态流转、乐观锁和审计日志
## 业务背景与问题
一个 B2B 订单系统,订单状态有待支付、已支付、已发货、已完成、已取消、退款中、已退款。线上出现过几类问题:
1. 用户点“取消”的同时支付回调到达,最后订单状态是“已取消”但钱已经扣了。
2. 运营在后台把“已完成”的订单误改回“已发货”,没人知道是谁改的。
3. 代码里到处是 `UPDATE orders SET status = ?`,什么状态能改成什么状态全靠各处的 if 判断,规则不一致。
## 约束
- 状态修改的并发主要来自:用户操作、支付 / 物流回调、运营后台、定时任务(超时取消)。
- 财务和客服要求:每一次状态变化都能查到“谁、什么时候、从什么状态改成什么状态、原因”。
- 不能引入太重的组件,先在数据库层解决。
## 方案设计
```mermaid
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. **对外通知**:状态变化后需要通知其他系统(发短信、更新搜索)时,把消息写进同一事务里的发件箱表,再由后台程序发送(见[消息可靠性](../../mq/03-reliability.md))。
## 关键代码
完整代码和并发测试见 [examples/order-state](../examples/order-state/):
```go
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 就是冲突;同一个事务里写审计日志,保证每次变化都有记录。写了并发测试,同时发起支付和取消,验证一定是一个成功一个冲突。”
可能的追问:为什么不用悲观锁?支付成功但订单已取消怎么处理(自动退款流程,人工兜底)?审计表越来越大怎么办(分区、归档)?