背景与问题
做订单系统的人,迟早都会遇到一个经典问题:
用户明明只下了一单,库存扣了两次;或者订单创建成功了,但支付结果迟迟没落库;再或者支付成功了,发货系统没收到消息。
单体时代,这类问题往往还能靠本地事务兜住。但一旦系统拆成了订单、库存、支付、营销、履约几个服务,一条“下单链路”通常会跨数据库、跨服务、跨消息队列。此时你会发现:
- 本地事务只能保证单库一致性
- 网络抖动、消费重试、接口超时是常态
- “成功响应丢了”比“请求失败”更难处理
- 精确一次(Exactly Once)在工程上代价极高,很多时候不现实
所以在分布式订单系统里,真正可落地的目标通常不是“绝对不出错”,而是“最终一致、可恢复、可观测”。
我自己踩过一个很典型的坑:支付服务回调接口超时,第三方支付平台重复通知了 8 次,结果订单状态更新逻辑没做幂等,导致积分发放、优惠券核销、发货预占都执行了多遍。最后只能靠人工对账回滚,代价非常大。后来再设计这类链路时,我基本都会先问两个问题:
- 这个动作重复执行会怎样?
- 如果消息丢了,谁来补?怎么补?
这篇文章就围绕这两个问题展开:用幂等设计解决“重复”,用消息补偿机制解决“缺失”,构建一个在真实生产环境可运行的订单一致性方案。
先明确目标:我们到底要保证什么
在订单系统中,“一致性”不是一句空话,通常要拆成几个具体目标:
- 订单状态正确推进:待支付 → 已支付 → 已发货 → 已完成
- 库存扣减不重复、不漏扣
- 支付结果不丢失
- 核心业务事件可靠传播:如下单成功、支付成功、取消订单
- 出现异常后可补偿、可重放、可审计
这里有一个很重要的认知:
不是所有场景都要强一致。
例如:
- 订单落库 + 本地生成业务事件:适合强一点,至少同库事务要一致
- 订单成功后通知积分系统加分:允许稍有延迟,做最终一致
- 营销券核销失败:可以补偿,必要时人工兜底
所以,工程上更推荐把一致性分层:
| 层次 | 目标 | 常用方案 |
|---|---|---|
| 单服务内 | 原子提交 | 本地事务 |
| 服务间主链路 | 最终一致 | 事务消息 / Outbox / 可靠事件表 |
| 异常恢复 | 可重试、可补偿 | 补偿任务、死信队列、人工对账 |
| 重复请求/重复消息 | 不重复生效 | 幂等设计 |
核心原理
1. 幂等设计:先把“重复执行”变成无害
幂等的定义很简单:同一个请求执行一次和执行多次,结果一致。
在订单系统里,重复来源主要有三类:
- 用户重复点击“提交订单”
- 网关超时后客户端重试
- MQ 消费失败后重复投递
- 第三方支付平台重复回调
常见幂等键设计
不同动作,幂等键不同:
- 创建订单:
userId + clientToken - 支付回调:
payTransactionId - 库存扣减:
orderId + skuId - 发券:
bizType + bizId + userId
幂等实现常见方法:
- 数据库唯一约束
- 状态机校验
- 幂等记录表
- Redis setnx + 过期时间
- 业务去重号
实践中,数据库唯一约束 + 状态机校验是最稳的底座。
2. 消息补偿:承认消息会出问题,然后设计恢复机制
消息链路里最麻烦的不是“失败”,而是“你不知道它到底失败在哪一步”。
一个典型风险链路:
- 订单服务写入订单成功
- 准备发送“订单创建”消息
- MQ 短暂不可用
- 订单已存在,但库存服务根本没收到事件
这时候如果没有补偿机制,系统就会留下“半成功状态”。
常见补偿思路
方案 A:本地事务 + 业务表 + 补偿任务(Outbox 模式)
核心思想:
- 在同一个数据库事务中,同时写入:
- 订单表
- 消息事件表(outbox)
- 后台投递程序不断扫描未发送事件并投递 MQ
- MQ 投递成功后更新事件状态
- 失败则重试,超过阈值转人工/告警
这是非常经典且工程上靠谱的方案。
方案 B:事务消息
如果所用 MQ 支持事务消息,也可以让“本地事务”和“消息发送”具备更强联动能力。但事务消息一般会增加接入复杂度,对运维与中间件能力也有要求。
方案 C:定时对账补偿
当上游、下游系统都可能在异常时失联,仅靠实时重试还不够,这时要加上:
- 支付对账
- 订单状态对账
- 库存冻结/释放对账
也就是说:
实时补偿解决大多数问题,离线对账解决漏网之鱼。
3. 状态机:让补偿和幂等都有“业务边界”
订单系统最怕“状态乱跳”。
例如:
- 已取消订单又被回调成已支付
- 已支付订单被重复取消
- 已发货订单因旧消息重放变成待支付
所以,除了技术层面的幂等,还必须有业务状态机约束。
stateDiagram-v2
[*] --> CREATED
CREATED --> PAID: 支付成功
CREATED --> CANCELED: 超时取消/用户取消
PAID --> SHIPPED: 履约发货
SHIPPED --> COMPLETED: 用户收货/自动完成
CANCELED --> [*]
COMPLETED --> [*]
状态机的意义在于:
- 拦截非法状态迁移
- 让重复消息“无事发生”
- 为补偿逻辑提供明确判断依据
方案设计:订单一致性的推荐落地架构
下面给一个适合中型系统的组合方案:
- 入口防重:客户端 token + 服务端幂等校验
- 订单创建:本地事务写订单表 + outbox 事件表
- 事件投递:后台任务异步投递 MQ
- 消费者处理:按业务唯一键做幂等
- 失败处理:重试、死信、人工告警
- 离线补偿:对账任务扫描异常订单
flowchart TD
A[用户提交订单] --> B[订单服务]
B --> C[本地事务: 写订单表]
C --> D[本地事务: 写Outbox事件表]
D --> E[提交事务]
E --> F[投递任务扫描Outbox]
F --> G[发送MQ消息]
G --> H[库存/营销/履约消费者]
H --> I[消费者幂等校验]
I --> J[业务处理]
G --> K[发送失败重试]
K --> L[超过阈值告警/人工介入]
B --> M[返回下单成功]
方案对比与取舍分析
1. 仅靠接口幂等,够不够?
不够。
接口幂等只能解决“调用重复”,但解决不了:
- 订单已落库但消息未发出
- 消息发出但消费失败
- 消费成功但 ACK 丢失导致重复消费
所以接口幂等只是入口防线,不是全链路方案。
2. 用分布式事务,能不能一步到位?
理论上可以,实践中往往不划算。
原因很现实:
- 成本高
- 耦合强
- 性能差
- 对中间件和团队要求高
对订单这种高频核心链路,更常见的做法是:
- 关键状态本地强一致
- 跨服务最终一致
- 依靠幂等和补偿收敛异常
3. Outbox 还是事务消息?
简化对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Outbox | 易理解、易控制、与业务库强绑定 | 有扫描延迟,需要额外表与任务 | 大多数业务系统 |
| 事务消息 | 实时性更好,消息与事务联动强 | 接入复杂,依赖 MQ 能力 | 中大型团队、基础设施成熟 |
| 纯定时补偿 | 实现简单 | 实时性差,问题暴露晚 | 非核心链路 |
如果你现在要落地一个可控、易维护的订单系统,我更推荐先上 Outbox + 消费幂等 + 定时补偿 这套组合。
实战代码(可运行)
下面用 Python + SQLite 模拟一个最小可运行版本,演示:
- 创建订单时写订单表与事件表
- 后台投递 outbox 事件
- 消费者做幂等处理
- 重复投递不会重复扣库存
说明:这是教学版示例,重点是机制,不是框架选型。
1. 初始化数据库
import sqlite3
import json
import time
from datetime import datetime
DB_FILE = "order_demo.db"
def get_conn():
return sqlite3.connect(DB_FILE)
def init_db():
conn = get_conn()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
amount INTEGER NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS outbox_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL UNIQUE,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
next_retry_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS inventory_deductions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
biz_key TEXT NOT NULL UNIQUE,
order_no TEXT NOT NULL,
sku_id TEXT NOT NULL,
quantity INTEGER NOT NULL,
created_at TEXT NOT NULL
)
""")
conn.commit()
conn.close()
if __name__ == "__main__":
init_db()
print("DB initialized.")
2. 订单创建:本地事务写订单和 Outbox
import sqlite3
import json
from datetime import datetime
from uuid import uuid4
DB_FILE = "order_demo.db"
def now():
return datetime.utcnow().isoformat()
def create_order(order_no, user_id, amount):
conn = sqlite3.connect(DB_FILE)
try:
cur = conn.cursor()
cur.execute("BEGIN")
cur.execute("""
INSERT INTO orders(order_no, user_id, amount, status, created_at)
VALUES (?, ?, ?, ?, ?)
""", (order_no, user_id, amount, "CREATED", now()))
event = {
"order_no": order_no,
"sku_id": "SKU-1001",
"quantity": 1
}
cur.execute("""
INSERT INTO outbox_events(
event_id, aggregate_type, aggregate_id, event_type, payload,
status, retry_count, next_retry_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
str(uuid4()),
"ORDER",
order_no,
"ORDER_CREATED",
json.dumps(event),
"NEW",
0,
now(),
now(),
now()
))
conn.commit()
print(f"订单创建成功: {order_no}")
except sqlite3.IntegrityError as e:
conn.rollback()
print(f"订单重复或唯一约束冲突: {e}")
except Exception as e:
conn.rollback()
print(f"创建订单失败: {e}")
finally:
conn.close()
if __name__ == "__main__":
create_order("ORD-10001", 1, 199)
3. 模拟 MQ 投递任务
这里不接真实 MQ,直接把 NEW 状态事件交给消费者函数处理。
import sqlite3
import json
from datetime import datetime
DB_FILE = "order_demo.db"
def now():
return datetime.utcnow().isoformat()
def consume_order_created(event):
conn = sqlite3.connect(DB_FILE)
try:
cur = conn.cursor()
biz_key = f"INV_DEDUCT:{event['order_no']}:{event['sku_id']}"
cur.execute("""
INSERT INTO inventory_deductions(biz_key, order_no, sku_id, quantity, created_at)
VALUES (?, ?, ?, ?, ?)
""", (
biz_key,
event["order_no"],
event["sku_id"],
event["quantity"],
now()
))
conn.commit()
print(f"库存扣减成功: {biz_key}")
except sqlite3.IntegrityError:
conn.rollback()
print(f"幂等命中,跳过重复扣减: {biz_key}")
finally:
conn.close()
def dispatch_events():
conn = sqlite3.connect(DB_FILE)
try:
cur = conn.cursor()
cur.execute("""
SELECT id, event_id, event_type, payload, retry_count
FROM outbox_events
WHERE status IN ('NEW', 'RETRY')
ORDER BY id ASC
""")
rows = cur.fetchall()
for row in rows:
event_db_id, event_id, event_type, payload, retry_count = row
event = json.loads(payload)
try:
if event_type == "ORDER_CREATED":
consume_order_created(event)
cur.execute("""
UPDATE outbox_events
SET status = ?, updated_at = ?
WHERE id = ?
""", ("SENT", now(), event_db_id))
conn.commit()
print(f"事件投递成功: {event_id}")
except Exception as e:
next_retry = now()
cur.execute("""
UPDATE outbox_events
SET status = ?, retry_count = ?, next_retry_at = ?, updated_at = ?
WHERE id = ?
""", ("RETRY", retry_count + 1, next_retry, now(), event_db_id))
conn.commit()
print(f"事件投递失败: {event_id}, err={e}")
finally:
conn.close()
if __name__ == "__main__":
dispatch_events()
4. 验证重复投递下的幂等
手工把同一事件再次改成 RETRY,模拟重复投递。
import sqlite3
DB_FILE = "order_demo.db"
def reset_one_event_to_retry(order_no):
conn = sqlite3.connect(DB_FILE)
cur = conn.cursor()
cur.execute("""
UPDATE outbox_events
SET status = 'RETRY'
WHERE aggregate_id = ?
""", (order_no,))
conn.commit()
conn.close()
print(f"事件已重置为 RETRY: {order_no}")
if __name__ == "__main__":
reset_one_event_to_retry("ORD-10001")
再执行一次投递程序,你会看到类似输出:
幂等命中,跳过重复扣减: INV_DEDUCT:ORD-10001:SKU-1001
事件投递成功: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
这说明:
- 事件可能被重复投递
- 但库存扣减不会重复生效
- 一致性依赖的是消费者幂等,不是“消息绝不重复”
关键流程时序图
sequenceDiagram
participant U as 用户
participant O as 订单服务
participant DB as 订单库
participant T as Outbox投递任务
participant MQ as 消息队列
participant I as 库存服务
U->>O: 提交订单
O->>DB: 事务写订单
O->>DB: 事务写Outbox事件
DB-->>O: 提交成功
O-->>U: 下单成功
T->>DB: 扫描未发送事件
T->>MQ: 发送 ORDER_CREATED
MQ-->>I: 投递消息
I->>I: 幂等校验
I->>I: 扣减库存
I-->>MQ: ACK
容量估算与设计边界
订单系统不是“功能能跑就行”,还要考虑量上来之后怎么扛。
1. Outbox 表会不会越来越大?
会,所以必须设计归档策略。
建议:
NEW/RETRY/SENT/FAILED分状态索引- 已发送成功的数据定期归档或清理
- 按时间分表或按月归档
- 扫描任务只扫最近窗口数据
例如:
- 日订单量 100 万
- 每单平均 3 个事件
- 每天 outbox 约 300 万行
如果不做归档,几个月后扫描性能就会明显下降。
2. 补偿频率怎么定?
建议按业务时效来分:
- 实时重试:秒级~分钟级
- 延迟重试:5 分钟、15 分钟、1 小时
- 离线对账:每日或每小时
常见做法是指数退避,例如:
- 第 1 次失败:10 秒后重试
- 第 2 次失败:1 分钟后
- 第 3 次失败:5 分钟后
- 第 4 次失败:30 分钟后
- 超过上限:告警 + 人工处理
3. 哪些场景不适合只靠补偿?
以下场景建议做更强保护:
- 支付扣款
- 库存超卖风险高的扣减
- 账户余额变更
- 法务/财务敏感流水
这些场景可以考虑:
- 更严的状态机
- 账户流水不可变模型
- 双重幂等校验
- 对账闭环
常见坑与排查
这一部分我尽量写得“像在排故现场”,因为很多问题不是不会设计,而是线上出事时不知道先看哪。
坑 1:只在生产者做幂等,消费者没做
表现:
- MQ 发生重复投递后,下游重复扣库存、重复发券
原因:
- 很多人误以为“消息只会消费一次”
- 实际上重复消息是正常现象,不是异常现象
排查路径:
- 查 MQ 投递次数
- 查消费日志里是否有同一业务键多次进入
- 查消费者是否有唯一索引或幂等表
建议:
- 消费者幂等是必需品,不是加分项
坑 2:状态更新没有做条件约束
错误写法:
UPDATE orders SET status = 'PAID' WHERE order_no = 'ORD-10001';
这会导致:
- 已取消订单也可能被改成已支付
更安全的写法:
UPDATE orders
SET status = 'PAID'
WHERE order_no = 'ORD-10001'
AND status = 'CREATED';
受影响行数为 0 时,要分情况处理:
- 可能已经支付成功,属于幂等命中
- 也可能订单已取消,属于状态冲突
这两者不能混在一起。
坑 3:Outbox 投递成功了,但状态没更新
表现:
- 下游业务已处理成功
- 生产者 outbox 仍是
NEW/RETRY - 扫描任务再次发送,造成重复投递
这不是罕见问题,尤其在“发送成功但更新 DB 失败”的情况下很常见。
建议:
- 接受“可能重复投递”的事实
- 把精力放在消费者幂等上
- 不要幻想通过生产者侧彻底消灭重复
坑 4:补偿任务扫全表,数据库被打挂
表现:
- 定时任务一启动,主库 CPU 飙升
- 线上订单写入抖动明显
原因:
- 没有索引
- 没有分页
- 没有限流
- 没有按状态和时间窗口过滤
建议 SQL 示例:
SELECT id, event_id, payload
FROM outbox_events
WHERE status IN ('NEW', 'RETRY')
AND next_retry_at <= CURRENT_TIMESTAMP
ORDER BY id
LIMIT 200;
要点:
- 小批量
- 有索引
- 可并发但避免重复抢占
- 控制扫描频率
坑 5:把“补偿”做成了“无限重试”
无限重试的后果是:
- 错误被掩盖
- 脏消息长期堆积
- 运维看不到真正故障
建议明确重试上限,例如:
- 最多重试 16 次
- 超过后标记
FAILED - 推送告警
- 支持人工干预或重放
安全/性能最佳实践
安全最佳实践
1. 幂等键不能完全信任客户端
客户端传的 token 可以用,但不能裸信。
建议:
- 服务端生成或校验 token 格式
- 与用户、场景、时间窗口绑定
- 防止伪造与重放攻击
2. 支付回调必须验签
支付回调是订单状态推进的关键入口,不能只凭“对方说支付成功”。
至少要做:
- 参数验签
- 金额校验
- 商户号校验
- 交易状态校验
- 回调幂等
3. 补偿接口要有权限隔离
很多系统会提供“消息重放”“订单补偿”后台功能。这个功能非常危险。
建议:
- 只对内开放
- 细粒度 RBAC 权限
- 全量操作审计日志
- 关键操作双人复核
性能最佳实践
1. 幂等表唯一索引要精简
唯一键别设计得又长又复杂,尤其别直接拿整段 JSON 做唯一比对。
更推荐:
- 固定长度业务键
- 哈希后的摘要键
- 明确业务主键组合
2. 热点订单要避免串行锁扩大
如果大量回调都竞争同一订单记录,可能造成锁等待。
可用手段:
- 缩小事务范围
- 先校验再更新
- 状态条件更新
- 把非核心动作异步化
3. 补偿任务与在线流量隔离
不要让补偿扫表和在线下单抢同一个资源池。
建议:
- 独立线程池/进程
- 限速
- 低峰执行部分离线补偿
- 必要时走只读副本做异常发现,再回主库修正
一套可执行的落地建议
如果你准备在现有订单系统里逐步升级一致性能力,可以按下面顺序推进:
第一阶段:先补幂等底座
优先做:
- 下单接口幂等 token
- 支付回调幂等
- 消费者唯一键去重
- 订单状态机校验
这样至少能把“重复执行”的伤害先压住。
第二阶段:引入 Outbox
在订单创建、支付成功、取消订单等关键动作中:
- 业务数据与事件记录同事务写入
- 后台任务负责投递
- 建立失败重试与告警机制
这样能解决“主业务成功但消息丢失”的问题。
第三阶段:做对账与人工兜底
上线后别以为万事大吉,还要补:
- 支付-订单对账
- 订单-库存对账
- 异常单查询页
- 消息重放工具
- 审计日志
这一步决定了系统是真正“可运营”,还是出了事只能查日志碰运气。
总结
在分布式订单系统里,一致性不是靠某个“银弹组件”一把解决的,而是几层机制叠起来:
- 幂等设计:把重复请求、重复消息变成无害
- Outbox/事务消息:保证业务事件不轻易丢
- 补偿机制:接受失败一定会发生,并设计恢复路径
- 状态机约束:防止业务状态乱跳
- 对账与审计:处理极端异常和长尾问题
如果只给一个最实用的建议,那就是:
不要追求“消息绝不重复、绝不丢失”的理想世界,而要建设“允许重复、允许失败、但最终能收敛”的工程体系。
最后给几个落地边界判断:
- 如果你的团队基础设施一般,优先选 Outbox + 消费者幂等 + 定时补偿
- 如果你的业务是支付、库存、账户类高敏感场景,必须加上严格状态机和对账闭环
- 如果你的系统吞吐量很高,尽早考虑 事件表归档、索引设计、补偿限流
把这套思路搭起来后,订单系统就不是“看起来能跑”,而是真正具备了可恢复、可扩展、可追责的一致性能力。