第 09 篇 · 分布式系统
本篇先钉一致与事务模型,再走消息、锁/ID、时钟、可观测与多活——分布式是取舍题,不是组件堆砌题。
骨架见 M07。本篇只补领域细枝,框在 核心方法论 · 开篇。
分布式主线是「一致/分区取舍 → 事务与消息实现 → 协调原语(锁/ID/时钟)→ 可观测与容灾多活」。
- CAP/BASE 与事务谱系:分区下 C/A 权衡决定强一致还是最终一致;2PC/TCC/Seata AT/SAGA/Outbox 各自用阻塞、侵入或补偿换原子性(C09.01/02/16)。
- 消息可靠、顺序与积压:Kafka 吞吐/ISR/EOS、通用 MQ 全链路确认与死信、千万级积压的止血—转存—回补(C09.04/05/09)。
- 锁、ID、哈希与幂等:Redis/ZK/DB 锁正确性边界与误用分型、号段/雪花与时钟回拨、一致性哈希迁移量、Token+状态机幂等(C09.03/06/07/08/17)。
- 共识、时钟与会话/缓存:崩溃 vs 拜占庭、Raft/Paxos 落地;物理钟不可默认排序,Lamport/HLC/TSO;会话集中或消除;Cache-DB 一致等级先定再选路径(C09.10/12/13/14)。
- 追踪与容灾多活:Metrics/Log/Trace 以 TraceId 联动;RPO/RTO 驱动单元化、切流、对账与持续演练(C09.11/15)。
- 架构评审先写失败模型与一致性承诺(最大不一致窗口、补偿责任),再选中间件;禁止「先定组件再找理由」。
- 写路径上线清单:幂等键/去重表、超时重试预算、死信与人工补偿、对账任务与告警;缺一项不得上线。
- 消息与 DB 双写默认 Outbox/CDC 或事务消息;禁止无补偿的「先发消息再写库」。
- 分布式锁/租约必须处理续期失败、过期误删与脑裂;误用按分型排查(未持锁并行 / TTL 短于业务 / 解锁他人),禁止当银弹。
- 定期混沌:超时、分区、重复投递、时钟回拨;结果进入错误预算与发布门禁讨论。
- 多活/单元化先划爆炸半径与数据归属,再谈同步;切流开关与对账可演练,「接近 0 RPO」靠自动化与验证,不靠口号。
- ID/时钟设计写明失效模式;跨机房禁止假设 NTP 完美同步;雪花类方案有回拨预案。
- 链路追踪与结构化日志默认接入跨服务上下文;排障路径固定为「现象 → Trace → 分区/副本/消费位点证据」。
- 先选一致模型:业务承诺决定技术栈,组件是实现细节。
- 副作用必须幂等:重试是常态,写路径可重入或可去重。
- 协调原语有租约:锁/领导选举要处理续期失败与脑裂,不能只演示成功路径。
- 对账兜底:最终一致系统以对账与补偿为正确性最后一道闸。
- 超时有端到端预算:重试风暴按事故预防,链路可加总。
- 故障域隔离:爆炸半径与降级开关优先于同构多机房堆积。
- 证据驱动排障:积压/锁不住/不一致先分型再动作,忌盲目扩容或跳位点。
这道题考察对分布式系统"网络分区下一致性(C)与可用性(A)不可兼得"这一基本定理的底层理解,以及 BASE 理论如何作为 CAP 在工程实践中的妥协指南。核心不是背诵"三选二",而是理解:P(分区容忍)在真实网络中是必然存在的,真正的权衡发生在 C 与 A 之间;BASE 不是另一套理论,而是 AP 选择的工程落地方法论。
起源:Brewer 2000 PODC keynote 提出 CAP 猜想。Eric Brewer 在"Towards Robust Distributed Systems"中首次提出分布式系统无法同时满足一致性(Consistency)、可用性(Availability)、分区容忍性(Partition Tolerance),只能三选二。
形式化证明:Gilbert & Lynch 2002。两位 MIT 学者在"Brewer's Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services"中严格证明了 CAP——在异步网络模型下,发生分区时不存在一个协议能同时保证线性一致性与总可用性。CAP 从猜想成为定理。
修正与反思:Brewer 2012 "CAP Twelve Years Later"。Brewer 公开承认"三选二是误导性的"——P 不是可选项而是必然(网络总会分区),实际选择是分区发生时在 C 与 A 之间权衡;且分区是少发的,无分区时系统可同时提供 C 与 A。PACELC(Abadi 2010)进一步扩展:分区时(P)权衡 A 与 C,无分区时(E)权衡 L(延迟) 与 C,更贴近工程实际。
BASE 理论:eBay Dan Pritchett 2008 提出。作为对 CAP 中 AP 选择的实践指南:Basically Available(基本可用,允许损失部分可用性如响应时间增加、降级)、Soft state(软状态,允许中间状态存在)、Eventually consistent(最终一致性,副本最终收敛)。BASE 让"最终一致"从模糊口号变为可工程化的设计原则。
趋势:① 从 ACID(强一致)→ CAP(理论边界)→ BASE(实践妥协)→ PACELC(精细化权衡)的演进,本质是对分布式权衡的认知不断精确化;② 现代系统多采用"分层一致性"——核心链路 CP(如账户、库存扣减)、非核心链路 AP(如商品展示、评论)+最终一致;③ CRDT(无冲突复制数据类型)、Calvin 等新理论在探索弱一致性下更强的可编程性。
CAP 形式化定义:
- 一致性(C):线性一致性(linearizability),任何读都返回最近一次写的结果,所有客户端看到相同视图,仿佛单机原子操作。
- 可用性(A):每个非故障节点对每次请求都必须返回非错误响应(不保证是最新数据)。
- 分区容忍(P):网络允许丢失任意节点间消息(分区发生时系统继续工作)。
反证法证明核心:假设存在协议同时满足 C+A+P。设两个节点 N1、N2 分区。客户端向 N1 写 v1,因可用性 N1 必须响应成功;客户端向 N2 读,因可用性 N2 必须返回。但分区使 N1 的 v1 无法同步到 N2,N2 只能返回旧值 v0,违反线性一致性。故 C 与 A 在分区下不可兼得。
线性一致性:读返回最新写"] A["Availability 可用性
非故障节点必响应"] P["Partition Tolerance 分区容忍
网络分区时继续工作(必然)"] end P -.->|"网络分区发生"| F{"权衡选择"} F -->|"选 C: 拒绝响应未同步节点"| CP["偏 CP 默认
etcd/Spanner 等
分区时牺牲可用性"] F -->|"选 A: 允许返回旧数据"| AP["偏 AP 默认
Eureka 等
牺牲线性一致"] style CP fill:#dbeafe,stroke:#2563eb style AP fill:#ede9fe,stroke:#7c3aed style P fill:#fef3c7,stroke:#d97706
PACELC 扩展:If Partition then choose between A and C, Else choose between L and C。例如 Cassandra(PA/EL)、MongoDB(PC/EC)、Spanner(PC/EC)。它揭示了即使无分区,强一致也意味着同步副本带来的延迟代价。
BASE 三要素:
| 要素 | 含义 | 工程体现 |
|---|---|---|
| Basically Available | 基本可用:故障时损失部分非核心可用性 | 响应时间变长、降级返回、限流排队 |
| Soft State | 软状态:允许中间状态,不要求每时刻强一致 | 副本异步同步、缓存延迟更新、订单状态流转 |
| Eventually Consistent | 最终一致:无新写入时副本最终收敛一致 | T+1 对账、消息补偿、Read Repair、反熵协议 |
最终一致性的变体:读己之写(Read Your Writes)、单调读(Monotonic Reads)、单调写(Monotonic Writes)、读后写(Writes Follow Reads)——不同会话一致性级别,业务按需选择。
分层一致性实践(电商场景):
- 支付/账户/库存扣减 → CP:资金不能错,用强一致(TCC/XA),宁可短时不可用也不允许超卖或余额错误。
- 商品展示/评论/推荐 → AP:用最终一致,缓存+异步刷新,短暂不一致可接受,可用性优先。
- 订单状态 → 最终一致:订单创建后各子系统异步处理,通过消息+对账保证最终一致。
BASE 落地手段:① 基本可用——网关层限流降级(Sentinel)、核心链路保障;② 软状态——订单"待支付/待发货"等中间态、库存"预扣/确认";③ 最终一致——本地消息表+重试、RocketMQ 事务消息、T+1 全量对账兜底。
资金链路选 CP(宁可阻塞不可错),展示链路选 AP(宁可旧值不可断),长流程事务选最终一致(BASE + 补偿 + 对账)。不要全局强一致,按业务域分级。
追问 1:CAP 中 P 真的不可选吗?单机系统是不是 CA?
CAP 定理的证明对象是异步分布式系统,不是给任意组件贴标签的通用模型。单机数据库通常有 ACID 和本地可用性,但不建议简单写成“CA”;它没有分布式网络分区这一参与方。分布式系统跨网络,分区不可避免(交换机故障、GC 长停顿、网络抖动),工程上“P 必选”是指必须容忍分区,而非可以选择不要 P。
追问 2:最终一致性的"最终"到底是多久?
无理论上界。工程上通过监控收敛时间(如副本 lag、消息消费延迟)量化。常用手段:Read Repair(读时发现不一致立即修复)、反熵协议(Anti-Entropy,节点间定期 gossip/Merkle Tree 比对修复)、T+1 对账兜底。Spanner 用 TrueTime + Paxos 将收敛控制在承诺的 SLA 内(外部一致性)。
- 误以为可以同时满足 CA:分布式系统中 P 必然存在,CA 是伪命题(除非单机)。
- 把 CAP 当成"任意时刻三选二":实际是无分区时可 CA,分区时才二选一。
- 把 BASE 当万能解:BASE 是 AP 的妥协,核心交易系统强求一致性时滥用最终一致会导致资金差错。
- 忽视 PACELC 中的延迟权衡:即使无分区,强一致副本同步也增加延迟,高 QPS 下延迟放大。
- 把"最终一致"等同于"不需要处理不一致":最终一致仍需补偿、对账、监控机制保证收敛。
OpenTelemetry 语义要分清:
- OTLP:OpenTelemetry 的线协议,支持 Trace、Metrics、Logs 等遥测数据;可使用 gRPC 或 HTTP 传输,但 OTLP 不等于某个后端。
- Collector:官方定位是 vendor-agnostic 的接收、处理和导出管道。它适合协议转换、批量重试、脱敏/过滤和后端解耦,但 Collector 本身不是长期查询存储。
- Java Agent:官方称为 zero-code instrumentation,可挂在 Java 8+ 应用上,对"many popular libraries and frameworks"注入字节码;它只在支持的库、版本和用法上自动生成边界遥测。自定义业务 Span、应用内指标和敏感字段治理仍需要 API/SDK/规则配置。
- 演进时间线:OpenTracing 与 OpenCensus 合并为 OpenTelemetry 是项目起点;OpenTracing/OpenCensus 处于兼容/迁移阶段,新系统不应把它们当作并行演进方向。Three Pillars 是概念模型,OTel 内部是 Resource + Scope + 信号的数据模型。
- 落地指标:除业务 RED 指标外,还要监控导出队列/重试丢弃、Collector 接收与导出失败、采样器命中、Agent/SDK 版本分布、插桩覆盖率和遥测成本。灰度时先影子管道,再逐步切换真实后端。
本质考察分布式事务方案的完整演进链:从强一致(2PC/3PC)到柔性事务(TCC/SAGA/可靠消息)的演进动机、原理、异常处理与选型决策。核心矛盾是"跨节点原子性"与"可用性/性能/业务侵入"的权衡——2PC 用阻塞与锁换强一致,TCC 用业务侵入换无锁最终一致,AT 用 SQL 解析换无侵入,SAGA 用补偿换长事务支持。
2PC(两阶段提交):协调者发起 Prepare(各参与者锁资源)→ Commit/Rollback。两阶段提交思想可追溯到 20 世纪 70 年代数据库研究,X/Open DTP 是后来的工业规范映射,不是证明 2PC 一定诞生于该规范。痛点:同步阻塞、协调者单点、参与者超时后无法凭局部信息决定最终决议。
3PC(三阶段提交):加 CanCommit(试探不锁)+ PreCommit + DoCommit,并用超时降低协调者故障后的无限等待。参与者超时动作依赖所处状态:收到 PreCommit 后通常继续提交;未收到而超时则按中止处理。3PC降低了某些阻塞场景,但仍不能消除网络分区下的不一致,且多一轮 RTT,实际很少用。
TCC(Try-Confirm-Cancel,2007):业务两阶段——Try(预留资源,如冻结额度)→ Confirm(确认)/Cancel(释放)。将数据库锁改为业务层"软锁",无阻塞但要求业务侵入设计三个接口。
Seata AT(Auto Transaction):无侵入的自动补偿事务。一阶段拦截 SQL,解析执行前后镜像,把 undo_log 与业务变更放进同一本地事务,并在本地事务提交前向 TC 注册分支、按表名+主键获取行级全局锁;本地提交后全局锁仍受全局事务约束。二阶段提交异步删除 undo_log;回滚根据 beforeImage 生成反向 SQL 还原数据。具体注册、锁获取与补偿细节随 Seata 版本演进,示例必须按所用版本核对。
SAGA(1987 论文,现代实现如 Seata/Axon):长事务拆分为本地事务序列 T1...Tn,每个 Ti 配补偿 C1...Cn。失败时反向执行补偿。适合长流程(跨多个服务、秒级以上)。
可靠消息最终一致(本地消息表/RocketMQ 事务消息):本地事务+消息表原子写入,异步投递消费端。最轻量,但只适合"最终一致+异步"场景。
1970s
强一致·阻塞"] --> B["3PC
加超时·仍分区不安全"] B --> C["TCC
2007
业务侵入·无锁"] A --> D["Seata AT
2019
无侵入·undo log"] B --> E["SAGA
长事务·补偿"] C --> F["可靠消息
最终一致·异步"] D --> F style A fill:#fee2e2,stroke:#dc2626 style C fill:#fef3c7,stroke:#d97706 style D fill:#dbeafe,stroke:#2563eb style F fill:#dcfce7,stroke:#059669
趋势:① 从"数据库层强一致"到"业务层柔性事务"——XA 几乎被淘汰,TCC/AT/SAGA 主流;② 从"同步阻塞"到"异步补偿"——长事务用 SAGA,短事务用 AT;③ 与消息队列融合——RocketMQ 事务消息、Kafka 事务实现"事务+消息"一体;④ Serverless 下的分布式事务仍是难题(无状态函数间状态协调)。
2PC 流程与缺陷:
TCC 三大异常问题与解法(核心考点):
| 异常 | 场景 | 解法 |
|---|---|---|
| 空回滚 | Try 未到达(网络丢包),Cancel 先到达 | Cancel 前查"事务表"无 Try 记录,则空回滚并插入标记 |
| 悬挂 | Cancel 先执行(空回滚)后,延迟的 Try 才到达并预留资源(资源悬挂) | Try 前查事务表,若已 Cancel 则拒绝 Try |
| 幂等 | Confirm/Cancel 可能被重试 | 事务表记录状态,Confirm/Cancel 前检查状态已执行则直接返回 |
Seata AT 原理:① 一阶段:拦截业务 SQL,解析执行前镜像(beforeImage)与执行后镜像(afterImage),undo_log 与业务变更进入同一本地事务;本地事务提交前向 TC 注册分支事务并申请全局锁(key=表名+主键值);② 二阶段提交:异步批量删除 undo_log;③ 二阶段回滚:根据 beforeImage 生成反向 SQL 还原数据。全局锁保证:分支事务本地提交后、全局事务回滚前,其他 AT 全局事务不能写该行(防脏写)。
SAGA 补偿:T1→T2→T3 成功,若 T4 失败则反向执行 C3→C2→C1。需保证每个补偿 Ci 满足语义补偿(如"扣款"的补偿是"退款"而非"加相同金额",因中间可能有人工操作)。SAGA 不保证隔离性,需业务容忍中间状态可见。
方案多维度对比:
| 方案 | 一致性 | 性能 | 侵入性 | 隔离性 | 适用场景 |
|---|---|---|---|---|---|
| 2PC/XA | 强一致 | 差(阻塞) | 无 | 有(锁) | 同库/异库强一致,几乎淘汰 |
| TCC | 最终一致 | 好 | 高(三接口) | 有(资源预留) | 资金/库存等需隔离的核心业务 |
| Seata AT | 最终一致 | 较好 | 低(注解) | 弱(全局锁) | 无侵入跨库/跨服务事务 |
| SAGA | 最终一致 | 好 | 中(补偿) | 无 | 长流程、多服务编排 |
| 可靠消息 | 最终一致 | 最好 | 中 | 无 | 异步解耦、不要求实时 |
选型决策树:
- 强一致 + 短事务 + 同库 → 本地事务(无需分布式)。
- 跨库/跨服务 + 无侵入 + 中等吞吐 → Seata AT(默认首选,注解接入)。
- 资金/库存 + 需要资源隔离 + 高吞吐 → TCC(侵入换性能与隔离)。
- 长流程(秒级以上)+ 多服务编排 → SAGA(补偿模式)。
- 异步解耦 + 容忍秒级延迟 → 可靠消息(RocketMQ 事务消息/本地消息表)。
Seata AT 落地要点:undo_log 表必须与业务表同库;全局锁在 TC(事务协调器)维护,TC 需高可用(DB 或 Raft 集群);高并发写同一行时全局锁成瓶颈,需拆分热点(如库存按 SKU 分桶)。@GlobalTransactional 修饰入口方法,下游用 @TwoPhaseBusinessAction 或自动分支。
TCC 接口设计示例:
// TCC 三接口:Try 预冻结、Confirm 扣减、Cancel 解冻
@LocalTCC
public interface AccountTccAction {
// Try: 冻结金额,扣减可用余额
@TwoPhaseBusinessAction(name = "deductAccount",
commitMethod = "confirm", rollbackMethod = "cancel")
boolean tryDeduct(BusinessActionContext ctx,
@BusinessActionContextParameter(paramName = "userId") Long userId,
@BusinessActionContextParameter(paramName = "amount") BigDecimal amount);
boolean confirm(BusinessActionContext ctx); // 扣减冻结金额
boolean cancel(BusinessActionContext ctx); // 解冻,恢复可用余额
}
追问 1:Seata AT 的全局锁如何解决脏写?又带来什么问题?
一阶段在本地事务提交前向 TC 注册分支并获取全局锁(key=表+主键),本地锁随本地事务提交释放,但全局锁在全局事务回滚前仍约束其他 AT 分支,防止“分支已提交、全局回滚”期间被其他全局事务覆盖(脏写)。代价:全局锁成性能瓶颈,热点行高并发写退化;且全局锁与本地锁解耦后,非 AT 事务(如直接 SQL)不受全局锁约束,可能写入被 AT 回滚的数据——需保证同一行的相关写入都走 AT 或另行纳入冲突控制。
追问 2:TCC 的 Confirm/Cancel 失败怎么办?
TC 会不断重试 Confirm/Cancel(默认有重试次数与间隔)。因 Confirm/Cancel 必须幂等,重试安全。若重试耗尽仍失败,需人工介入或告警。TCC 框架(Seata/Hmily)内置重试与事务表状态机。
追问 3:SAGA 的补偿顺序与并发问题?
补偿必须严格反向(Cn→...→C1)。SAGA 不保证隔离性,中间状态可能被其他事务读到(脏读),业务需容忍或用"语义锁"(在业务表加状态字段标记进行中)。前向恢复(重试 Ti 而非补偿)适合 Ti 最终可成功的场景。
- 误用 2PC 做微服务事务:跨服务 XA 性能极差且协调者单点,微服务场景应弃用。
- 忽视 TCC 三大异常:空回滚/悬挂/幂等必须处理,否则资金会错。
- AT 当强一致用:AT 是最终一致,读延迟可能读到中间状态,需结合业务。
- AT 全局锁未做热点拆分:秒杀扣库存用 AT 会因全局锁串行化崩溃,应 TCC + 分桶。
- SAGA 补偿设计为"反向数值操作":补偿应是"业务语义反转"(退款而非加钱),否则中间人工介入会出错。
- 可靠消息忽视消费幂等:消息重投递会导致重复消费,消费端必须幂等。
截至 2026-08-30 复核:Apache Seata GitHub release 元数据显示
v2.6.0、发布时间 2026-01-28、prerelease=false;2.x 源码样例 application.raft.example.yml 包含 server.raft 和 store.mode: raft。该证据只说明相关版本线提供 Raft 配置方向,不等同于“成熟默认/GA 推荐”;上线前应核对目标版本 release notes、兼容矩阵、存储与恢复行为,并完成压测和演练。
对账与最终一致性值班(并入原 C09.18):最终一致系统的值班核心不是「绝对一致」,而是差异可发现、可分级、可补偿。无对账的异步链路等于赌运气。差异分级:短暂延迟、可自动补偿、需人工、资金/库存红线。值班顺序:①对账任务是否跑/是否超窗 ②按金额/笔数分级 ③可自动走补偿,红线冻结写路径 ④核对 Outbox/位点与状态机。指标:差异笔数、补偿成功率、超窗未处理数。失败模式:只投递无对账;补偿不幂等;把延迟当成功。
本质考察分布式锁的底层机制、正确性边界、性能权衡。不要只背 CP/AP:Redis 常见主从异步配置在切换时可能丢锁,ZK/etcd 用多数派和 session/lease 提供更强协调保证,数据库依赖本地事务和唯一约束;每种实现都仍需处理进程暂停、时钟/租约、重连和业务幂等。理解 RedLock 争议(antirez vs Martin Kleppmann)是关键,它揭示了"非拜占庭故障下锁的正确性仍受 GC/时钟影响"。
数据库唯一索引(最早):利用唯一索引/for update 行锁实现互斥。简单可靠,但性能差、锁超时难控制、DB 压力大。
Redis setnx(高性能):SET key value NX PX timeout
原子加锁+过期。问题:单点故障丢锁、主从异步复制切换丢锁、超时后误删他人锁(需 value 唯一标识 + Lua 校验删除)。
RedLock(2014,antirez 提出):向 N(奇数)个独立 Redis 节点申请锁,多数(N/2+1)成功才算加锁成功。试图解决单 Redis 主从切换丢锁。但 Martin Kleppmann 2016 质疑:① 客户端 GC pause 期间锁过期,他人获锁,导致两客户端持锁;② 依赖系统时钟,时钟跳变破坏正确性。antirez 反驳:实践中 GC 与时钟问题可控。该结论应放到所用 Redis/Redisson 版本和故障模型下评估,不能写成跨时间的固定工业共识。
Redisson(生产常用):封装 Lua 加锁(Hash 结构记录可重入计数)+ WatchDog 看门狗自动续期(默认 30s,每 10s 续期)。历史 RedLock 封装
RedissonRedLock
在较新版本已标记废弃;引入任何多数派锁都要核对版本语义。
ZK 临时顺序节点(CP):创建临时顺序节点,最小序号获锁,其余 watch 前一个节点。强一致(ZAB 协议)、客户端宕机自动释放(临时节点 session 失效)。改进:从"watch 所有节点"(惊群)→"watch 前一个节点"(公平锁,避免惊群)。
趋势:① 高性能场景评估 Redisson,接受极端情况锁边界并用业务兜底;② 正确性敏感的协调评估 ZK/etcd(多数派 + session/lease),仍要唯一约束/状态机或资源端 fencing;③ etcd 在 K8s 生态更常见,但“云原生锁首选”取决于团队运维、协议和故障模型;④ RedLock 争议后,很多团队更倾向按正确性需求选协调服务或按性能需求选 Redis,而非默认折中。
Redis 锁的正确加解锁:
// 加锁: NX 唯一 + PX 过期, value 用唯一 requestId 防误删
String requestId = UUID.randomUUID().toString();
Boolean ok = redis.set(lockKey, requestId, "NX", "PX", 30000);
// 解锁: 必须用 Lua 保证"判断+删除"原子,避免超时后误删他人锁
String lua =
"if redis.call('get',KEYS[1])==ARGV[1] then " +
" return redis.call('del',KEYS[1]) " +
"else return 0 end";
redis.eval(lua, Collections.singletonList(lockKey),
Collections.singletonList(requestId));
ZK 公平锁流程:
/lock/node-00000001"] --> B["获取 /lock 下所有子节点排序"] B --> C{"自己是最小序号?"} C -->|是| D["获得锁,执行业务"] C -->|否| E["watch 前一个节点
(避免惊群)"] E --> F{"前一个节点删除?"} F -->|是| D D --> G["业务完成,删除自己的节点"] G --> H["后继 watch 者被唤醒"] style D fill:#dcfce7,stroke:#059669 style E fill:#dbeafe,stroke:#2563eb
三种方案对比:
| 维度 | Redis(Redisson) | ZooKeeper | 数据库 |
|---|---|---|---|
| 一致性 | 常见异步主从配置偏 AP,切换可能丢锁 | ZAB 多数派,偏 CP | 单库事务强一致 |
| 性能 | 极高(内存,万级 QPS) | 中(需集群写 quorum) | 低(DB IO) |
| 可靠性 | 中(极端丢锁) | 高(CP + 临时节点自动释放) | 高(但 DB 单点) |
| 锁释放 | 过期+主动删 | 客户端 session 失效自动删 | 事务提交/回滚释放 |
| 公平性 | 非公平(默认) | 公平(顺序节点) | for update 非公平 |
| 实现复杂度 | 中(需 Lua+续期) | 高(需处理连接中断/重连) | 低(SQL) |
| 适用 | 高并发、容忍极端丢锁 | 强一致、金融级 | 低并发、简单场景 |
选型建议:① 互联网高并发(如防重复提交、秒杀限流)→ Redisson,接受极端情况丢锁,业务做幂等和唯一约束兜底;② 金融/配置/选主 → 先落库约束/状态机,再按版本与故障模型评估 ZK/etcd;③ 简单防重、并发低 → 数据库唯一索引,无需引入中间件。
Redisson 锁核心特性:可重入(Hash 结构 key=锁名 field=线程ID value=计数)、看门狗续期(lockWatchdogTimeout 默认 30s,每 1/3 时间续期)、公平锁与读写锁等;历史 RedLock 封装 RedissonRedLock 已标记废弃,按所用版本确认替代能力。
分布式锁无法 100% 可靠(GC/网络/时钟)。锁只是"减少并发",业务必须做幂等与状态校验兜底。如扣库存:获锁后仍要 SQL where stock>=n 原子扣减,不能只依赖锁。
追问 1:RedLock 争议的本质是什么?
本质是"非拜占庭故障模型下,锁的正确性是否依赖时钟与进程暂停"。Kleppmann 指出:客户端 GC pause 期间锁过期,恢复后仍以为持锁,与后获锁者并发;且 RedLock 依赖各节点时钟一致,NTP 时钟跳变会破坏 TTL 计算。antirez 讨论 fencing token(单调递增令牌)作为缓解手段;关键在资源端必须比较单调 token 后才执行,而不是把 Redis 锁或 ZK/etcd 直接当资源访问授权。结论:正确性要求极高时,先以唯一约束/状态机兜底,再按版本与故障模型选协调系统;容忍极端丢锁时,单 Redis 加业务兜底常比 RedLock 更简单。
追问 2:ZK 锁的"羊群效应"如何避免?
早期实现所有等待者 watch 最小节点,锁释放时全部被唤醒争抢(惊群)。改进为 watch 前一个节点(顺序节点),每个节点只在前驱删除时被唤醒,形成链式通知。Curator 的 InterProcessMutex 即此实现。
- setnx + expire 非原子:两步之间宕机会死锁,必须用 SET NX PX 一条命令。
- 解锁不校验 value 直接 del:超时后删了别人加的锁,必须 Lua 判断 requestId。
- Redis 主从切换丢锁不自知:主节点加锁未同步到从即宕机,从晋升后锁丢失。
- 锁无续期,业务超时:长任务需看门狗续期,否则锁过期被他人抢占。
- 误以为分布式锁绝对可靠:锁+幂等才是完整方案,单靠锁无法防所有并发。
- ZK 用 watch 全部子节点:惊群效应,应 watch 前驱。
Etcd 锁(云原生第三极,lease+revision CAS):etcd v3 基于 Raft,所有写需多数派确认。锁语义 = put 带 Lease 租约(到期自动释放)+ 利用 revision 全局版本号做 Txn 原子 CAS;释放锁必须带 revision 做 txn 比较,不能直接 delete,否则误删他人锁。K8s 控制器选主即 etcd 锁。Java 侧无官方开箱锁类,需基于 jetcd 的 Lease + Txn 自行封装(Curator 的 InterProcessMutex 则是 ZK 侧即用实现:临时有序节点 + watch 前驱 + 可重入 + 会话重连,生产应直接用 curator-recipes,勿手写 zk api)。
三种锁的"底层共识"差异(高频考点):Redis 锁无内置共识(单主从异步复制、Redlock 只是业务层拼凑,非强一致系统);ZK 锁基于 ZAB(CFT 崩溃容错);Etcd 锁基于 Raft(CFT 崩溃容错)。三者性能:Redis 最高(内存)、ZK/Etcd 中等(写需多数派确认)。
ZAB/Raft 都是 CFT(崩溃容错),不是 BFT(拜占庭容错):它们假设节点只会宕机、不会撒谎/篡改消息;一旦出现恶意节点返回假数据,Raft/ZAB 无法识别欺骗。即 ZK/Etcd 分布式锁只能防机器宕机,不能防节点作恶篡改。Redis 连共识都没有,既不是 CFT 也不是 BFT。业务分布式锁几乎不需要拜占庭容错(BFT 性能差、QPS 低,多用于区块链/联盟链);若真要抗恶意节点,需用 PBFT/HotStuff 这类 BFT 共识(节点 ≥ 3f+1)。本视角与 C09.10 拜占庭将军与共识算法 呼应。
Kleppmann 对 Redlock 的 4 点批判(补充 追问1):① 系统时钟跳跃:TTL 基于墙钟,NTP 校时跳变会让 TTL 提前过期,两客户端同时持锁(最核心);② 进程 STW:GC 长暂停期间锁 TTL 到期,恢复后旧客户端继续执行业务造成并发(所有分布式锁共有的"锁 TTL 须远大于业务执行时间"约束);③ 网络延迟:请求过半节点的耗时 + 网络延迟已消耗大半锁窗口,可用窗口极小;④ 运维成本极高:需 5 个完全独立、互不通信的 Redis 实例,监控/扩容/故障恢复复杂,生产极少落地。结论:需严格互斥改 ZK/Etcd;可接受极小概率失效则单 Redis + 业务兜底,比 RedLock 更简单。
租约/TTL 时钟语义补充:Redis TTL 完全依赖服务端系统墙钟,NTP 跳变即乱;Etcd Lease 也受墙钟影响,但一致性与写复制靠 Raft 逻辑 revision,不靠租约时间,租约只负责锁过期释放;ZK 无 TTL,靠客户端 Session 心跳(由 ZK 集群维护会话状态),不受墙钟影响。三者中 ZK/Etcd 的锁释放不依赖"业务侧时钟",更抗时钟漂移。
本质考察 Kafka 三大特性的底层机制:高吞吐(分区并行+顺序写+零拷贝+页缓存)、可靠投递(ISR+acks+幂等+事务)、顺序性(分区级有序)。核心是理解 Kafka"分布式提交日志"的设计哲学——用追加写+顺序读替代随机读写,用副本与 ACK 协议在性能与可靠性间权衡。
ActiveMQ(2004,JMS 规范):传统消息中间件,支持持久化但基于 Queue 的"推/拉"模型,单机吞吐万级。痛点:Master-Slave 主备切换弱、消息堆积性能下降、路由灵活但复杂。(新项目使用率下降:较早期 MQ 方案,当前主流替代为 Kafka / RocketMQ / Pulsar,此处仅作为 MQ 演进历史对比保留)
RabbitMQ(2007,AMQP):Erlang 实现,灵活的 Exchange-Queue-Binding 路由模型,低延迟。痛点:吞吐仍受限(万级~十万级)、消息堆积后性能急剧下降、集群镜像队列开销大。
Kafka(2010,LinkedIn):颠覆性设计——分布式提交日志(Partition+Segment 追加写)、零拷贝、页缓存、消费者 pull 模型、副本 ISR。吞吐百万级,消息堆积不影响性能(顺序读盘)。从"消息队列"重新定义为"分布式流平台"。
Kafka 版本演进:① 0.11(2017):幂等生产者 + 事务,实现 Kafka 内部 Exactly-Once;② 2.4(2019):Incremental Cooperative Rebalance 增量协议,只撤销/分配变化的分区,减少 rebalance 迁移量;③ 2.8(2021):KRaft 模式预览(Kafka Raft Metadata Mode),开始"去 ZooKeeper 化";④ 3.3(2022):KRaft 模式生产可用(GA),新集群推荐直接使用 KRaft,ZK 模式进入迁移路径;⑤ 3.5~3.7(2023-2024):ZK→KRaft 迁移与分层存储(Tiered Storage,KIP-405)能力逐步落地;3.7 支持动态 KRaft 仲裁增删 Controller 节点;⑥ 3.9:ZooKeeper 模式的最后支持版本,也是升级 Kafka 4.0 前的迁移中间站;⑦ 4.0(2025-03):彻底移除 ZooKeeper 依赖,KRaft 成为唯一元数据管理方案,KIP-848 新消费组协议 GA(大幅缩短 rebalance 停顿),KIP-932 队列语义 Early Access(支持点对点消息模型),Broker 要求 Java 17、Client/Streams Java 11,MirrorMaker 1 移除。分层存储要点:本地盘保留热段,冷段迁对象存储以降本;读冷数据有额外延迟,需按保留策略/读放大评估,不是"无限保留无代价"。
Pulsar(2016,Yahoo):计算存储分离——Broker 无状态(计算)、BookKeeper 分层存储(持久化)。原生多租户、跨地域复制、存算独立扩缩。弥补 Kafka 的存储与计算耦合、Rebalance 慢、多租户弱等痛点。
趋势:① 从"消息队列"到"流平台"再到"事件流基础设施"——Kafka Streams/KSQL 流处理、Schema Registry;② 存算分离(Pulsar/Redpanda)成为新架构方向;③ 云原生 MQ——Serverless Kafka(Confluent Cloud/阿里云 MSK)、按需扩缩;④ 流批一体——Kafka 替代部分数据仓库 ETL 链路;⑤ Kafka 4.0(2025-03)彻底移除 ZooKeeper,KRaft 成为唯一元数据管理方案,KIP-848 新消费组协议大幅缩短 rebalance 停顿。
高吞吐四大机制:
- 分区并行:Topic 分多 Partition,分布在不同 Broker,生产/消费并行。吞吐 ∝ 分区数 × 单分区吞吐。
- 顺序写磁盘:消息追加到 Segment 文件末尾,磁盘顺序写速度接近内存(600MB/s vs 随机写 100KB/s)。
- 页缓存(Page Cache):生产者写到 OS Page Cache 即返回,由 OS 异步刷盘;消费者从 Page Cache 命中读取,避免 JVM 堆内存拷贝。Kafka 进程崩溃数据仍在 Page Cache。
- 零拷贝(sendfile):消费者读消息时,数据从 Page Cache 直接经 DMA 传到网卡,跳过用户态拷贝(传统 read+write 需 4 次拷贝 4 次上下文切换,sendfile 仅 2 次拷贝 2 次切换)。
可靠投递:ISR 副本机制:
(in ISR)"] L -->|"同步复制"| F2["Follower2
(in ISR)"] L -.->|"落后,移出ISR"| F3["Follower3
(out of ISR)"] F1 -->|"HW=LEO取小"| HW["High Watermark
消费可见位置"] F2 --> HW style L fill:#dbeafe,stroke:#2563eb style F1 fill:#dcfce7,stroke:#059669 style F2 fill:#dcfce7,stroke:#059669 style F3 fill:#fee2e2,stroke:#dc2626
ISR(In-Sync Replicas)= 与 Leader 保持同步的副本集合。落后超过
replica.lag.time.max.ms 的副本被移出 ISR。HW(High Watermark)= ISR 中最小 LEO(Log End Offset),只有 HW 以下的消息对消费者可见,保证消费到的消息都已同步。LEO
= 每个副本的最新写入位置。
acks 与可靠性:
| acks | 含义 | 可靠性 | 吞吐 |
|---|---|---|---|
| 0 | 不等待任何确认 | 最低(可能丢) | 最高 |
| 1 | Leader 写入即返回(不等 follower) | 中(Leader 宕机丢未同步) | 高 |
| all/-1 | ISR 全部同步才返回 | 最高(配合 min.insync.replicas) | 中 |
幂等生产者(0.11+):Producer 分配 PID(Producer ID),每条消息带 sequence number。Broker 按 (PID, partition, seq) 去重,解决重试导致的重复。enable.idempotence=true。
事务(0.11+):跨分区原子写——生产者必须配置稳定的 transactional.id,调用 initTransactions() 初始化,写多分区后在 beginTransaction() / commitTransaction() / abortTransaction() 边界内提交或中止;消费者用 isolation.level=read_committed 只读已提交结果。事务协议配合 producer epoch/fence 防僵尸实例。它用于"消费-处理-生产"的 Kafka 内部原子边界,不是外部数据库事务。
顺序性:Kafka 保证单 Partition 内消息有序(追加写顺序),不保证 Topic 多 Partition 全局有序。需全局有序则单分区(牺牲并行)。生产者按 key 路由分区,保证同 key 消息进同分区有序。
生产高可靠配置:
# Producer
acks=all
retries=2147483647 # 重试上限,配合幂等
enable.idempotence=true # 幂等生产者
max.in.flight.requests.per.connection=5 # 幂等下可保序
# Broker Topic
replication.factor=3 # 3 副本
min.insync.replicas=2 # 至少2副本同步才算成功
unclean.leader.election.enable=false # 禁止非ISR副本当Leader,防丢消息
事务生产者可编译骨架:
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "order-tx-producer-01");
try (KafkaProducer<String, String> producer = new KafkaProducer<>(props)) {
producer.initTransactions();
try {
producer.beginTransaction();
producer.send(new ProducerRecord<>("orders", orderId, payload), metadata -> {
if (metadata.exception() != null) throw new KafkaException(metadata.exception());
});
producer.commitTransaction();
} catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException fatal) {
producer.close(Duration.ofSeconds(5)); // 实例被更新 epoch 或授权失效,重建生产者
throw fatal;
} catch (KafkaException retryable) {
producer.abortTransaction(); // 可按业务决定重试整个事务边界
throw retryable;
}
}
消费侧处理该事务输出时,显式设置
isolation.level=read_committed;消费-DB-生产链路仍要用数据库事务或幂等表把外部效果与 offset/事件状态绑定,不能假设 Kafka 事务覆盖数据库。
分区设计:分区数 = max(期望吞吐/单分区吞吐, 消费者并发数)。单分区吞吐约 10MB/s,百万 QPS 消息(1KB)约需 100 分区。分区数不宜过多( Broker 元数据开销、rebalance 慢)。
顺序消费:同一业务 key(如 orderId)路由到同分区,消费者单线程消费该分区保证顺序。需多消费者并行则按 key 哈希分区分组。
追问 1:Kafka 的 Exactly-Once 如何实现?真的不丢不重吗?
Kafka 的 Exactly-Once 限定在"Kafka 内部"——生产端幂等+事务保证跨分区原子写、消费端 read_committed 只读已提交。但"消费-处理外部系统(如DB)-生产Kafka"的端到端 Exactly-Once 需配合事务外系统(如 Kafka Connect 的 sink 任务用 DB 事务 + offset 提交原子化)。跨外部系统的端到端 Exactly-Once 难以保证,常退化为 At-Least-Once + 消费幂等。
追问 2:消费者 rebalance 为何慢?如何优化?
rebalance 需协调所有消费者重新分配分区,期间消费暂停(Stop-The-World)。优化:① 增大 session.timeout.ms 与 max.poll.interval.ms 避免误判离线;② 减少分区数;③ 用 StickyAssignor(粘性分配)减少迁移量;④ Kafka 2.4+ Incremental Cooperative Rebalance 增量协议,只撤销/分配变化的分区;⑤ Kafka 4.0 KIP-848 新消费组协议 GA,将 rebalance 协调从 Broker 侧移到 Consumer 侧,大幅缩短停顿时间。Pulsar 用"独占订阅+Broker 端游标"避免 rebalance。
追问 3:KRaft 模式如何取代 ZooKeeper?迁移路径与影响?
KRaft(Kafka Raft Metadata Mode)用 Kafka 自身的 Raft 共识协议替代 ZooKeeper 管理集群元数据(Broker 注册、Topic/Partition 分配、ISR 状态等)。架构上设 Controller Quorum(3~5 节点推荐),Controller 节点以 Raft 维护一份统一的元数据日志(__cluster_metadata
topic),所有 Broker 从 Leader Controller 同步元数据。核心优势:①
部署简化——无需独立 ZK 集群,减少组件与运维链路;② 扩展性提升——ZK 元数据成为超大规模集群瓶颈(百万级 Partition 时 ZK 成瓶颈),KRaft 元数据日志原生支持更高 Partition 数;③ 启动更快——Controller 可秒级选举,ZK 重新选主需数十秒。迁移路径:3.3~3.9 ZK 模式集群需先用内置迁移工具转为 KRaft 模式,再升级 4.0;直接从 ZK 模式升级 4.0 不可行。4.0 要求 Broker Java 17、Client/Streams Java 11,MirrorMaker 1 已移除需迁移到 MM2。
- 误以为 Kafka 全局有序:只有单分区有序,多分区无序。
- acks=1 + unclean.leader.election=true:非 ISR 副本当 Leader 会丢未同步消息。
- min.insync.replicas 设为 1:acks=all 形同虚设,Leader 单副本同步即返回。
- retries 无限 + 无幂等:重试导致消息乱序与重复。
- 消费者自动提交 offset:enable.auto.commit 可能丢消息(先提交后处理失败)或重复(处理后提交前宕机),关键业务用手动提交。
- 忽视 Page Cache 依赖:Kafka 高吞吐依赖 OS Page Cache,与其他内存密集型服务混部署会争抢缓存。
- 忽视 KRaft 迁移窗口期:Kafka 4.0 完全移除 ZK 模式,ZK 集群必须先迁移到 KRaft 再升级 4.0,直接升级会启动失败;MirrorMaker 1 在 4.0 中已移除,需迁移到 MirrorMaker 2。
解决什么:本地盘成本随保留时长线性上涨;Tiered Storage(KIP-405)把冷段迁到对象存储,Broker 本地保留热数据与索引。
何时启用:长保留(天~周以上)、读冷比例可控、可接受冷读延迟;审计/回溯类 Topic 优先。
工程清单:① 对象存储权限与跨 AZ 成本;② 冷读放大与 Fetch 超时调参;③ 与压缩/紧凑 Topic 策略兼容性;④ 监控本地 vs 远端命中率;⑤ 升级路径确认所用小版本 Tiered 是否 GA。
不是银弹:超高频随机冷读、强实时全量重放场景,分层可能拖垮尾延迟——此时缩短保留或独立冷数据管道更合适。
上线门禁:一致性承诺书面化、超时重试预算、补偿可审计。表达提纲:模型选型→失败模式→观测与演练。
先给可观测证据与门禁,再谈实现细节;避免只报组件名。
本质考察线上消息积压的紧急处理方法论:止血 → 定位根因 → 补偿回溯 → 预防。核心是理解积压的本质"消费速率 < 生产速率"持续累积;止血时优先提升消费速率或降低生产速率,确需跳过堆积前必须先记录/转存旧区间,再移动消费位点,并受分区数(Kafka)与消费组机制约束。
早期:单队列人工排查。ActiveMQ(新项目使用率下降:较早期 MQ 方案,当前主流替代为 Kafka / RocketMQ / Pulsar)/RabbitMQ 时代靠管理台看 Queue 深度,手动重启消费者,处理粗放。
中期:消费组扩容 + 监控告警。Kafka Consumer Group 模型成熟后,通过增加消费者实例提升并行度;Prometheus + kafka_exporter 采集 consumer lag 告警。
现代:临时扩容 + 跳过堆积 + binlog 回溯。大规模积压时,"扩容受分区数限制"成为瓶颈,催生"跳过堆积到最新 + 异步 binlog/MQ 回溯重建"的紧急方案;Flink/Kafka Streams 流处理引入背压机制从源头限流。
积压数学模型:积压量 = ∫(生产速率 − 消费速率)dt。消费速率 = min(消费者数, 分区数) × 单消费者 TPS。Kafka 中消费者数 > 分区数时多余消费者空闲,扩容上限 = 分区数。
紧急处理三步法:
告警触发"] --> B{"止血决策"} B -->|"消费慢,分区数充足"| C["扩消费组实例至=分区数"] B -->|"分区数不足,扩容无效"| D["临时方案:跳过堆积"] B -->|"生产端暴增"| E["降级生产:限流/暂停非核心"] C --> F{"消费仍追不上?"} F -->|"是"| D F -->|"否"| G["等待消化,监控收敛"] D --> H["1、记录当前待处理区间并转存
2、实时组切到最新offset
3、异步限速回放补偿"] H --> I["业务恢复后,离线回溯补偿"] E --> G style D fill:#fee2e2,stroke:#dc2626 style C fill:#dbeafe,stroke:#2563eb
关键约束:Kafka 一个分区在消费组内同一时刻只分配给一个消费者,故消费者实例数 ≤ 分区数才有意义。若分区数=8,扩到 16 个消费者只有 8 个工作。Kafka 支持在线增加分区,但不支持减少分区;扩分区只影响后续消息路由,不能重排已写入数据,且同 key 顺序可能因分区数变化被打断。此时可考虑:① 在发布窗口评估增加分区;② 单消费者多线程(破坏分区顺序,需业务侧保证 key 顺序);③ 先转存再跳过堆积后回溯。
步骤 1:止血扩容(分区数充足时)——快速增加消费者实例到分区数上限:
# 1. 查看积压: 消费组各分区 lag
kafka-consumer-groups --bootstrap-server $BS \
--describe --group order-consumer-group
# 2. 若分区数=16, 当前消费者=4, 扩到16个实例
# K8s: kubectl scale deploy order-consumer --replicas=16
# 3. 确认 rebalance 完成, lag 开始下降
步骤 2:跳过堆积(分区数不足、消费追不上时)——保护实时业务,积压转存后慢速回溯:
# 1. 先记录消费组当前 offset 与日志结束 offset
# 启动转存消费者消费该区间, 写入备份Topic/补偿表
# 备份Topic分区数按回溯吞吐规划, 并保留原事件顺序映射
# 2. 转存位点确认后, 再把实时消费组 seekToEnd/latest
# 或使用新的消费组消费后续消息
# 3. 实时业务恢复后, 启动'回溯消费者'限速消费备份Topic
# 限速回放, 不冲击实时链路, 业务做幂等
步骤 3:根因排查——常见根因:① 消费者处理慢(慢 SQL、外部接口超时);② 消费者 OOM/频繁 FullGC;③ 消费者线程数配置错误;④ 生产端突发流量;⑤ 消费者 bug 抛异常导致持续重试(毒药消息)。
步骤 4:预防——① 监控 consumer lag 告警(Prometheus + kafka_exporter);② 容量规划(峰值生产速率 × 安全系数 ≤ 消费容量);③ 消费幂等(防重试重复);④ 死信队列(毒药消息隔离);⑤ 限流降级(Sentinel 保护消费端)。
追问 1:单消费者多线程消费如何保证顺序?
消费者拉取后按 key 哈希分发到固定线程(内存队列),同 key 进同线程顺序处理。注意:多线程下 offset 提交复杂(不能按拉取批次提交,需跟踪每条处理完成),通常用"处理完才提交 offset"或"按分区最小已处理 offset 提交"。破坏了 Kafka 原生分区顺序保证,需谨慎。
追问 2:跳过堆积后如何回溯不丢数据?
先确定当前消费组 offset 与 latest 的区间并完成转存确认,再把实时消费组移动到最新 offset。回溯时从备份 Topic 消费,业务用事件 ID/业务唯一键做幂等(因实时链路可能已处理相邻消息)。若数据源有 binlog(MySQL),也可用 Canal/Debezium 按时间段与主键顺序重建状态,但必须处理 CDC 重放和乱序。
- 扩容超过分区数:多余消费者空闲,扩容无效。
- 跳过堆积不转存:直接 seekToEnd 会永久丢失积压消息。
- 回溯不幂等:实时链路已处理部分消息,回溯重复消费导致脏数据。
- 忽视毒药消息:单条消息持续抛异常导致消费者卡死,需死信队列 + 跳过策略。
- 只止血不查根因:扩容缓解后不排查根因,下次复发更严重。
- 回溯冲击实时链路:回溯消费未限速,与实时消费争抢资源二次积压。
本质考察分布式唯一 ID 生成的核心需求(全局唯一、趋势递增、高性能、高可用)与各方案权衡。核心矛盾是"无序(UUID)导致 B+ 树页分裂"与"有序(雪花/号段)依赖外部协调"的权衡,以及雪花算法的时钟回拨这一经典难题。
UUID(RFC 4122):本地生成、无协调;在生成器正确实现和不考虑密码学碰撞攻击的常规前提下,碰撞概率工程上可忽略。痛点:多数版本无序(作主键易造成 B+ 树页分裂与写放大)、长度长(128bit)、无业务语义。
雪花算法 Snowflake(Twitter 2010):常见实现采用 64 位布局 = 1 符号 + 41 毫秒时间戳 + 10 机器ID + 12 序列;具体位宽、epoch 和分配方式是实现约定。趋势递增、本地生成、单节点理论吞吐约 4096/ms。痛点:依赖时钟、时钟回拨处理不当可能重复 ID。
号段模式 Leaf-Segment(美团 2017):DB 存号段上限,应用批量取号(如每次取 1000),本地发号。双 buffer 预加载:当前 buffer 用到 10% 时异步加载下一个,无缝切换。性能高、趋势递增、无时钟依赖。
Leaf 双模式(美团):Leaf-Segment(号段,DB 依赖)+ Leaf-Snowflake(雪花,ZK 协调机器 ID + 时钟回拨处理)。两种模式按场景选择。
Tinyid(百度):号段模式改进,支持多 DB、号段生成可分离为独立服务。
趋势:① 主流是"雪花算法 + 时钟回拨处理"或"号段双 buffer";② 云原生下用 etcd/ZK 动态分配 workerId 替代静态配置;③ 业务 ID 趋向"趋势递增 + 可读性"(如含时间戳的有序 ID)。
Snowflake 64 位结构:
符号位
(0)"] --- T["41 bit
时间戳(ms)
约69年"] --- W["10 bit
机器ID
1024节点"] --- SEQ["12 bit
序列号
4096/ms"] end style T fill:#dbeafe,stroke:#2563eb style W fill:#fef3c7,stroke:#d97706 style SEQ fill:#dcfce7,stroke:#059669
同一毫秒内序列号自增,同机器同毫秒最多 4096 个 ID。时间戳使 ID 趋势递增(B+ 树顺序插入,无页分裂)。
时钟回拨问题与解法:系统时钟回拨(NTP 同步、手动调整)后,时间戳变小,与已生成时间戳重叠时可能重复。解法:① 等待追平历史最大时间戳(阈值按业务和时钟管理策略设定,如 5ms 只是示例);② 超过阈值报错拒绝、切换实例或使用实现明确保证唯一性的扩展方案;③ 持久化 lastTimestamp/workerId(Leaf-Snowflake 用 ZK 记录,回拨时报警并按预设策略处理)。
Leaf-Segment 双 buffer 原理:
// DB 表: leaf_alloc(biz_tag, max_id, step, version)
// 应用内存维护两个 buffer: current, next
// 1. 启动: 从 DB 取号段 [max_id+1, max_id+step] 存入 current
// DB: UPDATE leaf_alloc SET max_id=max_id+step WHERE biz_tag=?
// 2. 发号: current.value++ (本地原子, 无 DB 交互)
// 3. 预加载: 剩余约 10% 时, 异步线程取下一号段存入 next
if (current.used > step * 0.9 && next == null) {
asyncLoadNextSegment(); // 异步, 不阻塞发号
}
// 4. current 用尽: 原子切换 current=next, next=null
方案对比:
| 方案 | 唯一性 | 有序性 | 性能 | 依赖 | 缺点 |
|---|---|---|---|---|---|
| UUID | 正确实现下常规场景唯一 | 多数版本无序 | 极高(本地) | 无 | 作主键随机写、占空间 |
| Snowflake | 全局唯一 | 趋势递增 | 极高(本地) | 时钟 | 时钟回拨 |
| Leaf-Segment | 全局唯一 | 趋势递增 | 高(批量取) | DB | DB 依赖;重启可能丢弃已预分配但未发出的号 |
| Leaf-Snowflake | 全局唯一 | 趋势递增 | 极高 | ZK | ZK 依赖 |
| Redis INCR | 全局唯一 | 严格递增 | 高 | Redis | Redis 依赖、持久化窗口丢号 |
选型建议:① 主键 ID 且高并发 → Snowflake/Leaf-Snowflake(趋势递增、本地高性能);② 业务可接受 DB 依赖、需严格递增(如订单号)→ Leaf-Segment;③ 非主键、纯标识(如 traceId、文件名)→ UUID;④ 已有 Redis 基础设施、低并发 → Redis INCR。
Snowflake 工程要点:workerId 分配用 ZK/etcd 持久化顺序节点(启动注册、宕机回收),避免硬编码冲突;时钟回拨必须处理(Leaf-Snowflake 持久化 lastTimestamp 到 ZK);序列号用位运算保证原子(CAS 或 synchronized)。
MySQL InnoDB 更适合趋势递增且较短的主键;随机 UUID 会增加页分裂、页写放大和二级索引存储成本,具体影响需用目标表结构和写入模型压测。Snowflake 生成的 long 型 ID 常用于该场景,但必须保证 workerId 与时钟回拨安全。
追问 1:雪花算法的 workerId 如何动态分配?
常见方案是 ZK/etcd 注册与持久化已分配 workerId:服务启动申请或校验 workerId,同时写入租约/临时节点;正常下线和异常过期后按实现回收,并保证同一业务集群内不重复。静态配置 workerId 易因部署错误导致冲突。回收策略必须考虑时钟未完全同步或旧实例仍处理请求的窗口。
追问 2:号段模式重启丢号怎么办?
号段模式应用重启会丢失当前未用完的号段(已从 DB max_id 扣除但未发号)。这是"用预分配换性能"的代价,业务需容忍号段级浪费。Leaf-Segment 通过 step 调优(如 step=1000,每次最多丢 1000 号)控制浪费。若号不能丢(如严格递增发票号),退化为 Redis INCR 或 DB 单点取号(性能差)。
- UUID 作 MySQL 主键:无序随机写入易造成页分裂和写放大;具体索引膨胀倍数取决于行长、页填充因子、版本与负载,不能使用固定倍数。
- Snowflake 不处理时钟回拨:NTP 同步回拨导致重复 ID。
- workerId 硬编码且多实例重复:容器化部署易配置错误,必须动态分配。
- 号段 step 设过大:重启丢号多、号段跳跃明显;过小:频繁取 DB。需按业务调优。
- Redis INCR 不开持久化或 AOF everysec:宕机丢已发号段,重启可能重复发号。
- 忽视 ID 安全性:连续 ID 可被遍历(如订单号),需加密或混淆(如 Hashids)。
分布式 ID 选型应以"发号 QPS、时钟回拨安全性、对索引写入性能的影响"量化——号段与雪花的核心是"高并发下不拖慢 DB、不产生重复 ID"。
| 指标 | 压测模板 | 说明 |
|---|---|---|
| Leaf-segment 发号 QPS | 记录单实例 P99/最大 QPS、step、双 buffer 加载耗时与 DB TPS | 号段预取降低 DB 实时交互,但上限受 CPU、锁实现、step 和故障切换策略影响 |
| Leaf-snowflake 协调依赖 | 记录 ZK/etcd 的 workerId 分配延迟、租约超时和 lastTimestamp 存储位置 | 解决 workerId 冲突与时钟回拨,避免重复 ID |
| 时钟回拨处理 | 注入 1ms/5ms/50ms 回拨,验证等待、拒绝和降级路径 | 5ms 只是示例阈值,必须由实现与运维策略明确 |
| UUID 作主键影响 | 同表分别压测 UUID、Snowflake、自增主键的写入 TPS、页分裂、索引体积和 P99 RT | 号段/雪花趋势递增通常对索引更友好,具体差异需目标负载实测 |
| 号段 step 调优 | step=1000 时,实例崩溃最多浪费当前号段中未发出的号 | 按业务 QPS、实例数和可接受浪费调 step,并持久化/审计已分配区间 |
选型核心不是固定 QPS 或固定性能倍数,而是验证唯一性、时钟/workerId 安全、重启浪费边界和主键写入影响。表中数字是压测模板与示例参数;上线结论必须来自目标数据规模、实例数和硬件下的实测数据。
本质考察传统哈希取模的扩容痛点与一致性哈希环 + 虚拟节点的解决原理。核心是理解"扩缩容时数据迁移量"这一指标——传统哈希取模扩容迁移几乎所有数据,一致性哈希只迁移相邻区间数据,虚拟节点解决数据倾斜。
哈希取模 hash(key) % N:简单直接。痛点:节点数 N 从 N 扩到 N+1 时,均匀哈希下只有约 1/(N+1) 的 key 恰好仍映射到原节点,其余约 1 - 1/(N+1) 需要迁移;N=3 时约 75%。
一致性哈希(Consistent Hashing,Karger 1997):将哈希空间组织成 0~2³² 的环,节点与 key 都映射到环上,key 顺时针找到的第一个节点。扩缩容只影响相邻区间的 key,迁移量 ≈ 1/N。痛点:节点少时数据倾斜(节点在环上分布不均)。
虚拟节点(VNode):每个物理节点对应多个虚拟节点,虚拟节点尽量均匀分布在环上,解决倾斜。Ketama 常被 Memcached 客户端使用;Cassandra 等系统也有自己的 vnode/令牌机制。数量需按数据分布、节点数和实现压测确定,不存在通用绝对值。
哈希槽(Redis Cluster):固定 16384 个槽,节点负责槽区间。扩缩容迁移的是槽(离散单元),而非连续区间。本质是"预分配固定槽 + 节点负责槽区间"的折中方案,避免一致性哈希的虚拟节点管理复杂度。
一致性哈希环:
(含150虚拟节点)"] N2["Node B
(含150虚拟节点)"] N3["Node C
(含150虚拟节点)"] K1["key1 → 顺时针→ Node B"] K2["key2 → 顺时针→ Node C"] K3["key3 → 顺时针→ Node A"] end N1 -.->|"扩容Node D
只迁移NodeD到下个节点间key"| N4["Node D"] style N4 fill:#fef3c7,stroke:#d97706
迁移量对比:
| 方案 | 扩容1节点迁移量 | 定位复杂度 |
|---|---|---|
| 哈希取模 hash%N | ≈ 1 - 1/(N+1) | O(1) |
| 一致性哈希(无虚拟节点) | ≈ 1/(N+1) | 平衡树/有序数组 O(log N);完整点表 O(1) |
| 一致性哈希+虚拟节点 | ≈ 1/(N+1),vnode 足够时更均匀 | 平衡树/有序数组 O(log V_total);完整点表 O(1) |
| 哈希槽(Redis) | 迁移部分槽 | O(1) 槽表 |
虚拟节点解决倾斜:少量物理节点的哈希点在环上可能聚集,导致负载不均。每个物理节点映射多个 vnode 后,key 命中 vnode 再映射到物理节点;均匀哈希和足够 vnode 下,各物理节点负责的环区间更均衡,但 vnode 增加会占用内存并影响增删节点时的定位与维护开销。
哈希槽 vs 一致性哈希:Redis Cluster 用固定 16384 槽,CRC16(key) % 16384 算槽,节点负责槽集合。扩缩容按槽迁移。槽号到节点可查表得 O(1);一致性哈希若用平衡树实现则是 O(log V_total),若把全部 vnode 哈希放入有序数组并二分也是 O(log V_total),只有维护完整槽/点表时才是 O(1)。
虚拟节点数配置:150~200 只是一些实现/资料中的经验示例,不是标准。数量应结合物理节点数、key 分布、负载方差、内存和增删节点耗时确定,并通过压测观察各节点 QPS、内存和命中率方差调整。
一致性哈希客户端实现(TreeMap 模拟环):
// 一致性哈希环: TreeMap 按 hash 排序, ceilingEntry 顺时针查找
public class ConsistentHash {
private final TreeMap<Long, String> ring = new TreeMap<>();
private static final int VNODES = 150;
public void addNode(String node) {
for (int i = 0; i < VNODES; i++) {
long hash = hash(node + "#" + i);
ring.put(hash, node);
}
}
public String getNode(String key) {
long hash = hash(key);
Map.Entry<Long, String> e = ring.ceilingEntry(hash);
return e == null ? ring.firstEntry().getValue() : e.getValue();
}
}
Redis Cluster 哈希槽迁移:扩容新节点时,从现有节点迁移部分槽到新节点(redis-trib reshard)。迁移中槽标记 migrating/importing,读写兼容处理。运维需在低峰执行。
追问 1:为什么 Redis Cluster 用 16384 槽而不是更多?
① 心跳报文大小:集群节点间 gossip 交换槽位图,16384 bit = 2KB,可控;若 65536 槽则 8KB,大集群心跳开销大。② 集群规模上限:Redis 作者建议集群节点数 ≤ 1000,16384 槽够用(每节点平均 16 槽)。③ CRC16 取模 16384 性能足够。
追问 2:一致性哈希的"环回绕"如何处理?
key 哈希值在环上顺时针找,若超过最大值(2³²-1)仍无节点,则回到环起点找第一个节点。TreeMap 实现中 ceilingEntry 返回 null 时取 firstEntry。
- 虚拟节点太少:3 节点各 1 虚拟节点,数据严重倾斜。
- 误以为哈希槽就是一致性哈希:哈希槽是预分配固定槽+区间映射,一致性哈希是连续环+顺时针查找,机制不同。
- 忽视扩容迁移期的一致性:迁移期间同一 key 可能新旧节点都查不到,需双写或重定向(Redis Cluster 的 ASK/MOVED)。
- 用哈希取模做缓存扩容:扩容瞬间几乎全部缓存失效,压垮 DB(雪崩)。
- 一致性哈希不配合虚拟节点:节点少时倾斜,某节点过载。
上线门禁:一致性承诺书面化、超时重试预算、补偿可审计。表达提纲:模型选型→失败模式→观测与演练。
先给可观测证据与门禁,再谈实现细节;避免只报组件名。
本质考察幂等性的定义、天然幂等与设计幂等、各方案的适用场景。核心是区分"幂等(重复执行结果相同)"与"防重(阻止重复提交)",并按业务场景(查询/写入/更新/状态流转)选择对应机制。底层逻辑是"用唯一标识 + 状态约束保证重复请求不产生副作用"。
天然幂等:查询(GET)、删除(DELETE 幂等,删一次与多次结果相同)、按绝对值更新(update balance=100)。这些无需额外设计。
唯一索引(DB 层防重):对业务唯一键建唯一索引,重复插入抛 DuplicateKeyException。最简单可靠,适合"创建类"接口。
Token 机制(防重复提交):前端先获取一次性 Token,提交时携带,后端原子校验并置为已用;Token 适合挡住前端重复点击/网络重试,但关键业务仍必须有数据库唯一约束或状态机兜底。
乐观锁 version(并发更新):update ... where version=?,并发更新只有一个成功。解决并发覆盖。
状态机(业务流转):订单状态流转校验,重复请求因状态已变更而拒绝。解决业务状态幂等。
分布式锁(防并发):Redis/ZK 锁,同一业务 key 串行化,缩短并发冲突窗口;锁不是幂等事实来源,锁失效、超时释放或主从切换后仍需唯一约束/状态机。
幂等定义:f(f(x)) = f(x),多次执行结果与一次执行相同。幂等 ≠ 防重:防重是阻止第二次请求到达,幂等是允许第二次请求但结果不变。
各方案原理与适用:
| 方案 | 原理 | 适用场景 | 局限 |
|---|---|---|---|
| 唯一索引 | DB 唯一约束,重复插入失败 | 创建订单、注册、发券 | 仅防新增,需业务唯一键 |
| Token | 前置获取一次性令牌,原子置为已用 | 前端表单提交防重复点击;配合业务唯一约束 | 需额外接口;Redis/内存 Token 不是持久事实来源,失败语义需定义 |
| 乐观锁 version | update where version=?,CAS 更新 | 并发更新(改地址、改库存) | 更新失败需业务重试 |
| 状态机 | 状态流转校验,已变更状态拒绝 | 订单取消、支付回调 | 需明确状态流转图 |
| 分布式锁 | 同 key 串行化 | 防并发操作(如重复提现) | 锁失效需配合其他幂等 |
| 防重表 | 请求唯一键插入防重表,事务内 | 跨服务幂等、MQ 消费幂等 | 额外表、需清理 |
Token 机制流程:
乐观锁与状态机组合(支付回调幂等):
// 支付回调幂等: 状态机 + 乐观锁
@Transactional
public ApiResponse<Void> payCallback(PayNotifyDTO dto) {
// 1. 查询订单当前状态
Order order = orderMapper.selectByOutTradeNo(dto.getOutTradeNo());
if (order == null) throw new BizException("订单不存在");
// 2. 状态机校验: 已支付则直接返回成功(幂等)
if (order.getStatus() == OrderStatus.PAID) {
return ApiResponse.success(); // 重复回调,幂等返回
}
if (order.getStatus() != OrderStatus.UNPAID) {
throw new BizException("订单状态异常");
}
// 3. 乐观锁 CAS 更新: where status=UNPAID 防并发
int n = orderMapper.updateStatusCAS(
order.getId(), OrderStatus.PAID, OrderStatus.UNPAID, order.getVersion());
if (n == 0) throw new BizException("并发更新失败");
// 4. 后续业务(发货、积分)用消息异步,各自幂等
return ApiResponse.success();
}
场景方案选择:
- 创建类(下单、注册) → 业务唯一键唯一索引;Token 只做前置快路径。
- 更新类(改地址、改库存) → 乐观锁 version。
- 回调类(支付回调、MQ 消费) → 状态机 + 唯一索引防重表。
- 并发操作类(重复提现、重复领券) → 分布式锁 + 唯一索引。
MQ 消费端必须幂等(消息至少投递一次)。标准做法:消费时用 msgId 或业务唯一键查"消费记录表/Redis",已消费则跳过;或业务表唯一索引兜底。不要依赖 MQ 的 Exactly-Once,业务幂等才是根本。
追问 1:Token 置为已用后业务失败怎么办?
先分清失败类型。可安全重试的业务:把 Token/幂等记录落库为
PROCESSING/FAILED,失败后允许同 Token 补偿或重试,成功后置为 CONFIRMED;不能确定结果的业务:先查询状态或对账,再决定返回原结果、补偿还是重试。Redis Token 只是快路径:不可用时,关键写接口要么拒绝并要求用户安全重建请求,要么改用数据库幂等键;不要把 Redis 失败静默降级为“直接执行”。已用 Token 按业务完成时间和审计需求清理。
追问 2:乐观锁的 ABA 问题如何影响幂等?
纯 version 乐观锁存在 ABA(A→B→A,version 相同)。业务幂等若依赖"version 不变"可能误判。解决:① version 严格递增不重用(DB 自增);② 状态机+version 组合(状态已变更即拒绝,不依赖 version 单独判断);③ 加时间戳或唯一流水号。实际订单场景用状态机+CAS(where status=?)更安全。
- 唯一索引未覆盖业务唯一键:用自增主键唯一,但业务上可重复创建。
- Token 非原子校验置用:get+del/更新两步并发下可能双通过,必须原子;且不能替代 DB 唯一约束。
- 乐观锁更新失败不重试:并发更新失败应业务重试或返回明确提示。
- 状态机设计不全:遗漏中间状态流转,重复请求误执行。
- MQ 消费不幂等:依赖 MQ Exactly-Once,重投递导致重复扣款。
- 分布式锁当幂等用:锁只防并发,锁失效后仍可重复,需配合唯一索引/状态机。
本题聚焦通用 MQ 可靠性与顺序性;Kafka 专有的 Exactly-Once、事务、分区顺序细节详见 C09.04。本质考察消息从生产到消费全链路的可靠性保障(不丢、不重、有序)。核心是"全链路思维"——生产端(确认+重试+本地消息表/事务消息)→ Broker(持久化+副本)→ 消费端(手动 ACK+幂等+死信),任一环节缺失都会破坏可靠性。顺序性则是"分区/队列级有序 + 单线程消费"的约束。
简单投递(fire-and-forget):生产者发完即忘,无确认。可靠性最低,丢消息无感知。
生产确认 + 重试:Producer 等待 Broker ACK,失败重试。RabbitMQ publisher confirm/return(见工程实践)、Kafka acks(详见 C09.04)。解决生产端丢失。
持久化存储 + 副本:Broker 落盘 + 多副本。Kafka acks=all + min.insync.replicas + ISR(详见 C09.04);RocketMQ 同步复制刷盘;RabbitMQ 4.x 使用 Quorum Queue(Raft 多数派复制;classic queue mirroring 已在 4.0 移除,见工程实践增量)。解决 Broker 故障丢失。
消费手动 ACK:消费者处理完成后才 ACK,未 ACK 的消息重新入队。解决消费端丢失。
死信队列:多次消费失败的消息转入死信队列,避免毒药消息阻塞。RabbitMQ DLX(Dead-Letter Exchange,绑定死信队列自动路由)、Kafka 需自建。
本地消息表 / 事务消息:生产端"本地事务 + 消息表"原子,异步投递;RocketMQ 事务消息(半消息+回查,见工程实践完整示例)。解决"生产端业务与消息发送的原子性"。
Exactly-Once 语义:Kafka 幂等生产者+事务 Exactly-Once(详见 C09.04);RocketMQ/通用方案退化为"At-Least-Once + 消费幂等"(见工程实践通用幂等双写设计)。
可靠投递全链路:
或事务消息"] P2["发送+确认ACK"] P3["失败重试"] end subgraph B["Broker"] B1["持久化落盘"] B2["多副本同步
(ISR/Quorum)"] end subgraph C["消费端"] C1["手动ACK"] C2["消费幂等"] C3["死信队列"] end P1 --> P2 --> P3 --> B1 --> B2 --> C1 --> C2 --> C3 P3 -.->|"重试耗尽"| DL["死信/告警"] C3 -.-> DL style P1 fill:#dbeafe,stroke:#2563eb style B2 fill:#dcfce7,stroke:#059669 style C2 fill:#fef3c7,stroke:#d97706
三段可靠性保障:
- 生产端不丢:① 本地消息表——业务事务与消息表插入原子,后台扫描消息表异步发送,成功后标记已发送;② RocketMQ 事务消息——发送半消息(对消费者不可见)→ 执行本地事务 → 提交/回滚半消息,Broker 回查本地事务状态;③ RabbitMQ confirm/return 双机制(见工程实践代码示例)。解决"业务成功但消息发送失败"或"消息未路由到 queue"。
- Broker 不丢:持久化(同步刷盘或副本同步)+ 多副本。Kafka acks=all + min.insync.replicas 多副本细节(详见 C09.04);RocketMQ 同步复制刷盘;RabbitMQ Quorum Queue 依赖 Raft 多数派,publisher confirm 在消息复制到多数派后返回(见工程实践配置示例)。
- 消费端不丢:手动 ACK(处理完再确认)+ 幂等(防重投递重复,见工程实践通用幂等双写设计)+ 死信队列(毒药消息隔离)。
顺序性保证:
| 有序级别 | 通用机制 | Kafka 实现 | RocketMQ 实现 | 代价 |
|---|---|---|---|---|
| 全局有序 | 单分区/单队列 + 单消费者 | 1 分区 + 1 consumer | 1 queue + MessageListenerOrderly | 无并行,吞吐极低,几乎不用 |
| 分区有序(业务级有序) | 按业务 key 路由同分区/队列 + 单分区单线程消费 | 按 key 路由分区 + 单消费者(详见 C09.04) | MessageQueueSelector 按业务 ID 选队列 + MessageListenerOrderly 顺序消费(见工程实践代码) | 同 key 串行,不同 key 并行,工程实践平衡 |
顺序性破坏场景:① 消费者多线程消费同分区(破坏分区顺序);② rebalance 后分区重分配,旧消费者未处理完新消费者已开始;③ 重试消息进入重试队列延迟后回到原队列,与后续消息乱序。解法:单线程消费分区 + 顺序重试(RocketMQ MessageListenerOrderly 内建顺序重试,失败阻塞当前队列不跳过)。
1. RocketMQ 事务消息(半消息+反查机制):
// RocketMQ 5.x 事务消息: 半消息对消费者不可见
TransactionMQProducer producer = new TransactionMQProducer(group);
producer.setTransactionListener(new TransactionListener() { // RocketMQ 5.3.3 仍是当前事务监听接口; 旧 TransactionCheckListener 才已废弃
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
// 执行本地事务(扣库存等)
try { db.deductStock(); return COMMIT_MESSAGE; }
catch (Exception e) { return ROLLBACK_MESSAGE; }
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
// Broker 回查: 本地事务是否成功
return db.isStockDeducted(msg) ? COMMIT_MESSAGE : ROLLBACK_MESSAGE;
}
});
producer.sendMessageInTransaction(msg, null);
2. RocketMQ 严格顺序(MessageQueueSelector + MessageListenerOrderly):发送时按 orderId 选择同一队列,消费时单线程顺序消费:
// 发送端: MessageQueueSelector 按业务键(orderId)选队列, 保证同 orderId 同 queue
producer.send(msg, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
Long orderId = (Long) arg;
int idx = (int) (orderId % mqs.size()); // 同 orderId 路由同队列
return mqs.get(idx);
}
}, orderId); // arg = orderId
// 消费端: MessageListenerOrderly 内建单线程消费每队列 + 顺序重试(失败阻塞当前队列不跳过)
consumer.registerMessageListener(new MessageListenerOrderly() {
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext ctx) {
for (MessageExt msg : msgs) {
Long orderId = (Long) msg.getProperty("orderId");
orderService.processByOrder(orderId); // 同 orderId 的消息串行处理
}
return ConsumeOrderlyStatus.SUCCESS;
}
});
3. RabbitMQ confirm/return 与 Quorum Queue:
// ========== 生产端 confirm + return 双确认 ==========
ConnectionFactory factory = new ConnectionFactory();
factory.setPublisherConfirmType(ConfirmType.CORRELATED); // 开启 confirm 异步回调
factory.setPublisherReturns(true); // 开启 return(消息未路由到 queue 时回调)
RabbitTemplate template = new RabbitTemplate(factory);
template.setConfirmCallback((correlationData, ack, cause) -> {
if (!ack) { log.error("消息未到 Broker: {}", cause); retry(correlationData); }
});
template.setReturnsCallback(returned -> {
log.error("消息未路由到 queue: exchange={}, routingKey={}, reply={}",
returned.getExchange(), returned.getRoutingKey(), returned.getReplyText());
});
template.setMandatory(true); // mandatory=true 时不可路由消息才触发 return
template.convertAndSend("order.exchange", "order.create", msg,
new CorrelationData(UUID.randomUUID().toString()));
// ========== Broker 副本: Quorum Queue(RabbitMQ 3.8+; 4.x 默认复制队列方向) ==========
# 声明 queue 时设置 x-queue-type=quorum; 队列成员基于 Raft 复制
# classic queue mirroring 已在 RabbitMQ 4.0 移除, 不要用 ha-mode policy 做新方案
# 推荐为 quorum queue 配置 delivery-limit 和死信/监控, 处理毒药消息
4. 通用幂等设计(业务唯一键 + Redis SETNX / DB UNIQUE 双写):Exactly-Once 在端到端(跨外部系统)场景无法绝对保证,工程退化为At-Least-Once + 消费幂等。双保险策略:
// 消费幂等:Redis 标记(快路径) + DB 事务内去重表/唯一约束(事实兜底)
public void onOrderCreated(String eventId, OrderEvent event) {
// 1. 快路径: Redis 原子抢占, 过期时间>业务最大处理时间
Boolean first = redisTemplate.opsForValue()
.setIfAbsent("mq:idempotent:" + eventId, "1", 24, TimeUnit.HOURS);
if (Boolean.FALSE.equals(first)) {
log.warn("重复消费跳过 eventId={}", eventId); // 绝大多数重复请求在此拦截
return;
}
try {
// 2. 执行业务: DB 同事务插入去重表(t_mq_consume_record, event_id UNIQUE 索引)
jdbcTemplate.update("INSERT INTO t_mq_consume_record(event_id, created_at) VALUES (?, NOW())", eventId);
orderService.create(event); // 与去重表插入同一 DB 事务, DB UNIQUE 兜底
} catch (DuplicateKeyException e) {
log.warn("DB 兜底拦截重复消费 eventId={}", eventId); // Redis 故障/过期/误标记时由 DB 兜底; 业务失败需补偿并清理/修正标记
}
}
消费端可靠消费通用检查项:① 关闭自动 ACK,手动 ACK(处理完再确认);② 失败重试有上限(如 3 次),超过转死信队列;③ 死信队列监控告警,人工兜底;④ Kafka 分区有序与 Exactly-Once/事务细节(详见 C09.04)。
追问 1:本地消息表与事务消息的优劣?
本地消息表:业务 DB 额外表,后台扫描发送,优点是与具体 MQ 无关、可审计;缺点是占用业务 DB、需定时扫描、延迟较高。事务消息(RocketMQ):MQ 原生支持,无需业务表,延迟低;缺点是绑定 RocketMQ、回查逻辑需实现。中小系统用本地消息表更通用,RocketMQ 用户用事务消息更优雅。
追问 2:Exactly-Once 真的可靠吗?跨外部系统呢?
Kafka/RocketMQ 的 Exactly-Once 限于 MQ 内部(生产幂等+事务+消费去重)。跨外部系统(如消费 MQ 写 DB)的端到端 Exactly-Once 需"消费+DB 操作"原子(DB 事务内提交 offset,或唯一键去重)。无法绝对保证,工程上退化为 At-Least-Once + 消费幂等。
- 生产端 fire-and-forget:不确认就认为发送成功,网络故障丢消息。
- 消费自动 ACK:先提交 offset 后处理失败,丢消息;必须处理完再 ACK。
- 消费不幂等:重投递导致重复消费,业务出错(重复扣款)。
- rebalance 破坏顺序:分区重分配期间重复消费或乱序,顺序场景需谨慎。
- 毒药消息无死信:单条消息持续失败阻塞队列,必须死信隔离。
- 事务消息不实现回查:Broker 回查无响应,半消息一直不可见。
本质考察分布式共识的理论基础(拜占庭将军问题、FLP 不可能定理)与三大共识算法(Paxos/Raft/ZAB)的演进与原理。核心区分"崩溃故障(节点宕机,不撒谎)"与"拜占庭故障(节点可作恶/撒谎)",前者用 Paxos/Raft/ZAB,后者需 PBFT/PoW。理解 Raft 如何通过"可理解性优先"设计让 Paxos 落地是关键。
拜占庭将军问题(Lamport, Shostak, Pease 1982):n 位将军通过信使达成一致进攻/撤退,部分将军(含司令)可能叛变发错误消息。结论:n ≥ 3f+1(f 为叛变数)才能容忍拜占庭故障。奠定了拜占庭容错(BFT)的理论基础。
Paxos(Lamport 1989,1998 论文):解决非拜占庭(崩溃)故障下的共识。Proposer/Acceptor/Learner + 多数派(majority)投票。理论严密但极难理解,工程落地少(Google Chubby、Spanner 用 Paxos 变种)。
PBFT(Castro, Liskov 1999):实用拜占庭容错,三阶段(pre-prepare/prepare/commit),n ≥ 3f+1。Hyperledger Fabric 早期用 PBFT。区块链前的 BFT 工程突破。
PoW(Nakamoto 2008,Bitcoin):工作量证明,用算力投票解决开放网络(节点可自由加入)的拜占庭共识。区块链的基石,但能耗高、吞吐低。
Raft(Ongaro, Ousterhout 2014):为"可理解性"设计的 Paxos 简化。Leader 选举 + 日志复制 + 安全性约束。etcd、Consul、TiKV 均用 Raft。成为工业界共识算法事实标准。
ZAB(ZooKeeper Atomic Broadcast,2008):ZooKeeper 专用,类似 Paxos/Raft 但为"主备复制广播"优化。ZXID(epoch + counter)保证顺序。崩溃恢复的快速 Leader 选举。
1982 Lamport"] --> P["Paxos
1989/1998
崩溃故障共识"] B --> PBFT["PBFT
1999
实用BFT"] P --> R["Raft
2014
可理解Paxos"] P --> Z["ZAB
2008
ZK广播协议"] B --> POW["PoW
2008
区块链共识"] PBFT --> BC["联盟链
Hyperledger"] POW --> BC2["公链
Bitcoin/Ethereum"] style R fill:#dbeafe,stroke:#2563eb style Z fill:#dcfce7,stroke:#059669 style P fill:#fef3c7,stroke:#d97706
FLP 不可能定理(Fischer, Lynch, Paterson 1985):其前提是异步、确定性消息传递模型。在该模型中,即使只有一个崩溃故障,也不存在确定性协议能同时保证 safety(不会决定错误值)和 liveness(最终达成共识)。工程系统通过引入超时(部分同步假设)、随机化或可探测的故障变化来换取实际可用性;这不推翻 FLP,而是改变定理前提。
Paxos 核心:两阶段——Prepare(Proposer 提议编号 n,Acceptor 承诺不再接受 <n 的提议)+ Accept(Proposer 发 accept,Acceptor 多数派批准)。关键:多数派交集保证不同多数派必有共同 Acceptor,从而保证已决值不被覆盖。Paxos 难在"多 Proposer 活锁"与"工程拆分(Multi-Paxos)"。
Raft 三大子问题:
- Leader 选举:节点三种状态 Follower/Candidate/Leader。Leader 心跳超时则 Candidate 自增 term 请求投票,多数同意则成 Leader。
- 日志复制:Leader 收到写请求,追加日志,并行复制到 Follower,多数确认后 commit 并响应客户端。
- 安全性:term + 日志连续性 + "已 commit 的日志不被覆盖"约束,保证所有节点状态机执行相同序列。
(被动)"] -->|"选举超时"| C["Candidate
(自荐)"] C -->|"获多数票"| L["Leader
(主导)"] L -->|"心跳超时被发现"| F C -->|"发现更高term的Leader"| F C -->|"选举超时再选
(随机化超时避免活锁)"| C style L fill:#dbeafe,stroke:#2563eb style C fill:#fef3c7,stroke:#d97706 style F fill:#f0f3f7,stroke:#6b7280
Raft 日志复制流程:① 客户端 → Leader 写命令;② Leader 追加到本地 log,并行 AppendEntries RPC 给所有 Follower;③ Follower 追加日志并回复 ACK;④ Leader 收到多数 ACK 后 commit(标记可应用),nextCommitIndex 推进;⑤ Leader 在后续 AppendEntries 携带 commitIndex,Follower 也 commit 并应用到状态机;⑥ Leader 响应客户端成功。
ZAB 与 Raft 区别:ZAB 为"主备原子广播"设计,Leader 唯一写入点,ZXID = epoch(32bit) + counter(32bit) 保证全局有序与 Leader 切换后顺序。Raft 用 term + log index。ZAB 的 Leader 选举更看重"拥有最新已提交日志的节点",恢复更快。ZooKeeper 用 ZAB 保证写顺序一致(CP)。
Raft 工业应用:etcd(K8s 元数据)、Consul(服务发现)、TiKV(TiDB 存储)、CockroachDB。etcd 是 K8s 的核心,所有集群状态经 Raft 共识写入。
ZAB 应用:ZooKeeper——配置中心、注册中心、分布式锁、Leader 选举。ZK 的写性能受限于 Leader(所有写经 Leader),读可任意 Follower(可能旧数据,CP 放弃部分可用性)。
选型:① 云原生元数据存储 → etcd(Raft),K8s 原生集成;② 传统 Java 中间件(Kafka 旧版、Dubbo)→ ZooKeeper(ZAB);③ 需拜占庭容错(联盟链)→ PBFT;④ 公链 → PoS/PoW。
Paxos/Raft/ZAB 都基于多数派(quorum):N 个节点容忍 ⌊(N-1)/2⌋ 个崩溃。3 节点容忍 1 个、5 节点容忍 2 个。多数派交集保证已决值一致。这是非拜占庭共识的数学基础。
追问 1:脑裂(split-brain)如何处理?
脑裂指网络分区导致两个 Leader 同时存在。Raft/ZAB 用多数派天然防御:分区后只有多数派一侧能选出 Leader 并 commit,少数派一侧无法 commit(写不进多数)。分区恢复后,少数派 Leader 因 term 落后自动降级为 Follower,未 commit 的日志回滚。需注意:少数派旧 Leader 可能短暂响应读(旧数据),强一致读需走 Leader 或 readIndex。
追问 2:Paxos 与 Raft 的本质差异?
Paxos 是"对等"的(多 Proposer 平等),靠 Prepare 两阶段解决竞争,但活锁与 Multi-Paxos 工程复杂。Raft 是"强 Leader"的(Leader 唯一写入点),简化为日志复制+Leader 选举,可理解性大幅提升。本质 Raft 是 Multi-Paxos 的"强 Leader 约束 + 工程化"特化,牺牲理论通用性换工程可用性。
- 混淆拜占庭故障与崩溃故障:Paxos/Raft/ZAB 只容忍崩溃故障,不能防作恶节点;区块链才需 BFT。
- 误以为多数派=绝对安全:多数派保证已决值一致,但少数派旧 Leader 短暂响应读可能返回旧数据。
- 忽视 FLP 不可能定理:异步网络无完美共识,工程用超时/随机化妥协 liveness。
- 3 节点集群容忍 2 节点故障:3 节点只能容忍 1 个崩溃,2 个宕机则无法形成多数派,集群不可用。
- 把 ZK 当 AP 用:ZK 是 CP,Leader 选举期间不可写,不适合做高可用注册中心(Eureka/Nacos AP 更合适)。
- 忽视共识算法的写性能瓶颈:所有写经 Leader + 多数派同步,QPS 受限,不适合高频写(应做批量/缓存)。
Raft 算法理解到位后,生产落地最关键的是按目标 etcd 版本核对参数语义与默认值。下面以 etcd v3.6 Tuning 文档为参照;升级时不能沿用旧版口头记忆,必须对照目标版本文档和变更记录。
- heartbeat-interval(v3.6 默认 100ms):Leader 发送心跳的间隔。官方建议取成员间平均 RTT 的约 0.5-1.5 倍;跨机房 RTT 变大时通常增大该值,而不是机械调小。
- election-timeout(v3.6 默认 1000ms):Follower 多久未收到心跳后发起选举。官方要求至少为 RTT 的 10 倍,并结合心跳与网络抖动设置;所有成员必须使用相同值。
- snapshot-count(v3.6 默认 10000):按变更数量触发快照,从而支持压缩旧日志。写入密集且内存/磁盘压力高时,官方文档建议可调低以更频繁快照;"大规模就必须调大"不是通用结论。
- quota-backend-bytes:etcd 后端存储配额。默认值与版本/部署配置有关,达到配额会拒绝写入;上线前按目标版本设置、监控并演练恢复。
- 快照语义:快照/压缩是有成本的后台操作,但"快照期间全局暂停写入"不准确。应根据磁盘 fsync 延迟、写入速率和压缩状态评估影响,并在低风险窗口演练。
- 调优方法:先测量成员间 RTT、磁盘 fsync P99、CPU 和网络抖动,再按目标版本调参。避免套用"SSD 固定 50/500ms、跨机房固定 200/2000ms、大集群固定 50000"这类无前提模板。
容量治理:etcd 空间达到配额后会使控制面写入失败,K8s 表现为已有工作负载可能继续运行,但创建、更新、删除和调度受限。治理顺序是:先用
etcd_mvcc_db_total_size_in_bytes
等指标建立容量告警;按官方流程在备份、健康检查和逐节点窗口内做 compact/defrag;必要时按容量规划调整配额;最后限制无界 CRD 和事件写入。不要把"调大配额"当作第一响应,也不应在无备份时直接对多节点执行 defrag。
考察可观测性(Observability)三大支柱(Metrics/Logging/Tracing)在分布式环境下的体系化建设能力。核心不是罗列组件,而是回答:Trace 如何在跨进程调用中保持上下文传递?Metrics 如何统一采集与告警?Logging 如何结构化与关联?以及三者如何联动实现故障定位的"从现象到根因"。
日志时代(2000s):ELK(Elasticsearch + Logstash + Kibana)栈为主,日志分散在各节点,grep 排查故障。问题是日志量爆炸、无上下文关联、跨服务定位困难。
Metrics 时代(2010s):时序数据库 Prometheus + Grafana 可视化,聚焦"数值型指标"(QPS/Latency/ErrorRate)。但 Metrics 是聚合的,丢失了单次请求的完整路径。
Tracing 时代(2010s中后期):Google Dapper 论文(2010)奠定了分布式链路追踪基础。Trace → Span → Annotation 模型,通过 TraceID 串联跨服务调用链。Zipkin(Twitter)、Jaeger(Uber)、SkyWalking(Apache)相继开源。
统一可观测性(2020s):OpenTelemetry(CNCF,2019 合并 OpenTracing + OpenCensus)提供跨语言 API/SDK、OTLP 数据协议和 Collector;云厂商后端接受 OTLP 或通过 exporter/collector 适配。趋势:从三支柱独立走向融合(TraceID 关联 Log + Metrics),并用自动插桩、Agent 或 eBPF 降低侵入,但其覆盖范围以官方支持矩阵为准。
文本/结构化
ELK/Loki"] --> OBS["统一可观测性
OpenTelemetry"] MET["Metrics
时序指标
Prometheus"] --> OBS TRA["Tracing
链路追踪
Jaeger/SkyWalking"] --> OBS OBS --> AUTO["自动插桩/Agent/eBPF
仅覆盖受支持组件"] style OBS fill:#dbeafe,stroke:#2563eb
Trace 核心模型:一次请求产生一个 Trace,由多个 Span 组成有向无环图。OpenTelemetry 的 Span 主要包含 TraceID、SpanID、ParentSpanID、时间与时长、attributes 和 events;旧 APM 中的 tags/logs 对应现代规范里的 attributes/events,但也可能包含 Span 状态等字段。
上下文传递(Context Propagation):跨进程通过 HTTP Header / MQ Header / RPC Attachment 传递 W3C Trace Context(如 traceparent)。进程内使用语言 SDK 的 Context 存储传递;传统 Servlet 场景底层可能落到 ThreadLocal,线程池和 Reactive 流仍需显式包装、instrumentation 或框架提供的传播机制。
Metrics 三大黄金指标:
- RED:Rate(请求率)、Errors(错误率)、Duration(延迟分布)。
- USE:Utilization(资源利用率)、Saturation(饱和度)、Errors(错误率)。
Logging 结构化:JSON 格式 + TraceID 字段,实现"从错误日志反查整条链路"。避免纯文本日志,因为无法被程序解析关联。
Sampling(采样):头部采样在请求入口决定是否采样;尾部采样等完整链路后按错误、延迟或业务规则保留。采样率应结合延迟 SLO、错误率、流量峰值和存储成本压测确定,不能固定为"1%~10%"。
Java 生态选型:
| 组件 | 定位 | 特点 | 适用 |
|---|---|---|---|
| SkyWalking | APM 全链路 | Agent 自动埋点、UI 强大、国产 | Java 主流,功能全 |
| Jaeger | Tracing | CNCF、云原生、与 OpenTelemetry 原生集成 | K8s 云原生环境 |
| Zipkin | Tracing | 轻量、简单、社区老 | 小型系统、快速验证 |
| Prometheus | Metrics | 拉模型、PromQL、Alertmanager | 指标采集标准 |
| ELK / Loki | Logging | 全文检索 / 轻量标签检索 | 日志存储分析 |
| OpenTelemetry | API/SDK/OTLP/Collector | 统一 API、Exporter 可插拔 | 长期演进方向 |
落地关键点:① 统一 TraceID 生成与传递,网关层生成并透传全链路;② 异步线程池必须包装 Runnable/Callable 以传递上下文;③ MQ 消费从 Message Header 提取 TraceID;④ 错误日志必带 TraceID;⑤ Metrics 标签规范化(服务名+接口名+状态码),避免高基数标签(如用户ID)。
Metrics 发现异常(错误率突增)→ Trace 定位到具体慢/错请求 → Log 查看详细报错堆栈。三者通过 TraceID 串联,缺一不可。
追问 1:Trace 采样率设为多少合适?全量采集有什么问题?
先定义问题:如果目标是趋势分析和容量画像,可对常规流量采样;如果目标是一次线上故障的证据链,错误/慢请求应尽量保留(可用尾部采样)。全量采集会增加 Agent CPU/内存、网络带宽和后端存储成本;具体开销必须用目标 Agent 版本和真实流量压测,不能引用固定百分比。
追问 2:异步/Reactive 编程中 Trace 上下文如何传递?
ThreadLocal 在异步场景失效。方案:① 线程池包装(SkyWalking 提供 RunnableWrapper/CallableWrapper);② Project Reactor 的 Context 透传;③ Kotlin Coroutine 的 ThreadContextElement。本质是"将 Context 绑定到执行单元(而非线程)"。
- TraceID 在跨服务调用中断链:MQ、定时任务、线程池未透传导致链路断裂。
- 日志未结构化:纯文本 grep 无法与 Metrics/Trace 关联,应输出 JSON。
- Metrics 高基数标签:如将 userId 放入 Prometheus label,导致时间线爆炸。
- 忽视 Agent 性能开销:生产环境直接全量采集导致 OOM,应渐进式上线。
- 三支柱各自为政:三个独立系统无法联动排查,需统一 TraceID 串联。
考察Cache-DB 一致性的经典问题:读缓存加速、写 DB 持久化,两者数据不一致时如何取舍与修复。核心回答:先区分业务允许的是强一致、单调读、读己之写还是最终一致,再选择读写路径、失效策略、失败补偿和监控;延迟双删只是特定竞态下的补救,不是所有缓存一致性方案的必然步骤。
Cache Aside(旁路缓存,常用):读时先查缓存,miss 则读 DB、校验版本/条件并回填;写路径通常更新 DB 后失效缓存。它实现简单、可控,但删除失败和并发读写会产生不一致窗口,必须有 TTL、重试和对账兜底。
Read/Write Through:应用只与缓存/访问层交互,缓存层负责同步读写 DB。若底层使用支持事务或线性一致性的存储/协调器,可提供更强语义;普通进程内缓存包装 DB 只是封装差异,不会自动变强一致。
Write Behind(异步写回):写时先更新缓存,异步批量写回 DB。写入吞吐高,但存在丢数据和乱序风险,只适合计数、日志等明确可补偿/可丢的数据;必须有持久队列、幂等写库、对账和降级开关。
趋势:① 按业务等级选择 Cache Aside + 补偿、条件写入、版本化缓存或直读 DB;② 本地缓存(Caffeine)+ 分布式缓存(Redis)需设计失效广播/版本号/TTL;③ Redis 生态还承担分布式锁、限流、队列等用途,这些场景的一致性要求要单独建模,不能套用普通缓存失效策略。
为什么常用"失效缓存",但不是唯一答案?
并发更新时,"应用层先算好值再更新缓存"可能乱序:A、B 更新 DB 的顺序与写缓存顺序不同,缓存可能保留 A 的旧值。失效缓存让下一次读回源,减少写入序与缓存序耦合,因此成为常用默认。但在读多写少、成本高的派生视图里,也可以更新缓存,前提是用版本号、逻辑时钟、分布式锁或单分区写入消除乱序,并写明失败时如何失效缓存。
Cache Aside 的失败语义:即使"先更新 DB,后删缓存",第二步也可能失败、超时或读到旧副本;"先删缓存,后写 DB"则更依赖后续补偿,否则旧值可能回填。方案应组合使用:
- 失败标记/重试:删除失败写入重试队列或 outbox,带 key、版本、重试次数和过期时间。
- Binlog/CDC 补偿:Canal/Debezium 监听变更后触发失效;同一个 key 的事件要按版本/顺序消费,消费失败进入死信和人工兜底。
- TTL:所有脏数据必须有上限窗口;TTL 是一致性边界,不只是性能参数。
- 延迟双删:用于"删缓存 → 写 DB → 读旧值回填"这类竞态。延迟必须按主从延迟、回源耗时和压测分布设定,并保留第二次删除失败重试;它不能替代版本化和对账。
- 版本化 key:写路径生成新版本 key 并切换指针,旧 key 短 TTL 存活;适合可接受短暂旧读且读多写少的场景。
本地缓存 + Redis 二级缓存同步:广播通知可能丢失,节点重启后也可能错过事件。除 Redis Pub/Sub/MQ 广播外,还应保留短 TTL、版本号或定期拉取变更;强一致读必须绕开本地缓存。
按一致性目标选型:
| 模式 | 一致性 | 复杂度 | 性能 | 适用 |
|---|---|---|---|---|
| Cache Aside | 最终一致,窗口取决于 TTL/补偿 | 低-中 | 高 | 可容忍短暂旧读的通用读路径 |
| Read Through + 条件回填 | 可减少旧写覆盖 | 中 | 高 | 读多写少,缓存组件支持版本/条件 |
| Write Through / 支持事务的访问层 | 取决于底层存储和失败协议 | 高 | 中 | 关键数据;不达标时业务直接读 DB |
| Write Behind | 最终一致且可能丢/乱序 | 高 | 极高 | 计数、日志等可重建数据 |
| 版本化失效/单写入口 | 可做到读己之写或单调读 | 高 | 中-高 | 核心视图、权限、余额等敏感数据 |
生产建议:① 为每类数据写明一致性目标和不一致窗口;② 写操作明确顺序、失败重试、版本和清理策略;③ 所有缓存和空值设 TTL;④ 本地缓存 TTL 按业务可接受旧读窗口评估,不套用固定 5~30s;⑤ 监控删除失败率、补偿延迟、TTL 内旧读估计、回源 QPS、击穿/穿透和对账差异。
追问 1:缓存穿透、击穿、雪崩的区别与应对?
穿透:查询不存在数据,每次都打到 DB。解法:布隆过滤器拦截、缓存空值(短 TTL)。击穿:热点 Key 过期瞬间大量请求打到 DB。解法:互斥锁(单线程回源)、逻辑过期(永不过期,异步刷新)。雪崩:大量 Key 同时过期或缓存集群宕机。解法:随机 TTL、高可用集群、熔断降级、多级缓存。
追问 2:如何保证本地缓存与 Redis 的一致性?
本地缓存(Caffeine)提升单机性能,但多节点本地缓存独立,广播也不可靠。工程组合是:① 用短 TTL 定义兜底窗口;② 变更时通过 Redis Pub/Sub、MQ 或配置中心广播 key + 版本;③ 节点启动和重连后刷新版本;④ 强一致数据不走本地缓存。若业务要求"写后读自己最新值",可在写后为该用户/会话失效或降级。
- 把某个顺序当成永久真理:先更新 DB 再失效缓存是常见默认,但删除失败仍会脏读;先删缓存则有旧值回填竞态。关键是为失败路径设计补偿。
- 把延迟双删当标准答案:sleep 值没有业务依据时,可能过长影响性能,也可能过短错过主从延迟;它只是竞态补救。
- CDC 只做"尽力删除":必须记录版本、保证单 key 顺序、处理重复/乱序和死信,并对账 Redis 与 DB。
- 缓存空值不设 TTL:导致内存被无效 Key 占满。
- 本地缓存 TTL 过长:多节点数据严重不一致,引发业务 bug。
- 忽视缓存组件的内存上限:未配置 maxmemory 与淘汰策略导致 OOM。
缓存一致性治理清单:
- 一致性等级:强一致(关键扣减/权限,通常绕缓存或使用支持线性一致性的存储)、读己之写(用户写后立刻读到新值)、单调读(同一用户不看到旧值回退)、最终一致(页面/推荐/统计)。同一系统可按数据域分级。
- 写路径模板:DB 事务提交 → 生成带版本/key 的失效事件 → 同步删除一次 → 失败入重试队列/outbox → CDC 或定时任务补偿 → 达到重试上限后告警并记录待修复 key。不允许"删失败只打日志"。
- 读路径模板:缓存 miss → 读 DB/从库 → 校验版本或至少校验业务有效性 → 回填 TTL → 回源失败走默认值或降级。禁止把异常对象、超大对象和无 TTL 数据写进缓存。
- 顺序与幂等:同一 key 的事件按版本或分区顺序处理;消费端以 key + 版本幂等。Redis 失效操作可重复执行,但补偿标记必须幂等。
- 可观测性:跟踪 DB 与缓存差异对账率、失效延迟 P99、删除失败率、CDC lag、死信数、缓存命中率、回源 QPS、击穿锁等待和热点分片均匀度。演练内容应包括删除失败、Redis 主从切换、CDC 阻塞和本地缓存节点重启。
考察无状态服务架构下用户会话(Session)如何跨节点共享。核心回答:Session 本质是有状态数据,与无状态服务目标矛盾;解决方案要么"集中存储状态",要么"消除服务端状态"。理解各方案在一致性、性能、复杂度上的权衡。
Sticky Session(会话粘滞):负载均衡器按 IP/Hash 将同一用户固定路由到同一节点。简单、无改造,但节点宕机会话丢失、无法弹性扩缩容、负载可能不均。
Session Replication(会话复制):Tomcat 等容器间互相复制 Session。节点间全量同步,小规模可用,节点增多时网络与内存开销指数级增长。
集中式 Session(Redis/Memcached):Session 存到外部缓存,各节点共享。Spring Session 提供透明集成,成为主流方案。
JWT / Token(可无服务端会话):服务端可以不保存完整 Session,用户态和声明放在客户端 Token 中(JWT)。适合分布式与微服务,但主动吊销、敏感声明、体积和刷新机制都需要额外设计。
趋势:微服务/云原生时代,常见组合是 短有效期 JWT + Refresh Token + 吊销状态;传统 Java Web 用 Spring Session + Redis。无论哪种方案,都必须有传输加密、认证隔离和防伪造身份头边界。
Sticky Session:基于负载均衡算法(IP Hash / Cookie-based)。Nginx 用 ip_hash 或 sticky cookie。本质是"牺牲弹性换零改造成本",不适合云原生。
集中式 Redis Session:Spring Session 提供 SessionRepositoryFilter,替换 Servlet HttpSession 实现,自动将 Session 序列化到 Redis。支持过期 TTL、事件监听(Session 创建/销毁/过期)。序列化方式:JDK(默认,体积大)→ JSON(可读性好)→ 自定义。Redis 必须启用 TLS、认证、ACL 和网络隔离;跨信任域共享 Session 时还要限制 key 命名空间和过期时间,避免"一个缓存集群等于一个信任边界"。
JWT 结构:Header.Payload.Signature。Payload 含用户ID、角色、过期时间、签发方和 jti 等声明。服务端校验签名、 issuer、audience、exp/nbf 后解析,无需查完整 Session。问题与边界:① 签名不等于吊销;主动失效需要短 TTL、版本号、denylist 或状态端点;② Payload 过大增加请求体积;③ Payload 仅 Base64URL,未加密,敏感信息不应放入;若必须加密应使用 JWE 并按目标库验证算法;④ 验签密钥必须有 kid、轮换和算法白名单。
Refresh Token 机制:Access Token 有效期短(如 15 分钟),Refresh Token 有效期长(如 7 天)。Access Token 过期后用 Refresh Token 换取新的,减少用户重新登录频率,同时保持安全(被盗的 Access Token 很快失效)。
方案对比:
| 方案 | 一致性 | 性能 | 复杂度 | 适用 |
|---|---|---|---|---|
| Sticky Session | 强(单节点) | 高 | 低 | 小规模、临时过渡 |
| Session Replication | 最终 | 中 | 中 | 小规模集群(<10节点) |
| Redis 集中式 | 取决于 Redis 持久化/可用性,会话视图一致 | 高 | 低-中 | Spring Boot 常用方案 |
| JWT + 吊销状态 | 访问令牌期内可验证,吊销状态取决于存储 | 高 | 中 | 微服务、前后端分离 |
Spring Session + Redis 配置要点:① 按目标 Spring Session/Boot 版本选择注解或自动配置;② 配置 Redis 连接、TLS、认证、ACL 与过期时间;③ 自定义序列化器并设计版本兼容;④ Session 中不要存大对象;⑤ Cookie 设置 Secure、HttpOnly、SameSite,并按需要绑定域名。
传统服务端渲染(JSP/Thymeleaf)→ Spring Session + 受保护的 Redis;前后端分离 / 微服务网关 → 短 JWT + Refresh Token + denylist/token version(登出或权限变化时吊销,denylist TTL 设为 JWT 剩余有效期;长期会话状态仍在授权服务可查)。
追问 1:JWT 被盗怎么办?如何登出?
JWT 签名验证本身不查询服务端会话,因此主动"撤销"要引入状态。方案:① 短 TTL(如 15 分钟),减少被盗后的风险窗口;② denylist:登出时将 jti/token hash 加入 Redis,网关/拦截器校验时查黑名单,TTL 为剩余有效期;③ token version / session version:用户登出、改密或权限变化时递增版本,校验 Token 中的版本;④ 双 Token:Access Token 短有效期 + Refresh Token 单点存储,异常时清除 Refresh Token。仍需假设 Access Token 在已发放的短窗口内可能有效,重要操作可二次认证或查授权服务。
追问 3:网关如何向下游传身份?
外部请求进入可信边界时,网关必须先剥离客户端携带的内部身份头(如
X-User-Id、X-Roles、X-Trace-User),认证通过后才由网关注入新的身份头/签名的内部令牌,并加上来源标识与过期时间;下游只信任来自内网/服务网格的网关流量。更安全的做法是下游再校验签名令牌或 mTLS 服务身份,而不是裸信任意 HTTP Header。
追问 2:Spring Session 序列化异常如何处理?
类结构变更后反序列化失败。方案:① 使用 JSON 序列化(兼容字段变更);② 自定义
RedisSerializer;③ 版本升级时清理旧 Session(渐进式发布)。
- JWT 中存放敏感信息:Payload 仅 Base64 编码,未加密,不应放密码、手机号。
- Session 存大对象:Redis 内存暴涨,序列化/反序列化耗时。
- Sticky Session 做弹性伸缩:节点上下线导致会话漂移,用户体验差。
- JWT 长期不刷新:单 Token 长期有效,被盗后风险极大。
- 把签名当吊销:有效签名只证明签发时的完整性,不证明当前会话仍被授权。
-
网关裸透传身份头:外部伪造
X-User-Id/X-Roles会造成越权;必须先剥离再在可信边界注入。 - 会话存储裸奔:Redis/Sessions 服务未开认证、TLS 或网络隔离,等于把登录态暴露给可访问缓存的调用方。
会话安全边界:
- 传输与存储:浏览器到网关使用 HTTPS;内部 Redis/授权服务启用 TLS、认证、ACL 和网络隔离。会话序列化要防止反序列化漏洞,避免把可执行对象直接存入共享缓存。
- 吊销模型:纯 JWT 不查库,适合低风险、短生命周期访问;登出、封禁、降权、改密、支付等高风险场景应有 denylist、token version 或服务端会话状态。可按用户、会话、设备粒度吊销。
- 刷新令牌:Refresh Token 存储要哈希、可检测重放、绑定设备/客户端指纹,支持轮换;泄露后只吊销该会话,而不是全站用户。
- 身份头契约:约定哪些 Header 是内部保留字,入口删除,网关认证后注入;服务网格/mTLS 场景可把服务身份和用户身份分离。审计日志记录认证来源与 impersonation。
- 演练:验证登出后 Access Token、Refresh Token、Remember-Me、第三方 SSO 会话和下游缓存身份是否同时失效;测试伪造身份头、Cookie 属性、Redis 主从切换和发布期序列化兼容。
考察分布式环境下没有全局精确时间时如何定义事件顺序。核心回答:NTP/物理时钟存在部署相关的不确定性,不能默认用于跨节点排序;因果序可用 Lamport/向量时钟或 HLC 表达,跨节点全序需要 TSO、共识日志或具备有界时钟不确定性和外部授时基础设施的系统(如 Spanner TrueTime)。System.nanoTime()
只适合同一 JVM 内测量耗时,不是跨节点时钟、逻辑时钟或墙上时间。
物理时钟(NTP):网络时间协议同步机器时钟,实际误差、步进和 slew 行为取决于部署环境、时钟源和网络路径,且可能出现回拨或跳变。不能默认用于跨节点事件排序。
Lamport 逻辑时钟(1978):每个节点维护单调递增计数器,事件发生时递增;消息携带发送方计数器,接收方取 max(本地, 消息)+1。能判断偏序(Happens-Before),但无法判断并发事件。
向量时钟(Vector Clock,1988):每个节点维护一个向量 [t1,t2,...,tn],记录已知其他节点的时钟。可精确判断因果序、并发、版本冲突。DynamoDB/Voldemort 用向量时钟检测冲突。缺点是向量长度随节点数增长。
TrueTime(Spanner,2012):依赖 Google 基础设施中的原子钟、GPS 和多时钟源冗余,返回带不确定性的时间区间。Spanner 在保障外部一致性时使用提交等待,使后续提交的时间戳不早于前序事务的不确定性上界。因此它不是“零误差时钟”,而是有界不确定性 + 专用部署前提的工程方案;不应把任何固定毫秒精度当作通用结论。
HLC(Hybrid Logical Clock,2014):在物理时间分量之上增加逻辑分量,用于表达同一物理时刻内的因果先后。它能保留可读的近似物理时间,但不改变本质:跨节点顺序仍由消息交换和规则推进,不能只靠本地墙上时间。
为什么物理时钟不可靠?
- 时钟漂移:石英晶振频率有误差,机器间时间逐渐偏离。
- NTP 同步误差:网络延迟不对称导致同步存在误差。
- 时钟回拨:NTP 校准或手动调时间可能导致时钟倒退,引发 ID 重复、日志乱序。
Happens-Before 关系(→):如果事件 A 与 B 满足以下之一,则 A → B:① A、B 在同一进程,A 先发生;② A 是发送消息,B 是接收该消息;③ 存在传递性:A → C 且 C → B。如果 A、B 互不 Happens-Before,则它们并发,系统无法判断先后。
向量时钟核心:节点 i 的向量 VCi,VCi[j] 表示节点 i "知道"节点 j 已发生的事件数。比较:VCa < VCb 当且仅当对所有 k,VCa[k] ≤ VCb[k] 且至少一个严格小于。若不可比,则并发。
工程选型:
| 方案 | 保障性质 | 成本 | 适用 |
|---|---|---|---|
| NTP | 部署相关误差界 | 低 | 日志时间戳、监控;不默认用于跨节点排序 |
| Lamport 时钟 | 因果偏序 | 低 | 简单因果判断 |
| 向量时钟 | 因果与并发检测 | 中 | 冲突检测;状态随节点/版本增长 |
| HLC | 因果序 + 近似物理时间 | 中 | 需要事件时间接近墙钟且保留因果规则 |
| TrueTime | 有界不确定性 | 极高(专用时钟基础设施) | 具备 Spanner 类部署前提的全球数据库 |
时钟回拨应对:① 雪花算法中维护 lastTimestamp,发现回拨则等待或抛异常;② 闰秒处理:NTP 的“跳变”方式可能引发回拨,部分授时服务采用“闰秒涂抹(leap smear)”,是否启用取决于提供方和系统配置;③ 跨节点顺序使用协议序号、共识日志、TSO、HLC 或带不确定性等待的授时服务。System.nanoTime()
只用于同一 JVM 内的经过时间测量,其原点无语义,且不能跨进程比较。
追问 1:为什么分布式事务不依赖物理时间戳?
未同步的物理时间戳无法保证全局全序。节点 A 的 T1 可能大于节点 B 的 T2,但 A 的时钟本身偏快。工程上有两类路径:要么引入 TSO、共识日志索引等显式全序来源;要么像 Spanner 一样建立有界时钟不确定性、外部授时和提交等待,再使用带不确定性处理的时间戳。不能把“本地物理时间戳 + NTP”默认等价于其中任一路径。
追问 2:MySQL 的 AUTO_INCREMENT 与 Snowflake 哪个更适合分布式?
AUTO_INCREMENT 强依赖单点(或 MySQL 多主自增步长),扩展性差、存在单点瓶颈。Snowflake 等分布式 ID 方案用时间戳+机器ID+序列号,本地生成、无单点、趋势递增。但 Snowflake 依赖时钟,时钟回拨会出问题。
- 用机器时间戳排序分布式事件:NTP 误差导致排序错误。
- 忽视闰秒:2012 年 Reddit、LinkedIn 等因闰秒处理不当宕机。
- 雪花算法时钟回拨未处理:导致 ID 重复。
- 混淆并发与先后:向量时钟不可比不代表"同时",只代表"互不知晓"。
上线门禁:一致性承诺书面化、超时重试预算、补偿可审计。表达提纲:模型选型→失败模式→观测与演练。
先给可观测证据与门禁,再谈实现细节;避免只报组件名。
考察RTO(恢复时间目标)与 RPO(恢复点目标)驱动下的容灾架构设计能力。核心回答:从"单活冷备"到"同城双活"再到"异地多活",成本与复杂度随一致性、切换自动化和演练要求上升;多活的本质不是"数据实时同步",而是流量分区+单元化+冲突消解+持续演练的工程体系。“接近 0”是经过设计、自动化、故障演练和数据对账验证后的目标,不是架构名称带来的天然结果。
冷备/热备(传统灾备):主中心运行,备中心定期同步数据(冷备)或实时同步(热备)。故障时手动/自动切换 DNS。RTO 分钟~小时级,RPO 分钟~天级。成本低但恢复慢,且备中心平时不承载流量,浪费资源。
同城双活:同一城市两个数据中心同时对外服务;数据层可用同步、多数派或其他强复制协议,也可按业务分级一写多读。时延由机房距离、网络、存储协议和提交策略决定,通常低于跨城链路,但不是固定值。目标是容忍单机房故障,不必然容忍城市级灾难。
异地多活(单元化):多个异地数据中心同时服务,数据按用户/地域分片(单元),每个单元尽量自包含(应用+数据+缓存+MQ)。用户请求按分片规则路由到归属单元。RTO/RPO 能否接近 0,取决于故障检测、自动切换、复制协议、仲裁、跨单元补偿、演练和业务降级前提。AWS Global Table、蚂蚁 LDC 等是实现案例,不是统一标准。
趋势:云原生平台和云厂商多可用区(AZ)降低了同城多可用区部署成本;金融/电商核心系统可能追求单元化异地多活;普通业务仍应先量化 RTO/RPO 与成本,再选择热备、多可用区或多活。
RTO vs RPO:
- RTO:故障后业务恢复所需时间目标。它由检测、决策、DNS/GSLB 切流、存储选主、应用扩容、预热和人工审批共同决定;不是只看架构形态。
- RPO:故障后可接受的数据丢失量目标。异步复制通常存在丢失窗口;同步或多数派提交可趋近 0,但前提是多数派可达、提交协议可用,并且跨地域时业务能接受时延。
一致性由协议决定,不是距离标签:线性一致性和可用性取舍取决于复制协议、提交确认、仲裁规则、故障检测和切换协议。同城可用强一致复制,异地也可对部分数据用共识协议;跨城异步复制则必须定义丢失窗口、乱序、冲突和补偿。不要把“同城双活=CP”“异地多活=AP”当作架构定律。
单元化核心设计:
- 分片键:按用户ID / 地域划分,确保同一用户所有数据在一个单元。
- 路由层:网关/负载均衡根据分片键路由到目标单元,避免跨单元访问。
- 数据同步:单元内自包含,跨单元只同步必要全局数据(如商品、配置)。
- 冲突消解:若必须跨单元写,用"最后写入优先"或业务层合并(如库存预分配)。
容灾等级对比:
| 架构 | RTO | RPO | 成本 | 适用 |
|---|---|---|---|---|
| 冷备 | 小时~天 | 小时~天 | 低 | 非核心系统 |
| 热备 | 分钟 | 分钟 | 中 | 一般业务 |
| 同城双活 | 目标可到秒~分钟 | 取决于复制/提交协议 | 高 | 可承担同城冗余成本的核心业务 |
| 异地多活 | 目标可趋近 0 | 目标可趋近 0,需复制/仲裁/补偿验证 | 极高 | 有明确跨地域 SLA、单元化和演练能力的系统 |
实施 checklist:① 故障演练(混沌工程):定期模拟机房断网、数据库宕机、仲裁失联和切流失败;② 数据校验:双活/多活下定时比对副本数据、追踪复制延迟和冲突;③ 自动化切换:明确检测窗口、人工/自动审批、DNS/GSLB 或服务路由切换顺序与回退方案;④ 降级预案:单机房故障时自动降级非核心链路;⑤ 网络与隔离:跨机房复制使用受控专线/云内网,设置限流、安全组和网络分区预案。
用户请求尽可能在一个单元内闭环(读+写),跨单元调用视为异常路径。单元之间"共享只读数据、隔离可变数据",这是异地多活可落地的核心前提。
追问 1:同城双活的数据库如何同步?脑裂怎么办?
MySQL 可用半同步复制、组复制(MGR)或基于共识的方案;Oracle 有 Data Guard;TiDB/OceanBase 等原生多副本可跨 AZ 部署。脑裂风险主要来自网络分区下多个可写主或错误故障切换。解法:① 使用仲裁、多数派提交或可靠选主,让少数派拒绝服务;② 业务层对关键数据设计为单写点;③ 每次演练验证 fencing、副本一致性和回切流程。
追问 2:异地多活中库存如何扣减?
库存是全局共享资源,无法按用户分片。方案:① 库存预分配:各单元预分配一定库存,卖完再调拨,牺牲全局实时一致;② 集中式库存服务:库存扣减走单一中心(如 Redis/数据库主库),其他单元只读,牺牲多活纯度;③ 异步对账:允许多单元同时扣减,日终对账补平。没有完美方案,只有业务可接受的妥协。
- 忽视 RTO/RPO 指标:容灾方案没有量化目标,无法评估是否达标。
- 双活变双主导致数据冲突:数据库双主写同一行,冲突后难以合并。
- 只做数据同步不做演练:真正故障时切换脚本、流程不工作。
- 用异步复制承载不可丢失数据:若 RPO 要求为 0,应选择能提供多数派提交/同步保障的协议,并验证仲裁失联时的行为。
- 单元划分粒度太粗:如按省划分,导致热点单元流量不均。
单元化是异地多活的常见落地形态。蚂蚁集团 LDC(Logic Data Center)架构是一个有代表性的实现案例,以下角色与事务选择反映其公开架构思路,不是行业统一标准;实际系统应按数据一致性等级、可用区拓扑和成本约束裁剪。
- 蚂蚁 LDC 架构三角色:① GZone(Global Zone)——全局单元,存放无用户归属的全局数据(如商品目录、系统配置),所有 RZone 共享只读;② RZone(Rover Zone)——用户单元,按 userId 取模分配,单元内数据闭环(DB/缓存/MQ 独立),用户请求路由到归属 RZone 完成全链路读写;③ CZone(City Zone)——城市级单元,做就近读缓存,减少跨城 RTT。
- 跨单元事务:① 核心交易可使用 TCC(Try-Confirm-Cancel)——Try 阶段预留资源(冻结额度),Confirm 确认扣减,Cancel 释放预留,但仍必须实现幂等、空回滚、悬挂和防重;② 非核心交易常用可靠消息、本地事务/Outbox + 异步补偿;③ 跨城 XA/两阶段提交通常因锁持有时间、失败率和时延成本被谨慎对待,而不是被无条件禁止。
- 数据路由原则:① 写——必须路由到归属 RZone,跨单元写直接拒绝(避免数据冲突);② 读——优先本地 RZone,全局数据读 GZone,跨用户聚合查询走 CZone 只读副本。
- 单元化 vs 分库分表:分库分表是数据层的水平拆分,仅解决存储与写入扩展;单元化是从接入层到数据层的全链路闭环隔离(包含路由、部署、容灾),每个单元是自包含的微缩系统。分库分表是单元化的数据层子集,单元化在其上叠加了路由、容灾、流量隔离能力。
单元化架构的最大挑战不是技术实现,而是“全局数据”的治理。商品、配置等全局数据天然无法按用户分片,GZone 类全局层可能成为所有 RZone 的共享依赖。常见做法是全局层多地多副本、本地 CZone 缓存和读降级;写入收敛到全局可写点后,副本可见性和一致性由复制/共识协议与失效策略决定,不能因为“异步同步”就宣称全局一致。“全局可写点收敛 + 多级读缓存 + 明确失效窗口”是值得评估的模式,不是免验证的默认答案。
考察分布式事务的工程化选型能力。核心要讲清:① 以具体版本核对 Seata 能力(截至证据日期 Apache Seata v2.6.0 提供 TC Raft 示例配置);② Saga 工程化的状态机编排与补偿幂等设计;③ Outbox 模式 vs 本地消息表的可靠性差异(CDC 订阅 vs 轮询);④ TCC 与 AT 的取舍(侵入性 vs 性能);⑤ 按一致性等级、链路形态和运维前提做场景选型。它不是“某一年度必选某方案”,而是一次可验证的架构决策。
本题聚焦分布式事务最新演进与选型决策矩阵;TCC/AT/Saga/本地消息表基础详见 C09.02。
基础模式回顾(详见 C09.02):XA/2PC 提供较强协调语义,但长事务锁和故障恢复成本高,通常不是跨微服务高并发链路的第一选择;TCC Try/Confirm/Cancel 三接口业务侵入大;Seata AT Undo Log+全局锁低侵入但热点行会受全局锁影响;Saga 正向+补偿适合长流程;本地消息表靠轮询投递。以下聚焦较新的演进点。
增量 1:Outbox 模式 + CDC。业务写库同时写 outbox 表,Debezium/Canal 等连接器订阅 binlog 并投递 MQ,应用无需自建轮询。与本地消息表相比,CDC 可减少应用侧轮询压力并便于多服务复用采集管道;但端到端延迟取决于数据库产生 binlog、连接器拉取/解析、队列、分区、消费者和重试,不是天然的毫秒级承诺。Outbox 表至少需要业务事件 ID、聚合类型/ID、事件类型、payload、创建时间和状态/版本字段;消费端必须按事件 ID 或业务唯一键幂等。
增量 2:按版本核对 Seata TC Raft。Apache Seata 当前证据版本为 v2.6.0(2026-01-28,非预发布),其 2.x 源码样例 application.raft.example.yml 包含 server.raft 与 store.mode: raft。这说明目标版本支持基于 Raft 的 TC 集群配置方向;但是否适合生产,必须核对该版本的 release notes、兼容矩阵、存储/恢复行为、监控和升级路径。与共享 DB 模式相比,Raft 模式可减少一个外部共享存储组件,但引入 Raft 成员管理、快照、网络分区和磁盘持久化运维;DB 模式则把高可用职责交给数据库平台。
增量 3:场景决策矩阵(五维度表格见原理层)。事件驱动最终一致常评估 Outbox+CDC;最终一致短事务可评估 Seata AT;长流程编排用 Saga;高并发资源预留用 TCC;无 CDC 基础设施的中小团队可先本地消息表。
Outbox vs 本地消息表:两者都把"业务操作 + 消息发送"收敛到一个本地事务,保证业务与消息原子性。差异在消息投递机制。
+ 消息表"] --> L2["定时轮询消息表"] L2 --> L3["投递 MQ"] L3 --> L4["标记已投递"] end subgraph Outbox["Outbox + CDC:订阅投递"] O1["业务写库
+ outbox 表"] --> O2["binlog"] O2 --> O3["Debezium/Canal
订阅解析"] O3 --> O4["投递 MQ"] end style L2 fill:#fee2e2,stroke:#dc2626 style O3 fill:#dcfce7,stroke:#059669
方案对比:
| 维度 | Seata AT | TCC | Saga | 本地消息表 | Outbox+CDC |
|---|---|---|---|---|---|
| 一致性 | 提交/回滚受全局锁和隔离规则约束 | 业务预留后的最终一致 | 补偿后的最终一致 | 投递后的最终一致 | 投递后的最终一致 |
| 侵入性 | 低(注解) | 高(三套接口) | 中(状态机) | 低 | 低 |
| 并发特征 | 热点行受全局锁影响 | 预留后并发较好 | 正向/补偿可并行程度高 | 受轮询和数据库负载影响 | 受 CDC/队列/消费并发影响 |
| 投递延迟 | — | — | — | 轮询间隔 + 处理时间 | binlog、连接器、队列和消费全链路延迟 |
| 额外组件 | TC 集群 | TC 集群 | 状态机引擎 | 定时任务 | Debezium/Canal |
TCC vs AT 取舍:AT 零侵入但有全局锁,适合低并发最终一致(如库存扣减的旁路校验);TCC 侵入大但无锁并发好,适合高并发资源预留(如秒杀库存)。核心差异:AT 回滚靠反向 SQL(可能脏写需校验),TCC 回滚靠业务 Cancel(精确可控)。
最新选型决策矩阵(五维度):
| 典型场景 | 一致性需求 | 可用性要求 | 实现成本 | 运维复杂度 | 推荐方案 | 决策说明 |
|---|---|---|---|---|---|---|
| 电商订单履约(长流程:下单→扣库存→支付→发货→通知) | 最终一致(允许中间态) | 极高(链路长,任一步阻塞不能影响其他) | 中(需 Saga 状态机编排+补偿幂等) | 高(补偿失败告警+人工兜底) | Saga 状态机 | 长流程无全局事务,正向+补偿可独立重试/人工兜底 |
| 跨服务订单创建+扣库存+优惠券(多服务原子性) | 最终一致 | 高 | 低(业务写 outbox+CDC 基础设施) | 中(需维护 Debezium/Canal 集群) | Outbox + CDC + 事件驱动 | 业务与消息同事务原子;需评估全链路延迟、顺序、幂等、重放和对账 |
| 秒杀库存预留(高并发热点行) | 最终一致 | 极高(抗瞬时流量) | 高(三套接口+幂等+防悬挂) | 中 | TCC | Try 阶段本地事务冻结资源,无全局锁,并发高,适合热点 |
| 账户转账/积分兑换(最终一致短事务) | 最终一致(允许短暂中间态) | 中(链路短) | 低(@GlobalTransactional 注解) | 中(按目标版本部署并验证 TC 集群) | Seata AT | 低侵入;热点并发高或隔离要求特殊时需要压测,必要时换 TCC/业务预留 |
| 中小团队微服务,无 CDC 基础设施 | 最终一致 | 高 | 极低(SQL 写消息表+Spring @Scheduled 轮询) | 低(无额外组件) | 本地消息表(起步方案) | 零额外组件,单服务内即可闭环;后期 CDC 基础设施就位后迁 Outbox |
场景化选型:① 跨服务最终一致 + 事件驱动 → 评估 Outbox + CDC;② 最终一致短事务(如转账)→ 评估 Seata AT;③ 长流程编排(如订单履约)→ Saga 状态机;④ 高并发资源预留(如秒杀)→ TCC;⑤ 无 CDC 基础设施的中小团队 → 本地消息表起步。
// Outbox 模式:业务与 outbox 同事务写入,CDC 订阅投递
@Transactional
public void createOrder(OrderCmd cmd) {
Order order = buildOrder(cmd);
orderMapper.insert(order); // 1. 业务落库
outboxMapper.insert(new OutboxEvent( // 2. outbox 同事务写入
"OrderCreated", toJson(order)));
// 3. Debezium 订阅 binlog 自动投递 MQ,应用无需感知
}
- 消息幂等:消费端按业务 ID 去重,CDC 可能重复投递。
- 顺序保证:单聚合的事件按 outbox 自增 ID 顺序,多分区间无序。
- outbox 清理:已投递事件定期归档,避免表膨胀。
- Seata TC:按目标版本文档部署 Raft/DB 高可用;Raft 常见使用奇数多数派节点,但仍需演练选举、快照、扩缩容和升级。
追问 1:Outbox 如何保证消息顺序与 Exactly-Once?
顺序只有在同一 binlog、同一连接器任务、同一队列分区和同一消费串行路径同时可控时才能保证;跨连接器、跨分区或重放时可能乱序。工程上按聚合 ID 路由到同一分区,并为乱序补偿设计版本号/状态机。投递语义通常按 At-Least-Once 设计,CDC 或队列可能重复;业务侧用事件 ID 去重表、唯一约束或状态机实现幂等效果。
追问 2:Seata AT 的全局锁为什么是性能瓶颈?
AT 模式在分支事务提交前需向 TC 注册全局锁(行级),同一行的多个分支事务串行获取锁。高并发写同一热点行(如库存)时,全局锁成为串行瓶颈,吞吐远低于本地事务。TCC 在 Try 阶段预留资源(如冻结库存),无全局锁串行,并发好但需业务实现三套接口。这是 AT 与 TCC 性能差异的根因。
追问 3:Saga 补偿失败怎么办?
Saga 是最终一致,补偿失败不能回滚到初始。工程做法:① 重试——补偿操作幂等,失败指数退避重试;② 人工兜底——重试耗尽后告警,转人工对账修复;③ 向前补偿——某些场景可改为重试正向操作而非回滚。关键设计原则:补偿须幂等、可重试、可告警,绝不依赖补偿一定成功。
- 用 Seata AT 扛高并发:全局锁串行,热点行吞吐崩塌,应换 TCC 或 Outbox。
- 混淆 Outbox 与本地消息表:前者由 CDC/队列链路投递,后者由轮询投递;两者都要处理失败重试、顺序、幂等、监控和清理。
- Outbox 消费端不幂等:CDC 重复投递导致重复消费,必须按事件 ID 去重。
- Saga 补偿不幂等:补偿重试时重复扣款/重复发货,补偿操作必须设计为幂等。
- TC/存储没有做高可用:无论 Raft 还是 DB 模式,都要验证注册中心、存储、网络分区和版本升级路径;不能只看协议名。
对账窗口与差异分级:Outbox/Saga 上线必须绑定对账 SLA;超窗未处理进红线,资金类差异冻结写路径后再补偿。
锁问题常被误判为「Redis 挂了」。先分型:未持锁并行、过期误删他人锁、TTL 短于业务、续期失败。
正确的锁至少具备:互斥、持有者标识(owner)、过期、校验 owner 再解锁;高正确性场景还要 fencing token。锁不是事务,不能替代 DB 约束。
排查顺序:
- 业务是否出现并行写入(对账/日志证据)。
- 锁 key/value(owner)是否匹配。
- TTL 与业务 RT 对比。
- 续期/watchdog 是否开启且成功。
- 解锁是否校验 owner。临时止血:扩 TTL 或降并发,再修正确性。
核对:lock key 设计、TTL、watchdog、是否需要 fencing。指标:锁获取失败率、持锁时长分布、误删告警。禁止 unlock 别人的锁。
失败模式:setnx 无过期;过期后删别人锁;用锁当事务。回滚点:恢复旧 TTL/关闭错误解锁逻辑;必要时用 DB 唯一约束兜底。
解锁报错优先查 owner 不匹配与时钟/TTL;锁太久查续期失败与业务 RT。锁失败率突增时先降并发再改代码。
正确性底线:DB 唯一约束/版本号兜底;锁只是互斥优化,不是事务替代。
量化:TTL 应 ≥ 业务 P99 耗时 × 3 且必须开 watchdog 续期;锁获取失败率 > 1% 或持锁时长 P99 突增 50%,先降并发再看代码。演进:高正确性场景(资金 / 库存)加 fencing token,并保留 DB 唯一约束 / 版本号作最终兜底;「解锁校验 owner」封装成工具方法强制走,禁止裸 DEL。