Skip to content

09 · 分布式事务与最终一致落地 ​

属于「架构师修炼」· 一致性专题(坎 4 的代价 · 跨服务数据一致性)· 先论证"能不能不做分布式事务",再讲"真要做怎么做" 上一篇:08 缓存与 DB 一致性落地 下一篇:10 etcd 与 Raft 选主租约落地

这篇解决什么问题:面试官问「订单扣了库存、加了积分、发了通知,中间挂了怎么办」,90% 的人会背 TCC 三个阶段,然后就答不下去了——因为他讲不出空回滚和悬挂具体怎么防、补偿失败了谁来兜底、最终一致到底多快能一致。这一篇的顺序是:① 先立判据(这笔数据值不值得引入分布式事务,能不能用架构手段根本绕开它);② 讲清 2PC 为什么死了;③ 逐个对齐六种主流方案的一致性/性能/侵入性/失败处理;④ 给出终极答案:补偿 + 幂等 + 对账三件套;⑤ 把订单链路完整走一遍,每一步的失败与对账都写清楚。


一、先立判据:需不需要分布式事务 ​

1.1 判据来自数据分级(不是来自技术偏好) ​

按 01 篇的数据分级,直接查表:

分级数据分布式事务的必要性实际落地方案
P0 资金级余额、订单金额、支付流水必须强一致,但绝不用跨服务强一致单库本地事务 + 唯一约束(把余额与流水放在同一个库)+ 跨系统只做最终一致 + T+1 对账
P1 关键业务库存、优惠券、任务状态不需要强一致,需要可补偿 + 可收敛本地消息表 / Saga / TCC;状态机 + 唯一键幂等
P2 用户可见帖子、评论、粉丝数不需要MQ 异步 + 幂等消费 + 定时对齐
P3 / P4 统计与分析播放量、点赞数、热榜、埋点日志完全不需要Redis 计数 + 定时落库(08 篇);埋点走 Kafka + 数仓,允许丢少量

判据一句话:问「这笔数据错一次,业务损失是什么」。答案是"钱错了/超卖了" → 强一致,但想办法把它塞进一个库;答案是"用户会看到不一致,但几分钟后能修好" → 最终一致 + 对账;答案是"只是不好看" → 异步就行,别加复杂度。

1.2 架构上避免分布式事务,比解决它更高级 ​

这是本篇最重要的一句话。分布式事务是"设计失误的补偿手段",不是"能力的象征"。三条收敛手段,优先级从高到低:

反模式(把问题留给分布式事务)正解(在架构上消灭它)收益
为了"微服务纯洁",把余额和流水拆到两个服务两个库同库同事务:余额、流水、账户明细必须在同一个库、同一个事务里;服务边界按事务边界划拿到 ACID,零分布式事务成本(这是财务系统的铁律)
订单服务和库存服务同步 RPC 扣减,要求"要么都成功要么都失败"把"必须原子"的写(订单 + 库存预占)放在同一个服务的同一个库里;跨服务的部分改成异步 + 补偿消除跨服务强依赖,延迟从 150ms 降到 20ms
用"查一次 + 判断 + 插入"防重复提交,或用分布式锁保证"同一笔业务只处理一次"UNIQUE KEY 唯一约束 + INSERT ... ON DUPLICATE KEY;幂等键 + 去重表(11 篇)用 DB 的原子性代替分布式事务,成本几乎为 0;从"防止重复"变成"重复了也无害"
多步骤链路要求"全成功,失败就整体回滚"设计成状态机:中间态可查询、可重入、可续跑不需要原子回滚,只需要"继续往前跑"

面试金句:「分布式事务的第一个答案,是重新划事务边界——把必须强一致的写收敛到一个库。如果画完边界还有跨服务的强一致需求,那多半是服务拆错了,而不是缺一个 Seata。」


二、2PC / XA:原理与致命缺陷 ​

2.1 两阶段提交流程 ​

2.2 致命缺陷与 MySQL XA 的具体坑 ​

