92 lines
5.1 KiB
Markdown
92 lines
5.1 KiB
Markdown
---
|
||
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 就是冲突;同一个事务里写审计日志,保证每次变化都有记录。写了并发测试,同时发起支付和取消,验证一定是一个成功一个冲突。”
|
||
|
||
可能的追问:为什么不用悲观锁?支付成功但订单已取消怎么处理(自动退款流程,人工兜底)?审计表越来越大怎么办(分区、归档)?
|