CHAPTER 09 / DISTRIBUTED SYSTEMS

第 09 篇 · 分布式系统

本篇先钉一致与事务模型,再走消息、锁/ID、时钟、可观测与多活——分布式是取舍题,不是组件堆砌题。

骨架见 M07。本篇只补领域细枝,框在 核心方法论 · 开篇。

题目数:17 | 高级 ×7 | 架构 ×8 | 专家 ×2 | 预计复习:205 分钟
核心知识链

分布式主线是「一致/分区取舍 → 事务与消息实现 → 协调原语(锁/ID/时钟)→ 可观测与容灾多活」。

  1. CAP/BASE 与事务谱系:分区下 C/A 权衡决定强一致还是最终一致;2PC/TCC/Seata AT/SAGA/Outbox 各自用阻塞、侵入或补偿换原子性(C09.01/02/16)。
  2. 消息可靠、顺序与积压:Kafka 吞吐/ISR/EOS、通用 MQ 全链路确认与死信、千万级积压的止血—转存—回补(C09.04/05/09)。
  3. 锁、ID、哈希与幂等:Redis/ZK/DB 锁正确性边界与误用分型、号段/雪花与时钟回拨、一致性哈希迁移量、Token+状态机幂等(C09.03/06/07/08/17)。
  4. 共识、时钟与会话/缓存:崩溃 vs 拜占庭、Raft/Paxos 落地;物理钟不可默认排序,Lamport/HLC/TSO;会话集中或消除;Cache-DB 一致等级先定再选路径(C09.10/12/13/14)。
  5. 追踪与容灾多活:Metrics/Log/Trace 以 TraceId 联动;RPO/RTO 驱动单元化、切流、对账与持续演练(C09.11/15)。
工程化要点
  • 架构评审先写失败模型与一致性承诺(最大不一致窗口、补偿责任),再选中间件;禁止「先定组件再找理由」。
  • 写路径上线清单:幂等键/去重表、超时重试预算、死信与人工补偿、对账任务与告警;缺一项不得上线。
  • 消息与 DB 双写默认 Outbox/CDC 或事务消息;禁止无补偿的「先发消息再写库」。
  • 分布式锁/租约必须处理续期失败、过期误删与脑裂;误用按分型排查(未持锁并行 / TTL 短于业务 / 解锁他人),禁止当银弹。
  • 定期混沌:超时、分区、重复投递、时钟回拨;结果进入错误预算与发布门禁讨论。
  • 多活/单元化先划爆炸半径与数据归属,再谈同步;切流开关与对账可演练,「接近 0 RPO」靠自动化与验证,不靠口号。
  • ID/时钟设计写明失效模式;跨机房禁止假设 NTP 完美同步;雪花类方案有回拨预案。
  • 链路追踪与结构化日志默认接入跨服务上下文;排障路径固定为「现象 → Trace → 分区/副本/消费位点证据」。
本篇思维模式
  • 先选一致模型:业务承诺决定技术栈,组件是实现细节。
  • 副作用必须幂等:重试是常态,写路径可重入或可去重。
  • 协调原语有租约:锁/领导选举要处理续期失败与脑裂,不能只演示成功路径。
  • 对账兜底:最终一致系统以对账与补偿为正确性最后一道闸。
  • 超时有端到端预算:重试风暴按事故预防,链路可加总。
  • 故障域隔离:爆炸半径与降级开关优先于同构多机房堆积。
  • 证据驱动排障:积压/锁不住/不一致先分型再动作,忌盲目扩容或跳位点。
C09.01
CAP 定理的证明与 BASE 理论如何指导实践?架构P1
本质问题

这道题考察对分布式系统"网络分区下一致性(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 在分区下不可兼得。