缺陷具体表现工程后果
同步阻塞prepare → commit 之间,参与者持有行锁不放热点行锁等待;并发一上来就是锁超时 / 死锁
协调者单点协调者崩溃后参与者不知道 commit 还是 abort只能等协调者恢复/查日志,资源长期被占,需要 DBA 介入
性能差两轮网络 RTT + 两次日志 fsync + 长事务单事务 P99 从 10ms 涨到 100ms+;吞吐降一个数量级
不一致窗口commit 阶段部分参与者提交后网络断了需要"推测中止/推测提交"+ 人工核对,2PC 本身不保证分区容忍下的收敛
MySQL XA 特有坑① max_prepared_transactions 默认 0,不开根本用不了 ② 连接异常断开 → 事务悬挂在 prepared 状态,要 XA RECOVER 手工 ROLLBACK ③ XA START 只读事务不能 XA END/COMMIT(XA-1 报错)④ 与 binlog 两阶段提交的协调(group commit)让崩溃恢复更复杂 ⑤ 长事务推高 innodb_lock_wait_timeout 触发频率运维复杂度剧增,故障时只能靠 DBA 手工救

结论(背下来):2PC/XA 只适合"低并发、跨库、可容忍秒级阻塞"的内部批处理场景(如日终账务调账)。任何在线高并发链路都不该用 XA。 面试里说"我们用 XA 保证跨库一致性",等于说"我们没上过线"。


三、六种主流方案逐一对齐 ​

3.1 TCC(Try-Confirm-Cancel) ​

阶段职责幂等要求失败处理
Try预留资源(冻结余额、预占库存、锁定券),不真正生效必须幂等(重复 Try 只预留一次)失败 → 由协调者对该事务所有分支发 Cancel
Confirm确认使用预留资源(真正扣减、真正占用)必须幂等(网络重试会重复到达)失败 → 重试 + 对账(此时业务已生效,不能回滚)
Cancel释放预留资源(解冻、释放库存)必须幂等失败 → 重试 + 告警 + 人工

TCC 的三大坑(面试必考,一定要讲具体机制):

坑怎么发生的正确解法错误解法(会被追问打穿)
空回滚Try 请求根本没到达(网络丢包/超时未执行),协调者却发了 Cancel → Cancel 执行"解冻",账上凭空多出钱① 建事务控制表,Cancel 时先查 Try 记录;② 查不到 → 判定为空回滚,插入一条状态为 CANCEL 的控制记录(为防悬挂留痕),直接返回成功,不做任何资源操作"Cancel 只更新余额表,影响行数为 0 就当成功"——如果余额已经被别的逻辑改过,这里会算错
悬挂Try 超时 → 协调者发 Cancel(执行成功)→ Try 才姗姗来迟并执行成功 → 资源被预留,但永远不会有人 Confirm(该分支已结束)→ 资源永久冻结Cancel 时的"空回滚留痕"(状态=CANCEL)就是防悬挂的关键:Try 执行前先 INSERT 控制记录(UNIQUE KEY),插入冲突说明已被 Cancel → 直接拒绝 Try只靠"Cancel 时留痕"但不校验 Try 的插入——那还是挡不住迟到的 Try
幂等Confirm / Cancel 被网络重试重复调用多次控制表状态判断:只有 TRY → CONFIRM / TRY → CANCEL / 空 → CANCEL 这三个单向迁移才产生副作用;重复到达的状态直接返回成功"靠 Try 的预留余额够不够"来判断——不可靠
sql
-- TCC 事务控制表:空回滚 / 悬挂 / 幂等三大坑全靠它兜住
CREATE TABLE tcc_branch (
  tx_id     BIGINT        NOT NULL COMMENT '全局事务 ID',
  branch_id VARCHAR(64)   NOT NULL COMMENT '分支 ID(服务名 + 业务键)',
  status    TINYINT       NOT NULL COMMENT '1=TRY 2=CONFIRM 3=CANCEL(只允许单向迁移)',
  amount    DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '预留金额,便于对账',
  PRIMARY KEY (tx_id, branch_id),
  UNIQUE KEY uk_biz (branch_id)          -- 防悬挂:Try 插入冲突 = 该分支已被 Cancel
) ENGINE=InnoDB;

