阶段2(上):后端专题 Go/Gin/PostgreSQL/MySQL/Redis/MQ/gRPC 的课程、企业场景、面试题和可运行示例

This commit is contained in:
Shikui Wang committed 2026-10-23 15:24:57 +08:00
1 parent 0bae9916a9
commit 5eb67c5d08
65 files changed
+6457 -77

No files matched your search

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