数据一致性与缓存同步 — 实战专题

数据一致性与缓存同步 — 实战专题

为什么单独成文: 前面 01~09 是按技术栈划分的“知识文档”,但生产环境真正的难题是跨组件的一致性问题——事务、消息、缓存三个东西搅在一起,任何一个环节出问题都会导致数据不一致,而且这类问题难复现、难定位、影响面大。

本文件的定位: 从“问题场景”出发,而不是从“技术组件”出发。每一节都是:真实场景 → 不一致是怎么产生的(含时序图)→ 解决方案(多种)→ 完整可运行代码 → 选型建议。

你简历的强相关点:

  • 项目三(零碳能源云):Caffeine L1 + Redis L2 多级缓存、大屏接口 200ms
  • 项目四(社区平台):RocketMQ 事务消息、幂等键(userId+activityId+date)、Redis + Lua 预减库存
  • 项目一(资产托管):资金/证券双重交收、头寸预增减与实入账匹配、事中监督
  • 项目三:SkyWalking + Prometheus 可观测性(一致性问题的排查手段)

📖 本文档名词速查(看到不认识的缩写,先查这里)

完整的白话解释、生活类比、面试话术见 00-名词速查手册.md。 下面只列本文档用到的名词,按出现频率排序。

缩写 / 术语 英文全称 一句话说明 出处
TTL Time To Live 数据的“保质期”,到期自动删除 第3章
TCC Try-Confirm-Cancel 业务层面的两阶段:先 Try 冻结资源,Confirm 真正扣,Cancel 释放 第1章
MQ Message Queue 消息队列,存消息的“中转站”,让两个服务不必同时在线 第2章
2PC Two-Phase Commit 两阶段提交:先举手投票,再统一行动 第1章
UNKNOWN Unknown State 三态设计里的“不知道成功了没”——最危险的状态,不能盲目重试 第1章
Seata Simple Extensible Autonomous Transaction Architecture 阿里开源的一站式分布式事务框架,支持 AT/TCC/Saga/XA 第1章
Caffeine Caffeine Java 里性能最好的本地缓存库(Spring 默认用它) 第3章
RocketMQ RocketMQ 阿里开源的消息队列,事务消息是它的强项 第2章
AT Automatic Transaction Seata 的无侵入模式:自动帮你生成反向 SQL,你只写业务代码 第1章
Saga Saga(长事务模式) 把长流程拆成多步,每步配一个“反向操作”,失败了就一步步往回退 第1章
Broker Broker(消息服务器) MQ 的服务端本体,负责收、存、发消息 第2章
XA eXtended Architecture X/Open 组织定的分布式事务规范,2PC 是它的实现方式 第1章
binlog Binary Log(二进制日志) MySQL Server 层的日志,用于主从复制和数据恢复 第5章
Canal Canal 阿里开源工具,伪装成 MySQL 从库,订阅 binlog 感知数据变更 第3章
QPS Queries Per Second 每秒请求数(系统能扛多少流量) 第8章
Kafka Kafka 最初为日志设计的消息队列,吞吐量极高 第2章
JSON JavaScript Object Notation 轻量级数据交换格式({} 和 []) 第13章
Cache Aside Cache Aside Pattern 旁路缓存:读时先查缓存,写时先更库再删缓存 第3章
RT Response Time 响应时间,一个请求多久返回 第8章
LRU Least Recently Used 淘汰最久没被用过的数据 第3章
offset offset(偏移量) 消费者“读到哪一条了”的位置标记 第2章
ACK ACKnowledgement 消费者处理完后的“确认回执” 第2章
Bean Bean(豆子) Spring 容器管理的对象,Spring 里的一切都是 Bean 第6章
3PC Three-Phase Commit 三阶段提交:2PC 的改良版,多了个“预提交”,想解决阻塞问题 第1章
CAS Compare And Swap 无锁原子操作:“如果是 A,就改成 B” 第4章
Producer Producer(生产者) 发消息的一方 第2章
UUID Universally Unique Identifier 通用唯一标识符,128 位随机字符串 第13章
Topic Topic(主题) 消息的分类,类似“频道” 第2章
W-TinyLFU Window Tiny Least Frequently Used Caffeine 用的算法,LRU 和 LFU 的混合增强版 第3章
CAP Consistency, Availability, Partition tolerance 分布式系统的“三选二”:网络一定不可靠,所以只能在“一致”和“可用”里挑一个 第1章
💡 为什么要有这张表?

技术文档最大的阅读障碍不是原理难,而是缩写不认识 —— 看到“2PC 在数据库层加锁” 这句话,如果不知道 2PC 是什么,整段就废了。

这张表保证你在任何位置遇到不认识的词,都能当场查到,不用跳出文档。 想知道“为什么是这样”“面试怎么答”,再点进手册看完整版。


目录


第一章:一致性问题全景

1.1 什么是一致性问题?

一句话定义: 同一份数据在不同地方(数据库、缓存、消息队列、其他服务)的副本,在某个时间点上值不一样,或者本该同时成功/失败的操作只成功了一部分。

同一个业务事实:用户下单成功,库存扣减 1

   数据库           缓存            消息队列          下游服务
     │               │                │                │
   ✅ 已扣减       ❌ 还是旧值      ⚠️ 消息丢失      ⚠️ 没收到通知
     │               │                │                │
     └───────────────┴────────────────┴────────────────┘
                          ↓
                  四处数据不一致

1.2 不一致的三大根源(记住这个分类)

┌─────────────────────────────────────────────────────────┐
│ 根源①:操作的原子性被破坏                                  │
│ ────────────────────────────────────────────────────    │
│   多个操作应该"要么全成功、要么全失败",但只成功了一部分     │
│                                                          │
│   典型场景:                                              │
│   · 事务失效(catch 吞异常、多数据源、多线程)              │
│   · 跨库操作(两个数据库,本地事务管不了另一个库)           │
│   · 跨服务操作(A 服务成功,B 服务失败)                    │
│   · 数据库成功但缓存删除失败                               │
│   · 数据库成功但消息发送失败                               │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ 根源②:并发时序问题(竞态条件)                             │
│ ────────────────────────────────────────────────────    │
│   单个操作都是对的,但多个操作交错执行导致结果错误           │
│                                                          │
│   典型场景:                                              │
│   · 缓存与 DB 双写的时序错乱(先删缓存后改DB 的经典问题)    │
│   · 并发扣减库存(超卖)                                   │
│   · 消息乱序(先收到"删除",后收到"创建")                  │
│   · 本地缓存与分布式缓存更新顺序错乱                        │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ 根源③:分布式系统的固有延迟与失败                           │
│ ────────────────────────────────────────────────────    │
│   网络延迟、节点故障、消息重复投递导致的中间状态             │
│                                                          │
│   典型场景:                                              │
│   · 主从延迟(写完主库读从库,读到旧值)                    │
│   · 多副本缓存同步延迟(Redis 更新了,本地缓存没收到通知)   │
│   · 网络分区导致部分节点更新失败                            │
│   · 消息重复投递(网络重试)                               │
└─────────────────────────────────────────────────────────┘

排查一致性问题时,先判断属于哪一类——这决定了排查方向:

根源 典型证据 排查方向
① 原子性被破坏 部分操作成功、部分失败 查事务配置、查异常处理、查跨库/跨服务边界
② 并发时序 高并发下偶发,低并发正常 画时序图、加锁或串行化、压测复现
③ 分布式延迟/失败 有规律延迟、节点故障时出现 查监控、查网络、加重试和补偿

1.3 CAP 与 BASE:一致性的理论边界

┌──────────────────────────────────────────────────────┐
│                    CAP 定理                           │
│  分布式系统中,三者最多同时满足两个:                    │
│                                                       │
│  C (Consistency)      一致性:所有节点同一时刻数据相同   │
│  A (Availability)     可用性:每个请求都能得到响应       │
│  P (Partition tolerance) 分区容错:网络分区时仍能工作    │
│                                                       │
│  ★ P 是分布式系统的必选项(网络一定会出问题)            │
│    → 实际是在 C 和 A 之间取舍                          │
│                                                       │
│  CP:牺牲可用性保证一致性(Zookeeper、etcd、Seata AT)  │
│  AP:牺牲一致性保证可用性(Redis 主从、Eureka、大部分    │
│       互联网业务——接受短暂不一致,最终一致)             │
└──────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────┐
│              BASE 理论(CAP 的工程落地)                │
│                                                       │
│  BA (Basically Available)  基本可用                    │
│    允许损失部分可用性(降级、限流、响应变慢)             │
│                                                       │
│  S  (Soft state)           软状态                      │
│    允许系统存在中间状态(如"支付中")                    │
│                                                       │
│  E  (Eventually consistent) 最终一致性                  │
│    经过一段时间后,数据最终会达到一致                    │
│                                                       │
│  ★ 实践意义:不需要追求实时强一致,                      │
│    而是通过「补偿 + 重试 + 对账」达到最终一致             │
└──────────────────────────────────────────────────────┘

面试话术: “CAP 理论告诉我们,分布式系统里 P 是必须的,所以实际是在 C 和 A 之间选。互联网业务基本都选 AP——接受短暂不一致,通过最终一致性来解决。BASE 就是 CAP 的工程落地:允许软状态和中间态,通过重试、补偿、对账最终达到一致。 我们资产托管系统里,资金扣减这种强一致场景用本地事务 + 行锁保证;而指令状态通知下游、大屏数据展示这类允许短暂延迟的,就用消息队列做最终一致。”

1.4 一致性级别与适用场景(选型总表)

一致性级别 实现方式 性能 复杂度 你的项目场景
强一致 本地事务、分布式锁、Seata AT 低 中 资金扣减、头寸变更、指令状态流转
弱一致 主从异步复制、缓存异步更新 高 低 大屏展示数据、设备实时指标
最终一致 可靠消息、事务消息、定时任务补偿 高 中 跨服务通知、缓存同步、积分发放
单调读一致 会话粘滞、读己之所写 中 低 用户个人信息(保证自己看到的是最新的)

决策树:

需要保证一致性吗?
   │
   ├─ 不需要(展示类数据)→ 直接异步/缓存,接受延迟
   │
   └─ 需要
       │
       ├─ 同一个数据库?
       │    └─ YES → 本地事务(@Transactional)+ 行锁   ★ 最简单
       │
       ├─ 跨库但同服务?
       │    └─ 多数据源事务管理器 / Seata / 最终一致+补偿
       │
       └─ 跨服务?
            ├─ 必须强一致?→ Seata AT(性能代价大,慎用)
            ├─ 允许最终一致?→ 可靠消息最终一致性(RocketMQ 事务消息)★ 推荐
            └─ 需要中间态可控?→ TCC(Try-Confirm-Cancel)

💡 第一次看到 TCC / Seata AT 这些词? 别急,下面 1.5 名词先行课 就是专门为你准备的:用大白话讲透 2PC、3PC、XA、TCC、Saga、Seata AT 这六个方案分别是什么、有什么区别、什么时候用。建议先读完 1.5 再看第二章。


1.5 名词先行课:六种分布式事务方案,白话讲透

这一节是给“第一次接触”的人写的。 后面的 2.2 / 2.3 / 2.4 会反复出现 2PC、XA、TCC、Saga、AT 这些词。 如果你从没搞清过它们到底是什么关系,先读完这一节再往下,否则第二章会有大量“每个字都认识、连起来不知道在说什么”的时刻。

读法建议: 每个方案都按「一句话定义 → 生活类比 → 时序图 → 致命缺点 → 什么时候用」五步走。 面试时不需要背实现细节,但类比和缺点必须能脱口而出——这才是区分度。


1.5.0 先看全景:这六个词到底是什么关系

很多人学完还是懵,是因为把这六个词当成并列的六个方案了。它们其实分属三个层次:

┌────────────────────────────────────────────────────────────────────┐
│                        先分清三个层次                                │
└────────────────────────────────────────────────────────────────────┘

  【层次一:理论协议】—— 只是"思想",没有代码
        │
        ├── 2PC(Two-Phase Commit,两阶段提交)
        │      └─ 最经典的强一致协议,两个阶段:先投票、再提交
        │
        └── 3PC(Three-Phase Commit,三阶段提交)
               └─ 2PC 的改良版,加了超时机制,但治标不治本,工业界基本不用

  【层次二:工业规范】—— 把协议落地成接口标准
        │
        └── XA(eXtended Architecture,分布式事务规范)
               └─ X/Open 组织定的标准,把 2PC 定义成了数据库厂商必须实现的接口
               └─ 在 Java 世界里就是 JTA,常见的实现是 Atomikos、Narayana
               └─ ★ 关系记牢:XA 是 2PC 的"工业标准实现",2PC 是 XA 的"理论基础"

  【层次三:业务层方案】—— 不用数据库锁,改用业务设计
        │
        ├── TCC(Try-Confirm-Cancel)
        │      └─ 在【业务层】预留资源,不用数据库锁
        │
        ├── Saga
        │      └─ 长事务,每个步骤写一个"反向补偿"操作
        │
        └── Seata AT(Automatic Transaction,自动事务)
               └─ 阿里 Seata 框架的"无侵入"模式,靠 undo_log 自动生成反向 SQL

┌────────────────────────────────────────────────────────────────────┐
│  ★ 一句话记住它们的关系:                                            │
│                                                                     │
│     2PC / 3PC  = 理论协议(怎么协调的"想法")                        │
│     XA / JTA   = 2PC 的工业标准实现(数据库层加锁,强一致但慢)        │
│     TCC/Saga/AT = 绕开数据库锁的"业务层方案"(最终一致,性能好)       │
│                                                                     │
│  所以面试官问"TCC 和 2PC 的区别",                                   │
│  本质上是在问"业务层预留"和"数据库层加锁"的区别!                     │
└────────────────────────────────────────────────────────────────────┘

为什么会有这么多方案?一个核心矛盾贯穿始终:

                    一致性(数据绝对不能错)
                         ▲
                         │
                         │  ★ 这个矛盾无法消除,只能权衡
                         │
                         ▼
                    性能/可用性(不能太慢、不能卡死)

  2PC/XA  → 站在左边(强一致),代价是慢 + 可能阻塞
  TCC/Saga → 站在右边(性能好),代价是要写大量业务代码
  Seata AT → 想两边都要,靠框架自动生成补偿 SQL 来省代码

1.5.1 打地基:三个必须先懂的词

后面每个方案都会用到这三个词,先看懂它们,后面就顺了。

① 协调者(Coordinator)与参与者(Participant)

┌─────────────────────────────────────────────────────────┐
│ 类比:公司团建订餐                                        │
│                                                          │
│   协调者 = 行政小姐姐(负责问每个人、最终拍板)            │
│   参与者 = 每个同事(负责回答"我要/我不要")               │
└─────────────────────────────────────────────────────────┘

         ┌──────────────┐
         │   协调者      │  ← 独立的一方,负责统筹全局
         │ (Coordinator)│     在代码里通常是个单独的"事务管理器"服务
         └──────┬───────┘
                │
      ┌─────────┼─────────┐
      ▼         ▼         ▼
  ┌───────┐ ┌───────┐ ┌───────┐
  │参与者A │ │参与者B │ │参与者C │  ← 真正干活的人
  │订单库  │ │库存库  │ │账户库  │     在代码里就是各个数据库/各个服务
  └───────┘ └───────┘ └───────┘

★ 关键点:协调者是"单点"。它宕机了,所有人都不知道该提交还是回滚
  → 这就是 2PC 最致命的问题,后面 1.5.2 会详细讲

② 预留资源(Reserve)

这是 TCC 的核心思想,也是它和 2PC 最大的区别。

┌─────────────────────────────────────────────────────────┐
│ 类比:订酒店房间                                          │
│                                                          │
│   ❌ 不预留(2PC 的做法):                                │
│      直接把房间卖给你,门锁上,别人订不了。                │
│      你不来的话,房间白空一晚上。                          │
│      → 对应数据库:行锁锁住,别的事务改不了,一直锁到提交   │
│                                                          │
│   ✅ 预留(TCC 的做法):                                  │
│      先"冻结"房间:别人能看到但订不了,系统显示"已预留"。  │
│      你来了 → 确认入住(Confirm)                          │
│      你不来 → 取消预订(Cancel),房间立刻放出来            │
│      → 对应业务:加一个"冻结金额/冻结库存"字段,            │
│                 而不是直接锁数据库行                        │
└─────────────────────────────────────────────────────────┘

  数据库里的样子:

    账户表 account
    ┌────┬──────────┬──────────┐
    │ id │ balance  │ frozen   │  ← frozen 就是"预留字段"
    ├────┼──────────┼──────────┤
    │  1 │  1000.00 │   200.00 │  ← 总余额1000,其中200被冻结
    └────┴──────────┴──────────┘
                       ▲
                       └─ 这200块:别人看得到但用不了,
                          不像行锁那样把整行锁死

   可用余额 = balance - frozen = 800

③ 补偿(Compensate)

┌─────────────────────────────────────────────────────────┐
│ 类比:转账转错了怎么办                                    │
│                                                          │
│   ❌ 回滚(Rollback):                                   │
│      像 Ctrl+Z,数据"从未发生过",数据库自己抹掉痕迹       │
│      ★ 只有【没提交】的事务才能回滚                        │
│                                                          │
│   ✅ 补偿(Compensate):                                 │
│      已经提交了、木已成舟 → 只能做一笔"反向操作"            │
│      转出去 100 → 再转回来 100(这笔反向转账本身会留痕)    │
│      ★ 补偿是【业务层面】的,数据库不认识它                 │
└─────────────────────────────────────────────────────────┘

  代码长这样:

    // 正向操作
    void transfer(Long from, Long to, BigDecimal amount) {
        accountMapper.deduct(from, amount);   // 扣钱
    }

    // 补偿操作(反向操作)—— 必须自己写!
    void compensateTransfer(Long from, Long to, BigDecimal amount) {
        accountMapper.add(from, amount);      // 加回去
        // 注意:这不是数据库回滚,是一笔新的 UPDATE,会留下流水
    }

★ 为什么有了 "回滚" 还需要 "补偿"?
  因为分布式事务里,每个服务都是【各自独立提交本地事务】的。
  服务A 提交完就提交了,数据库层面的 undo log 清了,没法 Ctrl+Z 了。
  只能靠业务代码"再做一笔反向交易"把它抵消掉。

三个词记牢了,我们开始看第一个方案。


1.5.2 2PC:两阶段提交(Two-Phase Commit)

一句话定义

先让所有参与者“表态能不能做”(投票阶段),全部同意后才让大家“真的做”(提交阶段)。 两个阶段之间,参与者的数据库行锁一直不释放。

生活类比:结婚领证

┌─────────────────────────────────────────────────────────┐
│ 类比:民政局办集体婚礼,5 对新人一起领证                  │
│                                                          │
│  【第一阶段:投票 / 准备阶段 Prepare】                     │
│   民政局工作人员(协调者)问每一对:                       │
│   "材料都带齐了吗?确定要结吗?"                           │
│     · 第1对:"齐了,愿意"  → 把材料压在工作人员那里        │
│     · 第2对:"齐了,愿意"  → 同上                          │
│     · 第3对:"我户口本忘带了" → 不 OK                      │
│     · 第4对、第5对:还在回答                               │
│                                                          │
│   ★ 关键:回答"愿意"之后,这对新人就【不能反悔也不能走】,  │
│     必须站在原地等工作人员宣布结果。                       │
│     这就是"资源被锁住"——从投票完到最终结果出来这段时间,   │
│     你被"冻结"了,啥也干不了。                             │
│                                                          │
│  【第二阶段:提交 / 回滚阶段 Commit / Rollback】            │
│   第3对没带材料 → 工作人员宣布:"今天这5对都不办了"         │
│   所有人解散,材料退回。                                   │
│                                                          │
│  ★ 如果是全部同意,工作人员才会宣布:"都通过,发证!"       │
└─────────────────────────────────────────────────────────┘

时序图

  协调者              参与者A(订单库)        参与者B(库存库)
    │
    │───── ① 开启全局事务,生成 XID ────────────────────────┐
    │                                                       │
    │  ╔═══════════ 第一阶段:Prepare(投票)═══════════╗    │
    │  ║                                                ║    │
    ├────► Prepare "能提交吗?" ────►│                    │    │
    │                                 │ 执行 SQL          │    │
    │                                 │ 写 undo/redo log  │    │
    │                                 │ ★ 加行锁,不提交! │    │
    │◄──── Yes(已锁定,待命)────────┤                    │    │
    │                                                      │    │
    ├────► Prepare "能提交吗?" ───────────────►│           │    │
    │                                          │ 执行 SQL  │    │
    │                                          │ ★ 加行锁  │    │
    │◄──── Yes(已锁定,待命)─────────────────┤           │    │
    │  ╚════════════════════════════════════════════════╝    │
    │                                                         │
    │  ── 协调者汇总:A=Yes,B=Yes → 全票通过,决定提交 ──     │
    │                                                         │
    │  ╔═══════════ 第二阶段:Commit(提交)══════════════╗    │
    │  ║                                                 ║    │
    ├────► Commit "提交!" ──────────►│                   │    │
    │                                  │ 提交本地事务      │    │
    │                                  │ 释放行锁          │    │
    │◄──── Ack ───────────────────────┤                   │    │
    │                                                      │    │
    ├────► Commit "提交!" ────────────────────►│          │    │
    │                                            │ 提交     │    │
    │◄──── Ack ─────────────────────────────────┤          │    │
    │  ╚════════════════════════════════════════════════╝    │
    │                                                         │
    │───── 全局事务结束 ─────────────────────────────────────┘


  ⏱️ 注意看:从"第一阶段投票完成"到"第二阶段提交完成"之间的这段
     【灰色时间窗】,A 和 B 的行锁【一直被占用】。

     如果网络慢、协调者卡了,这个时间窗可能是几百毫秒甚至几秒。
     这几秒里,任何要改这两行数据的其他请求,全部阻塞等待。
     → 这就是"2PC 性能差"的根本原因。

2PC 的四个致命缺点(面试必答)

┌─────────────────────────────────────────────────────────────────┐
│ 缺点①:阻塞(Blocking)—— 最致命                                 │
│ ───────────────────────────────────────────────────────────────  │
│   协调者在【发完 Prepare、还没发 Commit】的时候宕机了。            │
│                                                                  │
│   协调者(已死)              参与者A              参与者B         │
│        ✗                    │已锁定,等待│      │已锁定,等待│    │
│                              │            │      │            │    │
│                         "协调者让我等着,可它人呢?"               │
│                         "我要不要自己提交?"                       │
│                         "万一B那边投的是No呢?我提交了就错了"      │
│                                                                  │
│   → 参与者【只能一直等】,行锁【一直不释放】                       │
│   → 这个库的相关数据【彻底不可用】,直到协调者恢复                 │
│   → 生产环境:几千个请求全部卡死,雪崩                             │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ 缺点②:协调者单点故障(SPOF)                                     │
│ ───────────────────────────────────────────────────────────────  │
│   整个事务的命运系于协调者一身。                                  │
│   协调者没有"备份",它的决策结果(提交还是回滚)                   │
│   如果没写日志就宕机,那这个信息【永久丢失】,                     │
│   参与者永远等不到答案。                                           │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ 缺点③:数据不一致(第二阶段部分失败)                              │
│ ───────────────────────────────────────────────────────────────  │
│   协调者发 Commit 给 A 成功,发给 B 时网络断了。                   │
│                                                                  │
│      协调者 ──Commit──► A ✅ 提交了                                │
│              ──Commit──✗ B ❌ 没收到(网络故障)                   │
│                                                                  │
│   → A 提交了,B 没提交 → 数据不一致!2PC 【无法解决】              │
│   → 只能靠人工介入或事后对账补偿                                   │
│                                                                  │
│   ★ 这是面试的高频追问点:                                         │
│     面试官:2PC 能保证强一致吗?                                   │
│     你:理论上能,但有边界。第二阶段如果部分参与者没收到 Commit,    │
│         依然会不一致。所以生产上 2PC 也要配对账兜底。               │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ 缺点④:性能差(锁持有时间长)                                      │
│ ───────────────────────────────────────────────────────────────  │
│   锁持有时间 = 两阶段全程 + 网络往返 × 2                            │
│                                                                  │
│   对比:本地事务的锁只持有"执行 SQL"那几毫秒                        │
│         2PC 的锁要持有到"所有节点都投完票 + 收到提交指令"           │
│                                                                  │
│   → 实测:同样的操作,2PC 的 QPS 可能只有本地事务的 1/10           │
└─────────────────────────────────────────────────────────────────┘

2PC 什么时候用?

✅ 用:
   · 传统金融核心系统(银行账务),强一致是刚需,性能可以堆硬件
   · 单体内多数据源(JTA/Atomikos),数据量小、并发低
   · 同机房、网络极其稳定、参与者少(2~3 个)

❌ 不用:
   · 高并发互联网业务(会锁死)
   · 跨机房、跨地域(网络延迟放大阻塞问题)
   · 参与者多(任何一个慢都会拖垮全局)
   · 长流程业务(含人工审核,要等几十分钟 → 锁几十分钟 → 吞吐归零)

★ 你的项目:资产托管为什么不用 2PC?
  因为涉及"指令 + 头寸 + 流水"多库,且有人工审核环节,
  2PC 会把资源锁到人工审核结束(可能几小时),系统直接不可用。
  → 用的是"本地事务 + 补偿 + 对账"(见 2.2 方案①)

1.5.3 3PC:三阶段提交(了解即可,工业界基本不用)

一句话定义

给 2PC 加了一个“预提交”缓冲阶段 + 超时机制,试图解决阻塞问题。

生活类比:多了个“最后确认”的缓冲

┌─────────────────────────────────────────────────────────┐
│ 还是集体婚礼,但这次加了个"倒计时"规则:                   │
│                                                          │
│  阶段1 CanCommit:  "大家能来吗?"(只是问,不锁资源)      │
│  阶段2 PreCommit:  "那就这么定了,大家准备一下"            │
│                     (锁资源,但还没真正办)                │
│  阶段3 DoCommit:   "办!"(真正提交)                      │
│                                                          │
│  ★ 关键改进:参与者有【超时机制】                          │
│    如果卡在阶段3等不到协调者的指令,                        │
│    参与者【默认提交】(因为能走到阶段3,说明阶段1大家       │
│    都同意了,大概率是成功的)                               │
│                                                          │
│    → 不会再像 2PC 那样"永远等待",解决了阻塞问题            │
└─────────────────────────────────────────────────────────┘

时序图

  协调者                                       参与者
    │
    │ ╔═════ 阶段1:CanCommit(询问,不锁资源)═════╗
    │ ║                                             ║
    ├─► CanCommit "能做吗?" ─────────────►│        │
    │                                       │只检查 │
    │                                       │不锁! │
    │◄─ Yes ───────────────────────────────┤        │
    │ ╚═════════════════════════════════════════════╝
    │
    │ ╔═════ 阶段2:PreCommit(预提交,锁资源)═════╗
    │ ║                                             ║
    ├─► PreCommit "准备提交" ─────────────►│        │
    │                                       │执行SQL│
    │                                       │★加锁 │
    │◄─ Ack "已就绪" ──────────────────────┤        │
    │ ╚═════════════════════════════════════════════╝
    │
    │ ╔═════ 阶段3:DoCommit(真正提交)════════════╗
    │ ║                                             ║
    ├─► DoCommit "提交!" ────────────────►│        │
    │                                       │ 提交  │
    │                                       │ 释放锁│
    │◄─ Ack ───────────────────────────────┤        │
    │ ╚═════════════════════════════════════════════╝

  ★ 如果协调者在阶段3宕机:
     参与者超时未收到 DoCommit → 【默认提交】
     (因为能走到阶段3,说明阶段1所有人都投了 Yes)
     → 不会无限等待 → 解决了 2PC 的阻塞问题

3PC 改进了什么,又带来了什么问题

对比项 2PC 3PC
阶段数 2(Prepare / Commit) 3(CanCommit / PreCommit / DoCommit)
阶段1 是否锁资源 锁(一开始就锁) 不锁(只是询问)
协调者宕机后 参与者永远阻塞 参与者超时后默认提交,不阻塞
网络分区时 可能数据不一致 依然可能不一致(见下)
通信轮次 2 次往返 3 次往返(更慢)
工业界使用 广泛(XA/JTA) 几乎没有

3PC 为什么没被采用:

┌─────────────────────────────────────────────────────────┐
│ ❶ 治标不治本:仍有不一致窗口                              │
│    协调者发 PreCommit 后宕机,参与者超时提交。             │
│    但如果协调者之前其实想的是"回滚"(因为收到了某个No),   │
│    那超时提交的参与者就【提交错了】→ 还是不一致。           │
│                                                          │
│ ❷ 更慢:多一轮网络往返,性能比 2PC 还差                    │
│                                                          │
│ ❸ 复杂度高:状态机更复杂,实现容易出 bug                   │
│                                                          │
│ ❹ 收益不明确:花这么大代价,一致性问题还没彻底解决          │
└─────────────────────────────────────────────────────────┘

★ 面试态度:
   "3PC 是对 2PC 的改良,通过引入 CanCommit 阶段和超时机制
    解决了阻塞问题,但因为多一轮通信导致性能更差,
    且网络分区下依然可能不一致,所以工业界基本没有落地实现。
    现在主流是用 TCC / Saga / 消息最终一致来绕开这个问题。"

1.5.4 XA 规范:2PC 的工业标准实现

一句话定义

XA 是 X/Open 组织制定的“分布式事务接口规范”,它把 2PC 变成了一套数据库厂商必须实现的标准 API。 在 Java 世界里,这套 API 叫 JTA(Java Transaction API),常见的实现框架是 Atomikos、Narayana。

三者关系(面试常考,务必分清)

┌─────────────────────────────────────────────────────────┐
│                                                          │
│   2PC  ──── 理论协议(思想)                              │
│    │                                                     │
│    │  落地成标准                                          │
│    ▼                                                     │
│   XA   ──── 规范(X/Open 组织定义,数据库厂商实现)         │
│    │        规定了 xa_start / xa_end / xa_prepare /       │
│    │        xa_commit / xa_rollback 这套接口              │
│    │                                                     │
│    │  Java 世界的封装                                     │
│    ▼                                                     │
│   JTA  ──── Java 的 API 规范(javax.transaction)          │
│    │                                                     │
│    │  具体实现                                            │
│    ▼                                                     │
│   Atomikos / Narayana / Bitronix ──── 真正的"协调者"实现    │
│                                                          │
└─────────────────────────────────────────────────────────┘

  类比:
    2PC  = "先投票再执行"这个民主思想
    XA   = 把这套思想写成了《议会法》,规定"举手/计票/宣布"的标准流程
    JTA  = 中文版的《议会法》
    Atomikos = 具体执行这套流程的"议长"(协调者)

XA 的六个核心接口

// Java 中通过 JTA 操作 XA 的底层接口(了解即可,实际开发不用手写)
public interface XAResource {

    // ① 开始一个分支事务(绑定全局事务 XID)
    void start(Xid xid, int flags) throws XAException;

    // ② 结束分支事务(SQL 执行完,但还没提交)
    void end(Xid xid, int flags) throws XAException;

    // ③ 【第一阶段】投票:我这边准备好了,可以提交
    int prepare(Xid xid) throws XAException;   // 返回 XA_OK 表示同意
                                               // ★ 调用后行锁一直持有

    // ④ 【第二阶段】提交
    void commit(Xid xid, boolean onePhase) throws XAException;

    // ⑤ 【第二阶段】回滚
    void rollback(Xid xid) throws XAException;

    // ⑥ 忘记(清理挂起的事务)
    void forget(Xid xid) throws XAException;
}

Spring Boot + Atomikos 的配置示例

/**
 * 多数据源 XA 事务配置(JTA + Atomikos)
 *
 * ★ 生产提示:这套配置性能很差,只在低频、强一致场景使用
 */
@Configuration
public class XaDataSourceConfig {

    /**
     * 数据源1:订单库(走 XA)
     */
    @Bean("orderDataSource")
    @Primary
    public DataSource orderDataSource() {
        // Atomikos 的 XA 数据源包装类
        AtomikosDataSourceBean ds = new AtomikosDataSourceBean();
        ds.setUniqueResourceName("orderDB");          // ★ 资源名必须唯一
        ds.setXaDataSourceClassName("com.mysql.cj.jdbc.MysqlXADataSource");
        ds.setXaProperties(orderProps());              // 真实数据源配置
        ds.setMinPoolSize(5);
        ds.setMaxPoolSize(20);
        ds.setBorrowConnectionTimeout(60);             // 借连接超时(秒)
        ds.setMaxLifetime(0);                          // 0 = 永不过期
        return ds;
    }

    /**
     * 数据源2:库存库(走 XA)
     */
    @Bean("stockDataSource")
    public DataSource stockDataSource() {
        AtomikosDataSourceBean ds = new AtomikosDataSourceBean();
        ds.setUniqueResourceName("stockDB");           // ★ 不能和上面重名
        ds.setXaDataSourceClassName("com.mysql.cj.jdbc.MysqlXADataSource");
        ds.setXaProperties(stockProps());
        ds.setMinPoolSize(5);
        ds.setMaxPoolSize(20);
        return ds;
    }

    /**
     * Atomikos 事务管理器 —— 这就是【协调者】
     */
    @Bean
    public UserTransactionManager userTransactionManager() {
        UserTransactionManager utm = new UserTransactionManager();
        utm.setForceShutdown(false);
        return utm;
    }

    /**
     * JTA 事务管理器 —— Spring 通过它来驱动 XA 两阶段提交
     */
    @Bean
    public JtaTransactionManager jtaTransactionManager(
            UserTransactionManager userTransactionManager) {
        JtaTransactionManager jta = new JtaTransactionManager();
        jta.setTransactionManager(userTransactionManager);
        jta.setUserTransaction(userTransactionManager);
        return jta;
    }
}

// 使用:和普通 @Transactional 一模一样,但底层自动走两阶段提交
@Service
public class OrderService {

    @Transactional   // ★ Spring 会优先使用 JtaTransactionManager
    public void createOrder(Long userId, Long skuId, int count) {
        orderMapper.insert(...);      // 订单库(参与者A)
        stockMapper.deduct(...);      // 库存库(参与者B)
        // 提交时:Atomikos 自动执行 prepare(A) → prepare(B) → commit(A) → commit(B)
    }
}

XA 的生产级坑(面试加分项)

┌─────────────────────────────────────────────────────────┐
│ 坑①:MySQL 5.7 之前 XA 有 bug                            │
│   主库 prepare 后宕机,从库可能丢失该分支事务              │
│   → 必须 MySQL 5.7.7+ 且开启 binlog                       │
│                                                          │
│ 坑②:事务挂起需要人工清理                                 │
│   协调者宕机后,参与者库里会残留 PREPARED 状态的事务        │
│   → 用 XA RECOVER 命令查看,手工 XA COMMIT/ROLLBACK        │
│   → 生产必须监控这个指标!                                 │
│                                                          │
│ 坑③:连接池配置不当会连接耗尽                             │
│   两阶段期间连接【不能归还】,容易打满连接池                │
│   → maxPoolSize 要调大,且要有连接泄漏监控                 │
│                                                          │
│ 坑④:和 ORM 框架的缓存冲突                                │
│   MyBatis 一级缓存可能让第二次查询不走数据库                │
│   → 建议关闭 statement 级缓存                              │
└─────────────────────────────────────────────────────────┘

1.5.5 TCC:Try-Confirm-Cancel(业务层预留,面试最高频)

一句话定义

把一次操作拆成三步:先“试探性预留”(Try),成功就“确认”(Confirm),失败就“取消预留”(Cancel)。 关键:预留是在【业务字段】上做的(比如 frozen 冻结字段),不是在数据库行锁上做的。

生活类比:订餐厅包间

┌─────────────────────────────────────────────────────────┐
│ 你要请 10 个朋友吃饭,打电话订"吉祥厅"包间                 │
│                                                          │
│  【Try 阶段:预留】                                        │
│   你:"老板,周六晚上吉祥厅能留吗?"                        │
│   老板:"能,我给你记上【已预留,王先生,周六18:00】"        │
│                                                          │
│   ★ 注意老板做的事:                                       │
│     · 没有把包间"锁死不营业"(餐厅还能接其他桌)            │
│     · 只是在小本本上记了一笔"这个包间周六晚上有人了"        │
│     · 别的客人来问,老板会说"周六晚了,周日下午行吗?"      │
│     → 这就是"业务层预留":用登记本(frozen字段)控制,       │
│       而不是把整个餐厅关门(数据库行锁)                    │
│                                                          │
│  【Confirm 阶段:确认】                                    │
│   周六你来了 → 老板划掉"预留",改成"已入座"                │
│   → 真正的资源扣减发生在这里                                │
│                                                          │
│  【Cancel 阶段:取消】                                     │
│   周六你没来也没打招呼 → 老板 18:30 划掉预留,包间放出      │
│   → 释放预留,资源重新可用                                  │
└─────────────────────────────────────────────────────────┘

  对应到数据库:

  ┌──────────────────────────────────────────────────────┐
  │ Try:    UPDATE account                               │
  │          SET frozen = frozen + 100   ← 只加冻结字段    │
  │          WHERE id = 1 AND balance - frozen >= 100;    │
  │          ★ 不改 balance!不加行锁!                     │
  │                                                       │
  │ Confirm:UPDATE account                               │
  │          SET balance = balance - 100,                 │
  │              frozen  = frozen  - 100                  │
  │          WHERE id = 1;                                │
  │          ★ 真正扣钱,同时清除冻结                       │
  │                                                       │
  │ Cancel: UPDATE account                               │
  │          SET frozen = frozen - 100                    │
  │          WHERE id = 1;                                │
  │          ★ 只清除冻结,balance 从未变过                 │
  └──────────────────────────────────────────────────────┘

时序图

  协调者             订单服务(Try)        库存服务(Try)        账户服务(Try)
    │
    │ ╔══════════ 阶段1:Try(预留资源)══════════╗
    │ ║                                           ║
    ├─► tryCreate() ────►│                        │              │
    │                     │ 插入订单,状态=待确认  │              │
    │◄── 成功 ────────────┤ ★ 本地事务已提交!    │              │
    │                                                            │
    ├─► tryDeduct() ─────────────────►│                          │
    │                                  │ frozen_stock + 1        │
    │◄── 成功 ─────────────────────────┤ ★ 本地事务已提交!      │
    │                                                            │
    ├─► tryFreeze() ──────────────────────────────────►│         │
    │                                                   │frozen+│
    │◄── 成功 ──────────────────────────────────────────┤       │
    │ ╚══════════════════════════════════════════════════════════╝
    │
    │ ★ 注意:三个服务在 Try 阶段结束时就【各自提交了本地事务】!
    │   这一点和 2PC 完全不同!2PC 在 Prepare 阶段【不提交】,锁着等
    │
    │  ── 汇总:全部成功 → 进入 Confirm ──
    │
    │ ╔══════════ 阶段2A:Confirm(确认,全部成功时)══════════╗
    │ ║                                                        ║
    ├─► confirmCreate() ─►│ 订单状态=已确认                     │
    ├─► confirmDeduct() ────────────────►│ 真正扣 stock          │
    ├─► confirmFreeze() ──────────────────────►│ 真正扣 balance   │
    │ ╚════════════════════════════════════════════════════════╝
    │
    │  ╔══════════ 阶段2B:Cancel(取消,任一失败时)══════════╗
    │  ║  (与 Confirm 二选一,不会都执行)                     ║
    ├─► cancelCreate() ──►│ 订单状态=已取消                     │
    ├─► cancelDeduct() ─────────────────►│ 清除 frozen_stock    │
    ├─► cancelFreeze() ───────────────────────►│ 清除 frozen      │
    │  ╚════════════════════════════════════════════════════════╝

TCC 和 2PC 的本质区别(面试必考,答这个就满分)

┌─────────────────────────────────────────────────────────────────┐
│                    2PC                    TCC                    │
├─────────────────────────────────────────────────────────────────┤
│ 锁在哪一层      │ 【数据库层】行锁      │ 【业务层】预留字段      │
│                 │ 数据库自己加的        │ 自己设计 frozen 字段    │
├─────────────────┼──────────────────────┼─────────────────────────┤
│ 资源锁定范围     │ 整行锁死,谁都改不了  │ 只冻结"额度",          │
│                 │                      │ 其他字段还能改           │
├─────────────────┼──────────────────────┼─────────────────────────┤
│ 一阶段后        │ 【不提交】,         │ 【已提交】本地事务       │
│ 是否提交        │ 锁着等二阶段          │ (不需要等)             │
├─────────────────┼──────────────────────┼─────────────────────────┤
│ 锁持有时间      │ 两阶段全程            │ 只有 Try 那一下          │
│                 │ (几百ms~几秒)       │ (几毫秒)               │
├─────────────────┼──────────────────────┼─────────────────────────┤
│ 性能            │ 低                   │ 高(10倍+差距)          │
├─────────────────┼──────────────────────┼─────────────────────────┤
│ 代码侵入        │ 无(框架做)          │ ★ 极高:                 │
│                 │                      │ 1个业务要写3个接口        │
│                 │                      │ 3个服务 = 9个接口         │
├─────────────────┼──────────────────────┼─────────────────────────┤
│ 一致性          │ 强一致(理论)        │ 最终一致(Confirm 期间)  │
└─────────────────┴──────────────────────┴─────────────────────────┘

★ 一句话总结:
  "2PC 是【让数据库别动,等我发话】;
   TCC 是【大家先各自提交,我只需要通知你们确认还是撤销】。
   所以 TCC 的性能远好于 2PC,代价是业务代码量翻三倍。"

TCC 的三个必须处理的问题(面试必答,★★★★★)

这是 TCC 最有区分度的考点,很多人背了 TCC 概念但答不出这三个。

┌─────────────────────────────────────────────────────────────────┐
│ ❗ 问题①:空回滚(Empty Rollback)                                │
│ ───────────────────────────────────────────────────────────────  │
│   现象:Try 根本没执行(网络丢包,请求没到),                     │
│         但协调者超时后直接发了 Cancel。                            │
│                                                                  │
│   后果:Cancel 去扣 frozen 字段,但这个预留根本不存在!            │
│         frozen = frozen - 100 → 变成【负数】!                    │
│         或者 UPDATE 影响 0 行,程序以为失败了,一直重试            │
│                                                                  │
│   解决:Cancel 前先查"这个 Try 执行过没有"                         │
│         用【事务状态表】记录,没有记录就直接返回成功(空回滚)      │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ ❗ 问题②:幂等(Idempotent)                                      │
│ ───────────────────────────────────────────────────────────────  │
│   现象:网络抖动,Confirm 发了两次(或 Cancel 发了两次)            │
│                                                                  │
│   后果:扣了两次钱 / 释放了两次预留                                │
│                                                                  │
│   解决:每个分支用【全局事务ID + 分支ID】做唯一键,                 │
│         执行前先查状态表,已执行过就直接返回                        │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ ❗ 问题③:悬挂(Hanging)                                          │
│ ───────────────────────────────────────────────────────────────  │
│   现象:Cancel 【比 Try 先到】!                                   │
│         网络拥堵 → 协调者超时发 Cancel → Cancel 先到达并执行成功    │
│         然后那个迟到的 Try 请求【才姗姗来迟】,又开始预留资源       │
│                                                                  │
│   后果:事务已经结束了,但这个预留【永远没人来释放】                │
│         100 块钱被冻结一辈子 → 这就是"悬挂"                        │
│                                                                  │
│   解决:Try 执行前先查状态表,                                     │
│         如果发现已经有 Cancel 记录 → 【拒绝执行 Try】              │
└─────────────────────────────────────────────────────────────────┘

  三个问题的统一解法:一张【分支事务记录表】

  CREATE TABLE tcc_branch_log (
      id            BIGINT PRIMARY KEY AUTO_INCREMENT,
      xid           VARCHAR(64)  NOT NULL COMMENT '全局事务ID',
      branch_id     VARCHAR(64)  NOT NULL COMMENT '分支事务ID',
      status        TINYINT      NOT NULL COMMENT '1=已Try 2=已Confirm 3=已Cancel',
      create_time   DATETIME     NOT NULL,
      update_time   DATETIME     NOT NULL,
      UNIQUE KEY uk_xid_branch (xid, branch_id)   -- ★ 唯一键做幂等
  ) ENGINE=InnoDB COMMENT='TCC分支事务状态表';

  判断逻辑(伪代码):

    public boolean try(Xid xid, BranchId bid, BizParam param) {
        // ★ 防悬挂:如果已经 Cancel 过了,拒绝执行 Try
        if (logDao.exists(xid, bid, STATUS_CANCELLED)) {
            log.warn("防悬挂:事务已Cancel,拒绝Try. xid={}", xid);
            return false;
        }
        // 幂等:已经 Try 过了,直接返回
        if (logDao.exists(xid, bid, STATUS_TRIED)) {
            return true;
        }
        // 正常执行业务逻辑 + 插入 Try 记录(同一个本地事务)
        transactionTemplate.execute(status -> {
            bizService.freeze(param);
            logDao.insert(xid, bid, STATUS_TRIED);
        });
        return true;
    }

    public boolean cancel(Xid xid, BranchId bid, BizParam param) {
        // ★ 空回滚:Try 都没执行过,直接记一条 Cancel 记录并返回成功
        if (!logDao.exists(xid, bid, STATUS_TRIED)) {
            transactionTemplate.execute(status -> {
                logDao.insert(xid, bid, STATUS_CANCELLED);  // ★ 必须记!防悬挂靠它
            });
            return true;   // 空回滚也算成功,不要报错
        }
        // 幂等:已经 Cancel 过了
        if (logDao.exists(xid, bid, STATUS_CANCELLED)) {
            return true;
        }
        // 正常执行取消逻辑
        transactionTemplate.execute(status -> {
            bizService.unfreeze(param);
            logDao.update(xid, bid, STATUS_CANCELLED);
        });
        return true;
    }

完整的 TCC 工程实现代码见 2.3 方案二(571 行起),那里有资产托管资金冻结与扣划的完整示例。


1.5.6 Saga:长事务的补偿模式

一句话定义

把一个长流程拆成 N 个独立的本地事务,每个步骤都【立刻提交】,如果某步失败,就按【相反顺序】依次调用前面每一步的“补偿操作”。

生活类比:出差报销流程

┌─────────────────────────────────────────────────────────┐
│ 你出差:订机票 → 订酒店 → 订会议室                         │
│                                                          │
│  步骤1:订机票 ✅ 已出票(已提交,钱已付)                  │
│  步骤2:订酒店 ✅ 已预订(已提交)                          │
│  步骤3:订会议室 ❌ 满了,订不到                            │
│                                                          │
│  → 触发补偿,【倒着来】:                                   │
│     补偿步骤2:退酒店(可能扣手续费)                       │
│     补偿步骤1:退机票(可能扣退票费)                       │
│                                                          │
│  ★ 关键认知:                                              │
│    "退机票" 不是 "没订过机票",而是【一笔新的交易】          │
│    航空公司的系统里,购票记录和退票记录【都在】              │
│    → 这就是"补偿"和"回滚"的区别(回顾 1.5.1)               │
└─────────────────────────────────────────────────────────┘

时序图(两种实现方式)

┌─────────────────────────────────────────────────────────────────┐
│ 方式A:编排式(Orchestration)—— 有个"总指挥"                    │
│         适合步骤多、有分支逻辑的场景                              │
└─────────────────────────────────────────────────────────────────┘

   协调器(Orchestrator)
        │
        ├─①─► 服务A:扣库存 ✅ ──► 记录
        │
        ├─②─► 服务B:扣余额 ✅ ──► 记录
        │
        ├─③─► 服务C:发券    ❌ 失败!
        │
        │     ╔═══ 触发补偿(倒序)═══╗
        ├─④──╫─► 服务B:补偿(退余额)
        │    ╚═════════════════════════
        ├─⑤───► 服务A:补偿(退库存)
        │
        └─► 整个 Saga 失败,返回用户"下单失败"

   优点:流程集中管理,能画流程图,好排查
   缺点:协调器逻辑重,是新的单点


┌─────────────────────────────────────────────────────────────────┐
│ 方式B:协同式(Choreography)—— 没有总指挥,靠消息驱动            │
│         适合步骤少(3~5步)、流程固定的场景                        │
└─────────────────────────────────────────────────────────────────┘

   服务A(库存)  ──"库存已扣"MQ──►  服务B(余额)
       ▲                              │
       │                              ├─ 扣余额 ✅
       │                              └──"余额已扣"MQ──► 服务C(发券)
       │                                                     │
       │                                                     └─ ❌ 失败
       │                                                        │
       │                          "发券失败"MQ  ◄────────────────┘
       │                              │
       │◄──"余额补偿"MQ───────────────┤
       │                              ▼
   退库存 ◄────────────────── 服务B 退余额

   优点:无单点,服务解耦
   缺点:★ 流程散落在各服务里,出问题根本看不出"卡在哪一步"
         (生产事故高发区,不推荐超过 5 步的流程用这种)

Saga vs TCC 怎么选

对比项 TCC Saga
是否有“预留”阶段 有(Try 先冻结) 无(直接真扣)
隔离性 好(中间态是“冻结”,别人看得到但用不了) 差(中间态是“已扣”,别人能看到脏数据)
补偿操作 Cancel(撤销预留,无副作用) 补偿交易(真金白银的反向操作,可能有损失)
业务侵入 3 个接口/服务 2 个接口/服务(正向 + 补偿)
适合流程长度 短(秒级) 长(分钟~天,含人工审核)
典型场景 下单、支付 审批流、订票、开户
真实代价 无(只是解冻) 退票手续费、退款时效
★ 一句话选型:
   "短流程、要求中间态不脏 → TCC;
    长流程、能接受中间态可见 + 补偿有代价 → Saga。"

★ 你的项目里的对应:
   资产托管的"指令审核流"(录入 → 复核 → 清算 → 划款)
   是典型的 Saga 场景 —— 含人工审核,可能跨天,
   中间态"指令已复核待清算"本身就是业务需要展示的状态,
   不需要隐藏 → 用 Saga,不用 TCC。

1.5.7 Seata AT:无侵入的自动补偿(阿里开源)

一句话定义

Seata AT 模式:你只写业务 SQL,Seata 自动帮你生成“反向 SQL”并存在 undo_log 表里,需要回滚时自动执行反向 SQL。 它追求的目标是:像 TCC 一样的性能,像本地事务一样的代码量。

生活类比:Word 的撤销记录

┌─────────────────────────────────────────────────────────┐
│ 你在 Word 里改文档:                                      │
│                                                          │
│   原文:"库存是 100"                                      │
│   你改成:"库存是 99"                                     │
│                                                          │
│   Word 自动在后台记一笔:                                 │
│       【撤销记录】把"库存是 99"改回"库存是 100"            │
│                                                          │
│   你按 Ctrl+Z → Word 自动把那句话改回去                    │
│                                                          │
│   ★ 你【没有手动写】"撤销操作",Word 自动生成的             │
│                                                          │
│   Seata AT 就是这个机制:                                 │
│     UPDATE stock SET count=99 WHERE id=1;                │
│     ↓ Seata 自动解析这条 SQL,生成反向 SQL:               │
│     UPDATE stock SET count=100 WHERE id=1;               │
│     ↓ 存进 undo_log 表                                    │
│     需要回滚时 → 自动执行这条反向 SQL                      │
└─────────────────────────────────────────────────────────┘

执行流程(两阶段,但和 2PC 有本质区别)

┌─────────────────────────────────────────────────────────────────┐
│ 阶段一:业务 SQL + undo_log 【在同一个本地事务里提交】             │
└─────────────────────────────────────────────────────────────────┘

  @GlobalTransactional  // ← 加这个注解就行
  public void purchase() {
      stockMapper.deduct();   // 由 Seata 代理的数据源执行
  }

  Seata 的 JDBC 代理在背后做了这些事:

   ① 解析 SQL,查询【修改前】的数据镜像(before image)
      SELECT count FROM stock WHERE id=1;   →  count = 100

   ② 执行业务 SQL
      UPDATE stock SET count=99 WHERE id=1;

   ③ 查询【修改后】的数据镜像(after image)
      SELECT count FROM stock WHERE id=1;   →  count = 99

   ④ 把 before/after 镜像序列化成 undo_log,插到 undo_log 表
      INSERT INTO undo_log (xid, branch_id, rollback_info, ...)
      VALUES ('192.168.1.1:8091:123456', 1,
              '{beforeImage: {count:100}, afterImage: {count:99}}');

   ⑤ 【提交本地事务】★ 注意:这里就提交了!不等二阶段!
      同时本地行锁释放

   ↓ 向 TC(Seata 服务器 / 事务协调者)汇报:"我这个分支成功了"


┌─────────────────────────────────────────────────────────────────┐
│ 阶段二:                                                          │
└─────────────────────────────────────────────────────────────────┘

   全部成功 → TC 通知各分支【异步删除 undo_log】
              (只是删日志,很快,然后整个事务结束)

   任一失败 → TC 通知各分支【回滚】:
              ① 取出 undo_log 里的 before image
              ② 生成反向 SQL:UPDATE stock SET count=100 WHERE id=1
              ③ ★ 执行前先【校验脏写】:
                 对比当前 count 是否还等于 after image 的 99
                 · 相等 → 没人动过,安全,执行回滚
                 · 不相等 → 有人改过了!→ 报错,需要人工介入
              ④ 执行反向 SQL,删除 undo_log

Seata AT 的三个关键问题

┌─────────────────────────────────────────────────────────┐
│ ❶ 它和 2PC 的区别?                                      │
│    长得像(都是两阶段),但本质不同:                      │
│    · 2PC:一阶段【不提交】,锁着等 → 阻塞                  │
│    · AT :一阶段【就提交了】,锁立刻释放 → 不阻塞          │
│    → AT 是"2PC 的外形 + TCC 的性能"                       │
│                                                          │
│ ❷ 脏写怎么防?                                            │
│    靠【全局锁】—— 注意不是数据库行锁                       │
│    · Seata 的 TC 维护一张全局锁表                          │
│      (lock_table:表名 + 主键 + xid)                     │
│    · 分支事务提交前要先向 TC 申请全局锁                    │
│    → 但注意:全局锁【挡不住】直接操作数据库的手工 SQL!     │
│      所以 AT 模式【不能和手工改库混用】                    │
│                                                          │
│ ❸ 隔离级别是什么?                                        │
│    默认是【读未提交】(能读到别的全局事务未提交的中间态)   │
│    想要读已提交,要加 @GlobalLock + SELECT FOR UPDATE      │
│    → 这是 AT 模式最容易被面试官抓住追问的点                 │
└─────────────────────────────────────────────────────────┘

Seata 三种模式对比

模式 全称 侵入性 原理 适用
AT Automatic Transaction 无(只加注解) undo_log 自动生成反向 SQL 同构数据库(都是 MySQL),最常用
TCC Try-Confirm-Cancel 高(写3个接口) 业务层预留 异构/非关系型资源、高性能要求
Saga Saga 中(写补偿接口) 状态机 + 补偿服务 长流程、遗留系统接入

Seata 的部署:需要额外部署一个 TC(Transaction Coordinator,事务协调者) 服务端,这是它的运维成本。TC 本身要做高可用(DB 模式 + 多实例注册到注册中心)。这也是很多人选择“零中间件自研方案”的原因 —— 见 2.5.3。


1.5.8 六种方案一图流:选型总表

┌──────────────────────────────────────────────────────────────────────────┐
│                     分 布 式 事 务 方 案 全 景 对 比                        │
├────────────┬────────┬────────┬────────┬──────────┬─────────────────────┤
│   方案     │ 一致性 │  性能  │ 侵入性 │ 需要额外 │ 典型场景             │
│            │        │        │        │ 中间件? │                     │
├────────────┼────────┼────────┼────────┼──────────┼─────────────────────┤
│ 2PC / XA   │ 强一致 │  ✗ 低  │  无    │ JTA实现  │ 传统金融核心、       │
│ (JTA)      │        │ 阻塞   │(框架)│(Atomikos)│ 单体内多数据源       │
├────────────┼────────┼────────┼────────┼──────────┼─────────────────────┤
│ 3PC        │ 强一致 │ ✗✗更低 │  无    │ 无实现   │ 【工业界基本不用】   │
├────────────┼────────┼────────┼────────┼──────────┼─────────────────────┤
│ Seata AT   │ 最终   │  ✓ 中  │ ★ 无   │ ★ 需要TC │ 同构MySQL微服务,    │
│            │ (近似强)│       │(注解)│ 服务端   │ 想要省代码           │
├────────────┼────────┼────────┼────────┼──────────┼─────────────────────┤
│ TCC        │ 最终   │ ✓✓ 高  │ ✗ 极高 │ 需要TC   │ 短流程、高性能、     │
│            │        │        │ 3接口  │ 或自研   │ 有预留语义的业务     │
├────────────┼────────┼────────┼────────┼──────────┼─────────────────────┤
│ Saga       │ 最终   │ ✓✓ 高  │  中    │ 状态机   │ 长流程、含人工审核、 │
│            │        │        │ 2接口  │ 引擎     │ 跨天业务             │
├────────────┼────────┼────────┼────────┼──────────┼─────────────────────┤
│ 可靠消息   │ 最终   │ ✓✓✓最高│  中    │ 消息队列 │ ★ 互联网最主流       │
│ 最终一致   │        │        │        │(RocketMQ)│ 下单、异步通知       │
├────────────┼────────┼────────┼────────┼──────────┼─────────────────────┤
│ 最大努力   │ 最终   │ ✓✓✓最高│  低    │ 定时+对账│ 商户通知、           │
│ 通知       │ (最弱) │        │        │          │ 非核心旁路           │
└────────────┴────────┴────────┴────────┴──────────┴─────────────────────┘

一句话选型口诀(背下来,面试直接说)

┌─────────────────────────────────────────────────────────────┐
│                                                              │
│   ★ 能用业务设计避免,就不要用分布式事务(最优解)             │
│                                                              │
│   ★ 非要跨服务:                                              │
│       · 能异步 → 可靠消息最终一致(首选,90% 场景)           │
│       · 要强一致 + 同构库 + 想省代码 → Seata AT              │
│       · 要强一致 + 高性能 + 有预留语义 → TCC                 │
│       · 长流程含人工 → Saga                                   │
│       · 传统金融 + 低频 + 能堆硬件 → XA(2PC)                │
│       · 不核心的旁路通知 → 最大努力通知                        │
│                                                              │
│   ★ 不管选哪个,都要配:幂等 + 对账 + 告警                    │
│                                                              │
└─────────────────────────────────────────────────────────────┘

1.5.9 本节面试高频追问(连环问)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
追问链 1:从 2PC 一路问到 TCC
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Q1: 说一下 2PC 的流程。
A : 两阶段。第一阶段 Prepare,协调者问所有参与者"能不能提交",
    参与者执行 SQL 但不提交,锁住资源,回复 Yes/No。
    第二阶段,全部 Yes 才发 Commit,否则发 Rollback。

Q2: 2PC 有什么问题?
A : 四个:① 阻塞(协调者宕机,参与者永远等,锁不释放)
    ② 协调者单点故障 ③ 二阶段部分失败仍会不一致
    ④ 锁持有时间长导致性能差。

Q3: 那怎么解决阻塞问题?
A : 两个方向。一是 3PC 加超时机制,但性能更差且仍有不一致窗口,
    工业界没落地。二是【绕开数据库锁】,用 TCC/Saga 这类业务层方案,
    本地事务立刻提交不等人,性能高得多。这是现在的实际做法。

Q4: 那 TCC 和 2PC 的本质区别是什么?
A : 锁在哪一层。2PC 在数据库层加行锁,一阶段不提交,锁要持有到
    二阶段结束;TCC 在业务层用预留字段(比如 frozen)控制,
    一阶段就提交本地事务,锁只持有 Try 那一下。所以 TCC 性能好很多,
    代价是一个业务要写 Try/Confirm/Cancel 三个接口。

Q5: TCC 有什么坑?
A : 三个必须处理的问题:空回滚(Try 没执行却收到 Cancel,
    要防止把 frozen 扣成负数)、幂等(Confirm/Cancel 可能被重复调用)、
    悬挂(Cancel 比 Try 先到,导致预留永远没人释放)。
    统一解法是加一张分支事务记录表,用 xid+branchId 做唯一键。

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
追问链 2:从 Seata 问到落地
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Q1: 你们项目用 Seata 吗?
A : 没有用 Seata,我们用的是本地事务 + 可靠消息 + 对账兜底。
    (★ 诚实回答更好,然后讲为什么)

Q2: 为什么不用?
A : 三个考虑。一是 Seata 要额外部署 TC 服务端,运维成本和多一层
    故障点;二是 AT 模式要求同构数据库,我们 Oracle 和 GaussDB
    混用;三是我们业务有人工审核环节,流程长,Seata 的全局锁
    扛不住。所以对账 + 补偿对我们更合适。

Q3: 那 Seata AT 的原理你能说说吗?
A : 两阶段。一阶段 Seata 的 JDBC 代理会解析业务 SQL,查询修改前
    和修改后的数据镜像,把镜像序列化成 undo_log 和业务 SQL
    在同一个本地事务里提交,然后释放本地锁。
    二阶段全部成功就异步删 undo_log,失败就取出 before image
    生成反向 SQL 回滚,回滚前会校验有没有脏写。

Q4: AT 模式的隔离级别是什么?
A : 默认读未提交,能读到其他全局事务未提交的中间态。
    要读已提交得加 @GlobalLock 配合 SELECT FOR UPDATE。
    ★ 这是 AT 的一个使用限制。

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
追问链 3:场景选型(最常考)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Q: 现在有个业务:用户下单,要扣库存、扣余额、发优惠券,
   三个服务,你用什么方案?
A : 首选可靠消息最终一致。因为下单本质是"创建订单 + 异步扣减",
   天然适合异步,不需要预留语义。
   具体是:订单库本地事务写订单 + 写本地消息表,
   事务提交后发 MQ,库存和余额服务消费消息各自处理,
   消费端做幂等。另外配一个准实时对账,差异自动冲正。

Q: 为什么不用 TCC?
A : TCC 侵入太高,三个服务要写九个接口,而且下单不需要
   "预留"语义——用户没付款时库存不用冻结。
   性价比不划算。

Q: 那什么时候该用 TCC?
A : 需要【预留】语义的场景。比如余额支付:用户下单时先把钱
   冻结住,保证后续一定能扣成功,这时候 Try 冻结、
   Confirm 真扣、Cancel 解冻就非常契合。
   还有跨异构资源(比如既要改数据库又要调第三方接口),
   TCC 的 Confirm 可以是任意逻辑,比 AT 灵活。


第二章:事务不一致与解决方案

事务失效的技术细节见 02-开发框架-讲解与面试题.md,本章聚焦“已经发生了不一致,业务上怎么兜底“。

本章结构: 2.1~2.3 讲“不一致是怎么产生的 + 用什么方案解决”;2.4 给选型总表; 2.5 是重点增补章,讲“方案本身失效了怎么办“——场景化选型、零中间件的轻量自研实现、 回滚防御、协调器宕机、热点锁竞争、消费失败闭环、事务对账与故障兜底; 2.6 是本章面试题汇总。

2.1 场景一:单库但原子性被破坏(事务失效导致)

典型现象(你的资产托管业务):

指令状态已改为"已出款",但账户头寸未扣减 → 账实不符

产生原因: catch 吞异常 / 多数据源 / 多线程 / 非 public 等(详见 02 文档)。

业务层解决方案(三层防护):

/**
 * 资产托管:指令出款的三层一致性保障
 */
@Service
@Slf4j
public class InstructionPayService {

    // ========== 第一层:本地事务保证原子性 ==========
    @BizTransactional(timeout = 30)
    public void payInstruction(Long instructionId) {
        Instruction inst = instructionMapper.selectById(instructionId);

        // 状态机校验(防止重复出款)
        if (!"APPROVED".equals(inst.getStatus())) {
            throw new BizException("指令状态不允许出款:" + inst.getStatus());
        }

        // ① 更新指令状态
        instructionMapper.updateStatus(instructionId, "PAID");
        // ② 扣减头寸
        positionMapper.deduct(inst.getAccountId(), inst.getAmount());
        // ③ 记录流水(用于后续对账)
        fundFlowMapper.insert(buildFlow(inst));
        // 任一步失败 → 全部回滚
    }

    // ========== 第二层:唯一约束兜底(防重复出款)==========
    /*
     * 数据库层面加唯一索引,即使事务出问题也不会重复扣款
     * ALTER TABLE fund_flow ADD UNIQUE KEY uk_instruction (instruction_no);
     *
     * 幂等设计:同一条指令只能有一条出款流水
     */

    // ========== 第三层:定时对账(最终发现不一致)==========
}
/**
 * 对账任务:每日核对指令状态与头寸流水,发现不一致立即告警
 * ★ 资金系统的最后一道防线——不能只依赖事务
 */
@Component
@Slf4j
public class ReconciliationJob {

    @Autowired
    private JdbcTemplate jdbcTemplate;
    @Autowired
    private AlertService alertService;

    @Scheduled(cron = "0 0 2 * * ?")     // 每天凌晨 2 点
    public void dailyReconcile() {
        Date bizDate = DateUtils.addDays(new Date(), -1);

        // ① 找出"状态是已出款,但没有对应头寸流水"的指令
        List<Long> missingFlow = jdbcTemplate.queryForList(
            """
            SELECT i.id FROM instruction i
            WHERE i.status = 'PAID'
              AND i.biz_date = ?
              AND NOT EXISTS (
                  SELECT 1 FROM fund_flow f
                  WHERE f.instruction_no = i.instruction_no
                    AND f.flow_type = 'PAY'
              )
            """, Long.class, bizDate);

        // ② 找出"金额对不上"的指令
        List<Map<String, Object>> amountDiff = jdbcTemplate.queryForList(
            """
            SELECT i.instruction_no, i.amount AS inst_amount,
                   COALESCE(SUM(f.amount), 0) AS flow_amount
            FROM instruction i
            LEFT JOIN fund_flow f ON f.instruction_no = i.instruction_no
            WHERE i.status = 'PAID' AND i.biz_date = ?
            GROUP BY i.instruction_no, i.amount
            HAVING ABS(i.amount - COALESCE(SUM(f.amount), 0)) > 0.01
            """, bizDate);

        // ③ 告警 + 落库供人工处理
        if (!missingFlow.isEmpty() || !amountDiff.isEmpty()) {
            log.error("[对账异常] 缺失流水 {} 条,金额不符 {} 条",
                missingFlow.size(), amountDiff.size());
            alertService.sendUrgent(String.format(
                "对账发现不一致:缺失流水 %d 条,金额不符 %d 条,请立即处理",
                missingFlow.size(), amountDiff.size()));

            // 落库,供人工跟进
            diffRecordMapper.batchInsert(buildDiffs(missingFlow, amountDiff));
        }
    }
}

对账任务的设计要点(面试加分):

① 幂等性:对账任务可以重复执行,不产生副作用
② 不自动修复,只告警:资金类问题必须人工确认后再处理
   (自动修复可能掩盖真正的 bug,且修复逻辑本身也可能出错)
③ 记录处理状态:已发现 → 处理中 → 已解决
④ 分级告警:金额不符 > 缺失流水 > 延迟未同步
⑤ 保留现场:不一致数据落库,不直接修改(保留证据)

2.2 场景二:跨库事务不一致

典型场景(你的 Oracle → GaussDB 迁移):

同一个业务操作要写 Oracle 和 GaussDB 两个库,
本地事务只能管一个库 → 可能出现 Oracle 写成功、GaussDB 失败

解决方案对比:

方案 一致性 性能 复杂度 适用
① 本地事务 + 异步补偿 最终一致 高 中 推荐(你的迁移场景)
② JTA / Atomikos(XA) 强一致 低(2PC 阻塞) 高 传统金融,很少用
③ Seata AT 强一致 中 中 微服务同构数据库
④ 业务拆分(避免跨库) — 高 低 最优,能从设计上避免就避免

💡 看不懂 2PC / XA / Seata AT 是什么? 先跳到 1.5 名词先行课,那一节用大白话 + 生活类比把这六个方案讲透了(1.5.2 讲 2PC、1.5.4 讲 XA、1.5.7 讲 Seata AT),看完再回来看这张表就一目了然。

方案①完整实现(本地事务 + 事务同步器 + 补偿):

/**
 * Oracle → GaussDB 双写一致性方案
 *
 * 设计原则:
 *   ① 主库(GaussDB)写操作在本地事务内,保证强一致
 *   ② 从库(Oracle)在事务提交后异步同步,保证最终一致
 *   ③ 同步失败记录待补偿日志,由定时任务重试
 *   ④ 定时对账,兜底发现长期不一致
 */
@Service
@Slf4j
public class DualWriteService {

    @Autowired
    private GaussInstructionMapper gaussMapper;
    @Autowired
    private OracleInstructionMapper oracleMapper;
    @Autowired
    private SyncTaskMapper syncTaskMapper;

    /**
     * 双写:主库事务 + 从库异步同步
     */
    @BizTransactional(transactionManager = "gaussTxManager", timeout = 30)
    public void saveInstruction(Instruction inst) {
        // ① 主库写入(强一致,事务内)
        gaussMapper.insert(inst);
        gaussPositionMapper.deduct(inst.getAccountId(), inst.getAmount());

        // ② 记录同步任务(和业务数据同库同事务!)
        //    ★ 关键:把"待同步"这件事也落库,保证不会丢
        Long taskId = syncTaskMapper.insert(SyncTask.builder()
            .bizType("INSTRUCTION")
            .bizId(inst.getId())
            .targetDb("ORACLE")
            .status("PENDING")
            .retryCount(0)
            .build());

        // ③ 注册事务提交后回调
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    // 事务已提交,异步同步到 Oracle
                    CompletableFuture.runAsync(() -> doSync(taskId, inst), syncExecutor)
                        .exceptionally(e -> {
                            log.error("异步同步异常,等待补偿任务重试,taskId={}", taskId, e);
                            return null;
                        });
                }
            }
        );
        // 事务回滚 → afterCommit 不执行 → 不会同步到 Oracle ✅
        // 事务回滚 → syncTask 记录也回滚 → 不会留下待同步任务 ✅
    }

    /**
     * 执行同步(幂等)
     */
    @BizTransactional(transactionManager = "oracleTxManager", timeout = 30)
    public void doSync(Long taskId, Instruction inst) {
        SyncTask task = syncTaskMapper.selectById(taskId);
        // 幂等校验:已成功的不重复同步
        if ("SUCCESS".equals(task.getStatus())) {
            return;
        }

        // 幂等写入 Oracle(用唯一索引兜底)
        try {
            oracleMapper.insertIgnore(inst);       // INSERT ... ON DUPLICATE KEY UPDATE
        } catch (DuplicateKeyException e) {
            log.info("Oracle 已存在该记录,跳过,id={}", inst.getId());
        }

        syncTaskMapper.updateStatus(taskId, "SUCCESS");
    }

    /**
     * 补偿任务:扫描 PENDING 超时的任务重试
     */
    @Scheduled(fixedDelay = 60000)
    public void retryPendingSync() {
        List<SyncTask> pending = syncTaskMapper.selectPendingTimeout(100, 5);  // 超过5分钟未成功
        for (SyncTask task : pending) {
            try {
                Instruction inst = gaussMapper.selectById(task.getBizId());
                doSync(task.getId(), inst);
            } catch (Exception e) {
                log.error("补偿同步失败,taskId={}", task.getId(), e);
                syncTaskMapper.incrRetry(task.getId());
                // 超过 5 次告警,人工介入
                if (task.getRetryCount() >= 5) {
                    syncTaskMapper.markFailed(task.getId());
                    alertService.send("同步任务连续失败 5 次,taskId=" + task.getId());
                }
            }
        }
    }
}

⚠️ 关键设计点(面试要能说出来):

★ 为什么把"同步任务"落库而不是直接异步调用?

  ❌ 错误做法:afterCommit 里直接调 oracleMapper.insert()
     问题:如果此时应用宕机,这次同步就永久丢失了
          没有任何记录表明"还有数据没同步"

  ✅ 正确做法:先在事务里插入 sync_task 记录,再异步执行
     优势:① 同步任务和业务数据同事务,业务提交它就存在
          ② 宕机后重启,补偿任务能扫描到 PENDING 状态的记录
          ③ 有重试次数、状态流转,可观测可追踪
          ④ 这就是"本地消息表"模式(可靠消息最终一致性的基础)

2.3 场景三:跨服务事务不一致

典型场景:

下单服务:创建订单(成功)
库存服务:扣减库存(失败,网络超时)
→ 订单存在但库存没扣 → 数据不一致

四种解决方案:

方案一:可靠消息最终一致性(推荐,最常用)

/**
 * 基于 RocketMQ 事务消息的最终一致性
 * 保证:本地事务成功 ⇔ 消息一定能发出去
 */
@Service
@Slf4j
public class OrderService {

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    /**
     * 下单:本地事务 + 事务消息
     */
    @BizTransactional
    public Long createOrder(OrderDTO dto) {
        // ① 本地事务:创建订单(状态"待扣库存")
        Order order = buildOrder(dto);
        order.setStatus("PENDING");          // ★ 中间状态(软状态)
        orderMapper.insert(order);

        // ② 记录本地消息表(同事务,防丢失)
        LocalMessage msg = LocalMessage.builder()
            .bizId(order.getId())
            .topic("inventory-deduct")
            .body(JSON.toJSONString(buildDeductMsg(order)))
            .status("PENDING")
            .nextRetryTime(LocalDateTime.now())
            .build();
        localMessageMapper.insert(msg);

        // ③ 事务提交后发送消息
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    sendMessage(msg);
                }
            }
        );

        return order.getId();
    }

    /**
     * 发送消息 + 更新消息表状态
     */
    private void sendMessage(LocalMessage msg) {
        try {
            SendResult result = rocketMQTemplate.syncSend(
                msg.getTopic(),
                MessageBuilder.withPayload(msg.getBody())
                    .setHeader("bizId", msg.getBizId())
                    .setHeader("msgId", msg.getId())     // ★ 用于消费端幂等
                    .build()
            );
            if (result.getSendStatus() == SendStatus.SEND_OK) {
                localMessageMapper.updateStatus(msg.getId(), "SENT");
            }
        } catch (Exception e) {
            log.error("消息发送失败,等待补偿任务重试,msgId={}", msg.getId(), e);
            // 不抛异常,由补偿任务扫描 PENDING 状态的消息重试
        }
    }

    /**
     * 补偿任务:定时扫描未发送/发送失败的消息重试
     * ★ 这是"可靠消息"的"可靠"所在
     */
    @Scheduled(fixedDelay = 30000)
    public void resendFailedMessages() {
        List<LocalMessage> pending = localMessageMapper
            .selectPendingRetry(100, LocalDateTime.now());

        for (LocalMessage msg : pending) {
            sendMessage(msg);
            localMessageMapper.incrRetry(msg.getId());
            // 指数退避:失败次数越多,下次重试间隔越长
            int retry = msg.getRetryCount();
            long delayMinutes = Math.min((long) Math.pow(2, retry), 60);
            localMessageMapper.updateNextRetryTime(
                msg.getId(), LocalDateTime.now().plusMinutes(delayMinutes));
        }
    }

    /**
     * 消费端:库存扣减(幂等)
     */
    @RocketMQMessageListener(topic = "inventory-deduct", consumerGroup = "inventory-group")
    public void handleDeduct(MessageExt message) {
        String msgId = message.getProperty("msgId");
        Long bizId = Long.valueOf(message.getProperty("bizId"));

        // ★ 幂等校验:用消息 ID 做幂等键
        if (consumedMsgMapper.exists(msgId)) {
            log.info("消息已消费,跳过,msgId={}", msgId);
            return;
        }

        try {
            // 扣减库存
            inventoryService.deduct(bizId);

            // 记录已消费(和业务操作在同一事务里!)
            consumedMsgMapper.insert(msgId, bizId);
        } catch (Exception e) {
            log.error("库存扣减失败,等待 MQ 重试", e);
            throw e;        // 抛异常触发 MQ 重试
        }
    }
}

方案二:TCC(Try-Confirm-Cancel)

TCC 三阶段:

  Try      预留资源(冻结库存,不真正扣减)
    ↓ 所有参与者 Try 成功
  Confirm  确认执行(真正扣减冻结的库存)
    ↓ 任一参与者 Try 失败
  Cancel   取消释放(解冻冻结的库存)

★ 与 2PC 的区别:
  2PC 在【数据库层】加锁,资源一直被锁到提交(性能差)
  TCC 在【业务层】预留,锁的是业务资源,粒度可控(性能好)
/**
 * TCC 示例:资产托管的资金冻结与扣划
 */
@LocalTCC
public interface FundTccService {

    /**
     * Try:冻结资金(预留资源)
     */
    @TwoPhaseBusinessAction(name = "fundTcc", commitMethod = "confirm", rollbackMethod = "cancel")
    boolean prepare(BusinessActionContext context,
                    @BusinessActionContextParameter("accountId") Long accountId,
                    @BusinessActionContextParameter("amount") BigDecimal amount);

    /**
     * Confirm:真正扣减(必须幂等)
     */
    boolean confirm(BusinessActionContext context);

    /**
     * Cancel:解冻(必须幂等,且要处理空回滚)
     */
    boolean cancel(BusinessActionContext context);
}

@Service
@Slf4j
public class FundTccServiceImpl implements FundTccService {

    @Override
    @BizTransactional
    public boolean prepare(BusinessActionContext context, Long accountId, BigDecimal amount) {
        // ① 检查余额
        Account account = accountMapper.selectById(accountId);
        if (account.getAvailable().compareTo(amount) < 0) {
            throw new BizException("余额不足");
        }

        // ② 冻结资金(可用 → 冻结)
        accountMapper.deductAvailable(accountId, amount);
        accountMapper.addFrozen(accountId, amount);

        // ③ 记录冻结明细(用于 Confirm/Cancel 时定位)
        freezeRecordMapper.insert(FreezeRecord.builder()
            .xid(context.getXid())               // 全局事务 ID
            .accountId(accountId)
            .amount(amount)
            .status("FROZEN")
            .build());
        return true;
    }

    @Override
    @BizTransactional
    public boolean confirm(BusinessActionContext context) {
        Long accountId = Long.valueOf(context.getActionContext("accountId").toString());
        BigDecimal amount = new BigDecimal(context.getActionContext("amount").toString());

        FreezeRecord record = freezeRecordMapper.selectByXid(context.getXid());
        // ★ 幂等:已确认的直接返回
        if (record == null || "CONFIRMED".equals(record.getStatus())) {
            return true;
        }

        // 真正扣减(冻结 → 扣除)
        accountMapper.deductFrozen(accountId, amount);
        freezeRecordMapper.updateStatus(context.getXid(), "CONFIRMED");
        return true;
    }

    @Override
    @BizTransactional
    public boolean cancel(BusinessActionContext context) {
        Long accountId = Long.valueOf(context.getActionContext("accountId").toString());
        BigDecimal amount = new BigDecimal(context.getActionContext("amount").toString());

        FreezeRecord record = freezeRecordMapper.selectByXid(context.getXid());

        // ★ 空回滚:Try 没执行(可能网络问题),Cancel 却来了
        if (record == null) {
            // 记录一条空回滚标记,防止后续 Try 成功但无人回滚(防悬挂)
            freezeRecordMapper.insert(FreezeRecord.builder()
                .xid(context.getXid())
                .status("CANCELLED_EMPTY")      // 空回滚标记
                .build());
            return true;
        }

        // ★ 幂等:已取消的直接返回
        if ("CANCELLED".equals(record.getStatus())) {
            return true;
        }

        // 解冻(冻结 → 可用)
        accountMapper.deductFrozen(accountId, amount);
        accountMapper.addAvailable(accountId, amount);
        freezeRecordMapper.updateStatus(context.getXid(), "CANCELLED");
        return true;
    }
}

TCC 的三个必须处理的问题(面试必答):

① 幂等性:Confirm/Cancel 可能被重复调用(网络重试),必须幂等
   解决:用事务 ID(xid)做幂等键,执行前先查状态

② 空回滚:Try 没执行(网络失败),Cancel 却被调用了
   解决:Cancel 时发现没有 Try 记录,插入一条"空回滚"标记

③ 防悬挂:Cancel 先于 Try 执行(网络延迟导致乱序)
   场景:Try 超时 → 全局事务回滚 → Cancel 执行(空回滚)
        然后 Try 请求才到达 → 执行了预留,但永远不会有人 Confirm/Cancel
        → 资源被永久冻结!
   解决:Try 执行时检查是否已有"空回滚"记录,有就直接返回失败

方案三:Saga(长事务编排)

适用场景: 流程长、参与者多、无法预留资源(如涉及外部系统)。

Saga:把长事务拆成多个本地短事务,每个都有对应的补偿操作

  正向流程:T1 → T2 → T3 → ... → Tn
  失败补偿:C1 ← C2 ← C3 ← ... ← Cn(逆序执行补偿)

  例:下单 → 扣库存 → 扣余额 → 发券
      失败:退券 → 退余额 → 退库存 → 取消订单
/**
 * Saga 编排(简化版,状态机驱动)
 */
@Service
public class OrderSagaService {

    /**
     * Saga 步骤定义:正向操作 + 补偿操作
     */
    private static final List<SagaStep> STEPS = List.of(
        new SagaStep("CREATE_ORDER",
            ctx -> orderService.create(ctx),
            ctx -> orderService.cancel(ctx)),           // 补偿:取消订单

        new SagaStep("DEDUCT_INVENTORY",
            ctx -> inventoryService.deduct(ctx),
            ctx -> inventoryService.refund(ctx)),       // 补偿:回滚库存

        new SagaStep("DEDUCT_BALANCE",
            ctx -> fundService.deduct(ctx),
            ctx -> fundService.refund(ctx))             // 补偿:退款
    );

    @BizTransactional
    public void execute(OrderContext ctx) {
        List<SagaStep> executed = new ArrayList<>();

        try {
            for (SagaStep step : STEPS) {
                step.action().accept(ctx);
                executed.add(step);                      // 记录已执行的步骤
                sagaLogMapper.insert(buildLog(ctx, step.getName(), "SUCCESS"));
            }
        } catch (Exception e) {
            log.error("Saga 执行失败,开始逆序补偿,orderId={}", ctx.getOrderId(), e);
            // ★ 逆序执行补偿
            Collections.reverse(executed);
            for (SagaStep step : executed) {
                try {
                    step.compensation().accept(ctx);
                    sagaLogMapper.insert(buildLog(ctx, step.getName(), "COMPENSATED"));
                } catch (Exception ce) {
                    // 补偿失败也要记录,由补偿任务继续重试
                    log.error("补偿失败,step={}", step.getName(), ce);
                    sagaLogMapper.insert(buildLog(ctx, step.getName(), "COMPENSATE_FAILED"));
                    alertService.send("Saga 补偿失败,需人工介入:" + step.getName());
                }
            }
            throw new BizException("下单失败,已回滚");
        }
    }
}

Saga vs TCC 选型:

TCC Saga
一致性 接近强一致(有预留阶段) 最终一致(无预留)
隔离性 好(预留资源隔离) 差(可能有脏读,中间态可见)
侵入性 高(要改造成 Try/Confirm/Cancel) 中(只要提供补偿接口)
适用 资金、库存等可预留的资源 长流程、涉及外部系统
复杂度 高(要处理空回滚、防悬挂) 中

方案四:最大努力通知

适用场景: 一致性要求不高,允许偶尔失败(如通知、推送)。

实现:
  ① 业务处理后记录通知任务
  ② 定时重试 N 次(1min、5min、30min、2h、24h...指数退避)
  ③ 超过 N 次放弃,记录并告警
  ④ 提供对账查询接口,让对方主动来查

例:支付结果通知商户(微信/支付宝就是这个模式)

2.4 分布式事务方案选型总表(面试一张图说清)

                      一致性要求
                          ▲
                          │
   2PC / XA ─────────────┤ 强一致(性能最低,传统金融核心)
   Seata AT ─────────────┤ 强一致(同构数据库,有全局锁)
   Seata TCC ────────────┤ 接近强一致(有预留,性能好,侵入高)
   可靠消息最终一致 ──────┤ 最终一致 ★最常用(异步、吞吐高)
   最大努力通知 ─────────┤ 最弱(允许失败,靠对账兜底)
                          │
                          └──────────────────────────► 性能 / 吞吐

五维对比表:

方案 一致性 性能 侵入性 适用场景 你的简历对应
2PC / XA 强一致 低(阻塞、锁资源久) 低(框架做) 传统单体金融核心 —
Seata AT 强一致 中(全局锁) 低(加注解) 同构 MySQL 微服务 —
TCC 接近强一致 高 高(要写 Try/Confirm/Cancel) 资金、库存等可预留资源 资产托管头寸预增减
Saga 最终一致 高 中(要写补偿) 长流程、含外部系统 指令多环节流转
可靠消息最终一致 最终一致 最高 中(本地消息表) 绝大多数业务 ★推荐 项目四 RocketMQ 事务消息
最大努力通知 最弱 高 低 通知、推送类 —

面试回答套路(被问“你们怎么保证跨服务一致性”):

① 先说原则:能不用分布式事务就不用
   → 优先通过业务设计避免(比如下单时先冻结库存,避免跨服务回滚)
   → 优先"最终一致",不要一上来就谈强一致

② 再说选型:我们 90% 的场景用可靠消息最终一致性
   → 因为性能高、耦合低、成熟稳定
   → 资金类(可预留)才用 TCC

③ 再说保障:光有方案不够,必须有兜底
   → 幂等(防重复)+ 对账(防丢失)+ 告警(防无人处理)

④ 最后举例:社区平台发奖场景
   → RocketMQ 事务消息保证"本地事务成功 ⇄ 消息发出"
   → 消费端用 (userId+activityId+date) 幂等键防重复发奖
   → 每日对账任务兜底,发现漏发自动补发

2.5 分布式事务失效兜底:场景选型 + 零中间件轻量实现(★ 重点增补)

前面 2.1~2.4 讲的是“方案怎么选”,这一节讲“方案失效了怎么办”。

面试官真正想听的不是“我用 Seata AT / TCC / 事务消息”,而是:

  • 如果 Seata 的 TC 挂了,你的事务还能不能自愈?
  • 如果不允许引入任何中间件(没有 Seata、没有 RocketMQ),你怎么保证一致性?
  • 补偿失败了怎么办?对不上账怎么办?热点商品把行锁打爆了怎么办?

这一节的定位:把“分布式事务”从“选一个框架”升级成“设计一套兜底体系”。


2.5.1 先定义:什么叫“分布式事务失效”

很多人一说“事务失效”,第一反应是 @Transactional 不生效(同类调用、private 方法等)。那只是最浅的一层。生产上“失效”分三层:

┌────────────────────────────────────────────────────────────────────┐
│ 第①层:设计期失效 —— 方案选型错误                                     │
│   现象:方案本身扛不住业务形态                                        │
│   例子:秒杀场景用 TCC 预留库存 → 热点行锁竞争 → 大量超时 → 全站雪崩      │
│        长流程(含人工审核)用 2PC → 锁住资源几十分钟 → 吞吐归零          │
│   对策:2.5.2 的场景化选型                                            │
├────────────────────────────────────────────────────────────────────┤
│ 第②层:运行期失效 —— 异常路径没兜住                                    │
│   现象:网络超时、节点宕机、消费失败、补偿失败                          │
│   例子:Try 成功,Confirm 超时 → 不知道到底成没成                      │
│        补偿接口报错 → 没人重试 → 数据悬挂几个月                        │
│   对策:2.5.4 回滚防御 + 2.5.5 协调器高可用 + 2.5.7 消费失败闭环        │
├────────────────────────────────────────────────────────────────────┤
│ 第③层:治理期失效 —— 兜底体系缺失                                      │
│   现象:脏数据产生了,但没人知道、没人修                                │
│   例子:没有对账 → 差异累积半年才发现                                  │
│        没有人工补偿入口 → 只能 DBA 改库                                │
│   对策:2.5.8 事务对账 + 2.5.9 故障兜底全景                            │
└────────────────────────────────────────────────────────────────────┘

一句话记住(面试开场白可直接用):

「我不认为存在“不会失败的分布式事务方案”。所有方案都是在成功率和成本之间做权衡。 我的设计思路是分三层:自动方案(覆盖 99.9%)→ 回滚防御 + 重试自愈(覆盖剩下 0.09%)→ 对账 + 人工兜底(覆盖最后 0.01%,且必须可发现、可修复、可追责)。 评价一个方案好不好,不是看它正常时多优雅,而是看它异常时会不会留下无法自愈的脏数据。」

失效成本评估矩阵(做任何一致性设计前先问这个):

维度 要问的问题 决定什么
金额 单笔涉及多少钱?日累计多少? 是否值得上强一致、是否必须 T+0 对账
可逆性 错了能不能退?退款成本多大? 能否接受“先扣后退”的最终一致
时效 多久内必须一致?秒级 / 分钟级 / T+1 同步补偿 vs 异步对账
对手方 内部系统 / 外部银行 / 第三方商户 有无补偿接口、能否对账
并发 单点的峰值 QPS 多少?是否热点 是否必须拆分热点(2.5.6)

2.5.2 ★ 综合场景选型题(面试官原题:给 6 个场景,逐个选方案)

这是本节的核心题,也是面试最高频的开放题变体。 面试官问法通常是:「我这有 6 个业务场景,你分别说说用什么方案保证一致性?为什么?如果失败了怎么兜底?」 能把这张表答出来,并且每个场景都带上“失效兜底”,基本就是 P6/P7 水准。

题目(原题照抄式):

一个电商 + 金融混合系统,有以下 6 个跨服务场景,请分别为其选择一致性方案,
说明选择理由,并说明"当方案失效时"的兜底手段:

① 下单:扣库存(库存服务)+ 扣余额(账户服务)+ 创建订单(订单服务)
   三个服务同机房,都是 MySQL,RT 要求 < 200ms,日均 100 万单

② 用户注册成功:送积分 + 发优惠券 + 发站内信 + 发短信
   允许延迟几秒,允许短信失败

③ 资产托管指令交收:托管行系统划拨资金(行外系统,只有同步接口,
   没有"回滚接口",也没有"查询接口",只能靠当日对账文件)

④ 秒杀:单个爆款商品 100 件,峰值 10 万 QPS 抢购

⑤ 支付成功后通知第三方商户(商户系统不可控,可能超时、可能返回乱码)

⑥ 每日 T+1 与银行对账文件核对交收结果(文件几百万行)

【标准答案表】(建议背下来,面试时按行输出)

# 场景 一致性要求 选型 为什么这么选 失效兜底(关键加分项)
① 下单三服务 准实时一致,不能有超卖/多扣 可靠消息最终一致(本地消息表 / RocketMQ 事务消息)为主流程 + 库存侧 Redis 预扣防超卖 三个服务同机房但跨库,2PC 会锁资源 200ms 级拖垮吞吐;TCC 侵入太高(要写 6 个接口);下单本质是“创建订单 + 异步扣减”,天然适合异步 ① 库存先 Redis Lua 预扣(强原子,防超卖),MQ 异步落库
② 落库失败 → 回退 Redis 预扣 + 告警
③ 消费端幂等键 orderId+skuId
④ 5 分钟级“预扣 vs 实扣”准实时对账,差异自动冲正
⑤ 订单超过 15 分钟未支付 → 延时消息自动取消,释放预扣
② 注册送权益 最终一致即可,允许秒级延迟 可靠消息(普通消息 + 本地消息表) + 最大努力通知(短信) 权益类允许延迟;积分/券是内部服务可控;短信是外部且允许失败 → 分档处理 ① 积分/券:消费失败自动重试 16 次 + 死信告警
② 短信:最多 3 次,失败即放弃(本身允许失败)
③ 每日对账:注册用户数 vs 发积分人数,漏发自动补发
④ 券超发风险 → 发券接口做库存上限校验
③ 行外划拨 强一致做不到(对手方无接口) Saga(正向执行 + 本地补偿) + 状态机 + T+1 对账 对手方没有回滚接口 → TCC 不可用;没有查询接口 → 无法自动确认 → 只能“以我为主记录 + 事后对账” ① 划拨前先落「划拨指令」表(INIT),划拨后更新(SENT)
② 超时未知 → 标记 UNKNOWN,不重试(防重复划拨!)
③ T+1 银行对账文件确认最终态,自动平账
④ 对账差异 → 人工工单 + 银企直连查询
⑤ 关键:金额类 UNKNOWN 绝对不能盲目重试
④ 秒杀 不超卖即可,允许少量失败 不用分布式事务,用 Redis 原子预扣 + 分段库存 + 异步落库 10 万 QPS 下任何“预留型”方案(TCC/2PC)都会打爆单行锁 → 热点场景用分布式事务是反模式 ① 库存分 20 个桶(或 Redis 多 key 打散),降低单点竞争
② Redis Lua 原子扣减,扣不到直接返回“已售罄”
③ 扣减成功 → 写 MQ 异步创建订单(削峰)
④ 订单创建失败 → 反向归还 Redis 库存
⑤ 售罄标记进本地缓存,后续请求直接内存拦截
⑥ 超卖兜底:极端情况允许多卖几件 → 事后退款 + 客服补偿
⑤ 通知商户 尽力而为 最大努力通知(通知表 + 阶梯重试 + 对账) 商户系统不可控,重试无意义时只能放弃;业务上商户可主动查单 ① 通知表记录每次通知的响应(含原始报文快照)
② 阶梯重试:1min/5min/30min/2h/6h/24h,共 6~10 次
③ 商户侧必须提供主动查单接口(这是设计前提,不是兜底)
④ 超过次数 → 标记 FAIL + 告警 + 运营联系商户
⑤ 每日对账:本方成功单 vs 商户确认单
⑥ 文件对账 事后一致 批处理对账(分片 + 排序归并 + 差异工单) 百万行文件不可能放内存比对,必须流式归并 ① 文件先落盘校验(行数/总金额/MD5)
② 按业务主键排序后双指针归并比对,O(n) 内存 O(1)
③ 差异分四类(长款/短款/金额不符/状态不符)
④ 自动可修的自动冲正,不可修的转人工
⑤ 对账任务本身要有断点续跑能力

【选型决策树】(背这个图,比背表格更快)

                    ┌─────────────────────────┐
                    │ 需要跨服务/跨库一致性吗?│
                    └───────────┬─────────────┘
                    否 ─────────┴──────── 是
                    │                      │
              用本地事务              是否涉及资金/不可逆资源?
              + 状态机                      │
                                 ┌─────────┴──────────┐
                                是                    否
                                 │                     │
                    对手方有预留/回滚接口吗?      能接受秒级延迟吗?
                          │                            │
                  ┌───────┴────────┐          ┌────────┴────────┐
                 是                否         是                 否
                 │                 │         │                  │
              TCC              只能 Saga   可靠消息最终一致    考虑 2PC/AT
          (Try/Confirm/Cancel)  + 对账    (本地消息表/事务消息)  (同机房短事务)
                 │                 │              │
            加:幂等+空回滚      加:状态机        加:对账
                +防悬挂          +T+1对账         +死信告警
                                 │
                          ┌──────┴───────┐
                          │ 是热点商品吗?│
                          └──────┬───────┘
                         是 ─────┴──── 否
                          │             │
              拆桶/Redis预扣/串行化    常规方案
              (★ 别用预留型方案)

【通用答题模板】(任何一致性问题都能套)

第一步:先问清楚三个问题(体现你不是背方案的)
  ① 涉及金额吗?错了能退吗?     → 决定一致性级别
  ② 对手方是内部还是外部?有没有补偿/查询接口?  → 决定方案可行性
  ③ 峰值 QPS 多少?有没有热点?  → 决定要不要做热点拆分

第二步:选主方案,并说"为什么不用别的"
  「我选 X,因为 Y;不选 Z 是因为 Z 会……(说出具体代价)」

第三步:说清楚异常路径(这是区分度)
  「正常路径大家都差不多,我重点说异常:
    Try 成功 Confirm 超时 → 查对端 + 幂等重试;
    补偿失败 → 退避重试 + 死信 + 告警;
    进程宕机 → 状态在 DB,重启自愈。」

第四步:必须有兜底(没有兜底 = 不及格)
  「最后有一道对账:T+0 准实时对账发现差异,T+1 全量对账兜底,
    差异分自动修复和人工工单两类,人工工单有 SLA 和处理台账。」

第五步:上一个自己踩过的坑(STAR)
  「我们之前……(具体场景),原因是……,我们改成……,效果是……」

【6 个场景的追问预判】(面试官大概率接着问的)

追问 你要答的点
“①里为什么不用 TCC?” TCC 要写 6 个接口(3 服务 × Try/Confirm/Cancel),侵入高;且库存预留会长时间占库存,下单 15 分钟不支付会锁死库存;而下单本质可异步
“③里 UNKNOWN 为什么不重试?” 资金划拨没有查询接口,重试可能重复划拨 → 金额类宁可“少做”不可“多做”,转到 T+1 对账人工确认
“④里怎么保证不超卖?” 三层:Redis Lua 原子扣(第一道)→ DB 扣减时 WHERE stock >= n 乐观校验(第二道)→ 唯一索引/对账(第三道)
“⑤里商户一直不响应怎么办?” 停止重试 + 告警 + 让商户主动查单;通知只是优化手段,不是一致性保证,一致性靠商户查单
“⑥几百万行怎么比对?” 排序后双指针归并,流式处理,不落内存;或用分块哈希;支持断点续跑
“你怎么验证这套东西真的有效?” 故障注入演练:kill 协调器、断网、让补偿接口抛异常、制造消息堆积,看是否都能自愈 / 告警

2.5.3 ★ 不依赖任何三方中间件的轻量实现(自研 Mini-DTX)

面试官问法:「如果你们不让引入 Seata,也没有 MQ(或 MQ 不可用),只有 MySQL + Spring Boot, 你怎么保证跨服务一致性?写出你的设计。」

这题的考点不是让你造轮子,而是考:你能不能把“事务状态”这个东西正确地持久化 + 驱动。 核心答案只有一句话:把事务状态写进业务库的一张表,让“状态推进”变成“扫表 + 调用 + 改状态”,用定时恢复线程代替协调器。

2.5.3.1 设计思路:本地消息表 + Saga 补偿的合体

它的本质是:用一张 DB 表同时扮演“协调器日志”和“待办任务队列”两个角色。

┌────────────────────────────────────────────────────────────────────────┐
│                        自研 Mini-DTX 架构                               │
├────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  业务线程(同步,RT 敏感)                                                │
│  ┌────────────┐                                                          │
│  │ 业务方法    │                                                          │
│  │ @MiniTx    │  ① 开启本地事务                                           │
│  └─────┬──────┘                                                          │
│        │ ② 同库事务内:写业务数据 + 写 tx_record + 写 tx_branch(全部 INIT) │
│        ▼                                                                  │
│  ┌──────────────────────────────────────────────────────┐                │
│  │ 业务库(同一个 DataSource,同一个本地事务)              │                │
│  │   t_order        业务数据                              │                │
│  │   tx_record      主事务:TRYING / CONFIRMED / CANCELED │                │
│  │   tx_branch      分支:INIT / SUCCESS / FAIL / COMPED  │                │
│  └──────────────────────────────────────────────────────┘                │
│        │ ③ 本地事务提交(业务数据 + 事务记录原子提交)                     │
│        ▼                                                                  │
│  异步线程池 / 恢复线程(不占业务 RT)                                      │
│  ┌──────────────────────────────────────────────────────┐                │
│  │ ④ 按序调用分支服务(HTTP/Feign,带超时)                │                │
│  │    成功 → 分支改 SUCCESS,继续下一个                    │                │
│  │    失败 → 反向补偿已 SUCCESS 的分支,主事务改 CANCELING │                │
│  │    补偿成功 → CANCELED;补偿失败 → 留在表里等重试        │                │
│  └──────────────────────────────────────────────────────┘                │
│        │                                                                  │
│        ▼                                                                  │
│  ⑤ Recovery 定时任务(每秒扫一次):                                       │
│     捞"卡住"的事务(TRYING 超时 / 分支 INIT 未推进 / 补偿未完成)           │
│     → 按状态机恢复(幂等,重复执行无害)                                   │
│                                                                          │
│  ⑥ 对账任务(分钟级 / 日级):兜底,2.5.8 详述                             │
└────────────────────────────────────────────────────────────────────────┘

为什么这个设计是“正确”的:

关键点 说明
状态与业务数据同库同事务 这是全部正确性的基石。业务数据提交了但事务记录没写 → 没人知道要补偿;反过来更糟。同库同事务保证“要么都有,要么都没有”
先落状态,再调服务(WAL) 任何一次远程调用前,分支记录必须已经是 INIT/PROCESSING 且已提交。宕机重启后扫表就知道“有一个分支调用出去了,结果未知”
推进是幂等的 分支调用前先 CAS 改 INIT → PROCESSING,改成功才调。这样多实例/重复恢复都不会重复调用
补偿也是一条“待办” 补偿失败不抛异常了事,而是留在表里 + next_retry_time,由 Recovery 持续重试
不需要任何中间件 协调逻辑就是“扫表 + HTTP 调用 + 改状态”,MySQL + 定时任务即可

2.5.3.2 表结构设计(可直接用)

-- ============================================================
-- 主事务表:一条 = 一次分布式事务
-- 关键:与业务表同库,参与同一个本地事务
-- ============================================================
CREATE TABLE `tx_record` (
  `id`             BIGINT       NOT NULL AUTO_INCREMENT,
  `tx_id`          VARCHAR(64)  NOT NULL COMMENT '全局事务ID(业务侧生成,雪花/UUID)',
  `biz_type`       VARCHAR(32)  NOT NULL COMMENT '业务类型:ORDER_CREATE / REWARD / SETTLE',
  `biz_key`        VARCHAR(64)  NOT NULL COMMENT '业务主键:订单号/指令号(★ 用于对账与人工排查)',
  `status`         VARCHAR(16)  NOT NULL COMMENT 'TRYING/CONFIRMED/CANCELING/CANCELED/UNKNOWN',
  `context`        JSON                  COMMENT '事务上下文快照(分支调用所需的全部入参)',
  `retry_count`    INT          NOT NULL DEFAULT 0 COMMENT '主事务推进重试次数',
  `next_retry_time` DATETIME(3)          COMMENT '下次重试时间(退避用,NULL=不重试)',
  `timeout_at`     DATETIME(3)  NOT NULL COMMENT '全局超时时间,超过则强制进入补偿',
  `last_error`     VARCHAR(1024)         COMMENT '最后一次错误信息(排障关键)',
  `create_time`    DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `update_time`    DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_tx_id` (`tx_id`),
  KEY `idx_status_retry` (`status`, `next_retry_time`),   -- ★ Recovery 扫描用
  KEY `idx_biz` (`biz_type`, `biz_key`),                  -- 人工排查 / 对账用
  KEY `idx_timeout` (`timeout_at`)                        -- 超时扫描用
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分布式事务主记录';


-- ============================================================
-- 分支事务表:一条 = 一次远程调用(正向或补偿)
-- ============================================================
CREATE TABLE `tx_branch` (
  `id`            BIGINT       NOT NULL AUTO_INCREMENT,
  `tx_id`         VARCHAR(64)  NOT NULL,
  `branch_id`     VARCHAR(64)  NOT NULL COMMENT '分支ID',
  `seq`           INT          NOT NULL COMMENT '执行顺序(补偿时倒序执行)',
  `service_name`  VARCHAR(64)  NOT NULL COMMENT '目标服务:inventory-service',
  `action`        VARCHAR(128) NOT NULL COMMENT '正向动作:/stock/deduct',
  `compensate`    VARCHAR(128)          COMMENT '补偿动作:/stock/deduct/rollback(可为空=不需要补偿)',
  `params`        JSON                  COMMENT '正向入参',
  `status`        VARCHAR(16)  NOT NULL COMMENT 'INIT/PROCESSING/SUCCESS/FAIL/COMPENSATING/COMPENSATED/COMPENSATE_FAIL/SKIPPED',
  `result`        VARCHAR(1024)         COMMENT '正向返回结果(补偿时可能需要,如扣款流水号)',
  `retry_count`   INT          NOT NULL DEFAULT 0,
  `next_retry_time` DATETIME(3),
  `last_error`    VARCHAR(1024),
  `create_time`   DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `update_time`   DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_tx_branch` (`tx_id`, `branch_id`),
  KEY `idx_status_retry` (`status`, `next_retry_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分布式事务分支记录';


-- ============================================================
-- 调用流水表:每一次实际远程调用都留痕(★ 排障与对账的核心依据)
-- 没有这张表,UNKNOWN 状态就无从查证
-- ============================================================
CREATE TABLE `tx_invoke_log` (
  `id`           BIGINT      NOT NULL AUTO_INCREMENT,
  `tx_id`        VARCHAR(64) NOT NULL,
  `branch_id`    VARCHAR(64) NOT NULL,
  `phase`        VARCHAR(16) NOT NULL COMMENT 'TRY / CONFIRM / CANCEL / QUERY',
  `request`      JSON COMMENT '请求报文快照',
  `response`     VARCHAR(2048) COMMENT '响应报文快照',
  `success`      TINYINT COMMENT '1成功 0失败 NULL未知(超时)',
  `cost_ms`      INT,
  `create_time`  DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`id`),
  KEY `idx_branch` (`tx_id`, `branch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='事务调用流水(可观测性)';

表设计的 6 个要点(面试会追问“为什么这么设计”):

  1. tx_record 必须和业务表同库(不能单独建一个“事务库”)——否则无法用本地事务保证原子性
  2. next_retry_time 单独建索引——Recovery 扫描 WHERE status IN (...) AND next_retry_time <= NOW() 才能走索引,几百万行也不慢
  3. context / params 存 JSON——分支调用失败后重试需要原始入参,不能依赖线程上下文(线程早没了)
  4. result 存正向返回结果——补偿时经常需要(如扣款的流水号、冻结的资源 ID)
  5. tx_invoke_log 不要省——UNKNOWN 状态只能靠它查证“到底调没调成功”
  6. seq 字段必须有——补偿必须倒序执行(先建的后撤),否则会出现“库存已还但订单还在”的中间态

2.5.3.3 状态机(画在白板上的那张图)

【主事务 tx_record.status】

                    ┌─────────┐
      开启事务  ───► │ TRYING  │  所有分支推进中
                    └────┬────┘
                         │
        ┌────────────────┼────────────────┐
        │                │                │
   全部分支 SUCCESS  任一分支 FAIL    超过 timeout_at
        │                │                │
        ▼                ▼                ▼
  ┌───────────┐   ┌────────────┐   ┌──────────┐
  │ CONFIRMED │   │ CANCELING  │   │ UNKNOWN  │(分支超时,结果未知)
  │  (终态)  │   │  补偿进行中 │   │ → 反查后 │
  └───────────┘   └──────┬─────┘   │   决定    │
                         │         └────┬─────┘
              补偿全部完成 │              │
                         ▼              │
                  ┌───────────┐         │
                  │ CANCELED  │ ◄───────┘
                  │  (终态)  │
                  └───────────┘
                         │
              补偿重试 N 次仍失败
                         ▼
                  ┌───────────────┐
                  │ CANCELED + 告警 │  ← 留在表里,人工介入
                  │ (status=CANCEL │     注意:不删记录!
                  │  ING, 持续重试) │
                  └───────────────┘


【分支 tx_branch.status】

  INIT ──CAS抢锁──► PROCESSING ──成功──► SUCCESS ──需要补偿──► COMPENSATING ──► COMPENSATED
                        │                                          │
                        │──失败──► FAIL(触发主事务 CANCELING)       └──失败──► COMPENSATE_FAIL
                        │                                                       (留在表里重试)
                        │──超时──► PROCESSING 保持 → Recovery 反查
                        │
                        └──► SKIPPED(空回滚记痕:分支从未执行,Cancel 先到)

状态机设计的 3 条铁律:

  1. 只能向前,不能回退(除了 INIT→PROCESSING 的抢占):PROCESSING 不能直接改回 INIT,SUCCESS 不能直接改 FAIL
  2. 终态只有两个:CONFIRMED / CANCELED。其余都是“中间态”,都必须能被 Recovery 驱动
  3. 任何中间态都必须有超时出口:否则一次宕机就留下永久悬挂记录

2.5.3.4 核心代码实现

① 注解定义

/**
 * 轻量分布式事务入口。
 * 语义:方法内的本地事务提交后,异步推进 tx_branch 中注册的分支调用;
 *       任一分支失败 → 倒序补偿已成功的分支。
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MiniTx {

    /** 业务类型,用于对账与排查 */
    String bizType();

    /** 业务主键(SpEL 或直接从参数取),如 "#order.orderNo" */
    String bizKey();

    /** 全局超时时间(秒),超时后强制进入补偿 */
    int timeout() default 60;

    /** 最大推进重试次数(分支调用失败的正向重试次数) */
    int maxRetry() default 3;
}

② 上下文与分支注册(业务代码里用)

/**
 * 事务上下文:在 @MiniTx 方法内可用,用于注册分支。
 * 注册的分支会在【本地事务内】落库,与方法内的业务数据同生共死。
 */
public class TxContext {

    private final String txId;
    private final List<BranchDef> branches = new ArrayList<>();

    public TxContext(String txId) { this.txId = txId; }
    public String getTxId() { return txId; }

    /**
     * 注册一个分支。
     * @param branchId   分支ID(同一 txId 内唯一)
     * @param service    目标服务名(对应 BranchInvoker 的实现)
     * @param action     正向动作
     * @param compensate 补偿动作(null 表示该分支无需补偿,如"发站内信")
     * @param params     正向入参(必须可 JSON 序列化,★ 不能放对象引用)
     */
    public TxContext addBranch(String branchId, String service,
                               String action, String compensate, Object params) {
        branches.add(new BranchDef(branchId, service, action, compensate, params));
        return this;
    }

    List<BranchDef> getBranches() { return branches; }
}

③ AOP 切面(核心:把事务记录塞进业务本地事务)

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class MiniTxAspect {

    private final TxRecordMapper txRecordMapper;
    private final TxBranchMapper txBranchMapper;
    private final TxEngine txEngine;          // 异步推进引擎
    private final BizKeyResolver bizKeyResolver;

    @Around("@annotation(miniTx)")
    public Object around(ProceedingJoinPoint pjp, MiniTx miniTx) throws Throwable {
        String txId = IdUtil.fastSnowflakeId();
        TxContext ctx = new TxContext(txId);

        // ── 阶段一:执行业务方法(内部有 @Transactional 本地事务)──
        Object result;
        try {
            // 注意:必须在业务方法【执行前】把 ctx 放进 ThreadLocal,
            // 让业务代码能调用 TxContext.current().addBranch(...)
            TxContextHolder.set(ctx);
            result = pjp.proceed();
        } catch (Throwable ex) {
            // 业务方法本身抛异常 → 本地事务回滚 → 事务记录也不会落库 → 什么都不用做
            // ★ 这就是"同库同事务"的威力:不需要任何补偿
            log.warn("[MiniTx] biz method failed, no tx record. txId={}", txId, ex);
            throw ex;
        } finally {
            TxContextHolder.clear();
        }

        // ── 阶段二:业务本地事务已提交,此时【再开一个事务】写事务记录 ──
        // 如果没有分支(纯本地操作),直接返回,零开销
        if (ctx.getBranches().isEmpty()) {
            return result;
        }

        String bizKey = bizKeyResolver.resolve(miniTx.bizKey(), pjp.getArgs());
        persistTxRecords(ctx, miniTx, bizKey);

        // ── 阶段三:异步推进(不阻塞业务 RT)──
        txEngine.submit(txId);

        return result;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void persistTxRecords(TxContext ctx, MiniTx miniTx, String bizKey) {
        TxRecord rec = new TxRecord();
        rec.setTxId(ctx.getTxId());
        rec.setBizType(miniTx.bizType());
        rec.setBizKey(bizKey);
        rec.setStatus(TxStatus.TRYING.name());
        rec.setTimeoutAt(LocalDateTime.now().plusSeconds(miniTx.timeout()));
        rec.setMaxRetry(miniTx.maxRetry());
        txRecordMapper.insert(rec);

        int seq = 0;
        for (BranchDef def : ctx.getBranches()) {
            TxBranch br = new TxBranch();
            br.setTxId(ctx.getTxId());
            br.setBranchId(def.getBranchId());
            br.setSeq(seq++);
            br.setServiceName(def.getService());
            br.setAction(def.getAction());
            br.setCompensate(def.getCompensate());
            br.setParams(JsonUtil.toJson(def.getParams()));
            br.setStatus(BranchStatus.INIT.name());
            txBranchMapper.insert(br);
        }
    }
}

⚠️ 这里有个必须说清楚的坑(面试必问):「业务方法提交了,但写事务记录失败/宕机了,怎么办?」

答:这是本方案唯一的理论漏洞,有三种应对:

  1. 兜底对账(最重要):业务数据落库了但没事务记录 → 对账任务扫“业务表有、tx_record 无”的数据,补建事务记录并推进(或告警)。
  2. 反过来更常见也更危险:事务记录写了但业务没成 → 不会发生,因为同库同事务。
  3. 缩小窗口:把 persistTxRecords 放在业务方法返回后立即执行(毫秒级窗口),实际生产中概率极低,且对账能兜住。

这也是为什么再完美的方案也必须有对账。

④ 分支调用器(带超时、留痕、三态判定)

/**
 * 分支调用的三态结果:成功 / 失败 / 未知。
 * ★ 必须区分"失败"和"未知":失败可以安全补偿(对方明确没做),
 *   未知不能直接补偿(对方可能已经做了)。
 */
public enum InvokeResult {
    SUCCESS,   // 明确成功
    FAIL,      // 明确失败(对方明确拒绝/校验不通过)—— 可以安全补偿
    UNKNOWN    // 超时/网络异常 —— 结果未知,必须先反查
}

@Component
@RequiredArgsConstructor
@Slf4j
public class BranchInvoker {

    private final Map<String, RemoteClient> clients;  // serviceName -> HTTP/Feign Client
    private final TxInvokeLogMapper logMapper;

    public InvokeResult invoke(TxBranch branch, String phase, String paramsJson) {
        long start = System.currentTimeMillis();
        String response = null;
        Boolean success = null;

        try {
            RemoteClient client = clients.get(branch.getServiceName());
            String path = "CANCEL".equals(phase) ? branch.getCompensate() : branch.getAction();

            // ★ 超时必须设置,且要小于全局超时
            Response<String> resp = client.post(path, paramsJson, Duration.ofSeconds(3));

            response = resp.body();
            int code = resp.code();

            if (code == 200) {
                // 200 且业务码成功
                if (JsonUtil.isBizSuccess(response)) {
                    success = true;
                    return InvokeResult.SUCCESS;
                }
                // 200 但业务失败(如"库存不足")→ 明确失败
                success = false;
                return InvokeResult.FAIL;
            }

            if (code == 404 || code == 400 || code == 409) {
                // 4xx:明确失败(参数错误/资源不存在/冲突)
                success = false;
                return InvokeResult.FAIL;
            }

            // 5xx:服务端异常,可能是"做了但返回失败"→ 判 UNKNOWN
            return InvokeResult.UNKNOWN;

        } catch (TimeoutException | SocketTimeoutException e) {
            // ★ 超时是 UNKNOWN 的主要来源
            return InvokeResult.UNKNOWN;
        } catch (IOException e) {
            // 连接被拒(服务没起来)→ 大概率没执行,判 FAIL 更激进但更安全
            // 生产上建议:对"明确幂等的接口"判 FAIL,对"非幂等的写接口"判 UNKNOWN
            return InvokeResult.UNKNOWN;
        } catch (Exception e) {
            return InvokeResult.UNKNOWN;
        } finally {
            // ★ 无论成功失败,调用流水必留痕(UNKNOWN 就靠它查)
            saveLog(branch, phase, paramsJson, response, success,
                    (int) (System.currentTimeMillis() - start));
        }
    }

    private void saveLog(TxBranch branch, String phase, String req,
                         String resp, Boolean success, int cost) {
        try {
            TxInvokeLog log = new TxInvokeLog();
            log.setTxId(branch.getTxId());
            log.setBranchId(branch.getBranchId());
            log.setPhase(phase);
            log.setRequest(req);
            log.setResponse(resp == null ? null : resp.substring(0, Math.min(resp.length(), 2048)));
            log.setSuccess(success);
            log.setCostMs(cost);
            logMapper.insert(log);
        } catch (Exception e) {
            // 日志写失败不能影响主流程,但要打日志
            log.error("save invoke log failed", e);
        }
    }
}

⑤ 推进引擎(正向推进 + 反向补偿)

@Component
@RequiredArgsConstructor
@Slf4j
public class TxEngine {

    private final TxRecordMapper txRecordMapper;
    private final TxBranchMapper txBranchMapper;
    private final BranchInvoker invoker;
    private final TxQueryService queryService;   // 反查服务(处理 UNKNOWN)
    private final AlarmService alarmService;

    private final ExecutorService executor = new ThreadPoolExecutor(
            8, 32, 60L, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(5000),
            new ThreadFactoryBuilder().setNameFormat("mini-tx-engine-%d").build(),
            new ThreadPoolExecutor.CallerRunsPolicy());   // ★ 队列满了由业务线程跑,防止丢任务

    public void submit(String txId) {
        executor.execute(() -> {
            try {
                drive(txId);
            } catch (Exception e) {
                log.error("[MiniTx] drive error. txId={}", txId, e);
            }
        });
    }

    /**
     * 推进主事务:这是唯一的驱动入口,Recovery 也调它(幂等)。
     */
    @Transactional
    public void drive(String txId) {
        TxRecord rec = txRecordMapper.selectForUpdate(txId);   // ★ 行锁,防并发推进
        if (rec == null) return;

        // 终态直接返回(幂等)
        if (TxStatus.CONFIRMED.name().equals(rec.getStatus())
                || TxStatus.CANCELED.name().equals(rec.getStatus())) {
            return;
        }

        // 超时 → 强制补偿
        if (rec.getTimeoutAt().isBefore(LocalDateTime.now())
                && TxStatus.TRYING.name().equals(rec.getStatus())) {
            rec.setStatus(TxStatus.CANCELING.name());
            txRecordMapper.updateById(rec);
        }

        if (TxStatus.CANCELING.name().equals(rec.getStatus())) {
            doCompensate(rec);
            return;
        }

        // ── 正向推进:按 seq 顺序执行 INIT 分支 ──
        List<TxBranch> branches = txBranchMapper.selectByTxIdOrderBySeq(txId);

        for (TxBranch br : branches) {
            if (BranchStatus.SUCCESS.name().equals(br.getStatus())) continue;
            if (BranchStatus.SKIPPED.name().equals(br.getStatus())) continue;

            if (BranchStatus.FAIL.name().equals(br.getStatus())) {
                // 明确失败且已超过正向重试次数 → 转补偿
                if (br.getRetryCount() >= rec.getMaxRetry()) {
                    rec.setStatus(TxStatus.CANCELING.name());
                    txRecordMapper.updateById(rec);
                    doCompensate(rec);
                    return;
                }
            }

            // 抢锁:CAS INIT/PROCESSING/FAIL -> PROCESSING
            int n = txBranchMapper.casStatus(
                    br.getId(),
                    List.of(BranchStatus.INIT.name(), BranchStatus.COMPENSATE_FAIL.name(),
                            BranchStatus.FAIL.name(), BranchStatus.PROCESSING.name()),
                    BranchStatus.PROCESSING.name());
            if (n == 0) {
                // 被其他线程/实例抢走了(比如 Recovery 正在处理 UNKNOWN)→ 本次不推
                log.info("[MiniTx] branch locked by other. txId={} branch={}", txId, br.getBranchId());
                return;
            }

            InvokeResult r = invoker.invoke(br, "TRY", br.getParams());

            if (r == InvokeResult.SUCCESS) {
                br.setStatus(BranchStatus.SUCCESS.name());
                br.setLastError(null);
            } else if (r == InvokeResult.FAIL) {
                br.setStatus(BranchStatus.FAIL.name());
                br.setRetryCount(br.getRetryCount() + 1);
                br.setNextRetryTime(LocalDateTime.now().plusSeconds(
                        Backoff.nextDelay(br.getRetryCount())));
            } else {
                // UNKNOWN:保持 PROCESSING,等 Recovery 反查
                // ★ 关键点:不改状态、不重试、不补偿,交给反查决定
                br.setStatus(BranchStatus.PROCESSING.name());
                br.setNextRetryTime(LocalDateTime.now().plusSeconds(10));
                log.warn("[MiniTx] branch UNKNOWN, wait for query. txId={} branch={}",
                        txId, br.getBranchId());
            }
            txBranchMapper.updateById(br);

            if (r != InvokeResult.SUCCESS) {
                return;   // 本次推进结束,等下次重试或 Recovery
            }
        }

        // 全部成功
        boolean allDone = branches.stream().allMatch(b ->
                BranchStatus.SUCCESS.name().equals(b.getStatus())
                        || BranchStatus.SKIPPED.name().equals(b.getStatus()));
        if (allDone) {
            rec.setStatus(TxStatus.CONFIRMED.name());
            txRecordMapper.updateById(rec);
        }
    }

    /**
     * 补偿:按 seq 倒序,补偿所有 SUCCESS 的分支。
     * ★ 补偿失败不抛异常,留在表里等 Recovery 重试(这是"能自愈"的关键)
     */
    private void doCompensate(TxRecord rec) {
        List<TxBranch> branches = txBranchMapper.selectByTxIdOrderBySeqDesc(rec.getTxId());
        boolean allCompensated = true;

        for (TxBranch br : branches) {
            // 只有正向成功过的分支才需要补偿
            if (!BranchStatus.SUCCESS.name().equals(br.getStatus())) continue;
            if (BranchStatus.COMPENSATED.name().equals(br.getStatus())) continue;
            if (br.getCompensate() == null) {
                br.setStatus(BranchStatus.COMPENSATED.name());   // 无需补偿的分支,直接标记
                txBranchMapper.updateById(br);
                continue;
            }

            // 检查是否到了重试时间
            if (br.getNextRetryTime() != null
                    && br.getNextRetryTime().isAfter(LocalDateTime.now())) {
                allCompensated = false;
                continue;
            }

            int n = txBranchMapper.casStatus(br.getId(),
                    List.of(BranchStatus.SUCCESS.name(), BranchStatus.COMPENSATE_FAIL.name()),
                    BranchStatus.COMPENSATING.name());
            if (n == 0) { allCompensated = false; continue; }

            InvokeResult r = invoker.invoke(br, "CANCEL", buildCompensateParams(br));

            if (r == InvokeResult.SUCCESS) {
                br.setStatus(BranchStatus.COMPENSATED.name());
                br.setLastError(null);
            } else {
                br.setStatus(BranchStatus.COMPENSATE_FAIL.name());
                br.setRetryCount(br.getRetryCount() + 1);
                br.setNextRetryTime(LocalDateTime.now().plusSeconds(
                        Backoff.nextDelay(br.getRetryCount())));
                br.setLastError(r == InvokeResult.UNKNOWN ? "UNKNOWN" : "FAIL");
                allCompensated = false;

                // ★ 补偿重试超过阈值 → 告警(人工必须介入)
                if (br.getRetryCount() >= 8) {
                    alarmService.critical("TX_COMPENSATE_FAILED",
                            String.format("补偿失败超过8次,需人工处理!txId=%s branch=%s bizKey=%s",
                                    rec.getTxId(), br.getBranchId(), rec.getBizKey()));
                }
            }
            txBranchMapper.updateById(br);
        }

        if (allCompensated) {
            rec.setStatus(TxStatus.CANCELED.name());
            txRecordMapper.updateById(rec);
        }
    }

    private String buildCompensateParams(TxBranch br) {
        // 用正向的入参 + 正向的返回结果构造补偿入参
        // 例如:正向 {"skuId":1,"num":2},返回 {"freezeId":"F123"}
        //      补偿 {"skuId":1,"num":2,"freezeId":"F123"}
        Map<String, Object> p = JsonUtil.parse(br.getParams());
        if (StringUtils.hasText(br.getResult())) {
            p.putAll(JsonUtil.parse(br.getResult()));
        }
        return JsonUtil.toJson(p);
    }
}

⑥ 退避策略(Backoff)

public final class Backoff {
    /** 阶梯退避:秒。第 8 次之后固定 1 小时,避免无限频繁重试打垮下游 */
    private static final int[] DELAYS = {5, 15, 60, 300, 900, 3600, 3600, 3600, 3600, 3600};

    public static int nextDelay(int retryCount) {
        if (retryCount <= 0) return DELAYS[0];
        return DELAYS[Math.min(retryCount - 1, DELAYS.length - 1)];
    }
}

⑦ Recovery 定时任务(自愈的心脏)

@Component
@RequiredArgsConstructor
@Slf4j
public class TxRecoveryJob {

    private final TxRecordMapper txRecordMapper;
    private final TxEngine txEngine;
    private final TxQueryService queryService;

    /**
     * 每 5 秒扫一批"卡住"的事务。
     * ★ 只扫"需要重试且到了重试时间"的记录,走 idx_status_retry 索引
     */
    @Scheduled(fixedDelay = 5000)
    public void recover() {
        List<String> txIds = txRecordMapper.selectStuckTxIds(
                List.of(TxStatus.TRYING.name(), TxStatus.CANCELING.name()),
                LocalDateTime.now(),
                200);   // 每批处理 200 个,防止一次拉太多
        for (String txId : txIds) {
            try {
                txEngine.drive(txId);
            } catch (Exception e) {
                log.error("[MiniTx] recover error. txId={}", txId, e);
            }
        }
    }

    /**
     * 处理 UNKNOWN 分支:反查对端,确认到底成没成。
     * 每 30 秒一次(比主推进慢,因为反查有成本)
     */
    @Scheduled(fixedDelay = 30_000)
    public void resolveUnknown() {
        List<TxBranch> unknowns = txBranchMapper.selectUnknownBranches(
                List.of(BranchStatus.PROCESSING.name(), BranchStatus.COMPENSATING.name()),
                LocalDateTime.now().minusSeconds(30),   // 30 秒还没结果才反查
                100);

        for (TxBranch br : unknowns) {
            try {
                // 反查:调用对端的 query 接口,用 bizKey 查
                QueryResult qr = queryService.query(br);
                if (qr == QueryResult.DONE) {
                    br.setStatus("CANCEL".equals(qr.getPhase())
                            ? BranchStatus.COMPENSATED.name() : BranchStatus.SUCCESS.name());
                    txBranchMapper.updateById(br);
                    txEngine.drive(br.getTxId());       // 继续推进
                } else if (qr == QueryResult.NOT_FOUND) {
                    // 对端明确没有 → 没执行 → 可以安全重试或标记失败
                    br.setStatus(BranchStatus.FAIL.name());
                    br.setNextRetryTime(LocalDateTime.now().plusSeconds(5));
                    txBranchMapper.updateById(br);
                    txEngine.drive(br.getTxId());
                }
                // 仍是 UNKNOWN → 什么都不做,下次再查(★ 金额类绝不盲动)
            } catch (Exception e) {
                log.error("[MiniTx] resolve unknown error. branch={}", br.getBranchId(), e);
            }
        }
    }
}

⑧ 业务侧怎么用(完整示例:下单三分支)

@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

    private final OrderMapper orderMapper;
    private final TxContextHolder holder;

    @Override
    @Transactional                     // ← 本地事务
    @MiniTx(bizType = "ORDER_CREATE", bizKey = "#req.orderNo", timeout = 60)
    public CreateOrderResp createOrder(CreateOrderReq req) {

        // ① 本地业务:创建订单(与事务记录同库同事务)
        Order order = new Order();
        order.setOrderNo(req.getOrderNo());
        order.setUserId(req.getUserId());
        order.setAmount(req.getAmount());
        order.setStatus(OrderStatus.INIT.name());
        orderMapper.insert(order);

        // ② 注册分支(落 tx_branch,同本地事务)
        TxContext.current()
            .addBranch("STOCK", "inventory-service",
                       "/stock/deduct", "/stock/deduct/rollback",
                       Map.of("skuId", req.getSkuId(), "num", req.getNum(),
                              "orderNo", req.getOrderNo()))
            .addBranch("BALANCE", "account-service",
                       "/balance/freeze", "/balance/freeze/rollback",
                       Map.of("userId", req.getUserId(), "amount", req.getAmount(),
                              "orderNo", req.getOrderNo()))
            .addBranch("POINT", "point-service",
                       "/point/add", null,          // 加积分失败不需要补偿(不重要的分支)
                       Map.of("userId", req.getUserId(), "point", req.getAmount() / 100));

        return new CreateOrderResp(req.getOrderNo());
    }
}

下游服务的分支接口(必须幂等,见 2.5.4):

@RestController
@RequestMapping("/stock")
@RequiredArgsConstructor
public class StockController {

    /**
     * 扣减库存(正向)
     * ★ 幂等键:orderNo + skuId
     */
    @PostMapping("/deduct")
    public Result<DeductResp> deduct(@RequestBody DeductReq req) {
        // ① 幂等校验(见 2.5.4.2)
        String idemKey = "deduct:" + req.getOrderNo() + ":" + req.getSkuId();
        IdempotenceGuard guard = idempotence.begin(idemKey);
        if (guard.isDuplicated()) {
            return Result.success(guard.getPreviousResult());  // 重复请求,直接返回上次结果
        }

        // ② 业务执行(乐观锁防超卖)
        int n = stockMapper.deductWithCas(req.getSkuId(), req.getNum());
        if (n == 0) {
            guard.fail("库存不足");
            return Result.fail("STOCK_NOT_ENOUGH", "库存不足");
        }

        // ③ 记录冻结流水(补偿时需要)
        String freezeId = freezeLogService.record(req);

        DeductResp resp = new DeductResp(freezeId);
        guard.success(resp);
        return Result.success(resp);
    }

    /**
     * 库存回滚(补偿)
     * ★ 必须处理:空回滚、幂等、防悬挂(2.5.4)
     */
    @PostMapping("/deduct/rollback")
    public Result<Void> rollback(@RequestBody DeductReq req) {
        // ① 防悬挂:如果这条订单的扣减还没发生(Try 未执行),Cancel 先到
        //    → 记录一条"空回滚"标记,让后续的 Try 直接失败
        if (!freezeLogService.exists(req.getOrderNo(), req.getSkuId())) {
            emptyRollbackService.mark(req.getOrderNo(), req.getSkuId());  // 空回滚记痕
            return Result.success();   // ★ 空回滚也返回成功,否则协调器会一直重试
        }

        // ② 幂等:已经回滚过就直接返回
        if (freezeLogService.isRollbacked(req.getOrderNo(), req.getSkuId())) {
            return Result.success();
        }

        stockMapper.refund(req.getSkuId(), req.getNum());
        freezeLogService.markRollbacked(req.getOrderNo(), req.getSkuId());
        return Result.success();
    }
}

2.5.3.5 自研 Mini-DTX vs Seata:什么时候该自研

维度 Seata(AT/TCC/Saga) 自研 Mini-DTX 纯本地消息表
引入成本 中(要部署 TC 集群 + 配置) 低(3 张表 + ~800 行代码) 低(1 张表 + 定时任务)
一致性级别 AT 接近强一致(全局锁) 最终一致(秒~分钟级) 最终一致
隔离性 AT 有全局锁,防脏写 无隔离性(补偿前能看到中间态) 无隔离性
RT 影响 AT 有全局锁开销 业务 RT 无影响(异步推进) 无影响
侵入性 AT 低(注解)/ TCC 高 中(要写补偿接口) 中
运维成本 高(TC 要高可用、要监控) 低(无额外组件) 最低
适用场景 同构 MySQL、短事务、要求准强一致 跨异构系统、无中间件、中长事务 只需“通知下游”,无需回滚
你的简历可说 — ★ 可说“我设计过” 项目四已用

什么时候坚决不自研(直接说出来的加分项):

「自研的前提是业务能接受最终一致、且能接受短暂的中间态可见。 如果需要强隔离(比如两个事务同时改同一笔资金,中间态不能外露), 或者需要 AT 那种“自动生成反向 SQL”的能力,我还是会选 Seata—— 因为自研做不了全局锁,做隔离性要付出的代价远超引入一个中间件。」

面试话术(被问“不用 Seata 你怎么保证一致性”):

「我们有个场景确实没上 Seata——(举项目三/项目一某个跨系统流程)。
我的做法是"本地事务表 + 异步补偿 + 对账"三件套:

第一,状态先行。业务数据和事务记录写在同一个本地事务里,
      保证"有业务数据就一定有事务记录",不会出现没人管的孤儿数据。

第二,异步推进 + 自动补偿。分支调用失败就倒序补偿已成功的分支,
      补偿失败留在表里按阶梯退避重试(5s/15s/60s/5min/...),
      超过 8 次告警人工介入。

第三,区分失败和未知。这是最关键的一点——超时不等于失败,
      我把调用结果分成 SUCCESS / FAIL / UNKNOWN 三态,
      UNKNOWN 的不重试也不补偿,而是通过反查接口确认后决定,
      金额类场景宁可挂着走人工,也绝不盲目重试。

第四,Recovery 兜底。进程重启后扫表恢复,协调逻辑是幂等的,
      多实例也能安全抢占(CAS 改状态实现乐观锁)。

第五,对账兜底。每天全量对账,业务表和事务表双向核对,
      漏发的补发、多扣的冲正,差异转人工工单。

这套东西大概 800 行代码,没有引入任何中间件,
上线半年处理了 X 万笔,自动补偿成功率 99.9%,剩下 0.1% 靠对账和人工。」

2.5.4 回滚防御四件套(写补偿接口必须处理,★ 面试必考)

面试官问法:「你说用 TCC / Saga 补偿,那补偿接口要处理哪些问题?」 这是区分“背过 TCC”和“写过 TCC”的分水岭。 标准答案是四个: 空回滚、幂等、防悬挂、去重。少说一个就说明你没真正写过。

2.5.4.1 四件套总览(先给全景,再逐个展开)

# 问题 产生原因 后果 解决手段
① 空回滚 Try 没收到(网络丢包/服务未启动),Cancel 却先到了 补偿一个不存在的操作 → 有的实现直接报错 → 协调器一直重试 → 永远无法回滚 Cancel 发现没有 Try 记录时,也要插入一条“空回滚”标记并返回成功
② 幂等 网络超时导致 Confirm/Cancel 被重复调用 重复扣款、重复还库存 每次调用前查状态,已执行则直接返回上次结果
③ 防悬挂 Cancel 先到(空回滚完成),Try 请求后到(网络延迟) 资源被永久冻结(扣了库存没人还) Try 执行前先查“是否已空回滚”,是则直接放弃执行并标记
④ 去重 上游业务重复提交(用户双击、前端重发) 生成两笔业务 全局唯一幂等键 + 去重表

四件套的时序关系(一张图看懂):

正常时序:
  协调器 ──Try──► 参与者    参与者:记录冻结流水(状态 TRY)
  协调器 ──Confirm──► 参与者 参与者:确认扣减(状态 CONFIRM)

────────────────────────────────────────────────────────────

异常① 空回滚(Try 丢了,Cancel 先到):
  协调器 ──Try──✗(丢包/超时)
  协调器 ──Cancel──► 参与者   参与者:查不到冻结流水
                              ❌ 错误做法:抛异常/返回失败 → 协调器无限重试
                              ✅ 正确做法:插入 (状态=CANCELED, 标记 empty_rollback=1) 返回成功

异常② 幂等(Confirm 超时后重发):
  协调器 ──Confirm──► 参与者  执行成功,但响应丢了
  协调器 ──Confirm──► 参与者(重试)
                              ❌ 错误做法:再扣一次
                              ✅ 正确做法:查到状态已是 CONFIRM,直接返回成功

异常③ 防悬挂(Cancel 完了,迟到的 Try 才到):
  协调器 ──Try──✗(慢,未到达)
  协调器 ──Cancel──► 参与者   空回滚,记录 empty_rollback=1
  协调器 ──Try──► 参与者(迟到的 Try 终于到了)
                              ❌ 错误做法:正常执行冻结 → 库存被永久冻结(悬挂)
                              ✅ 正确做法:发现 empty_rollback=1 → 拒绝执行,返回失败

异常④ 去重(业务层重复提交):
  用户双击提交 ──► 两条一样的下单请求
                              ✅ 用 orderNo(业务主键)做幂等键,第二条直接返回第一条的结果

2.5.4.2 ① 空回滚(Empty Rollback)

为什么会发生:

Try 请求发出去后,可能因为:
  · 网络丢包,参与者根本没收到
  · 参与者收到了,但执行前进程重启(本地事务回滚)
  · 参与者收到了,正在执行,但协调器已超时
协调器超时后决定"全局回滚",向所有分支发 Cancel。
此时分支上可能压根没有 Try 的痕迹。

正确实现(关键代码):

@PostMapping("/balance/freeze/rollback")
public Result<Void> rollback(@RequestBody FreezeReq req) {
    String bizKey = req.getOrderNo() + ":" + req.getUserId();

    // ① 查冻结流水(Try 阶段写的)
    FreezeLog log = freezeLogMapper.selectByBizKey(bizKey);

    if (log == null) {
        // ★ 空回滚:Try 从未执行过
        // 必须"记痕",否则后面的防悬挂无法判断
        emptyRollbackMapper.insertIgnore(new EmptyRollback(bizKey, req));

        // ★ 必须返回成功!否则协调器会一直重试补偿,事务永远无法结束
        log.warn("empty rollback. bizKey={}", bizKey);
        metrics.increment("tx.empty_rollback");   // ★ 要监控,数量异常说明有大问题
        return Result.success();
    }

    // ② 正常回滚逻辑(见下面幂等)
    ...
}

为什么“必须返回成功”(面试高频追问):

补偿失败会导致协调器无限重试,事务永远停在 CANCELING,资源(可能有其他分支的冻结)永远不释放。 空回滚本质上是“无事可做”,无事可做 = 成功。 但要同时做两件事:记痕(供防悬挂判断)+ 打点告警(空回滚比例异常说明上游有问题)。

空回滚记痕表:

CREATE TABLE `tx_empty_rollback` (
  `id`         BIGINT      NOT NULL AUTO_INCREMENT,
  `biz_key`    VARCHAR(128) NOT NULL COMMENT '业务幂等键',
  `tx_id`      VARCHAR(64)  NOT NULL,
  `payload`    JSON COMMENT 'Cancel 请求快照(用于事后追溯)',
  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_biz_key` (`biz_key`)   -- ★ 唯一键,用 insert ignore 实现幂等
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='空回滚记痕(防悬挂依据)';

2.5.4.3 ② 幂等(Idempotence)

TCC 三个方法都必须幂等,因为网络超时导致的重发是必然的,不是小概率事件。

标准实现:状态机前置校验

/**
 * 冻结(Try)—— 必须幂等
 */
@PostMapping("/balance/freeze")
public Result<FreezeResp> freeze(@RequestBody FreezeReq req) {
    String bizKey = req.getOrderNo() + ":" + req.getUserId();

    // ① 查已有记录
    FreezeLog exist = freezeLogMapper.selectByBizKeyForUpdate(bizKey);   // ★ 加行锁,防并发

    if (exist != null) {
        // ② 幂等分支:按已有状态决定返回什么
        switch (exist.getStatus()) {
            case "TRY":
                // Try 过但还没 Confirm → 说明是重复 Try,返回上次结果
                return Result.success(new FreezeResp(exist.getFreezeId()));
            case "CONFIRM":
                // 已经确认过了 → 返回成功(★ 不能报错,否则协调器以为失败去补偿)
                return Result.success(new FreezeResp(exist.getFreezeId()));
            case "CANCEL":
                // 已经回滚了 → 返回业务失败,让协调器走补偿/失败路径
                return Result.fail("ALREADY_CANCELED", "已回滚,不可再冻结");
            default:
                return Result.fail("UNKNOWN_STATUS", exist.getStatus());
        }
    }

    // ③ 首次执行:冻结余额
    int n = accountMapper.freeze(req.getUserId(), req.getAmount());
    if (n == 0) {
        return Result.fail("BALANCE_NOT_ENOUGH", "余额不足");
    }

    String freezeId = IdUtil.fastSnowflakeId();
    freezeLogMapper.insert(new FreezeLog(bizKey, freezeId, req, "TRY"));
    return Result.success(new FreezeResp(freezeId));
}

幂等的三个层次(都要有):

┌─────────────────────────────────────────────────────────┐
│ 层次一:业务状态机幂等(最可靠,必须有)                    │
│   用业务表自身的状态字段判断:"这笔单已经处理过了吗?"        │
│   例:冻结流水表、订单状态机                                │
│   优点:零额外成本,语义最准确                              │
├─────────────────────────────────────────────────────────┤
│ 层次二:幂等表/唯一索引(通用兜底)                         │
│   建一张 tx_idempotent 表,(biz_type, biz_key) 唯一索引    │
│   用 INSERT 成功与否判断是否首次                            │
│   优点:与业务解耦,任何接口都能套                           │
├─────────────────────────────────────────────────────────┤
│ 层次三:Redis SETNX(高性能场景,但要小心)                  │
│   SET key value NX EX 300                                │
│   ★ 陷阱:业务执行失败必须删 key,否则永远无法重试(3.4.3)  │
│   适合:高并发 + 可容忍极小概率不一致的场景                  │
└─────────────────────────────────────────────────────────┘

通用幂等表实现(AOP 版,可套任意接口):

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    /** 业务类型 */
    String type();
    /** 幂等键(SpEL,从参数取) */
    String key();
    /** 过期时间(秒),0=永不过期 */
    int expire() default 0;
    /** 执行中时,并发请求的等待时间(毫秒) */
    long waitMs() default 3000;
}

@Aspect
@Component
@Slf4j
public class IdempotentAspect {

    @Autowired private IdempotentMapper mapper;
    @Autowired private ResultStore resultStore;   // 存上次的执行结果(Redis)

    @Around("@annotation(idem)")
    public Object around(ProceedingJoinPoint pjp, Idempotent idem) throws Throwable {
        String key = SpelUtil.parse(idem.key(), pjp);
        String fullKey = idem.type() + ":" + key;

        // ① 尝试占位(唯一索引实现原子性)
        try {
            mapper.insert(new IdempotentRecord(fullKey, "PROCESSING"));
        } catch (DuplicateKeyException e) {
            // ② 已存在 → 查状态
            IdempotentRecord rec = mapper.selectByKey(fullKey);
            if ("SUCCESS".equals(rec.getStatus())) {
                // 已成功 → 返回缓存的上次结果(★ 必须返回相同结果,不能返回"重复请求")
                return resultStore.get(fullKey);
            }
            if ("PROCESSING".equals(rec.getStatus())) {
                // 并发执行中 → 短暂等待后重试查结果
                Object r = waitForResult(fullKey, idem.waitMs());
                if (r != null) return r;
                throw new BizException("PROCESSING", "请求处理中,请稍后");
            }
            if ("FAILED".equals(rec.getStatus())) {
                // 上次失败了 → 允许重试,先把状态改回 PROCESSING(CAS)
                int n = mapper.casStatus(fullKey, "FAILED", "PROCESSING");
                if (n == 0) throw new BizException("CONFLICT", "并发冲突,请重试");
            }
        }

        // ③ 首次执行
        try {
            Object result = pjp.proceed();
            mapper.casStatus(fullKey, "PROCESSING", "SUCCESS");
            resultStore.set(fullKey, result, idem.expire());   // ★ 缓存结果供重复请求返回
            return result;
        } catch (Throwable ex) {
            // ★ 执行失败必须把状态改回 FAILED,允许下次重试
            //   (很多人的 bug:失败后状态卡在 PROCESSING,导致永远无法重试)
            mapper.casStatus(fullKey, "PROCESSING", "FAILED");
            throw ex;
        }
    }
}

⚠️ 幂等实现的三个致命陷阱(详见 3.4.5,这里重申要点):

  1. 执行失败不改状态 → 请求永远卡在 PROCESSING,业务再也执行不了
  2. Redis TTL 短于重试周期 → 幂等失效(项目四踩过的坑:TTL 5 分钟 < 重试 2 小时)
  3. 返回“重复请求”而不是上次结果 → 上游拿到异常,可能触发错误补偿

2.5.4.4 ③ 防悬挂(Anti-Hanging)

危害最大的一个:空回滚之后,迟到的 Try 把资源冻结了,但永远不会有人来 Confirm 或 Cancel,资源永久悬挂。

正确实现:Try 前必须先查空回滚记录

@PostMapping("/balance/freeze")
public Result<FreezeResp> freeze(@RequestBody FreezeReq req) {
    String bizKey = req.getOrderNo() + ":" + req.getUserId();

    // ★★★ 防悬挂:第一步就是查空回滚记录 ★★★
    if (emptyRollbackMapper.exists(bizKey)) {
        // 已经空回滚过了(说明全局事务已经决定回滚)
        // → 绝对不能执行冻结,否则资源永久悬挂
        log.warn("hanging prevented. bizKey={}", bizKey);
        metrics.increment("tx.hanging_prevented");   // ★ 要监控
        return Result.fail("TX_ALREADY_CANCELED", "事务已回滚,拒绝执行");
    }

    // 正常幂等 + 冻结逻辑……
    ...
}

防悬挂的两种实现对比:

方案 做法 优点 缺点
记痕表(推荐) Cancel 时空回滚写 tx_empty_rollback 表,Try 前查 语义清晰、可追溯、可监控 多一张表、多一次查询
状态字段 在业务冻结流水表插一条 status=CANCELED 的空记录(而不是单独建表) 少一张表 污染业务表,且要处理“这条记录是空的”的语义

注意:防悬挂检查必须在幂等检查之前(顺序不能反):

  • 先查幂等记录 → 没有 → 再查空回滚 → 有 → 拒绝
  • 这个顺序下,空回滚记录和幂等记录是互斥的,逻辑最简单

2.5.4.5 ④ 去重(业务层幂等键)

这一层和 ② 的区别:

  • ② 幂等是框架/TCC 层的(同一次事务内的重复调用)
  • ④ 去重是业务层的(用户重复提交、上游重复下单)

幂等键设计三原则(详见 3.4.4,这里给结论):

❌ 错误:UUID / 时间戳 / 随机数
   原因:每次都不一样,起不到去重作用

❌ 错误:只用 userId
   原因:同一个用户可以下多单,会误判为重复

✅ 正确:业务语义决定,必须"同一次业务操作恒定,不同业务操作必不同"
   例:userId + activityId + date      (项目四发奖:一个用户一天一个活动只能发一次)
   例:orderNo                          (订单号天然唯一)
   例:orderNo + skuId                  (一个订单里同一商品只扣一次库存)

去重表必须有唯一索引(不能只靠先查后插):

CREATE TABLE `biz_idempotent` (
  `id`         BIGINT       NOT NULL AUTO_INCREMENT,
  `biz_type`   VARCHAR(32)  NOT NULL,
  `biz_key`    VARCHAR(128) NOT NULL,
  `result`     VARCHAR(2048) COMMENT '首次执行结果(JSON)',
  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_type_key` (`biz_type`, `biz_key`)   -- ★ 数据库做最后的原子保证
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

面试加分点:「为什么不能“先查再插”?」 因为高并发下两个请求同时查到“不存在”,然后都插入成功 → 去重失效。 必须靠数据库唯一索引做原子保证,“先查”只是为了快速返回和拿上次结果,不是正确性保证。

2.5.4.6 补偿接口设计的 5 条铁律

┌────────────────────────────────────────────────────────────────┐
│ 铁律一:可重入。补偿接口必须能被调用 N 次,结果一致。              │
│        (做不到幂等的补偿接口 = 定时炸弹)                        │
├────────────────────────────────────────────────────────────────┤
│ 铁律二:不依赖请求上下文。补偿可能发生在几小时后,                 │
│        线程上下文、ThreadLocal、Session 全都没了。                │
│        → 所有需要的入参必须能从 tx_branch.params / result 还原     │
├────────────────────────────────────────────────────────────────┤
│ 铁律三:能按业务主键补偿。不能只支持"按 txId 补偿",                │
│        因为人工介入时往往只有业务单号(客服给的是订单号)。          │
│        → 提供 /rollback?orderNo=xxx 这种按业务键补偿的接口          │
├────────────────────────────────────────────────────────────────┤
│ 铁律四:补偿要有凭证。补偿前必须能查到"正向确实执行过"的流水,        │
│        不能凭空反向操作。                                        │
│        → 冻结流水表就是凭证                                      │
├────────────────────────────────────────────────────────────────┤
│ 铁律五:补偿失败要有出口。不能无限重试(打垮下游),                 │
│        也不能一次失败就放弃(留下脏数据)。                        │
│        → 阶梯退避 + 次数上限 + 告警 + 人工工单                     │
└────────────────────────────────────────────────────────────────┘

2.5.5 协调器宕机了怎么办(高可用 + 自愈)

面试官问法:「你的协调器(TC / 自建 Recovery)挂了,正在执行的事务怎么办?」 核心答案:协调器必须是“无状态”的,状态全在 DB。协调器只是一个“扫表 + 驱动”的执行器。

2.5.5.1 三类宕机场景与影响

┌──────────────────────────────────────────────────────────────────┐
│ 场景 A:业务服务(事务发起方)宕机                                  │
│   时刻:本地事务已提交,事务记录已落库,分支还没调用                 │
│   影响:数据不会丢(在 DB 里),但没人推进 → 事务卡在 TRYING         │
│   恢复:其他实例/重启后,Recovery 扫表继续推进 ✅ 自动自愈            │
├──────────────────────────────────────────────────────────────────┤
│ 场景 B:分支调用已发出,业务服务宕机(结果未知)★ 最危险              │
│   时刻:HTTP 已发出,响应还没收到,进程被 kill                       │
│   影响:分支状态卡在 PROCESSING,不知道对方做没做                    │
│   恢复:Recovery 发现 PROCESSING 超过 30s → 反查接口确认 → 推进      │
│        ⚠️ 必须有反查接口,否则只能人工!                            │
├──────────────────────────────────────────────────────────────────┤
│ 场景 C:协调器(Recovery 节点)全部宕机                             │
│   影响:所有中间态事务暂停推进,但不丢数据                           │
│   恢复:重启任一实例即可继续;多实例部署时其他实例自动接管            │
│   关键:Recovery 必须是无状态 + 可抢占的                            │
└──────────────────────────────────────────────────────────────────┘

2.5.5.2 协调器高可用的三个必备设计

① 状态全在 DB,协调器无状态

❌ 错误设计:协调器内存里维护"正在执行的事务列表"
   → 协调器一挂,列表丢失,事务永久悬挂

✅ 正确设计:协调器只是"扫表 + 执行 + 改表"
   → 任何实例都能接手任何事务
   → 加机器就是加能力,不需要数据迁移

② 多实例安全抢占(防止重复推进)

三种实现方式,选一种即可:

// ── 方式一:MySQL 行锁 SELECT ... FOR UPDATE(最简单,推荐)──
@Transactional
public void drive(String txId) {
    TxRecord rec = txRecordMapper.selectForUpdate(txId);   // 行锁,其他实例阻塞
    // ... 推进逻辑
}


// ── 方式二:CAS 乐观锁(无阻塞,性能好)──
public void drive(String txId) {
    int n = txRecordMapper.casStatus(txId, "TRYING", "PROCESSING_BY_" + instanceId, version);
    if (n == 0) return;   // 被别的实例抢了,直接返回
    try {
        // ... 推进逻辑
    } finally {
        txRecordMapper.casStatus(txId, "PROCESSING_BY_" + instanceId, newStatus);
    }
}


// ── 方式三:ShedLock(分布式定时任务锁,最省事)──
@Scheduled(fixedDelay = 5000)
@SchedulerLock(name = "tx_recovery", lockAtMostFor = "4m", lockAtLeastFor = "1s")
public void recover() {
    // 同一时刻只有一台机器执行
    // lockAtMostFor 必须 > 单次执行最长时间,防止节点挂了锁不释放
    ...
}
-- ShedLock 表(如果用方式三)
CREATE TABLE `shedlock` (
  `name`       VARCHAR(64)  NOT NULL,
  `lock_until` TIMESTAMP(3) NOT NULL,
  `locked_at`  TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `locked_by`  VARCHAR(255) NOT NULL,
  PRIMARY KEY (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

③ 租约(Lease)机制:处理“持有者假死”

问题:如果用 lock_until 固定时间,实例 A 拿到锁后卡死(Full GC / 网络分区),超过 lock_until 时间后实例 B 接管,但 A 可能还在跑 → 双重执行。

解决:租约 + 心跳 + fencing token( fencing 令牌)

CREATE TABLE `tx_lease` (
  `id`           BIGINT      NOT NULL AUTO_INCREMENT,
  `resource`     VARCHAR(64) NOT NULL COMMENT '锁定的资源,如 recovery-shard-0',
  `holder`       VARCHAR(64) NOT NULL COMMENT '持有者:ip:port:uuid',
  `fencing_token` BIGINT     NOT NULL COMMENT '★ 单调递增令牌,防旧持有者误操作',
  `expire_at`    DATETIME(3) NOT NULL,
  `update_time`  DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_resource` (`resource`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@Component
@Slf4j
public class LeaseManager {

    /** 获取租约(含 fencing token) */
    public Lease acquire(String resource, String holder, Duration ttl) {
        Lease cur = leaseMapper.selectByResource(resource);
        long newToken = (cur == null ? 0 : cur.getFencingToken()) + 1;

        if (cur == null) {
            try {
                leaseMapper.insert(new Lease(resource, holder, newToken,
                        LocalDateTime.now().plus(ttl)));
                return new Lease(resource, holder, newToken, LocalDateTime.now().plus(ttl));
            } catch (DuplicateKeyException e) {
                return null;   // 被别人抢先
            }
        }

        if (cur.getExpireAt().isBefore(LocalDateTime.now())
                || holder.equals(cur.getHolder())) {
            // 租约过期 或 是自己的(续约)→ 抢占,token +1
            int n = leaseMapper.cas(resource, cur.getFencingToken(), holder, newToken,
                    LocalDateTime.now().plus(ttl));
            return n > 0 ? new Lease(resource, holder, newToken, LocalDateTime.now().plus(ttl)) : null;
        }
        return null;   // 别人持有且未过期
    }

    /**
     * ★ 关键:每次写操作都带上 fencing token 校验。
     * 旧持有者(假死后恢复)的 token 更小 → 写操作被拒绝 → 不会破坏数据。
     */
    public void driveWithFencing(String txId, Lease lease) {
        int n = txBranchMapper.updateWithFencing(txId, lease.getFencingToken(), ...);
        // UPDATE tx_branch SET ... WHERE tx_id=? AND fencing_token <= ?
    }
}

面试话术:「多实例抢占我用两层保护: 第一层是数据库行锁 / 唯一索引做互斥,保证同一时刻只有一个实例处理; 第二层是 fencing token(单调递增令牌),防止实例假死(Full GC)后“复活”继续写, 写操作带上 token 校验,token 比当前小的请求直接拒绝。 Seata 的 TC 也是类似思路,只是它的锁在 TC 侧,我的锁在 DB。」

2.5.5.3 UNKNOWN 状态处理(★ 金额类场景的核心难题)

这是“协调器宕机”最难的部分:分支调用发出去了,响应没收到,进程挂了。 重启后你不知道对方到底扣没扣。

处理原则(背下来):

┌────────────────────────────────────────────────────────────────────┐
│ 原则一:超时 ≠ 失败。                                                │
│   超时可能是"请求没到"(没执行),也可能是"响应没回来"(已执行)。      │
│   把超时当失败去补偿 = 可能"回滚了一个不存在的操作"或更糟。             │
├────────────────────────────────────────────────────────────────────┤
│ 原则二:能反查就反查,反查不到才交给人工。                             │
│   反查接口是设计前提,不是可选项。                                    │
│   没有反查接口的外部系统(如银行)→ 只能走对账文件。                    │
├────────────────────────────────────────────────────────────────────┤
│ 原则三:金额类宁可"少做",不可"多做"。                                 │
│   UNKNOWN 时:重试 = 可能重复扣款(资金损失 + 客诉)                   │
│              不重试 = 可能少扣(对账能发现,可补扣)                   │
│   → 选择不重试,转人工/对账。这是业务权衡,不是技术问题。                │
├────────────────────────────────────────────────────────────────────┤
│ 原则四:所有 UNKNOWN 必须可观测。                                     │
│   UNKNOWN 数量 / 平均悬挂时长 / 超过 X 分钟未决的 → 都要告警。          │
│   "静默的 UNKNOWN" 是最可怕的,等你发现时可能已经错了几万笔。            │
└────────────────────────────────────────────────────────────────────┘

反查接口设计(下游服务必须提供):

/**
 * 事务结果反查接口。
 * ★ 这是"可自愈"的前提:协调器必须能用一个业务键问出"你做没做、做成了没"
 */
@GetMapping("/tx/query")
public Result<TxQueryResp> query(@RequestParam String bizKey) {

    FreezeLog log = freezeLogMapper.selectByBizKey(bizKey);

    if (log == null) {
        // 从未执行过 → 可以安全重试
        return Result.success(new TxQueryResp(TxState.NOT_FOUND, null));
    }

    // 映射自己的状态到事务状态
    TxState state = switch (log.getStatus()) {
        case "TRY"      -> TxState.TRY_SUCCESS;    // 冻结了,未确认
        case "CONFIRM"  -> TxState.CONFIRMED;      // 已完成
        case "CANCEL"   -> TxState.CANCELED;       // 已回滚
        default         -> TxState.UNKNOWN;
    };

    return Result.success(new TxQueryResp(state, log.getFreezeId()));
}

UNKNOWN 处理流程(画在白板上的图):

Recovery 发现分支卡在 PROCESSING 超过 30s
        │
        ▼
  调用反查接口 GET /tx/query?bizKey=xxx
        │
   ┌────┴─────┬──────────────┬────────────────┐
   │          │              │                │
NOT_FOUND  TRY_SUCCESS    CONFIRMED        CANCELED
   │          │              │                │
   ▼          ▼              ▼                ▼
没执行过   执行了但未确认   已完成          已回滚
   │          │              │                │
   ▼          ▼              ▼                ▼
标记 FAIL  按主事务决定    标记 SUCCESS     标记 COMPENSATED
可以安全   (继续推进 or   继续推进          继续推进
重试       补偿)
   │
   └──► 但仍需重试次数上限:反查 N 次还是 NOT_FOUND 且业务不允许重试 → 告警人工

⚠️ 特殊情况:反查接口也超时 / 不可用
   → 保持 PROCESSING,下次再查(绝不盲动)
   → 超过 10 分钟仍 UNKNOWN → 升级告警(P1),值班人员介入

2.5.5.4 超时时间分层设计(别用同一个超时值)

┌────────────────────────────────────────────────────────────────┐
│ 第一层:单次 HTTP 调用超时      3s                               │
│   作用:快速失败,不拖垮线程池                                    │
├────────────────────────────────────────────────────────────────┤
│ 第二层:分支调用超时(含重试)   10s                              │
│   = 单次超时 3s × 重试 3 次                                      │
│   超过 → 分支判 UNKNOWN,转反查                                   │
├────────────────────────────────────────────────────────────────┤
│ 第三层:主事务全局超时           60s(@MiniTx(timeout=60))        │
│   超过 → 强制进入 CANCELING(不管分支在干什么)                     │
├────────────────────────────────────────────────────────────────┤
│ 第四层:补偿超时                 30 min                          │
│   补偿可以慢(用户已经看到失败了),但不能无限                      │
├────────────────────────────────────────────────────────────────┤
│ 第五层:人工介入阈值             补偿重试 8 次 / 2 小时             │
│   超过 → P1 告警 + 工单                                          │
└────────────────────────────────────────────────────────────────┘

★ 设计原则:层层放大,上层 >= 下层之和
  否则会出现"全局已超时开始补偿,但分支还在重试正向调用"的矛盾状态

2.5.5.5 监控指标(没有监控 = 没有兜底)

# Prometheus 指标定义
- tx_record_status_total{status}          # 各状态事务数(★ 中间态持续增长=故障)
- tx_branch_status_total{service,status}  # 各服务分支状态
- tx_stuck_duration_seconds{quantile}     # 中间态事务的悬挂时长分位
- tx_unknown_total                        # UNKNOWN 累计(★ 核心告警)
- tx_empty_rollback_total                 # 空回滚次数(异常增长=上游有问题)
- tx_hanging_prevented_total              # 防悬挂拦截次数
- tx_compensate_failed_total              # 补偿失败次数
- tx_recovery_scanned_total               # Recovery 每轮扫描量(★ 突增=积压)
# 告警规则(Grafana / AlertManager)
groups:
  - name: distributed_tx
    rules:
      - alert: TxStuckTooMany
        expr: sum(tx_record_status_total{status=~"TRYING|CANCELING"}) > 100
        for: 5m
        annotations:
          summary: "中间态事务堆积超过 100 笔,协调器可能故障"

      - alert: TxUnknownAppeared
        expr: increase(tx_unknown_total[5m]) > 0
        for: 1m
        labels: { severity: warning }
        annotations:
          summary: "出现 UNKNOWN 事务,需关注是否会自动恢复"

      - alert: TxStuckTooLong
        expr: tx_stuck_duration_seconds{quantile="0.95"} > 600
        for: 10m
        labels: { severity: critical }
        annotations:
          summary: "P95 事务悬挂超过 10 分钟,补偿链路可能已断"

      - alert: TxCompensateFailed
        expr: increase(tx_compensate_failed_total[10m]) > 5
        for: 5m
        labels: { severity: critical }
        annotations:
          summary: "补偿连续失败,数据一致性风险!"

2.5.6 热点商品的锁竞争(★ 秒杀场景下的“事务失效”主因)

面试官问法:「秒杀场景,10 万 QPS 抢 100 件商品,你用 TCC 预留库存,结果大量超时失败,怎么解决?」 关键认知:这道题表面问“怎么优化”,实际考的是“你知不知道热点场景下不该用预留型分布式事务”。

2.5.6.1 问题本质:一行库存就是一把全局锁

t_stock 表:
  sku_id = 1001, stock = 100    ← 这一行,10 万个请求都要改它

MySQL InnoDB 行锁机制:
  请求1 UPDATE t_stock SET stock=stock-1 WHERE sku_id=1001  → 拿到行锁
  请求2 ... 请求100000  → 全部阻塞等待同一把行锁

后果链(这是重点,能讲出这条链就是 P7):
  ┌────────────────────────────────────────────────────────────┐
  │ 并发锁等待 ↑                                                 │
  │   → 单次 UPDATE 耗时从 1ms 涨到 500ms+                        │
  │   → 事务 RT 超过超时阈值 → 大量事务超时回滚                     │
  │   → 应用层重试 → 并发更高 → 锁竞争更激烈(雪崩正反馈)           │
  │   → 连接池耗尽 → 所有接口(不只是秒杀)全部不可用                │
  │   → 【分布式事务大量失败】—— 不是方案 bug,是场景选错了            │
  └────────────────────────────────────────────────────────────┘

为什么 TCC / 2PC 在热点场景是“反模式”:

特性 在正常场景 在热点场景(10 万 QPS 打一行)
预留资源(Try 冻结) 隔离性好,防脏写 把 100 件库存在 Try 阶段就锁住,其它请求全部阻塞
长事务持有锁 毫秒级无所谓 锁持有 = 一次网络往返 + 业务处理,几百 ms × 10 万 = 灾难
全局锁(Seata AT) 防脏写 直接成为全系统瓶颈点
补偿/回滚 小概率,可接受 大量超时 → 大量补偿 → 补偿本身也是热点写

一句话结论: 热点场景下,“先抢到资格,再异步落地” 才是正确姿势; 任何“先预留、再确认”的方案都会把并发转成串行锁等待。

2.5.6.2 五层解法(从推荐到兜底)

┌────────────────────────────────────────────────────────────────────┐
│ 第①层(推荐主方案):Redis 原子预扣 + 异步落库                        │
│   思路:库存放 Redis,Lua 脚本原子扣减,扣到的人拿"下单资格",         │
│        再通过 MQ 异步创建订单 + 扣 DB 库存                            │
│   并发:Redis 单机 10 万 QPS 无压力(Lua 是单线程原子执行)            │
│   一致性:最终一致;DB 扣减失败 → 反向归还 Redis                       │
├────────────────────────────────────────────────────────────────────┤
│ 第②层:库存分桶(DB 层拆分,解决"必须走 DB"的场景)                   │
│   思路:一行库存拆成 N 行(桶),请求分散到不同桶,锁竞争降为 1/N       │
│   适用:不能引入 Redis,或必须 DB 强校验的场景(如资金账户)            │
├────────────────────────────────────────────────────────────────────┤
│ 第③层:请求串行化(单品队列)                                         │
│   思路:同一 sku 的请求进同一个队列/线程,把并发转串行(无锁)           │
│   适用:极端热点(如 1 个商品 100 万 QPS)                            │
├────────────────────────────────────────────────────────────────────┤
│ 第④层:热点 key 打散(缓存层)                                        │
│   思路:stock:{skuId} 拆成 stock:{skuId}:0~9,读时随机选一个           │
│   适用:读多写少的缓存热点(和 4.5.2 呼应)                            │
├────────────────────────────────────────────────────────────────────┤
│ 第⑤层:业务降级(兜底,不是技术解)                                    │
│   思路:允许超卖少量 → 事后退款;或限流直接拒绝一部分请求                │
│   适用:业务可接受时,这是成本最低的方案                                │
└────────────────────────────────────────────────────────────────────┘

2.5.6.3 第①层:Redis 原子预扣 + 异步落库(完整实现)

Lua 脚本(原子预扣,防超卖的核心):

-- deduct_stock.lua
-- KEYS[1] = 库存 key:      seckill:stock:{skuId}
-- KEYS[2] = 已购用户集合:   seckill:users:{skuId}   (防重复购买)
-- ARGV[1] = 扣减数量
-- ARGV[2] = 用户ID
-- 返回: 1=成功  -1=已售罄  -2=库存不足  -3=重复购买

-- ① 防重复购买
if redis.call('SISMEMBER', KEYS[2], ARGV[2]) == 1 then
    return -3
end

-- ② 查库存
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil then
    return -1          -- key 不存在 = 未初始化 = 视为售罄
end
if stock <= 0 then
    return -1          -- 已售罄
end
if stock < tonumber(ARGV[1]) then
    return -2          -- 库存不足
end

-- ③ 扣减 + 记录用户(★ 两步在同一个 Lua 里,保证原子)
redis.call('DECRBY', KEYS[1], ARGV[1])
redis.call('SADD', KEYS[2], ARGV[2])

-- ④ 如果扣完为 0,设置售罄标记(供本地缓存快速拦截)
if stock - tonumber(ARGV[1]) == 0 then
    redis.call('SET', KEYS[1] .. ':sold_out', '1', 'EX', 3600)
end

return 1

Java 侧实现:

@Service
@RequiredArgsConstructor
@Slf4j
public class SeckillService {

    private final StringRedisTemplate redis;
    private final RedisScript<Long> deductScript;
    private final MqProducer mqProducer;
    private final SeckillOrderMapper orderMapper;

    /** 本地缓存:售罄标记,避免每次都打 Redis(★ 4.6 多级缓存思路) */
    private final Cache<String, Boolean> soldOutCache = Caffeine.newBuilder()
            .expireAfterWrite(5, TimeUnit.SECONDS)   // 5 秒,防止售罄状态长期不准
            .maximumSize(10_000)
            .build();

    public SeckillResult seckill(Long skuId, Long userId) {
        // ① 本地缓存快速失败(售罄后,99% 的请求在这里被拦掉,不打 Redis)
        if (Boolean.TRUE.equals(soldOutCache.getIfPresent("sku:" + skuId))) {
            return SeckillResult.fail("已售罄");
        }

        String stockKey = "seckill:stock:" + skuId;
        String userKey  = "seckill:users:" + skuId;

        // ② Redis Lua 原子预扣
        Long r = redis.execute(deductScript,
                List.of(stockKey, userKey), "1", userId.toString());

        if (r == null || r == -1) {
            soldOutCache.put("sku:" + skuId, TRUE);   // 售罄标记进本地缓存
            return SeckillResult.fail("已售罄");
        }
        if (r == -2) return SeckillResult.fail("库存不足");
        if (r == -3) return SeckillResult.fail("请勿重复购买");

        // ③ 预扣成功 → 发 MQ 异步创建订单(★ 削峰,不直接写 DB)
        try {
            mqProducer.send("SECKILL_ORDER_TOPIC",
                    new SeckillMsg(skuId, userId, System.currentTimeMillis()));
        } catch (Exception e) {
            // ★ MQ 发送失败 → 必须归还 Redis 库存,否则少卖
            rollbackStock(skuId, userId);
            log.error("send seckill msg failed, rollback stock. sku={} user={}", skuId, userId, e);
            return SeckillResult.fail("系统繁忙,请重试");
        }

        // ④ 返回"排队中",前端轮询结果(★ 不要同步等结果)
        return SeckillResult.processing();
    }

    /** 归还 Redis 库存(补偿动作,必须幂等) */
    public void rollbackStock(Long skuId, Long userId) {
        String stockKey = "seckill:stock:" + skuId;
        String userKey  = "seckill:users:" + skuId;
        // SREM 返回 1 表示确实移除过 → 才归还库存(幂等保证)
        Long removed = redis.opsForSet().remove(userKey, userId.toString());
        if (removed != null && removed > 0) {
            redis.opsForValue().increment(stockKey);
            // 清掉售罄标记(可能又有货了)
            redis.delete(stockKey + ":sold_out");
            soldOutCache.invalidate("sku:" + skuId);
        }
    }
}

消费端(异步落库,含失败归还):

@RocketMQMessageListener(topic = "SECKILL_ORDER_TOPIC", consumerGroup = "seckill_order_cg")
@Component
@RequiredArgsConstructor
public class SeckillOrderConsumer implements RocketMQListener<SeckillMsg> {

    @Override
    public void onMessage(SeckillMsg msg) {
        try {
            // ① 幂等(防重复消费导致重复下单)
            //    唯一索引:uk_sku_user(sku_id, user_id)  ← DB 层最终保证
            //    这里先查一次,快速返回
            if (orderMapper.existsBySkuAndUser(msg.getSkuId(), msg.getUserId())) {
                return;
            }

            // ② 本地事务:创建订单 + 扣 DB 库存(同一个库 → 本地事务即可)
            seckillOrderService.createOrderInLocalTx(msg);

        } catch (DuplicateKeyException e) {
            // 唯一索引冲突 = 已处理过 → 忽略(幂等)
            return;
        } catch (Exception e) {
            // ③ 消费失败 → 抛异常触发 MQ 重试
            //    ★ 重试超过 16 次进死信 → 死信消费者归还 Redis 库存
            log.error("create seckill order failed. msg={}", msg, e);
            throw new RuntimeException("retry", e);
        }
    }
}

/** 死信消费者:最终失败 → 归还库存(★ 必须有,否则少卖) */
@RocketMQMessageListener(topic = "%DLQ%seckill_order_cg", consumerGroup = "seckill_dlq_cg")
@Component
public class SeckillDlqConsumer implements RocketMQListener<SeckillMsg> {

    @Override
    public void onMessage(SeckillMsg msg) {
        seckillService.rollbackStock(msg.getSkuId(), msg.getUserId());   // 归还 Redis
        alarmService.warn("SECKILL_ORDER_FAILED",
                "秒杀订单创建失败已归还库存: sku=" + msg.getSkuId() + " user=" + msg.getUserId());
        // ★ 不抛异常:死信里不能再抛,否则无限循环
    }
}

2.5.6.4 第②层:库存分桶(DB 层,资金类场景必用)

什么时候必须用分桶而不是 Redis: 涉及资金/账户、监管要求 DB 强一致、或者公司不允许 Redis 承载核心数据(你的项目一资产托管就是这类场景)。

-- 分桶库存表:一行拆 N 行
CREATE TABLE `t_stock_bucket` (
  `id`         BIGINT      NOT NULL AUTO_INCREMENT,
  `sku_id`     BIGINT      NOT NULL,
  `bucket_no`  INT         NOT NULL COMMENT '桶序号 0~N-1',
  `stock`      INT         NOT NULL COMMENT '该桶库存',
  `version`    INT         NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_sku_bucket` (`sku_id`, `bucket_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 初始化:100 件库存拆 20 个桶,每桶 5 件
INSERT INTO t_stock_bucket(sku_id, bucket_no, stock)
SELECT 1001, t.n, 5
FROM (SELECT 0 n UNION SELECT 1 UNION SELECT 2 ... UNION SELECT 19) t;

扣减策略(三种,按场景选):

/**
 * 策略一:随机桶(最简单,推荐)
 * 优点:实现简单、分布均匀
 * 缺点:可能出现"总库存够,但连续随机到的几个桶都不够"→ 需要多试几次
 */
public int deductRandom(Long skuId, int num) {
    int buckets = 20;
    int start = ThreadLocalRandom.current().nextInt(buckets);
    for (int i = 0; i < buckets; i++) {
        int bucket = (start + i) % buckets;
        int n = stockMapper.deduct(skuId, bucket, num);
        // UPDATE t_stock_bucket SET stock=stock-#{num}
        // WHERE sku_id=#{skuId} AND bucket_no=#{bucket} AND stock >= #{num}
        if (n > 0) return bucket;   // 扣减成功
    }
    return -1;   // 所有桶都不够(可能总库存也不够了)
}

/**
 * 策略二:按用户 Hash 固定桶(★ 推荐,可复现)
 * 优点:同一用户总是命中同一桶 → 幂等、防重、回滚都好做
 * 缺点:某些桶可能先耗尽(热点用户集中)
 */
public int deductByUserHash(Long skuId, Long userId, int num) {
    int bucket = Math.floorMod(userId.hashCode(), 20);
    int n = stockMapper.deduct(skuId, bucket, num);
    if (n > 0) return bucket;
    // 该桶不够 → 降级到随机桶(★ 关键:不能因为一个桶不够就拒绝)
    return deductRandom(skuId, num);
}

/**
 * 策略三:贪心 + 合并扣减(库存紧张时才用)
 * 场景:只剩 3 件,分散在不同桶,要买 3 件
 * 做法:查出所有有货的桶,按库存降序,逐个扣,凑够为止
 * 缺点:要加锁(防止并发下超卖),性能差
 */
@Transactional
public boolean deductMerge(Long skuId, int num) {
    List<StockBucket> list = stockMapper.selectAvailableForUpdate(skuId);  // 悲观锁
    int remain = num;
    for (StockBucket b : list) {
        if (remain <= 0) break;
        int take = Math.min(b.getStock(), remain);
        stockMapper.deduct(skuId, b.getBucketNo(), take);
        remain -= take;
    }
    if (remain > 0) throw new BizException("库存不足");   // 回滚
    return true;
}

分桶的关键问题(面试必问):

追问 回答
“分桶后,某个桶空了但总库存还有,怎么办?” ① 优先用 hash 桶,失败降级随机桶;② 定期做桶间rebalance(把富余桶的库存搬到空桶);③ 库存紧张时走合并扣减
“分桶数怎么定?” 一般 1664。不是越多越好:桶太多 → 单桶库存少 → 大订单凑不齐;桶太少 → 竞争仍激烈。经验值:桶数 ≈ 峰值 QPS / 单桶可承受 QPS(约 10003000)
“分桶会不会导致超卖?” 不会,只要每条 UPDATE 带 AND stock >= num(数据库乐观锁保证原子性)。分桶只降低竞争,不改变正确性
“和 Redis 预扣怎么配合?” 两层:Redis 预扣做第一道拦截(扛 99% 流量),分桶 DB 做最终落库(强一致校验)。Redis 是“资格”,DB 是“事实”

2.5.6.5 第③④⑤层:串行化、热点打散、降级

第③层:请求串行化(单品队列)

/**
 * 把同一 sku 的请求路由到同一个线程/队列,串行执行 → 无锁
 * 适用:极端热点(单品百万 QPS)
 */
@Component
public class SkuSerialExecutor {

    /** 每个 sku 一个单线程 + 队列,用 Disruptor 或 BlockingQueue */
    private final Map<Long, ExecutorService> executors = new ConcurrentHashMap<>();

    public CompletableFuture<SeckillResult> submit(Long skuId, Supplier<SeckillResult> task) {
        // 按 skuId 取模路由到固定线程 → 同一 sku 严格串行
        ExecutorService ex = executors.computeIfAbsent(skuId % 64,
                k -> Executors.newSingleThreadExecutor(
                        new ThreadFactoryBuilder().setNameFormat("sku-" + k + "-%d").build()));
        return CompletableFuture.supplyAsync(task, ex);
    }
}

第④层:热点 key 打散(缓存层,呼应 4.5.2)

原始:seckill:stock:1001        ← 单一热点 key,Redis 单分片被打爆
打散:seckill:stock:1001:0 ~ seckill:stock:1001:9   ← 10 个 key
读时:随机选一个 key,够就扣;不够就换下一个(最多试 3 次)
总库存:10 个 key 之和
优点:把压力分散到 Redis 的多个分片(Cluster 模式下不同 slot)

第⑤层:业务降级(成本最低的兜底)

方案 A:允许少量超卖 → 事后退款 + 补偿券
   适用:用户可接受(大部分电商可接受超卖 0.1%)
   成本:几乎为零,不用改架构

方案 B:硬性限流(令牌桶 / 漏桶)
   做法:只放行 1000 QPS 进入核心链路,其余直接返回"活动火爆"
   注意:限流的"拒绝"必须发生在 Redis 之前,否则 Redis 还是被打

方案 C:售罄后本地缓存拦截(★ 最重要,实现最简单)
   售罄标记进 Caffeine,5 秒过期 → 99% 请求在应用内存就被拦掉
   效果:10 万 QPS 打进来,真正到 Redis 的可能只有几千

2.5.6.6 锁竞争的监控与排查

-- ① 查看当前行锁等待
SELECT * FROM performance_schema.data_lock_waits;

-- ② 查看 InnoDB 行锁状态
SHOW ENGINE INNODB STATUS\G
-- 关注:
--   TRANSACTIONS 段的 "LOCK WAIT n lock struct(s)"
--   "---TRANSACTION xxx, ACTIVE 5 sec starting index read"  ← 活跃超过 5 秒的事务
--   "LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)"  ← 正在等行锁

-- ③ 锁等待超时次数(★ 这个指标持续增长 = 热点问题)
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock%';
--   Innodb_row_lock_waits         等待次数(★ 核心)
--   Innodb_row_lock_time_avg      平均等待时间(ms)
--   Innodb_row_lock_time_max      最长等待时间

-- ④ 找出被锁的表和行
SELECT
    r.trx_id           waiting_trx_id,
    r.trx_query        waiting_query,      -- 被阻塞的 SQL
    b.trx_id           blocking_trx_id,
    b.trx_query        blocking_query,     -- 造成阻塞的 SQL(★ 元凶)
    b.trx_started      blocking_started
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
# Prometheus 告警
- alert: MysqlRowLockWaitHigh
  expr: rate(mysql_global_status_innodb_row_lock_waits[5m]) > 10
  for: 3m
  annotations:
    summary: "MySQL 行锁等待每秒超过 10 次,可能存在热点行"

- alert: MysqlLockWaitTimeHigh
  expr: mysql_global_status_innodb_row_lock_time_avg > 500
  for: 3m
  annotations:
    summary: "行锁平均等待超过 500ms"

热点探测(怎么提前发现热点商品):

① 业务侧埋点:统计每个 skuId 的访问 QPS,Top N 进"热点池"
② Redis 侧:redis-cli --hotkeys(Redis 4.0+,基于 LFU)
   redis-cli --hotkeys    # 输出访问频率最高的 key
③ 应用侧:Caffeine 的命中统计能反映局部热点
④ 活动前预热:运营配置的秒杀商品,提前标记进热点池(★ 最实用)

2.5.7 消息消费失败的处理(兜底闭环)

面试官问法:「消息消费失败了怎么办?重试一直失败怎么办?」 核心:消费失败不是“异常”,是“常态”。必须有一个完整闭环,而不是 try-catch 打日志。

2.5.7.1 先把失败分类(不同失败处理完全不同)

┌──────────────────────────────────────────────────────────────────┐
│ 类型一:可重试失败(临时性)                                        │
│   例子:网络抖动、下游超时、DB 连接池满、限流                        │
│   特征:过一会儿再试可能就成功了                                     │
│   处理:★ 重试(本地重试 + MQ 重试队列 + 退避)                      │
│   占比:约 80%                                                     │
├──────────────────────────────────────────────────────────────────┤
│ 类型二:业务失败(永久性)                                          │
│   例子:余额不足、商品已下架、状态非法、参数错误                      │
│   特征:重试 100 次也是一样的结果                                    │
│   处理:★ 不重试,直接进死信 + 告警(重试是浪费资源且污染监控)        │
│   占比:约 15%                                                     │
├──────────────────────────────────────────────────────────────────┤
│ 类型三:未知失败(结果不明)                                        │
│   例子:调用下游超时(不知道做没做)                                  │
│   处理:★ 先反查,再决定;查不到就按"失败"处理 + 对账兜底             │
│   占比:约 5%,但危害最大                                            │
└──────────────────────────────────────────────────────────────────┘

**代码层怎么区分:**用自定义异常区分

/** 可重试异常:会触发重试 */
public class RetryableException extends RuntimeException {
    public RetryableException(String msg, Throwable cause) { super(msg, cause); }
}

/** 不可重试异常:直接进死信,不浪费重试次数 */
public class NonRetryableException extends RuntimeException {
    public NonRetryableException(String msg) { super(msg); }
}

// 使用
try {
    accountService.deduct(req);
} catch (TimeoutException | SocketTimeoutException e) {
    throw new RetryableException("下游超时,可重试", e);
} catch (InsufficientBalanceException e) {
    throw new NonRetryableException("余额不足,不可重试");   // 重试没意义
}

2.5.7.2 RocketMQ 的重试机制(项目四用的)

消费失败(抛异常 / 返回 RECONSUME_LATER)
        │
        ▼
进入【重试队列】%RETRY%{consumerGroup}
        │
        ▼
RocketMQ 自动延迟重投,间隔递增(★ 共 16 次):
   10s  30s  1min  2min  3min  4min  5min  6min
   7min  8min  9min  10min  20min  30min  1h  2h
        │
        ▼
16 次仍失败 → 进入【死信队列】%DLQ%{consumerGroup}
        │
        ▼
不再自动投递 → 需要人工/程序处理(★ 必须有死信消费者!)

关键配置:

@RocketMQMessageListener(
    topic = "REWARD_TOPIC",
    consumerGroup = "reward_cg",
    consumeMode = ConsumeMode.CONCURRENTLY,   // 并发消费
    messageModel = MessageModel.CLUSTERING,   // 集群消费
    maxReconsumeTimes = 5,                    // ★ 重试次数(默认 16,建议调小到 5)
    consumeThreadMax = 64
)
public class RewardConsumer implements RocketMQListener<RewardMsg> { ... }

⚠️ 调小重试次数的原因(面试加分): 默认 16 次,最后一次间隔 2 小时,总共要 4 小时 46 分钟才进死信。 对于“用户等着看结果”的业务(如发奖),4 小时后才告警太晚了。 建议:5 次(累计约 10 分钟)就进死信并告警,剩下的交给人工/对账。

2.5.7.3 自建退避重试(不依赖 MQ 重试的场景)

有些场景 MQ 客户端能力弱(如 Kafka 默认没有重试队列),或者需要更灵活的重试策略,就自建:

CREATE TABLE `mq_retry_task` (
  `id`           BIGINT      NOT NULL AUTO_INCREMENT,
  `biz_type`     VARCHAR(32) NOT NULL,
  `biz_key`      VARCHAR(128) NOT NULL COMMENT '幂等键',
  `topic`        VARCHAR(64) NOT NULL,
  `payload`      JSON        NOT NULL COMMENT '原始消息快照(★ 必须存,重试要用)',
  `status`       VARCHAR(16) NOT NULL COMMENT 'PENDING/SUCCESS/DEAD',
  `retry_count`  INT         NOT NULL DEFAULT 0,
  `next_retry_time` DATETIME(3) NOT NULL,
  `last_error`   VARCHAR(1024),
  `create_time`  DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `update_time`  DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_biz` (`biz_type`, `biz_key`),   -- ★ 幂等保证
  KEY `idx_retry` (`status`, `next_retry_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@Component
@Slf4j
public class RetryTaskScheduler {

    /** 退避阶梯:第 n 次重试前的等待(秒) */
    private static final int[] BACKOFF = {10, 30, 60, 300, 900, 3600, 7200, 21600}; // 10s ~ 6h

    @Scheduled(fixedDelay = 10_000)
    public void run() {
        List<MqRetryTask> tasks = taskMapper.selectPending(LocalDateTime.now(), 100);
        for (MqRetryTask task : tasks) {
            try {
                handlerRegistry.get(task.getBizType()).handle(task.getPayload());
                taskMapper.markSuccess(task.getId());
            } catch (NonRetryableException e) {
                // 业务失败 → 直接死信,不再重试
                taskMapper.markDead(task.getId(), e.getMessage());
                alarmService.warn("MQ_DEAD_LETTER", task.toString());
            } catch (Exception e) {
                int cnt = task.getRetryCount() + 1;
                if (cnt >= BACKOFF.length) {
                    // 超过最大重试次数 → 死信
                    taskMapper.markDead(task.getId(), e.getMessage());
                    alarmService.critical("MQ_RETRY_EXHAUSTED",
                            "重试耗尽,转人工!bizKey=" + task.getBizKey());
                } else {
                    taskMapper.updateRetry(task.getId(), cnt,
                            LocalDateTime.now().plusSeconds(BACKOFF[cnt - 1]),
                            e.getMessage());
                }
            }
        }
    }
}

⚠️ 退避必须加抖动(jitter): 否则同一批失败的消息会在同一时刻一起重试,形成“重试风暴”。

long delay = BACKOFF[cnt - 1] * 1000L;
long jitter = ThreadLocalRandom.current().nextLong(0, delay / 2);  // 0~50% 抖动
nextRetryTime = now.plus(delay + jitter, ChronoUnit.MILLIS);

2.5.7.4 消费端的标准处理模板(三段式)

@RocketMQMessageListener(topic = "REWARD_TOPIC", consumerGroup = "reward_cg", maxReconsumeTimes = 5)
@Component
@RequiredArgsConstructor
@Slf4j
public class RewardConsumer implements RocketMQListener<RewardMsg> {

    private final RewardService rewardService;
    private final IdempotentService idempotent;

    @Override
    public void onMessage(RewardMsg msg) {
        // ═══ 第一段:前置校验(不修改任何状态,纯校验)═══
        if (msg.getUserId() == null || msg.getAmount() == null) {
            // 参数非法 → 不可重试,直接丢弃(记日志 + 告警)
            log.error("invalid msg, discard. msg={}", msg);
            alarmService.warn("MSG_INVALID", "非法消息: " + msg);
            return;   // ★ 直接返回,不抛异常(不重试)
        }

        // ═══ 第二段:幂等占位(★ 在做任何业务前)═══
        // 幂等键:userId + activityId + date(项目四的真实设计)
        String idemKey = msg.getUserId() + ":" + msg.getActivityId() + ":" + msg.getDate();
        if (!idempotent.tryLock(idemKey, Duration.ofHours(24))) {   // ★ TTL 必须 > 最大重试周期
            log.info("duplicated msg, skip. idemKey={}", idemKey);
            return;
        }

        // ═══ 第三段:业务执行(失败要区分类型)═══
        try {
            rewardService.grant(msg);
            idempotent.markSuccess(idemKey);     // 标记成功
        } catch (InsufficientStockException | BizRejectException e) {
            // 业务失败 → 不可重试 → 释放幂等锁 + 告警
            idempotent.release(idemKey);
            alarmService.warn("REWARD_BIZ_FAILED", idemKey + " " + e.getMessage());
            // ★ 不抛异常,不重试
        } catch (Exception e) {
            // 未知/可重试失败 → 释放幂等锁(★ 必须释放,否则重试时会被幂等拦截)
            idempotent.release(idemKey);
            log.error("reward failed, will retry. msg={}", msg, e);
            throw new RetryableException("reward failed", e);   // 抛异常 → 触发重试
        }
    }
}

模板要点(面试能说出这 5 点就是高手):

① 前置校验失败 → 直接返回,不重试(重试也没用,纯浪费)
② 幂等锁必须在业务执行【前】获取,否则并发下会重复执行
③ 幂等锁的 TTL 必须 > 最大重试总时长,否则锁过期后重复消费会穿透
   (项目四真实踩坑:TTL 5 分钟 < 重试 2 小时 → 重复发奖)
④ 业务失败要【释放】幂等锁,否则这笔业务永远无法重试
⑤ 可重试失败抛异常,不可重试失败不抛(★ 这是很多人的 bug:全都抛)

2.5.7.5 死信队列的处理(★ 90% 的人漏掉这一步)

很多团队的死信队列 = 数据黑洞(消息进去了,没人管,一直到磁盘满)
正确做法:死信必须有消费者 + 必须有处理平台 + 必须有告警

死信处理平台要有的能力:

能力 说明
可视化查询 按 topic / 时间 / bizKey / 错误信息查询死信
查看原始报文 完整的消息体 + 消费失败堆栈 + 重试次数
单条重投 修复问题后,手动重投(★ 必须幂等,重投不会重复执行)
批量重投 选一批死信批量重投(如“下游故障已恢复”场景)
批量丢弃 确认业务上无需处理的(如“活动已结束”),标记丢弃
归因分类 按错误类型分组统计,找出系统性问题(不是个案)
SLA 告警 死信数量 > N、或存在超过 X 小时未处理的死信 → 告警
/** 死信消费者:不是"处理业务",而是"留痕 + 告警 + 补偿动作" */
@RocketMQMessageListener(topic = "%DLQ%reward_cg", consumerGroup = "reward_dlq_cg")
@Component
public class RewardDlqConsumer implements RocketMQListener<RewardMsg> {

    @Override
    public void onMessage(RewardMsg msg) {
        // ① 入库,供运营在后台看到
        dlqRecordService.save(msg);

        // ② 执行"业务兜底动作"(这一步很多人没有)
        //    例:发奖失败 → 标记该用户的奖励为"待人工补发"状态
        rewardService.markNeedManualCompensate(msg.getUserId(), msg.getActivityId());

        // ③ 告警
        alarmService.critical("REWARD_DLQ",
                String.format("发奖进入死信,需人工处理!userId=%s activityId=%s",
                        msg.getUserId(), msg.getActivityId()));

        // ④ ★ 死信消费者绝对不能再抛异常(否则无限循环)
    }
}

2.5.7.6 消费端也要对账(最后一道防线)

对账逻辑(以发奖为例):
  左边:应该发奖的用户(从活动参与记录表查)
  右边:实际发了奖的用户(从奖励发放流水表查)

  差异①:左有右无 → 漏发 → 自动补发(或人工确认后补发)
  差异②:左无右有 → 多发 → 自动冲正(或人工追回)
  差异③:两边都有但金额不同 → 金额错误 → 人工核对

执行时机:
  · 准实时对账:每 5 分钟对最近 1 小时的数据(★ 快速发现系统性故障)
  · 日终全量对账:每天凌晨对前一天全量(★ 兜底,不漏一笔)

项目四真实踩坑(可直接讲):

【背景】社区平台活动发奖,用 RocketMQ 事务消息 + Redis SETNX 幂等。

【问题】上线后出现重复发奖(同一用户同一活动收到 2 份奖品)。

【排查】
  · 幂等键:userId + activityId + date ✅ 设计没问题
  · Redis SETNX 的 TTL 设置的是 5 分钟
  · RocketMQ 的重试间隔最大到 2 小时
  → 消费失败后,5 分钟锁过期,2 小时后的重试绕过了幂等 → 重复发奖

【修复】
  ① 幂等 TTL 改为 48 小时(> 最大重试总时长 4h46min 的余量)
  ② 幂等从"Redis SETNX"升级为"DB 唯一索引"((user_id, activity_id, date) 唯一)
     → Redis 可能过期/丢失,DB 唯一索引是最终保证
  ③ 增加日终对账:参与记录 vs 发放流水,差异自动告警

【效果】重复发奖从每天 3~5 笔降到 0,且对账能兜住任何遗漏。

【收获】(面试升华)
  幂等键的 TTL 必须覆盖"最大重试周期",这是我最容易忽略的一个细节。
  而且 Redis 幂等只能挡"快重复",挡不住"慢重复",
  真正的最后一道防线必须是 DB 唯一索引 + 对账。

2.5.8 事务对账系统(一致性的最后一道防线)

3.7 讲的是“消息对账”,这一节讲“事务对账”。两者视角不同:

  • 消息对账:核对“消息发出去 vs 消息被消费”(关注消息链路)
  • 事务对账:核对“本方业务数据 vs 他方业务数据”(关注业务结果)

面试官问法:「你说自动补偿能覆盖 99.9%,那剩下 0.1% 怎么办?」 标准答案:对账。没有对账的一致性方案 = 没有一致性方案。

2.5.8.1 为什么自动方案一定有“漏网之鱼”

┌───────────────────────────────────────────────────────────────────┐
│ 自动方案覆盖不到的 6 种情况(这就是对账存在的理由)                   │
├───────────────────────────────────────────────────────────────────┤
│ ① 事务记录写成功了,但业务数据没写(极端窗口,2.5.3.4 提过)          │
│ ② 业务数据写成功了,事务记录没写(对账能发现"有业务无事务")           │
│ ③ 补偿重试次数耗尽,转人工后人工没处理                                │
│ ④ 下游系统静默失败(返回成功但实际没做,或做了但返回失败)              │
│ ⑤ 外部系统(银行/第三方)根本不给你补偿接口,只有对账文件                │
│ ⑥ 代码 bug 导致的状态机错误(如状态写错、分支漏执行)                   │
└───────────────────────────────────────────────────────────────────┘

★ 一句话:自动方案解决"已知异常",对账解决"未知异常"。
  你永远不可能穷举所有异常路径,所以对账是必需的,不是可选的。

2.5.8.2 对账的四种差异类型(必须能分类,才能自动处理)

类型 名称 例子 危险度 处理方式
① 长款(本方多) 我方扣了钱,他方没收到 高(资损) 自动冲正 or 人工确认后冲正
② 短款(本方少) 他方扣了钱,我方没记 高(资损) 自动补记 or 人工确认后补记
③ 金额不符 两边都有,金额不同 最高(可能是 bug) 必须人工,不自动修
④ 状态不符 金额一致,但状态不同(我方成功、他方失败) 中 按“以谁为准”规则自动平账 + 抽样人工复核

2.5.8.3 对账系统的三要素

┌────────────────────────────────────────────────────────┐
│ 要素一:对账数据源(拿什么跟什么比)                      │
│   内部:本方 DB(订单表、流水表)                          │
│   外部:对端 API 查询 / 对端 DB 直连(有权限时)/ 对账文件   │
│   ★ 关键:对账数据必须是"独立的第二数据源",               │
│     不能拿同一份数据自己跟自己比(那就没意义了)             │
├────────────────────────────────────────────────────────┤
│ 要素二:对账粒度                                          │
│   · 逐笔对账:一笔一笔比(★ 资金类必须,能定位到具体单)     │
│   · 汇总对账:只比总数和总金额(快,但定位不到具体单)        │
│   → 做法:先汇总(快速判断有没有问题),有问题再逐笔          │
├────────────────────────────────────────────────────────┤
│ 要素三:对账时机                                          │
│   · T+0 准实时:每 5 分钟对最近 1 小时(★ 快速发现系统性故障)│
│   · T+1 日终:每天凌晨对前一天全量(★ 兜底,不漏一笔)       │
│   · 离线补对:修数据后重新对历史某一天                       │
└────────────────────────────────────────────────────────┘

2.5.8.4 实现:海量数据的归并比对(★ 面试爱考)

核心问题:几百万行文件 / 几百万条记录,不能全 load 到内存。

解法:排序 + 双指针归并,O(n) 时间、O(1) 内存

左边(本方,按 bizKey 排序)        右边(他方,按 bizKey 排序)
  A001  100元                        A001  100元     → 一致,双方指针都前进
  A002  200元                        A003  150元     → 左边 A002 是长款(他方无),左指针前进
  A003  150元                        A004  300元     → 一致
  A005  500元                        A006  400元     → 左边 A005 长款,左指针前进
  A006  400元                        A007  600元     → 一致
                                     A008  700元     → 右边剩余全是短款

双指针规则:
  left.key == right.key  → 比对金额 → 一致/金额不符 → 双方前进
  left.key  < right.key  → 左边这条是【长款】        → 左指针前进
  left.key  > right.key  → 右边这条是【短款】        → 右指针前进
  一方遍历完               → 另一方剩余全是长款/短款

代码实现(流式,支持百万级):

/**
 * 流式归并比对。
 * 两边数据源都只需提供"按 bizKey 升序"的迭代器,不落内存。
 */
public class Reconciler {

    public ReconResult reconcile(Iterator<ReconItem> left,
                                 Iterator<ReconItem> right,
                                 Consumer<DiffRecord> diffSink) {
        ReconItem l = next(left);
        ReconItem r = next(right);
        long totalLeft = 0, totalRight = 0, diffCount = 0;

        while (l != null && r != null) {
            int cmp = l.getBizKey().compareTo(r.getBizKey());
            if (cmp == 0) {
                totalLeft  += l.getAmount();
                totalRight += r.getAmount();
                // 金额比对(★ 用 BigDecimal,且要处理精度)
                if (l.getAmount().compareTo(r.getAmount()) != 0) {
                    diffSink.accept(DiffRecord.amountMismatch(l, r));   // 类型③
                    diffCount++;
                } else if (!l.getStatus().equals(r.getStatus())) {
                    diffSink.accept(DiffRecord.statusMismatch(l, r));   // 类型④
                    diffCount++;
                }
                l = next(left);
                r = next(right);
            } else if (cmp < 0) {
                totalLeft += l.getAmount();
                diffSink.accept(DiffRecord.longSide(l));    // 类型① 长款
                diffCount++;
                l = next(left);
            } else {
                totalRight += r.getAmount();
                diffSink.accept(DiffRecord.shortSide(r));   // 类型② 短款
                diffCount++;
                r = next(right);
            }
        }
        // 剩余部分
        while (l != null) { diffSink.accept(DiffRecord.longSide(l));  diffCount++; l = next(left); }
        while (r != null) { diffSink.accept(DiffRecord.shortSide(r)); diffCount++; r = next(right); }

        return new ReconResult(totalLeft, totalRight, diffCount);
    }

    private ReconItem next(Iterator<ReconItem> it) { return it.hasNext() ? it.next() : null; }
}

分页游标拉取(避免一次性查全表打爆 DB):

/**
 * 按主键游标分页(★ 不能用 LIMIT offset,深分页会越来越慢)
 */
public Iterator<ReconItem> streamByCursor(LocalDate date) {
    return new Iterator<>() {
        private Long lastId = 0L;
        private List<ReconItem> buffer = new ArrayList<>();
        private int idx = 0;

        { loadMore(); }

        private void loadMore() {
            buffer = orderMapper.selectReconItems(date, lastId, 1000);
            // SELECT * FROM t_order WHERE create_date = #{date} AND id > #{lastId}
            // ORDER BY id ASC LIMIT 1000        ← ★ 走主键索引,恒定性能
            idx = 0;
        }

        @Override public boolean hasNext() {
            if (idx < buffer.size()) return true;
            if (buffer.size() < 1000) return false;
            lastId = buffer.get(buffer.size() - 1).getId();
            loadMore();
            return idx < buffer.size();
        }

        @Override public ReconItem next() { return buffer.get(idx++); }
    };
}

对账任务调度(含断点续跑):

CREATE TABLE `recon_task` (
  `id`            BIGINT      NOT NULL AUTO_INCREMENT,
  `recon_date`    DATE        NOT NULL,
  `biz_type`      VARCHAR(32) NOT NULL,
  `status`        VARCHAR(16) NOT NULL COMMENT 'RUNNING/SUCCESS/FAILED/PARTIAL',
  `left_count`    BIGINT DEFAULT 0,
  `right_count`   BIGINT DEFAULT 0,
  `diff_count`    BIGINT DEFAULT 0,
  `checkpoint`    VARCHAR(128) COMMENT '断点:最后处理到的 bizKey(★ 续跑用)',
  `start_time`    DATETIME(3),
  `end_time`      DATETIME(3),
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_date_type` (`recon_date`, `biz_type`)   -- ★ 防重复对账
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@Component
@Slf4j
public class ReconJob {

    /** T+1 日终全量对账:每天凌晨 2 点跑 */
    @Scheduled(cron = "0 0 2 * * ?")
    public void dailyRecon() {
        LocalDate date = LocalDate.now().minusDays(1);
        for (String bizType : BIZ_TYPES) {
            try {
                runRecon(date, bizType);
            } catch (Exception e) {
                log.error("recon failed. date={} type={}", date, bizType, e);
                alarmService.critical("RECON_FAILED", date + "/" + bizType + " 对账失败");
                // ★ 不中断,继续跑下一个 bizType
            }
        }
    }

    /** T+0 准实时对账:每 5 分钟跑最近 1 小时(★ 快速发现系统性故障) */
    @Scheduled(fixedDelay = 300_000)
    public void nearRealTimeRecon() {
        // 只跑最近 1 小时的数据,量小,可快速完成
        // 差异数超过阈值 → 立即告警(不用等到日终)
        // 典型作用:下游系统挂了,5 分钟内就能发现"最近一小时大量单边账"
    }

    private void runRecon(LocalDate date, String bizType) {
        // ① 占位(唯一索引防重复跑)
        ReconTask task = taskMapper.tryCreate(date, bizType);
        if (task == null) return;

        try {
            // ② 校验文件完整性(文件对账场景)
            //    行数 / 总金额 / MD5 校验,文件不完整直接失败,不要用残缺数据对账

            // ③ 归并比对
            AtomicLong diffCount = new AtomicLong();
            ReconResult r = reconciler.reconcile(
                    streamByCursor(date),
                    streamExternal(date),
                    diff -> {
                        diffMapper.insertIgnore(diff);   // 差异入库(唯一键防重复)
                        diffCount.incrementAndGet();
                    });

            // ④ 差异自动处理(见策略表)
            autoFixService.batchFix(date, bizType);

            // ⑤ 汇总告警
            if (diffCount.get() > THRESHOLD) {
                alarmService.critical("RECON_DIFF_TOO_MANY",
                        String.format("%s %s 差异 %d 笔,请立即排查!", date, bizType, diffCount.get()));
            }
            taskMapper.markSuccess(task.getId(), r, diffCount.get());
        } catch (Exception e) {
            taskMapper.markFailed(task.getId(), e.getMessage());
            throw e;
        }
    }
}

2.5.8.5 差异处理策略表(★ 这张表是“能不能自动修”的依据)

差异类型 场景 自动处理? 处理动作 兜底
长款(本方多) 我方扣款成功、他方未入账 ✅ 自动 冲正本方(若业务允许);否则补推他方 冲正失败 → 工单
长款 我方创建了订单、他方没有 ❌ 人工 — 工单(可能是他方丢消息)
短款(本方少) 他方入账、我方未记 ✅ 自动 补记本方流水 + 通知下游 补记失败 → 工单
金额不符 任何 ❌ 必须人工 — P0 工单(可能是资金 bug)
状态不符 我方成功、他方处理中 ❌ 人工/等待 等待下一个对账周期(他方可能还没完成) 连续 3 个周期仍不符 → 工单
状态不符 我方失败、他方成功 ✅ 自动 以他方为准改我方状态(★ 需业务确认“以谁为准”规则) 抽样人工复核

“以谁为准”规则(必须提前定义,否则对账无法自动化):

★ 核心原则:以"资金实际流动方"为准,以"下游(后手)"为准。

例:我方订单系统 → 支付系统
  · 支付系统说成功,订单说失败 → 以支付为准(钱已经收了,订单必须改成已支付)
  · 支付系统说失败,订单说成功 → 以支付为准(订单改回未支付,退款)

例:我方 → 银行
  · 以银行对账文件为准(银行是资金实际托管方)

⚠️ 例外:如果本方是"权威源"(如本方是记账系统),则以本方为准。
   这个规则必须在需求阶段就定义清楚,写在设计文档里。

2.5.8.6 对账系统的 6 个设计要点

① 幂等:对账任务可以重复跑(唯一索引 + insert ignore),结果一致
② 断点续跑:几百万数据跑到一半失败,不能从头再来
③ 不阻塞业务:对账走从库(★ 必须,否则拖垮主库)
④ 差异不自动删除:差异记录永久保留(审计要求),只改处理状态
⑤ 对账本身要监控:任务没跑 / 跑超时 / 差异突增 → 都要告警
   (★ "对账任务挂了但没人知道"是最危险的,你会以为一切正常)
⑥ 人工工单闭环:工单有状态、有处理人、有 SLA、有复核

2.5.9 故障兜底全景(把前面所有手段串起来)

2.5.9.1 三层开关(出事时能一键止血)

┌────────────────────────────────────────────────────────────────┐
│ 第一层:方案开关(配置中心动态调整)                              │
│   例:tx.mode = TCC | RELIABLE_MSG | LOCAL_ONLY                 │
│   作用:TCC 出问题 → 一键切到可靠消息,不用发版                    │
├────────────────────────────────────────────────────────────────┤
│ 第二层:补偿开关                                                 │
│   例:tx.compensate.enabled = true/false                        │
│   作用:下游大面积故障时,暂停自动补偿(避免雪上加霜),           │
│        等下游恢复后再开(★ 补偿也要能熔断)                        │
├────────────────────────────────────────────────────────────────┤
│ 第三层:业务开关                                                 │
│   例:seckill.enabled = false                                   │
│   作用:兜底中的兜底,直接停掉业务(★ 保住系统,损失业务)           │
└────────────────────────────────────────────────────────────────┘

降级链路示例(面试话术):

「我们有三级降级:
 一级降级:TCC 改可靠消息(牺牲实时一致,保可用)
 二级降级:关闭自动补偿 + 转为异步批量补偿(牺牲时效,保下游)
 三级降级:关闭非核心分支(如发积分、发站内信),只保主链路(扣库存、扣款)
 四级兜底:整个功能降级为"只记录 + 事后对账补单"(如大促期间)
 每一级都有配置中心开关,30 秒内生效,不需要发版。」

2.5.9.2 兜底四件套(缺一不可)

┌──────────────────────────────────────────────────────────────────┐
│ ① 监控告警 —— 让你【知道】出事了                                   │
│    · 中间态事务数、UNKNOWN 数、补偿失败数、死信数                   │
│    · ★ 还要监控"监控本身":对账任务是否按时跑、Recovery 是否存活     │
├──────────────────────────────────────────────────────────────────┤
│ ② 对账 —— 让你【发现】自动方案漏掉的问题                            │
│    · T+0 准实时(快速发现系统性故障)+ T+1 全量(兜底不漏)           │
├──────────────────────────────────────────────────────────────────┤
│ ③ 人工补偿工作台 —— 让你【能修】                                    │
│    · 查询(按 bizKey 查事务全貌:分支状态、调用流水、错误堆栈)        │
│    · 操作(手动重试 / 手动补偿 / 强制改状态 / 标记为已处理)           │
│    · 审计(谁在什么时候做了什么,★ 必须留痕)                        │
│    · ★ 权限控制 + 二次确认(这是能改资金状态的系统,必须严控)         │
├──────────────────────────────────────────────────────────────────┤
│ ④ 故障演练 —— 让你【相信】它真的能工作                              │
│    · 定期注入故障:kill 协调器、断网、让补偿接口抛异常、制造消息堆积    │
│    · 验证:是否能自愈?不能自愈的是否告警?告警是否有人处理?           │
│    · ★ 没演练过的兜底方案 = 没有兜底方案                            │
└──────────────────────────────────────────────────────────────────┘

2.5.9.3 事故复盘案例(STAR,面试直接讲)

【S - 背景】
项目三(零碳能源云)的能耗数据上报 + 结算流程,涉及 3 个服务:
  数据采集服务 → 结算服务 → 账单服务
用自研的本地事务表 + 异步补偿(2.5.3 的方案),日均 10 万笔。

【T - 问题】
某天下午,账单服务发版后有一个接口的路径改了(/bill/create → /bill/v2/create),
但结算服务的事务分支表里存的还是旧路径。

【A - 我们的处理】
1. 【发现】15:32 监控告警:tx_compensate_failed_total 5 分钟内涨到 200+
   —— 这一步很关键,是监控先发现的,不是用户投诉。

2. 【止血】15:35 通过配置中心关闭 tx.compensate.enabled
   —— 防止大量补偿重试把账单服务打垮(当时账单服务刚发版,还在观察期)

3. 【定位】15:40 看 tx_branch 表的 last_error,全是 404
   —— ★ 这就是"调用流水表 + last_error 字段"的价值,1 分钟定位

4. 【修复】15:50 写了条运维脚本,批量把 INIT/COMPENSATE_FAIL 状态的
   分支记录的 action 字段改成新路径(UPDATE ... WHERE service='bill-service')
   —— ★ 事务记录存的是"数据"而不是"代码",所以能批量修,这点是自研的优势

5. 【恢复】15:52 重新打开补偿开关,500 多笔积压在 3 分钟内全部补偿完成

6. 【复盘改进】
   · 接口路径不写死在分支表里,改成"服务名 + 动作标识",由服务注册表解析
     (避免以后再改路径又出问题)
   · 增加"404/400 类错误不重试"的规则(这是明显的配置错误,重试无意义)
   · 增加"同一服务分支失败率 > 50% 时自动熔断补偿"的规则
     (不等人手动关开关,系统自己就能止血)

【R - 结果】
故障总时长 20 分钟,无数据丢失,无资损。
改进后,类似的配置错误能在 1 分钟内自动熔断,不再需要人工介入。

【面试升华(最重要的一句)】
这次事故让我明白:分布式事务方案好不好,不看正常时多优雅,
而看异常时【能不能快速发现、能不能止血、能不能批量修复、能不能自动收敛】。
后来我把这四条做成了我们做一致性设计的 checklist。

2.5.9.4 一致性方案上线前 Checklist(可直接用)

# ─── 设计阶段 ───
[ ] 是否真的需要分布式事务?(能不能通过业务设计避免)
[ ] 一致性级别定清楚了吗?(强一致/最终一致/最大努力)
[ ] "以谁为准"规则定义了吗?(对账时需要)
[ ] 补偿接口设计了吗?可重入吗?幂等吗?
[ ] 热点评估做了吗?(单 key 峰值 QPS)

# ─── 编码阶段 ───
[ ] 事务状态是否与业务数据同库同事务?
[ ] 空回滚处理了吗?(Cancel 时 Try 不存在)
[ ] 防悬挂处理了吗?(Try 前查空回滚记录)
[ ] 幂等键设计正确吗?TTL 覆盖最大重试周期吗?
[ ] 幂等执行失败时,状态能回到"可重试"吗?
[ ] 是否区分了 FAIL 和 UNKNOWN?
[ ] 远程调用都有超时设置吗?超时分层合理吗?
[ ] 调用流水(请求/响应快照)记录了吗?
[ ] 分支入参可 JSON 序列化吗?(不能放对象引用)

# ─── 运维阶段 ───
[ ] Recovery 任务部署了吗?多实例能安全抢占吗?
[ ] 监控指标齐全吗?(中间态数/UNKNOWN 数/补偿失败数/死信数)
[ ] 告警规则配置了吗?(含"对账任务没跑"这类元告警)
[ ] 对账任务有了吗?(T+0 准实时 + T+1 全量)
[ ] 死信队列有消费者吗?有处理平台吗?
[ ] 人工补偿工作台有了吗?有权限控制和审计吗?
[ ] 配置开关有了吗?(方案开关/补偿开关/业务开关)
[ ] 故障演练做过吗?(kill 协调器/断网/补偿失败/消息堆积)
[ ] 应急预案写了吗?(值班同学照着能做)

2.5.10 本节(分布式事务失效兜底)面试题汇总

# 题目 难度
1 什么是“分布式事务失效”?分哪几个层次? ⭐⭐⭐
2 下单/注册送权益/行外划拨/秒杀/通知商户/文件对账,6 个场景分别怎么选型? ⭐⭐⭐⭐⭐
3 热点商品用 TCC 为什么会出问题?正确做法是什么? ⭐⭐⭐⭐⭐
4 不依赖 Seata / MQ,怎么实现跨服务一致性?讲设计 ⭐⭐⭐⭐⭐
5 事务记录表为什么要和业务表同库? ⭐⭐⭐⭐
6 自研方案中,业务事务提交了但事务记录没写成功怎么办? ⭐⭐⭐⭐⭐
7 分支调用的三态(SUCCESS/FAIL/UNKNOWN)怎么区分?为什么要区分? ⭐⭐⭐⭐⭐
8 UNKNOWN 状态怎么处理?金额类场景为什么不重试? ⭐⭐⭐⭐⭐
9 反查接口是必须的吗?没有反查接口怎么办? ⭐⭐⭐⭐
10 什么是空回滚?为什么必须返回成功? ⭐⭐⭐⭐⭐
11 什么是悬挂(防悬挂)?怎么解决? ⭐⭐⭐⭐⭐
12 空回滚记痕表为什么需要?和幂等表能合并吗? ⭐⭐⭐⭐
13 补偿接口要遵循哪几条铁律? ⭐⭐⭐⭐
14 补偿失败了怎么办?重试次数上限怎么定? ⭐⭐⭐⭐
15 退避策略怎么设计?为什么要加抖动? ⭐⭐⭐⭐
16 协调器宕机了怎么办?正在执行的事务会丢吗? ⭐⭐⭐⭐⭐
17 多个 Recovery 实例怎么避免重复处理? ⭐⭐⭐⭐
18 什么是 fencing token?解决什么问题? ⭐⭐⭐⭐⭐
19 超时时间怎么分层设计? ⭐⭐⭐⭐
20 热点商品库存怎么设计?分桶数怎么定? ⭐⭐⭐⭐⭐
21 分桶后某个桶空了但总库存还有,怎么办? ⭐⭐⭐⭐
22 Redis 预扣和 DB 分桶怎么配合?哪个是“资格”哪个是“事实”? ⭐⭐⭐⭐
23 秒杀场景怎么防止超卖?(三道防线) ⭐⭐⭐⭐
24 消息消费失败怎么处理?重试一直失败怎么办? ⭐⭐⭐⭐
25 死信队列怎么处理?你们有死信处理平台吗? ⭐⭐⭐⭐
26 消费失败时幂等锁要不要释放?为什么? ⭐⭐⭐⭐⭐
27 对账有哪几种差异类型?分别怎么处理? ⭐⭐⭐⭐
28 几百万条数据怎么比对?排序归并的思路? ⭐⭐⭐⭐⭐
29 “以谁为准”规则怎么定? ⭐⭐⭐⭐
30 对账任务本身挂了怎么发现? ⭐⭐⭐⭐
31 你们的降级开关有几层?分别降什么? ⭐⭐⭐⭐
32 怎么做故障演练?验证什么? ⭐⭐⭐⭐
33 讲一个你处理过的一致性事故(STAR) ⭐⭐⭐⭐⭐


2.6 本章(事务一致性)面试题汇总

# 题目 难度
1 什么是分布式事务?和本地事务的区别? ⭐
2 CAP 理论是什么?为什么 CA 不可兼得? ⭐⭐
3 BASE 理论是什么?和 CAP 的关系? ⭐⭐
4 分布式事务有哪些解决方案?各自适用场景? ⭐⭐⭐
5 TCC 是什么?和 2PC 的区别? ⭐⭐⭐
6 TCC 必须处理哪三个问题?如何处理? ⭐⭐⭐⭐⭐
7 什么是空回滚?什么是悬挂?怎么解决? ⭐⭐⭐⭐
8 Saga 和 TCC 怎么选?Saga 的隔离性问题怎么解决? ⭐⭐⭐⭐
9 Seata 的 AT / TCC / Saga / XA 模式区别? ⭐⭐⭐
10 Seata AT 模式的全局锁是什么?为什么会有脏写问题? ⭐⭐⭐⭐
11 可靠消息最终一致性的实现原理? ⭐⭐⭐⭐
12 本地消息表怎么设计?为什么消息表要和业务表同库? ⭐⭐⭐⭐
13 消息表状态一直 PENDING 怎么办? ⭐⭐⭐
14 最大努力通知和可靠消息的区别? ⭐⭐
15 你们项目怎么保证跨服务一致性?遇到过什么问题? ⭐⭐⭐⭐⭐

第三章:消息不一致与解决方案

本章要回答的问题: 数据库操作成功了,消息没发出去怎么办?消息发出去了,消费端没处理怎么办?消息重复发了怎么办?消息顺序错了怎么办?消息堆积了怎么办?

你简历的强相关点:

  • 项目四(社区平台):RocketMQ 事务消息、幂等键 userId+activityId+date、Redis + Lua 预减库存
  • 项目一(资产托管):指令状态机流转、交收消息
  • 项目三(零碳云):设备数据采集消息

3.1 消息不一致的四种表现

┌──────────────────────────────────────────────────────────┐
│ 表现①:消息丢失(该到的没到)                               │
│   业务成功了,但下游根本没收到通知                          │
│   后果:下游数据永远是旧的,且没人知道                      │
├──────────────────────────────────────────────────────────┤
│ 表现②:重复消费(不该到的到了多次)                         │
│   同一条消息被处理了两遍                                    │
│   后果:重复发奖、重复扣款(资损!)                        │
├──────────────────────────────────────────────────────────┤
│ 表现③:顺序错乱(到的顺序不对)                             │
│   "创建订单"晚于"支付订单"到达                              │
│   后果:状态机倒流,数据被旧值覆盖                          │
├──────────────────────────────────────────────────────────┤
│ 表现④:本地事务与消息发送不一致(★ 最难)                   │
│   DB 成功 + 消息失败 → 下游不知道                           │
│   DB 失败 + 消息成功 → 下游处理了不存在的数据(更严重!)    │
└──────────────────────────────────────────────────────────┘

为什么表现④最严重?

场景:用户支付成功 → 发"支付成功"消息 → 消费端发货

❌ DB 失败 + 消息成功:
   支付扣款回滚了,但消息已经发出去
   → 仓库发货了,但用户根本没付钱 → 直接资损,且不可逆

❌ DB 成功 + 消息失败:
   扣款成功,但消息丢了
   → 用户付了钱不发货 → 投诉(可通过对账补救,相对可控)

3.2 消息丢失:三个环节逐一击破

消息从生产到消费要走三步,每一步都可能丢:

  ┌────────┐   ①网络    ┌────────┐   ②刷盘   ┌────────┐   ③ACK   ┌────────┐
  │ Producer│ ────────► │ Broker │ ───────► │ Broker │ ──────► │Consumer│
  └────────┘            │ 内存   │          │ 磁盘   │          └────────┘
                        └────────┘          └────────┘
     丢失点①               丢失点②            丢失点②         丢失点③
   发送失败/异步丢        Broker 宕机内存丢   磁盘损坏        业务没处理完就 ACK

3.2.1 环节①:生产端丢失

原因:

① 用 Oneway 发送(只管发,不管结果)
② 异步发送但没处理回调里的异常
③ 同步发送失败后没重试
④ 发送超时但 Broker 其实收到了(伪丢失,实际是重试导致重复)

解决方案:同步发送 + 重试 + 业务侧兜底

/**
 * RocketMQ 生产者可靠发送配置
 */
@Bean
public DefaultMQProducer mqProducer() throws MQClientException {
    DefaultMQProducer producer = new DefaultMQProducer("reward-producer-group");
    producer.setNamesrvAddr("127.0.0.1:9876");

    // ① 同步发送(默认),send() 会阻塞直到拿到 Broker 的响应
    //    对比:sendOneway() 不等待响应 → 丢消息无感知,禁止用于核心业务

    // ② 发送失败重试
    producer.setRetryTimesWhenSendFailed(3);            // 同步发送重试 3 次
    producer.setRetryTimesWhenSendAsyncFailed(3);       // 异步发送重试 3 次
    producer.setRetryAnotherBrokerWhenNotStoreOK(true); // 失败时换一个 Broker 重试

    // ③ 超时时间(默认 3s,弱网环境适当放大)
    producer.setSendMsgTimeout(10000);

    // ④ 消息体压缩(超过 4KB 自动压缩,减少网络失败概率)
    producer.setCompressMsgBodyOverHowmuch(4096);

    producer.start();
    return producer;
}

/**
 * 可靠发送封装:捕获异常 → 落库 → 由补偿任务重投
 */
@Service
@Slf4j
public class ReliableMqSender {

    @Autowired
    private DefaultMQProducer producer;
    @Autowired
    private MqSendLogMapper sendLogMapper;

    /**
     * 发送 + 失败兜底
     */
    public SendResult sendReliable(String topic, String tag, String body, String bizKey) {
        // ① 先落库(状态 INIT),保证"发送意图"不丢
        Long logId = sendLogMapper.insert(MqSendLog.builder()
            .topic(topic).tag(tag).body(body).bizKey(bizKey)
            .status("INIT").retryCount(0).build());

        try {
            Message msg = new Message(topic, tag, bizKey, body.getBytes(StandardCharsets.UTF_8));
            SendResult result = producer.send(msg);   // 同步发送

            if (SendStatus.SEND_OK.equals(result.getSendStatus())) {
                sendLogMapper.updateStatus(logId, "SUCCESS");
            } else {
                // SLAVE_NOT_AVAILABLE / FLUSH_DISK_TIMEOUT / FLUSH_SLAVE_TIMEOUT
                // 这些属于"疑似成功",不能盲目重投,标记待确认 + 回查
                log.warn("消息状态异常,status={}, logId={}", result.getSendStatus(), logId);
                sendLogMapper.updateStatus(logId, "UNCERTAIN");
            }
            return result;

        } catch (Exception e) {
            log.error("消息发送失败,转补偿任务重试,logId={}", logId, e);
            sendLogMapper.updateStatus(logId, "FAILED");
            throw new BizException("消息发送失败,已记录待补偿", e);
        }
    }

    /**
     * 补偿任务:重投 FAILED / UNCERTAIN 的消息
     */
    @Scheduled(fixedDelay = 30_000)
    public void resendFailed() {
        List<MqSendLog> logs = sendLogMapper.selectNeedResend(100);
        for (MqSendLog log : logs) {
            if (log.getRetryCount() >= 5) {
                sendLogMapper.markDead(log.getId());
                alertService.send("消息重投 5 次仍失败,需人工处理,bizKey=" + log.getBizKey());
                continue;
            }
            try {
                Message msg = new Message(log.getTopic(), log.getTag(),
                    log.getBizKey(), log.getBody().getBytes(StandardCharsets.UTF_8));
                // ★ 重投必须带唯一业务键,消费端靠它幂等
                producer.send(msg);
                sendLogMapper.updateStatus(log.getId(), "SUCCESS");
            } catch (Exception e) {
                sendLogMapper.incrRetry(log.getId());
            }
        }
    }
}

⚠️ 关键认知(面试常问):

Q:同步发送失败了,我重试不就行了?为什么还要落库 + 补偿任务?

A:因为重试是在"当前这一次请求"的上下文里做的。
   如果重试 3 次都失败 → send() 抛异常 → 如果此时方法直接返回失败,
   这次"要发消息"的意图就彻底消失了,没有任何记录。

   落库 + 补偿任务的价值:
   ① 意图持久化:应用重启、宕机都不丢
   ② 可观测:有多少条没发出去,一眼看到
   ③ 可控:重试次数、间隔、死信告警都能配置
   ④ 这就是"本地消息表"的雏形(3.3.2 详讲)

异步发送的正确写法(很多人写错):

// ❌ 错误:没处理回调异常 → 发送失败完全无感知
producer.send(msg, new SendCallback() {
    @Override public void onSuccess(SendResult r) { }
    @Override public void onException(Throwable e) { /* 空的!消息丢了都不知道 */ }
});

// ✅ 正确:异常必须落库 + 告警 + 补偿
producer.send(msg, new SendCallback() {
    @Override
    public void onSuccess(SendResult r) {
        sendLogMapper.updateStatus(logId, "SUCCESS");
    }
    @Override
    public void onException(Throwable e) {
        log.error("异步发送失败,logId={}", logId, e);
        sendLogMapper.updateStatus(logId, "FAILED");   // 由补偿任务兜底
        alertService.send("MQ 异步发送失败,logId=" + logId);
    }
});

3.2.2 环节②:Broker 丢失

原因:

① 消息还在 PageCache(内存)里,Broker 宕机 → 丢
② 磁盘损坏(RAID / 多副本可解)
③ 主从切换时未同步的消息丢失

解决方案:刷盘策略 + 主从复制策略

# broker.conf —— 可靠性优先配置

# ① 刷盘方式
#    ASYNC_FLUSH(默认):异步刷盘,写内存就返回 → 吞吐高,宕机丢少量
#    SYNC_FLUSH        :同步刷盘,落盘才返回  → 不丢,性能降约 10 倍
flushDiskType = SYNC_FLUSH

# ② 主从复制
#    ASYNC_MASTER(默认):主写成功就返回 → 主宕机丢未同步部分
#    SYNC_MASTER       :主从都写成功才返回 → 不丢,RT 变高
brokerRole = SYNC_MASTER

四种组合的可靠性对比(RocketMQ):

刷盘 主从 可靠性 性能 适用
SYNC_FLUSH SYNC_MASTER 最高(不丢) 最低 金融核心、资金类
SYNC_FLUSH ASYNC_MASTER 高(主磁盘不丢) 低 重要业务
ASYNC_FLUSH SYNC_MASTER 高(从节点有副本) 中 推荐平衡方案
ASYNC_FLUSH ASYNC_MASTER 低(宕机丢秒级数据) 最高 日志、埋点、监控

Kafka 的对应配置:

Properties props = new Properties();
// acks 参数(对应 RocketMQ 的刷盘+主从策略)
props.put("acks", "all");           // 0=不等确认(会丢) 1=leader写入(all=全部ISR副本写入,不丢)
props.put("retries", 3);
props.put("enable.idempotence", true);   // 开启生产者幂等,防重试产生重复消息

// Broker 侧
props.put("min.insync.replicas", 2);     // 最少 2 个副本确认,配合 acks=all
props.put("unclean.leader.election.enable", false);  // 禁止不同步副本选主,防丢数据

面试必背:刷盘 vs 复制的区别

刷盘(flush)    :内存 → 本机磁盘,解决"单机宕机"丢失
复制(replicate):主   → 从节点,  解决"机器损坏"丢失

两个维度独立配置,都做才最可靠(SYNC_FLUSH + SYNC_MASTER)

3.2.3 环节③:消费端丢失

原因(最常见):

消费端拿到消息后,还没处理完业务就返回了消费成功(或自动 ACK)
→ Broker 认为消费成功,offset 前进
→ 应用宕机/异常 → 这条消息永远不会再被投递 → 丢了

解决方案:先处理业务,再确认(手动 ACK)

/**
 * RocketMQ 消费者:并发消费(无序)
 */
@Component
@Slf4j
public class RewardConsumer implements MessageListenerConcurrently {

    @Autowired
    private RewardService rewardService;

    @Override
    public ConsumeConcurrentlyStatus consumeMessage(
            List<MessageExt> msgs, ConsumeConcurrentlyContext context) {

        for (MessageExt msg : msgs) {
            String body = new String(msg.getBody(), StandardCharsets.UTF_8);
            String bizKey = msg.getKeys();          // 幂等键
            try {
                // ① 先执行业务(幂等)
                rewardService.grantReward(bizKey, body);

                // ② 业务成功才返回 CONSUME_SUCCESS(自动 ACK)
                //    ★ 千万不要在业务前面 return SUCCESS
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;

            } catch (BizException e) {
                // ③ 业务失败(如余额不足,重试也没用)→ 记录下来,人工处理
                log.error("业务处理失败,转人工,bizKey={}", bizKey, e);
                rewardService.markDeadLetter(bizKey, e.getMessage());
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;   // 不再重试,避免堵队列

            } catch (Exception e) {
                // ④ 系统异常(如 DB 抖了)→ 稍后重试
                log.error("系统异常,稍后重试,reconsumeTimes={}", msg.getReconsumeTimes(), e);
                if (msg.getReconsumeTimes() >= 5) {
                    rewardService.markDeadLetter(bizKey, e.getMessage());
                    alertService.send("消息重试 5 次仍失败,bizKey=" + bizKey);
                    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;   // 进死信,不再堵
                }
                return ConsumeConcurrentlyStatus.RECONSUME_LATER;       // 稍后重新投递
            }
        }
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }
}

Kafka 手动提交 offset:

@KafkaListener(topics = "reward", groupId = "reward-group")
public void consume(List<ConsumerRecord<String, String>> records, Acknowledgment ack) {
    try {
        for (ConsumerRecord<String, String> record : records) {
            rewardService.grantReward(record.key(), record.value());
        }
        ack.acknowledge();        // ★ 全部处理完才手动提交 offset
        // 如果开启自动提交(enable.auto.commit=true,默认),
        // 可能业务没处理完 offset 就前进了 → 消息丢失
    } catch (Exception e) {
        log.error("消费异常,不提交 offset,等待重新投递", e);
        throw e;                  // 不 ack → 下次重新消费
    }
}

⚠️ 三个消费端经典坑:

坑①:批量消费时只处理了部分就 return SUCCESS
     → 后面几条丢了
     解法:要么一条一条处理 + 精确 ack,要么保证批量内全部成功才 ack

坑②:try-catch 把所有异常都吞掉,然后 return SUCCESS
     → 业务失败了但消息被确认,永久丢失
     解法:区分「业务异常」(不可重试,进死信)和「系统异常」(可重试,RECONSUME_LATER)

坑③:无限重试导致队列堵塞
     → 一条毒丸消息(永远处理失败)会反复重试,阻塞整个队列
     解法:设置最大重试次数 → 超过进死信队列 + 告警

3.2.4 三处保障汇总(生产 Checklist)

环节 配置项 推荐值 说明
生产端 发送方式 同步 send() 核心业务禁用 Oneway
生产端 retryTimesWhenSendFailed 3 网络抖动自愈
生产端 失败兜底 落 mq_send_log + 补偿任务 防重试也失败
生产端 异步回调 onException 必须落库 禁止空实现
Broker flushDiskType ASYNC_FLUSH + 多副本 平衡性能与可靠
Broker brokerRole SYNC_MASTER 主从都写成功才返回
Broker Kafka acks all + min.insync.replicas=2 不丢
消费端 ACK 时机 业务成功后 禁止先 ACK 后处理
消费端 重试上限 5 次 → 死信 防毒丸堵塞
消费端 幂等 必做 重试/重投必然产生重复
全局 对账 T+1 全量对账 最后一道防线

3.3 本地事务与消息发送的原子性(★ 本章核心,面试最高频)

问题定义: 数据库操作和消息发送是两个独立的资源,本地事务管不到 MQ,怎么保证“要么都成功,要么都失败”?

3.3.1 两种朴素做法为什么都不行

做法一:先发消息,再执行本地事务

// ❌ 错误示例
public void pay(Long orderId) {
    mqSender.send("PAID", orderId);      // ① 消息发出去了
    orderMapper.updatePaid(orderId);     // ② 数据库操作失败(唯一索引冲突/DB抖动/应用宕机)
    // 结果:下游收到"已支付",实际订单没支付 → 用户没付钱却发了货 → 资损!
}
时序:
   T1 发消息 ✅ ──────► Broker(已投递,不可撤回!)
   T2 写数据库 ❌ 失败/回滚

   ❌ 消息无法撤回(MQ 没有"取消已发送消息"的能力)
   ❌ 这是最严重的一种,会直接造成资损

做法二:先执行本地事务,再发消息

// ❌ 错误示例(也很常见)
@Transactional
public void pay(Long orderId) {
    orderMapper.updatePaid(orderId);     // ① 数据库成功
    mqSender.send("PAID", orderId);      // ② 发消息失败(网络/MQ 宕机)
    // 结果:订单已支付,但下游不知道 → 用户付了钱不发货
}
时序:
   T1 写数据库 ✅
   T2 发消息 ❌ 失败

   ⚠️ 比做法一好一点(可以重试/对账补救),但仍然不一致
   ⚠️ 还有更隐蔽的情况:
      T2 消息"发出去了"但 send() 超时抛异常 → 事务回滚
      → 数据库回滚了,但消息其实 Broker 收到了 → 回到做法一的资损场景!

   ⚠️ 另外:把 send() 放在 @Transactional 方法内,
      网络慢会拉长事务时间 → 长事务 → 连接池耗尽(见 02 文档专题二)

结论:两个独立资源,必须引入“协调者”。三种方案:

方案一:本地消息表(最通用,任何 MQ 都能用)★ 理解原理必学
方案二:RocketMQ 事务消息(半消息 + 回查)★ 你的简历项目四用的
方案三:Kafka 事务 + Exactly Once(限 Kafka 生态内)

3.3.2 方案一:本地消息表

思路:把“要发的消息”当成业务数据存在同一个数据库里,用本地事务保证原子性。

┌─────────────────────────────────────────────────────────┐
│                     同一个本地事务                        │
│  ┌──────────────────┐        ┌────────────────────────┐ │
│  │ ① 更新订单状态     │        │ ② INSERT 本地消息表     │ │
│  │   orders 表       │        │   mq_message 表        │ │
│  │   status = PAID   │        │   status = PENDING     │ │
│  └──────────────────┘        └────────────────────────┘ │
│            │                             │               │
│            └──────── 要么都提交 ──────────┘               │
└─────────────────────────────────────────────────────────┘
                          │ 事务提交后
                          ▼
              ┌───────────────────────┐
              │ ③ 定时任务扫描 PENDING │
              │    发送到 MQ           │
              │ ④ 发送成功 → SUCCESS   │
              │    失败 → 下次继续重试 │
              └───────────────────────┘

表结构:

CREATE TABLE mq_message (
    id            BIGINT PRIMARY KEY AUTO_INCREMENT,
    biz_key       VARCHAR(64)  NOT NULL COMMENT '业务唯一键,用于幂等',
    topic         VARCHAR(64)  NOT NULL,
    tag           VARCHAR(64)  DEFAULT NULL,
    body          TEXT         NOT NULL,
    status        TINYINT      NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送 2已确认 3死亡',
    retry_count   INT          NOT NULL DEFAULT 0,
    next_retry_at DATETIME     NOT NULL COMMENT '下次重试时间(指数退避)',
    create_time   DATETIME     NOT NULL,
    update_time   DATETIME     NOT NULL,
    UNIQUE KEY uk_biz_key (biz_key),           -- ★ 幂等核心:同一业务只允许一条
    KEY idx_status_retry (status, next_retry_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地消息表';

完整代码:

/**
 * 本地消息表模式:保证「业务数据」与「待发消息」的强一致
 */
@Service
@Slf4j
public class LocalMessageTableService {

    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private MqMessageMapper mqMessageMapper;
    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    /**
     * ① 业务方法:一个本地事务内写业务数据 + 写消息表
     */
    @BizTransactional(rollbackFor = Exception.class, timeout = 30)
    public void payOrder(Long orderId, String bizKey) {
        // 业务操作
        orderMapper.updateStatus(orderId, "PAID");

        // ★ 同一个事务里插入消息记录
        //   业务提交 → 消息记录必然存在 → 一定会被发出去
        //   业务回滚 → 消息记录也回滚 → 一定不会发出去
        try {
            mqMessageMapper.insert(MqMessage.builder()
                .bizKey(bizKey)
                .topic("ORDER_TOPIC")
                .tag("PAID")
                .body(JSON.toJSONString(new PaidEvent(orderId)))
                .status(0)
                .nextRetryAt(LocalDateTime.now())
                .build());
        } catch (DuplicateKeyException e) {
            // 幂等:同一 bizKey 已存在,说明已经处理过,直接跳过
            log.info("消息已存在,跳过,bizKey={}", bizKey);
        }
    }

    /**
     * ② 定时扫描 + 发送(核心:至少一次投递)
     *
     * 用 @Scheduled 或独立的投递服务。多实例部署时用 SELECT ... FOR UPDATE SKIP LOCKED
     * 或分片扫描(按 id % 实例数)避免重复投递。
     */
    @Scheduled(fixedDelay = 5000)
    public void scanAndSend() {
        List<MqMessage> pending = mqMessageMapper.selectPending(100, LocalDateTime.now());

        for (MqMessage msg : pending) {
            try {
                // 发送(带 bizKey 作为消息 key,消费端用它幂等)
                SendResult result = rocketMQTemplate.syncSend(
                    msg.getTopic() + ":" + msg.getTag(),
                    MessageBuilder.withPayload(msg.getBody())
                        .setHeader(MessageConst.PROPERTY_KEYS, msg.getBizKey())
                        .build()
                );

                if (SendStatus.SEND_OK.equals(result.getSendStatus())) {
                    mqMessageMapper.updateStatus(msg.getId(), 1);   // 已发送
                }

            } catch (Exception e) {
                log.error("消息发送失败,id={}, retry={}", msg.getId(), msg.getRetryCount(), e);
                int retry = msg.getRetryCount() + 1;
                if (retry >= 5) {
                    mqMessageMapper.markDead(msg.getId());          // 死亡,告警 + 人工
                    alertService.send("消息投递失败超 5 次,id=" + msg.getId());
                } else {
                    // 指数退避:1min、5min、30min、2h
                    long[] delays = {1, 5, 30, 120};
                    long minutes = delays[Math.min(retry - 1, delays.length - 1)];
                    mqMessageMapper.incrRetry(msg.getId(),
                        LocalDateTime.now().plusMinutes(minutes));
                }
            }
        }
    }

    /**
     * ③ 可选:消费端回执确认(把状态从「已发送」推进到「已确认」)
     *    用于做「发送但没被消费」的监控
     */
    @RocketMQMessageListener(topic = "ORDER_ACK_TOPIC", consumerGroup = "ack-group")
    public void onAck(String bizKey) {
        mqMessageMapper.confirmByBizKey(bizKey);      // status = 2
    }
}

本地消息表的优缺点(面试要能评价):

✅ 优点:
   ① 原理简单,不依赖 MQ 的特殊能力(任何 MQ 都能用)
   ② 强一致:业务数据和消息记录在同一个事务里
   ③ 可观测:消息表就是投递台账,看状态就知道有没有漏发
   ④ 可靠:宕机不丢,重启后继续扫表

❌ 缺点:
   ① 侵入业务:每个发消息的地方都要写消息表
   ② 增加 DB 压力:每次业务操作多一次 INSERT + 状态更新
   ③ 定时任务有延迟(秒级),实时性不如事务消息
   ④ 消息表和业务表同库 → 分库分表后消息表也要跟着拆

优化:
   ① 消息表可异步归档(已确认的消息定期清理/转冷存)
   ② 投递成功率高时,可用「事务提交后立即异步发一次 + 扫表兜底」降低延迟
      (这就是方案二事务消息的思路)

3.3.3 方案二:RocketMQ 事务消息(半消息 + 回查)★ 你的简历项目四

时序图(背下来,面试画出来):

  Producer                    Broker                      Consumer
     │                           │                            │
     │ ① 发送 Half 半消息         │                            │
     │ ────────────────────────► │                            │
     │   (对消费端不可见)        │                            │
     │                           │                            │
     │ ② Half 消息写入成功        │                            │
     │ ◄──────────────────────── │                            │
     │                           │                            │
     │ ③ 执行本地事务             │                            │
     │    (写 DB)               │                            │
     │                           │                            │
     │ ④ COMMIT / ROLLBACK       │                            │
     │ ────────────────────────► │                            │
     │                           │ ⑤ COMMIT → 消息对消费端可见  │
     │                           │   ROLLBACK → 丢弃(不投递) │
     │                           │ ─────────────────────────► │
     │                           │                            │ ⑥ 消费
     │                           │                            │
  ═══╪═══════════════════════════╪════════════════════════════╪═══════
  异常分支:④ 没收到(Producer 宕机 / 网络断)
     │                           │                            │
     │ ⑦ 事务回查(默认 15 次)   │                            │
     │ ◄──────────────────────── │  定时扫描 Half 消息          │
     │   checkLocalTransaction   │                            │
     │   (查 DB 判断本地事务结果)│                            │
     │ ⑧ 返回 COMMIT/ROLLBACK    │                            │
     │ ────────────────────────► │                            │

为什么半消息能解决问题?

★ 关键:Half 消息对消费端不可见

  「先发消息」的做法之所以会资损,是因为消息一旦发出就不可撤回。
  半消息发出去了但消费端看不见 → 即使后面本地事务失败,
  只要回一个 ROLLBACK,Broker 直接丢弃,下游根本不知道有过这条消息。

  ✅ 本地事务成功 → COMMIT → 消息可见
  ✅ 本地事务失败 → ROLLBACK → 消息丢弃
  ✅ 什么都不回(宕机)→ Broker 回查 → 按 DB 里的真实结果决定

完整代码(对应你简历项目四的社区平台):

/**
 * 场景:用户报名社区活动 → 报名成功 → 发放积分奖励
 *
 * 一致性要求:
 *   报名记录写库成功 ⇄ 发奖消息发出(两者必须一致)
 *   且发奖必须幂等(防重复发奖 = 资损)
 */
@Service
@Slf4j
public class ActivitySignupService {

    @Autowired
    private SignupMapper signupMapper;
    @Autowired
    private SignupTxLogMapper txLogMapper;      // ★ 事务日志表,供回查使用
    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    /**
     * ① 主流程:发送事务消息
     */
    public void signup(SignupRequest req) {
        // 幂等键:用户 + 活动 + 日期(简历项目四原样)
        String bizKey = req.getUserId() + ":" + req.getActivityId() + ":"
                      + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);

        String txId = UUID.randomUUID().toString();

        // 发送事务消息
        // 第一个参数:事务监听器所在的 group(★ 必须和监听器 @RocketMQTransactionListener 的 txProducerGroup 一致)
        TransactionSendResult result = rocketMQTemplate.sendMessageInTransaction(
            "reward-tx-producer-group",
            "REWARD_TOPIC:GRANT",
            MessageBuilder.withPayload(JSON.toJSONString(
                    RewardEvent.builder()
                        .bizKey(bizKey)
                        .userId(req.getUserId())
                        .activityId(req.getActivityId())
                        .points(req.getPoints())
                        .build()))
                .setHeader(MessageConst.PROPERTY_KEYS, bizKey)
                .setHeader("txId", txId)          // ★ 带事务 ID,回查时定位
                .build(),
            req                                    // 透传给监听器的参数
        );

        log.info("事务消息发送结果:{}", result.getLocalTransactionState());
    }
}
/**
 * ② 事务监听器:执行本地事务 + 事务回查
 */
@RocketMQTransactionListener(txProducerGroup = "reward-tx-producer-group")
@Slf4j
public class RewardTransactionListener implements RocketMQLocalTransactionListener {

    @Autowired
    private SignupMapper signupMapper;
    @Autowired
    private SignupTxLogMapper txLogMapper;

    /**
     * ③ 执行本地事务(Half 消息发送成功后回调)
     *    ★ 这个方法里的事务是独立的本地事务,和发消息不是同一个
     */
    @Override
    @BizTransactional(rollbackFor = Exception.class, timeout = 10)
    public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        String txId = msg.getHeaders().get("txId", String.class);
        SignupRequest req = (SignupRequest) arg;
        String bizKey = msg.getHeaders().get(MessageConst.PROPERTY_KEYS, String.class);

        try {
            // ① 业务写库(报名记录)
            signupMapper.insertIgnore(          // INSERT IGNORE,靠唯一索引幂等
                Signup.builder()
                    .bizKey(bizKey)
                    .userId(req.getUserId())
                    .activityId(req.getActivityId())
                    .status("SIGNED")
                    .build());

            // ② ★ 记录事务日志(回查的依据!)
            //    必须和业务操作在同一个事务里:
            //    业务成功 → 日志必然存在 → 回查时返回 COMMIT
            //    业务失败 → 日志也回滚 → 回查时查不到 → 继续查/最终 ROLLBACK
            txLogMapper.insert(TxLog.builder()
                .txId(txId)
                .bizKey(bizKey)
                .status("COMMIT")
                .createTime(LocalDateTime.now())
                .build());

            return RocketMQLocalTransactionState.COMMIT;

        } catch (Exception e) {
            log.error("本地事务执行失败,回滚消息,txId={}", txId, e);
            return RocketMQLocalTransactionState.ROLLBACK;
        }
    }

    /**
     * ④ 事务回查:Broker 没收到 COMMIT/ROLLBACK 时调用
     *
     * 触发条件:
     *   · Producer 在 executeLocalTransaction 期间宕机
     *   · COMMIT/ROLLBACK 请求网络丢失
     *   · 本地事务执行超时(默认 6 秒不返回就回查)
     *
     * 默认回查 15 次(transactionCheckMax=15),间隔 60 秒,
     * 超过次数仍未确认 → 默认回滚(ROLLBACK)
     */
    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
        String txId = msg.getHeaders().get("txId", String.class);
        String bizKey = msg.getHeaders().get(MessageConst.PROPERTY_KEYS, String.class);

        TxLog txLog = txLogMapper.selectByTxId(txId);

        if (txLog == null) {
            // ★ 坑点:查不到 ≠ 失败!
            //   可能①:本地事务真的没执行(Half 消息发出后就宕机)→ ROLLBACK
            //   可能②:事务还没提交完(延迟)→ 应该返回 UNKNOWN 再等等
            //   可能③:从库延迟读不到 → 主从不一致导致误判!
            //
            // 稳妥做法:先查业务表(主库)有没有数据,再决定
            Signup signup = signupMapper.selectByBizKey(bizKey);
            if (signup != null) {
                // 业务数据存在,说明本地事务已成功 → 补一条日志 + COMMIT
                txLogMapper.insertIgnore(TxLog.builder()
                    .txId(txId).bizKey(bizKey).status("COMMIT").build());
                return RocketMQLocalTransactionState.COMMIT;
            }
            // 业务数据也没有 → 再等等(UNKNOWN),避免误回滚
            log.warn("回查未查到事务记录,返回 UNKNOWN 等待下次,txId={}", txId);
            return RocketMQLocalTransactionState.UNKNOWN;
        }

        // 有日志 → 按日志状态返回
        return "COMMIT".equals(txLog.getStatus())
            ? RocketMQLocalTransactionState.COMMIT
            : RocketMQLocalTransactionState.ROLLBACK;
    }
}
/**
 * ⑤ 消费端:幂等发奖(简历项目四)
 */
@Service
@Slf4j
public class RewardGrantService {

    @Autowired
    private RewardRecordMapper rewardRecordMapper;
    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 幂等发奖:三重保障
     *   第一重:Redis SETNX 快速拦截(挡住高并发重复请求)
     *   第二重:数据库唯一索引(最终兜底,绝对不重复)
     *   第三重:状态机校验(防业务态错误)
     */
    @BizTransactional(rollbackFor = Exception.class, timeout = 10)
    public void grant(RewardEvent event) {
        String bizKey = event.getBizKey();     // userId:activityId:date

        // ── 第一重:Redis SETNX(原子加锁,防并发重复)
        String lockKey = "reward:idempotent:" + bizKey;
        Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", Duration.ofHours(24));
        if (Boolean.FALSE.equals(acquired)) {
            log.info("重复请求,已被 Redis 拦截,bizKey={}", bizKey);
            return;                             // 直接返回,不发奖
        }

        try {
            // ── 第二重:数据库唯一索引(绝对兜底)
            //    reward_record 表有 UNIQUE KEY uk_biz_key
            //    重复插入会抛 DuplicateKeyException
            rewardRecordMapper.insert(RewardRecord.builder()
                .bizKey(bizKey)
                .userId(event.getUserId())
                .activityId(event.getActivityId())
                .points(event.getPoints())
                .status("GRANTED")
                .build());

            // ── 第三重:积分累加(用数据库原子操作,不用"查-改-写")
            userPointMapper.addPoints(event.getUserId(), event.getPoints());
            // SQL: UPDATE user_point SET points = points + #{p} WHERE user_id = #{id}

        } catch (DuplicateKeyException e) {
            // 已发过奖 → 直接返回,不重复发
            log.info("重复发奖已被唯一索引拦截,bizKey={}", bizKey);
            // ★ 注意:这里不能抛异常让消息重试(否则无限重试)
            return;
        } catch (Exception e) {
            // 系统异常 → 删除 Redis 锁,让消息重试时能重新执行
            redisTemplate.delete(lockKey);
            throw e;                            // 抛给消费端 → RECONSUME_LATER
        }
    }
}

RocketMQ 事务消息的三个必须知道的限制(面试加分):

限制①:事务消息不支持延迟消息和批量消息
限制②:Half 消息的 topic 是系统内部的(RMQ_SYS_TRANS_HALF_TOPIC),
        消费端订阅不到,只有 COMMIT 后才投递到目标 topic
限制③:回查次数有限(默认 15 次),超过后默认回滚
        → 所以 executeLocalTransaction 里必须尽快返回,不能做耗时操作
限制④(最重要):事务消息保证的是「本地事务 与 消息发送」的原子性,
        ★ 不是「本地事务 与 消息消费」的原子性!
        下游消费失败仍然要靠重试 + 幂等 + 对账解决

面试高频追问:回查时查不到事务日志怎么办?

这是最容易踩坑的地方,标准回答分三层:

① 不能简单返回 ROLLBACK
   因为查不到有几种可能:
   · 本地事务真的没执行(Half 发出后立刻宕机)
   · 事务还在执行中(慢 SQL / 锁等待)
   · 主从延迟,回查打到从库读不到

② 正确做法:
   · 优先查主库(或强制走主库的读)
   · 查业务表本身(不只是日志表)交叉验证
   · 仍不确定就返回 UNKNOWN,让 Broker 下次再回查
   · 设置合理的回查总时长(15 次 × 60s ≈ 15 分钟),覆盖最长事务耗时

③ 兜底:
   · 超过回查次数被回滚,但本地事务其实成功了 → 靠对账任务发现并补偿
   · 所以「对账」是任何一致性方案的最后一道防线,不能省

3.3.4 方案三:Kafka 事务 / Exactly Once

Kafka 的事务是「多分区原子写入」:

  producer.beginTransaction();
  producer.send(record1);              // 写 topic A
  producer.send(record2);              // 写 topic B
  producer.sendOffsetsToTransaction(); // 提交消费 offset(消费-生产 原子)
  producer.commitTransaction();
  // 要么三个都可见,要么都不可见

配置:
  enable.idempotence = true            // 生产者幂等(PID + sequence number 去重)
  acks = all
  transactional.id = "xxx"             // 跨会话唯一,用于僵尸实例隔离
  isolation.level = read_committed     // 消费端只读已提交的消息

★ 局限:
  Kafka 事务解决的是「Kafka 内部多 topic 原子写」,
  无法把「数据库事务」和「Kafka 事务」绑在一起(没有 XA 直通)。
  所以「DB + 发 Kafka 消息」仍然要用本地消息表,或 Canal 监听 binlog 发消息。

Canal / Debezium 方案(无侵入,值得了解):

思路:不主动发消息,而是监听数据库 binlog,数据变了自动发

  ┌──────┐   binlog   ┌───────┐   ─────►   ┌────────┐
  │ MySQL│ ─────────► │ Canal │             │ Kafka  │
  └──────┘            └───────┘             └────────┘

✅ 优点:
   ① 业务代码零侵入(不用写消息表、不用事务消息)
   ② 天然保证一致:binlog 里有的就是已提交的,不会出现"DB 回滚但消息发出"
   ③ 可回溯:能重放历史变更,做数据修复/缓存重建

❌ 缺点:
   ① 增加运维组件(Canal / Debezium 集群)
   ② 有延迟(通常毫秒~秒级)
   ③ 消息内容受限于表结构变更(需要转换层)
   ④ 表结构变更要同步维护映射

适用场景:数据同步、缓存更新(第 4 章 Canal 订阅 binlog 更新缓存就是这个)

3.3.5 三种方案怎么选

方案 侵入性 实时性 依赖 适用
本地消息表 高(业务库多张表) 秒级(定时扫描) 无(任何 MQ) 通用、MQ 不支持事务消息时
RocketMQ 事务消息 中(写监听器 + 事务日志表) 毫秒级 RocketMQ RocketMQ 用户的首选 ★
Kafka 事务 中 毫秒级 Kafka Kafka 内部多 topic 原子写
Canal 监听 binlog 零侵入 毫秒~秒级 Canal/Debezium 数据同步、缓存更新

3.4 重复消费与幂等设计

3.4.1 为什么必然会有重复消息?

重要认知:MQ 只能保证「至少一次投递(At Least Once)」,重复是常态,不是异常。

产生重复的五个来源:

① 生产者重试:send() 超时但 Broker 实际已收到 → 生产者重试 → 两条
② Broker 主从切换:从节点晋升,部分已消费 offset 丢失 → 重新投递
③ 消费者 ack 失败:业务处理成功,但 ack 时网络断了 → 重新投递
④ Rebalance:消费者上下线,分区重分配 → 部分消息重新消费
⑤ 运维重放:人工重推历史消息做数据修复

三种投递语义:

语义 含义 实现难度 MQ 支持
At most once 最多一次,可能丢 低 都支持
At least once 至少一次,可能重复 低 默认
Exactly once 精确一次 高 Kafka 事务(限内部)/ 业务幂等
★ 核心结论:
   MQ 的 Exactly Once 只能覆盖「MQ 内部」,无法覆盖「下游业务副作用」。
   真正的 Exactly Once = At Least Once(MQ 投递)+ 业务幂等(消费端保证)
   这就是幂等设计为什么是必须项,而不是可选项。

3.4.2 幂等的定义与判定

幂等(Idempotent):同一个操作执行一次和执行 N 次,对系统的影响(副作用)相同。

  ✅ 幂等:  UPDATE user SET name='张三' WHERE id=1        (执行 N 次结果一样)
  ❌ 非幂等:UPDATE user SET points=points+10 WHERE id=1   (执行 N 次加 N 次)

  ✅ 幂等:  DELETE FROM t WHERE id=1
  ❌ 非幂等:INSERT INTO t VALUES(1,'x')                    (重复插入报错/多条)

  ✅ 幂等:  SETNX lock_key 1
  ❌ 非幂等:INCR counter

3.4.3 五种幂等方案(从弱到强)


方案一:数据库唯一索引(★ 最可靠,必做)

-- 幂等表 / 业务表加唯一索引
ALTER TABLE reward_record ADD UNIQUE KEY uk_biz_key (biz_key);
-- 或组合唯一键
ALTER TABLE signup ADD UNIQUE KEY uk_user_activity_date (user_id, activity_id, signup_date);
// 利用唯一索引做幂等:用 INSERT 的成功/失败判断是否首次执行
try {
    rewardRecordMapper.insert(record);      // 首次:成功
    // ↓ 只有首次才会走到这里
    doRealGrant(record);                    // 真正发奖
} catch (DuplicateKeyException e) {
    log.info("重复请求,已拦截,bizKey={}", bizKey);
    return;                                  // 重复:直接返回
}
✅ 优点:数据库层面强制保证,任何并发、任何来源的重复都能挡住
❌ 注意:
   · 唯一索引会让 INSERT 变慢一点点(要查重),可接受
   · 分库分表时,唯一索引必须包含分片键,否则跨分片无法保证唯一
   · 幂等表要定期归档,否则数据膨胀

方案二:Redis SETNX(高性能前置拦截)

/**
 * Redis 幂等:适合高并发场景,挡在 DB 前面
 */
public boolean tryIdempotent(String bizKey, Duration ttl) {
    String key = "idempotent:" + bizKey;
    // setIfAbsent = SETNX + EXPIRE,原子操作
    Boolean ok = redisTemplate.opsForValue().setIfAbsent(key, "1", ttl);
    return Boolean.TRUE.equals(ok);
}

// 使用
if (!tryIdempotent(bizKey, Duration.ofHours(24))) {
    return;                     // 重复请求,快速返回
}
try {
    doBiz();                    // 真正业务
} catch (Exception e) {
    redisTemplate.delete("idempotent:" + bizKey);   // ★ 失败要删锁,允许重试
    throw e;
}
⚠️ 三个必须注意的点:

① TTL 必须足够长
   要长于「消息最大重试周期」。消息重试可能持续几小时,
   TTL 设太短 → 锁过期 → 重试的消息被当成新请求 → 重复执行

② 业务失败必须删锁
   否则后续重试会被永久拦截(业务没成功,但再也不让执行了)

③ Redis 不能用 SETNX + EXPIRE 两条命令(非原子)
   ❌ setnx key; expire key 60     → setnx 后宕机 → 锁永不过期 → 死锁
   ✅ SET key value NX EX 60       → 原子(setIfAbsent(key, v, ttl))

④ Redis 幂等不是强保证(Redis 可能丢数据/主从切换丢锁)
   → 必须配合数据库唯一索引做最终兜底(双保险)

方案三:状态机幂等(业务态校验)

/**
 * 状态机:只有处于"前置状态"才允许流转到"目标状态"
 * 天然幂等:重复执行时状态已变更,条件不匹配 → 更新影响行数 = 0
 */
@BizTransactional
public void confirmInstruction(String instructionNo) {
    // ★ 带状态条件的 UPDATE(CAS),幂等且防并发
    int rows = instructionMapper.updateStatusWithExpect(
        instructionNo,
        "PENDING",        // 期望当前状态
        "CONFIRMED",      // 目标状态
        LocalDateTime.now()
    );
    // SQL: UPDATE instruction SET status='CONFIRMED' WHERE instruction_no=?
    //      AND status='PENDING'

    if (rows == 0) {
        // 没更新到 → 说明:① 已经确认过(重复请求)或 ② 状态不对
        Instruction inst = instructionMapper.selectByNo(instructionNo);
        if ("CONFIRMED".equals(inst.getStatus())) {
            log.info("指令已确认,重复请求跳过,no={}", instructionNo);
            return;                       // 幂等返回
        }
        throw new BizException("指令状态不允许确认,当前状态:" + inst.getStatus());
    }

    // ↓ 只有抢到状态变更的实例才执行后续动作
    positionService.deduct(instructionNo);        // 扣减头寸
    auditLogService.log(instructionNo, "CONFIRM"); // 审计日志
}
✅ 优点:
   ① 零额外表,用业务状态本身做幂等
   ② 天然支持并发安全(数据库行锁 + CAS)
   ③ 可追溯:状态流转历史就是业务流水

📌 你的简历项目一(资产托管指令状态机)就是这个模式:
   指令状态:录入 → 复核 → 确认 → 交收中 → 已交收 / 已撤销
   每次流转都用「期望状态 + 目标状态」的 CAS 更新,重复请求自然被拦截

方案四:乐观锁版本号

ALTER TABLE account ADD COLUMN version INT NOT NULL DEFAULT 0;
@BizTransactional
public boolean deduct(Long accountId, BigDecimal amount, int expectedVersion) {
    int rows = accountMapper.deductWithVersion(accountId, amount, expectedVersion);
    // SQL: UPDATE account SET balance = balance - #{amt}, version = version + 1
    //      WHERE id = #{id} AND version = #{expectedVersion}

    return rows > 0;      // 0 表示版本已变(被并发修改过 或 重复请求)
}
适用:更新类操作、并发扣减
不适用:插入类操作(插入没有"版本"可比较 → 用唯一索引)

方案五:去重表(消费台账)

/**
 * 独立的消费记录表,记录"哪些消息处理过"
 * 适合:无法给业务表加唯一索引、或一个业务操作对应多条消息的场景
 */
@BizTransactional(rollbackFor = Exception.class)
public void consumeWithDedup(String msgId, String bizKey, Supplier<Void> bizAction) {
    try {
        // ① 插入消费记录(msgId 唯一)
        consumeLogMapper.insert(ConsumeLog.builder()
            .msgId(msgId)          // MQ 的消息 ID,全局唯一
            .bizKey(bizKey)
            .status("PROCESSING")
            .build());
    } catch (DuplicateKeyException e) {
        log.info("消息已消费过,跳过,msgId={}", msgId);
        return;
    }

    try {
        bizAction.get();                                   // ② 执行业务
        consumeLogMapper.updateStatus(msgId, "SUCCESS");   // ③ 标记成功
    } catch (Exception e) {
        consumeLogMapper.updateStatus(msgId, "FAILED", e.getMessage());
        throw e;
    }
}
去重表 vs 业务表唯一索引:
  去重表:通用,不改业务表结构;但多一次 INSERT,且要处理"记录成功但业务失败"的中间态
  业务唯一索引:更简洁,和业务强绑定;但需要业务本身有天然唯一键

  ★ 建议:优先用业务唯一索引(更可靠),
         没有天然唯一键时才用去重表

3.4.4 幂等键设计原则(面试会追问)

好的幂等键必须满足三点:

① 唯一性:能唯一标识"这一次业务操作"
   ✅ userId + activityId + date       (同一用户同一活动同一天只发一次奖)
   ✅ orderId + operationType          (同一订单的同一操作)
   ❌ 只用 userId                       (用户今天参加活动 A,明天参加活动 B,会互相覆盖)

② 稳定性:重试/重投时算出来的键必须一样
   ❌ 用 UUID.randomUUID() 每次生成     (重试时键变了,幂等失效!)
   ❌ 用 System.currentTimeMillis()     (每次都不同)
   ✅ 用业务主键组合                     (稳定不变)

③ 可解释性:出问题能一眼看出是哪笔业务
   ✅ "10086:9527:20260902"             (用户10086 + 活动9527 + 日期)
   ❌ "a3f2c1e8-..."                    (无法定位业务)

★ 你的简历项目四用的 (userId + activityId + date) 就是标准范式:
   · 唯一:三元组确定一次报名
   · 稳定:重试时三要素不变
   · 可解释:能直接看出是谁、哪个活动、哪天

3.4.5 幂等的三个致命陷阱

陷阱①:先删幂等标记,后做业务
   ❌ delete lock → doBiz()     → 删除后业务还没做完,重复请求进来了
   ✅ doBiz() → 成功才保留标记;失败才删标记

陷阱②:把"查询"当幂等依据(check-then-act 非原子)
   ❌ if (!exists(bizKey)) { insert(); }     → 并发下两个请求都查询到不存在 → 都插入
   ✅ 直接 insert 靠唯一索引报错,或用 Redis SETNX 原子抢锁

陷阱③:幂等键粒度过粗/过细
   过粗:只用 userId    → 用户参加第二个活动被误拦截(正常业务被挡)
   过细:带了时间戳    → 重试时键变了,幂等失效(重复执行)

3.4.6 幂等设计专项面试题

# 题目 难度
1 为什么 MQ 不能保证 Exactly Once?怎么做才能真正不重复? ⭐⭐⭐
2 消息为什么会重复?列举至少 3 个来源 ⭐⭐⭐
3 什么是幂等?举一个幂等和一个非幂等的例子 ⭐⭐
4 幂等有哪几种实现方案?各自的优缺点? ⭐⭐⭐⭐
5 幂等键怎么设计?为什么不能用 UUID? ⭐⭐⭐⭐
6 Redis SETNX 做幂等有什么坑?TTL 设多长? ⭐⭐⭐⭐
7 用唯一索引做幂等,分库分表后还有效吗? ⭐⭐⭐⭐
8 状态机怎么实现幂等?你项目里怎么用的? ⭐⭐⭐⭐
9 消费端处理失败要重试,但业务异常不可重试,怎么区分? ⭐⭐⭐
10 如果用 SETNX 做了幂等,业务执行失败了怎么办? ⭐⭐⭐⭐⭐

3.5 顺序消息

3.5.1 什么时候需要顺序?

典型场景:同一个业务对象的多个状态变更必须按序处理

  ✅ 需要顺序:
     · 订单:创建 → 支付 → 发货 → 完成
       ("发货"先到会出问题:订单还不存在)
     · 资产托管指令:录入 → 复核 → 确认 → 交收
     · 数据库 binlog 同步:INSERT → UPDATE → DELETE

  ❌ 不需要顺序:
     · 发短信、发推送、埋点上报、日志收集
       (谁先谁后无所谓)

⚠️ 顺序和吞吐是矛盾的:

顺序保证 = 串行处理 = 无法并发 = 吞吐受限
所以能不用顺序就不用,能用"局部顺序"就不要"全局顺序"

3.5.2 全局顺序 vs 分区顺序

┌──────────────────────────────────────────────────────────┐
│ 全局顺序:整个 Topic 的所有消息严格 FIFO                    │
│                                                           │
│   Topic: 1个Queue(或所有Queue串行消费)                    │
│   消息: M1 → M2 → M3 → M4 → M5                            │
│   消费: 单线程,严格按序                                    │
│                                                           │
│   吞吐:★(最低,等于单线程)                                │
│   容错:差(一条卡住,全部卡住)                             │
│   适用:极少数场景(如 binlog 全局回放)                     │
└──────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────┐
│ 分区顺序(局部顺序)★ 生产常用                               │
│                                                           │
│   同一个 ShardingKey(如 orderId)的消息 → 同一个 Queue      │
│   → 同一个 Queue 内 FIFO                                    │
│                                                           │
│   Queue0: [order1-M1, order1-M2, order1-M3]  ──► 消费者A   │
│   Queue1: [order2-M1, order2-M2]             ──► 消费者B   │
│   Queue2: [order3-M1, order3-M2, order3-M3]  ──► 消费者C   │
│                                                           │
│   吞吐:★★★★(Queue 数量 = 并发度)                         │
│   容错:好(一个 Queue 卡住不影响其他)                      │
│   适用:99% 的业务顺序需求                                   │
└──────────────────────────────────────────────────────────┘

3.5.3 RocketMQ 顺序消息实现

要点有三:① 发送端按 Key 路由到同一 Queue;② 消费端用有序监听器;③ 消费端对 Queue 加锁。

/**
 * ① 发送端:用 MessageQueueSelector 保证同一 orderId 进同一 Queue
 */
public SendResult sendOrderly(OrderEvent event) {
    String orderId = String.valueOf(event.getOrderId());

    return producer.send(
        new Message("ORDER_TOPIC", "STATUS", orderId,
                    JSON.toJSONString(event).getBytes(StandardCharsets.UTF_8)),

        // ★ 选择器:按 orderId 的 hash 取模选 Queue
        new MessageQueueSelector() {
            @Override
            public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
                String key = (String) arg;
                int index = Math.abs(key.hashCode()) % mqs.size();
                return mqs.get(index);
            }
        },
        orderId                          // 传给 select 的 arg
    );
}
/**
 * ② 消费端:必须用 MessageListenerOrderly(不是 Concurrently)
 */
@Component
@Slf4j
public class OrderStatusConsumer implements MessageListenerOrderly {

    @Autowired
    private OrderStatusService orderStatusService;

    @Override
    public ConsumeOrderlyStatus consumeMessage(
            List<MessageExt> msgs, ConsumeOrderlyContext context) {

        // ★ 有序消费的关键配置
        context.setAutoCommit(true);    // 自动提交(也可以手动控制 offset)
        // context.setSuspendCurrentQueueTimeMillis(1000);  // 见下方"顺序 vs 重试"

        for (MessageExt msg : msgs) {
            OrderEvent event = JSON.parseObject(msg.getBody(), OrderEvent.class);
            try {
                // 单个 Queue 内串行执行,天然有序
                orderStatusService.applyStatus(event);
            } catch (Exception e) {
                log.error("顺序消息处理失败,orderId={}", event.getOrderId(), e);

                // ★ 顺序消息不能返回 RECONSUME_LATER 直接重试!
                //   因为重试消息会进入重试队列,破坏本 Queue 的顺序
                //   RocketMQ 的处理:挂起当前队列一段时间后再继续消费(不换队列)

                int times = msg.getReconsumeTimes();
                if (times >= 3) {
                    // 失败太多,记录后跳过,避免整个队列被毒丸消息永久阻塞
                    orderStatusService.markError(event, e.getMessage());
                    alertService.send("顺序消息处理失败,需人工介入,orderId=" + event.getOrderId());
                    return ConsumeOrderlyStatus.SUCCESS;      // 跳过这条,继续后面的
                }
                // 挂起当前队列 1 秒后重试(★ 仍在同一个 Queue,不破坏顺序)
                context.setSuspendCurrentQueueTimeMillis(1000);
                return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
            }
        }
        return ConsumeOrderlyStatus.SUCCESS;
    }
}
/**
 * ③ 消费端配置:并发线程数
 *    RocketMQ 顺序消费会对 Queue 加三把锁:
 *      · Broker 侧:Queue 锁(防同一 Queue 被多个消费者同时消费)
 *      · 客户端侧:MessageQueue 本地锁(防同一客户端内多线程并发)
 *      · ProcessQueue 锁(防 Rebalance 时重复消费)
 */
@Bean
public DefaultMQPushConsumer orderConsumer() throws MQClientException {
    DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("order-consumer-group");
    consumer.setNamesrvAddr("127.0.0.1:9876");
    consumer.subscribe("ORDER_TOPIC", "*");

    // 顺序消费:线程数一般 <= Queue 数量,多了也没用(Queue 被锁住)
    consumer.setConsumeThreadMin(4);
    consumer.setConsumeThreadMax(8);
    consumer.setPullBatchSize(32);

    consumer.registerMessageListener(new OrderStatusConsumer());
    consumer.start();
    return consumer;
}

3.5.4 顺序消息的三个坑(面试加分)

坑①:顺序 vs 重试的冲突 ★★★
   问题:消费失败重试时,RocketMQ 会把消息投递到【重试队列 %RETRY%group】,
        而重试队列和原 Queue 是分开的 → 后面的消息先被消费 → 顺序被破坏
   解法:有序监听器用 SUSPEND_CURRENT_QUEUE_A_MOMENT(挂起当前队列),
        而不是 RECONSUME_LATER(投递到重试队列)。
        RocketMQ 的 MessageListenerOrderly 内部已经做了这个处理。

坑②:发送端用了多线程/异步
   问题:即使用了 MessageQueueSelector,
        如果多线程并发 send,线程 A 的 M1 可能比线程 B 的 M2 晚到 Broker
   解法:同一个业务 Key 的发送必须串行(单线程 or 对 Key 加本地锁)

坑③:Broker 扩容 / Queue 数量变化
   问题:Queue 从 4 个扩到 8 个,hash % 4 和 hash % 8 结果不同
        → 同一个 orderId 的新消息进了另一个 Queue → 顺序断裂
   解法:
      · 扩容前先停写,等存量消息消费完
      · 或改用一致性 hash / 固定映射表(业务 Key → Queue 映射落库)
      · 或业务侧用"状态机 + 版本号"做兜底(乱序到达时用版本号丢弃旧消息)

兜底方案:版本号 / 状态机防乱序(比起顺序消息更推荐)

/**
 * ★ 更实用的做法:不依赖 MQ 顺序,业务侧自己防乱序
 *
 * 思路:每条消息带一个"状态版本号",只有版本号比当前大的才处理
 */
@BizTransactional
public void applyStatus(OrderEvent event) {
    // 状态序号:CREATED=1, PAID=2, SHIPPED=3, FINISHED=4
    int incoming = event.getStatus().getSeq();

    // SQL: UPDATE order SET status=?, status_seq=?
    //      WHERE order_id=? AND status_seq < ?
    int rows = orderMapper.updateStatusIfNewer(
        event.getOrderId(), event.getStatus().name(), incoming);

    if (rows == 0) {
        // 更新失败 → 要么已处理过(幂等),要么是乱序的旧消息(丢弃)
        log.info("状态未更新(重复或乱序),orderId={}, incomingSeq={}",
                 event.getOrderId(), incoming);
        return;
    }
    // 只有状态真正推进才执行后续动作
    afterStatusChanged(event);
}
★ 面试可以说:"我们优先用『状态机 + 版本号』做业务兜底,
  而不是强依赖 MQ 的顺序能力,因为顺序消息会牺牲吞吐,
  且扩容、重试都会破坏顺序。业务侧防乱序更健壮。"
  —— 这个回答比直接说"用顺序消息"更成熟

3.5.5 Kafka 的顺序保证

Kafka 的顺序保证比 RocketMQ 弱一些:

✅ 保证:单个 Partition 内严格有序
❌ 不保证:跨 Partition 的顺序
⚠️ 陷阱:max.in.flight.requests.per.connection > 1 且没开幂等时,
        重试可能导致乱序!

  场景:req1(失败重试) 和 req2(成功) 同时在途
       → req2 先到,req1 后到 → 乱序

  解法:
    enable.idempotence = true                    // 开启幂等(自动限制 in.flight <= 5 并保证顺序)
    max.in.flight.requests.per.connection = 1     // 或严格串行(性能差)

  ★ Kafka 顺序消费的另一个坑:
    消费者多线程消费同一个 Partition 会乱序
    → 解法:每个 Partition 一个线程,或用「按 key 分发到内存队列」的方式

3.6 消息堆积

3.6.1 堆积的本质与原因

堆积 = 生产速率 > 消费速率

  生产速率 ──────────────────────►
                                    ╲ 差值 = 堆积量
  消费速率 ──────────────►          ╱

四个常见原因:

原因 典型表现 排查方式
① 消费端变慢 消费 RT 突然变高 看消费端监控(RT、慢 SQL)
② 消费端异常反复重试 大量消息 reconsumeTimes > 0 看日志 + 重试次数分布
③ 消费实例减少 扩容缩容/实例崩溃 看消费者实例数
④ 流量突增 大促、活动、上游批量导入 看生产 TPS 曲线

3.6.2 排查命令(RocketMQ)

# ① 查看 Topic 堆积量(最重要)
sh mqadmin consumerProgress -n 127.0.0.1:9876 -g reward-consumer-group
# 输出:
#   #Topic        #Broker Name  #QID  #Broker Offset  #Consumer Offset  #Diff
#   REWARD_TOPIC  broker-a      0     120000          100000            20000  ← 堆积 2 万
#   REWARD_TOPIC  broker-a      1     120000          119000            1000
#   Diff Total: 21000

# ② 查看消费者连接情况(有没有实例掉线)
sh mqadmin consumerConnection -n 127.0.0.1:9876 -g reward-consumer-group

# ③ 查看 Topic 的 Queue 分布
sh mqadmin topicRoute -n 127.0.0.1:9876 -t REWARD_TOPIC

# ④ 按消息 Key 查询某条消息(排查具体问题消息)
sh mqadmin queryMsgByKey -n 127.0.0.1:9876 -t REWARD_TOPIC -k 10086:9527:20260902

# ⑤ 跳过堆积(紧急情况,丢弃部分消息)
sh mqadmin skipAccumulation -n 127.0.0.1:9876 -g reward-consumer-group \
    -t REWARD_TOPIC -f false          # -f false 表示不强制,慢慢跳

Kafka 对应命令:

# 查看消费组堆积(LAG 列就是堆积量)
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
    --describe --group reward-group
# 输出:TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG  CONSUMER-ID
#       reward 0       100000         120000         20000 ...

# 重置 offset(紧急跳过)
kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group reward-group \
    --reset-offsets --to-latest --topic reward --execute

3.6.3 四种处理手段(按优先级)


手段一:先止损——判断能不能扩容消费者(最简单,但有限制)

前提:消费者实例数 ≤ Queue/Partition 数
  4 个 Queue,部署 4 个消费者实例 → 再加第 5 个实例是空闲的,没用!

所以:
  ① 先看 Queue 数量够不够
  ② 不够就先扩 Queue(RocketMQ 可在线扩,Kafka 扩分区要注意 key 路由变化)
  ③ 再扩消费者实例

  RocketMQ 在线扩容 Queue:
    mqadmin updateTopic -n 127.0.0.1:9876 -t REWARD_TOPIC -c DefaultCluster -w 16
    (把写队列数从 4 扩到 16,新消息会进新 Queue;
      ★ 但顺序消息的 hash 路由会变,见 3.5.4 坑③)

手段二:提升单实例消费能力(最有效)

/**
 * ① 批量消费:一次拉多条,减少网络往返
 */
@Bean
public DefaultMQPushConsumer batchConsumer() throws MQClientException {
    DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("reward-group");
    consumer.subscribe("REWARD_TOPIC", "*");

    // 批量拉取(默认 32)
    consumer.setPullBatchSize(128);
    // ★ 批量消费:ConsumeMessageBatchMaxSize 默认 1,调大后一次收到多条
    consumer.setConsumeMessageBatchMaxSize(64);
    consumer.setConsumeThreadMin(20);
    consumer.setConsumeThreadMax(64);

    consumer.registerMessageListener((MessageListenerConcurrently) (msgs, ctx) -> {
        // msgs 最多 64 条 → 批量写库,性能提升数倍
        rewardService.batchGrant(msgs);
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    });
    consumer.start();
    return consumer;
}

/**
 * ② 消费端业务逻辑优化(最常见瓶颈)
 */
@Service
public class RewardServiceOptimize {

    // ❌ 优化前:每条消息 3 次 DB 交互 + 1 次远程调用
    @BizTransactional
    public void grantSlow(RewardEvent e) {
        User user = userMapper.selectById(e.getUserId());          // 查 1
        Activity act = activityMapper.selectById(e.getActivityId());// 查 2
        if (!checkRule(user, act)) return;                          // 远程风控调用 ← 慢!
        rewardRecordMapper.insert(buildRecord(e));                  // 写 1
        userPointMapper.addPoints(e.getUserId(), e.getPoints());    // 写 2
    }
    // 单条 RT ≈ 50ms(含 30ms 远程调用)→ 单线程 QPS ≈ 20

    // ✅ 优化后:本地缓存 + 批量合并 + 远程调用异步化
    @BizTransactional
    public void grantFast(List<RewardEvent> events) {
        // 1. 字典数据走 Caffeine 本地缓存(活动信息几乎不变)
        // 2. 风控改为「事后抽检」+ 实时只做本地规则
        // 3. 批量 INSERT(一条 SQL 写 100 条)
        rewardRecordMapper.batchInsert(buildRecords(events));
        // 4. 积分批量累加
        userPointMapper.batchAddPoints(events);
    }
    // 批量 100 条 RT ≈ 30ms → QPS ≈ 3300(提升 150 倍)
}
消费端优化清单(按收益排序):
  ① 批量写库(INSERT 多行 / MyBatis foreach / JDBC batch)★★★★★
  ② 去掉消费链路里的远程调用(改本地缓存 / 异步 / 事后核对)★★★★★
  ③ 增加消费线程数(注意:CPU 密集型和 IO 密集型取值不同)★★★★
  ④ 减少事务范围(消费端不一定需要大事务)★★★
  ⑤ 预热缓存、连接池调大 ★★

手段三:紧急降级——批量转发到更多队列

场景:堆积 1000 万条,扩消费者来不及,业务允许一定延迟

做法(RocketMQ 官方推荐):
  ① 新建 N 倍 Queue 的临时 Topic(如 REWARD_TOPIC_FIX,Queue 数 × 10)
  ② 部署一个"搬运"消费者,不做业务,只把原 Topic 的消息转投到临时 Topic
  ③ 临时 Topic 部署 10 倍消费者实例并行消费
  ④ 堆积消化完后,切回原 Topic,下线临时资源

  ┌──────────────┐   搬运消费者    ┌─────────────────────┐
  │ REWARD_TOPIC │ ─────────────► │ REWARD_TOPIC_FIX    │
  │ 4 Queue      │                │ 40 Queue            │
  └──────────────┘                └─────────────────────┘
                                            │
                          ┌─────────────────┼─────────────────┐
                          ▼                 ▼                 ▼
                      消费者×10          消费者×10         消费者×10

手段四:跳过堆积(最后手段,会丢消息)

# RocketMQ:把消费位点推进到最新,丢弃堆积的消息
mqadmin skipAccumulation -n 127.0.0.1:9876 -g reward-group -t REWARD_TOPIC -f true
⚠️ 这是丢消息的操作,必须满足两个前提才敢做:
  ① 消息可以丢(如埋点、日志、可重建的缓存刷新消息)
  ② 有对账/补偿机制能把丢的部分找回来

  ✋ 资金、订单、发奖类消息绝对不能这么做!

3.6.4 预防:监控与告警

# Prometheus + Grafana 告警规则(RocketMQ Exporter)
groups:
  - name: mq-alerts
    rules:
      - alert: MQMessageBacklog
        expr: rocketmq_group_diff{group="reward-group"} > 10000
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "MQ 堆积告警:{{ $labels.topic }} 堆积 {{ $value }} 条"

      - alert: MQMessageBacklogCritical
        expr: rocketmq_group_diff{group="reward-group"} > 100000
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "MQ 严重堆积:{{ $labels.topic }} 堆积 {{ $value }} 条"

      - alert: MQConsumeSlow
        expr: rate(rocketmq_consume_rt[5m]) > 500
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "消费耗时升高:{{ $value }} ms"

      - alert: MQConsumerDown
        expr: rocketmq_group_count{group="reward-group"} < 2
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "消费者实例数不足,当前 {{ $value }} 个"
生产必配的四个告警:
  ① 堆积量(Diff / LAG)> 阈值,持续 5 分钟
  ② 消费 RT 突增(同比昨天 > 2 倍)
  ③ 消费者实例数 < 预期
  ④ 死信队列有消息(说明有永远处理不了的消息)

3.7 对账与兜底:一致性的最后一道防线

3.7.1 为什么一定要有对账?

★ 核心认知:任何一致性方案都不是 100% 可靠的

  · 事务消息:回查 15 次都失败 → 被回滚,但本地事务可能成功了
  · 幂等:幂等键设计错了 → 重复执行
  · 重试:重试 5 次都失败 → 进死信,没人处理就丢了
  · 缓存:TTL 到期那一刻正好 DB 更新失败 → 长期不一致
  · 人为:运营手工改库、DBA 误操作、灰度 bug

  → 所以必须有一个独立的、不依赖主链路的机制来发现不一致:对账

3.7.2 对账的三要素

对账 = 拿两份数据比对,找出差异,并处理差异

  ① 比对基准(以谁为准)
     资金类:以银行/支付渠道流水为准
     业务类:以业务主表为准,缓存/消息/下游为副本
     ★ 原则:以"不可变的、权威的、源头的数据"为准

  ② 比对粒度
     全量对账:T+1 全表扫描比对(慢,但完整)
     增量对账:按时间窗口比对(快,实时性好)
     抽样对账:随机抽查(轻量,用于高频场景)

  ③ 差异处理
     自动补偿:能明确判断的(如漏发消息)→ 自动重试
     人工介入:无法判断的(如对不上账)→ 告警 + 工单

3.7.3 三种对账实现


① 离线对账(T+1,最常用)

/**
 * T+1 对账任务:比对「报名记录」与「发奖记录」,找出漏发/多发
 */
@Service
@Slf4j
public class DailyReconciliationJob {

    @Autowired
    private SignupMapper signupMapper;
    @Autowired
    private RewardRecordMapper rewardRecordMapper;
    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    /**
     * 每天凌晨 3 点跑前一天的数据
     */
    @Scheduled(cron = "0 0 3 * * ?")
    public void reconcile() {
        LocalDate date = LocalDate.now().minusDays(1);
        log.info("开始对账,日期={}", date);

        int offset = 0, size = 1000;
        int fixedCount = 0, errorCount = 0;

        while (true) {
            // ① 分页扫描业务主表(报名记录)
            List<Signup> signups = signupMapper.selectByDate(date, offset, size);
            if (signups.isEmpty()) break;

            for (Signup s : signups) {
                String bizKey = s.getUserId() + ":" + s.getActivityId() + ":"
                              + date.format(DateTimeFormatter.BASIC_ISO_DATE);

                // ② 检查发奖记录是否存在
                RewardRecord record = rewardRecordMapper.selectByBizKey(bizKey);

                if (record == null) {
                    // ★ 差异类型一:漏发(有报名无发奖)
                    log.warn("发现漏发,bizKey={}", bizKey);
                    // 自动补偿:重新发消息(消费端有幂等,不会重复)
                    rocketMQTemplate.syncSend("REWARD_TOPIC:GRANT",
                        MessageBuilder.withPayload(JSON.toJSONString(
                            RewardEvent.builder().bizKey(bizKey)
                                .userId(s.getUserId())
                                .activityId(s.getActivityId())
                                .points(s.getPoints())
                                .source("RECONCILE")     // 标记来源,便于追溯
                                .build()))
                            .setHeader(MessageConst.PROPERTY_KEYS, bizKey)
                            .build());
                    fixedCount++;

                } else if (record.getPoints().compareTo(s.getPoints()) != 0) {
                    // ★ 差异类型二:金额/积分不一致
                    log.error("发奖积分不一致,bizKey={}, 期望={}, 实际={}",
                              bizKey, s.getPoints(), record.getPoints());
                    errorCount++;                        // 需人工介入
                    alertService.send("对账发现积分不一致,需人工处理,bizKey=" + bizKey);
                }
            }
            offset += size;
        }

        // ③ 反向对账:有发奖无报名(多发)
        List<RewardRecord> orphans = rewardRecordMapper.selectOrphanByDate(date);
        for (RewardRecord r : orphans) {
            log.error("发现孤儿发奖记录(无对应报名),bizKey={}", r.getBizKey());
            errorCount++;
            alertService.send("对账发现异常发奖,bizKey=" + r.getBizKey());
        }

        // ④ 对账结果落库 + 汇总告警
        reconcileLogMapper.insert(ReconcileLog.builder()
            .bizDate(date).fixedCount(fixedCount).errorCount(errorCount)
            .status(errorCount == 0 ? "OK" : "HAS_ERROR").build());

        log.info("对账完成,日期={},自动修复 {} 条,需人工 {} 条", date, fixedCount, errorCount);
    }
}

② 实时对账(准实时,秒级~分钟级)

/**
 * 思路:消费端处理完后,写一条"对账凭证",由对账服务异步核验
 *
 * 适合:资金类、不允许隔夜才发现问题的场景
 */
@Component
@Slf4j
public class RealtimeReconcileChecker {

    /**
     * 定时扫描"处理中超过 N 分钟未闭环"的业务单
     */
    @Scheduled(fixedDelay = 60_000)
    public void checkUnfinished() {
        // 找出超过 5 分钟仍处于中间态的记录
        List<RewardRecord> stuck = rewardRecordMapper.selectStuck(
            LocalDateTime.now().minusMinutes(5), 500);

        for (RewardRecord r : stuck) {
            // ① 先查上游状态(不能只信本地状态)
            Signup signup = signupMapper.selectByBizKey(r.getBizKey());

            if (signup == null) {
                // 上游都没成功 → 本条是脏数据,回滚
                rewardService.rollbackGrant(r.getBizKey());
            } else if ("SIGNED".equals(signup.getStatus())
                    && !"GRANTED".equals(r.getStatus())) {
                // 上游成功但本地没发奖 → 重投消息
                rocketMQTemplate.syncSend("REWARD_TOPIC:GRANT", buildEvent(r));
            }
        }
    }
}

③ 你的简历项目一:头寸预增减与实入账匹配(业务级对账范例)

场景(资产托管的资金/证券交收):

  指令确认时:预增减头寸(可用数量 -N,在途数量 +N)
  实际交收后:实入账(在途 -N,实际持有 -N / +N)

  一致性要求:预增减 与 实入账 必须匹配,差额为 0

  ┌──────────────┐         ┌──────────────┐        ┌──────────────┐
  │ 指令确认      │         │ 交收完成      │        │ 日终对账      │
  │ 预增减头寸    │ ──────► │ 实入账        │ ─────► │ 差额校验      │
  │ 可用-100 在途+100      │ 在途-100      │        │ 差额应为 0    │
  └──────────────┘         └──────────────┘        └──────────────┘

★ 这就是"事中监督"的核心:
  不是等日终才发现,而是在每个环节校验头寸是否守恒
/**
 * 头寸守恒校验(事中监督)
 */
@Service
@Slf4j
public class PositionReconcileService {

    /**
     * 头寸守恒公式(必须恒等):
     *   可用 + 在途 - 待交收 = 总持仓
     */
    public ReconcileResult checkPosition(String accountId, String securityCode) {
        Position p = positionMapper.select(accountId, securityCode);

        BigDecimal total = p.getAvailable()
            .add(p.getInTransit())
            .subtract(p.getPendingDelivery());

        if (total.compareTo(p.getTotalHolding()) != 0) {
            log.error("头寸不守恒!account={}, sec={}, 差额={}",
                      accountId, securityCode,
                      total.subtract(p.getTotalHolding()));

            // 记录差异 + 冻结账户 + 告警
            positionDiffMapper.insert(buildDiff(accountId, securityCode,
                total.subtract(p.getTotalHolding())));
            accountService.freeze(accountId, "头寸不平,自动冻结");
            alertService.send("头寸不守恒,已冻结账户,account=" + accountId);

            return ReconcileResult.mismatch(total.subtract(p.getTotalHolding()));
        }
        return ReconcileResult.ok();
    }

    /**
     * 日终全量对账:比对 预增减流水 与 实入账流水
     */
    @Scheduled(cron = "0 30 18 * * ?")      // 每个交易日 18:30
    public void dailyPositionReconcile() {
        LocalDate bizDate = tradingCalendarService.currentBizDate();
        List<PositionDiff> diffs = positionMapper.findDiffs(bizDate);

        for (PositionDiff d : diffs) {
            // 差异分级处理
            if (d.getDiff().abs().compareTo(THRESHOLD_SMALL) <= 0) {
                // 小额差异:自动调平(记调账流水)
                positionService.autoAdjust(d);
            } else {
                // 大额差异:冻结 + 告警 + 工单
                accountService.freeze(d.getAccountId(), "日终对账不平");
                alertService.send("日终头寸不平,差额=" + d.getDiff());
                workOrderService.create(d);
            }
        }
    }
}

3.7.4 对账系统设计要点

① 对账任务必须幂等
   重跑 N 次结果一样,不能重复补偿

② 对账不能影响主链路
   独立部署、独立数据源(读从库)、限流扫描

③ 差异要分级处理
   小额/明确 → 自动补偿
   大额/不明 → 冻结 + 人工

④ 对账结果要可观测
   每天出对账报表:成功多少、差异多少、修复多少
   差异率持续升高 = 系统有问题,必须复盘

⑤ 对账要有"兜底的兜底"
   对账任务本身也可能挂 → 监控对账任务的执行情况
   ("谁来看守看守者"——对账任务的监控告警)

3.8 本章(消息一致性)面试题汇总

# 题目 难度
1 消息丢失可能发生在哪几个环节?怎么解决? ⭐⭐⭐
2 RocketMQ 的刷盘策略和主从复制各有哪几种?怎么配不丢消息? ⭐⭐⭐⭐
3 Kafka 的 acks=0/1/all 有什么区别?和 min.insync.replicas 的关系? ⭐⭐⭐⭐
4 消费端为什么会丢消息?正确的 ACK 时机是什么? ⭐⭐⭐
5 先发消息还是先执行本地事务?两种做法各有什么问题? ⭐⭐⭐⭐⭐
6 本地消息表的原理?为什么消息表要和业务表同库? ⭐⭐⭐⭐
7 RocketMQ 事务消息的实现原理(半消息 + 回查)?画出时序图 ⭐⭐⭐⭐⭐
8 事务回查时查不到事务记录怎么办? ⭐⭐⭐⭐⭐
9 事务消息能保证“消费一定成功”吗? ⭐⭐⭐⭐
10 Half 消息是什么?为什么消费端看不到? ⭐⭐⭐⭐
11 RocketMQ 事务消息有哪些限制? ⭐⭐⭐
12 什么是幂等?MQ 场景下为什么必须做幂等? ⭐⭐⭐
13 幂等键怎么设计?为什么不能用 UUID 或时间戳? ⭐⭐⭐⭐
14 幂等有哪五种实现方案?你项目里用的哪种? ⭐⭐⭐⭐
15 用 Redis SETNX 做幂等,业务执行失败了怎么办? ⭐⭐⭐⭐⭐
16 全局顺序和分区顺序的区别?怎么实现? ⭐⭐⭐⭐
17 顺序消息和重试为什么冲突?怎么解决? ⭐⭐⭐⭐⭐
18 Queue 扩容为什么会影响顺序消息? ⭐⭐⭐⭐
19 不用顺序消息,怎么保证状态不乱? ⭐⭐⭐⭐
20 消息堆积的原因有哪些?怎么排查? ⭐⭐⭐
21 堆积了几百万条消息,怎么处理? ⭐⭐⭐⭐
22 消费者实例数加得比 Queue 数多有用吗? ⭐⭐⭐
23 为什么要对账?对账的三种方式? ⭐⭐⭐⭐
24 对账发现差异怎么处理? ⭐⭐⭐
25 死信队列是什么?什么时候用? ⭐⭐⭐
26 你们项目怎么保证消息不丢、不重复?遇到过什么问题? ⭐⭐⭐⭐⭐

开放题回答框架(“你们项目怎么保证消息可靠性?”):

第一步:说清楚问题(20 秒)
  "消息可靠性要分三个环节看:生产端、Broker、消费端,
   另外还有一个是本地事务与消息发送的原子性。"

第二步:逐个说方案(60 秒)
  生产端:同步发送 + 重试 3 次 + 失败落 mq_send_log + 补偿任务重投
  Broker :ASYNC_FLUSH + SYNC_MASTER(多副本),Kafka 用 acks=all
  消费端:业务处理成功才 ACK,失败区分业务异常(进死信)和系统异常(重试 5 次)
  原子性:核心场景用 RocketMQ 事务消息(半消息 + 回查),
          非核心用本地消息表

第三步:说幂等(30 秒)
  "MQ 只能保证至少一次投递,重复是必然的,所以消费端必须幂等。
   我们用 (userId + activityId + date) 作为幂等键,
   Redis SETNX 做前置快速拦截 + 数据库唯一索引做最终兜底,
   双保险。业务失败时删除 Redis 锁,允许重试。"

第四步:说兜底(30 秒)—— ★ 这一步是区分度所在
  "光有方案不够,我们还有两道兜底:
   ① 死信队列监控,有消息就告警;
   ② 每天 T+1 全量对账,比对业务表和发奖表,
      漏发的自动重新投递(消费端幂等所以不怕重复),
      金额不一致的告警人工处理。
   对账任务的健康度本身也有监控,防止对账自己挂了没人知道。"

第五步:举一个真实踩过的坑(30 秒)—— ★ 加分项
  "早期我们遇到一个坑:消费端处理成功但 ACK 时网络抖动,
   消息被重新投递,导致重复发奖。
   原因是当时只用了 Redis SETNX,TTL 设的 5 分钟,
   而消息重试周期最长能到 2 小时,锁过期了就挡不住了。
   后来加了数据库唯一索引做最终兜底,并把 TTL 调整为 24 小时。"

第四章:缓存一致性(重点章)

本章要回答的问题: 数据库改了,缓存还是旧值怎么办?先删缓存还是先改数据库?多级缓存(Caffeine + Redis)怎么保证节点间一致?缓存穿透/击穿/雪崩在多级缓存架构下怎么处理?

你简历的强相关点(项目三 · 零碳能源云):

  • Caffeine L1 + Redis L2 多级缓存,大屏接口 RT 从 1200ms 降到 200ms
  • 设备采集数据高频读、低频更新
  • 多实例部署 → L1 是进程内缓存,节点间一致性是核心难点

4.1 缓存一致性的本质问题

4.1.1 为什么会不一致?

引入缓存后,同一份数据有了两个副本:

    ┌────────────┐                    ┌────────────┐
    │   缓存     │                    │   数据库   │
    │  value=A   │                    │  value=A   │
    └────────────┘                    └────────────┘
          │                                  │
          └────────── 两个副本 ──────────────┘

    更新操作:只改了数据库 → 缓存还是 A,数据库变成 B → 不一致
    更新操作:只改了缓存   → 缓存被淘汰后从 DB 读回旧值 A → 不一致

★ 本质:两个副本的更新不是原子的(和 3.3 节"DB + 消息"是同一类问题)
   DB 和 缓存 是两个独立资源,没有分布式事务能同时管住它们

4.1.2 缓存一致性能做到多强?

┌────────────────────────────────────────────────────────┐
│ 结论:只要用缓存,就不可能做到"强一致"(除非用分布式锁串行化)  │
│                                                         │
│ 强一致代价:更新时加分布式锁,读也加锁 → 缓存失去意义           │
│                                                         │
│ 业界标准做法:追求「最终一致」                              │
│   · 允许短暂不一致(毫秒 ~ 秒级)                           │
│   · 用 TTL 兜底(即使更新失败,TTL 到期后也会自愈)           │
│   · 关键业务可以「读主库 + 缓存」绕过                        │
└────────────────────────────────────────────────────────┘

一致性级别对照:

级别 含义 实现方式 代价
强一致 任何时刻读到的都是最新值 分布式锁 / 读主库 / 不缓存 性能差
弱一致 更新后可能有短暂不一致窗口 Cache Aside(先更 DB 后删缓存) 低,推荐
最终一致 一段时间后自动一致 TTL + 重试 + binlog 订阅 低
不一致 长期不一致(Bug) — 资损

4.1.3 你的项目三:多级缓存结构

                          读请求
                             │
                             ▼
                    ┌─────────────────┐
                    │  L1: Caffeine   │  进程内,纳秒级,无网络
                    │  (每个 JVM 一份)│  命中率 ~60%
                    └────────┬────────┘
                         miss │ (40%)
                             ▼
                    ┌─────────────────┐
                    │  L2: Redis      │  分布式共享,毫秒级
                    │  (集群共享一份) │  命中率 ~35%
                    └────────┬────────┘
                         miss │ (5%)
                             ▼
                    ┌─────────────────┐
                    │  L3: MySQL      │  磁盘,10ms 级
                    └─────────────────┘

  命中率:L1 60% + L2 35% = 95% 走缓存,只有 5% 打到 DB
  平均 RT:0.6×0.05ms + 0.35×2ms + 0.05×20ms ≈ 1.7ms
  无缓存时:100% 打 DB ≈ 20ms(实际业务里还有复杂查询 = 1200ms)

★ 代价:数据有 3 份副本(L1 × N 个实例 + Redis + DB)
  一致性难度随副本数增加 → 4.6 节专门讲多级缓存一致性

4.2 Cache Aside Pattern(旁路缓存,★ 最常用)

4.2.1 读流程与写流程

┌─────────────────────────────────────────────────────────┐
│ 读流程(Read)                                            │
│                                                          │
│   ① 读缓存 ── hit ──► 直接返回 ✅                          │
│        │                                                 │
│      miss                                                │
│        ▼                                                 │
│   ② 读数据库                                              │
│        │                                                 │
│        ▼                                                 │
│   ③ 写入缓存(设置 TTL)                                   │
│        │                                                 │
│        ▼                                                 │
│   ④ 返回数据                                              │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ 写流程(Write)★ 关键                                      │
│                                                          │
│   ① 更新数据库                                            │
│        │                                                 │
│        ▼                                                 │
│   ② 删除缓存(★ 不是更新缓存!)                            │
│                                                          │
└─────────────────────────────────────────────────────────┘

4.2.2 完整代码(含多级缓存)

/**
 * Cache Aside Pattern 完整实现(Caffeine L1 + Redis L2)
 *
 * 场景(你的项目三):能源设备档案查询
 *   读:高频(大屏每秒刷新,几十个设备)
 *   写:低频(设备档案变更,一天几次)
 */
@Service
@Slf4j
public class DeviceCacheAsideService {

    @Autowired
    private DeviceMapper deviceMapper;
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    @Qualifier("deviceLocalCache")
    private Cache<String, Device> localCache;      // Caffeine L1

    private static final String KEY_PREFIX = "device:";
    private static final Duration REDIS_TTL = Duration.ofMinutes(30);
    private static final Duration LOCAL_TTL = Duration.ofMinutes(5);   // ★ L1 TTL 更短

    // ───────────────────────────────────────────────
    // 读流程
    // ───────────────────────────────────────────────
    public Device getById(Long deviceId) {
        String key = KEY_PREFIX + deviceId;

        // ① 查 L1(Caffeine,进程内)
        Device device = localCache.getIfPresent(key);
        if (device != null) {
            metrics.increment("cache.l1.hit");
            return device;
        }
        metrics.increment("cache.l1.miss");

        // ② 查 L2(Redis)
        String json = redisTemplate.opsForValue().get(key);
        if (StringUtils.hasText(json)) {
            metrics.increment("cache.l2.hit");
            device = JSON.parseObject(json, Device.class);
            localCache.put(key, device);            // ★ 回填 L1
            return device;
        }
        metrics.increment("cache.l2.miss");

        // ③ 查 DB
        device = deviceMapper.selectById(deviceId);
        if (device == null) {
            // ★ 防穿透:空值也缓存,但要短 TTL(见 4.5.1)
            redisTemplate.opsForValue().set(key, "NULL",
                ThreadLocalRandom.current().nextInt(60, 120), TimeUnit.SECONDS);
            return null;
        }

        // ④ 回填缓存(先 L2 后 L1)
        redisTemplate.opsForValue().set(key, JSON.toJSONString(device), REDIS_TTL);
        localCache.put(key, device);
        metrics.increment("cache.db.query");

        return device;
    }

    // ───────────────────────────────────────────────
    // 写流程
    // ───────────────────────────────────────────────
    @BizTransactional(rollbackFor = Exception.class)
    public void update(Device device) {
        // ① 先更新数据库
        deviceMapper.updateById(device);

        // ② 后删除缓存(★ 顺序不能反,见 4.3)
        String key = KEY_PREFIX + device.getId();
        evictCache(key);
    }

    /**
     * 删除缓存:先删 L1(本地),再删 L2(Redis),再广播其他节点删 L1
     */
    private void evictCache(String key) {
        try {
            // ① 删本地 L1
            localCache.invalidate(key);

            // ② 删 Redis L2
            redisTemplate.delete(key);

            // ③ 广播其他节点删各自的 L1(★ 多级缓存的关键,见 4.6)
            redisTemplate.convertAndSend("cache:invalidate", key);

        } catch (Exception e) {
            // ★ 删缓存失败不能让业务失败,但要告警 + 兜底重试
            log.error("缓存删除失败,key={}", key, e);
            alertService.send("缓存删除失败,key=" + key);
            // 兜底:发 MQ 异步重试删除
            mqSender.send("CACHE_INVALIDATE_RETRY", key);
        }
    }
}

4.2.3 为什么是“删除缓存”而不是“更新缓存”?(★ 面试高频)

四个理由,从性能到正确性:

理由①:性能(懒加载)
   更新缓存 = 每次写都要序列化 + 写一次 Redis
   如果这条数据写多读少(如设备状态每秒更新,但大屏 5 分钟才看一次)
   → 白白写了几百次缓存,没人读
   删除缓存 = 写时才加载,第一次读才回写 → 省掉无用写

理由②:并发写导致脏数据 ★ 关键
   两个并发更新:
     线程A:更新 DB value=1 → 更新缓存 value=1
     线程B:更新 DB value=2 → 更新缓存 value=2
   但如果网络延迟导致顺序变成:
     线程A:更新 DB value=1
     线程B:更新 DB value=2
     线程B:更新缓存 value=2   ← B 先到缓存
     线程A:更新缓存 value=1   ← A 后到缓存(!)
   → DB = 2,缓存 = 1 → 永久不一致(直到 TTL 过期)

   而"删除缓存"不存在这个问题:
     无论 A、B 谁先删,结果都是"缓存没了"
     → 下次读必然从 DB 加载最新值 ✅

理由③:缓存值可能是"计算出来的"
   很多缓存不是简单的表记录,而是聚合结果:
     缓存 = 设备信息 + 告警统计 + 最近 24h 能耗曲线(三张表 JOIN + 聚合)
   更新时如果要"更新缓存",就得重新算一遍(很贵)
   删除缓存 = 用到再算 ✅

理由④:事务回滚
   如果先更新缓存,然后 DB 事务回滚了 → 缓存里是脏数据
   删除缓存 + 事务提交后再删 → 回滚就不会删(用 afterCommit)

★ 一句话记忆:
   "更新缓存"要处理并发顺序问题,"删除缓存"是幂等的(删一次和删 N 次一样)
   所以 Cache Aside 选择删除

4.2.4 Cache Aside 的已知缺陷(要能说出来)

缺陷①:首次读必然 miss(缓存冷启动)
   解法:预热(启动时加载热点数据)

缺陷②:删除缓存后、下次读之前的窗口内,如果有并发读,
        可能把旧值写回缓存(详见 4.3.2)

缺陷③:缓存删除失败 → 长期不一致
   解法:重试 + MQ 补偿 + binlog 订阅(4.4)

缺陷④:如果缓存里是"聚合数据"(多张表算出来的),
        任何一张源表变了都要删,容易漏删
   解法:统一封装缓存 key 的维护,或用 binlog 订阅自动失效

4.3 先删缓存,还是先更新数据库?(★ 本章最重要的一节)

4.3.1 方案一:先删缓存,再更新数据库

正常情况:
   T1 删除缓存 ✅
   T2 更新数据库 ✅
   → 缓存空,DB 最新 → 下次读加载新值 ✅

异常情况(并发读写)★ 问题在这里:

   时间 │  线程A(写:value 1→2)        │  线程B(读)
   ─────┼──────────────────────────────┼────────────────────────
    t1  │  删除缓存                      │
   ─────┼──────────────────────────────┼────────────────────────
    t2  │                              │  读缓存 → miss
   ─────┼──────────────────────────────┼────────────────────────
    t3  │                              │  读 DB → 旧值 1(A还没改!)
   ─────┼──────────────────────────────┼────────────────────────
    t4  │                              │  写入缓存 = 1(脏数据!)
   ─────┼──────────────────────────────┼────────────────────────
    t5  │  更新数据库 → 2                │
   ─────┼──────────────────────────────┼────────────────────────
   结果 │  DB = 2                       │  缓存 = 1  ❌ 长期不一致!
        │  (直到 TTL 过期或下次删除才会恢复)

为什么这个异常很容易发生?

★ 因为"读操作"比"写操作"快得多!

   写操作:删缓存(1ms) + 更新DB(10ms,还有事务、锁、binlog)
   读操作:查缓存(1ms) + 查DB(2ms) + 写缓存(1ms)

   t1 到 t5 之间有 10ms 的窗口,读操作完全来得及插进来
   → 在高并发读的场景(大屏每秒刷新几十次),这个概率不低!

4.3.2 方案二:先更新数据库,再删缓存(★ 推荐)

正常情况:
   T1 更新数据库 ✅
   T2 删除缓存 ✅
   → 一致 ✅

异常情况(并发读写):

   时间 │  线程A(写:value 1→2)        │  线程B(读)
   ─────┼──────────────────────────────┼────────────────────────
    t1  │                              │  读缓存 → miss
   ─────┼──────────────────────────────┼────────────────────────
    t2  │                              │  读 DB → 旧值 1
   ─────┼──────────────────────────────┼────────────────────────
    t3  │  更新数据库 → 2                │
   ─────┼──────────────────────────────┼────────────────────────
    t4  │  删除缓存                      │
   ─────┼──────────────────────────────┼────────────────────────
    t5  │                              │  写入缓存 = 1(脏数据?)
   ─────┼──────────────────────────────┼────────────────────────
   结果 │  DB = 2                       │  缓存 = 1  ❌

   ⚠️ 看起来也会不一致?但注意这个时序要求的条件极其苛刻:
      ① t1 缓存恰好失效(miss)
      ② t2 读 DB 完成后,t3 写 DB 才开始(读比写快,要恰好错开)
      ③ t4 删除缓存在 t5 写缓存之前完成

   即:写缓存(t5) 必须晚于 删缓存(t4),且早于下一次读
   → 而"写缓存"是纳秒~微秒级操作,"读 DB"是毫秒级
   → t2(读DB,ms级)→ t5(写缓存,μs级)之间的间隔极小
   → 要让 删缓存(t4) 插在 t2 和 t5 之间,概率极低

   ★ 更重要的是:即使发生了,影响也很短暂
     因为缓存刚被写进去,下一次读之前如果有任何一次删除就恢复了
     而且有 TTL 兜底

两种方案对比:

先删缓存,后更 DB 先更 DB,后删缓存 ★
不一致条件 读操作在“删缓存后、更新DB前”读到旧值并回填 读操作在“更DB前”读旧值,且回填发生在删缓存之后
发生概率 较高(写 DB 慢,窗口大) 极低(写缓存快,窗口极小)
理论保证 无 无(但概率极低)
兜底 TTL TTL
推荐度 ❌ 不推荐 ✅ 推荐(业界标准)
★ 面试标准答案:

"我们用『先更新数据库,再删除缓存』(Cache Aside)。

 理论上两种方案都有并发不一致的可能,但概率差别很大:
 · 先删缓存的窗口是『更新数据库』的耗时(10ms 级),
   高并发读很容易插进来,把旧值回填进缓存,造成长期不一致;
 · 先更 DB 的窗口要求『读 DB 完成 → 写缓存』之间刚好插入删除操作,
   而写缓存是微秒级,概率极低;
 而且即使发生,缓存刚写入就被下一次删除覆盖,影响非常短暂。

 另外还有三重兜底:
 ① 缓存都设了 TTL(最长不超过 30 分钟),最坏情况 TTL 到期自愈;
 ② 删除缓存失败会发 MQ 重试;
 ③ 核心场景(如设备实时状态)直接读主库,不走缓存。"

4.3.3 删除缓存失败怎么办?

问题:DB 更新成功了,但删 Redis 失败(网络抖动/Redis 超时)
     → 缓存里一直是旧值,且没有后续删除动作 → 长期不一致

方案一:事务提交后删除 + 异步重试(推荐,实现简单)

@BizTransactional(rollbackFor = Exception.class)
public void update(Device device) {
    deviceMapper.updateById(device);             // ① 更新 DB

    String key = "device:" + device.getId();

    // ② ★ 注册事务提交后回调(不能用 @Async 直接调!)
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                // 事务已提交,才删缓存
                // 如果事务回滚,这里不执行 → 不会误删缓存
                try {
                    localCache.invalidate(key);
                    redisTemplate.delete(key);
                    redisTemplate.convertAndSend("cache:invalidate", key);
                } catch (Exception e) {
                    // ③ 删除失败 → 发 MQ 重试
                    log.error("缓存删除失败,转 MQ 重试,key={}", key, e);
                    mqSender.sendReliable("CACHE_INVALIDATE", key);
                }
            }
        }
    );
}

/**
 * ④ 重试消费者(带退避 + 死信)
 */
@RocketMQMessageListener(topic = "CACHE_INVALIDATE", consumerGroup = "cache-invalidate-group")
public void retryInvalidate(String key) {
    try {
        redisTemplate.delete(key);
        redisTemplate.convertAndSend("cache:invalidate", key);
    } catch (Exception e) {
        log.error("重试删除缓存仍失败,key={}, 重试次数={}", key, msg.getReconsumeTimes());
        throw new RuntimeException(e);        // 触发 MQ 重试(最多 16 次,约几小时)
    }
}

为什么必须用 afterCommit 而不是方法末尾直接删?

❌ 错误写法:
   @Transactional
   public void update(Device d) {
       deviceMapper.updateById(d);
       redisTemplate.delete(key);     // ← 这里删了!
       // 后面如果还有代码抛异常 → 事务回滚
       // 但缓存已经删了 → 缓存空了,DB 还是旧值
       //   虽然下次读会重新加载(不算脏数据),
       //   但如果是"删了又立刻被回填旧值"就会脏
   }

   更严重的情况:
       redis 删除成功 → 事务还没提交 → 另一个读线程
       → 缓存 miss → 读 DB(读到旧值,因为事务未提交!)
       → 回填缓存 = 旧值 → 事务提交 → 缓存永久是旧值 ❌

✅ 正确:afterCommit 里删,此时事务已提交,读线程读到的必然是新值

方案二:订阅 binlog 异步删除(★ 最可靠,无侵入)

┌────────┐  binlog  ┌───────┐  解析   ┌──────────────┐
│ MySQL  │ ───────► │ Canal │ ──────► │ 缓存删除服务  │ ──► Redis
└────────┘          └───────┘         └──────────────┘
                                              │
                                              └──► MQ 广播各节点删 L1

✅ 优点:
   ① 业务代码零侵入(不用在每个 update 方法里写删缓存)
   ② 天然保证顺序:binlog 里有的都是已提交的,不会出现"回滚但缓存已删"
   ③ 不会漏删:所有 UPDATE/DELETE 都能捕获(包括 DBA 手工改库!)
   ④ 删除失败可以重放 binlog

❌ 缺点:
   ① 增加运维组件(Canal / Debezium)
   ② 有延迟(通常 100ms ~ 1s)
   ③ 表结构变更要同步维护映射关系

★ 生产建议:核心业务用 Canal 兜底 + 业务代码主动删(双保险)
/**
 * Canal 监听 binlog 删除缓存
 */
@CanalListener(destination = "device", schema = "energy", table = "t_device")
public class DeviceCacheListener {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @CanalEventListener
    public void onUpdate(CanalEntry.RowChange rowChange) {
        for (CanalEntry.RowData row : rowChange.getRowDatasList()) {
            // 取变更前和变更后的主键(更新时两个都删,防止主键变更)
            String beforeId = getColumn(row.getBeforeColumnsList(), "id");
            String afterId = getColumn(row.getAfterColumnsList(), "id");

            if (StringUtils.hasText(beforeId)) {
                invalidate("device:" + beforeId);
            }
            if (StringUtils.hasText(afterId) && !afterId.equals(beforeId)) {
                invalidate("device:" + afterId);
            }
        }
    }

    private void invalidate(String key) {
        redisTemplate.delete(key);
        // ★ 广播各节点清理本地 L1
        redisTemplate.convertAndSend("cache:invalidate", key);
        log.info("binlog 触发缓存失效,key={}", key);
    }
}

4.4 延迟双删

4.4.1 为什么要延迟双删?

针对"先删缓存后更 DB"方案的补丁:

   ① 删除缓存
   ② 更新数据库
   ③ sleep(N ms)        ← 关键
   ④ 再次删除缓存

第 ④ 步的作用:把"在 ①~② 窗口内被回填的脏数据"再删掉
时序:
   t1  写线程:删除缓存
   t2  读线程:缓存 miss → 读 DB(旧值)
   t3  写线程:更新 DB(新值)
   t4  读线程:回填缓存 = 旧值(脏)
   t5  写线程:sleep 结束 → 再次删除缓存 ✅ 脏数据被清掉
   t6  下次读:miss → 读 DB(新值)→ 回填 ✅ 一致

4.4.2 完整代码

/**
 * 延迟双删
 */
@BizTransactional(rollbackFor = Exception.class)
public void updateWithDoubleDelete(Device device) {
    String key = "device:" + device.getId();

    // ① 第一次删除
    localCache.invalidate(key);
    redisTemplate.delete(key);

    // ② 更新数据库
    deviceMapper.updateById(device);

    // ③ 事务提交后,延迟再删一次
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                // ★ 必须用独立线程池,不能阻塞业务线程
                CompletableFuture.runAsync(() -> {
                    try {
                        // 延迟时间要 > 一次读操作的耗时(读DB + 写缓存)
                        TimeUnit.MILLISECONDS.sleep(500);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                    localCache.invalidate(key);
                    redisTemplate.delete(key);
                    redisTemplate.convertAndSend("cache:invalidate", key);
                    log.info("延迟双删完成,key={}", key);
                }, cacheExecutor)
                .exceptionally(e -> {
                    log.error("延迟双删失败,转 MQ 重试,key={}", key, e);
                    mqSender.sendReliable("CACHE_INVALIDATE", key);
                    return null;
                });
            }
        }
    );
}

4.4.3 延迟时间怎么定?(面试会追问)

sleep 时间必须满足:
   T(sleep) > T(读DB) + T(写缓存) + 一点余量

   经验值:
   · 普通业务:500ms ~ 1s
   · 慢查询场景:读 DB 要 100ms+,则 sleep 1s ~ 2s

   参考依据(从监控里取):
   · 读 DB 的 P99 耗时
   · 主从同步延迟(如果读从库,必须 > 主从延迟!)

★ 关键:如果读操作走的是【从库】,
   sleep 时间必须大于【主从同步延迟】,否则:
     读线程从从库读到旧值(主库已更新但还没同步过来)
     → 回填缓存 = 旧值
     → sleep 500ms 后删缓存 → 但这次读发生在 500ms 之后的话又脏了
   → 主从延迟 > 1s 时,延迟双删基本失效

4.4.4 延迟双删的局限(要客观评价)

❌ 局限①:sleep 时间只能凭经验,不准
   太短:脏数据还没回填就被删,白删
   太长:请求线程/线程池被占用,且不一致窗口变大

❌ 局限②:sleep 阻塞(即使用异步线程,也占用线程资源)
   高并发写时,线程池可能打满

❌ 局限③:只能缓解,不能根治
   如果第二次删除也失败了 → 还是不一致
   如果有多个并发读 → 可能有多次回填,删不完

❌ 局限④:主从延迟场景下基本无效(见上)

✅ 所以:
   延迟双删是"先删缓存"方案的补丁。
   如果一开始就选"先更 DB 后删缓存",延迟双删的收益很小,
   通常不需要(业界主流做法是 Cache Aside + 重试 + binlog 兜底)

★ 面试话术:
   "延迟双删我们早期用过,后来发现 sleep 时间很难定准,
    而且我们的场景是『先更新 DB 后删缓存』,本身不一致概率就极低,
    所以现在主力方案是 Cache Aside + 删除失败 MQ 重试 + Canal 兜底,
    延迟双删只在个别强一致要求的场景保留。"

4.5 缓存三剑客:穿透、击穿、雪崩(多级缓存下的处理)

┌──────────────────────────────────────────────────────────┐
│ 三剑客速记                                                  │
│                                                           │
│ 穿透(Penetration):查【不存在】的数据 → 每次都打 DB         │
│                     「一直查一个不存在的 key」                │
│                                                           │
│ 击穿(Breakdown)  :某个【热点 key 过期】→ 瞬间大量请求打 DB  │
│                     「一个 key 失效,千万请求涌入」           │
│                                                           │
│ 雪崩(Avalanche)  :【大量 key 同时过期】→ DB 被打垮         │
│                     「一批 key 集体失效」                    │
└──────────────────────────────────────────────────────────┘

4.5.1 缓存穿透

问题:
   查询一个数据库里根本不存在的数据(如 deviceId = -1,或恶意刷不存在的 ID)
   → 缓存永远不命中 → 每次都打到 DB
   → 恶意攻击时 DB 被打垮

方案一:缓存空值(最简单实用)

public Device getByIdSafe(Long deviceId) {
    String key = "device:" + deviceId;

    // ① 查 L1
    Device device = localCache.getIfPresent(key);
    if (device != NULL_MARKER && device != null) return device;

    // ② 查 L2
    String json = redisTemplate.opsForValue().get(key);
    if ("NULL".equals(json)) {
        return null;                      // ★ 空值标记,直接返回,不打 DB
    }
    if (StringUtils.hasText(json)) {
        device = JSON.parseObject(json, Device.class);
        localCache.put(key, device);
        return device;
    }

    // ③ 查 DB
    device = deviceMapper.selectById(deviceId);

    if (device == null) {
        // ★ 空值也缓存,但 TTL 要短(2~5 分钟),且加随机抖动
        //   理由:① 防止恶意刷不存在的 key 占满内存
        //        ② 万一这个 ID 后来被创建了,短 TTL 能尽快生效
        int ttl = ThreadLocalRandom.current().nextInt(120, 300);
        redisTemplate.opsForValue().set(key, "NULL", ttl, TimeUnit.SECONDS);
        return null;
    }

    redisTemplate.opsForValue().set(key, JSON.toJSONString(device), REDIS_TTL);
    localCache.put(key, device);
    return device;
}
⚠️ 空值缓存的两个注意点:
   ① TTL 必须短(2~5 分钟),否则不存在的 key 会占满内存
   ② TTL 加随机值,避免同一批空值同时过期(引发雪崩)

方案二:布隆过滤器(Bloom Filter)

原理:用多个 hash 函数把一个元素映射到一个 bit 数组

   插入 deviceId=100:
       hash1(100) % m = 3   → bit[3] = 1
       hash2(100) % m = 7   → bit[7] = 1
       hash3(100) % m = 15  → bit[15] = 1

   查询 deviceId=100:三个位置都是 1 → 【可能存在】
   查询 deviceId=999:bit[5]=0       → 【一定不存在】✅ 直接返回

   ┌────────────────────────────────┐
   │ ★ 布隆过滤器的特性:              │
   │   · 说"不存在" → 100% 准确        │
   │   · 说"存在"   → 可能误判(假阳性)│
   │   · 不支持删除(除非用 Counting BF)│
   └────────────────────────────────┘
/**
 * 布隆过滤器防穿透
 */
@Service
@Slf4j
public class BloomFilterCacheService {

    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private DeviceMapper deviceMapper;

    private static final String BF_KEY = "bf:device:ids";

    /**
     * ① 系统启动时把已有 ID 全部加入布隆过滤器
     */
    @PostConstruct
    public void initBloomFilter() {
        log.info("开始初始化布隆过滤器...");
        // Redisson 的布隆过滤器(基于 Redis,分布式共享)
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(BF_KEY);
        // 预计元素数量 100 万,误判率 3%
        bloomFilter.tryInit(1_000_000L, 0.03);

        int offset = 0, size = 5000;
        while (true) {
            List<Long> ids = deviceMapper.selectAllIds(offset, size);
            if (ids.isEmpty()) break;
            for (Long id : ids) {
                bloomFilter.add(id);
            }
            offset += size;
        }
        log.info("布隆过滤器初始化完成");
    }

    public Device getById(Long deviceId) {
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(BF_KEY);

        // ① 布隆过滤器拦截:一定不存在 → 直接返回,不打 DB
        if (!bloomFilter.contains(deviceId)) {
            log.debug("布隆过滤器拦截,deviceId={}", deviceId);
            metrics.increment("cache.bloom.block");
            return null;
        }

        // ② 正常走缓存流程
        return getByIdSafe(deviceId);
    }

    /**
     * ② 新增设备时要同步加入布隆过滤器
     */
    @BizTransactional
    public void create(Device device) {
        deviceMapper.insert(device);
        redissonClient.getBloomFilter(BF_KEY).add(device.getId());    // ★ 别漏了
    }
}

方案对比:

方案 优点 缺点 适用
缓存空值 实现简单,无额外组件 占内存(要设短 TTL);不存在的 key 太多仍占空间 大多数场景,首选
布隆过滤器 内存极省(100万 ID ≈ 1MB) 有误判;不支持删除;要维护同步 数据量极大、ID 枚举成本高
参数校验 + 限流 最简单 只能挡住格式错误/恶意刷 必配(第一道防线)
★ 生产建议:三管齐下
   ① 接口层做参数校验(ID 必须为正整数、范围校验)+ 限流(防恶意刷)
   ② 缓存空值(挡住正常的"查不到")
   ③ ID 量极大时用布隆过滤器(挡住恶意枚举)

4.5.2 缓存击穿(热点 key 失效)

问题:
   某个【超级热点 key】(如首页大屏的汇总数据、秒杀商品)
   在某一瞬间过期 → 成千上万的请求同时 miss
   → 全部打到 DB → DB 瞬间被打垮

   区别于雪崩:击穿是【一个 key】,雪崩是【一批 key】

方案一:互斥锁(SETNX + 双重检查)

/**
 * 互斥锁重建缓存:只允许一个线程去 DB 加载,其他线程等待
 */
public Device getByIdWithMutex(Long deviceId) {
    String key = "device:" + deviceId;

    // ① 查缓存
    Device device = getFromCache(key);
    if (device != null) return device;

    // ② 缓存 miss → 抢锁
    String lockKey = "lock:rebuild:" + key;
    String lockValue = UUID.randomUUID().toString();

    boolean locked = false;
    try {
        // SET lockKey lockValue NX EX 10(原子,10 秒自动释放防死锁)
        locked = Boolean.TRUE.equals(redisTemplate.opsForValue()
            .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(10)));

        if (locked) {
            // ③ 抢到锁 → 双重检查(★ 必须!)
            device = getFromCache(key);
            if (device != null) return device;      // 其他线程已经重建好了

            // ④ 查 DB + 回填缓存
            device = deviceMapper.selectById(deviceId);
            if (device != null) {
                setCache(key, device);
            } else {
                setCacheNull(key);
            }
            return device;

        } else {
            // ⑤ 没抢到锁 → 短暂休眠后重试(自旋)
            Thread.sleep(50);
            return getByIdWithMutex(deviceId);      // 递归重试(要限制次数!)
        }

    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new BizException("获取缓存被中断");
    } finally {
        // ⑥ 释放锁(★ 只能释放自己的锁,用 Lua 保证原子)
        if (locked) {
            String script =
                "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                "    return redis.call('del', KEYS[1]) " +
                "else return 0 end";
            redisTemplate.execute(
                new DefaultRedisScript<>(script, Long.class),
                Collections.singletonList(lockKey), lockValue);
        }
    }
}

方案二:逻辑过期(★ 最推荐,无锁、无等待)

/**
 * 逻辑过期:缓存永不过期(物理 TTL 设很长),
 *          但在 value 里存一个"逻辑过期时间",过期了就异步刷新
 *
 * 优点:所有请求都能立即拿到数据(哪怕稍旧),不阻塞、不等待
 */
@Data
public class CacheWrapper<T> {
    private T data;
    private long expireAt;          // 逻辑过期时间(毫秒时间戳)
}

@Service
@Slf4j
public class LogicalExpireCacheService {

    private static final Duration PHYSICAL_TTL = Duration.ofDays(7);    // 物理 TTL 很长
    private static final Duration LOGICAL_TTL  = Duration.ofMinutes(30); // 逻辑 TTL 正常

    public Device getById(Long deviceId) {
        String key = "device:" + deviceId;

        // ① 查缓存(物理上不会过期)
        String json = redisTemplate.opsForValue().get(key);
        if (!StringUtils.hasText(json)) {
            // 冷启动:同步加载(首次必然 miss)
            return loadAndCache(deviceId, key);
        }

        CacheWrapper<Device> wrapper = JSON.parseObject(json,
            new TypeReference<CacheWrapper<Device>>() {});

        // ② 判断逻辑过期
        if (wrapper.getExpireAt() > System.currentTimeMillis()) {
            return wrapper.getData();               // ✅ 未过期,直接返回
        }

        // ③ 逻辑已过期 → 返回旧值 + 异步刷新 ★ 关键
        String lockKey = "lock:refresh:" + key;
        boolean locked = Boolean.TRUE.equals(redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", Duration.ofSeconds(30)));

        if (locked) {
            // 抢到刷新锁 → 提交异步任务
            CompletableFuture.runAsync(() -> {
                try {
                    Device fresh = deviceMapper.selectById(deviceId);
                    CacheWrapper<Device> newWrapper = new CacheWrapper<>();
                    newWrapper.setData(fresh);
                    newWrapper.setExpireAt(System.currentTimeMillis()
                        + LOGICAL_TTL.toMillis());
                    redisTemplate.opsForValue().set(key,
                        JSON.toJSONString(newWrapper), PHYSICAL_TTL);
                    // ★ 也要广播清理各节点 L1
                    redisTemplate.convertAndSend("cache:invalidate", key);
                } catch (Exception e) {
                    log.error("异步刷新缓存失败,key={}", key, e);
                } finally {
                    redisTemplate.delete(lockKey);
                }
            }, cacheRefreshExecutor);
        }

        // ★ 返回旧值(短暂不一致可接受),不阻塞用户
        return wrapper.getData();
    }

    private Device loadAndCache(Long deviceId, String key) {
        Device device = deviceMapper.selectById(deviceId);
        if (device == null) {
            setCacheNull(key);
            return null;
        }
        CacheWrapper<Device> wrapper = new CacheWrapper<>();
        wrapper.setData(device);
        wrapper.setExpireAt(System.currentTimeMillis() + LOGICAL_TTL.toMillis());
        redisTemplate.opsForValue().set(key, JSON.toJSONString(wrapper), PHYSICAL_TTL);
        return device;
    }
}

方案三:热点 key 永不过期 + 定时刷新

思路:对确定的热点数据(如大屏汇总),
     ① 不设 TTL(或设很长)
     ② 用定时任务主动刷新(每 5 分钟刷一次)
     ③ 数据变更时主动失效

适用:更新频率可预期的场景(如你的项目三大屏数据,5 分钟刷新一次即可)

三种方案对比:

方案 一致性 性能 复杂度 适用
互斥锁 强(返回最新值) 中(等待线程阻塞) 中 一致性要求高的热点
逻辑过期 弱(可能返回旧值) 高(无等待) 中 高并发读,推荐 ★
永不过期+定时刷新 弱(取决于刷新频率) 最高 低 更新频率可预期

4.5.3 缓存雪崩

问题:
   大量 key 在【同一时刻】集体过期 → 所有请求同时打 DB → DB 崩溃
   或:Redis 集群整体宕机 → 全部请求打 DB

   常见诱因:
   ① 系统启动时批量预热,设置了相同的 TTL
   ② 用了固定过期时间(如都设 30 分钟)
   ③ Redis 故障/网络分区

五个防御手段:

// ① TTL 随机化(★ 最简单有效)
private Duration randomTtl(Duration base) {
    long baseSeconds = base.getSeconds();
    // 在 base 的 80% ~ 120% 之间随机
    long actual = baseSeconds + ThreadLocalRandom.current()
        .nextLong(-baseSeconds / 5, baseSeconds / 5);
    return Duration.ofSeconds(actual);
}
// 使用
redisTemplate.opsForValue().set(key, value, randomTtl(Duration.ofMinutes(30)));

// ② 多级缓存(Caffeine L1 + Redis L2)
//    Redis 挂了还有 L1 顶着(虽然数据可能不全,但能扛一部分流量)

// ③ 熔断降级:DB 压力过大时,返回兜底数据而不是继续打 DB
@SentinelResource(
    value = "queryDevice",
    fallback = "queryDeviceFallback",          // 异常兜底
    blockHandler = "queryDeviceBlocked"        // 限流兜底
)
public Device queryDevice(Long id) { ... }

public Device queryDeviceFallback(Long id, Throwable e) {
    log.warn("查询设备降级,id={}", id, e);
    return Device.defaultValue();               // 返回静态兜底数据
}

// ④ 缓存预热(启动时加载热点数据,避免冷启动雪崩)
@PostConstruct
public void warmUp() {
    List<Long> hotIds = deviceMapper.selectHotDeviceIds(1000);
    hotIds.parallelStream().forEach(id -> {
        Device d = deviceMapper.selectById(id);
        CacheWrapper<Device> w = new CacheWrapper<>();
        w.setData(d);
        w.setExpireAt(System.currentTimeMillis()
            + randomTtl(LOGICAL_TTL).toMillis());
        redisTemplate.opsForValue().set("device:" + id, JSON.toJSONString(w), PHYSICAL_TTL);
    });
    log.info("缓存预热完成,共 {} 条", hotIds.size());
}

// ⑤ Redis 高可用:哨兵 / Cluster + 持久化 + 限流

雪崩应急处理(已经发生了怎么办):

① 紧急扩容 DB 读库(临时)
② 开启限流:只放过能承受的 QPS,其余返回降级响应
③ 熔断:暂停非核心业务(如报表、导出)
④ 手动预热:把热点 key 重新加载到缓存
⑤ 事后复盘:为什么 TTL 会集中过期?改成随机 TTL

4.5.4 三剑客在多级缓存架构下的特殊注意点

★ 多级缓存(Caffeine L1 + Redis L2)下,三剑客有新变化:

① 穿透:L1 miss → L2 miss → DB 也没
   注意:空值要同时写到 L1 和 L2,否则 L1 每次 miss 都会打 L2
   → 空值也要回填 L1(短 TTL)

② 击穿:L1 和 L2 可能同时过期
   注意:L1 TTL 要比 L2 短(如 L1=5min, L2=30min)
   → 这样 L1 过期后还能从 L2 加载,不会直接打 DB
   → 只有 L1、L2 都 miss 才打 DB,概率大幅降低
   ★ 这是多级缓存设计的一个关键原则:层层递减的 TTL

③ 雪崩:Redis 挂了
   优势:L1 还能扛一部分(命中率 60% 的话,只有 40% 打穿)
   注意:L1 的大小和 TTL 要合理,不能让 L1 也同时雪崩
   建议:Redis 故障时,临时调大 L1 容量 + 熔断降级

④ ★ 新增的坑:L1 缓存的"击穿"更隐蔽
   单实例内,L1 miss 后所有线程都会去查 L2
   → 即使 L2 有数据,瞬间几万次 Redis 调用也可能压垮 Redis 连接
   解法:Caffeine 自带「同一 key 只有一个线程加载」的能力(get(key, loader))
        → 用 localCache.get(key, k -> loadFromRedis(k)),天然防击穿 ✅
/**
 * ★ Caffeine 的 get(key, loader) 天然防止 L1 击穿
 *   同一个 key 的并发请求只有一个线程会执行 loader,其他线程等待结果
 */
public Device getByIdSafe(Long deviceId) {
    String key = "device:" + deviceId;
    try {
        return localCache.get(key, k -> {
            // 只有 miss 的 key 才会进这里,且同一 key 只有一个线程执行
            String json = redisTemplate.opsForValue().get(k);
            if (StringUtils.hasText(json) && !"NULL".equals(json)) {
                return JSON.parseObject(json, Device.class);
            }
            Device d = deviceMapper.selectById(deviceId);
            if (d != null) {
                redisTemplate.opsForValue().set(k, JSON.toJSONString(d), REDIS_TTL);
            }
            return d;      // null 时 Caffeine 不会缓存(除非用 Optional 包装)
        });
    } catch (Exception e) {
        log.error("查询设备失败,降级查 DB,deviceId={}", deviceId, e);
        return deviceMapper.selectById(deviceId);
    }
}

4.6 多级缓存不一致:本质与五种解决方案(★ 核心难点)

4.6.1 问题的本质

单级缓存(只用 Redis)时:
   所有节点共享一份 Redis,删一次就都失效了 ✅

多级缓存(Caffeine L1 + Redis L2)时:
   ┌─────────┐  ┌─────────┐  ┌─────────┐
   │ 实例 A   │  │ 实例 B   │  │ 实例 C   │
   │ L1: v=1 │  │ L1: v=1 │  │ L1: v=1 │   ← 三份独立的进程内缓存!
   └────┬────┘  └────┬────┘  └────┬────┘
        │            │            │
        └────────────┼────────────┘
                     ▼
              ┌─────────────┐
              │ Redis L2    │
              │ v=1         │
              └─────────────┘

   实例 A 更新了数据,删了 Redis 和自己的 L1
   → 但实例 B、C 的 L1 还是旧值!
   → B、C 上的用户读到的还是 v=1 ❌
   → 直到 B、C 的 L1 TTL 过期才恢复

★ 本质:L1 是【进程内】的,节点之间没有任何天然的通知机制
   实例 A 无法直接操作实例 B 的内存

不一致窗口有多大?

取决于 L1 的 TTL:
   L1 TTL = 5 分钟 → 最坏情况不一致 5 分钟
   L1 TTL = 1 分钟 → 最坏情况不一致 1 分钟
   L1 TTL = 10 秒  → 最坏情况不一致 10 秒(但缓存效果变差)

   ★ 这是一个典型的「一致性 vs 性能」权衡:
     TTL 越短 → 一致性越好,但缓存命中率越低、DB 压力越大

4.6.2 五种解决方案


方案一:短 TTL(最简单,靠过期兜底)

/**
 * 纯靠 TTL:L1 设很短的过期时间
 */
@Bean
public Cache<String, Device> deviceLocalCache() {
    return Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofSeconds(30))      // ★ 只缓存 30 秒
        .build();
}
✅ 优点:实现最简单,零额外组件,零网络开销
❌ 缺点:
   · 一致性取决于 TTL,30 秒 TTL = 最多不一致 30 秒
   · TTL 太短 → 命中率下降,多级缓存意义打折

适用:对一致性要求不高的场景(如设备档案、配置、字典)

方案二:Redis Pub/Sub 广播失效(★ 生产推荐,性价比最高)

原理:
   实例 A 更新数据 → 删 Redis + 删自己 L1 + 向 Redis 频道发一条"失效消息"
   实例 B/C 订阅该频道 → 收到消息 → 删除自己的 L1

   ┌─────────┐   ① 更新DB + 删L1 + 删Redis
   │ 实例 A   │ ──────────────────────────┐
   └─────────┘                            │
                                          ▼
   ┌─────────┐   ② publish "device:100"  ┌─────────────┐
   │ 实例 B   │ ◄─────────────────────── │ Redis 频道   │
   └─────────┘   ③ 收到 → 删自己 L1      │ cache:inval │
                                          └─────────────┘
   ┌─────────┐                                   │
   │ 实例 C   │ ◄────────────────────────────────┘
   └─────────┘

完整实现:

/**
 * ① 统一的多级缓存管理器
 */
@Component
@Slf4j
public class MultiLevelCacheManager {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    @Qualifier("deviceLocalCache")
    private Cache<String, Device> localCache;

    public static final String INVALIDATE_CHANNEL = "cache:invalidate";

    /**
     * 失效:删本地 + 删 Redis + 广播
     */
    public void invalidate(String key) {
        // ① 删本地 L1
        localCache.invalidate(key);

        // ② 删 Redis L2
        redisTemplate.delete(key);

        // ③ 广播给其他节点
        //    消息格式:key|instanceId|timestamp
        String msg = key + "|" + instanceId + "|" + System.currentTimeMillis();
        redisTemplate.convertAndSend(INVALIDATE_CHANNEL, msg);
        log.debug("已广播缓存失效,key={}", key);
    }
}
/**
 * ② 订阅端:监听失效消息,清理本地 L1
 */
@Component
@Slf4j
public class CacheInvalidateListener implements MessageListener {

    @Autowired
    @Qualifier("deviceLocalCache")
    private Cache<String, Device> localCache;

    private final String instanceId = ManagementFactory.getRuntimeMXBean().getName();

    @Bean
    public RedisMessageListenerContainer container(RedisConnectionFactory factory) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(factory);
        // 订阅频道
        container.addMessageListener(this,
            new ChannelTopic(MultiLevelCacheManager.INVALIDATE_CHANNEL));
        return container;
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String body = new String(message.getBody(), StandardCharsets.UTF_8);
        String[] parts = body.split("\\|");
        if (parts.length < 2) return;

        String key = parts[0];
        String from = parts[1];

        // ★ 关键:忽略自己发出的消息(自己已经删过了)
        if (instanceId.equals(from)) {
            return;
        }

        localCache.invalidate(key);
        metrics.increment("cache.l1.invalidate.by.broadcast");
        log.debug("收到失效广播,清理本地缓存,key={}, from={}", key, from);
    }
}
/**
 * ③ 业务侧使用
 */
@BizTransactional
public void update(Device device) {
    deviceMapper.updateById(device);
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                // 一行搞定:本地 + Redis + 广播全清
                cacheManager.invalidate("device:" + device.getId());
            }
        }
    );
}
✅ 优点:
   · 实时性好(毫秒级,所有节点几乎同时失效)
   · 实现简单(Redis 原生 Pub/Sub,无需额外组件)
   · 可以配合长 TTL(不用靠过期兜底)

❌ 缺点(★ 面试要能说出来):
   ① Pub/Sub 是【不可靠】的!
      Redis Pub/Sub 不持久化,订阅者掉线期间的消息会永久丢失
      → 某个实例正好重启/网络抖动 → 漏收 → 该节点 L1 脏到 TTL 过期
      缓解:保留 L1 较短 TTL 做兜底(如 5 分钟),广播只是加速失效

   ② 消息量大的时候有压力
      每次更新都广播,高频更新场景会产生大量消息
      缓解:合并广播(批量失效发一次),或只对热点 key 用 L1

   ③ 广播风暴
      100 个实例,每次更新广播 99 次(自己不处理)
      缓解:实例数控制在合理范围,或用 MQ 代替

   ④ 顺序问题
      并发更新同一 key 时,失效消息可能乱序
      → 但因为"删除"是幂等的,乱序也无害(最坏多删一次)✅

方案三:Canal 订阅 binlog 广播(最可靠)

   实例 A 更新 DB
        │
        ▼
   ┌────────┐  binlog  ┌───────┐  解析  ┌──────────────┐
   │ MySQL  │ ───────► │ Canal │ ─────► │ 缓存失效服务  │
   └────────┘          └───────┘        └──────┬───────┘
                                                │
                    ┌───────────────────────────┼───────────────┐
                    ▼                           ▼               ▼
              publish 到 Redis 频道 / MQ   →  各实例清理 L1 + Redis

✅ 相比方案二的优势:
   ① 不会漏:binlog 是数据库的事实日志,DBA 手工改库也能捕获
   ② 与业务解耦:业务代码不用写缓存失效逻辑,不会漏删
   ③ 可重放:Canal 挂了重启后能接着消费

❌ 劣势:
   ① 延迟比 Pub/Sub 大(100ms ~ 秒级)
   ② 要维护 Canal 集群
   ③ binlog 解析要处理表结构变更

★ 生产最佳实践:
   业务代码主动删(快,覆盖 99%)+ Canal 兜底(慢,覆盖漏网的 1%)

方案四:MQ 广播(可靠性最好,适合大规模集群)

/**
 * 用 RocketMQ 广播模式做缓存失效
 * broadcast:每个消费者实例都会收到消息(不是负载均衡)
 */
@Service
@Slf4j
public class CacheInvalidateMqService {

    /**
     * ① 发送失效消息
     */
    public void broadcastInvalidate(String key) {
        mqTemplate.send("CACHE_INVALIDATE_TOPIC",
            MessageBuilder.withPayload(key).build());
    }
}

/**
 * ② 消费端:★ 关键配置 MessageModel.BROADCASTING
 */
@RocketMQMessageListener(
    topic = "CACHE_INVALIDATE_TOPIC",
    consumerGroup = "cache-invalidate-group",
    messageModel = MessageModel.BROADCASTING       // ★ 广播模式
)
@Component
@Slf4j
public class CacheInvalidateConsumer implements RocketMQListener<String> {

    @Autowired
    @Qualifier("deviceLocalCache")
    private Cache<String, Device> localCache;

    @Override
    public void onMessage(String key) {
        localCache.invalidate(key);
        log.debug("MQ 广播失效本地缓存,key={}", key);
    }
}
✅ 优点:
   ① 可靠(MQ 持久化,不会丢消息,支持重试)
   ② 支持大量消费者实例
   ③ 可以和业务解耦(binlog → MQ → 各实例)

❌ 缺点:
   ① 延迟比 Pub/Sub 大(几十毫秒 ~ 百毫秒)
   ② RocketMQ 广播模式不支持重试(广播消费失败不重试,只能自己兜底)
      → 所以还要保留 L1 TTL 做最终兜底
   ③ 增加 MQ 消息量

适用:实例数很多(几十上百个),且对可靠性要求高

方案五:版本号 / 一致性 Hash 路由

思路一:版本号校验(读时校验,而非主动失效)

   缓存 value 里带版本号:
     { "data": {...}, "version": 12345 }
   每次读取时,先查一次 Redis 里的全局版本号(很轻量)
   版本不一致 → 本地缓存失效,重新加载

   ✅ 优点:不需要广播,节点自己判断
   ❌ 缺点:每次读都要一次 Redis 查询(增加一次网络往返)
         全局版本号的管理本身也是个问题

思路二:一致性 Hash 路由(让同一个 key 的请求总是打到同一实例)

   网关层按 key 做一致性 hash,把「同一设备」的请求路由到固定实例
   → 该设备的 L1 只在这一台实例上,更新时只需要失效这一台

   ✅ 优点:L1 命中率高(同一 key 集中在一台),失效简单
   ❌ 缺点:
     · 负载可能不均(热点 key 集中到一台)
     · 扩缩容时 hash 环变化,大量 key 重新分布(缓存全失效)
     · 实例宕机 → 该实例的 L1 全丢,且期间请求打到别的实例
   适用:网关可控、key 分布均匀的场景

4.6.3 五种方案对比选型

方案 实时性 可靠性 复杂度 适用规模 推荐度
① 短 TTL 差(TTL 级) 高(必然过期) ⭐ 任意 ⭐⭐(兜底必配)
② Redis Pub/Sub 好(毫秒) 中(可能丢) ⭐⭐ < 50 实例 ⭐⭐⭐⭐⭐ 首选
③ Canal binlog 中(百毫秒~秒) 高(可重放) ⭐⭐⭐⭐ 任意 ⭐⭐⭐⭐(兜底)
④ MQ 广播 中 最高 ⭐⭐⭐ 任意,实例多时优选 ⭐⭐⭐⭐
⑤ 版本号/Hash 路由 好 中 ⭐⭐⭐ 特定 ⭐⭐(特殊场景)

4.6.4 你的项目三:推荐的生产方案与话术

★ 推荐组合(分层防御):

   第一层:L1 短 TTL(5 分钟)
          → 保证最坏情况下 5 分钟内必然自愈,不会因为广播失败永久脏

   第二层:Redis Pub/Sub 广播(主力)
          → 正常情况毫秒级失效,覆盖 99.9% 的更新

   第三层:删除失败 → MQ 重试
          → 防止删除 Redis / 广播失败导致的不一致

   第四层:Canal 订阅 binlog(可选兜底)
          → 防止业务代码漏写缓存失效、DBA 手工改库

   监控:L1 命中率、广播消息量、失效延迟、脏数据比对抽样

面试话术(项目三被问“多级缓存怎么保证一致”):

"我们的多级缓存是 Caffeine L1 + Redis L2,主要难点在于
 L1 是进程内的,一个节点更新了,其他节点的 L1 感知不到。

 我们的方案是分层防御,四道:

 第一,L1 的 TTL 设 5 分钟,比 L2 的 30 分钟短很多。
       这样即使所有通知机制都失效,最多 5 分钟也自愈了。
       而且 L1 短、L2 长,能保证 L1 过期后从 L2 加载,不会直接打 DB。

 第二,主力的实时失效用 Redis Pub/Sub。
       更新时:先更新数据库,事务提交后删 Redis、删本地 L1,
       然后往 cache:invalidate 频道发一条失效消息,
       其他节点订阅这个频道,收到就删自己的 L1,毫秒级生效。
       消息里带 instanceId,自己发的自己忽略。

 第三,删除失败会发 MQ 重试,避免网络抖动导致长期不一致。

 第四,我们也知道 Pub/Sub 是不可靠的——订阅者掉线期间的消息会丢,
       所以才必须有第一层的 TTL 兜底,两层是互补的。
       另外核心场景我们评估过用 Canal 订阅 binlog 做最终兜底,
       能覆盖业务代码漏写失效和 DBA 手工改库的情况。

 实际效果:大屏接口 RT 从 1200ms 降到 200ms,
           上线半年没有出现过因缓存不一致导致的数据投诉。"

加分补充(如果面试官继续追问):

Q:为什么 L1 的 TTL 要比 L2 短?
A:两个原因。一是一致性,L1 是进程内的、最难失效的,
    TTL 短能限制不一致窗口;二是防击穿,L1 过期后还能从 L2 加载,
    不会所有请求直接打穿到 DB。如果反过来 L1 比 L2 长,
    L2 先过期,L1 还命中旧值,那 L2 就白搭了。

Q:Pub/Sub 丢了消息怎么办?
A:靠 TTL 兜底,这是设计时就考虑到的。Pub/Sub 的定位是
   "加速失效"而不是"保证失效",可靠性由 TTL 保证。
   这也是为什么不能把 L1 的 TTL 设成永不过期。

Q:广播风暴怎么办?
A:我们控制实例数在 20 以内,而且只有真正被频繁读的数据才放 L1。
   另外失效消息是按 key 发的,一次更新只发一条,不是全量刷新。
   如果实例数再多,会考虑改用 MQ 广播或者干脆去掉 L1 只留 Redis。

4.7 Caffeine 深入(L1 本地缓存的原理与最佳实践)

4.7.1 为什么选 Caffeine 而不是 Guava Cache?

Caffeine 是 Guava Cache 的作者 Ben Manes 重写的"升级版",
设计目标就是解决 Guava Cache 的两个核心缺陷:

缺陷①:Guava Cache 用 LRU 淘汰
   LRU(最近最少使用)的问题:
     一次"全表扫描"式的批量查询,会把大量热点数据挤出去
     → 缓存命中率断崖式下降(这叫 LRU 的"缓存污染")

缺陷②:Guava Cache 的并发控制基于分段锁(类似 JDK7 ConcurrentHashMap)
   高并发下锁竞争明显

Caffeine 的改进:
   ① 淘汰算法:LRU → W-TinyLFU(命中率显著更高)
   ② 并发控制:借鉴 JDK8 ConcurrentHashMap 的无锁化(CAS + 环形缓冲区)
   ③ 更丰富的过期策略:支持可变过期时间(expireAfter(Expiry))
   ④ 支持异步加载、自动刷新(refreshAfterWrite)

三者对比(官方 Benchmark,Zipf 分布):

命中率 读写性能 维护状态 推荐
Guava Cache ~75% 中 维护(新项目不推荐) ❌
Caffeine ~90%+ 高 活跃 ✅ 首选
Ehcache 3 ~80% 中(序列化开销) 活跃 需要堆外/磁盘时

4.7.2 W-TinyLFU 算法原理(★ 面试加分,能讲出来很值钱)

先看 LRU 和 LFU 各自的缺陷:

LRU(Least Recently Used,最近最少使用):
   淘汰最久没被访问的
   ❌ 缺陷:一次批量扫描(如全表导出)会把所有热点数据挤走
   ❌ 不区分"访问 100 次"和"访问 1 次"的数据

LFU(Least Frequently Used,最少使用):
   淘汰访问次数最少的
   ❌ 缺陷①:需要给每个 key 维护一个计数器,内存开销大
   ❌ 缺陷②:历史热点永远赖着不走(早期访问 1 万次的过期活动,
              热度过了还占着位置)
   ❌ 缺陷③:对新数据不友好(新数据频率低,容易被立刻淘汰)

W-TinyLFU = Window LRU + TinyLFU(频率草图准入) + Segmented LRU

┌──────────────────────────────────────────────────────────────┐
│                    W-TinyLFU 结构                              │
│                                                               │
│   新数据 ──► ┌────────────────┐                                │
│              │  Window LRU    │  1%(小窗口)                   │
│              │  接纳新数据     │                                │
│              └────────┬───────┘                                │
│                       │ 窗口满,候选淘汰                        │
│                       ▼                                        │
│           ┌─────────────────────────┐                          │
│           │  TinyLFU 准入判断         │                          │
│           │  新候选 vs 受害者         │                          │
│           │  比"频率",谁高谁留下     │                          │
│           └──────┬───────────┬──────┘                          │
│            胜出  │           │ 败出(直接淘汰)                  │
│                  ▼           ▼                                 │
│        ┌──────────────────────────────┐                        │
│        │       Main Region (SLRU)     │  99%                   │
│        │  ┌────────────┬────────────┐ │                        │
│        │  │ Protected  │  Probation │ │                        │
│        │  │   80%      │    20%     │ │                        │
│        │  │ 访问≥2次    │  访问1次    │ │                        │
│        │  └────────────┴────────────┘ │                        │
│        │   命中则升级到 Protected      │                        │
│        │   Probation 满则降级淘汰      │                        │
│        └──────────────────────────────┘                        │
└──────────────────────────────────────────────────────────────┘

三部分各自的作用:

① Window LRU(占 1%):
   接纳所有新数据。新数据频率低,如果直接进 Main 会被立刻淘汰,
   给一个小窗口让它有机会积累访问次数。
   → 解决 LFU"对新数据不友好"的问题

② TinyLFU 准入(核心):
   当 Window 满了,要淘汰一个候选者时,
   用 TinyLFU 比较「候选者」和「Main 里的受害者」的访问频率,
   频率高的留下,低的淘汰。
   → 一次批量扫描进来的冷数据频率低,会被 Main 里的热点数据"挡住"
   → 解决 LRU"缓存污染"问题

③ Main Region(SLRU,分段 LRU):
   Probation(试用期,20%):新进入的数据
   Protected(保护区,80%):被再次访问的数据升级到这里
   Protected 满了会降级回 Probation,Probation 满则淘汰
   → 二次机会机制,避免"只访问一次"的数据长期占位

TinyLFU 怎么做到“几乎不占内存”地统计频率?

★ Count-Min Sketch(频率草图):

   传统做法:Map<Key, Long> 计数器
     100 万个 key × 8 字节 = 8MB(而且还存了 key 本身)

   Count-Min Sketch:
     一个 long[] 数组(4 个 hash 函数,每个 key 映射到 4 个位置)
     ┌───┬───┬───┬───┬───┬───┬───┬───┐
     │ 0 │ 3 │ 1 │ 7 │ 0 │ 2 │ 5 │ 1 │   ← 计数器数组
     └─┬─┴─┬─┴─┬─┴─┬─┴───┴───┴───┴───┘
       │   │   │   │
       └───┴───┴───┴── hash(key) 的 4 个位置

     访问 key:"device:100"
       h1 % 8 = 1 → counter[1]++
       h2 % 8 = 3 → counter[3]++
       h3 % 8 = 6 → counter[6]++
       h4 % 8 = 2 → counter[2]++

     查询频率:取 4 个位置的最小值(min)
       因为 hash 冲突会【高估】,取最小值最接近真实值

   ✅ 空间:Caffeine 默认每个 key 只用 4 bit × 4 = 16 bit(2 字节)
      100 万个 key ≈ 2MB(且不需要存 key 本身)
   ❌ 代价:是【概率性】的,可能高估频率(但不会低估)
           对缓存淘汰来说,高估一点点完全可以接受

★ 老化机制(解决 LFU"历史热点赖着不走"):
   Caffeine 记录了所有 key 的"总访问次数",
   当总数达到 sampleSize(默认 10 × maximumSize)时,
   把所有计数器【减半】(reset),相当于"忘记"很久以前的访问记录
   → 老热点频率降下来,新热点有机会进来

一句话总结(面试时说):

"Caffeine 用的是 W-TinyLFU:
 它把缓存分成一个 1% 的 Window LRU 小窗口接纳新数据,
 和 99% 的 Main Region(又分 Probation 和 Protected 两段)。
 窗口满了要淘汰时,用 TinyLFU 比较候选者和受害者的访问频率,
 频率高的留下。
 频率的统计用 Count-Min Sketch 频率草图,
 每个 key 只占十几个 bit,非常省内存;
 还有老化机制,总访问数达到一定量就把计数器减半,
 避免老热点长期占位。
 这样既解决了 LRU 被批量扫描污染的问题,
 也解决了 LFU 对新数据不友好和内存开销大的问题。"

4.7.3 三种驱逐策略

@Configuration
public class CaffeineConfig {

    /**
     * ① 基于容量驱逐(最常用)
     */
    @Bean
    public Cache<String, Device> deviceLocalCache() {
        return Caffeine.newBuilder()
            // 方式一:按条目数(推荐,简单)
            .maximumSize(10_000)

            // 方式二:按权重(不同 value 大小差异大时用)
            // .maximumWeight(100_000)
            // .weigher((String key, Device value) -> value.estimateSize())

            .expireAfterWrite(Duration.ofMinutes(5))
            .recordStats()                    // ★ 开启统计
            .removalListener((key, value, cause) -> {
                // cause: EXPLICIT(手动删)/ REPLACED / EXPIRED / SIZE(容量淘汰)
                if (cause == RemovalCause.SIZE) {
                    metrics.increment("cache.l1.evict.by.size");
                }
            })
            .build();
    }

    /**
     * ② 基于时间驱逐
     *
     * 三种时间策略(★ 面试常问区别):
     */
    @Bean
    public Cache<String, Object> timeBasedCache() {
        return Caffeine.newBuilder()
            // expireAfterWrite:写入后固定时间过期
            //   适合:数据会主动更新的场景(配合"删缓存"模式)
            //   你的项目三用的就是这个
            // .expireAfterWrite(Duration.ofMinutes(5))

            // expireAfterAccess:最后一次访问后多久过期
            //   适合:session、用户权限等"活跃就续期"的场景
            //   注意:读操作也会续期 → 热点数据可能永不过期(内存风险)
            // .expireAfterAccess(Duration.ofMinutes(30))

            // expireAfter(Expiry):自定义,每个 key 可以有不同 TTL
            .expireAfter(new Expiry<String, Object>() {
                @Override
                public long expireAfterCreate(String key, Object value, long now) {
                    // 返回纳秒;热点数据 TTL 长,冷数据 TTL 短
                    if (key.startsWith("hot:")) {
                        return TimeUnit.MINUTES.toNanos(30);
                    }
                    return TimeUnit.MINUTES.toNanos(5);
                }
                @Override
                public long expireAfterUpdate(String key, Object value,
                                              long now, long currentDuration) {
                    return currentDuration;         // 更新后保持原 TTL
                }
                @Override
                public long expireAfterRead(String key, Object value,
                                            long now, long currentDuration) {
                    return currentDuration;         // 读取不续期
                }
            })
            .maximumSize(10_000)
            .recordStats()
            .build();
    }

    /**
     * ③ 异步加载 + 自动刷新(★ 高并发场景推荐)
     */
    @Bean
    public AsyncLoadingCache<String, Device> asyncDeviceCache() {
        return Caffeine.newBuilder()
            .maximumSize(10_000)
            // refreshAfterWrite:写入后多久【异步刷新】
            //   ★ 和 expireAfterWrite 的区别:
            //     expire:过期后请求会【阻塞等待】新值
            //     refresh:过期后返回【旧值】,后台异步刷新 → 不阻塞!
            .refreshAfterWrite(Duration.ofMinutes(5))
            .expireAfterWrite(Duration.ofMinutes(30))   // 物理兜底过期
            .recordStats()
            .buildAsync((key, executor) ->
                CompletableFuture.supplyAsync(() -> loadFromDb(key), executor));
    }
}

三种时间策略对比:

策略 含义 读操作是否续期 适用场景
expireAfterWrite 写入后 N 时间过期 ❌ 不续期 缓存 + 主动失效 ★ 你的项目三
expireAfterAccess 最后访问后 N 时间过期 ✅ 续期 Session、权限
expireAfter(Expiry) 自定义 自定义 不同 key 不同 TTL
refreshAfterWrite 写入后 N 时间异步刷新 ❌(但会触发刷新) 高并发读,避免阻塞 ★

4.7.4 Spring 集成(@Cacheable)

/**
 * ① 依赖
 *   <dependency>
 *       <groupId>com.github.ben-manes.caffeine</groupId>
 *       <artifactId>caffeine</artifactId>
 *   </dependency>
 *   <dependency>
 *       <groupId>org.springframework.boot</groupId>
 *       <artifactId>spring-boot-starter-cache</artifactId>
 *   </dependency>
 */
@Configuration
@EnableCaching
public class CacheConfig {

    /**
     * 多 CacheManager 配置:本地缓存 + Redis 并存
     */
    @Bean("caffeineCacheManager")
    @Primary
    public CacheManager caffeineCacheManager() {
        SimpleCacheManager manager = new SimpleCacheManager();

        List<CaffeineCache> caches = List.of(
            // 设备档案:10000 条,5 分钟
            new CaffeineCache("device", Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(Duration.ofMinutes(5))
                .recordStats()
                .build()),

            // 字典数据:500 条,30 分钟(几乎不变)
            new CaffeineCache("dict", Caffeine.newBuilder()
                .maximumSize(500)
                .expireAfterWrite(Duration.ofMinutes(30))
                .recordStats()
                .build()),

            // 大屏汇总:200 条,1 分钟(要比较新)
            new CaffeineCache("dashboard", Caffeine.newBuilder()
                .maximumSize(200)
                .expireAfterWrite(Duration.ofMinutes(1))
                .recordStats()
                .build())
        );

        manager.setCaches(caches);
        return manager;
    }
}
/**
 * ② 使用 @Cacheable
 *
 * ⚠️ 注意:Spring 的 @Cacheable 只支持【单级】缓存,
 *    要实现 Caffeine + Redis 多级,需要自定义 CacheManager
 *    (或用 @Caching 组合,但语义不对)
 */
@Service
@Slf4j
public class DeviceServiceImpl implements DeviceService {

    /**
     * 查询:先查缓存,miss 则执行方法并回填
     * unless = "#result == null":★ 结果为 null 时不缓存(防穿透风险)
     */
    @Cacheable(cacheNames = "device", key = "#deviceId",
               unless = "#result == null")
    @Override
    public Device getById(Long deviceId) {
        log.info("缓存 miss,查询 DB,deviceId={}", deviceId);
        return deviceMapper.selectById(deviceId);
    }

    /**
     * 更新:更新 DB + 删缓存
     * ★ 注意:@CacheEvict 默认在【方法执行后】删除(after invocation)
     *   如果方法抛异常,默认不会删(beforeInvocation = false)
     */
    @CacheEvict(cacheNames = "device", key = "#device.id")
    @Override
    @BizTransactional
    public void update(Device device) {
        deviceMapper.updateById(device);
    }

    /**
     * 删除全部缓存
     */
    @CacheEvict(cacheNames = "device", allEntries = true)
    @Override
    public void refreshAll() {
        log.info("清空设备缓存");
    }
}

⚠️ Spring @Cacheable 的五个经典坑:

坑①:类内部方法调用,缓存不生效
   @Cacheable 基于 AOP,自调用不走代理 → 缓存失效
   解法:注入自己 / 拆到另一个类 / AopContext(和 @Transactional 同一个坑)

坑②:@Cacheable 和 @Transactional 一起用时,缓存可能在事务提交前就写入
   → 事务回滚了但缓存已经写入脏数据
   解法:用 TransactionAwareCacheManagerProxy 包装 CacheManager

坑③:key 生成错误(多参数时)
   默认用所有参数组合成 key(SimpleKey)
   注意:参数顺序变化 → key 变化 → 缓存 miss

坑④:缓存了 null
   不加 unless = "#result == null" 会缓存 null
   → 下次查询直接返回 null(即使 DB 后来有数据了)
   解法:unless 过滤,或缓存 Optional / 空对象标记

坑⑤:@CacheEvict 在事务中的时机
   beforeInvocation = false(默认):方法成功执行后才删
   如果方法抛异常 → 不删(可能是对的,也可能是错的,要看业务)

自定义多级 CacheManager(Caffeine + Redis):

/**
 * ★ 自定义多级缓存实现(Spring Cache 抽象层面)
 */
public class MultiLevelCache extends AbstractValueAdaptingCache {

    private final String name;
    private final Cache<Object, Object> localCache;      // Caffeine
    private final RedisTemplate<String, Object> redisTemplate;
    private final StringRedisTemplate stringRedisTemplate;
    private final Duration redisTtl;
    private final String instanceId;

    @Override
    protected Object lookup(Object key) {
        String cacheKey = buildKey(key);

        // ① 查 L1
        Object value = localCache.getIfPresent(cacheKey);
        if (value != null) {
            return value;
        }

        // ② 查 L2
        value = redisTemplate.opsForValue().get(cacheKey);
        if (value != NULL_MARKER && value != null) {
            localCache.put(cacheKey, value);      // 回填 L1
            return value;
        }
        return null;                              // 都 miss
    }

    @Override
    public void put(Object key, Object value) {
        String cacheKey = buildKey(key);
        localCache.put(cacheKey, value);                                  // L1
        redisTemplate.opsForValue().set(cacheKey, value, redisTtl);       // L2
    }

    @Override
    public void evict(Object key) {
        String cacheKey = buildKey(key);
        localCache.invalidate(cacheKey);                                  // 删 L1
        redisTemplate.delete(cacheKey);                                   // 删 L2
        // ★ 广播其他节点删 L1
        stringRedisTemplate.convertAndSend("cache:invalidate",
            cacheKey + "|" + instanceId);
    }

    @Override
    public void clear() {
        localCache.invalidateAll();
        Set<String> keys = redisTemplate.keys(name + ":*");
        if (keys != null && !keys.isEmpty()) {
            redisTemplate.delete(keys);
        }
        stringRedisTemplate.convertAndSend("cache:invalidate", "*|" + instanceId);
    }
    // ... 省略其他方法
}

4.7.5 监控与调优

/**
 * ① 开启统计并暴露到 Prometheus
 */
@Component
@Slf4j
public class CaffeineMetricsExporter {

    @Autowired
    private CacheManager cacheManager;

    @Scheduled(fixedDelay = 30_000)
    public void exportMetrics() {
        cacheManager.getCacheNames().forEach(name -> {
            Cache<Object, Object> cache = (Cache<Object, Object>)
                cacheManager.getCache(name).getNativeCache();

            CacheStats stats = cache.stats();

            // ★ 核心指标
            double hitRate = stats.hitRate();                   // 命中率
            long hitCount = stats.hitCount();
            long missCount = stats.missCount();
            long evictionCount = stats.evictionCount();         // 淘汰数
            long evictionWeight = stats.evictionWeight();
            double avgLoadMs = stats.averageLoadPenalty() / 1_000_000.0;  // 平均加载耗时(ms)
            long estimatedSize = cache.estimatedSize();

            // 上报 Prometheus
            Metrics.gauge("caffeine_hit_rate", Tags.of("cache", name), hitRate);
            Metrics.gauge("caffeine_size", Tags.of("cache", name), estimatedSize);
            Metrics.counter("caffeine_eviction", Tags.of("cache", name))
                   .increment(evictionCount);

            log.info("缓存[{}] 命中率={}%, 条目={}, 淘汰={}, 平均加载={}ms",
                name, String.format("%.2f", hitRate * 100),
                estimatedSize, evictionCount,
                String.format("%.2f", avgLoadMs));
        });
    }
}
★ 四个关键指标与健康阈值:

   ① 命中率(hitRate):> 80% 为健康
      < 60% → 缓存没起作用,检查:
              · TTL 是不是太短
              · maximumSize 是不是太小(一直在淘汰)
              · key 设计是不是有问题(如带了时间戳导致每次都 miss)

   ② 淘汰数(evictionCount):持续增长且量大 → 容量不够
      → 调大 maximumSize,或拆分缓存

   ③ 平均加载耗时(averageLoadPenalty):> 100ms 要警惕
      → 说明缓存 miss 时代价很高,要考虑预热 + 提高命中率

   ④ 条目数(estimatedSize):接近 maximumSize 说明满了
      → 结合淘汰数判断是否要扩容

★ 告警规则:
   · 命中率 < 60% 持续 5 分钟 → warning
   · 命中率突降 20 个点(可能是缓存污染/预热失效)→ critical
   · 平均加载耗时 > 500ms → warning

调优清单:

① maximumSize 怎么定?
   经验:热点数据量的 1.5 ~ 2 倍
   算法:先用 10000 跑一周,看 estimatedSize 和 evictionCount
        如果 estimatedSize 一直贴着上限且 eviction 很高 → 调大

② TTL 怎么定?
   看数据更新频率 + 一致性容忍度
   多级缓存原则:L1 TTL < L2 TTL

③ 不要缓存的对象:
   ❌ 大对象(> 100KB)→ 占内存、GC 压力大
   ❌ 频繁变更的数据 → 缓存收益低,一致性成本高
   ❌ 有状态/不可变性问题的数据(如同一个对象被多处修改)

④ GC 考量:
   Caffeine 是堆内缓存,大容量会增加 GC 压力
   超过几万条 + 大对象时,考虑:
   · 减小 maximumSize
   · 或只缓存 ID、让业务层自己组装
   · 或用堆外缓存(Ehcache 3 / Chronicle Map)

⑤ softValues / weakValues 慎用!
   softValues:内存不足时被 GC 回收 → 命中率不可控,且依赖 GC 行为
   weakValues:没有强引用就回收 → 基本等于不缓存
   ★ 生产几乎不用,用 maximumSize + TTL 明确控制更好

4.8 其他缓存模式(了解即可,面试能说区别)

┌──────────────────────────────────────────────────────────┐
│ ① Cache Aside(旁路缓存)★ 最常用 —— 4.2 节已讲             │
│    应用自己管:读 miss 自己加载,写完自己删缓存              │
│    缓存是"被动"的,应用知道缓存的存在                        │
├──────────────────────────────────────────────────────────┤
│ ② Read Through(读穿透)                                    │
│    应用只跟缓存打交道,缓存自己负责加载 DB                    │
│    应用:cache.get(key)  ← 不知道 DB 的存在                 │
│    缓存 miss 时,由缓存组件(Provider)自己去 DB 加载         │
├──────────────────────────────────────────────────────────┤
│ ③ Write Through(写穿透)                                   │
│    应用只写缓存,缓存组件负责同步写 DB                        │
│    ★ 写 DB 成功才算写成功 → 强一致,但写性能差                │
├──────────────────────────────────────────────────────────┤
│ ④ Write Behind / Write Back(异步回写)                     │
│    应用只写缓存,缓存异步批量刷 DB                           │
│    ★ 写性能极高(合并多次写),但有【丢数据】风险              │
│    适用:点赞数、浏览量、计数器等允许少量丢失的场景             │
└──────────────────────────────────────────────────────────┘

四种模式对比:

模式 一致性 写性能 复杂度 丢数据风险 典型应用
Cache Aside 最终 高 低 无 绝大多数业务 ★
Read Through 最终 高 中 无 少见(框架层)
Write Through 强 低(同步写DB) 中 无 少见
Write Behind 弱 极高 高 有 计数器、点赞
★ 面试问到"你了解哪些缓存更新策略"时的回答:

"主要是 Cache Aside、Read/Write Through、Write Behind 四种。

 我们项目用的是 Cache Aside,因为实现简单、灵活,
 应用能完全控制缓存的生命周期。

 Write Through 虽然强一致,但每次写都要同步写 DB,
 写性能差,而且缓存组件要能操作 DB,耦合太紧。

 Write Behind 写性能最好,适合点赞数、浏览量这种
 可以合并写、允许少量丢失的场景,
 但如果缓存宕机,还没刷到 DB 的数据就丢了,
 所以我们资金相关的业务不敢用。

 补充一点:Caffeine 的 refreshAfterWrite 有点类似
 Read Through 的思路——由缓存组件自己异步加载新值,
 请求不阻塞、直接返回旧值,这个在高并发读场景很好用。"

4.9 缓存一致性监控与排查

4.9.1 必备监控指标

┌────────────────────────────────────────────────────────┐
│ L1(Caffeine)                                           │
│   · 命中率 hitRate          告警 < 60%                   │
│   · 条目数 estimatedSize    告警 > 90% maximumSize       │
│   · 淘汰数 evictionCount    用于容量评估                  │
│   · 平均加载耗时            告警 > 100ms                  │
├────────────────────────────────────────────────────────┤
│ L2(Redis)                                              │
│   · 命中率 keyspace_hits/(hits+misses)  告警 < 80%        │
│   · 内存使用 used_memory    告警 > 70% maxmemory          │
│   · 连接数 connected_clients                             │
│   · 慢查询 slowlog                                       │
│   · 键数量 dbsize           突增要排查(可能有 key 泄漏)   │
│   · 逐出数 evicted_keys     > 0 说明内存不够在淘汰          │
├────────────────────────────────────────────────────────┤
│ 一致性专项                                                │
│   · 缓存失效消息广播量                                     │
│   · 缓存失效失败次数(MQ 重试队列长度)                     │
│   · 脏数据抽样比对不一致率   ★ 最重要的业务指标              │
│   · DB QPS(缓存击穿时 QPS 会突增)                        │
└────────────────────────────────────────────────────────┘

4.9.2 脏数据检测(主动发现不一致)

/**
 * 抽样比对:定期从 DB 读一批数据,和缓存比对,发现不一致就告警
 *
 * ★ 这是"缓存对账",和 3.7 节的消息对账思路一致
 */
@Component
@Slf4j
public class CacheConsistencyChecker {

    @Autowired
    private DeviceMapper deviceMapper;
    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 每 5 分钟抽样 100 条比对
     */
    @Scheduled(fixedDelay = 300_000)
    public void check() {
        List<Long> sampleIds = deviceMapper.selectRandomIds(100);
        int mismatch = 0;

        for (Long id : sampleIds) {
            Device db = deviceMapper.selectById(id);
            String json = redisTemplate.opsForValue().get("device:" + id);

            if (json == null || "NULL".equals(json)) {
                continue;                    // 缓存没数据,不算不一致
            }

            Device cached = JSON.parseObject(json, Device.class);

            // 比对关键字段(版本号/更新时间最靠谱)
            if (!Objects.equals(db.getUpdateTime(), cached.getUpdateTime())
                    || !Objects.equals(db.getName(), cached.getName())) {
                log.error("缓存不一致!id={}, db={}, cache={}",
                          id, db.getUpdateTime(), cached.getUpdateTime());
                mismatch++;

                // 自动修复:删除缓存
                redisTemplate.delete("device:" + id);
                redisTemplate.convertAndSend("cache:invalidate",
                    "device:" + id + "|" + instanceId);
            }
        }

        double rate = mismatch * 1.0 / sampleIds.size();
        Metrics.gauge("cache_inconsistency_rate", rate);

        if (rate > 0.05) {                   // 不一致率 > 5% 告警
            alertService.send("缓存不一致率异常:" + String.format("%.2f%%", rate * 100));
        }
    }
}

4.9.3 排查工具箱

# ─── Redis 侧 ───

# ① 命中率
redis-cli info stats | grep -E "keyspace_hits|keyspace_misses"
# 命中率 = hits / (hits + misses)

# ② 内存
redis-cli info memory | grep -E "used_memory_human|maxmemory_human|mem_fragmentation_ratio"

# ③ 大 key 扫描(> 10KB 的 key,会导致网络拥堵、删除卡顿)
redis-cli --bigkeys
redis-cli memory usage device:10086

# ④ 热 key(Redis 4.0+)
redis-cli --hotkeys                        # 需要 maxmemory-policy 是 LFU 系列
redis-cli object freq device:10086         # 查看某个 key 的访问频率

# ⑤ 慢查询
redis-cli slowlog get 10

# ⑥ 实时命令监控(排查"谁在频繁访问")
redis-cli monitor | head -1000

# ⑦ 查看某个 key 的剩余 TTL
redis-cli ttl device:10086                 # -1=永不过期 -2=不存在

# ⑧ key 数量与分布
redis-cli dbsize
redis-cli --scan --pattern "device:*" | wc -l

# ─── 应用侧 ───

# ⑨ JVM 堆内存(Caffeine 是堆内缓存,看 GC 和老年代)
jmap -histo:live <pid> | head -30
jstat -gcutil <pid> 1000 10

# ⑩ Caffeine 统计(通过 Actuator 或自定义端点)
curl http://localhost:8080/actuator/caches/device

4.9.4 六个常见缓存问题排查剧本

CASE 1:缓存命中率突然下降
  排查:① Redis info stats 看 hits/misses
       ② Caffeine stats 看 hitRate
       ③ 看是不是有大量新 key 涌入(dbsize 变化)
       ④ 看是不是 TTL 集中过期(检查 TTL 设置)
       ⑤ 看是不是有批量任务把缓存刷掉了(--bigkeys 或 monitor)
  常见原因:批量导出/定时任务全表扫描 → 缓存污染
  解法:批量任务走独立数据源,不走缓存;或用 Caffeine(W-TinyLFU 抗污染)

CASE 2:DB 压力突增(缓存被打穿)
  排查:① 看缓存命中率
       ② 看是不是热点 key 过期(ttl 检查)
       ③ 看是不是 Redis 挂了(连接数、响应延迟)
       ④ 看是不是新增了大量不存在的 key 查询(穿透)
  解法:逻辑过期 + 布隆过滤器 + 熔断

CASE 3:数据不一致(用户反映看到旧数据)
  排查:① 抽样比对脚本跑一遍,量化不一致率
       ② 检查是不是删除缓存失败(看 MQ 重试队列)
       ③ 检查是不是广播失效没生效(Pub/Sub 订阅是否正常)
       ④ 检查是不是业务代码漏写缓存失效(用 Canal 兜底能发现)
       ⑤ 检查是不是在事务提交前就删了缓存(afterCommit 问题)
  解法:见 4.6.4 分层防御

CASE 4:Redis 内存暴涨
  排查:① --bigkeys 找大 key
       ② 检查是不是有 key 没设 TTL(ttl = -1)
       ③ 检查是不是有 key 泄漏(只增不删,如拼了随机后缀)
       ④ dbsize 趋势
  解法:统一 TTL、key 命名规范、定期清理、内存淘汰策略改为 allkeys-lru

CASE 5:缓存删除很慢 / Redis 卡顿
  排查:① --bigkeys:大 key 删除是阻塞操作(尤其是集合类)
       ② slowlog:是不是有 KEYS * 之类的危险命令
  解法:大 key 拆分、用 UNLINK 代替 DEL(异步删除)、禁用 KEYS

CASE 6:Caffeine 导致 Full GC
  排查:① jstat 看老年代增长
       ② jmap -histo 看是不是缓存对象占大头
  解法:减小 maximumSize、只缓存必要字段、考虑堆外缓存

4.10 本章(缓存一致性)面试题汇总

# 题目 难度
1 引入缓存后为什么会不一致?能做强一致吗? ⭐⭐⭐
2 Cache Aside 的读流程和写流程? ⭐⭐⭐
3 为什么是删除缓存而不是更新缓存? ⭐⭐⭐⭐⭐
4 先删缓存还是先更新数据库?为什么? ⭐⭐⭐⭐⭐
5 删除缓存失败了怎么办? ⭐⭐⭐⭐
6 为什么要在事务提交后(afterCommit)再删缓存? ⭐⭐⭐⭐
7 什么是延迟双删?延迟时间怎么定?有什么局限? ⭐⭐⭐⭐
8 Canal 订阅 binlog 更新缓存的原理和优缺点? ⭐⭐⭐⭐
9 缓存穿透是什么?怎么解决? ⭐⭐⭐
10 布隆过滤器的原理?为什么说“不存在”是绝对准确的? ⭐⭐⭐⭐
11 布隆过滤器为什么不支持删除?怎么解决? ⭐⭐⭐⭐
12 缓存击穿和雪崩的区别? ⭐⭐⭐
13 缓存击穿怎么解决?互斥锁 vs 逻辑过期怎么选? ⭐⭐⭐⭐
14 逻辑过期是什么?和物理过期有什么区别? ⭐⭐⭐⭐
15 缓存雪崩怎么预防?TTL 为什么要加随机值? ⭐⭐⭐
16 多级缓存(Caffeine + Redis)不一致的本质是什么? ⭐⭐⭐⭐⭐
17 多级缓存一致性有哪五种方案?各自优缺点? ⭐⭐⭐⭐⭐
18 Redis Pub/Sub 做失效广播有什么问题? ⭐⭐⭐⭐⭐
19 为什么 L1 的 TTL 要比 L2 短? ⭐⭐⭐⭐
20 为什么选 Caffeine 而不是 Guava Cache? ⭐⭐⭐
21 W-TinyLFU 是什么?讲讲原理 ⭐⭐⭐⭐⭐
22 LRU 有什么缺陷?LFU 有什么缺陷? ⭐⭐⭐⭐
23 Count-Min Sketch 是什么?为什么省内存? ⭐⭐⭐⭐⭐
24 Caffeine 的 expireAfterWrite / expireAfterAccess / refreshAfterWrite 区别? ⭐⭐⭐⭐
25 maximumSize 和 maximumWeight 怎么选? ⭐⭐⭐
26 Spring @Cacheable 有哪些坑? ⭐⭐⭐⭐
27 @CacheEvict 和 @Transactional 一起用要注意什么? ⭐⭐⭐⭐
28 缓存命中率多少算健康?低于阈值怎么排查? ⭐⭐⭐
29 怎么主动发现缓存和数据库不一致? ⭐⭐⭐⭐
30 Cache Aside / Read Through / Write Through / Write Behind 的区别? ⭐⭐⭐⭐
31 热点 key 怎么处理?(拆分、多副本、本地缓存) ⭐⭐⭐⭐
32 大 key 有什么危害?怎么处理? ⭐⭐⭐
33 缓存预热怎么做? ⭐⭐⭐
34 你的项目多级缓存怎么保证一致?遇到过什么问题? ⭐⭐⭐⭐⭐

开放题回答框架(“说说你们的缓存方案”):

第一步:说架构(20 秒)
   "我们用的是 Caffeine L1 + Redis L2 的多级缓存。
    L1 是进程内的,纳秒级;L2 是分布式的,毫秒级。
    大屏接口 RT 从 1200ms 降到 200ms,命中率 95%。"

第二步:说读写流程(40 秒)
   "读:L1 → L2 → DB,逐层回填。
    写:Cache Aside,先更新数据库,事务提交后删 L2 再删 L1。
    为什么先更 DB?因为先删缓存的窗口是更新 DB 的耗时(10ms 级),
    高并发读很容易把旧值回填进去造成长期不一致;
    而先更 DB 的窗口要求读 DB 之后、写缓存之前刚好插入删除操作,
    概率极低。"

第三步:说三剑客(40 秒)
   "穿透:参数校验 + 缓存空值(TTL 2~5 分钟带随机)+ 布隆过滤器备选
    击穿:L1 用 Caffeine 的 get(key, loader),天然单飞;
         L2 用逻辑过期,返回旧值 + 异步刷新,不阻塞
    雪崩:TTL 随机化 + 多级缓存 + 熔断降级 + 启动预热"

第四步:说多级一致性(60 秒)★ 核心区分点
   "多级的难点在于 L1 是进程内的,一个节点更新了别的节点不知道。
    我们做了四层:
    ① L1 TTL 5 分钟(比 L2 的 30 分钟短),最坏 5 分钟自愈,
       而且 L1 过期能从 L2 加载,不会直接打穿 DB;
    ② 主力用 Redis Pub/Sub 广播失效,毫秒级;
    ③ 删除失败发 MQ 重试;
    ④ 知道 Pub/Sub 不可靠(掉线会丢消息),所以必须有 TTL 兜底,
       两层是互补的。"

第五章:方案选型速查与生产 Checklist

本章是“速查手册”:不展开原理,只给结论表格和可直接执行的清单。面试前看这一章,上线前对照这一章。

5.1 一致性方案选型速查表

5.1.1 按场景查方案(★ 最实用,直接对照)

你的场景 推荐方案 关键要点
单库,多个表要一起成功/失败 本地事务(@BizTransactional) 注意事务失效的 10 种场景(见 02 文档)
单库 + 要发消息 RocketMQ 事务消息(半消息+回查) 核心业务;非核心用本地消息表
两个数据库(如 Oracle + GaussDB) 本地事务 + 事务同步器 + 补偿任务 主库强一致,从库最终一致 + 对账
跨服务,允许异步 可靠消息最终一致性 ★ 本地消息表 / 事务消息 + 消费端幂等 + 对账
跨服务,资金类可预留 TCC 必须处理幂等、空回滚、防悬挂
跨服务,长流程含外部系统 Saga 逆序补偿,注意隔离性差(有脏读)
跨服务,一致性要求极低 最大努力通知 定时重试 + 对账查询接口
不允许/不能引入中间件(无 Seata、无 MQ) 自研轻量 DTX(事务表 + 异步补偿 + Recovery)★ 见 2.5.3 状态与业务同库同事务 + 三态判定 + 对账兜底
对手方无回滚、无查询接口(如银行) Saga + 状态机 + T+1 文件对账 UNKNOWN 不重试(宁可少做,不可多做)
热点商品 / 秒杀(10 万 QPS 打一行) 不用预留型方案:Redis Lua 预扣 + DB 分桶 + 异步落库 ★ 见 2.5.6 TCC/2PC 在热点场景是反模式
同构 MySQL 微服务,想要简单 Seata AT 注意全局锁和性能损耗
DB 改了缓存要更新 Cache Aside(先更 DB 后删缓存) + MQ 重试 + Canal 兜底
多级缓存(Caffeine + Redis) 短 TTL + Redis Pub/Sub 广播 TTL 是必须兜底,广播是加速
高并发读,怕缓存击穿 逻辑过期(返回旧值 + 异步刷新) 比互斥锁吞吐高
查不存在的数据太多 缓存空值(短 TTL + 随机) ID 量极大时用布隆过滤器
热点 key 压力太大 拆分 key + 多副本 + 本地缓存 本地缓存最有效
担心长期不一致 T+1 全量对账 ★ 任何方案都要有,最后一道防线

5.1.2 按“一致性强度”选型

                    强 ─────────────────────────────► 弱
                    │                                  │
┌───────────────────┼──────────────────────────────────┼──────────────┐
│ 本地事务           │  XA/2PC  │  Seata AT  │  TCC  │  Saga  │ 可靠消息 │ 最大努力 │
│(单库)            │          │            │        │        │  最终一致 │  通知     │
└───────────────────┴──────────┴────────────┴────────┴────────┴──────────┴──────────┘
                    │                                                    │
              性能差、复杂                                          性能好、简单

★ 选型的黄金法则:
   ① 能不用分布式事务就不用(优先业务设计规避)
   ② 能用最终一致就不用强一致
   ③ 强一致只在"资金/库存"等核心场景用
   ④ 无论选哪个,都要有对账 + 幂等 + 告警

5.1.3 消息可靠性速查

环节 配置项 推荐值
生产端 发送方式 同步 send()(核心业务禁用 Oneway)
生产端 retryTimesWhenSendFailed 3
生产端 失败兜底 落 mq_send_log + 补偿任务
Broker flushDiskType ASYNC_FLUSH(+多副本,平衡)
Broker brokerRole SYNC_MASTER
Broker Kafka acks all + min.insync.replicas=2
消费端 ACK 时机 业务成功后
消费端 重试上限 5 次 → 死信队列
消费端 幂等 必做(唯一索引 + Redis SETNX 双保险)
全局 对账 T+1 全量对账

5.1.4 缓存三剑客速查卡(浓缩版,建议背下来)

┌────────────────────────────────────────────────────────────┐
│ 【穿透】查不存在的数据 → 每次打 DB                             │
│   现象:DB QPS 高、缓存命中率低、key 都是不存在的 ID            │
│   解法:① 参数校验 + 限流(第一道防线)                        │
│        ② 缓存空值(TTL 2~5 分钟 + 随机抖动)★ 首选             │
│        ③ 布隆过滤器(ID 量极大时)                             │
├────────────────────────────────────────────────────────────┤
│ 【击穿】单个热点 key 过期 → 瞬间大量请求打 DB                   │
│   现象:某一个 key 的 QPS 突增、DB 该表压力飙升                │
│   解法:① 逻辑过期(返回旧值 + 异步刷新)★ 推荐,无等待         │
│        ② 互斥锁(SETNX + 双重检查 + Lua 释放锁)              │
│        ③ 热点永不过期 + 定时刷新                               │
│        ④ 多级缓存:Caffeine 的 get(key, loader) 天然单飞 ★     │
├────────────────────────────────────────────────────────────┤
│ 【雪崩】大量 key 同时过期 / Redis 宕机 → DB 被打垮              │
│   现象:DB QPS 暴涨、大量慢 SQL、服务超时                      │
│   解法:① TTL 随机化(±20%)★ 最简单有效                       │
│        ② 多级缓存(Redis 挂了 L1 还能扛)                      │
│        ③ 熔断降级(Sentinel)                                  │
│        ④ 启动预热(避免冷启动雪崩)                            │
│        ⑤ Redis 高可用(哨兵/Cluster)                          │
└────────────────────────────────────────────────────────────┘

5.1.5 多级缓存失效方案速查

方案 实时性 可靠性 适用规模 推荐
① 短 TTL(30s~5min) 差 高(必然过期) 任意 ⭐⭐ 必配兜底
② Redis Pub/Sub 毫秒 中(掉线丢消息) < 50 实例 ⭐⭐⭐⭐⭐ 主力
③ Canal 订阅 binlog 百毫秒~秒 高(可重放) 任意 ⭐⭐⭐⭐ 兜底
④ MQ 广播 几十毫秒 最高 实例多时 ⭐⭐⭐⭐
⑤ 版本号/Hash 路由 好 中 特定 ⭐⭐

推荐组合:①(TTL 兜底)+ ②(Pub/Sub 主力)+ ③ 或 ④(可选增强)


5.2 生产 Checklist(上线前逐项对照)

5.2.1 事务一致性 Checklist

□ 所有事务方法都用 @BizTransactional(封装了 rollbackFor=Exception + timeout)
□ 事务方法是 public、非 final、非 static
□ 没有在同一个类里自调用事务方法
□ 事务方法里没有 catch 异常后不抛出
□ 事务里没有远程调用、发消息、写文件等不可回滚操作
□ 事务里没有大批量处理(超过 1000 条考虑分批)
□ 多数据源场景显式指定了 transactionManager
□ 多线程场景没有共享事务(用 afterCommit 或独立事务)
□ 数据库表引擎是 InnoDB(不是 MyISAM)
□ 有长事务监控(超过 5 秒告警)
□ 连接池配置了 leak-detection-threshold(HikariCP)
□ 关键业务有对账任务

5.2.2 消息一致性 Checklist

□ 核心业务用同步发送(不用 Oneway)
□ 发送失败有重试(retryTimes >= 3)
□ 发送失败有兜底(落库 + 补偿任务)
□ 异步发送的 onException 有落库 + 告警(不是空实现)
□ Broker 配置了合理的刷盘 + 主从策略
□ 消费端在业务【成功后】才 ACK
□ 消费端区分业务异常(进死信)和系统异常(重试)
□ 消费端有重试上限(5 次),避免毒丸消息堵队列
□ 消费端幂等:Redis SETNX(前置)+ 数据库唯一索引(兜底)
□ 幂等键设计合理(业务主键组合,不用 UUID/时间戳)
□ 业务执行失败时会删除 Redis 幂等锁(允许重试)
□ 有本地事务与消息一致方案(事务消息 / 本地消息表)
□ 事务消息有事务日志表,回查逻辑考虑了"查不到"的情况
□ 消息堆积有监控告警(Diff/LAG > 阈值)
□ 死信队列有监控
□ 有 T+1 对账任务,能发现漏发并自动补偿
□ 对账任务本身的健康度有监控

5.2.3 缓存一致性 Checklist

□ 使用 Cache Aside:先更新 DB,后删除缓存
□ 缓存删除在【事务提交后】执行(afterCommit)
□ 【不是】更新缓存
□ 缓存都设置了 TTL(★ 没有任何 key 是永不过期)
□ TTL 加了随机值(防雪崩)
□ 多级缓存:L1 TTL < L2 TTL
□ 删除缓存失败有重试(MQ)
□ 核心场景有 Canal 订阅 binlog 兜底
□ 防穿透:参数校验 + 缓存空值(短 TTL + 随机)
□ 防击穿:逻辑过期 或 Caffeine get(key, loader)
□ 防雪崩:TTL 随机 + 多级 + 熔断 + 预热
□ 缓存命中率有监控(< 60% 告警)
□ 缓存大小有监控(Caffeine estimatedSize、Redis used_memory)
□ 有脏数据抽样比对任务
□ 禁用 KEYS 命令(用 SCAN)
□ 大 key 已拆分(用 --bigkeys 检查过)
□ Redis 内存淘汰策略已配置(allkeys-lru 或 volatile-lru)
□ 有缓存预热机制(避免冷启动)
□ Caffeine 开启了 recordStats
□ 没有用 softValues / weakValues

5.2.4 监控告警 Checklist

【事务】
□ 长事务(> 5s)告警
□ 事务回滚率异常告警
□ 连接池活跃连接数 > 80% 告警
□ 死锁告警(数据库监控)

【分布式事务兜底】(2.5.5.5 / 2.5.9.4)★
□ 中间态事务数(TRYING/CANCELING)持续增长告警
□ UNKNOWN 分支出现即告警
□ 事务悬挂时长 P95 > 10 分钟告警
□ 补偿失败次数 10 分钟内 > 5 次告警
□ 空回滚次数异常增长告警(说明上游有问题)
□ Recovery 实例存活 + 扫描量监控(★ 协调器挂了要能发现)
□ 对账任务"是否按时执行"的元告警(★ 谁来看守看守者)
□ MySQL 行锁等待次数/平均等待时长告警(热点检测)

【消息】
□ 堆积量(Diff / LAG)> 10000 持续 5 分钟告警
□ 消费 RT P99 > 500ms 告警
□ 消费者实例数 < 预期告警
□ 死信队列消息数 > 0 告警
□ 消息发送失败率 > 0.1% 告警

【缓存】
□ L1 命中率 < 60% 告警
□ L2 命中率 < 80% 告警
□ Redis 内存 > 70% 告警
□ Redis 逐出数 > 0 告警
□ 缓存不一致率 > 5% 告警
□ DB QPS 突增(可能是缓存击穿)告警

【对账】
□ T+1 对账任务执行成功(未执行要告警)★ 容易漏
□ 对账差异数 > 0 告警
□ 对账自动修复数异常升高告警

5.3 故障应急速查(现象 → 原因 → 处理)

现象 可能原因 立即处理 根治方案
DB QPS 突然暴涨 缓存击穿 / 雪崩 ① 开启限流 ② 熔断非核心 ③ 手动预热 TTL 随机化 + 逻辑过期
缓存命中率突然下降 批量任务污染 / TTL 集中过期 ① 查 --bigkeys ② 检查 TTL 设置 批量任务走独立数据源
消息堆积百万 消费端变慢 / 实例减少 ① 扩 Queue + 扩消费者 ② 批量消费 ③ 必要时跳过 消费端优化(批量写 + 去远程调用)
消息一直重试进死信 消费端代码 bug / 数据异常 ① 看死信内容 ② 修复后重投 区分业务异常和系统异常
数据不一致(用户投诉) 事务失效 / 缓存未删 / 消息丢失 ① 跑对账脚本量化 ② 对账自动补偿 见 5.2 Checklist
重复扣款 / 重复发奖 幂等失效 ① 立即止血(暂停消费)② 对账退款 ③ 修复后重投 唯一索引兜底 + 幂等键检查
Redis 内存满了 大 key / key 泄漏 / 没设 TTL ① --bigkeys 找大 key ② 清理无 TTL 的 key 统一 TTL + key 命名规范
Redis 卡顿 大 key 删除 / KEYS 命令 ① slowlog 查慢命令 ② 禁用 KEYS 用 SCAN + UNLINK
连接池耗尽 长事务 / 慢 SQL ① 杀长事务 ② 扩容连接池 长事务拆分 + SQL 优化
事务不回滚 事务失效(catch / 非 public / 多数据源) ① 看日志 ② Arthas trace 查代理 见 02 文档十大失效场景
中间态事务(TRYING/CANCELING)持续堆积 协调器挂了 / Recovery 线程池满 / 下游全挂 ① 查 Recovery 实例存活 ② 查下游健康 ③ 必要时关补偿开关止血 Recovery 多实例 + 队列监控(见 2.5.5)
出现 UNKNOWN 状态分支 下游超时,结果未知 ① 不重试不补偿 ② 调反查接口确认 ③ 查不到转人工 下游必须提供反查接口(设计前提)
补偿连续失败告警 下游接口变更 / 数据异常 / 补偿不幂等 ① 关补偿开关 ② 看 last_error 定位 ③ 批量修复分支记录 4xx 不重试 + 失败率熔断(见 2.5.9.3)
MySQL 行锁等待暴涨 热点行(如秒杀库存) ① 查 Innodb_row_lock_waits ② 定位热点 SQL ③ 限流 分桶 + Redis 预扣(见 2.5.6)
死信队列持续增加 消费端 bug / 依赖不可用 ① 按错误类型分组看 ② 修复后批量重投(必须幂等) 死信处理平台 + SLA 告警(见 2.5.7.5)
对账差异突增 系统性问题(不是个案) ① 立即查最近发版 ② 关自动修复防扩散 ③ 人工确认后批量处理 T+0 准实时对账(5 分钟内发现)

5.4 一页纸速查卡(可打印)

┌─────────────────────────────────────────────────────────────────┐
│                  一致性 & 缓存同步 · 一页纸速查卡                   │
├─────────────────────────────────────────────────────────────────┤
│ 【三大根源】原子性被破坏 / 异步延迟 / 并发时序                       │
├─────────────────────────────────────────────────────────────────┤
│ 【事务】单库→本地事务  跨库→本地事务+补偿  跨服务→可靠消息/TCC/Saga  │
│        选型:资金用TCC,长流程用Saga,其余用可靠消息最终一致           │
├─────────────────────────────────────────────────────────────────┤
│ 【消息】三环节:生产端同步发+重试+落库                              │
│                Broker  SYNC_MASTER + 多副本                      │
│                消费端  业务成功后 ACK + 幂等 + 死信                 │
│        原子性:RocketMQ 事务消息(半消息+回查)                     │
│        幂等键:业务主键组合(禁 UUID/时间戳)                       │
│        兜底:T+1 对账                                              │
├─────────────────────────────────────────────────────────────────┤
│ 【缓存】写:先更DB → 事务提交后 → 删缓存(不是更新缓存)              │
│        读:L1 → L2 → DB(逐层回填)                                │
│        TTL:L1 < L2,且必须加随机                                  │
│        多级一致性:TTL兜底 + Pub/Sub广播(+ Canal/MQ 增强)          │
│        三剑客:穿透→空值+布隆                                       │
│                击穿→逻辑过期/Caffeine单飞                           │
│                雪崩→TTL随机+多级+熔断+预热                          │
├─────────────────────────────────────────────────────────────────┤
│ 【失效兜底】(2.5)★                                                │
│  三层失效:设计期(选型错)/ 运行期(异常没兜住)/ 治理期(没对账)    │
│  零中间件实现:事务表与业务同库同事务 → 异步推进 → 失败倒序补偿        │
│              → Recovery 扫表自愈 → 对账兜底                         │
│  三态判定:SUCCESS / FAIL / UNKNOWN(超时≠失败,UNKNOWN 先反查)      │
│  回滚四件套:空回滚(记痕+返回成功)/ 幂等 / 防悬挂 / 去重            │
│  协调器:无状态 + 行锁抢占 + fencing token + 扫表恢复                │
│  热点  :不用预留型方案 → Redis预扣(资格) + DB分桶(事实) + 降级        │
│  消费失败:可重试(退避+抖动) / 业务失败(直接死信) / 未知(反查)         │
│  对账  :长款 / 短款 / 金额不符(必人工) / 状态不符(先定"以谁为准")   │
├─────────────────────────────────────────────────────────────────┤
│ 【黄金法则】                                                        │
│  ① 能不用分布式事务就不用(业务设计优先)                           │
│  ② 重复是常态 → 幂等必做                                           │
│  ③ MQ 只保证至少一次 → Exactly Once 靠业务幂等                      │
│  ④ 删缓存不是更新缓存(幂等 + 防并发乱序)                          │
│  ⑤ 任何方案都要有对账(最后一道防线)                               │
│  ⑥ 对账任务本身也要有监控(谁来看守看守者)                          │
│  ⑦ 金额类 UNKNOWN:宁可少做,不可多做(重试=可能重复扣款)            │
│  ⑧ 没演练过的兜底方案 = 没有兜底方案                                 │
└─────────────────────────────────────────────────────────────────┘

第六章:面试题汇总(全篇总索引)

前五章每章末尾已有「本章面试题」,本章做全篇总索引 + 高频必背 + 开放题完整回答框架 + 追问陷阱。 建议:先按 6.2 的 TOP 24 突击,再按 6.3 练开放题,最后用 6.4 检查自己能不能扛住连环追问。

6.1 全篇题目总索引(共 108 题)

6.1.1 A 类:事务一致性(15 题)

# 题目 难度 出处
A1 什么是分布式事务?和本地事务的区别? ⭐ 2.6
A2 CAP 理论是什么?为什么 CA 不可兼得? ⭐⭐ 1.3
A3 BASE 理论是什么?和 CAP 的关系? ⭐⭐ 1.3
A4 分布式事务有哪些解决方案?各自适用场景? ⭐⭐⭐ 2.4
A5 TCC 是什么?和 2PC 的区别? ⭐⭐⭐ 2.3
A6 TCC 必须处理哪三个问题?如何处理? ⭐⭐⭐⭐⭐ 2.3
A7 什么是空回滚?什么是悬挂?怎么解决? ⭐⭐⭐⭐ 2.3 / 2.5.4
A8 Saga 和 TCC 怎么选?Saga 的隔离性问题怎么解决? ⭐⭐⭐⭐ 2.3
A9 Seata 的 AT / TCC / Saga / XA 模式区别? ⭐⭐⭐ 2.3
A10 Seata AT 的全局锁是什么?为什么会有脏写问题? ⭐⭐⭐⭐ 2.3
A11 可靠消息最终一致性的实现原理? ⭐⭐⭐⭐ 2.3
A12 本地消息表怎么设计?为什么消息表要和业务表同库? ⭐⭐⭐⭐ 3.3.2
A13 消息表状态一直 PENDING 怎么办? ⭐⭐⭐ 3.3.2
A14 最大努力通知和可靠消息的区别? ⭐⭐ 2.3
A15 你们项目怎么保证跨服务一致性?遇到过什么问题? ⭐⭐⭐⭐⭐ 6.3

6.1.2 B 类:消息一致性(26 题)

# 题目 难度 出处
B1 消息丢失可能发生在哪几个环节?怎么解决? ⭐⭐⭐ 3.2
B2 RocketMQ 的刷盘策略和主从复制各有哪几种?怎么配不丢消息? ⭐⭐⭐⭐ 3.2.2
B3 Kafka 的 acks=0/1/all 区别?和 min.insync.replicas 的关系? ⭐⭐⭐⭐ 3.2.2
B4 消费端为什么会丢消息?正确的 ACK 时机是什么? ⭐⭐⭐ 3.2.3
B5 先发消息还是先执行本地事务?两种做法各有什么问题? ⭐⭐⭐⭐⭐ 3.3.1
B6 本地消息表的原理?为什么消息表要和业务表同库? ⭐⭐⭐⭐ 3.3.2
B7 RocketMQ 事务消息的实现原理(半消息+回查)?画时序图 ⭐⭐⭐⭐⭐ 3.3.3
B8 事务回查时查不到事务记录怎么办? ⭐⭐⭐⭐⭐ 3.3.3
B9 事务消息能保证“消费一定成功”吗? ⭐⭐⭐⭐ 3.3.3
B10 Half 消息是什么?为什么消费端看不到? ⭐⭐⭐⭐ 3.3.3
B11 RocketMQ 事务消息有哪些限制? ⭐⭐⭐ 3.3.3
B12 什么是幂等?MQ 场景下为什么必须做幂等? ⭐⭐⭐ 3.4.1
B13 幂等键怎么设计?为什么不能用 UUID 或时间戳? ⭐⭐⭐⭐ 3.4.4
B14 幂等有哪五种实现方案?你项目里用的哪种? ⭐⭐⭐⭐ 3.4.3
B15 用 Redis SETNX 做幂等,业务执行失败了怎么办? ⭐⭐⭐⭐⭐ 3.4.3
B16 全局顺序和分区顺序的区别?怎么实现? ⭐⭐⭐⭐ 3.5.2
B17 顺序消息和重试为什么冲突?怎么解决? ⭐⭐⭐⭐⭐ 3.5.4
B18 Queue 扩容为什么会影响顺序消息? ⭐⭐⭐⭐ 3.5.4
B19 不用顺序消息,怎么保证状态不乱? ⭐⭐⭐⭐ 3.5.4
B20 消息堆积的原因有哪些?怎么排查? ⭐⭐⭐ 3.6
B21 堆积了几百万条消息,怎么处理? ⭐⭐⭐⭐ 3.6.3
B22 消费者实例数加得比 Queue 数多有用吗? ⭐⭐⭐ 3.6.3
B23 为什么要对账?对账的三种方式? ⭐⭐⭐⭐ 3.7
B24 对账发现差异怎么处理? ⭐⭐⭐ 3.7.4
B25 死信队列是什么?什么时候用? ⭐⭐⭐ 3.2.3
B26 你们项目怎么保证消息不丢不重复?遇到过什么问题? ⭐⭐⭐⭐⭐ 3.8

6.1.3 C 类:缓存一致性(34 题)

# 题目 难度 出处
C1 引入缓存后为什么会不一致?能做强一致吗? ⭐⭐⭐ 4.1
C2 Cache Aside 的读流程和写流程? ⭐⭐⭐ 4.2
C3 为什么是删除缓存而不是更新缓存? ⭐⭐⭐⭐⭐ 4.2.3
C4 先删缓存还是先更新数据库?为什么? ⭐⭐⭐⭐⭐ 4.3
C5 删除缓存失败了怎么办? ⭐⭐⭐⭐ 4.3.3
C6 为什么要在事务提交后(afterCommit)再删缓存? ⭐⭐⭐⭐ 4.3.3
C7 什么是延迟双删?延迟时间怎么定?有什么局限? ⭐⭐⭐⭐ 4.4
C8 Canal 订阅 binlog 更新缓存的原理和优缺点? ⭐⭐⭐⭐ 4.3.3
C9 缓存穿透是什么?怎么解决? ⭐⭐⭐ 4.5.1
C10 布隆过滤器的原理?为什么说“不存在”是绝对准确的? ⭐⭐⭐⭐ 4.5.1
C11 布隆过滤器为什么不支持删除?怎么解决? ⭐⭐⭐⭐ 4.5.1
C12 缓存击穿和雪崩的区别? ⭐⭐⭐ 4.5
C13 缓存击穿怎么解决?互斥锁 vs 逻辑过期怎么选? ⭐⭐⭐⭐ 4.5.2
C14 逻辑过期是什么?和物理过期有什么区别? ⭐⭐⭐⭐ 4.5.2
C15 缓存雪崩怎么预防?TTL 为什么要加随机值? ⭐⭐⭐ 4.5.3
C16 多级缓存(Caffeine+Redis)不一致的本质是什么? ⭐⭐⭐⭐⭐ 4.6.1
C17 多级缓存一致性有哪五种方案?各自优缺点? ⭐⭐⭐⭐⭐ 4.6.2
C18 Redis Pub/Sub 做失效广播有什么问题? ⭐⭐⭐⭐⭐ 4.6.2
C19 为什么 L1 的 TTL 要比 L2 短? ⭐⭐⭐⭐ 4.5.4
C20 为什么选 Caffeine 而不是 Guava Cache? ⭐⭐⭐ 4.7.1
C21 W-TinyLFU 是什么?讲讲原理 ⭐⭐⭐⭐⭐ 4.7.2
C22 LRU 有什么缺陷?LFU 有什么缺陷? ⭐⭐⭐⭐ 4.7.2
C23 Count-Min Sketch 是什么?为什么省内存? ⭐⭐⭐⭐⭐ 4.7.2
C24 expireAfterWrite / expireAfterAccess / refreshAfterWrite 区别? ⭐⭐⭐⭐ 4.7.3
C25 maximumSize 和 maximumWeight 怎么选? ⭐⭐⭐ 4.7.3
C26 Spring @Cacheable 有哪些坑? ⭐⭐⭐⭐ 4.7.4
C27 @CacheEvict 和 @Transactional 一起用要注意什么? ⭐⭐⭐⭐ 4.7.4
C28 缓存命中率多少算健康?低于阈值怎么排查? ⭐⭐⭐ 4.7.5
C29 怎么主动发现缓存和数据库不一致? ⭐⭐⭐⭐ 4.9.2
C30 Cache Aside / Read Through / Write Through / Write Behind 区别? ⭐⭐⭐⭐ 4.8
C31 热点 key 怎么处理? ⭐⭐⭐⭐ 4.5.2
C32 大 key 有什么危害?怎么处理? ⭐⭐⭐ 4.9.3
C33 缓存预热怎么做? ⭐⭐⭐ 4.5.3
C34 你的项目多级缓存怎么保证一致?遇到过什么问题? ⭐⭐⭐⭐⭐ 4.6.4

6.1.4 D 类:分布式事务失效兜底(33 题,★ 2.5 新增)

# 题目 难度 出处
D1 什么是“分布式事务失效”?分哪几个层次? ⭐⭐⭐ 2.5.1
D2 下单/注册送权益/行外划拨/秒杀/通知商户/文件对账,6 个场景分别怎么选型? ⭐⭐⭐⭐⭐ 2.5.2
D3 热点商品用 TCC 为什么会出问题?正确做法是什么? ⭐⭐⭐⭐⭐ 2.5.6
D4 不依赖 Seata / MQ,怎么实现跨服务一致性?讲设计 ⭐⭐⭐⭐⭐ 2.5.3
D5 事务记录表为什么要和业务表同库? ⭐⭐⭐⭐ 2.5.3.1
D6 自研方案中,业务事务提交了但事务记录没写成功怎么办? ⭐⭐⭐⭐⭐ 2.5.3.4
D7 分支调用的三态(SUCCESS/FAIL/UNKNOWN)怎么区分?为什么要区分? ⭐⭐⭐⭐⭐ 2.5.3.4
D8 UNKNOWN 状态怎么处理?金额类场景为什么不重试? ⭐⭐⭐⭐⭐ 2.5.5.3
D9 反查接口是必须的吗?没有反查接口怎么办? ⭐⭐⭐⭐ 2.5.5.3
D10 什么是空回滚?为什么必须返回成功? ⭐⭐⭐⭐⭐ 2.5.4.2
D11 什么是悬挂(防悬挂)?怎么解决? ⭐⭐⭐⭐⭐ 2.5.4.4
D12 空回滚记痕表为什么需要?和幂等表能合并吗? ⭐⭐⭐⭐ 2.5.4.2
D13 补偿接口要遵循哪几条铁律? ⭐⭐⭐⭐ 2.5.4.6
D14 补偿失败了怎么办?重试次数上限怎么定? ⭐⭐⭐⭐ 2.5.3.4
D15 退避策略怎么设计?为什么要加抖动? ⭐⭐⭐⭐ 2.5.7.3
D16 协调器宕机了怎么办?正在执行的事务会丢吗? ⭐⭐⭐⭐⭐ 2.5.5.2
D17 多个 Recovery 实例怎么避免重复处理? ⭐⭐⭐⭐ 2.5.5.2
D18 什么是 fencing token?解决什么问题? ⭐⭐⭐⭐⭐ 2.5.5.2
D19 超时时间怎么分层设计? ⭐⭐⭐⭐ 2.5.5.4
D20 热点商品库存怎么设计?分桶数怎么定? ⭐⭐⭐⭐⭐ 2.5.6.4
D21 分桶后某个桶空了但总库存还有,怎么办? ⭐⭐⭐⭐ 2.5.6.4
D22 Redis 预扣和 DB 分桶怎么配合?哪个是“资格”哪个是“事实”? ⭐⭐⭐⭐ 2.5.6.4
D23 秒杀场景怎么防止超卖?(三道防线) ⭐⭐⭐⭐ 2.5.6.3
D24 消息消费失败怎么处理?重试一直失败怎么办? ⭐⭐⭐⭐ 2.5.7
D25 死信队列怎么处理?你们有死信处理平台吗? ⭐⭐⭐⭐ 2.5.7.5
D26 消费失败时幂等锁要不要释放?为什么? ⭐⭐⭐⭐⭐ 2.5.7.4
D27 对账有哪几种差异类型?分别怎么处理? ⭐⭐⭐⭐ 2.5.8.2
D28 几百万条数据怎么比对?排序归并的思路? ⭐⭐⭐⭐⭐ 2.5.8.4
D29 “以谁为准”规则怎么定? ⭐⭐⭐⭐ 2.5.8.5
D30 对账任务本身挂了怎么发现? ⭐⭐⭐⭐ 2.5.8.6
D31 你们的降级开关有几层?分别降什么? ⭐⭐⭐⭐ 2.5.9.1
D32 怎么做故障演练?验证什么? ⭐⭐⭐⭐ 2.5.9.2
D33 讲一个你处理过的一致性事故(STAR) ⭐⭐⭐⭐⭐ 2.5.9.3

6.1.5 全篇难度分布(按题号统计,共 108 题)

⭐⭐⭐⭐⭐(30 题,必背)
  A6  A15   B5  B7  B8  B15  B17  B26
  C3  C4  C16  C17  C18  C21  C23  C34
  D2  D3  D4  D6  D7  D8  D10  D11  D16  D18  D20  D26  D28  D33

⭐⭐⭐⭐(51 题,重点)
  A7 A8 A10 A11 A12
  B2 B3 B6 B9 B10 B13 B14 B16 B18 B19 B21 B23
  C5 C6 C7 C8 C10 C11 C13 C14 C19 C22 C24 C26 C27 C29 C30 C31
  D5 D9 D12 D13 D14 D15 D17 D19 D21 D22 D23 D24 D25 D27 D29 D30 D31 D32

⭐⭐⭐(23 题,基础)
  A4 A5 A9 A13   B1 B4 B11 B12 B20 B22 B24 B25
  C1 C2 C9 C12 C15 C20 C25 C28 C32 C33   D1

⭐⭐(3 题):A2 A3 A14
⭐ (1 题):A1

6.2 高频 TOP 24 必背题(突击用)

按面试出现频率排序,这 20 题能答好,一致性专题基本过关。

┌──────────────────────────────────────────────────────────────┐
│ TOP 24 必背(21~24 为 2.5 新增的 D 类高频题)                    │
├──────────────────────────────────────────────────────────────┤
│  1. 先删缓存还是先更新数据库?为什么?                           │
│  2. 为什么是删除缓存而不是更新缓存?                            │
│  3. RocketMQ 事务消息原理(半消息 + 回查)                      │
│  4. 你们项目怎么保证消息不丢不重复?                            │
│  5. 你们项目怎么保证跨服务一致性?                              │
│  6. 多级缓存怎么保证一致?                                      │
│  7. 什么是缓存穿透/击穿/雪崩?怎么解决?                        │
│  8. 幂等怎么实现?幂等键怎么设计?                              │
│  9. TCC 的三个必须处理的问题?                                  │
│ 10. 为什么先发消息后写 DB 会资损?                              │
│ 11. 消息丢失在哪三个环节?分别怎么解决?                        │
│ 12. Redis Pub/Sub 做失效广播有什么问题?                        │
│ 13. 消费端为什么要在业务成功后才 ACK?                          │
│ 14. 消息堆积几百万怎么办?                                      │
│ 15. 本地消息表为什么和业务表同库?                              │
│ 16. 为什么要对账?对账怎么做?                                  │
│ 17. 为什么 L1 的 TTL 要比 L2 短?                               │
│ 18. 事务回查查不到记录怎么办?                                  │
│ 19. 分布式事务有哪些方案?怎么选?                              │
│ 20. 顺序消息和重试为什么冲突?                                  │
│ 21. 【D】不用 Seata / MQ,你怎么保证跨服务一致性?               │
│ 22. 【D】空回滚、幂等、防悬挂分别是什么?怎么处理?               │
│ 23. 【D】调用超时(UNKNOWN)了,重试还是补偿?                   │
│ 24. 【D】热点商品秒杀,库存怎么设计才不会锁竞争?                 │
└──────────────────────────────────────────────────────────────┘

6.3 三大开放题完整回答框架

6.3.1 开放题一:“你们怎么保证数据一致性?”

【总起 10 秒】
"一致性要分层看:单库靠本地事务,跨服务靠最终一致方案,
 缓存靠 Cache Aside + 失效通知。
 我的理解是:没有银弹,选型取决于业务对一致性的要求,
 而且任何方案都必须有对账做兜底。"

【分层展开 90 秒】

① 单库(30 秒)
  "单库用本地事务。但我们踩过坑,比如 catch 异常不抛出导致事务不回滚,
   多数据源没指定 transactionManager,多线程共享事务上下文。
   所以我们封装了 @BizTransactional,统一指定 rollbackFor=Exception 和 timeout,
   还用 ArchUnit 写了架构守护测试,在 CI 阶段拦截不规范的事务方法。"

② 跨库(20 秒)—— 用你简历的真实案例
  "我们做 Oracle 到 GaussDB 迁移时,双写是本地事务 + 事务同步器 + 补偿:
   主库写操作在事务内,从库在 afterCommit 后异步同步,
   同步任务先落库再执行,失败由定时任务重试,超过 5 次告警。
   这样即使应用宕机,重启后也能从待同步记录继续。"

③ 跨服务(30 秒)
  "跨服务我们用可靠消息最终一致性:RocketMQ 事务消息保证
   '本地事务成功 ⇄ 消息发出',消费端幂等保证不重复处理,
   最后 T+1 对账兜底。
   资金类场景才考虑 TCC,因为 TCC 要处理空回滚和防悬挂,复杂度高。"

④ 缓存(20 秒)
  "缓存用 Cache Aside:先更新 DB,事务提交后删缓存,
   删除失败发 MQ 重试。多级缓存场景下 L1 用短 TTL 兜底,
   Redis Pub/Sub 做实时失效广播。"

【收尾 20 秒】★ 加分
  "最后我想强调一点:我们所有的一致性方案都配了对账。
   因为事务消息回查可能超过次数、幂等键可能设计错、
   缓存删除可能失败,任何一个环节都可能出问题。
   对账是唯一不依赖主链路的发现手段,
   而且对账任务本身的健康度我们也有监控——
   否则对账自己挂了都没人知道。"

6.3.2 开放题二:“说说你们的缓存方案”

【架构 20 秒】
  "多级缓存:Caffeine L1 + Redis L2。
   L1 进程内纳秒级,L2 分布式毫秒级。
   项目三大屏接口 RT 从 1200ms 降到 200ms,命中率 95%。"

【读写流程 40 秒】
  "读:L1 → L2 → DB,逐层回填,空值也缓存(短 TTL + 随机,防穿透)。
   写:Cache Aside,先更新数据库,事务提交后删 L2 再删 L1。

   为什么先更 DB?因为先删缓存的窗口是更新 DB 的耗时(10ms 级),
   高并发读很容易把旧值回填进去,造成长期不一致;
   先更 DB 的窗口要求'读 DB 之后、写缓存之前'刚好插入删除操作,
   而写缓存是微秒级,概率极低。

   为什么在 afterCommit 里删?如果事务还没提交就删缓存,
   其他线程可能读到旧值并回填,事务提交后就永久不一致了。"

【三剑客 40 秒】
  "穿透:参数校验 + 限流 + 缓存空值,ID 量大的场景上布隆过滤器。
   击穿:L1 用 Caffeine 的 get(key, loader),天然单飞,
        同一个 key 只有一个线程去加载;
        L2 用逻辑过期,返回旧值 + 异步刷新,请求不阻塞。
   雪崩:TTL 随机化(±20%)+ 多级缓存 + Sentinel 熔断 + 启动预热。"

【多级一致性 60 秒】★ 核心
  "多级的难点是 L1 是进程内的,一个节点更新了别的节点不知道。

   我们四层防御:
   第一,L1 TTL 5 分钟,比 L2 的 30 分钟短,
         最坏 5 分钟自愈,且 L1 过期能从 L2 加载不会打穿 DB;
   第二,主力用 Redis Pub/Sub 广播失效,毫秒级,
         消息带 instanceId 自己忽略自己的;
   第三,删除失败发 MQ 重试;
   第四,我们知道 Pub/Sub 不可靠——订阅者掉线期间的消息会丢,
         所以必须有 TTL 兜底,两者是互补关系,
         Pub/Sub 是'加速失效',TTL 是'保证失效'。"

【效果 10 秒】
  "上线半年没有出现过因缓存不一致导致的数据投诉,
   我们有抽样比对任务,不一致率一直低于 1%。"

6.3.3 开放题三:“遇到过什么一致性相关的线上问题?”

★ 用 STAR 结构,讲一个真实的、有技术深度的、有复盘的案例

【Situation 背景】
  "社区平台做活动发奖,用户报名成功后发积分。
   技术上是:写报名记录 + 发 RocketMQ 消息,消费端发奖。"

【Task 任务】
  "上线第二天运营反馈:有几个用户反映积分发了两次。"

【Action 排查与解决】★ 重点
  "① 先查发奖记录表,确实有重复记录,但 bizKey 不同——
      说明幂等键算出来不一样,幂等失效了。

   ② 查代码,发现幂等键是 userId + activityId + Date,
      但 Date 用的是 new Date() 的完整时间戳(精确到毫秒),
      而消息重试时重新计算了一次,时间戳变了 → 幂等键变了 → 幂等失效。

   ③ 更糟的是,我们当时只用了 Redis SETNX 做幂等,
      TTL 设的是 5 分钟,而消息重试周期最长 2 小时,
      锁过期后重试的消息又当成新请求处理了。

   ④ 修复分三步:
      · 幂等键改成 userId + activityId + yyyyMMdd 日期字符串(稳定)
      · 加数据库唯一索引 uk_biz_key 做最终兜底(Redis 只是前置加速)
      · Redis TTL 从 5 分钟改成 24 小时(> 最大重试周期)

   ⑤ 补偿:写对账脚本扫出重复发奖的记录,给用户扣回多发的积分,
      并给受影响用户发站内信道歉。"

【Result 结果 + 复盘】★ 加分
  "修复后再没出现过重复发奖。

   复盘后有三个工程改进:
   ① 所有幂等键统一走 IdempotentKeyGenerator 工具类,
      禁止业务代码自己拼(避免再拼出时间戳这种);
   ② 定下规范:Redis SETNX 只做前置拦截,
      必须有数据库唯一索引做兜底,双保险;
   ③ 加了 T+1 对账任务,比对报名表和发奖表,
      漏发自动补发,多发告警人工处理。

   这个坑让我明白:幂等不能只靠一层,
   而且幂等键的'稳定性'比'唯一性'更容易被忽略。"

6.4 面试官常见追问陷阱(连环问)

面试官往往会顺着你的回答层层追问,这里整理最常见的追问链。

追问链一:缓存更新

Q1: 数据库和缓存怎么保证一致?
A : 用 Cache Aside,先更新数据库,再删除缓存。
    ↓
Q2: 为什么要删除缓存,而不是更新缓存?
A : 四个理由……(性能、并发乱序、计算型缓存、事务回滚)
    ↓
Q3: 那如果删除缓存失败了呢?
A : 发 MQ 重试,核心场景用 Canal 订阅 binlog 兜底。
    ↓
Q4: 那你这个方案能保证强一致吗?
A : 不能,只能保证最终一致。要强一致只能读主库或加分布式锁,
    但那样缓存就没意义了。
    ↓
Q5: 有没有可能出现长期不一致?
A : 会。如果删除失败且重试也失败、Canal 也挂了,
    就靠 TTL 兜底,所以我们所有缓存都有 TTL,没有永不过期的 key。
    ↓
Q6: TTL 设多长?
A : 看数据更新频率和一致性容忍度。我们 L2 是 30 分钟,L1 是 5 分钟。
    ↓
Q7: 为什么 L1 比 L2 短?★ 很多人答不上来
A : 两个原因:一是一致性,L1 最难失效(进程内),TTL 短限制不一致窗口;
    二是防击穿,L1 过期后能从 L2 加载,不会直接打穿 DB。
    如果反过来,L2 先过期而 L1 还命中旧值,那 L2 就白搭了。

追问链二:事务消息

Q1: 怎么保证本地事务和消息发送的一致性?
A : RocketMQ 事务消息,半消息 + 事务回查。
    ↓
Q2: 什么是半消息?
A : Half 消息发出去了但对消费端不可见,本地事务成功后 COMMIT 才可见。
    ↓
Q3: 如果 COMMIT 请求发出去但网络断了,Broker 没收到怎么办?
A : Broker 会定时回查,调用 checkLocalTransaction。
    ↓
Q4: 回查的时候查什么?
A : 查事务日志表,这个表和业务操作在同一个本地事务里写入,
    业务成功它就在,业务失败它就不在。
    ↓
Q5: 如果回查时查不到记录呢?★ 陷阱
A : 不能简单返回 ROLLBACK。查不到有几种可能:
    本地事务真的没执行、事务还在执行中、主从延迟读不到。
    我们会先查主库的业务表交叉验证,
    业务数据存在就返回 COMMIT,仍不确定就返回 UNKNOWN 等下次回查。
    ↓
Q6: 回查超过次数被回滚了,但本地事务其实成功了怎么办?★ 陷阱
A : 这正是事务消息不能 100% 保证的地方,所以必须有对账。
    我们的 T+1 对账会扫出"有业务数据但没有下游记录"的情况,自动补偿。
    ↓
Q7: 事务消息能保证消费一定成功吗?★ 陷阱
A : 不能。事务消息只保证"本地事务 与 消息发送"的原子性,
    不保证"消息消费"的成功。消费失败还是要靠重试 + 幂等 + 死信 + 对账。

追问链三:幂等

Q1: 消息重复消费怎么处理?
A : 消费端做幂等。
    ↓
Q2: 怎么实现幂等?
A : Redis SETNX 做前置拦截 + 数据库唯一索引做最终兜底。
    ↓
Q3: 为什么两层?一层不够吗?
A : 不够。Redis 是AP 的,主从切换可能丢锁,而且 TTL 过期后锁就没了。
    唯一索引是数据库层面的强保证。
    ↓
Q4: TTL 设多长?
A : 必须大于消息的最大重试周期,我们是 24 小时。
    早期设 5 分钟踩过坑——锁过期后重试的消息被当成新请求,重复发奖。
    ↓
Q5: 业务执行失败了,锁怎么办?★ 陷阱
A : 必须删除锁,否则后续重试会被永久拦截,业务永远无法成功。
    我们是在 catch 里 delete 锁然后抛异常让 MQ 重试。
    ↓
Q6: 那如果删了锁,重试又失败,一直循环怎么办?
A : 有重试上限(5 次),超过进死信队列并告警,人工介入。
    ↓
Q7: 幂等键怎么设计的?
A : 业务主键组合,我们项目是 userId + activityId + 日期(yyyyMMdd)。
    三个原则:唯一性、稳定性、可解释性。
    绝对不能用 UUID 和时间戳,因为重试时会变。

追问链四:多级缓存

Q1: 多级缓存怎么保证一致?
A : L1 短 TTL + Redis Pub/Sub 广播失效。
    ↓
Q2: Pub/Sub 可靠吗?★ 陷阱
A : 不可靠。Pub/Sub 不持久化,订阅者掉线期间的消息会永久丢失。
    ↓
Q3: 那丢消息了怎么办?
A : 靠 TTL 兜底。这也是为什么 L1 必须设 TTL,
    Pub/Sub 的定位是"加速失效"而不是"保证失效"。
    ↓
Q4: 那 TTL 设多久?设短了命中率低怎么办?
A : 权衡。我们 L1 设 5 分钟,L2 设 30 分钟。
    实测 L1 命中率 60%,L2 35%,总体 95%,可以接受。
    ↓
Q5: 实例很多的时候广播风暴怎么办?
A : 我们实例数控制在 20 以内。如果再多,
    会考虑改用 MQ 广播,或者对非热点数据干脆不用 L1。
    ↓
Q6: 除了广播还有什么方案?
A : 五种:短 TTL、Pub/Sub、Canal 订阅 binlog、MQ 广播、版本号/Hash 路由。
    Canal 最可靠(能覆盖 DBA 手工改库),但延迟大、要维护组件。
    ↓
Q7: 你为什么不用 Canal?
A : 评估过。我们业务代码的缓存失效逻辑是全的,
    而且有对账兜底,Canal 的收益主要在"防漏写",
    当时优先级不如其他需求。如果后续出现漏删问题会上。

追问链五:分布式事务失效兜底(★ 2.5 新增,最容易被问崩的一条)

Q1: 跨服务一致性你们怎么做的?
A : 主链路用可靠消息最终一致,资金类用 TCC,都有对账兜底。
    ↓
Q2: 如果不用 Seata,也没有 MQ,你怎么保证?★ 陷阱
A : 自己实现轻量 DTX——事务状态和业务数据写在同一个本地事务里,
    异步线程推进分支调用,失败就倒序补偿,Recovery 定时扫表自愈。
    ↓
Q3: 为什么事务记录必须和业务数据同库?
A : 保证"有业务数据就一定有事务记录"。
    如果分开两个库,可能出现业务提交了但事务记录没写 → 孤儿数据没人管。
    ↓
Q4: 那业务提交了、事务记录没写成功的情况不是还有吗?★ 陷阱
A : 有,这是本方案唯一的理论漏洞,窗口在毫秒级。
    靠对账兜底:扫"业务表有、tx_record 无"的数据补建或告警。
    这也是为什么再完美的方案也必须有对账。
    ↓
Q5: 分支调用超时了,你怎么处理?
A : 超时不等于失败。我把结果分成 SUCCESS / FAIL / UNKNOWN 三态,
    UNKNOWN 的不重试也不补偿,先调反查接口确认。
    ↓
Q6: 反查也查不到呢?★ 陷阱
A : 金额类宁可少做不可多做——保持 UNKNOWN,告警转人工,走对账。
    因为重试可能重复扣款(资损+客诉),不重试最多少扣,对账能补。
    这是业务权衡,不是技术问题。
    ↓
Q7: 补偿失败了怎么办?
A : 留在表里按阶梯退避重试(5s/15s/60s/5min/...),
    超过 8 次告警转人工。绝不无限重试(打垮下游),也绝不一次失败就放弃。
    ↓
Q8: 协调器挂了呢?
A : 协调器是无状态的,状态全在 DB。
    多实例通过行锁/CAS 抢占,配合 fencing token 防假死实例误写。
    重启后扫表继续推进,事务不会丢。
    ↓
Q9: 补偿接口要注意什么?★ 陷阱
A : 四件套——空回滚(Try 没执行也要返回成功并记痕)、
    幂等(重复调用结果一致)、防悬挂(Cancel 先到后 Try 必须拒绝执行)、
    去重(业务层幂等键 + DB 唯一索引)。
    ↓
Q10: 秒杀场景也用这套吗?
A : 不用。热点商品用预留型方案(TCC/2PC)是反模式——
    10 万 QPS 打一行库存会锁竞争雪崩。
    改成 Redis Lua 原子预扣拿资格 + MQ 异步落库 + DB 分桶兜底,
    必要时降级为"允许少量超卖 + 事后退款"。
    ↓
Q11: 你怎么证明这套真的有效?★ 加分
A : 定期故障演练:kill 协调器、断网、让补偿接口抛异常、制造消息堆积,
    验证能否自愈、不能自愈的是否告警、告警是否有人处理。
    没演练过的兜底方案等于没有兜底方案。

6.5 你可以反问面试官的问题(体现思考深度)

① "咱们业务对一致性的要求是什么级别?
    比如发奖这种场景,允许延迟多久?有没有对账机制?"

② "现在缓存是单级还是多级?如果用多级,
    节点间的失效是怎么做的?Pub/Sub 还是 MQ?"

③ "线上出现过数据不一致的问题吗?最后是怎么发现和定位的?
    —— 这个问题能问出团队的工程成熟度"

④ "消息中间件用的是 RocketMQ 还是 Kafka?
    事务消息用得多吗?还是本地消息表?"

⑤ "有完善的对账体系吗?T+1 还是实时?差异是怎么处理的?"

⑥ "容量大概什么量级?热点 key 和大 key 有专门的治理吗?"

⑦ "如果不一致导致了资损,处理流程是什么?有熔断/止血机制吗?"

6.6 白板画图题(要求你画出来的)

面试常要求当场画图,这 9 张图建议练到能默画。

【图 1】RocketMQ 事务消息时序图
   Producer ──① Half 消息──► Broker
   Producer ◄──② 写入成功──  Broker
   Producer ──③ 执行本地事务(写 DB)
   Producer ──④ COMMIT/ROLLBACK──► Broker
   Broker   ──⑤ 消息可见/丢弃──► Consumer
   (异常分支)Broker ──⑦ 回查──► Producer
              Producer ──⑧ 返回状态──► Broker

【图 2】缓存 Cache Aside 读写流程
   读:缓存 hit → 返回;miss → 读 DB → 回填缓存
   写:更新 DB → 删除缓存(不是更新缓存)

【图 3】多级缓存结构 + 失效广播
   L1(实例A/B/C) → L2(Redis) → DB
   更新时:删 Redis + 删自己 L1 + publish 失效消息 → 其他节点删 L1

【图 4】W-TinyLFU 结构
   Window LRU(1%) → TinyLFU 准入判断 → Main(SLRU: Probation 20% + Protected 80%)

【图 5】消息丢失三个环节
   Producer →①→ Broker内存 →②→ Broker磁盘 →③→ Consumer

【图 6】本地消息表模式
   一个事务里:写业务表 + 写消息表
   事务提交后:定时任务扫 PENDING → 发 MQ → 更新状态

【图 7】自研轻量 DTX 架构(2.5.3)★ 新增
   业务线程:@MiniTx 方法 → 本地事务写【业务表 + tx_record + tx_branch】
   提交后  :异步线程按 seq 调分支 → 成功推进 / 失败倒序补偿
   兜底    :Recovery 每秒扫卡住的事务 → 按状态机恢复(幂等)
   再兜底  :T+0 准实时对账 + T+1 全量对账

【图 8】TCC 三态异常时序(2.5.4)★ 新增
   空回滚:Try 丢包 → Cancel 先到 → 无记录也要记痕并返回成功
   幂等  :Confirm 超时重发 → 查状态 → 已成功直接返回上次结果
   防悬挂:空回滚后迟到的 Try → 查到 empty_rollback 标记 → 拒绝执行

【图 9】热点库存分层(2.5.6)★ 新增
   售罄本地缓存(拦 99%)→ Redis Lua 原子预扣(拿资格)
   → MQ 异步创建订单 → DB 分桶扣减(事实,stock >= num 乐观校验)
   → 失败归还 Redis

6.7 本文件使用建议

【面试前一周】
  Day 1-2:精读第二章(事务 + 2.5 失效兜底)与第三章(消息),背 6.2 的 TOP 24
  Day 3-4:精读第四章(缓存),重点 4.3、4.6、4.7
  Day 5  :过第五章 Checklist + 2.5.9.4 上线前 Checklist,对着自己的项目逐项自查
  Day 6  :练 6.3 三大开放题 + 2.5.2 六场景选型题,用手机录音自测(3 分钟内讲完)
  Day 7  :默画 6.6 的 9 张图

【复习优先级】
  P0(必背):4.3 先删缓存还是先更 DB / 3.3.3 事务消息 / 4.6 多级缓存一致
             / 2.5.2 六场景选型 / 2.5.4 回滚防御四件套
  P1(重点):3.4 幂等设计 / 4.5 三剑客 / 4.7.2 W-TinyLFU / 2.3 TCC
             / 2.5.3 零中间件轻量实现 / 2.5.5 协调器宕机 / 2.5.6 热点锁竞争
  P2(了解):3.5 顺序消息 / 3.6 堆积 / 4.8 其他缓存模式 / 4.9 排查
             / 2.5.8 事务对账 / 2.5.9 故障兜底

【自查标准】
  ✅ 能不看书讲清楚"先更 DB 后删缓存"为什么比"先删缓存"好
  ✅ 能画出事务消息的完整时序图(含回查分支)
  ✅ 能说出多级缓存五种失效方案的优缺点和选型
  ✅ 能讲一个自己踩过的一致性坑,且有复盘和工程改进
  ✅ 被追问三轮还能答得上来(见 6.4)
  ✅ 不看 Seata/MQ,能讲出一套"事务表 + 补偿 + Recovery + 对账"的轻量设计
  ✅ 能分清空回滚、幂等、防悬挂三件事,说出各自的触发时序
  ✅ 被问"超时了怎么办"时,能答出"三态判定 + 反查 + 金额类不重试"
  ✅ 被问"秒杀怎么保证一致"时,能答出"热点场景不用预留型方案"

6.8 与其他文档的关联

01-Java核心内功        → 多线程、锁、CAS(幂等和并发控制的底层基础)
02-开发框架            → Spring 事务失效十大场景、线程上下文、传播行为 ★ 强相关
03-数据库与缓存        → MySQL 锁、索引、Redis 基础、Caffeine 基础
04-中间件              → RocketMQ 基础(生产/消费/顺序/事务消息)
05-大数据              → 批量处理、数据同步
06-AI应用开发          → RAG 向量库与业务数据的一致性
07-云原生与DevOps      → 容器部署、配置管理(缓存/MQ 配置的发布)
08-可观测性            → 链路追踪、监控告警(一致性问题的排查手段)★ 强相关
09-前端基础            → 前端缓存(localStorage / HTTP 缓存)
10-数据一致性与缓存同步 ← 本文件(跨组件的综合性问题)

最后一句: 一致性问题的核心不是“记住多少方案”,而是建立分层思维: 先判断是什么级别的一致性问题(单库/跨库/跨服务/缓存), 再选对应层级的方案,最后一定加上“幂等 + 对账 + 告警”三件套。 面试时能讲出这个思维框架,比背十个方案更有说服力。