-- Try(防悬挂):先抢控制记录,唯一键冲突说明已被 Cancel,直接拒绝
INSERT INTO tcc_branch(tx_id, branch_id, status, amount) VALUES (?, ?, 1, ?);
UPDATE account SET frozen = frozen + ? WHERE user_id = ? AND balance - frozen >= ?;
-- Cancel(含空回滚):查不到 TRY 记录 → 插入 CANCEL 留痕后直接返回,不做任何资源操作
UPDATE tcc_branch SET status = 3 WHERE tx_id = ? AND branch_id = ? AND status = 1; -- 影响 0 行 = 重复 Cancel 或空回滚
TCC 评价说明
一致性强度准强一致(资源在 Try 阶段就被预留,业务侧不会超卖)
性能中(3 次 RPC/分支,但无长事务持锁;比 XA 好一个数量级)
侵入性最高:每个参与方都要实现 Try/Confirm/Cancel 三个接口 + 控制表,业务代码改动 3 倍
适用场景资金冻结解冻、库存预占、券锁定这类天然有"预留"语义的业务
失败处理Confirm/Cancel 重试 + 控制表状态机 + 对账;Confirm 阶段失败不能让业务回滚,只能重试

3.2 Saga:正向链路 + 反向补偿 ​

维度编排式(Orchestration)协同式(Choreography)
结构中央协调器按状态机依次调用各服务各服务监听事件,自行决定下一步
可观测性好(协调器有完整状态机,一眼看到卡在哪一步)差(链路散在各服务的事件订阅里)
耦合度 / 复杂度协调器知道所有步骤,且它是单点(要 HA)服务间松耦合,但谁都不知道全局,事件环路/重复消费风险高
适用步骤 3~10 个、需要人工介入兜底的业务(订单、履约)步骤少、参与方稳定、已有成熟事件总线

Saga 的三个必答点:① 补偿必须幂等——补偿本身也会重试(UPDATE ... SET status='CANCELLED' WHERE id=? AND status='CREATED',靠状态 CAS 而不是无脑扣减);② 不可逆操作怎么办——短信已发、券已核销、货已出库,补偿要设计成对冲操作(发一条更正短信、补发一张券),或把不可逆步骤放到链路最后一步;③ Saga 没有隔离性——中间态对外可见,要用语义锁(状态标 PENDING,读方按状态分支渲染)+ 前端友好文案("处理中")。

3.3 本地消息表(最实用,工程首选) ​

核心思想:把"发消息"变成"写本地一行数据"——业务写入与消息记录在同一个本地事务里,于是"业务成功但消息丢了"这个经典问题被 DB 的原子性消灭了。

