17.5 BASE 与柔性事务
一句话
BASE 不是“不要一致性”,而是把一致性从“每一步都正确”降级为“最终能收敛”,代价是把正确性责任从数据库转移到业务——中间态、补偿、幂等、对账四件套,少一件就会资损。
三个词,先各自归位
| 词 | 准确含义 | 常见误解 | 工程对应 |
|---|---|---|---|
| BA(Basically Available) | 允许降级:少返回一些数据(harvest)、少回答一些请求(yield),但整体仍在服务 | “基本能用就行” | 限流、熔断、只读、部分结果(见 17.6 CAP 之后的补充理论) |
| S(Soft state) | 状态没有外部输入也会变:副本会收敛、TTL 会过期、反熵会修复 | “软绵绵、随便写” | 副本收敛、租约到期、缓存 TTL、对账修复 |
| E(Eventually consistent) | 停止写入后,所有副本终将收敛到同一值——“终将”没有时间界 | “很快就会一致” | 收敛保证 + 冲突解决 + 监控陈旧窗口(见 16.5 最终一致性) |
🔑 BASE 不是定理,是一套工程纲领:它把“正确性”从一个瞬间(提交成功)拉长成一段时间(收敛完成),于是“这段时间里数据是错的”必须被业务看见、并被业务处理。
它是怎么来的
| 时间 | 事件 | 贡献 |
|---|---|---|
| 1998 | Fox & Patterson《Harvest, Yield, and Scalable Tolerant Systems》 | 把“可用性”拆成**产量(harvest)与产出率(yield)**两个连续量,奠定了“降级可用”的思想 |
| 2008 | Pritchett《BASE: An Acid Alternative》(eBay) | 正式提出 BASE 缩写,用 eBay 的实践说明“放弃强一致换可用”怎么做 |
| 2012 | Abadi 提出 PACELC | 把“平时也要在延迟与一致性之间选”讲清楚(见 17.4 PACELC) |
需要说清:BASE 不是 CAP 的推论。CAP 是“分区时二选一”的定理;BASE 是“即使没有分区,也主动选择弱一致以换延迟与吞吐”的工程取向——它与 PACELC 的 EL 分支更接近。
ACID 与 BASE:责任方的转移
| 维度 | ACID(单库事务) | BASE(柔性事务) |
|---|---|---|
| 原子性 | 数据库保证:要么全成功,要么全失败 | 业务保证:中间态 + 补偿 |
| 隔离性 | 锁/MVCC 提供隔离级别 | 中间态对外可见,靠状态机与幂等约束 |
| 一致性 | 提交瞬间满足业务不变式 | 收敛后满足;期间允许违反 |
| 失败处理 | 回滚(数据库替你撤销) | 补偿(业务写反向操作)+ 对账 |
| 性能 | 锁竞争、日志同步、跨节点协调 | 无长事务锁,吞吐高,延迟低 |
| 正确性责任方 | 数据库 | 业务代码 + 运维流程 |
💡 分区时少数派到底能不能写、写了之后谁来收拾,是 17.3 分区期间的取舍 的问题;BASE 解决的是“允许中间态存在之后,业务怎么把它收敛回来”。
⚠️ 最后一行是全部代价的来源:ACID 把“出错怎么办”封装进了数据库;BASE 把它还给了你。团队没有能力做幂等、对账、补偿,就不要选 BASE——不是“一致性弱一点”,而是“正确性没人管了”。
柔性事务:六种机制,一句话各自归位
“柔性事务”不是一个具体协议,而是“不满足 ACID、靠业务手段收敛”的一类方案。机制细节各有主场,这里只给边界与结论:
| 机制 | 一句话 | 主场 |
|---|---|---|
| 2PC | 协调者两阶段提交,同步阻塞,协调者故障会悬挂 | 24.2 2PC 与恢复协议 |
| TCC | Try 预留资源、Confirm 确认、Cancel 释放;要处理空回滚、幂等、悬挂 | 24.4 TCC (空回滚 / 幂等 / 悬挂) |
| Saga | 长事务拆成局部事务 + 补偿动作;无隔离,中间态可见 | 24.5 Saga (编排 / 协同 / 补偿) |
| 本地消息表 | 业务写与消息写同库同事务,再由投递器发出 | 24.6 本地消息表 |
| 事务消息 | 半消息 + 回查,保证“本地事务成功则消息一定可投” | 24.7 事务消息 (半消息 / 回查) |
| 最大努力通知 | 反复重试通知,不保证顺序与次数,对端必须幂等 | 24.8 最大努力通知 |
选型方法(哪种场景用哪种、失败模式怎么比较)在 24.9 事务模型选型。本节只回答一个更靠前的问题:什么时候该用柔性事务,什么时候必须用 ACID。
补偿不是回滚
这是柔性事务里最容易被误解的一点,也是资损的高发区:
| 维度 | 回滚(ACID rollback) | 补偿(compensation) |
|---|---|---|
| 语义 | 撤销未提交的修改,等价于“没发生过” | 执行一个新的业务动作,抵消已发生的影响 |
| 可见性 | 外部看不到中间状态 | 中间状态可能已经被用户/下游看到 |
| 例子 | UPDATE balance SET x = x - 100 回滚 | 扣款成功后“退款 100”,是一笔新流水 |
| 失败处理 | 数据库保证 | 补偿本身可能失败,要重试 + 对账 + 人工 |
| 幂等要求 | 不需要 | 必须(重复补偿 = 重复退款) |
| 是否完美 | 完美 | 不完美:手续费、积分、已发货、已开票都可能收不回来 |
🔑 补偿只能保证“业务上大致回到原点”,不能保证“物理上没发生过”。所以凡是“发生过就不可逆”的动作(发货、开票、发券给用户),必须在链路设计上尽量往后放,或者用预留(Try)把不可逆性挡住。
中间态可见,就要有四件套
BASE 的系统里,“数据正在处理中”是一个合法的、对外可见的状态。要让它不变成事故,四件事缺一不可:
| 件 | 作用 | 缺了会怎样 |
|---|---|---|
| 显式状态机 | CREATED → PAYING → PAID / FAILED,每个状态的含义与出口明确 | 靠 status=1/2/3 猜语义,悬挂与重复无法判断 |
| 幂等 | 回调、补偿、重试都能安全重复执行 | 重复回调导致重复发货/重复退款(见 25.4 幂等设计与去重) |
| 补偿 / 查询 | 超时未决时,主动向权威源查询或反向补偿 | 悬挂订单永久卡在“支付中” |
| 对账 | 定期与权威源比对,发现漏单、错单、悬挂 | 错误在几天后才发现,错过追回窗口 |
实测:1 万单里,回调丢了 3%,最后能收敛吗
用一个最小模型跑一遍:支付回调有 3% 丢失、5% 重复;回调是幂等的;超过 50ms 仍处于 PAYING 的订单,由补偿器向支付网关查询权威结果。
package main
import (
"fmt"
"math/rand"
)
// 柔性状态机:中间态 PAYING 对用户可见
const (
created = "CREATED"
paying = "PAYING"
paid = "PAID"
failed = "FAILED"
)
type order struct {
id int
state string
tick int64 // 进入当前状态的虚拟时间
}
var now int64
// 支付网关的权威结果:回调可能丢、可能重,但它自己是最准的
var authoritative = map[int]bool{}
// 回调必须幂等:只有第一次能从 PAYING/ CREATED 迁走,重复回调被吸收
func callback(o *order, success bool) bool {
if o.state != paying && o.state != created {
return false
}
if success {
o.state = paid
} else {
o.state = failed
}
o.tick = now
return true
}
func main() {
rnd := rand.New(rand.NewSource(7)) // 固定种子,结果可复现
const n = 10000
orders := make([]*order, 0, n)
dupAbsorbed, lostCallbacks, dupCallbacks := 0, 0, 0
for i := 0; i < n; i++ {
success := rnd.Float64() < 0.9
authoritative[i] = success
o := &order{id: i, state: created, tick: now}
o.state = paying // 已发起支付,等待回调
if rnd.Float64() > 0.03 { // 97% 的回调送达
callback(o, success)
} else {
lostCallbacks++
}
if rnd.Float64() < 0.05 { // 5% 重复回调
dupCallbacks++
if !callback(o, success) {
dupAbsorbed++
}
}
orders = append(orders, o)
}
count := func() map[string]int {
m := map[string]int{}
for _, o := range orders {
m[o.state]++
}
return m
}
before := count()
fmt.Printf("回调处理完:PAID=%d FAILED=%d PAYING(悬挂)=%d(丢失 %d 次,重复 %d 次,被幂等吸收 %d 次)\n",
before[paid], before[failed], before[paying], lostCallbacks, dupCallbacks, dupAbsorbed)
// 对账/补偿:PAYING 超过 50ms 的,向网关查询权威结果
const timeout = 50
now = 60
recovered, compensated := 0, 0
for _, o := range orders {
if o.state != paying || now-o.tick < timeout {
continue
}
if authoritative[o.id] {
o.state = paid
recovered++
} else {
o.state = failed
compensated++
}
o.tick = now
}
after := count()
mismatch := 0
for _, o := range orders {
if (o.state == paid) != authoritative[o.id] {
mismatch++
}
}
fmt.Printf("补偿后:PAID=%d FAILED=%d PAYING=%d(查询补回 %d 单,确认失败 %d 单)\n",
after[paid], after[failed], after[paying], recovered, compensated)
fmt.Printf("与网关权威结果比对:不一致 %d 单(漏单率 %.4f%%)\n", mismatch, 100*float64(mismatch)/n)
}
本机实测(macOS,Apple M1,Go 1.27):
回调处理完:PAID=8746 FAILED=985 PAYING(悬挂)=269(丢失 278 次,重复 467 次,被幂等吸收 458 次)
补偿后:PAID=8986 FAILED=1014 PAYING=0(查询补回 240 单,确认失败 29 单)
与网关权威结果比对:不一致 0 单(漏单率 0.0000%)
三个数字值得记住:
| 数字 | 含义 | 如果缺了对应机制 |
|---|---|---|
| 269 单悬挂 | 3% 回调丢失后卡在 PAYING | 缺补偿器 → 用户永远看到“支付中”,客诉与对账爆炸 |
| 458 次被幂等吸收 | 重复回调没有造成二次处理 | 缺幂等 → 重复发货/重复加余额 |
| 240 单补回、0 不一致 | 对账把“不知道”变成“知道” | 缺对账 → 只能等用户来投诉 |
💡 注意:这个模型里补偿是“查询网关”而不是“反向退款”。真实系统里两种都要有——能查到结果就查,查不到再补偿;补偿本身也要幂等,并且最终仍要落到对账。
成本:柔性不是免费的
| 成本项 | 量级(示意) | 说明 |
|---|---|---|
| 补偿代码 | 每个正向操作都要一个反向操作 | 代码量接近翻倍,且要单独测试 |
| 对账任务 | 每天/每小时跑一次,覆盖所有链路 | 数据量大时要分片、要控频 |
| 人工兜底 | 补偿失败率 0.1% × 100 万单/天 = 1000 单/天 | 必须有人/有工单系统处理 |
| 状态与监控 | 每个中间态都要有监控与告警 | 悬挂超过阈值就要告警,而不是等客诉 |
⚠️ “0.1% 的补偿失败率”听起来很小,但 100 万单/天就是 1000 单。柔性事务的可行性,最终取决于你有没有能力消化这 1000 单——没有对账与人工流程,BASE 只是把故障从“当时报错”推迟成“事后资损”。
什么时候不该用柔性事务
| 场景 | 该用什么 | 原因 |
|---|---|---|
| 单库、单行余额扣减 | ACID | 一个事务能解决的事,不要拆成 Saga |
| 同库多表、强不变式(账务) | ACID | 隔离性帮你挡住并发写冲突 |
| 跨服务、长流程(下单 → 库存 → 支付 → 发货) | 柔性(Saga/TCC/消息) | 长事务不能持锁等用户 |
| 高并发秒杀扣库存 | 预留 + 最终一致(或单行热点优化) | 强一致锁会打爆数据库 |
| 不可逆动作(开票、发货) | 尽量后置,或前置预留 | 补偿收不回来 |
反模式
| 反模式 | 后果 |
|---|---|
| 把 BASE 当“不用设计一致性” | 没有对账与幂等,错误静默累积 |
| 用补偿代替回滚,却没做幂等 | 重复退款、重复发货 |
| 补偿失败只打日志 | 没有人处理,等于没补偿 |
| 中间态不对外可见,硬说“已成功” | 用户看到成功,后台其实悬挂 |
| 长事务跨服务持锁 | 连接池打满、死锁、雪崩 |
| 用 LWW 合并资金流水 | 丢写且无法追溯 |
| 不做对账,靠“应该不会错” | 资损在几天后才发现 |
| 所有链路都上 Saga | 简单场景复杂化,故障点变多 |
自检
Q1 为什么说“补偿不是回滚”?举一个补偿做不到“等价于没发生”的例子。
▸ 答案:回滚是数据库撤销未提交的修改,外部世界看不到中间态,效果等价于“没发生过”;补偿是在已经发生之后执行一个反向的业务动作,它本身是一笔新流水,可能被外部看到,也可能失败。例子:用户下单后系统扣了积分并给他发了一张优惠券,随后库存不足要取消订单——积分可以退回,但优惠券可能已经被使用,只能作废或协商;如果已经开票、已经发货,补偿更是收不回来。所以补偿只能保证“业务上大致回到原点”。
Q2 前面的实测里,回调丢失 278 次,为什么只有 269 单悬挂?这个差额说明了什么?
▸ 答案:因为重复回调(5%)中有一部分恰好落在了“回调丢失”的订单上,把这个订单从 PAYING 推进到了终态——278 次丢失里,有 9 单被重复回调“救”了回来。这说明了幂等的另一面:重复回调既是噪声,也是天然的重试;正确实现(第一次生效、后续吸收)让系统免费获得了一次额外的投递机会,而错误实现(不幂等)会把这份“免费重试”变成重复处理。
Q3 一个团队把“余额扣减”做成了 Saga:Try 阶段不冻结、直接扣,失败再补偿回来。这个设计有什么问题?
▸ 答案:问题在 Try 阶段直接扣减破坏了不变式:“余额不能为负”在并发下无法保证——两个请求同时读到 100,各扣 60,补偿前就已经透支;而且补偿(加回 60)是异步的,期间用户看到的是错误余额。正确做法:① 余额这类强不变式用 ACID 或 TCC 的冻结(Try 阶段预留),保证任何时刻“可用余额 ≥ 0”;② 如果必须跨服务,用“冻结 + 确认/释放”,而不是“先扣再补”。
Q4 柔性事务的对账任务应该对什么?只对“金额”够不够?
▸ 答案:不够。对账至少要覆盖四类差异:① 漏单(上游成功、下游没收到);② 错单(金额/状态不一致);③ 悬挂(长期停在中间态);④ 重复(同一笔业务被处理多次)。只对金额会漏掉“状态错但金额相同”的情况(比如已退款但状态仍是已支付)。对账的输出不应该是“一个数字”,而应该是可处理的差异清单(带业务主键、差异类型、建议动作),否则跑完也没人能用。