flowchart TD subgraph CAP["CAP 三角(P 不可选,实为 C vs A 权衡)"] direction TB C["Consistency 一致性
线性一致性:读返回最新写"] 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
图 9.1-1 CAP 定理:P 必然存在,分区时 C 与 A 二选一

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 版本分布、插桩覆盖率和遥测成本。灰度时先影子管道,再逐步切换真实后端。
C09.02
分布式事务的 2PC/3PC/TCC/Seata AT/SAGA 原理与选型?专家P1
本质问题

本质考察分布式事务方案的完整演进链:从强一致(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 事务消息):本地事务+消息表原子写入,异步投递消费端。最轻量,但只适合"最终一致+异步"场景。

flowchart LR A["2PC
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
图 9.2-1 分布式事务演进:从强一致阻塞到柔性最终一致

趋势:① 从"数据库层强一致"到"业务层柔性事务"——XA 几乎被淘汰,TCC/AT/SAGA 主流;② 从"同步阻塞"到"异步补偿"——长事务用 SAGA,短事务用 AT;③ 与消息队列融合——RocketMQ 事务消息、Kafka 事务实现"事务+消息"一体;④ Serverless 下的分布式事务仍是难题(无状态函数间状态协调)。

第一性原理

2PC 流程与缺陷:

sequenceDiagram participant C as 协调者 participant P1 as 参与者1 participant P2 as 参与者2 C->>P1: Prepare(准备+锁资源) C->>P2: Prepare(准备+锁资源) P1-->>C: Yes(持锁等待) P2-->>C: Yes(持锁等待) Note over C: 全部Yes则Commit C->>P1: Commit C->>P2: Commit P1-->>C: ACK P2-->>C: ACK Note over C,P2: 缺陷: 协调者宕机→参与者阻塞持锁
图 9.2-2 2PC 时序:Prepare 持锁 + Commit,协调者单点导致阻塞

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/位点与状态机。指标:差异笔数、补偿成功率、超窗未处理数。失败模式:只投递无对账;补偿不幂等;把延迟当成功。

C09.03
分布式锁的三种实现(Redis/ZK/数据库)对比?架构P1
本质问题

本质考察分布式锁的底层机制、正确性边界、性能权衡。不要只背 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 公平锁流程:

flowchart TD A["客户端创建临时顺序节点
/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
图 9.3-1 ZK 临时顺序节点公平锁:watch 前驱避免惊群

三种方案对比:

维度 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 的锁释放不依赖"业务侧时钟",更抗时钟漂移。

C09.04
Kafka 如何保证高吞吐、可靠投递与消息顺序?架构P0
本质问题

本质考察 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 停顿。

第一性原理

高吞吐四大机制:

  1. 分区并行:Topic 分多 Partition,分布在不同 Broker,生产/消费并行。吞吐 ∝ 分区数 × 单分区吞吐。
  2. 顺序写磁盘:消息追加到 Segment 文件末尾,磁盘顺序写速度接近内存(600MB/s vs 随机写 100KB/s)。
  3. 页缓存(Page Cache):生产者写到 OS Page Cache 即返回,由 OS 异步刷盘;消费者从 Page Cache 命中读取,避免 JVM 堆内存拷贝。Kafka 进程崩溃数据仍在 Page Cache。
  4. 零拷贝(sendfile):消费者读消息时,数据从 Page Cache 直接经 DMA 传到网卡,跳过用户态拷贝(传统 read+write 需 4 次拷贝 4 次上下文切换,sendfile 仅 2 次拷贝 2 次切换)。

可靠投递:ISR 副本机制:

flowchart LR P["Producer"] -->|"acks=all"| L["Leader Partition"] L -->|"同步复制"| F1["Follower1
(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
图 9.4-1 Kafka ISR 机制:Leader 等待 ISR 全部 ack,HW 取 ISR 最小 LEO

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。
扩展:Kafka Tiered Storage 落地边界

解决什么:本地盘成本随保留时长线性上涨;Tiered Storage(KIP-405)把冷段迁到对象存储,Broker 本地保留热数据与索引。

何时启用:长保留(天~周以上)、读冷比例可控、可接受冷读延迟;审计/回溯类 Topic 优先。

工程清单:① 对象存储权限与跨 AZ 成本;② 冷读放大与 Fetch 超时调参;③ 与压缩/紧凑 Topic 策略兼容性;④ 监控本地 vs 远端命中率;⑤ 升级路径确认所用小版本 Tiered 是否 GA。

不是银弹:超高频随机冷读、强实时全量重放场景,分层可能拖垮尾延迟——此时缩短保留或独立冷数据管道更合适。

生产实践

上线门禁:一致性承诺书面化、超时重试预算、补偿可审计。表达提纲:模型选型→失败模式→观测与演练。

P0 表达提纲

先给可观测证据与门禁,再谈实现细节;避免只报组件名。

C09.05
消息积压 1000 万条如何紧急处理?高级P1
本质问题

本质考察线上消息积压的紧急处理方法论:止血 → 定位根因 → 补偿回溯 → 预防。核心是理解积压的本质"消费速率 < 生产速率"持续累积;止血时优先提升消费速率或降低生产速率,确需跳过堆积前必须先记录/转存旧区间,再移动消费位点,并受分区数(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 中消费者数 > 分区数时多余消费者空闲,扩容上限 = 分区数。

紧急处理三步法:

flowchart TD A["发现积压1000万
告警触发"] --> 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
图 9.5-1 消息积压紧急处理决策树

关键约束: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 会永久丢失积压消息。
  • 回溯不幂等:实时链路已处理部分消息,回溯重复消费导致脏数据。
  • 忽视毒药消息:单条消息持续抛异常导致消费者卡死,需死信队列 + 跳过策略。
  • 只止血不查根因:扩容缓解后不排查根因,下次复发更严重。
  • 回溯冲击实时链路:回溯消费未限速,与实时消费争抢资源二次积压。
C09.06
分布式 ID 生成方案如何选型?架构P1
本质问题

本质考察分布式唯一 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 位结构:

flowchart LR subgraph Snowflake["Snowflake 64 bit 结构"] direction LR S["1 bit
符号位
(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
图 9.6-1 Snowflake 64 位结构:时间戳+机器ID+序列号

同一毫秒内序列号自增,同机器同毫秒最多 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 安全、重启浪费边界和主键写入影响。表中数字是压测模板与示例参数;上线结论必须来自目标数据规模、实例数和硬件下的实测数据。

C09.07
一致性哈希如何解决分布式缓存的数据分布?高级P0
本质问题

本质考察传统哈希取模的扩容痛点与一致性哈希环 + 虚拟节点的解决原理。核心是理解"扩缩容时数据迁移量"这一指标——传统哈希取模扩容迁移几乎所有数据,一致性哈希只迁移相邻区间数据,虚拟节点解决数据倾斜。

演进脉络

哈希取模 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 个槽,节点负责槽区间。扩缩容迁移的是槽(离散单元),而非连续区间。本质是"预分配固定槽 + 节点负责槽区间"的折中方案,避免一致性哈希的虚拟节点管理复杂度。

第一性原理

一致性哈希环:

flowchart TD subgraph Ring["一致性哈希环 0 ~ 2^32-1"] direction TB N1["Node A
(含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
图 9.7-1 一致性哈希环:扩容只迁移相邻区间,虚拟节点解决倾斜

迁移量对比:

方案 扩容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(雪崩)。
  • 一致性哈希不配合虚拟节点:节点少时倾斜,某节点过载。
生产实践

上线门禁:一致性承诺书面化、超时重试预算、补偿可审计。表达提纲:模型选型→失败模式→观测与演练。

P0 表达提纲

先给可观测证据与门禁,再谈实现细节;避免只报组件名。

C09.08
接口幂等设计如何实现?高级P1
本质问题

本质考察幂等性的定义、天然幂等与设计幂等、各方案的适用场景。核心是区分"幂等(重复执行结果相同)"与"防重(阻止重复提交)",并按业务场景(查询/写入/更新/状态流转)选择对应机制。底层逻辑是"用唯一标识 + 状态约束保证重复请求不产生副作用"。

演进脉络

天然幂等:查询(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 机制流程:

sequenceDiagram participant F as 前端 participant B as 后端 participant R as Redis F->>B: 1. GET /token (获取令牌) B->>R: SET token_value EX 600 B-->>F: 返回 token_value F->>B: 2. POST /order (Header: X-Idempotent-Token) B->>R: CHECK token + SET used (Lua 原子) Note over B: token 存在则置为已用;不存在则按重复/过期处理 B->>B: 3. 执行业务(创建订单) B->>B: 4. DB 唯一约束/状态机兜底;失败补偿或标记重试 B-->>F: 返回结果
图 9.8-1 Token 快路径:Lua 原子置为已用,数据库约束/状态机兜底

乐观锁与状态机组合(支付回调幂等):

// 支付回调幂等: 状态机 + 乐观锁
@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 消费幂等标配

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,重投递导致重复扣款。
  • 分布式锁当幂等用:锁只防并发,锁失效后仍可重复,需配合唯一索引/状态机。
C09.09
消息队列如何保证可靠投递与顺序?架构P1
本质问题

本题聚焦通用 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 + 消费幂等"(见工程实践通用幂等双写设计)。

第一性原理

可靠投递全链路:

flowchart LR subgraph P["生产端"] P1["本地事务+消息表
或事务消息"] 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
图 9.9-1 消息可靠投递全链路:生产确认+持久化副本+消费ACK+幂等死信

三段可靠性保障:

  1. 生产端不丢:① 本地消息表——业务事务与消息表插入原子,后台扫描消息表异步发送,成功后标记已发送;② RocketMQ 事务消息——发送半消息(对消费者不可见)→ 执行本地事务 → 提交/回滚半消息,Broker 回查本地事务状态;③ RabbitMQ confirm/return 双机制(见工程实践代码示例)。解决"业务成功但消息发送失败"或"消息未路由到 queue"。
  2. Broker 不丢:持久化(同步刷盘或副本同步)+ 多副本。Kafka acks=all + min.insync.replicas 多副本细节(详见 C09.04);RocketMQ 同步复制刷盘;RabbitMQ Quorum Queue 依赖 Raft 多数派,publisher confirm 在消息复制到多数派后返回(见工程实践配置示例)。
  3. 消费端不丢:手动 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 回查无响应,半消息一直不可见。
C09.10
分布式系统的拜占庭将军问题与共识算法?高级P1
本质问题

本质考察分布式共识的理论基础(拜占庭将军问题、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 选举。

flowchart LR B["拜占庭将军问题
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
图 9.10-1 共识算法演进:从理论(Paxos)到工程(Raft/ZAB)与区块链(PoW)
第一性原理

FLP 不可能定理(Fischer, Lynch, Paterson 1985):其前提是异步、确定性消息传递模型。在该模型中,即使只有一个崩溃故障,也不存在确定性协议能同时保证 safety(不会决定错误值)和 liveness(最终达成共识)。工程系统通过引入超时(部分同步假设)、随机化或可探测的故障变化来换取实际可用性;这不推翻 FLP,而是改变定理前提。

Paxos 核心:两阶段——Prepare(Proposer 提议编号 n,Acceptor 承诺不再接受 <n 的提议)+ Accept(Proposer 发 accept,Acceptor 多数派批准)。关键:多数派交集保证不同多数派必有共同 Acceptor,从而保证已决值不被覆盖。Paxos 难在"多 Proposer 活锁"与"工程拆分(Multi-Paxos)"。

Raft 三大子问题:

  1. Leader 选举:节点三种状态 Follower/Candidate/Leader。Leader 心跳超时则 Candidate 自增 term 请求投票,多数同意则成 Leader。
  2. 日志复制:Leader 收到写请求,追加日志,并行复制到 Follower,多数确认后 commit 并响应客户端。
  3. 安全性:term + 日志连续性 + "已 commit 的日志不被覆盖"约束,保证所有节点状态机执行相同序列。
flowchart TD F["Follower
(被动)"] -->|"选举超时"| 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
图 9.10-2 Raft 节点状态机:Follower↔Candidate↔Leader,随机超时避免活锁

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。

C09.11
分布式系统的链路追踪与可观测性如何落地?架构P1
本质问题

考察可观测性(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 降低侵入,但其覆盖范围以官方支持矩阵为准。

flowchart LR LOG["Logging
文本/结构化
ELK/Loki"] --> OBS["统一可观测性
OpenTelemetry"] MET["Metrics
时序指标
Prometheus"] --> OBS TRA["Tracing
链路追踪
Jaeger/SkyWalking"] --> OBS OBS --> AUTO["自动插桩/Agent/eBPF
仅覆盖受支持组件"] style OBS fill:#dbeafe,stroke:#2563eb
图 9.11-1 可观测性演进:从独立三支柱到统一 OpenTelemetry
第一性原理

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 串联。
C09.12
分布式缓存的一致性如何保障?高级P1
本质问题

考察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 阻塞和本地缓存节点重启。
C09.13
分布式会话共享有哪些方案?高级P1
本质问题

考察无状态服务架构下用户会话(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 主从切换和发布期序列化兼容。
C09.14
分布式系统的全局时钟与顺序保证?架构P0
本质问题

考察分布式环境下没有全局精确时间时如何定义事件顺序。核心回答: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 重复。
  • 混淆并发与先后:向量时钟不可比不代表"同时",只代表"互不知晓"。
生产实践

上线门禁:一致性承诺书面化、超时重试预算、补偿可审计。表达提纲:模型选型→失败模式→观测与演练。

P0 表达提纲

先给可观测证据与门禁,再谈实现细节;避免只报组件名。

C09.15
分布式系统的容灾与多活架构如何设计?专家P1
本质问题

考察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”当作架构定律。

单元化核心设计:

  1. 分片键:按用户ID / 地域划分,确保同一用户所有数据在一个单元。
  2. 路由层:网关/负载均衡根据分片键路由到目标单元,避免跨单元访问。
  3. 数据同步:单元内自包含,跨单元只同步必要全局数据(如商品、配置)。
  4. 冲突消解:若必须跨单元写,用"最后写入优先"或业务层合并(如库存预分配)。
工程实践

容灾等级对比:

架构 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 缓存和读降级;写入收敛到全局可写点后,副本可见性和一致性由复制/共识协议与失效策略决定,不能因为“异步同步”就宣称全局一致。“全局可写点收敛 + 多级读缓存 + 明确失效窗口”是值得评估的模式,不是免验证的默认答案。

C09.16
分布式事务新演进与最新选型?架构P1
本质问题

考察分布式事务的工程化选型能力。核心要讲清:① 以具体版本核对 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 本地消息表:两者都把"业务操作 + 消息发送"收敛到一个本地事务,保证业务与消息原子性。差异在消息投递机制。

flowchart LR subgraph Local["本地消息表:轮询投递"] L1["业务写库
+ 消息表"] --> 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
图 9.16-1 本地消息表(轮询)vs Outbox(CDC 订阅)

方案对比:

维度 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,应用无需感知
}
Outbox 落地要点
  • 消息幂等:消费端按业务 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;超窗未处理进红线,资金类差异冻结写路径后再补偿。

C09.17
分布式锁误用排查:锁不住、锁太久、解锁报错怎么看?高级P1
本质问题

锁问题常被误判为「Redis 挂了」。先分型:未持锁并行、过期误删他人锁、TTL 短于业务、续期失败。

原理映射

正确的锁至少具备:互斥、持有者标识(owner)、过期、校验 owner 再解锁;高正确性场景还要 fencing token。锁不是事务,不能替代 DB 约束。

落地排查

排查顺序:

  1. 业务是否出现并行写入(对账/日志证据)。
  2. 锁 key/value(owner)是否匹配。
  3. TTL 与业务 RT 对比。
  4. 续期/watchdog 是否开启且成功。
  5. 解锁是否校验 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。