sql
CREATE TABLE local_message (
  id            BIGINT      NOT NULL AUTO_INCREMENT,
  biz_key       VARCHAR(96) NOT NULL COMMENT '业务唯一键,如 order_created:10086,用于幂等去重',
  topic         VARCHAR(64) NOT NULL,
  payload       JSON        NOT NULL,
  status        TINYINT     NOT NULL DEFAULT 0 COMMENT '0=待发送 1=发送中 2=已确认',
  retry_count   INT         NOT NULL DEFAULT 0,
  next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  UNIQUE KEY uk_biz (biz_key),
  KEY idx_scan (status, next_retry_at)   -- 扫表必须走这个索引,否则表一大就退化成全表扫
) ENGINE=InnoDB;
-- 消费端幂等另建去重表 consumed_message(biz_key PRIMARY KEY):插入冲突 = 已处理过,直接返回成功
go
// ① 业务写入与消息记录:同一个本地事务(这是本地消息表的全部前提)
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderReq) (*Order, error) {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback() //nolint:errcheck // Commit 后为 no-op

	orderID := s.idGen.Next()
	if _, err = tx.ExecContext(ctx,
		`INSERT INTO orders(id, user_id, amount, status) VALUES(?, ?, ?, ?)`,
		orderID, req.UserID, req.Amount, StatusCreated); err != nil {
		return nil, fmt.Errorf("insert order: %w", err)
	}

	// 消息与业务同事务落库:要么都在,要么都不在 —— 不存在"扣了钱没发消息"
	payload, _ := json.Marshal(OrderCreatedEvent{OrderID: orderID, UserID: req.UserID, Amount: req.Amount})
	if _, err = tx.ExecContext(ctx,
		`INSERT INTO local_message(biz_key, topic, payload, status, next_retry_at)
		 VALUES(?, ?, ?, 0, NOW(3)) ON DUPLICATE KEY UPDATE biz_key = biz_key`, // 重复提交不报错 = 幂等
		fmt.Sprintf("order_created:%d", orderID), TopicOrderCreated, payload); err != nil {
		return nil, fmt.Errorf("insert local message: %w", err)
	}
	if err = tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit: %w", err)
	}
	return &Order{ID: orderID, Status: StatusCreated}, nil
}
go
// ② 后台扫表发消息:CAS 抢占 + 指数退避,天然支持多实例并行
func (s *MessageRelay) relayOnce(ctx context.Context) (int, error) {
	rows, err := s.db.QueryContext(ctx, `
		SELECT id, topic, payload, retry_count FROM local_message
		WHERE status = 0 AND next_retry_at <= NOW(3) ORDER BY id LIMIT 200`)
	if err != nil {
		return 0, err
	}
	defer rows.Close()

	var sent int
	for rows.Next() {
		var id int64
		var topic string
		var payload []byte
		var retry int
		if err := rows.Scan(&id, &topic, &payload, &retry); err != nil {
			return sent, err
		}
		// CAS 抢占:多实例并发扫表时,只有一个能把 status 0 → 1
		res, err := s.db.ExecContext(ctx,
			`UPDATE local_message SET status = 1 WHERE id = ? AND status = 0`, id)
		if err != nil {
			return sent, err
		}
		if n, _ := res.RowsAffected(); n == 0 {
			continue // 被别的实例抢到了
		}
		// 发送:生产端必须 acks=all + 幂等 producer(见 05 篇)
		if err := s.producer.Send(ctx, topic, payload); err != nil {
			backoff := time.Duration(1<<min(retry, 8)) * time.Second // 1s→2s→…→256s,上限 5min
			if backoff > 5*time.Minute {
				backoff = 5 * time.Minute
			}
			_, _ = s.db.ExecContext(ctx, `
				UPDATE local_message SET status = 0, retry_count = retry_count + 1,
				       next_retry_at = NOW(3) + INTERVAL ? SECOND WHERE id = ?`,
				int(backoff.Seconds()), id)
			if retry+1 >= 10 { // 超过 10 次转人工告警,绝不无限重试
				s.alert.Warnf("local message retry exhausted id=%d topic=%s", id, topic)
			}
			continue
		}
		// 发送成功 → 标记已确认(归档任务清理 7 天前的 status=2 记录,防表膨胀拖慢扫表)
		if _, err := s.db.ExecContext(ctx, `UPDATE local_message SET status = 2 WHERE id = ?`, id); err != nil {
			return sent, err
		}
		sent++
	}
	return sent, rows.Err()
}
参数建议值理由
扫表间隔200ms ~ 1s(或"本地事务提交后立即触发 + 定时兜底")间隙越小延迟越低,但空扫浪费 DB;积压时按积压量自适应缩短间隔
批大小200 条太大 → 单轮耗时长、失败重试代价高
退避1s → 2s → 4s → … 上限 5min应对 MQ 短暂不可用,避免打爆 MQ
重试上限10 次超过即告警 + 人工(绝不无限重试,会掩盖故障)
退避 / 重试上限1s → 2s → 4s → … 上限 5min;最多 10 次应对 MQ 短暂不可用且不打爆 MQ;超过 10 次即告警 + 人工(绝不无限重试,那会掩盖故障)
清理 / 监控归档 status=2 且 7 天前的记录;看待发送条数 + 最老待发送消息的年龄表膨胀正是"扫表变慢→积压"的根因;这两个指标比"扫表是否在跑"重要得多

3.4 事务消息(RocketMQ 半消息 + 回查) ​

阶段动作失败处理
① 发半消息消息写入 Broker 但对消费者不可见发送失败 → 直接失败,业务不执行
② 执行本地事务 → commit / rollback半消息发送成功后执行本地事务;成功则 commit 投递本地事务失败 → 发 rollback,消息丢弃;commit 请求丢失 → Broker 回查
③ 回查Broker 定时回查生产者"这笔本地事务到底成没成"生产者必须能查本地事务状态表回答;回查默认最多 15 次,仍无结论 → 消息丢弃(要告警)

