04 · 一致性与事务边界:本地 / 分布式 / 幂等
"数据对不对"是后端最难的部分。本篇把事务从单库讲到跨服务:ACID、隔离级别、分布式事务方案、幂等与最终一致。
速览卡
| 项 | 内容 |
|---|---|
| 一句话定位 | 多步写操作怎么保证"要么全成功要么全失败",跨服务时又怎么妥协 |
| 核心内容 | ACID、事务隔离级别、本地事务边界、分布式事务方案(SAGA/本地消息表)、幂等设计、最终一致 |
| 读完能回答 | 什么时候该用事务?跨服务怎么保证一致性?重复请求怎么不重复扣款? |
| 相关板块 | C10 · 数据建模 | C06 · 异步与消息 | C13 · 缓存与 Redis |
一、先看清一致性光谱
| 级别 | 含义 | 代价 |
|---|---|---|
| 强一致 | 写后立刻读到 | 延迟高、跨服务难 |
| 最终一致 | 短暂不一致,最终一致 | 可接受窗口,要处理补偿 |
| 无所谓 | 无所谓 | 缓存随便用 |
后端 90% 的"一致性难题"其实是"该用哪档"的问题:账务要强,榜单可最终一致。
二、本地事务:ACID 与隔离级别
2.1 ACID
| 特性 | 含义 | 通俗 |
|---|---|---|
| Atomicity 原子性 | 全成或全败 | 要么都做 |
| Consistency 一致性 | 约束不被破坏 | 状态合法 |
| Isolation 隔离性 | 并发互不干扰 | 各做各的 |
| Durability 持久性 | 提交后不丢 | 断电还在 |
2.2 隔离级别(使用侧)
| 级别 | 防脏读 | 防不可重复读 | 防幻读 | 用在哪 |
|---|---|---|---|---|
| 读未提交 | ❌ | ❌ | ❌ | 别用 |
| 读已提交 | ✅ | ❌ | ❌ | 多数默认 |
| 可重复读 | ✅ | ✅ | ~ | MySQL 默认 |
| 串行化 | ✅ | ✅ | ✅ | 极少数 |
使用原则:默认用数据库的默认隔离级别;只有具体场景(如金额校验)才考虑升级,升级前先理解性能代价。
三、事务边界:包什么、不包什么
python
def create_order(order, user):
with db.begin():
lock_user(user.id) # 锁账户
if user.balance < order.total:
raise BusinessError("INSUFFICIENT_BALANCE")
db.insert(order)
db.update(user.balance -= order.total)| 该包 | 不该包 |
|---|---|
| 多步写操作(扣款+下单) | 纯读 |
| 状态必须原子变化 | 外部调用(短信/HTTP) |
| 有前置校验的写 | 长时间运行的逻辑 |
⚠️ 外部调用不要放在事务里:短信、第三方 HTTP 不可控,事务回滚不了外部副作用。正确顺序是"事务内做本地写 → 提交后发外部消息/事件"。
四、跨服务:分布式事务方案
单个服务管不了多个服务的数据库,方案都是"妥协的艺术":
| 方案 | 思路 | 适用 |
|---|---|---|
| SAGA | 拆成多步 + 每步有补偿 | 主流 |
| 本地消息表 | 业务表 + 消息表同一事务写,异步投递 | 可靠投递 |
| 事务消息(MQ) | 半消息 + 确认 | 依赖 MQ 能力 |
| TCC | Try-Confirm-Cancel 三段 | 强管控但复杂 |
| 2PC | 两阶段提交 | 少用,阻塞问题 |
SAGA 是默认答案:比如"下单 → 扣款 → 减库存",每一步失败就执行反向补偿(退款、加回库存)。代价是中间状态可见(最终一致)。
五、幂等设计:重复请求不重复扣款
前端可能重试、用户可能连点、MQ 可能重投——后端必须幂等:
| 手段 | 做法 | 场景 |
|---|---|---|
| 唯一键 | 请求带 idempotency_key,建唯一约束 | 下单/支付 |
| 状态机 | UPDATE ... WHERE status='pending',影响行数=0 则跳过 | 任务处理 |
| 查重后写 | 先查后写,但要配合唯一约束防并发 | 弱校验 |
python
def create_payment(req):
# 唯一约束兜底:同一个幂等键只能成功一次
row = db.insert_payment(idempotency_key=req.key)
if row is None: # 撞唯一约束,说明已处理
return db.get_payment_by_key(req.key)幂等三件套:客户端传幂等键 + 数据库唯一约束 + 返回已存在的结果。
六、最终一致的落地姿势
text
下单服务 ──写 orders + outbox 表(同一事务)──▶ MQ ──▶ 积分服务
↑
投递失败则定时任务重扫 outbox 重投| 环节 | 保证 |
|---|---|
| 本地消息表 | 业务写和消息写同事务,不丢 |
| 投递 | MQ 至少一次 |
| 消费幂等 | 消费方按业务键去重 |
| 补偿 | 失败重试,终态重扫 |
最终一致的关键不是"不失败",而是"失败了还能补到一致"——重试 + 幂等 + 补偿三件套。
自检清单
- [ ] 能按场景选择强一致 / 最终一致
- [ ] 会说清隔离级别该选哪档、为什么
- [ ] 知道外部调用不能放事务里
- [ ] 能讲 SAGA 的补偿思路,并说出它与强一致的区别
- [ ] 会给写接口做幂等(幂等键 + 唯一约束)
常见误区
- 误区:跨服务也能靠数据库事务。 不能,跨服务只能靠分布式方案 + 最终一致。
- 误区:最终一致 = 不保证。 不是,要有重试、幂等、补偿把它补到一致。
- 误区:事务包住外部调用。 外部副作用回滚不了,提交后再发消息。
- 误区:幂等 = 查一下再写。 并发下查重会漏,必须唯一约束兜底。
动手练习
练习 A:给转账加事务
实现转账接口,用事务保证两边余额变化原子,并验证失败回滚。
练习 B:幂等下单
给下单接口加幂等键 + 唯一约束,用两次相同请求验证只产生一单。
练习 C:SAGA 演练
设计"下单 → 扣款 → 减库存"的补偿表:列出每步的补偿动作和失败分支。
相关文档
本区
- 10 · 数据建模与数据访问 —— 事务的使用起点
- 06 · 异步与消息 —— 本地消息表与最终一致
- 13 · 缓存与 Redis —— 一致性 vs 性能的取舍
- 15 · 定时任务 —— 补偿与状态机
其他域
- DB 数据库与存储 —— 事务隔离内部原理