事务消息 vs 本地消息表:事务消息把"消息表"搬进了 MQ(少一张表、少一个扫表任务),但回查逻辑必须依赖本地事务状态记录,本质上还是"要有一张表能回答事务结果"。没有 MQ 事务能力时,本地消息表是更可控的选择。

3.5 最大努力通知(对账兜底型) ​

适合外部系统(第三方支付回调、外部履约平台):不可能和它共享事务,也不可能让它实现 TCC。

环节做法
通知 / 退让收到外部事件后最多重试 N 次(如 5 次,间隔 1min/5min/10min/30min/60min);重试耗尽不阻塞业务,标记为"待对账"
兜底 / 幂等主动查询接口 + T+1 对账文件(真正的保证来自对账,不是来自重试);通知与查询共用同一套幂等键(外部单号)

关键认知:最大努力通知不承诺可靠,它的承诺是"最终一定会被对账发现并修复"。所以它必须和对账系统一起设计,单独讲最大努力通知等于没讲。

3.6 Seata AT:全局锁 + undo log ​

环节机制
一阶段拦截业务 SQL,生成 before image / after image 存入 undo_log;本地事务直接提交(释放本地行锁);同时向 TC 注册分支并申请全局行锁
二阶段提交 → TC 通知后异步删除 undo_log(极快);回滚 → 用 before image 反向补偿,且回滚前校验 after image 与当前数据一致(不一致 = 被脏写,要人工)
代价① 全局锁按行加,热点行(如秒杀库存)上全局锁竞争 → 吞吐显著下降 ② undo_log 表增长快,要定期清理 ③ 需要独立部署 TC(Seata Server,要 HA,又引入 Raft/DB 存储)④ 脏写检测失败要人工

适用边界:已有 Java/Seata 生态、并发不高、想用最小改造拿到"可回滚"能力的系统。高并发在线链路不要用 AT。

3.7 选型矩阵与决策树 ​

方案一致性强度单链路额外延迟侵入性适用场景失败处理推荐度
本地事务 + 唯一约束强(单库)0无一切能收敛到单库的场景DB 自己保证★★★★★
本地消息表最终一致(秒级)0(异步)低(一张表 + 一个任务)跨库单服务、事件驱动、已有 MQ 或可加扫表重试 + 对账★★★★★
事务消息最终一致(秒级)0(异步)低已有 RocketMQ 且会用回查MQ 回查 + 对账★★★★
Saga最终一致(秒~分钟)1 次协调 RTT/步中(每步要写补偿)跨服务多步骤、可补偿(订单履约、审批流)补偿重试 + 人工★★★★
TCC准强一致(预留语义)2~3 次 RPC/分支高(3 接口 + 控制表)资金冻结、库存预占、券锁定Confirm/Cancel 重试 + 对账★★★
最大努力通知最终一致(分钟~T+1)0低外部系统交互对账文件兜底★★★
Seata AT准强一致(可回滚)全局锁等待(不确定)低(框架代劳)低并发、Seata 生态内二阶段自动回滚★★
2PC / XA强(理论)高(持锁阻塞)中离线批处理、跨库日终DBA 手工介入★(在线禁用)

决策树一句话版:跨库单服务 → 本地消息表;跨服务多步可补偿 → Saga/TCC;资金强一致 → 不做,改成单库事务 + 对账;已有 MQ → 事务消息;外部系统 → 最大努力通知 + 对账。


四、一致性的终极答案:补偿 + 幂等 + 对账 ​

4.1 三件套的分工(背下来) ​

件解决什么靠什么实现缺了会怎样
本地事务原子性(单个库内)DB ACID自己库内都不一致,无从谈起
补偿跨服务回滚/推进Saga 反向操作 / 状态机续跑 / 重试中间态永久卡住
幂等重复执行无害唯一键 / 状态机 CAS / 去重表(11 篇)重试导致重复扣款、重复发货
对账发现并修复任何漏掉的差异定时比对两端 + 自动补偿 + 人工工单最终一致变成"永远不一致"

一句话:原子性靠本地事务,跨服务靠最终一致,最终一致靠对账收口。没有对账的最终一致 = 裸奔。

4.2 完整链路:订单 → 库存 → 积分 → 通知 ​

步骤幂等键失败表现补偿动作对账方式一致性窗口
创建订单clientToken(客户端生成,唯一键)用户重试 → 命中唯一键返回原订单无需补偿(本地事务已保证)订单表自身状态一致0(强一致)
发 MQbiz_key = order_created:{id}扫表重试,退避到 5min无(重试即补偿)待发送条数 / 最老消息年龄秒级(通常 < 1s)
扣库存biz_key = stock_deducted:{order_id}库存不足 → 业务性失败;宕机 → 技术性失败重试库存不足 → 发起取消订单;技术失败 → 重试订单已支付数 vs 库存扣减记录数秒级
加积分biz_key = points_added:{order_id}重试耗尽 → 死信对账发现后补发已支付订单数 vs 积分流水数分钟级(对账周期)
发通知biz_key = notify:{order_id}3 次后放弃无(用户可主动查询)不参与资金对账分钟~T+1

要讲清的三句关键话:① 前一步的"成功"要落成状态记录,后一步靠状态记录驱动——这就是下面第五节的状态机思想;② 业务性失败(库存不足)和技术性失败(超时/宕机)必须分开处理:前者要补偿(取消订单),后者只能重试(不能因为超时就取消用户订单);③ 通知类步骤不参与资金对账,不要为了"全都成功"把非核心步骤做成强依赖——那是把可用性送给非核心链路。


五、状态机与超时:把"回滚"变成"续跑" ​

5.1 状态机只允许单向迁移(用 CAS,不用读-改-写) ​

状态含义允许迁移到触发者
CREATED订单已创建,等待扣库存STOCK_DEDUCTED / CLOSED库存服务回调
STOCK_DEDUCTED库存已扣,等待支付PAID / CLOSING支付回调 / 超时任务
PAID已支付,等待履约与积分FULFILLED履约服务
FULFILLED终态————
CLOSED终态(未支付关闭 / 库存不足取消)——超时任务 / 补偿
sql
-- 所有状态迁移都用 CAS:影响行数为 0 说明"状态已被别人改过",直接放弃(这就是幂等)
UPDATE orders SET status = 'STOCK_DEDUCTED', updated_at = NOW(3)
WHERE id = ? AND status = 'CREATED';   -- RowsAffected == 0 → 已迁移过,返回成功即可

5.2 超时回查与"悬挂"的通用解法 ​

问题通用解法
中间态卡住(如订单停在 CREATED 超过 15 分钟)定时任务扫描中间态:WHERE status IN ('CREATED','STOCK_DEDUCTED') AND updated_at < NOW() - INTERVAL 15 MINUTE;主动向对端查询真实状态,再决定"补推进"还是"补偿"
中间态堆积(扫描出 10 万条)说明下游持续故障 → 先告警止损(阈值:单次扫描 > 1000 条 或 最老中间态 > 30 分钟),不要指望任务自己扫完
悬挂(迟到的 Try / 迟到的消息)三件套:① 先落状态记录再执行动作(write-ahead intent:先写"我要做这件事"再去做)② 唯一键让迟到者无法插入 ③ 状态机单向迁移让迟到者发现"状态已终态"而拒绝执行
超时时间怎么定业务可接受的最长等待 —— 支付回调超时取 15 分钟(覆盖用户支付全流程),库存扣减超时取 30 秒(内部 RPC P99 × 10 倍);超时值必须写进配置,不能散落在代码里。回查本身会重复执行(可能多实例并发),必须用 CAS / 唯一键保证只生效一次

核心心法:「不要问『怎么回滚』,要问『现在这个状态,我下一步该做什么』」——只要每一步都能从当前持久化状态重新推导出下一步,链路就永远不会卡死,也就不需要分布式事务的"原子回滚"。


六、对账系统设计:最终一致的最后一道防线 ​

6.1 T+1 全量对账 vs 准实时对账 ​

维度T+1 全量对账准实时对账(每 5 分钟)
数据源对账文件(第三方/银行/渠道下发)+ 内部账(DB 流水表)双方接口查询或增量宽表
覆盖100% 全量,能发现任意长尾差异只覆盖最近窗口(如近 30 分钟变更)
时效 / 成本差异最早 T+1 才发现;低(离线跑,可慢慢跑)差异 5 分钟内发现并修复;高(要限速、避免打爆在线库)
关系准实时对账不能替代 T+1 全量对账:前者抓不到"从未进入增量窗口"的脏数据两者都要有,准实时先跑,T+1 做终审

6.2 差异分类与处置 ​

类型名称成因处置时限
A单边账我方有、对方无(消息丢了/补偿多发);对方有我方无(回调丢了)自动补偿:我方多 → 发起冲正;我方少 → 补录流水(必须带人工审批标记)5 分钟自动,超阈值转工单
B金额不符精度/币种/手续费计算差异、部分退款禁止自动修复(金额差异可能是系统性 bug)→ 立即告警 + 人工核查立即
C状态不符状态机漂移(我方 PAID、对方 CLOSED)以权威方(通常是渠道/资金方)为准推进状态机;记录原因15 分钟
D时间不符时区、updated_at 语义(创建时间当成更新时间)修工具/修语义,不是数据问题排查即可

6.3 工程要点 ​

要点做法
解析与修复都必须幂等对账文件按 (biz_date, channel) 唯一键重跑覆盖而不是追加;修复动作按 (batch_id, diff_id) 去重,允许整批重跑且无副作用
diff 结果落库 + 可重放每次对账生成一个 reconcile_batch,差异明细挂在其下,可追溯、可重放
自动补偿上限单批自动补偿 ≤ 100 条且单条金额 ≤ 100 元,超过即 暂停自动补偿 + 人工工单(防"自动补偿掩盖系统性 bug")
对账任务自身的监控 + 工单闭环心跳 + 最近成功时间("对账任务没跑"是最危险的故障);差异 → 工单 → 处理 → 回报状态到对账系统(否则下次对账又报同一条,形成噪声)

七、故障与一致性边界 ​

故障现象影响处置一致性边界
补偿失败Cancel/反向操作报错或超时;TCC Confirm 无法确认业务停在中间态 / 资源冻结但不生效重试(退避)+ 死信 + 对账兜底;重试超限 → 告警 + 人工。Confirm 失败不能回滚(Try 已对外承诺),只能重试 + 对账补偿失败 = 链路卡在中间态,业务侧要能容忍(展示"处理中"),不能当成功
消息重复消费端收到同一消息多次重复扣款/重复发货唯一键 + 状态机 CAS,重复执行返回成功(幂等不是"尽量避免重复",而是"重复了也无害")幂等失效 = 资损;所以幂等键必须是业务唯一键而不是消息 ID
中间态堆积扫描出大量 CREATED / PENDING用户看到大量"处理中";资金/库存被长期占用阈值告警(> 1000 条)→ 定位下游 → 补推进或批量补偿堆积期间一致性未收敛;堆积本身要当成一次小故障来处理并复盘
对账任务挂了无异常日志,但差异在累积一致性的最后防线失效心跳告警 + 下次运行补跑全量窗口;差异累积 = 必须手工确认无资损对账停了多久,就意味着有多久"没人知道是否一致"
MQ 整体不可用消息发不出去下游全部停更本地消息表扫表重试(退避到 5min)+ 积压监控;业务主链路不受影响(写本地库 + 消息表在同事务里)窗口 = MQ 恢复时间;恢复后按序重放,靠幂等去重
事务回查无结论半消息回查 15 次仍失败消息被丢弃告警 + 对账任务补偿(这就是为什么回查必须能查本地事务状态表)该消息对应的业务可能已成功 → 靠对账发现

八、面试追问链 ​

  1. 「跨服务转账怎么做?」 → 第一层先否定题型:「先问能不能不做成跨服务——把付款方和收款方的账户放在同一个库,就是一个本地事务,这是财务系统的标准做法。」第二层如果确实要跨系统(比如跨行),那不是"分布式事务问题"而是"最终一致 + 对账问题":本地事务记流水 + 本地消息表发出(同库同事务)→ 对端幂等入账 → T+1 全量对账 + 准实时增量对账,差异分级(单边账/金额不符)自动补偿或人工工单;金额类差异绝不自动修复。
  2. 「TCC 的空回滚和悬挂是什么?」 → 空回滚:Try 没执行(丢包/超时未到达),Cancel 却到了,若无脑执行解冻就会凭空加钱;解法是 Cancel 先查事务控制表,查不到 Try 记录就判为空回滚,插入一条 CANCEL 状态记录留痕并直接返回成功。悬挂:Try 超时 → Cancel 先执行成功 → 迟到的 Try 又执行了,资源被预留但永远没人 Confirm,永久冻结;解法就是靠空回滚留下的那条 CANCEL 记录——Try 执行前先 INSERT 控制记录(唯一键),冲突即说明已被 Cancel,直接拒绝 Try。两者是同一个控制表的两个方向,缺一不可。
  3. 「为什么不用 XA?」 → 三个硬伤:① 同步阻塞——prepare 后到 commit 前参与者一直持行锁,热点行直接排队,并发一上来就锁超时;② 协调者单点——协调者挂了参与者不知道该提交还是回滚,资源被长期占用,要 DBA 手工 XA RECOVER;③ 性能——两轮 RTT + 两次 fsync,P99 从 10ms 涨到 100ms+。加上 MySQL 的具体坑(max_prepared_transactions 默认 0、连接断开导致事务悬挂、只读 XA 不能提交)。所以 XA 只用于离线低并发场景;在线链路用本地消息表/Saga 这类"非阻塞 + 可补偿"的方案。
  4. 「本地消息表的扫表间隔与积压怎么处理?」 → 间隔取 200ms~1s,并且做成自适应:积压越多扫得越勤(空扫时拉长间隔省 DB)。积压处理分三层:① 定位——是 MQ 挂了(看 MQ 健康度)还是下游消费太慢(看消费延迟,05 篇),还是扫表任务自己挂了(心跳告警);② 止损——加大 relay 并发(CAS 抢占天然支持多实例)、提高批大小到 500~1000、必要时临时直连下游;③ 治理——status=2 的记录定期归档(表膨胀正是扫表变慢的常见根因,索引 (status, next_retry_at) 必须建)。监控最重要的是**"最老待发送消息的年龄"**,不是"任务在不在跑"。
  5. 「最终一致到底多久才能一致?」 → 必须给分层的数字,而不是笼统的"最终会一致":正常路径——本地事务提交后 200ms~1s 被 relay 扫出发出,消费端处理 10~100ms → P99 在 2 秒内;MQ 抖动/下游故障——退避重试到 5 分钟一次,恢复后 5 分钟内追平;重试耗尽(超过 10 次)——进死信,靠对账收口 → 准实时对账 5 分钟、T+1 全量对账兜底。完整答案是:"P99 秒级,异常情况 5 分钟内,最坏情况靠 T+1 对账修复——这几个数字要能分别报出来,并说明每一档的触发条件。"

九、自测清单 ​

  • [ ] 能按数据分级(P0/P1)判断"要不要分布式事务",并说出为什么 P0 也应尽量收敛到单库
  • [ ] 能说出至少三条"用架构手段避免分布式事务"的具体做法(同库同事务/唯一约束/状态机)
  • [ ] 能画出 2PC 时序图,并说出三个致命缺陷 + MySQL XA 的两个具体坑
  • [ ] 能讲清 TCC 的空回滚与悬挂,并写出事务控制表的关键字段与状态迁移
  • [ ] 能手写本地消息表(建表 SQL + 同事务写入 + 扫表重试 + 消费幂等),并说出扫表间隔/批大小/退避/清理参数
  • [ ] 能在选型矩阵里给「跨库单服务 / 跨服务可补偿 / 资金强一致 / 外部系统」四类场景各选一个方案并说明理由
  • [ ] 能背出"补偿 + 幂等 + 对账"三件套,并说清缺了任何一件会发生什么
  • [ ] 能设计一个对账系统:T+1 与准实时分工、差异分类 A/B/C/D 的处置、自动补偿上限与工单闭环

下一篇:10 etcd 与 Raft 选主租约落地 —— 补偿任务、对账任务、扫表任务都需要"同一时刻只有一个实例在跑",这就轮到 etcd 的租约与选主上场了。

持续学习,持续构建。