分布式协同面试
分布式协同面试
复制
【简单】什么是复制?复制有什么作用?⭐⭐
复制是将同一份数据存储在多台机器上,以提升系统的可用性、可靠性和性能。
复制数据,可能出于各种各样的原因:
- 提高可用性:当部分组件出现位障,系统依然可以继续工作,系统依然可以继续工作。
- 降低访问延迟:使数据在地理位置上更接近用户。
- 提高读吞吐量:扩展至多台机器以同时提供数据访问服务。
【中等】复制有哪些模式?⭐⭐
复制的模式有以下几种:
- 主从复制:所有的写入操作都发送到主节点,由主节点负责将数据更改事件发送到从节点。每个从节点都可以接收读请求,但内容可能是过期值。支持主从复制的系统:
- 数据库:Mysql、PostgreSQL、MongoDB 等
- 消息队列:Kafka、RabbitMQ 等
- 多主复制:系统存在多个主节点,每个都可以接收写请求,客户端将写请求发送到其中的一个主节点上,由该主节点负责将数据更改事件同步到其他主节点和自己的从节点。
- 无主复制:系统中不存在主节点,每一个节点都能接受客户端的写请求。此外,读取时从多个节点上并行读取,以此检测和纠正某些过期数据。支持无主复制的系统:
- 数据库:Cassandra
此外,复制还需要考虑以下问题:
- 同步还是异步
- 如何处理失败的副本
- 如何保证数据一致
【中等】主从复制的工作原理是什么?⭐⭐
主节点负责写操作,从节点同步主节点数据并处理读操作。
最常见的解决方案就是主从复制,其原理如下:
主从复制模式中只有一个主副本(或称为主节点) ,其余称为从副本(或称为从节点)。
- 所有的写请求只能发送给主副本,主副本首先将新数据写入本地存储。
- 然后,主副本将数据更改作为复制的日志或更新流发送给所有从副本。每个从副本获得更新数据之后将其应用到本地,且严格保持与主副本相同的写入顺序。
- 读请求既可以在主副本上,也可以在从副本上执行。
再次强调,只有主副本才可以接受写请求:从客户端的角度来看,从副本都是只读的。如果由于某种原因,例如与主节点之间的网络中断而导致主节点无法连接,主从复制方案就会影响所有的写入操作。

【中等】同步复制、半同步复制、异步复制有什么差异?⭐⭐

一般,复制速度会非常快;但是,系统不能保证复制多久能完成。有些情况下,从节点可能落后主节点几分钟甚至更长时间,例如:从节点刚从故障中恢复;或系统已经接近最大设计上限;或节点之间的网络出现问题。
全同步复制的优缺点:
- 优点:只有所有从节点都完成复制,才视为成功,因此是强一致的。
- 缺点:即使只有一个从节点未完成复制,写入都不能视为成功。所有从节点完成复制过程之前,主节点会阻塞后续所有的写操作。
因此,把所有从节点都配置为同步复制有些不切实际。因为这样的话,任何一个同步节点的中断都会导致整个系统更新停滞不前。
全异步复制的优缺点:
- 优点:不管从节点上数据多么滞后,主节点总是可以继续响应写请求,系统的性能更好。
- 缺点:如果主节点发生故障且不可恢复,则所有尚未复制到从节点的写请求都会丢失。
还有一种折中的方案——半同步复制:只要有一个从节点或半数以上的从节点同步成功,就视为同步,直接返回结果;剩下的节点都通过异步方式同步。万一同步的从节点变得不可用或性能下降,则将另一个异步的从节点提升为同步模式。这样可以保证至少有两个节点(即主节点和一个同步从节点)拥有最新的数据副本。
【中等】新的从节点如何复制主节点数据?⭐⭐
两种不可行的方案:
- 由于主节点会源源不断接受新的写入数据,数据始终处于变化中,因此一次性从主节点复制数据到从节点是无法保证数据一致的。
- 另一种思路是:考虑锁定数据库(使其不可写)来使磁盘上的文件保持一致,但这会违反高可用的设计目标。
可行的方案:初始化连接→全量数据同步→增量数据同步
- 生成主节点某时刻的快照,避免长时间锁定数据库。
- 将快照复制到从节点。
- 从节点复制主节点快照过程中,所有的数据变更写入一个日志中(这个数据变更日志在不同数据库中有着不同的称呼,Mysql 称其为 binlog;Redis 称其为 AOF)。
- 从节点复制完主节点的快照后,请求数据变更日志中的数据,并基于此补全数据,这个过程称为追赶,直至主从数据一致。井重复步骤 1 ~步骤 4 。
【困难】如何通过主从复制技术来实现系统高可用呢?⭐⭐
主从复制实现高可用 = 数据冗余(一主多从) + 心跳监控 + 自动选主切换 + 客户端重定向
数据冗余
- 一个主节点(写+读),多个从节点(读+备份)。
- 数据从主实时/近实时复制到从。
从节点的本地磁盘上都保存了副本收到的数据变更日志。如果从节点从故障中恢复,可以和主节点对比数据变更日志的偏移量,从而确认数据是否滞后。如果数据存在滞后,则向主节点请求数据变更日志,并补全数据。这个过程称为追赶。
故障检测
- 监控服务(如 Prometheus、ZooKeeper)持续检查主节点健康。
- 常用方法:心跳续活、超时判断
自动切换
- 主节点故障时,选主算法(如 Raft、Paxos)自动从从节点中选举新主。
- 配置更新:客户端/中间件(如 Proxy)自动将写请求路由到新主。
主节点失效后,需要选举出新主节点。然后,客户端需要更新路由,将所有写请求发送给新的主节点;其他从节点要接受来自新的主节点上的变更数据。这个过程称之为切换。
主节点切换可以手动或自动进行。自动切换的步骤通常如下:
- 确认主节点失效。有很多种出错可能性,很难准确检测出问题的原因。所以,大多数系统都基于超时机制来确认主节点是否失效:节点间频繁地互相发生发送心跳存活悄息,如果发现某一个节点在一段比较长时间内没有响应,即认为该节点发生失效。
- 选举新的主节点。基于多数派共识选主。候选节点最好与原主节点的数据差异最小,这样可以最小化数据丢失的风险。
- 重新配置系统使新主节点生效。客户端现在需要将写请求发送给新的主节点。原主节点若恢复,需降级处理,避免脑裂。
数据一致性
- 切换后,确保新主拥有最新数据(避免脑裂)。
- 常用方法:半同步复制(确保至少一个从有最新数据)。
【困难】复制日志的工作原理是什么?⭐⭐
复制日志的实现方式:
- 基于语句的复制:将数据写操作写入日志。主要缺点是必须完全按照相同顺序执行,否则可能会产生不同的结果。
- 基于预写日志(WAL)传输:通常每个写操作都是以追加写的方式写入到日志中。主要缺点是日志描述的数据结果非常底层,如果数据库不同版本的存储格式存在差异,就可能无法兼容。
- 对于日志结构存储引擎,日志是主要的存储方式。日志段在后台压缩井支持垃圾回收。
- 对于采用覆写磁盘的 BTree 结构,每次修改会预先写入日志,如系统发生崩溃,通过索引更新的方式迅速恢复到此前一致状态。
- 基于行的逻辑日志复制:如果复制和存储引擎采用不同的日志格式,这样复制与存储的逻辑就可以剥离。这种复制日志称为逻辑日志,以区分物理存储引擎的数据表示。
- 基于触发器的复制:这种方式很灵活,可以定制化控制复制逻辑。主要缺点是复制开销更高,也更容易出错。
对比与选择:
- 追求性能与可靠 → 基于预写日志的复制(适用于同构、同版本)。
- 追求灵活与兼容 → 基于行的逻辑日志复制(现代数据库主流选择)。
- 特殊定制需求 → 基于触发器的复制(需谨慎评估性能)。
- 遗留或简单场景 → 基于语句的复制(不推荐用于强一致性要求)。
【困难】多主复制的工作原理是什么?⭐⭐
对主从复制模型进行自然的扩展,则可以配置多个主节点,每个主节点都可以接受写操作,后面复制的流程类似:处理写的每个主节点都必须将该数据更改转发到所有其他节点。这就是多主节点( 也称为主-主,或主动/主动)复制。此时,每个主节点还同时扮演其他主节点的从节点。
在一个数据中心内部使用多主节点基本没有太大意义,其复杂性已经超过所能带来的好处。
但是,以下场景这种配置则是合理的:
- 多数据中心
- 离线客户端操作
- 协作编辑
有了多主节点复制模型,则可以在每个数据中心都配置主节点。在每个数据中心内,采用常规的主从复制方案;而在数据中心之间,由各个数据中心的主节点来负责同其他数据中心的主节点进行数据的交换、更新。

部署单主节点的主从复制方案与多主复制方案之间的差异
- 性能:对于主从复制,每个写请求都必须经由广域网传送至主节点所在的数据中心。这会大大增加写入延迟,井基本偏离了采用多数据中心的初衷(即就近访问)。而在多主节点模型中,每个写操作都可以在本地数据中心快速响应,然后采用异步复制方式将变化同步到其他数据中心。因此,对上层应用有效屏蔽了数据中心之间的网络延迟,使得终端用户所体验到的性能更好。
- 容忍数据中心失效:对于主从复制,如果主节点所在的数据中心发生故障,必须切换至另一个数据中心,将其中的一个从节点被提升为主节点。在多主节点模型中,每个数据中心则可以独立于其他数据中心继续运行,发生故障的数据中心在恢复之后更新到最新状态。
- 容忍网络问题:数据中心之间的通信通常经由广域网,它往往不如数据中心内的本地网络可靠。对于主从复制模型,由于写请求是同步操作,对数据中心之间的网络性能和稳定性等更加依赖。多主节点模型则通常采用异步复制,可以更好地容忍此类问题,例如临时网络闪断不会妨碍写请求最终成功。
【困难】无主复制的工作原理是什么?⭐⭐
无主复制模式,系统中不存在主节点,每一个节点都能接受客户端的写请求。此外,读取时从多个节点上并行读取,以此检测和纠正某些过期数据。
无主复制流程
- 写入时:
- 客户端并行将数据写入 N 个副本节点(W 个成功即返回)。
- 无固定主节点,所有节点平等。
- 读取时:
- 客户端并行从 N 个副本节点读取数据(读取 R 个响应)。
- 通过版本号(向量时钟、时间戳)识别最新数据。
- 修复与同步:
- 读修复:读取时若发现旧副本,立即用新数据修复它。
- 反熵:后台进程持续同步副本,弥合差异。
QuorumNWR 算法
无主复制模式中,究竟多少个副本完成才可以认为写成功?
如果有 N 个副本,写人需要 W 个节点确认,读取必须至少查询 R 个节点, 则只要 W + R > N,读取的节点中一定会包含最新值。即:确保读取集 (R) 和写入集 (W) 必有重叠,从而一定能读到最新写入。
并发写冲突
无主模式中,并发向多副本写操作,以及读时修复或数据回传都会导致并发写冲突。如何解决冲突呢?有以下几种机制:
- 最后写入获胜 (LWW):简单但可能丢失并发写入。
- 版本向量:跟踪因果历史,识别并发冲突,交应用层解决。
【困难】什么是复制延迟问题?有哪些常见的解决思路?⭐⭐
复制延迟问题是指:在异步(或半同步)主从复制中,从节点的数据滞后于主节点,导致从从节点读取到过期数据。复制延迟的原因包括:网络延迟、从节点负载高、大事务、批量写入等。
复制延迟引发的典型问题:
- 读写不一致:用户写入主节点后,立即从从节点读取,读到的还是旧数据(Read-After-Write 问题)。
- 跨会话不一致:用户 A 发帖,用户 B 立即查看,由于读到的是未同步的从节点,看不到 A 的帖子。
- 因果关系破坏:先回复评论,再查看帖子,发现回复在帖子之前出现(时间倒错)。
常见的解决思路:
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 读己之写一致性 | 用户写主后,读主(或读从 + 超时回退读主) | 用户修改个人资料后立即查看 |
| 单调读一致性 | 同一用户始终读同一从节点(基于用户 ID 哈希路由) | 防止"数据倒退"(读到新再读旧) |
| 读前缀一致性 | 按因果顺序读取(如版本号、时间戳) | 聊天、评论等有因果关系的场景 |
| 同步/半同步复制 | 等待从节点同步后再返回写成功 | 对一致性要求高的场景 |
| 读写分离 + 强制读主 | 关键路径强制从主节点读取 | 写后立即读的场景 |
Read-After-Write(读己之写)一致性实现策略:
- 读主策略:用户写后的某段时间内(如 1 分钟),该用户的所有读请求都路由到主节点。
- 时间戳策略:从节点记录最后同步的主节点时间戳。读请求携带客户端的最后写时间戳,如果从节点同步时间 < 该时间戳,则拒绝读取(或等待)。
- 会话粘连:同一会话内的读写都路由到主节点(牺牲读扩展性)。
【困难】无主复制中如何解决并发写冲突?⭐
无主复制模式下,多个客户端并发写入同一 key 到不同副本,会产生冲突。常见的冲突解决机制:
1. 最后写入获胜(LWW, Last Write Wins)
- 每个写入附带时间戳,保留时间戳最大的版本,丢弃其他版本。
- 优点:简单,易于实现。
- 缺点:可能丢失并发写入;依赖时钟同步,时钟偏移会导致错误丢弃。
- 适用:缓存等对数据丢失不敏感的场景。
2. 版本号 / 向量时钟
- 每个副本维护一个版本号(或向量时钟),记录数据的因果历史。
- 读取时比较版本号,识别并发写入,交由应用层解决冲突。
- 优点:不丢失数据,能识别并发。
- 缺点:应用层需处理冲突,复杂度高。
- 适用:Riak、Voldemort 等 Dynamo 风格数据库。
3. 读修复(Read Repair)
- 读取时发现多个副本版本不一致,将最新版本写回旧副本。
- 优点:自愈机制,无需额外后台进程。
- 缺点:只在读取时修复,未被读取的数据可能长期不一致。
4. 反熵(Anti-Entropy)
- 后台进程持续比较副本间数据差异,修复不一致。
- 优点:最终一致性保证。
- 缺点:延迟较高,占用后台资源。
分区
【简单】什么是分区?为什么要分区?⭐⭐
分区(Partitioning):将大数据集水平切分成多个独立子集,分散到不同节点存储与管理。
分区的核心思想是:分而治之。
分区的目的:
- 突破单机极限:数据量、吞吐量
- 提升扩展性:数据与负载线性扩展:加节点 → 加容量与性能。
- 实现局部性优化:将数据就近部署到用户所在区域(地理分区),降低访问延迟。
【中等】分区有哪些模式?⭐⭐
分区通常与复制结合使用,即每个分区在多个节点都存有副本。这意味着某条记录属于特定的分区,而同样的内容会保存在不同的节点上以提高系统的容错性。
一个节点上可能存储了多个分区。每个分区都有自己的主副本,例如被分配给某节点,而从副本则分配在其他一些节点。一个节点可能既是某些分区的主副本,同时又是其他分区的从副本。
分区主要有两种模式:
- 范围分区:先对关键字进行排序,每个分区只负责一段包含最小到最大关键字范围的一段关键字。对关键字排序的优点是可以支持高效的区间查询,但是如果应用程序经常访问与排序一致的某段关键字,就会存在热点的风险。采用这种方怯,当分区太大时,通常将其分裂为两个子区间,从而动态地再平衡分区。典型代表:HBase
- 哈希分区:将哈希函数作用于每个关键字,每个分区负责一定范围的哈希值。这种方法打破了原关键字的顺序关系,它的区间查询效率比较低,但可以更均匀地分配负载。采用哈希分区时,通常事先创建好足够多(但固定数量)的分区, 让每个节点承担多个分区,当添加或删除节点时将某些分区从一个节点迁移到另一个节点,也可以支持动态分区。典型代表:Elasticsearch、Redis。
【困难】二级索引如何分区?⭐⭐
二级索引是关系数据库的必备特性,在文档数据库中应用也非常普遍。但考虑到其复杂性,许多键值存储(如 HBase 和 Voldemort)并不支持二级索引。此外, 二级索引技术也是 Solr 和 Elasticsearch 等搜索引擎数据库存在之根本。
分区不仅仅是针对数据,二级索引也需要分区。通常有两种方法:
基于文档来分区二级索引(本地索引):二级索引存储在与关键字相同的分区中,这意味着写入时我们只需要更新一个分区,但缺点是读取二级索引时需要在所有分区上并行执行。它广泛用于实践: MongoDB 、Riak、Cassandra、Elasticsearch 、SolrCloud 和 VoltDB 都支持基于文档分区二级索引。

基于词条来分区二级索引(全局索引):它是基于索引的值而进行的独立分区。二级索引中的条目可能包含来自关键字的多个分区里的记录。在写入时,不得不更新二级索引的多个分区;但读取时,则可以从单个分区直接快速提取数据。

【简单】什么是分区再均衡?⭐⭐
分区再均衡:当集群节点数量发生变化时,自动重新分区,使得集群分布均匀的过程。
触发时机:
- 集群伸缩:增加节点(扩容)或减少节点(缩容)。
- 数据倾斜:某个节点负载过高(热点分区)。
- 节点故障:故障节点被移除。
【困难】分区再均衡有哪些策略?⭐⭐⭐
固定数量的分区
要点:
- 预先创建远多于节点数的固定分区(如 1000 个)。
- 增删节点时,只需在节点间转移部分分区,无需修改分区键范围。
创建远超实际节点数的分区数,然后为每个节点分配多个分区。接下来, 如果集群中添加了一个新节点,该新节点可以从每个现有的节点上匀走几个分区,直到分区再次达到全局平衡。
选中的整个分区会在节点之间迁移,但分区的总数量仍维持不变,也不会改变关键字到分区的映射关系。这里唯一要调整的是分区与节点的对应关系。考虑到节点间通过网络传输数据总是需要些时间,这样调整可以逐步完成,在此期间,旧分区仍然可以接收读写请求。

原则上,也可以将集群中的不同的硬件配置因素考虑进来,即性能更强大的节点将分配更多的分区,从而分担更多的负载。
目前,Riak、Elasticsearch、Couchbase 和 Voldemort 都支持这种动态平衡方法。
使用该策略时,分区的数量往往在数据库创建时就确定好,之后不会改变。原则上也可以拆分和合并分区(稍后介绍),但固定数量的分区使得相关操作非常简单,因此许多采用固定分区策略的数据库决定不支持分区拆分功能。所以,在初始化时,已经充分考虑将来扩容增长的需求(未来可能拥有的最大节点数),设置一个足够大的分区数。而每个分区也有些额外的管理开销,选择过高的数字可能会有副作用。
动态分区
分区按数据量自动分裂与合并(如达到阈值就分裂)。
对于采用关键宇区间分区的数据库,如果边界设置有问题,最终可能会出现所有数据都挤在一个分区而其他分区基本为空,那么设定固定边界、固定数量的分区将非常不便:而手动去重新配置分区边界又非常繁琐。
因此, 一些数据库如 HBase 和 RethinkDB 等采用了动态创建分区。当分区的数据增长超过一个可配的参数阔值(HBase 上默认值是 10GB),它就拆分为两个分区,每个承担一半的数据量。相反,如果大量数据被删除,并且分区缩小到某个阈值以下,则将其与相邻分区进行合井。该过程类似于 B 树的分裂操作。
每个分区总是分配给一个节点,而每个节点可以承载多个分区,这点与固定数量的分区一样。当一个大的分区发生分裂之后,可以将其中的一半转移到其他某节点以平衡负载。对于 HBase,分区文件的传输需要借助 HDFS。
动态分区的一个优点是分区数量可以自动适配数据总量。如果只有少量的数据,少量的分区就足够了,这样系统开销很小;如果有大量的数据,每个分区的大小则被限制在一个可配的最大值。
但是,需要注意的是,对于一个空的数据库, 因为没有任何先验知识可以帮助确定分区的边界,所以会从一个分区开始。可能数据集很小,但直到达到第一个分裂点之前,所有的写入操作都必须由单个节点来处理, 而其他节点则处于空闲状态。为了缓解这个问题,HBase 和 MongoDB 允许在一个空的数据库上配置一组初始分区(这被称为预分裂)。对于关键字区间分区,预分裂要求已经知道一些关键字的分布情况。
动态分区不仅适用于关键字区间分区,也适用于基于哈希的分区策略。MongoDB 从版本 2.4 开始,同时支持二者,井且都可以动态分裂分区。
按节点比例分区
每个节点持有固定数量的分区(如 Redis Cluster 的哈希槽)。
采用动态分区策略,拆分和合并操作使每个分区的大小维持在设定的最小值和最大值之间,因此分区的数量与数据集的大小成正比关系。另一方面,对于固定数量的分区方式,其每个分区的大小也与数据集的大小成正比。两种情况,分区的数量都与节点数无关。
Cassandra 和 Ketama 则采用了第三种方式,使分区数与集群节点数成正比关系。换句话说,每个节点具有固定数量的分区。此时, 当节点数不变时,每个分区的大小与数据集大小保持正比的增长关系; 当节点数增加时,分区则会调整变得更小。较大的数据量通常需要大量的节点来存储,因此这种方法也使每个分区大小保持稳定。
当一个新节点加入集群时,它随机选择固定数量的现有分区进行分裂,然后拿走这些分区的一半数据量,将另一半数据留在原节点。随机选择可能会带来不太公平的分区分裂,但是当平均分区数量较大时(Cassandra 默认情况下,每个节点有 256 个分区),新节点最终会从现有节点中拿走相当数量的负载。Cassandra 在 3.0 时推出了改进算洁,可以避免上述不公平的分裂。
随机选择分区边界的前提要求采用基于哈希分区(可以从哈希函数产生的数字范围里设置边界)。这种方法也最符合本章开头所定义一致性哈希。一些新设计的哈希函数也可以以较低的元数据开销达到类似的效果。
【困难】如何确定读写请求发往哪个节点?⭐
当数据集分布到多个节点上,需要解决一个问题:当客户端发起请求时,如何知道应该连接哪个节点?如果发生了分区再平衡,分区与节点的对应关系随之还会变化。
这其实属于一类典型的服务发现问题,任何通过网络访问的系统都有这样的需求,尤其是当服务目标支持高可用时(在多台机器上有冗余配置)。
服务发现有以下处理策略:
- 客户端路由:
- 客户端内置/依赖 SDK,知晓分区与节点映射(元数据)。
- 直接连接目标节点,无中间跳转。
- 优点:低延迟,架构简单。
- 缺点:客户端需感知拓扑变化。
- 代表:Cassandra、Redis Cluster。
- 代理路由:
- 请求先发往独立的代理/中间件(如 ProxySQL、MyCat)。
- 代理根据路由规则转发到正确节点。
- 优点:客户端无感知,集中管理。
- 缺点:增加一跳延迟,代理可能成瓶颈。
- 代表:数据库中间件、服务网格 Sidecar。
- 服务端重定向:
- 客户端先发请求到任意节点,若数据不在该节点,则收到重定向响应(含正确节点地址)。
- 客户端再向正确节点发送请求。
- 优点:客户端无初始元数据。
- 缺点:增加一次往返。
- 代表:MongoDB 分片集群(
mongos路由)、DynamoDB(SDK 缓存路由表)。

许多分布式数据系统依靠独立的协调服务(如 ZooKeeper )跟踪集群范围内的元数据。每个节点都向 ZooKeeper 中注册自己, ZooKeeper 维护了分区到节点的最终映射关系。其他参与者(如路由层或分区感知的客户端)可以向 ZooKeeper 订阅此信息。一旦分区发生了改变,或者添加、删除节点, ZooKeeper 就会主动通知路由层,这样使路由信息保持最新状态。
例如,HBase、SolrCloud 和 Kafka 也使用 ZooKeeper 来跟踪分区分配情况。MongoDB 有类似的设计,但它依赖于自己的配置服务器和 mongos 守护进程来充当路由层。
Cassandra 和 Redis 则采用了不同的方法,它们在节点之间使用 gossip 协议来同步群集状态的变化。请求可以发送到任何节点,由该节点负责将其转发到目标分区节点。这种方式增加了数据库节点的复杂性,但是避免了对 ZooKeeper 之类的外部协调服务的依赖。

【中等】如何解决分区热点问题?⭐⭐
分区热点(Hotspot) 是指:大量请求集中访问某个分区,导致该分区所在节点负载过高,而其他节点空闲。热点问题会削弱分区的负载均衡效果。
热点产生的原因:
- 数据倾斜:某些 key 的访问频率远高于其他 key(如热门商品、明星用户)。
- 单调递增的 key:基于时间戳或自增 ID 的 key,写入总是落在最后一个分区。
- 范围查询集中:基于范围分区时,某段范围的查询特别频繁。
解决方案:
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 哈希分片 | 对 key 做哈希,打散到不同分区 | 无法预知热点的场景 |
| key 添加随机前缀 | 在热点 key 前加随机数(如 商品ID_0~9),分散到多分区 | 热点 key 可识别的场景 |
| 一致性哈希 | 节点增减时只影响相邻分区,减少数据迁移 | 节点频繁增减的场景 |
| 虚拟槽 | Redis Cluster 的 16384 个虚拟槽,节点负责多个槽 | 中大规模缓存集群 |
| 热点缓存 | 热点数据多副本缓存,读取时随机选择副本 | 读多写少的热点数据 |
| 客户端缓存 | 热点数据在客户端本地缓存,减少对服务端的请求 | 读多写少且容忍短暂不一致 |
Redis Cluster 的热点 key 处理
Redis Cluster 对于热点 key,常见的处理方式:
- 客户端本地缓存:如 Guava Cache、Caffeine,减少对 Redis 的访问。
- key 分片:将热点 key 拆分为多个子 key(如
hotkey_0~hotkey_9),分散到不同节点,读取时随机选一个。 - 读写分离:热点的读请求分散到从节点。
- proxy 层拦截:如 Twemproxy、Codis 在代理层做热点识别和限流。
【中等】分区再均衡过程中如何保证服务不中断?⭐
分区再均衡(Rebalancing)涉及大量数据在节点间迁移,如果处理不当,会导致服务中断或性能抖动。主流数据库通过以下策略保证再均衡期间的可用性:
1. 滚动迁移
- 一次只迁移一个分区(或分区的一部分),迁移期间旧分区继续提供服务。
- 迁移完成后,更新路由表,将流量切到新节点。
- 类似蓝绿部署,保证任何时候都有可用的副本。
2. 双写 / 影子写
- 再均衡期间,写入同时发往新旧两个节点。
- 迁移完成后,停止旧节点的写入。
- 优点:无停机;缺点:写入开销加倍。
3. 追赶机制
- 迁移期间,记录源节点上的增量变更日志。
- 全量数据迁移完成后,回放增量日志,使新节点追上最新状态。
- 类似于主从复制的追赶过程。
4. 限流
- 再均衡期间,控制迁移速度,避免占用过多网络/磁盘 IO 影响正常请求。
- 在业务低峰期执行再均衡。
再均衡的注意事项
- 避免频繁再均衡:频繁的再均衡会导致数据反复迁移,影响性能。应设置合理的触发阈值(如负载差异超过 30% 才触发)。
- 预分裂:对于动态分区,空数据库启动时可以预创建一批初始分区(如 HBase 的
region预分裂),避免冷启动时所有写入集中在一个节点。 - 元数据一致性:再均衡期间,路由表的更新必须原子化,避免客户端路由到错误节点。
分布式共识
【简单】什么是分布式共识?共识和一致性有什么区别?⭐⭐⭐
分布式共识(Consensus) 是指:分布式系统中的多个节点就某一项提议(proposal)达成一致。一个或多个节点可以提议某些值,集群中的所有有效节点根据共识算法进行协商,最终决议(decide)采纳某个节点的提议。
共识算法必须满足以下四个性质:
- 达成一致(Uniform Agreement):没有两个节点的决定不同。
- 完整性(Integrity):每个节点最多决议一次。
- 有效性(Validity):如果一个节点决定了值
v,则v由某个节点所提议。 - 终止(Termination):由所有未崩溃的节点来最终决议。
共识(Consensus)与一致性(Consistency)的区别:
| 共识(Consensus) | 一致性(Consistency) | |
|---|---|---|
| 定义 | 多个节点就某个值达成一致的方法与过程 | 多个数据副本之间的状态差异 |
| 关注 | 关注的是如何达成一致(算法层面) | 关注的是对外呈现的状态(模型层面) |
| 关系 | 共识是手段 | 一致性是目的 |
很多中文资料把 Consensus 翻译为一致性,其实是不准确的。共识算法(如 Raft、Paxos)用于实现一致性模型(如线性化、顺序一致性)。
【中等】什么是 CAP 理论?BASE 理论又是什么?⭐⭐⭐
一句话概括:CAP 指出分区发生时一致性(C)与可用性(A)只能二选一;BASE 是偏 A 一侧时的最终一致性工程方法论。定理本身的严格定义、证明思路与常见误解在《分布式理论面试》的 CAP/BASE 专题中已展开,本题聚焦协同组件选型的工程视角。
协同组件的 CAP 选型全景
| 组件 | CAP 取向 | 选型理由 | 分区时的实际行为 |
|---|---|---|---|
| ZooKeeper | CP | 定位是分布式协调:锁、选主、元数据。这类数据一旦不一致就是脑裂、双写等灾难,宁可短暂不可用也不能错 | 少数派分区拒绝服务;Leader 选举期间(约 200ms ~ 数秒)写不可用 |
| etcd | CP | K8s 集群状态的唯一事实源,错一个 key 就可能导致调度错乱 | 同 ZooKeeper,Raft 多数派不可用时拒绝写 |
| Eureka | AP | 注册中心的服务列表短暂滞后不致命(客户端本地有缓存),但服务发现不可用会直接拖死所有调用方 | 自我保护:心跳续约率低于 85% 时不再摘除实例,宁可保留已下线的实例 |
| Nacos | 可切换 | 同时支持临时实例(AP,Distro 协议)和持久实例(CP,Raft 协议),用同一套产品覆盖两类需求 | 临时实例分区时保可用;持久实例分区时保一致 |
| Consul | CP 为主 | 服务目录 + 健康检查要求一致视图,但提供 stale 读模式换取可用性与读吞吐 | 默认一致读;stale 模式允许任意节点响应 |
选型经验法则:看"数据不一致的代价"与"短暂不可用的代价"哪个大。锁、选主、配额这类"错了就是事故"的数据 → CP(ZooKeeper/etcd);服务列表、配置快照这类"客户端有缓存兼底、滞后不致命"的数据 → AP(Eureka/Nacos AP)。
踩坑案例:某团队用 ZooKeeper 做 Dubbo 注册中心,某次机房交换机割接造成约 5 秒的网段隔离,触发 ZK 选主,期间服务发现全部不可用;而 Dubbo 客户端未开启本地注册表缓存,新发布的服务无法注册、部分客户端重连风暴,可用性被 ZK 的 CP 行为放大成 P1 故障。修复:① 开启 Dubbo 本地缓存文件(注册中心宕机时用上次快照启动);② 评估后把注册中心迁到 Nacos AP 模式;③ ZK 仅保留给强一致用途(分布式锁、Kafka 元数据)。教训:CP 组件的"不可用窗口"会沿着依赖链放大,使用 CP 注册中心必须配套客户端缓存兼底。
拓展追问
- 为什么 Nacos 能同时支持 AP 和 CP?
Nacos 按实例类型分流:临时实例用 AP 的 Distro 协议(各节点对等、异步互相同步,分区时各自可写),持久实例用 CP 的 Raft 协议(写必须过半确认)。这印证了 CAP 的正确用法:不是给系统贴标签,而是按数据类型分别选择策略。 - Eureka 的自我保护为什么是 AP 的典型设计?
网络分区时,Eureka 无法区分"实例真挂了"还是"心跳丢了"。摘错实例的代价(流量打到不存在的地址)小于保护不足,所以它选择宁可保留可能过期的注册信息,把正确性交给客户端重试 + 负载均衡兼底。 - 注册中心选 AP 之后,还需要担心不一致吗?
需要,但风险转移到了客户端:AP 注册中心可能把已下线的实例推送给调用方,因此必须配套客户端容错(重试、超时、负载均衡剔除坏节点)和本地缓存。AP 不是"免一致性",而是把一致性责任从服务端下沉到客户端。
场景题
场景:公司要把微服务注册中心从 ZooKeeper 迁到 Nacos。迁移窗口内,某次机房网络抖动导致 Nacos 两个机房间的节点互相失联约 40 秒,部分客户端读到了旧的服务列表,出现少量调用失败。有人提议回退 ZooKeeper,你怎么决策?
- 应急处理:先确认故障面:旧列表导致的失败是"调用已下线实例",靠客户端重试 + 坏节点剔除即可自愈;对持续报错的服务开启降级开关,观察失败率回落后再评估。
- 根因分析:这正是 AP 组件的预期行为:分区时两侧各自可读写,恢复后合并产生短暂旧数据。与 ZK 对比:同样 40 秒抖动,ZK 会因无法过半而整体写不可用(注册、心跳全断),故障面更大。所以问题不在"选错了 AP",而在"客户端对 AP 的容错配套没做足"(缓存、重试、剔除策略)。
- 长期方案:① 客户端开启本地服务列表快照,启动时先用快照兼底;② 负载均衡层启用坏节点自动剔除(连续失败 N 次熔断);③ 关键链路保留双注册中心过渡,灰度验证后再下线 ZK;④ 把"分区时读到旧列表"纳入混沌演练预案。
- 权衡:回退 ZK 是把"少量调用失败"换成"全量注册/发现不可用",代价反而更大。正确结论:AP 选型的成立条件不是"不会不一致",而是"不一致的代价被客户端容错吸收"——把配套做足,而不是退回 CP。
【中等】什么是 FLP 不可能定理?它对分布式系统有什么影响?⭐⭐
FLP 不可能定理(Fischer、Lynch、Paterson 三人提出)论证了:在一个异步系统中,即使只有一个进程出现了故障,也没有算法能保证达成共识。
简单来说,在一个异步系统中,由于进程可以随时发出响应,所以没有办法分辨一个进程是速度很慢还是已经崩溃,这不满足终止性(Termination)。
FLP 的现实意义
FLP 是一种限制性很强的理论模型,它假定:
- 共识算法不能使用任何时钟或超时。
- 消息延迟没有上限。
如果允许算法使用超时或其他方法来识别可疑的崩溃节点(即使怀疑有时是错误的),则共识变为一个可解的问题。因此,虽然 FLP 是关于共识不可能性的重要理论结果,但现实中的分布式系统通常是可以达成共识的(如 Raft、Zab 都依赖超时机制)。
【困难】Paxos、Raft、Zab 三大共识算法有什么区别?⭐⭐⭐
这三种算法都是基于"领导者 + 多数派"的共识模型,但在实现细节上有显著差异:
| 特性 | Paxos | Raft | Zab |
|---|---|---|---|
| 提出者 | Leslie Lamport | Diego Ongaro | Yahoo / ZooKeeper |
| 领导者 | Multi-Paxos 引入 Leader 优化 | 强 Leader 模型 | 强 Leader 模型 |
| 选举 | 较复杂,未明确定义 | 随机超时选举,简单易懂 | 快速 Leader 选举 |
| 日志复制 | 允许日志空洞 | 不允许日志空洞(连续) | 不允许日志空洞 |
| 成员变更 | 支持但复杂 | 原生支持联合共识 | 支持 |
| 可理解性 | 较难,学术论文晦涩 | 易理解,专为教学设计 | 中等 |
| 典型应用 | Chubby、Spanner | etcd、Consul、TiKV | ZooKeeper |
三者的核心思想可以概括为:
- 纪元(Epoch/Term/Ballot):每次选举产生一个单调递增的逻辑时钟,保证每届 Leader 唯一。不同算法中名称不同:Paxos 称 ballot,Raft 称 term,Zab 称 epoch。
- 多数派(Quorum):决议需要获得半数以上节点的同意,保证任何两个多数派必有交集,从而保证一致性。
- 复制状态机:所有节点以相同顺序应用相同的操作日志,保证状态一致。
【中等】什么是 Quorum 机制?为什么多数派能保证一致性?⭐⭐
Quorum(法定人数)机制是分布式共识的基础。其核心思想是:决议需要获得半数以上节点的同意。假设集群有 N 个节点,Quorum 为 ⌈N/2⌉ + 1(即 N/2 + 1 向上取整)。
为什么多数派能保证一致性?
关键在于鸽巢原理:任何两个多数派必然有交集。因为:
- 假设 N = 5,则多数派至少为 3。
- 两个多数派分别至少包含 3 个节点,共 6 个"名额"。
- 但集群只有 5 个节点,所以至少有 1 个节点同时属于两个多数派。
这个交集节点保证了:
- 已经提交的值不会丢失(交集节点知道该值)。
- 新的提案必须通过交集节点的校验,从而保证安全性。
集群节点数为何推荐奇数?
对于 N 个节点的集群,能容忍的故障节点数为 f = (N-1)/2。
| 节点数 | 容忍故障数 | Quorum |
|---|---|---|
| 3 | 1 | 2 |
| 5 | 2 | 3 |
| 7 | 3 | 4 |
对比 5 节点(容忍 2 个故障)和 6 节点(容忍 2 个故障),6 节点并未提升容错能力,但增加了 Quorum 的大小(4 vs 3),降低了写入性能。因此,共识集群的节点数一般要求是奇数。
【困难】什么是全序广播?它与共识有什么关系?⭐
全序广播(Total Order Broadcast) 要求:将消息按照相同的顺序,恰好传递一次,准确传送到所有节点。
全序广播相当于重复进行多轮共识(每次共识决定与一次消息传递相对应):
- 一致同意:所有节点决定以相同的顺序传递相同的消息。
- 完整性:消息不会重复。
- 有效性:消息不会被损坏,也不能凭空编造。
- 终止:消息不会丢失。
全序广播与共识等价:
- 如果有共识算法,可以通过多轮共识实现全序广播(每轮共识决定下一条要发送的消息)。
- 如果有全序广播,可以通过它实现共识(广播一个提议,所有节点按相同顺序收到,取第一条即为决议值)。
Raft 和 Zab 直接实现了全序广播,因为这样做比重复"一次一值(one value a time)"的共识更高效。在 Paxos 的情况下,这种优化被称为 Multi-Paxos。
【简单】Raft 算法的 Leader 选举原理是什么?⭐⭐
Raft 算法通过领导者选举和日志复制两个核心机制实现共识。
节点角色:
- Follower(跟随者):被动接收 Leader 的请求。
- Candidate(候选人):发起选举,竞选 Leader。
- Leader(领导者):处理所有客户端请求,复制日志到其他节点。
Leader 选举流程:
- 选举超时:Follower 在一段时间(随机化的选举超时时间,如 150-300ms)内未收到 Leader 的心跳,转为 Candidate。
- 增加任期:Candidate 将自己的 term 加 1,投票给自己。
- 请求投票:Candidate 向所有节点发送
RequestVoteRPC。 - 获得多数票:如果 Candidate 获得半数以上节点的投票,成为 Leader。
- 心跳维持:Leader 周期性发送心跳(空的
AppendEntriesRPC)给所有 Follower,维持领导地位。
随机化选举超时
Raft 通过随机化选举超时时间来避免多个节点同时发起选举导致选票瓜分(split vote)。每个节点的选举超时时间在一个随机区间内(如 150-300ms),率先超时的节点先发起选举,大概率能获得多数票。
日志复制流程:
- 客户端发送命令给 Leader。
- Leader 将命令追加到本地日志(uncommitted 状态)。
- Leader 并行发送
AppendEntriesRPC 给所有 Follower。 - Follower 收到后追加日志并回复 ack。
- Leader 收到多数派 ack 后,将日志标记为 committed,并应用到状态机。
- Leader 在后续的
AppendEntriesRPC 中通知 Follower 日志已 committed。 - Follower 将日志应用到状态机。
方案权衡:随机超时选举 vs ZAB 式快速选举
| 方案 | 机制 | 优点 | 缺点 / 适用边界 |
|---|---|---|---|
| Raft 随机超时选举 | 各节点独立倒计时,先超时者发起选举 | 实现简单、天然避免活锁;无需全局协调 | 无法保证选出的 Leader 数据最优(靠选举限制保证不丢已提交日志);适合通用共识场景 |
| ZAB FastLeaderElection | 全局选票 PK(zxid 优先),选数据最新的节点 | 新 Leader 数据最完整,同步开销最小 | 需要所有节点互连交换选票,消息量更大;适合主备复制场景 |
选举机制的核心权衡是:随机超时牺牲一点"选最优"换"简单 + 不活锁";而"已提交日志不丢"的安全底线,Raft 靠选举限制(日志不够新就拿不到多数票)而非"选数据最新者"来保证。
失效场景:选举在什么条件下失效
- 选票瓜分(Split Vote):多个 Candidate 同时超时发起选举,各自得票都不过半。随机超时把概率压得很低,但若超时区间过窄(如 150~160ms)或节点数多,仍会反复重选。
- 分区期间的少数派自嗨:少数派分区的节点会不断超时、递增 term、发起选举,但永远拿不到多数票。这不会破坏正确性(它们无法提交任何日志),但会消耗 CPU,且分区恢复后它们的高 term 会迫使多数派侧更新 term。
- 时钟 / GC 引起的误判:Follower 因 Full GC 停顿超过选举超时,醒来后误以为 Leader 已死而发起选举,造成不必要的切主。这也是为什么 GC 停顿是共识集群频繁切主的常见根因。
- 脑裂风险的真实来源:Raft 协议本身不会出现双主同时提交;但基于 Lease 的优化读在时钟回拨时可能读到旧数据;运维对少数派"强制选主"则是人为制造脑裂。
踩坑案例:某团队的 5 节点 etcd 集群每天凌晨出现 1~2 次无原因切主,业务侧偶发写超时。排查:切主时间点与某节点上的备份任务吻合,备份进程打满磁盘 IO,wal_fsync_duration 飙升,Leader 心跳延迟超过选举超时,被 Follower 误判死亡。根因是"备份与 etcd 共盘"。修复:备份改走从库快照、etcd 独占 SSD,切主消失。量化:etcd 官方要求磁盘 fsync P99 < 10ms,备份时实测飙到 500ms+。教训:选举超时的默认值(150~300ms)隐含了"环境是健康的"假设,慢盘 / GC 会把"故障检测"变成"误报制造机"。
量化参考
- 选举超时常见
150ms ~ 300ms(随机区间);心跳间隔通常为选举超时的 1/10 量级(如50ms),etcd 默认heartbeat-interval=100ms、election-timeout=1000ms。 - 正常选举耗时 ≈ 1 个超时周期 + 1~2 次 RTT,同机房通常 < 1 秒;若反复 split vote,可能拖到数秒甚至更长。
- 集群规模与切主代价:节点数越多,单次选举的消息量(O(n))越大,但多数派要求也越高;3~5 节点是通用最佳点。
拓展追问
- 为什么投票规则要求"先到先得、每任期只投一票"?
若一个 Follower 在同一 term 投给多个 Candidate,就可能出现两个 Candidate 都拿到多数票,破坏"一个 term 最多一个 Leader"的不变量。先到先得 + 任期唯一票,配合多数派交集原理,从源头消灭双主。 - 选举限制(日志新旧比较)为什么能防止已提交日志丢失?
已提交日志必然存在于某个多数派集合的节点上;Candidate 要当选必须获得另一个多数派的投票,两个多数派必有交集,而交集节点只投给"日志至少和自己一样新"的 Candidate。因此新 Leader 必然包含所有已提交日志。比较规则是:先比最后一条日志的 term,再比 index。 - Follower 收到更高 term 的请求后会发生什么?为什么这是关键?
立即退位为 Follower 并更新自己的 term。这是整个协议的"逻辑时钟同步机制":term 单调递增且随消息传播,使任何过期的 Leader / Candidate 会被迅速识别并降级,避免旧任期节点干扰新任期。
场景题
场景:大促前夜,5 节点 Raft 集群(配置中心)因一次 3 秒的网络抖动发生切主,随后 10 分钟内又发生了 3 次切主,业务侧配置拉取出现大量超时。如何排查和决策?
- 应急处理:先确认集群当前状态稳定(有唯一 Leader、多数派健康);若仍在反复切主,优先隔离可疑节点(磁盘/网络指标异常者)而不是扩容;配置客户端切本地缓存兼底,阻断故障向业务放大。
- 根因分析:首次切主由 3 秒抖动触发(合理);但随后的反复切主几乎必有诱因:① 新 Leader 负载突增,处理不过来导致心跳延迟;② 某个"毒节点"反复被选上又迅速失联;③ 抖动未真正恢复,路由在震荡。检查各节点心跳 RTT、fsync 耗时、term 增长速率即可定位。
- 长期方案:① 选举超时从默认值调大到 5~10 倍实测 RTT 抖动上限,减少误切主;② 客户端对配置拉取加本地快照 + 指数退避重试;③ 把"切主后新 Leader 的负载冲击"纳入压测;④ 网络抖动告警与集群告警关联,避免误把环境问题当集群问题。
- 权衡:调大超时能减少误切主,但代价是真故障时恢复变慢(检测窗口变长)。优先级应该是:先消除环境噪声(独盘、独占网络、无 GC 长停顿),再把超时调到与实际噪声匹配,而不是无限调大超时掩盖环境问题。
【中等】Raft 如何保证日志一致性?⭐
Raft 通过以下机制保证所有节点的日志最终一致:
日志特性:
- 如果两个日志条目在不同节点上具有相同的 index 和 term,那么它们存储的命令相同。
- 如果两个日志条目在不同节点上具有相同的 index 和 term,那么它们之前的所有日志条目也完全相同。
日志冲突处理:
当 Leader 发现 Follower 的日志与自己不一致时:
- Leader 为每个 Follower 维护一个
nextIndex(下一条要发送的日志索引)。 - Leader 将
nextIndex递减,直到找到与 Follower 日志一致的点。 - 从该点开始,Leader 用自己的日志覆盖 Follower 的日志。
安全性保证:
- 选举限制:Candidate 的日志必须至少和多数派节点一样新(up-to-date),才能获得投票。这保证了已提交的日志不会丢失。
- 提交规则:Leader 只提交当前 term 的日志条目,之前的日志条目会随着当前 term 日志的提交而被间接提交。
【中等】共识算法的局限性有哪些?⭐
共识算法虽然强大,但也存在一些局限性:
1. 集群节点数限制
- 最多容忍半数以下的节点故障。
- 集群节点数一般要求是奇数,避免偶数节点在选主时出现平票。
2. 选举影响性能
- 共识系统依靠超时检测失效节点,在网络延迟高度变化的环境中,容易误判 Leader 失效。
- 频繁的领导者选举会导致性能下降,系统可能"花在权力倾轧上的时间比花在建设性工作上的多"。
3. 不适合跨数据中心部署
- 多数派共识要求多数节点在同一数据中心内,跨数据中心的网络延迟会严重影响性能。
- 解决方案:每个数据中心内部用共识算法,数据中心之间用多主复制或异步复制。
4. 对网络要求高
- 共识算法假设网络是部分同步的(Eventually Synchronous),需要超时机制配合。
- 在极端网络抖动下,可能导致频繁选举甚至脑裂。
【简单】ZooKeeper 的 Zab 协议和 Raft 有什么区别?⭐
Zab(ZooKeeper Atomic Broadcast)协议是 ZooKeeper 专用的共识协议,与 Raft 有以下主要区别:
| 特性 | Raft | Zab |
|---|---|---|
| 设计目标 | 通用共识算法 | 专为主备复制设计 |
| Leader 选举 | 随机超时选举 | 快速选举(基于 zxid 选最优节点) |
| 日志编号 | (term, index) 二元组 | zxid(epoch + counter)单调递增 |
| 新 Leader 同步 | 修剪 Follower 多余日志 | 保留并补齐 Follower 缺失的日志 |
| 日志特性 | Leader 日志为准,Follower 对齐 | 新 Leader 的 zxid 最大,保证已提交 |
| 阶段划分 | 选举 + 日志复制 | 发现 + 同步 + 广播 + 选主 |
核心差异:Zab 在选主时会优先选择拥有最大 zxid(即数据最完整)的节点作为 Leader,且新 Leader 会保留所有已提交的日志;而 Raft 的 Leader 日志不一定是最完整的,但通过选举限制保证已提交日志不丢失。
分布式事务
【简单】什么是事务?什么是分布式事务?⭐⭐
事务将多个读、写操作捆绑在一起成为一个逻辑操作单元。事务中的所有读写是一个执行的整体,整个事务要么成功(提交)、要么失败(中止或回滚)。
在单一数据节点中,事务仅限于对单一数据库资源的访问控制,称之为本地事务。几乎所有的成熟的关系型数据库都提供了对本地事务的原生支持。
分布式事务指的是事务操作跨越多个节点,并且要求满足事务的 ACID 特性。
本质:从"单机日志"到"跨节点共识"的鸿沟
单机事务之所以能实现 ACID,是因为所有状态都在一个进程 / 一台机器内:回滚靠 undo log、持久化靠 redo log、隔离靠锁和 MVCC,全部由一个权威(数据库引擎)裁决。分布式事务的难点在于没有一个全局权威:参与者分属不同服务 / 不同存储,任何一个环节宕机、网络中断,都会让事务停在"结果未知"的中间态。所以分布式事务的本质问题是:在部分失效的环境下,如何让多个独立节点对"提交还是回滚"达成一致——这实际上就是一个共识问题。
方案权衡:强一致 vs 最终一致的路线选择
| 路线 | 代表方案 | 代价 | 适用边界 |
|---|---|---|---|
| 强一致 | 2PC/XA、Seata XA | 同步阻塞、锁持有时间长、协调者单点风险;吞吐低 | 跨库资金转账、强监管报表等"必须即时一致"的场景 |
| 最终一致 | 本地消息表、事务消息、SAGA、TCC | 存在不一致窗口,需幂等 + 对账兼底;开发成本高 | 高吞吐互联网业务,绝大多数跨服务协同 |
工程经验:能用最终一致就绝不用强一致。强一致方案的可用性是所有参与者可用性的交集(任一参与者不可用则整体阻塞),在互联网规模下几乎必然成为瓶颈。
失效场景:分布式事务的典型死法
- 协调者宕机:2PC 下参与者无限期持锁等待决议,锁住的资源拖死整个库。
- 结果未知的中间态:参与者发出 commit 后宕机,重启后不知道事务是否已提交——任何"结果未知"都必须可重查,否则只能人工介入。
- 悬挂资源:TCC 的 Try 成功但 Cancel/Confirm 因异常未送达,冻结的库存/资金永远不释放。
踩坑案例:某支付系统用 XA 跨两个库做转账。某天协调者(应用服务器)在第二阶段发完第一个 commit 后崩溃,第二个参与者未收到指令;参与者默认超时策略是"无限等待协调者",其行锁一直不释放。现象:该账户后续所有交易全部排队超时,30 分钟后堆积到数百笔。排查:DBA 发现长时间持锁会话,追溯到 XA 事务处于 PREPARED 状态。修复:① 参与者配置 xa recovery 定时轮询恢复;② 协调者改多实例 + 事务日志持久化,重启后可续处理;③ 长期方案把转账链路改成事务消息(最终一致)+ 对账。教训:XA 的"强一致"是拿"可用性 + 锁时长"换的,协调者单点和超时策略是它的两颗地雷。
量化参考
- 单机 MySQL 事务平均耗时约 1~5ms;同样业务用 2PC 跨两库,耗时通常翻 3~10 倍(多两轮网络 + 锁持有时间变长)。
- XA 事务的锁持有时间贯穿 prepare→commit 全程,若协调者响应慢(如 500ms),热点行的锁冲突率会指数级上升。
- 业界主流互联网交易链路几乎全部采用最终一致方案,不一致窗口目标一般控制在秒级(如 P99 < 10s),用对账任务兼底到零差异。
拓展追问
- 分布式事务和分布式共识是什么关系?
"所有参与者一致决定提交或回滚"本身就是一个共识问题。2PC 是退化的共识(协调者独裁,无容错);TCC/SAGA 则把"达成一致"拆解为"本地事务 + 可靠消息 + 幂等补偿",用工程手段绕过共识的同步阻塞代价。 - 为什么"结果未知"是分布式事务最危险的状态?
明确失败可以重试,明确成功可以继续;结果未知时,重试可能重复执行,放弃可能丢失已生效的变更。所以一切生产级方案都必须具备:事务状态持久化(可重查)+ 操作幂等(可重试)。 - 什么场景下你会坚持用强一致方案(2PC/XA)?
三个条件同时满足:① 业务不允许任何不一致窗口(如监管要求的实时对账);② 参与者数量少且同机房(控制延迟);③ 吞吐不高(可接受锁放大)。典型如银行核心跨库转账;其余场景首选最终一致。
场景题
场景:电商下单链路需要"创建订单 + 扣库存 + 扣优惠券",当前用 Seata XA 实现。大促压测时发现 TPS 上不去,库存库的行锁等待飙升,DBA 建议拆掉 XA。你怎么决策?
- 应急处理:压测环境先确认瓶颈确实是 XA(观察 prepare→commit 间隔与锁持有时间);临时缩短事务边界(把非必要的 DB 操作移出事务),观察改善幅度。
- 根因分析:XA 的锁持有时间贯穿两阶段全程,大促时 prepare 后等待协调者汇总所有参与者响应的窗口被拉长,热点库存行的锁队列雪崩式堆积。强一致方案的可用性 = 最慢参与者的可用性,大促时必然先死。
- 长期方案:拆 XA,改为"订单本地事务 + 事务消息驱动库存/优惠券扣减"的最终一致方案;库存扣减用乐观更新(
UPDATE stock SET cnt = cnt - ? WHERE id = ? AND cnt >= ?)+ 幂等;优惠券支持回滚补偿;T+0 对账兼底。若业务坚持"下单时库存必须实时准确",可用"预扣 + 异步确认"折中。 - 权衡:拆 XA 后用"秒级不一致窗口 + 对账兼底"换 5~10 倍的吞吐与可用性;库存超卖风险用数据库行级条件更新 + 对账控制到零。大促场景下,可用性损失(拒绝下单)的代价远大于短暂不一致,这个权衡几乎总是值得的。
【简单】什么是 ACID?什么是 BASE?二者有何区别?⭐⭐
ACID
ACID 是数据库事务正确执行的四个基本要素的单词缩写:
- 原子性(Atomicity)
- 原子是指不可分解为更小粒度的东西。事务的原子性意味着:事务中的所有操作要么全部成功,要么全部失败。
- 回滚可以用日志来实现,日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。
- ACID 中的原子性并不关乎多个操作的并发性,它并没有描述多个线程试图访问相同的数据会发生什么情况,后者其实是由 ACID 的隔离性所定义。
- 一致性(Consistency)
- 数据库在事务执行前后都保持一致性状态。
- 在一致性状态下,所有事务对一个数据的读取结果都是相同的。
- 一致性本质上要求应用层来维护状态一致(或者恒等),应用程序有责任正确地定义事务来保持一致性。这不是数据库可以保证的事情。
- 隔离性(Isolation)
- 同时运行的事务互不干扰。换句话说,一个事务所做的修改在最终提交以前,对其它事务是不可见的。
- 持久性(Durability)
- 一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。
- 可以通过数据库备份和恢复来实现,在系统发生奔溃时,使用备份的数据库进行数据恢复。
BASE
BASE 是 基本可用(Basically Available)、软状态(Soft State) 和 最终一致性(Eventually Consistent) 三个短语的缩写。
BASE 理论的核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。
- **基本可用(Basically Available)**分布式系统在出现故障的时候,保证核心可用,允许损失部分可用性。例如,电商在做促销时,为了保证购物系统的稳定性,部分消费者可能会被引导到一个降级的页面。
- 软状态(Soft State)指允许系统中的数据存在中间状态,并认为该中间状态不会影响系统整体可用性,即允许系统不同节点的数据副本之间进行同步的过程存在延时。
- 最终一致性(Eventually Consistent)强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能达到一致的状态。
BASE vs. ACID
ACID 要求强一致性,通常运用在传统的数据库系统上。而 BASE 要求最终一致性,通过牺牲强一致性来达到可用性,通常运用在大型分布式系统中。BASE 唯一可以确定的是“它不是 ACID”,此外它几乎没有承诺任何东西。
【简单】什么是强一致性?什么是最终一致性?⭐⭐
一致性(Consistency)指的是多个数据副本是否能保持一致的特性。
数据一致性又可以分为以下几点:
- 强一致性:数据更新操作结果和操作响应总是一致的,即操作响应通知更新失败,那么数据一定没有被更新,而不是处于不确定状态。
- 最终一致性:即物理存储的数据可能是不一致的,终端用户访问到的数据可能也是不一致的,但系统经过一段时间的自我修复和修正,数据最终会达到一致。
在分布式领域,要实现强一致性,代价非常高昂。因此,有人基于 CAP 理论以及 BASE 理论,有人就提出了柔性事务的概念。柔性事务是指:在不影响系统整体可用性的情况下 (Basically Available 基本可用),允许系统存在数据不一致的中间状态 (Soft State 软状态),在经过数据同步的延时之后,达到最终一致性。并不是完全放弃了 ACID,而是通过放宽一致性要求,借助本地事务来实现最终分布式事务一致性的同时也保证系统的吞吐。
【中等】分布式事务有哪些解决方案?各有什么利弊?⭐⭐⭐
分布式事务的常见方案如下:
- 两阶段提交(2PC):将事务的提交过程分为两个阶段来进行处理:准备阶段和提交阶段。参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
- 三阶段提交(3PC):与二阶段提交不同的是,引入超时机制。同时在协调者和参与者中都引入超时机制。将二阶段的准备阶段拆分为 2 个阶段,插入了一个 preCommit 阶段,使得原先在二阶段提交中,参与者在准备之后,由于协调者发生崩溃或错误,而导致参与者处于无法知晓是否提交或者中止的“不确定状态”所产生的可能相当长的延时的问题得以解决。
- 补偿事务(TCC)
- Try:操作作为一阶段,负责资源的检查和预留。
- Confirm:操作作为二阶段提交操作,执行真正的业务。
- Cancel:是预留资源的取消。
- 本地消息表:在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。
- 事务消息:基于 MQ 的分布式事务方案其实是对本地消息表的封装。
- SAGA:Saga 事务核心思想是将长事务拆分为多个本地短事务,由 Saga 事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。
分布式事务方案对比:
- 要强一致,不怕慢 → 2PC、3PC
- 强一致,性能中等,但侵入性高:TCC
- 要高性能,可接受最终一致 → 本地消息表、MQ 事务消息、SAGA
- 长事务,跨多服务 → SAGA
- 快速集成,有现成框架 → Seata
| 2PC | 3PC | TCC | 本地消息表 | MQ 事务消息 | SAGA | |
|---|---|---|---|---|---|---|
| 数据一致性 | 强¹ | 强¹ | 最终² | 最终 | 最终 | 最终 |
| 容错性 | 低 | 中 | 高 | 高 | 高 | 高 |
| 复杂性 | 中 | 高 | 高 | 低 | 低 | 高 |
| 性能 | 低 | 低 | 中 | 中 | 高 | 中 |
| 维护成本 | 低 | 中 | 高 | 中 | 中 | 高 |
| 业务侵入 | 低 | 低 | 高 | 中 | 中 | 中 |
¹ 2PC/3PC 在协调者正常工作时保证强一致性,但协调者宕机可能导致参与者处于阻塞状态,存在数据不一致风险。
² TCC 通过 Try 阶段预留资源、Confirm/Cancel 保证最终一致,并非真正的强一致,但比消息类方案一致性更强(Try 阶段已隔离资源)。
选型决策:按四个维度判断
选型的优先级建议:先问"能不能不做"(能否通过业务设计消除跨服务事务),再问"能不能只做本地事务 + 异步补偿",最后才在 2PC/TCC/SAGA 中选:
| 判断维度 | 倾向方案 |
|---|---|
| 资源是否支持预留/冻结(库存可冻结、资金可预授权) | 能预留 → TCC;不能预留 → SAGA / 事务消息 |
| 事务时长 | 毫秒级短事务 → 2PC/TCC;长流程(跨天履约)→ SAGA |
| 吞吐要求 | 高吞吐(万级 TPS)→ 事务消息;低吞吐强一致 → 2PC/XA |
| 失败后能否"反向补偿" | 可补偿(退款、恢复库存)→ SAGA;不可补偿(已发短信、已开票)→ 只能向前恢复(重试到成功) |
失效场景:各方案的阿喀琉斯之踵
- 2PC/3PC:协调者宕机 → 参与者阻塞持锁;第二阶段局部网络故障 → 部分提交部分回滚。
- TCC:空回滚(Cancel 先于 Try 到达)、悬挂(Cancel 后 Try 才到达)、幂等失败导致重复冻结/释放。
- 本地消息表 / 事务消息:消息积压导致不一致窗口拉长;回查接口实现错误导致半消息永远悬着;消费端无幂等导致重复执行。
- SAGA:补偿操作本身失败(补偿也需要重试到成功);无隔离性导致"更新丢失"(两个 SAGA 并发修改同一资源)。
踩坑案例:某电商用 SAGA 实现下单履约(扣库存 → 创建订单 → 扣积分)。上线后某次积分服务发布时批量报错,触发大量反向补偿;但补偿动作"恢复积分"的实现是简单的 UPDATE score = score + ?(非幂等),而失败重试机制把同一条补偿执行了多次,造成部分用户积分凭空多出数倍,财务盘账才发现。修复:① 所有正向/补偿操作全部改为基于流水号的幂等实现(流水表唯一索引);② 补偿失败进人工工单队列而非无限重试;③ 增加积分流水与余额的定时对账。教训:SAGA 的每个 Ti 和 Ci 都必须幂等,否则"修复不一致的机制"本身会制造新的不一致。
量化参考
- 2PC:跨两库事务端到端耗时通常 50~200ms(取决于协调者汇总速度),锁持有时间是本地事务的 3~10 倍。
- 事务消息:RocketMQ 回查默认间隔 6s、最多 15 次;正常链路从发送到消费可见通常毫秒~秒级。
- TCC:Try 阶段需额外一次 DB 写(冻结记录),接口开发量约为普通接口的 3 倍(三个方法),适合资金/库存等核心资源。
- SAGA:每步都是真实提交,失败时已执行步骤的"暴露时间"= 全部步骤累计耗时,长流程可能达分钟级,需评估中间态的业务影响。
拓展追问
- 为什么 3PC 在生产中几乎没人用?
3PC 引入超时默认提交来解决阻塞,但"参与者超时自动提交"在网络分区时恰恰会制造不一致(一侧提交一侧回滚)。它解决了 2PC 的可用性痛点却引入了更隐蔽的正确性痛点,而这两个问题用最终一致方案都能更好地解决,所以 3PC 停留在理论层面。 - TCC 和 SAGA 的本质区别是什么?
TCC 有 Try 预留阶段,资源在事务结束前处于"冻结但可见"的隔离态,一致性更强;SAGA 每步直接提交,无隔离,靠补偿回滚。选型关键:资源能否低成本预留(能 → TCC),以及中间态被其他业务看到是否有害(有害 → TCC)。 - 所有方案都绕不开的两个工程地基是什么?
幂等和对账。幂等让重试安全(所有方案都依赖重试来兑现"最终");对账让不一致可发现可修复(所有方案都存在兼底窗口外的极端 case)。没有这两个地基,任何分布式事务方案都是玩具。
场景题
场景:公司要做"预订机票 + 预订酒店 + 扣积分"的套餐下单,各子能力分属不同团队的独立服务,其中机票供应商接口偶尔会超时 30 秒,且预订成功后 10 分钟内可免费取消。技术选型怎么做?
- 应急处理:(新项目无应急,此处置为"上线前兵棋推演":先枚举每个环节失败时的行为,确认供应商取消接口可用后再定方案。)
- 根因分析:需求特征:长流程(含 30 秒级外部调用)、每步都有天然的反向操作(取消预订、退积分)、中间态对用户可见(订单显示"预订中"可接受)。逐一对应:长事务排除 2PC(锁持有 30 秒会拖死库存/积分库);有天然补偿动作且资源无需严格隔离 → SAGA 比 TCC 更合适(TCC 要求每个供应商接口支持 Try 预留,改造成本不可控)。
- 长期方案:① 编排式 SAGA(中央协调器),每步子事务幂等,每步配幂等补偿;② 机票步骤用"向前恢复":超时后不立即取消,而是轮询查单确认状态(避免"其实成功了但被当失败取消");③ 补偿失败进人工工单;④ 全链路流水表 + T+1 对账。
- 权衡:SAGA 的代价是无隔离(两个套餐单可能并发抢同一张特价票,需要库存侧行级条件更新兼底)和中间态暴露(用户会看到"预订中"状态)。相比 TCC 的"要求所有外部供应商配合改造 Try 接口",SAGA 的代价明显更小——选型的本质是看哪个方案的假设更容易在现实中成立。
【中等】2PC 的工作原理是什么?⭐⭐⭐
二阶段提交协议(Two-phase Commit,即 2PC)将事务的提交过程分为两个阶段来进行处理:准备阶段和提交阶段。事务的发起者称协调者,事务的执行者称参与者。二阶段提交的思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈,决定提交或回滚。
阶段 1:准备阶段
- 协调者向所有参与者发送事务内容,询问是否可以提交事务,并等待所有参与者答复。
- 各参与者执行事务操作,将 undo 和 redo 信息记入事务日志中(但不提交事务)。
- 如参与者执行成功,给协调者反馈 yes,即可以提交;如执行失败,给协调者反馈 no,即不可提交。
阶段 2:提交阶段
如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚 (rollback) 消息;否则,发送提交 (commit) 消息;参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源) 接下来分两种情况分别讨论提交阶段的过程。
情况 1,当所有参与者均反馈 yes,提交事务。

- 协调者向所有参与者发出正式提交事务的请求(即 commit 请求)。
- 参与者执行 commit 请求,并释放整个事务期间占用的资源。
- 各参与者向协调者反馈 ack(应答)完成的消息。
- 协调者收到所有参与者反馈的 ack 消息后,即完成事务提交。
情况 2,任何一个参与者反馈 no,中断事务。

- 协调者向所有参与者发出回滚请求(即 rollback 请求)。
- 参与者使用阶段 1 中的 undo 信息执行回滚操作,并释放整个事务期间占用的资源。
- 各参与者向协调者反馈 ack 完成的消息。
- 协调者收到所有参与者反馈的 ack 消息后,即完成事务中断。
方案总结:
2PC 方案实现起来简单,实际项目中使用比较少,主要因为以下问题:
- 性能问题:所有参与者在事务提交阶段处于同步阻塞状态,占用系统资源,容易导致性能瓶颈。
- 可靠性问题:如果协调者存在单点故障问题,如果协调者出现故障,参与者将一直处于锁定状态。
- 数据一致性问题:在阶段 2 中,如果发生局部网络问题,一部分事务参与者收到了提交消息,另一部分事务参与者没收到提交消息,那么就导致了节点之间数据的不一致。
【中等】3PC 的工作原理是什么?⭐⭐
三阶段提交协议(Three-phase Commit,3PC),是二阶段提交协议的改进版本,与二阶段提交不同的是,引入超时机制。同时在协调者和参与者中都引入超时机制。
阶段 1:canCommit
协调者向参与者发送 commit 请求,参与者如果可以提交就返回 yes 响应(参与者不执行事务操作),否则返回 no 响应:
- 协调者向所有参与者发出包含事务内容的 canCommit 请求,询问是否可以提交事务,并等待所有参与者答复。
- 参与者收到 canCommit 请求后,如果认为可以执行事务操作,则反馈 yes 并进入预备状态,否则反馈 no。
阶段 2:preCommit
协调者根据阶段 1 canCommit 参与者的反应情况来决定是否可以基于事务的 preCommit 操作。根据响应情况,有以下两种可能。
情况 1:阶段 1 所有参与者均反馈 yes,参与者预执行事务。

- 协调者向所有参与者发出 preCommit 请求,进入准备阶段。
- 参与者收到 preCommit 请求后,执行事务操作,将 undo 和 redo 信息记入事务日志中(但不提交事务)。
- 各参与者向协调者反馈 ack 响应或 no 响应,并等待最终指令。
情况 2:阶段 1 任何一个参与者反馈 no,或者等待超时后协调者尚无法收到所有参与者的反馈,即中断事务。

- 协调者向所有参与者发出 abort 请求。
- 无论收到协调者发出的 abort 请求,或者在等待协调者请求过程中出现超时,参与者均会中断事务。
阶段 3:doCommit
该阶段进行真正的事务提交,也可以分为以下两种情况:
情况 1:阶段 2 所有参与者均反馈 ack 响应,执行真正的事务提交。

- 如果协调者处于工作状态,则向所有参与者发出 doCommit 请求。
- 参与者收到 doCommit 请求后,会正式执行事务提交,并释放整个事务期间占用的资源。
- 各参与者向协调者反馈 ack 完成的消息。
- 协调者收到所有参与者反馈的 ack 消息后,即完成事务提交。
情况 2:任何一个参与者反馈 no,或者等待超时后协调者尚无法收到所有参与者的反馈,即中断事务。

- 如果协调者处于工作状态,向所有参与者发出 abort 请求。
- 参与者使用阶段 1 中的 undo 信息执行回滚操作,并释放整个事务期间占用的资源。
- 各参与者向协调者反馈 ack 完成的消息。
- 协调者收到所有参与者反馈的 ack 消息后,即完成事务中断。
注意:进入阶段 3 后,无论协调者出现问题,或者协调者与参与者网络出现问题,都会导致参与者无法接收到协调者发出的 doCommit 请求或 abort 请求。此时,参与者都会在等待超时之后,继续执行事务提交。
方案总结:
优点:相比二阶段提交,三阶段降低了阻塞范围,在等待超时后协调者或参与者会中断事务。避免了协调者单点问题,阶段 3 中协调者出现问题时,参与者会继续提交事务。
缺点:数据不一致问题依然存在,当在参与者收到 preCommit 请求后等待 doCommit 指令时,此时如果协调者请求中断事务,而协调者无法与参与者正常通信,会导致参与者继续提交事务,造成数据不一致。
【中等】TCC 的工作原理是什么?⭐⭐
TCC 是服务化的二阶段编程模型,其 Try、Confirm、Cancel 3 个方法均由业务编码实现;
- Try:操作作为一阶段,负责资源的检查和预留。
- Confirm:操作作为二阶段提交操作,执行真正的业务。
- Cancel:是预留资源的取消。
TCC 事务的 Try、Confirm、Cancel 可以理解为 SQL 事务中的 Lock、Commit、Rollback。
Try 阶段
从执行阶段来看,与传统事务机制中业务逻辑相同。但从业务角度来看,却不一样。TCC 机制中的 Try 仅是一个初步操作,它和后续的确认一起才能真正构成一个完整的业务逻辑,这个阶段主要完成:
- 完成所有业务检查(一致性)
- 预留必须业务资源(准隔离性)
- Try 尝试执行业务 TCC 事务机制以初步操作(Try)为中心的,确认操作(Confirm)和取消操作(Cancel)都是围绕初步操作(Try)而展开。因此,Try 阶段中的操作,其保障性是最好的,即使失败,仍然有取消操作(Cancel)可以将其执行结果撤销。
假设商品库存为 100,购买数量为 2,这里检查和更新库存的同时,冻结用户购买数量的库存,同时创建订单,订单状态为待确认。
Confirm / Cancel 阶段
根据 Try 阶段服务是否全部正常执行,继续执行确认操作(Confirm)或取消操作(Cancel)。 Confirm 和 Cancel 操作满足幂等性,如果 Confirm 或 Cancel 操作执行失败,将会不断重试直到执行完成。
Confirm:当 Try 阶段服务全部正常执行, 执行确认业务逻辑操作

这里使用的资源一定是 Try 阶段预留的业务资源。在 TCC 事务机制中认为,如果在 Try 阶段能正常的预留资源,那 Confirm 一定能完整正确的提交。Confirm 阶段也可以看成是对 Try 阶段的一个补充,Try+Confirm 一起组成了一个完整的业务逻辑。
Cancel:当 Try 阶段存在服务执行失败, 进入 Cancel 阶段

Cancel 取消执行,释放 Try 阶段预留的业务资源,上面的例子中,Cancel 操作会把冻结的库存释放,并更新订单状态为取消。
方案总结
TCC 事务机制相比于上面介绍的 XA 事务机制,有以下优点:
- 性能提升:具体业务来实现控制资源锁的粒度变小,不会锁定整个资源。
- 数据最终一致性:基于 Confirm 和 Cancel 的幂等性,保证事务最终完成确认或者取消,保证数据的一致性。
- 可靠性:解决了 XA 协议的协调者单点故障问题,由主业务方发起并控制整个业务活动,业务活动管理器也变成多点,引入集群。
缺点: TCC 的 Try、Confirm 和 Cancel 操作功能要按具体业务来实现,业务耦合度较高,提高了开发成本。
【困难】什么是 TCC 事务的空回滚、防悬挂?⭐
- 空回滚与悬挂本质是网络异常导致的时序错乱。
- 解决方案:幂等设计 + 事务日志记录执行状态,确保 Try 和 Cancel 只生效一次。
空回滚
- 定义:Cancel 先于 Try 执行,但 Try 实际并未执行。
- 产生:网络延迟导致 Try 未到,事务协调器超时判定失败,直接发起 Cancel。
- 应对:Cancel 方法需能识别 Try 是否执行,若未执行则直接返回成功。
悬挂
- 定义:Cancel 执行后,迟到的 Try 又执行成功,预留资源无法释放。
- 产生:空回滚后,网络恢复使延迟的 Try 到达并执行。
- 应对:Try 方法需能识别 Cancel 是否已执行,若已执行则拒绝执行。
【困难】SAGA 事务的工作原理是什么?⭐
Saga 事务的核心思想是:将长事务拆分为多个本地短事务,由 Saga 事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序依次调用补偿操作。
Saga 事务基本协议如下:
- 将长事务拆分为多个有序子事务:每个 Saga 事务由一系列幂等的有序子事务 (sub-transaction) Ti 组成。
- 每个子事务 Ti 都有对应的幂等补偿动作 Ci,补偿动作用于撤销 Ti 造成的结果。
可以看到,和 TCC 相比,Saga 没有“预留”动作,它的 Ti 就是直接提交到库。
下面以下单流程为例,整个操作包括:创建订单、扣减库存、支付、增加积分 Saga 的执行顺序有两种:

- 事务正常执行完成 T1, T2, T3, ..., Tn,例如:扣减库存 (T1),创建订单 (T2),支付 (T3),依次有序完成整个事务。
- 事务回滚 T1, T2, ..., Tj, Cj,..., C2, C1,其中 0 < j < n,例如:扣减库存 (T1),创建订单 (T2),支付 (T3,支付失败),支付回滚 (C3),订单回滚 (C2),恢复库存 (C1)。
恢复策略
Saga 定义了两种恢复策略:
- 向前恢复 (forward recovery)

对应于上面第一种执行顺序,适用于必须要成功的场景,失败需要进行重试,执行顺序是类似于这样的:T1, T2, ..., Tj(失败), Tj(重试),..., Tn,其中 j 是发生错误的子事务 (sub-transaction)。该情况下不需要 Ci。
- 向后恢复 (backward recovery)

对应于上面提到的第二种执行顺序,其中 j 是发生错误的子事务 (sub-transaction),这种做法的效果是撤销掉之前所有成功的子事务,使得整个 Saga 的执行结果撤销。
Saga 事务常见的有两种不同的实现方式:命令协调和事件编排。
命令协调
- 命令协调 (Order Orchestrator):中央协调器负责集中处理事件的决策和业务逻辑排序。
中央协调器(Orchestrator,简称 OSO)以命令/回复的方式与每项服务进行通信,全权负责告诉每个参与者该做什么以及什么时候该做什么。

以电商订单的例子为例:
- 事务发起方的主业务逻辑请求 OSO 服务开启订单事务。
- OSO 向库存服务请求扣减库存,库存服务回复处理结果。
- OSO 向订单服务请求创建订单,订单服务回复创建结果。
- OSO 向支付服务请求支付,支付服务回复处理结果。
- 主业务逻辑接收并处理 OSO 事务处理结果回复。
中央协调器必须事先知道执行整个订单事务所需的流程(例如通过读取配置)。如果有任何失败,它还负责通过向每个参与者发送命令来撤销之前的操作来协调分布式的回滚。基于中央协调器协调一切时,回滚要容易得多,因为协调器默认是执行正向流程,回滚时只要执行反向流程即可。
事件编排
- 事件编排 (Event Choreography0:没有中央协调器(没有单点风险)时,每个服务产生并观察其他服务的事件,并决定是否应采取行动。
在事件编排方法中,第一个服务执行一个事务,然后发布一个事件。该事件被一个或多个服务进行监听,这些服务再执行本地事务并发布(或不发布)新的事件。
当最后一个服务执行本地事务并且不发布任何事件时,意味着分布式事务结束,或者它发布的事件没有被任何 Saga 参与者听到都意味着事务结束。
以电商订单的例子为例:

- 事务发起方的主业务逻辑发布开始订单事件
- 库存服务监听开始订单事件,扣减库存,并发布库存已扣减事件
- 订单服务监听库存已扣减事件,创建订单,并发布订单已创建事件
- 支付服务监听订单已创建事件,进行支付,并发布订单已支付事件
- 主业务逻辑监听订单已支付事件并处理。
事件编排是实现 Saga 模式的自然方式,它很简单,容易理解,不需要太多的代码来构建。如果事务涉及 2 至 4 个步骤,则可能是非常合适的。
方案总结
命令协调设计的优点和缺点:
优点如下:
- 服务之间关系简单,避免服务之间的循环依赖关系,因为 Saga 协调器会调用 Saga 参与者,但参与者不会调用协调器
- 程序开发简单,只需要执行命令/回复(其实回复消息也是一种事件消息),降低参与者的复杂性。
- 易维护扩展,在添加新步骤时,事务复杂性保持线性,回滚更容易管理,更容易实施和测试
缺点如下:
- 中央协调器容易处理逻辑容易过于复杂,导致难以维护。
- 存在协调器单点故障风险。
事件/编排设计的优点和缺点
优点如下:
- 避免中央协调器单点故障风险。
- 当涉及的步骤较少服务开发简单,容易实现。
缺点如下:
- 服务之间存在循环依赖的风险。
- 当涉及的步骤较多,服务间关系混乱,难以追踪调测。
值得补充的是,由于 Saga 模型中没有 Prepare 阶段,因此事务间不能保证隔离性,当多个 Saga 事务操作同一资源时,就会产生更新丢失、脏数据读取等问题,这时需要在业务层控制并发,例如:在应用层面加锁,或者应用层面预先冻结资源。
【困难】本地消息表的工作原理是什么?⭐⭐
本地消息表的核心思路是将分布式事务拆分成本地事务进行处理。
方案通过在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。
这样设计可以避免”业务处理成功 + 事务消息发送失败",或"业务处理失败 + 事务消息发送成功"的棘手情况出现,保证 2 个系统事务的数据一致性。
事务的主动方需要额外新建事务消息表,用于记录分布式事务的消息的发生、处理状态。
整个业务处理流程如下:

- 步骤 1、事务主动方处理本地事务。 事务主动发在本地事务中处理业务更新操作和写消息表操作。 上面例子中库存服务阶段再本地事务中完成扣减库存和写消息表(图中 1、2)。
- 步骤 2、事务主动方通过 MQ 通知事务被动方处理事务。 消息中间件可以基于 Kafka、RocketMQ 消息队列,事务主动方法主动写消息到消息队列,事务消费方消费并处理消息队列中的消息。 上面例子中,库存服务把事务待处理消息写到消息中间件,订单服务消费消息中间件的消息,完成新增订单(图中 3 - 5)。
- 步骤 3、事务被动方通过 MQ 返回处理结果。 上面例子中,订单服务把事务已处理消息写到消息中间件,库存服务消费中间件的消息,并将事务消息的状态更新为已完成(图中 6 - 8)
为了数据的一致性,当处理错误需要重试,事务发送方和事务接收方相关业务处理需要支持幂等。具体保存一致性的容错处理如下:
- 当步骤 1 处理出错,事务回滚,相当于什么都没发生。
- 当步骤 2、步骤 3 处理出错,由于未处理的事务消息还是保存在事务发送方,事务发送方可以定时轮询超时 d 的消息数据,再次发送消息到 MQ 进行处理。事务被动方消费事务消息重试处理。
- 如果是业务上的失败,事务被动方可以发消息给事务主动方进行回滚。
- 如果多个事务被动方已经消费消息,事务主动方需要回滚事务时需要通知事务被动方回滚。
方案总结
方案的优点如下:
- 从应用设计开发的角度实现了消息数据的可靠性,消息数据的可靠性不依赖于消息中间件,弱化了对 MQ 中间件特性的依赖。
- 方案简单,容易实现。
缺点如下:
- 与具体的业务场景绑定,耦合性高,不可复用。
- 需要额外维护消息数据的传输,占用业务系统资源。
- 业务系统在使用关系型数据库的情况下,消息服务性能会受到关系型数据库并发性能的局限。
【困难】事务消息的工作原理是什么?⭐⭐
MQ 事务方案本质是利用 MQ 功能实现的本地消息表。事务消息需要消息队列提供相应的功能才能实现,Kafka 和 RocketMQ 都提供了事务相关功能。
- Kafka 的解决方案是:直接抛出异常,让用户自行处理。用户可以在业务代码中反复重试提交,直到提交成功,或者删除之前修改的数据记录进行事务补偿。
- RocketMQ 的解决方案是:通过事务反查机制来解决事务消息提交失败的问题。如果 Producer 在提交或者回滚事务消息时发生网络异常,RocketMQ 的 Broker 没有收到提交或者回滚的请求,Broker 会定期去 Producer 上反查这个事务对应的本地事务的状态,然后根据反查结果决定提交或者回滚这个事务。为了支撑这个事务反查机制,业务代码需要实现一个反查本地事务状态的接口,告知 RocketMQ 本地事务是成功还是失败。
事务消息是 Apache RocketMQ 提供的一种困难消息类型,支持在分布式场景下保障消息生产和本地事务的最终一致性。
事务消息处理流程
事务消息交互流程如下图所示。

- 生产者将消息发送至 Apache RocketMQ 服务端。
- Apache RocketMQ 服务端将消息持久化成功之后,向生产者返回 Ack 确认消息已经发送成功,此时消息被标记为"暂不能投递",这种状态下的消息即为半事务消息。
- 生产者开始执行本地事务逻辑。
- 生产者根据本地事务执行结果向服务端提交二次确认结果(Commit 或是 Rollback),服务端收到确认结果后处理逻辑如下:
- 二次确认结果为 Commit:服务端将半事务消息标记为可投递,并投递给消费者。
- 二次确认结果为 Rollback:服务端将回滚事务,不会将半事务消息投递给消费者。
- 在断网或者是生产者应用重启的特殊情况下,若服务端未收到发送者提交的二次确认结果,或服务端收到的二次确认结果为 Unknown 未知状态,经过固定时间后,服务端将对消息生产者即生产者集群中任一生产者实例发起消息回查。 说明 服务端回查的间隔时间和最大回查次数,请参见 参数限制。
- 生产者收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
- 生产者根据检查到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤 4 对半事务消息进行处理。
事务消息生命周期

- 初始化:半事务消息被生产者构建并完成初始化,待发送到服务端的状态。
- 事务待提交:半事务消息被发送到服务端,和普通消息不同,并不会直接被服务端持久化,而是会被单独存储到事务存储系统中,等待第二阶段本地事务返回执行结果后再提交。此时消息对下游消费者不可见。
- 消息回滚:第二阶段如果事务执行结果明确为回滚,服务端会将半事务消息回滚,该事务消息流程终止。
- 提交待消费:第二阶段如果事务执行结果明确为提交,服务端会将半事务消息重新存储到普通存储系统中,此时消息对下游消费者可见,等待被消费者获取并消费。
- 消费中:消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程。 此时服务端会等待消费者完成消费并提交消费结果,如果一定时间后没有收到消费者的响应,Apache RocketMQ 会对消息进行重试处理。具体信息,请参见 消费重试。
- 消费提交:消费者完成消费处理,并向服务端提交消费结果,服务端标记当前消息已经被处理(包括消费成功和失败)。 Apache RocketMQ 默认支持保留所有消息,此时消息数据并不会立即被删除,只是逻辑标记已消费。消息在保存时间到期或存储空间不足被删除前,消费者仍然可以回溯消息重新消费。
- 消息删除:Apache RocketMQ 按照消息保存机制滚动清理最早的消息数据,将消息从物理文件中删除。更多信息,请参见 消息存储和清理机制。
MQ 事务方案总结
相比本地消息表方案,MQ 事务方案优点是:
- 业务解耦:消息数据独立存储 ,降低业务系统与消息系统之间的耦合。
- 吞吐量优于本地消息表方案。
缺点是:
- 一次消息发送需要两次网络请求 (half 消息 + commit/rollback 消息)
- 业务处理服务需要实现消息状态回查接口
通用原理抽象:半消息 + 二次确认 + 回查
抛开具体实现,事务消息的通用机制可以抽象为三步,本质是把"本地事务与消息发送的原子性"问题转化为"MQ 先存住意图,再等确认":
- 半消息(Half Message):生产者先发送一条对消费者不可见的消息,MQ 持久化成功即返回——这一步把"消息会不会丢"的不确定性消除掉。
- 本地事务 + 二次确认:生产者执行本地事务,根据结果提交 Commit(消息变可见)或 Rollback(消息丢弃)。
- 状态回查(Check-back):若 MQ 迟迟等不到二次确认(生产者宕机、网络断),主动回查生产者的本地事务状态,由生产者给出确定结果。这一步是整个方案的灵魂:它把"结果未知"的中间态变成了"可重查的确定态"。
因此,事务消息要求生产者必须能回答"某个事务标识对应的本地事务最终是成还是败",这通常靠本地事务流水表实现。
方案权衡:事务消息 vs 本地消息表 vs 同步双写
| 方案 | 原理 | 优点 | 缺点 / 适用边界 |
|---|---|---|---|
| 事务消息 | 半消息 + 回查,意图存 MQ | 不占业务库资源,吞吐高;业务解耦 | 依赖 MQ 支持该特性;需实现回查接口 |
| 本地消息表 | 业务与消息意图同库同事务落盘,定时轮询发送 | 不依赖 MQ 特性,任何 MQ 可用 | 消息表与业务库耦合,轮询有延迟,业务库压力大 |
| 同步双写(发 MQ 失败则业务回滚) | 业务提交前同步发消息 | 实现最简单 | 发消息失败则业务失败,可用性耦合;发送成功但业务提交失败时无法回滚已发消息,存在不一致窗口 |
主流 MQ 的支持差异(横向对比)
| MQ | 事务支持 | 机制差异 |
|---|---|---|
| RocketMQ | 原生事务消息 | 半消息 + 定时回查(默认间隔 6s、最多 15 次),业务实现回查接口即可,是"事务消息"形态的典型代表 |
| Kafka | 事务 API(Exactly-Once 语义) | 定位不同:Kafka 事务解决的是"多分区原子写入 + 消费端幂等"(produce-consume 链路),而非"本地事务 + 消息的原子性"。没有半消息/回查机制,本地事务失败需用户自行补偿或重试提交 |
| RabbitMQ / Pulsar | 无原生回查式事务消息 | 通常用本地消息表或 publisher confirm + 重试兼底来实现等价效果 |
选型结论:需要"本地事务与消息原子绑定"时,RocketMQ 类回查机制最直接;用 Kafka 时应明确其事务语义不覆盖该场景,需用本地消息表或事务流水兼底。
失效场景:事务消息在什么条件下失效
- 回查接口实现错误:回查总是返回 Unknown(如只查内存状态、服务重启后查不到流水),半消息到达回查上限后被丢弃,消息永久丢失。
- 本地事务流水缺失:回查无法定位事务结果,只能靠猜(默认回滚)——可能把已成功的事务回滚掉。
- 回查上限耗尽:生产者长时间宕机(超过 回查间隔 × 次数 的窗口,如 6s × 15 ≈ 90s),消息被丢弃。
- 消费端无幂等:事务消息只保证"生产侧不丢",消费侧的 at-least-once 重试仍可能重复执行。
踩坑案例:某系统的"支付成功→通知清结算"链路用事务消息。某次支付服务滚动发布时,一批半消息的二次确认因进程被 kill 而丢失,Broker 启动回查;但回查接口的实现是"查当前实例的本地缓存",新实例上查不到旧事务状态,一律返回 Unknown。结果这批消息被回查耗尽后丢弃,清结算当晚少对账数十笔,靠 T+1 对账才发现。修复:① 回查改为查数据库事务流水表(跨实例可查);② 对"回查达上限被丢弃"的消息配置告警 + 自动落表待人工处理;③ 发布流程改为优雅停机(先停新事务、等存量事务确认完成再下线)。教训:回查接口的正确性是事务消息的生命线,它必须基于持久化状态而非进程内存。
量化参考
- RocketMQ:回查间隔默认 6s(
transactionCheckInterval)、最大回查次数默认 15(transactionCheckMax);半消息在确认前对消费者不可见。 - 一条事务消息的网络开销 ≈ 2 次请求(半消息 + 确认),比本地消息表的"轮询批量发送"单条成本高,但省掉了业务库的表写入与轮询压力,高吞吐场景综合更优。
- 回查窗口(间隔 × 次数)必须大于生产者可能的最长不可用时间(发布、重启、GC),否则需要调大参数或兼底对账。
拓展追问
- 事务消息和"本地消息表"在本质上有什么共同点?
两者都是"先把事务意图持久化到某个可靠存储(MQ 的半消息存储 / 业务库的消息表),再用异步机制(回查 / 轮询)兑现"。区别只在意图存储的位置:前者把存储与驱动逻辑外包给 MQ,吞吐高但依赖 MQ 特性;后者自主可控但占用业务库资源。 - Kafka 的事务为什么不能替代 RocketMQ 式事务消息?
Kafka 事务解决的是"一批 produce 要么全成要么全败 + 与消费位点原子提交",服务于 exactly-once 流处理;它没有"半消息挂起、等业务本地事务结果"的机制,Broker 不会也不能回查业务库状态。两者同名不同义。 - 为什么半消息不能被消费者看到?
若半消息可见,本地事务失败时消费者可能已经执行了副作用,回滚就来不及了。半消息的本质是"意图的持久化暂存",只有在明确 Commit 后才转为可见,从而把"消息可见性"与"本地事务结果"绑定。
场景题
场景:支付系统用事务消息实现"支付成功→发放奖励"。某天运营发现,凌晨发布窗口期间有约 200 笔支付成功了但奖励没发。用户开始投诉。你怎么排查和处理?
- 应急处理:先按支付流水表反查这 200 笔的状态,用幂等补发脚本补发(按支付流水号去重),同步客服口径;确认非系统性持续丢失后,再定位根因。
- 根因分析:时间窗口与发布重合,重点查事务消息链路的三个环节:① 半消息是否发送成功(Broker 侧可查);② 本地事务提交后二次确认是否发出(发布 kill 进程会导致丢失,依赖回查);③ 回查结果——若回查接口基于内存状态或发布期间新实例未就绪,回查会返回 Unknown/失败,耗尽次数后消息被丢弃。大概率是②+③叠加:确认丢失 + 回查窗口内服务不可用。
- 长期方案:① 回查接口基于持久化流水表,且容忍发布窗口(回查窗口调大到 > 发布时长,如间隔 10s × 60 次);② 优雅停机:发布前先停止新事务,等待存量半消息全部确认;③ 对"回查耗尽丢弃"事件实时告警 + 自动落补发表;④ T+0 对账:支付流水 vs 奖励流水,差异自动补发。
- 权衡:事务消息把"不丢"的上限押在回查窗口上,而发布、容灾这类长窗口场景必然超出默认窗口。正确姿势不是无限调大回查参数(会拖慢所有异常消息的处置),而是"发布感知(优雅停机)+ 对账兼底"双保险。
【中等】什么是最大努力通知?它和本地消息表有什么区别?⭐
最大努力通知是一种柔性事务方案,事务发起方在完成本地事务后,尽最大努力(通过消息队列等)通知事务接收方处理,通知可能有多次重试,但不保证接收方一定能处理成功。接收方可以根据通知结果自行核对、补偿。
典型场景:支付回调通知。支付平台完成支付后,通知商户系统支付结果。如果商户系统没有收到或处理失败,支付平台会按递增的时间间隔多次重试(如 1m、5m、10m、30m、1h、2h、6h、15h),直到通知成功或达到最大重试次数。
工作流程:
- 事务发起方(如支付平台)完成本地事务。
- 发起方通过 MQ 或 HTTP 回调通知接收方。
- 接收方处理业务并返回结果。
- 如果通知失败或未收到响应,发起方按策略多次重试。
- 接收方也可主动调用发起方的查询接口核对结果。
与本地消息表的区别:
| 维度 | 本地消息表 | 最大努力通知 |
|---|---|---|
| 可靠性 | 发送方保证消息不丢(本地事务保证) | 接收方需主动核对(发送方尽力通知) |
| 业务方向 | 发送方 → 接收方(推) | 发送方 → 接收方(推)+ 接收方查询(拉) |
| 一致性 | 较强(发送方保证消息发出) | 较弱(通知可能丢失,需接收方兜底) |
| 重试机制 | 发送方轮询重发 | 发送方按递增间隔重试,接收方提供查询接口 |
| 典型场景 | 跨服务数据一致性(如扣库存+下单) | 第三方回调(如支付回调、物流通知) |
【困难】Seata 的工作原理是什么?支持哪些事务模式?⭐⭐
Seata 是阿里巴巴开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。
核心角色:
- TC(Transaction Coordinator,事务协调者):维护全局事务和分支事务的状态,驱动全局事务提交或回滚。
- TM(Transaction Manager,事务管理器):定义全局事务的范围,开始、提交或回滚一个全局事务。
- RM(Resource Manager,资源管理器):管理分支事务上的资源,向 TC 注册分支事务,汇报分支事务状态,接收 TC 的指令提交或回滚分支事务。
Seata 支持四种事务模式:
| 模式 | 原理 | 一致性 | 侵入性 | 性能 |
|---|---|---|---|---|
| AT | 基于 SQL 解析自动生成回滚 SQL,通过 undo_log 表实现补偿 | 最终一致 | 低 | 高 |
| TCC | 业务实现 Try/Confirm/Cancel 三个接口 | 最终一致 | 高 | 中 |
| SAGA | 长事务拆分为多个短事务,失败时执行补偿 | 最终一致 | 中 | 中 |
| XA | 基于数据库 XA 协议的强一致性方案 | 强一致 | 低 | 低 |
AT 模式工作原理(最常用):
AT 模式是 Seata 的默认模式,其核心是通过 undo_log 表记录数据变更前后的快照,实现自动补偿。
一阶段:
- TM 向 TC 注册全局事务,获取全局事务 XID。
- 拦截业务 SQL,查询更新前数据,生成
before image。 - 执行业务 SQL。
- 查询更新后数据,生成
after image。 - 将
before image和after image存入undo_log表。 - 向 TC 注册分支事务,并在本地事务中提交(业务 SQL + undo_log 一起提交)。
- 报告分支事务状态给 TC。
二阶段-提交:
- TC 收到所有分支事务成功报告,通知各 RM 异步删除
undo_log记录。 - 由于一阶段已经提交本地事务,二阶段只需清理 undo_log,非常高效。
二阶段-回滚:
- TC 收到某个分支事务失败报告,通知各 RM 回滚。
- RM 根据 XID 和 Branch ID 查找
undo_log记录。 - 校验
after image与当前数据是否一致(脏写检测)。 - 如果一致,用
before image恢复数据;如果不一致,说明数据被其他事务修改,需要人工介入。 - 删除
undo_log记录。
AT 模式的写隔离
AT 模式在一阶段提交本地事务后,会释放本地锁,这可能导致脏写问题。Seata 通过全局锁机制解决:在提交本地事务前,RM 先向 TC 申请全局锁(锁定被修改行的主键)。其他全局事务在修改同一行时,必须等待全局锁释放。这样保证了在全局事务提交/回滚前,不会被其他全局事务修改。
但注意,AT 模式的全局锁不能防止本地事务的脏写(非 Seata 管理的事务不受约束),这是 AT 模式的一个局限。
【中等】什么是幂等性?分布式幂等如何实现?⭐⭐⭐⭐
幂等性指同一个操作执行多次与执行一次产生的结果相同。分布式环境下,网络重试、MQ 重复投递、定时任务重复执行都无法完全避免,因此写操作的幂等设计是分布式系统的基本要求。
幂等性分级
- 读操作:天然幂等。
- 删除操作:
DELETE WHERE id = x天然幂等。 - 更新操作:绝对值更新(
SET balance = 100)幂等,增量更新(SET balance = balance + 1)不幂等。 - 插入操作:不幂等,需要额外设计。
常见实现方案
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 唯一索引/约束 | 业务唯一键建唯一索引,重复插入报异常 | 订单、支付等有天然业务主键 |
| Token 机制 | 先申请 token,提交时原子校验并删除,重复请求拒绝 | 表单提交、防重复点击 |
| 去重表 | 将 requestId/bizId 写入去重表(唯一键) | 通用消息消费去重 |
| 乐观锁 | UPDATE ... WHERE version = ? | 更新场景 |
| 状态机 | 状态只能单向流转(已支付 → 已完成),非法流转拒绝 | 订单状态流转 |
| 分布式锁 | 执行前先锁住资源,防止并发重复执行 | 并发防护 |
实现要点
- MQ 消费端应提取消息唯一 ID(或业务唯一键)做去重,消费失败重试时靠幂等兼容。
- Redis 去重有过期窗口,强一致要求高的场景应用数据库去重表。
- 去重判断与业务操作尽量在同一事务内完成,避免"判断通过但业务失败"导致的状态不一致。
一句话总结:幂等的本质是"唯一标识 + 去重"——先找到业务唯一键,再选择可靠的去重介质。
方案权衡:去重介质的可靠性 vs 性能
| 去重介质 | 可靠性 | 性能 | 适用边界 |
|---|---|---|---|
| 数据库唯一索引 / 去重表 | 强(事务内原子判断 + 业务同事务提交) | 中(受 DB 并发限制) | 资金、订单等不容许任何重复的场景,首选 |
| Redis SETNX / TTL | 中(过期窗口、主从切换可能丢 key) | 高 | 高并发、可容忍极端情况重复(可事后对账)的场景 |
| Token 机制 | 强(申请-核销原子化,可用 Redis Lua 或 DB) | 中 | 表单提交、防重复点击等前置校验场景 |
| 状态机 | 强(状态单向流转天然去重) | 高 | 订单状态流转类业务,应与唯一索引组合使用 |
选型经验:去重判断必须与业务操作在同一事务 / 同一原子操作内完成,否则会出现"判断通过但业务失败"的窗口:Redis 去重与 DB 写入分两步时,若中间崩溃,去重记录已存在但业务未执行,重试反而被拒绝。
失效场景:幂等方案在什么条件下失效
- 唯一键选错:用自增 ID、时间戳这类非业务键做去重,重试时每次生成新键,去重形同虚设。
- Redis 去重窗口失效:去重 key TTL 设短了(如 10 分钟),超过窗口的延迟重试(如 MQ 重投递、跨天补偿)照样重复执行;Redis 主从切换时未同步的 key 丢失,同样失效。
- "查询 + 插入"两步操作的竞态:两个并发请求同时查到"不存在",都插入成功。必须用唯一索引兑底或分布式锁串行化。
- 时钟回拨导致唯一键重复:用雪花算法生成业务键时,时钟回拨可能生成重复 ID,与幂等去重叠加成双重风险。
踩坑案例:某支付回调接口用 Redis SETNX 做幂等(TTL 1 小时)。某次渠道方因自身故障,在 3 小时后才补推一批历史支付通知,此时去重 key 早已过期,这批订单被重复入账,产生资损。排查:对账发现同一支付单号出现两笔入账,追溯发现回调间隔超过了 TTL。修复:① 幂等介质改为数据库唯一索引(支付单号 + 渠道流水号),永久去重;② Redis 仅保留作为前置快速拦截(减轻 DB 压力),不再作为唯一防线;③ 对超过 24 小时的迟到回调转人工审核。教训:幂等的有效期必须 ≥ 重试可能发生的最大时间跨度,资金类场景的唯一防线必须是持久化的唯一索引。
量化参考
- MQ 重复投递:Kafka 消费重试默认最多 3 次(
delivery.timeout默认 2 分钟),RocketMQ 消费重试最多 16 次、从 10s 到 2h 递增——去重窗口必须覆盖小时级。 - Redis 去重的 TTL 经验值:效率拦截场景 5~10 分钟;兼底场景必须用持久化介质,而不是加大 TTL。
- 数据库唯一索引的并发兑底:MySQL 下并发插入相同唯一键,只有一个成功,其余报
Duplicate entry(错误码 1062),业务需捕获该异常并返回幂等成功而非报错。
拓展追问
- 幂等和"恰好一次(exactly-once)"是什么关系?
分布式系统的消息投递只能做到 at-least-once,"恰好一次"实际上是"at-least-once + 幂等去重"的效果。所以任何声称 exactly-once 的系统,本质上都是在某一端做了幂等(如 Kafka 事务是把消费位点提交与生产原子绑定 + 生产者幂等序列号)。 - 为什么"先查后写"不能实现幂等?
查询与写入之间存在竞态窗口,并发重试可能同时通过查询。真正的幂等必须依赖原子约束:唯一索引、乐观锁版本号、CAS、分布式锁,把"判断与生效"变成不可分割的一步。 - 接口幂等了还需要分布式锁吗?
幂等解决"重复执行结果相同",锁解决"并发执行的互斥",两者维度不同。若并发执行会产生资源竞争(如同一账户余额并发扣减),仍需锁或数据库行级约束;幂等不能替代互斥。
场景题
场景:订单支付回调接口在大促期间被渠道方重复回调(同一单最多 7 次),监控发现部分订单的"发放奖励"被重复执行,奖励多发。如何排查和修复?
- 应急处理:立即对回调接口加临时去重前置(Redis SETNX 支付单号,拦掉大部分重复);对已多发的奖励暂停自动发放,导出清单人工核对回收;与渠道方确认其重试策略(间隔、次数上限),评估后续重复压力。
- 根因分析:检查代码发现"发放奖励"的幂等依赖的是"查询订单是否已发放"(先查后写),两个并发回调同时查到"未发放",都执行了发放;且没有唯一索引兑底。这属于典型的"用查询代替幂等约束"错误。
- 长期方案:① 发放记录表对(订单号)建唯一索引,发放动作与插入记录同事务,靠
Duplicate entry兑底;② 回调入口保留 Redis 前置拦截降低 DB 压力;③ 状态机约束:订单只能从"待发放"单向流转;④ 奖励流水与订单 T+1 对账。 - 权衡:数据库唯一索引比 Redis 去重性能低,但幂等场景的正确性优先级永远高于性能:前置缓存挡流量、DB 约束保正确,两层组合而不是二选一。
分布式锁
【简单】什么是分布式锁?为什么需要分布式锁?⭐⭐
在计算机科学中,锁是在并发场景下用于强行限制资源访问的一种同步机制,即用于在并发控制中通过互斥手段来保证数据同步安全。
在 Java 进程中,可以使用 Lock、synchronized 等来支持并发锁。如果是同一台机器的不同进程,想要同时操作一个共享资源(例如修改同一个文件),可以使用操作系统提供的「文件锁」或「信号量」来做互斥。这些发生在同一台机器上的互斥操作,可以称为本地锁。

本地锁无法协同不同机器间的互斥操作。为了解决这个问题,需要引入分布式锁。
分布式锁,顾名思义,应用于分布式场景下,它和单进程中的锁并没有本质上的不同,只是控制对象由一个进程中的多个线程变成了多个进程中的多个线程。此外,临界区的资源也由进程内共享资源变成了分布式系统内部共享资源。

为什么需要分布式锁:两类典型动机
| 动机 | 说明 | 例子 | 失效后果 |
|---|---|---|---|
| 正确性(防数据损坏) | 互斥失败会直接破坏数据 | 并发扣减同一账户余额、并发分配同一库存 | 资损、超卖,不可逆 |
| 效率(防重复劳动) | 互斥失败只是浪费资源 | 定时任务多实例重复执行、重复计算报表 | 资源浪费、重复通知,可容忍偶发 |
这个区分至关重要,它直接决定选型:保正确性的锁必须选强一致存储(ZooKeeper/etcd)并配套 fencing;保效率的锁可以用高性能的 Redis,偶尔失效可接受。
方案权衡:三大实现路线的定位
| 路线 | 代表 | 优点 | 缺点 / 适用边界 |
|---|---|---|---|
| 基于数据库 | 唯一索引锁表 | 实现简单、无额外组件 | 性能最差、无阻塞等待、易死锁;仅适合低频、无现成中间件的场景 |
| 基于 Redis | SET NX EX + Redisson | 性能最高(10 万+ TPS 量级) | 主从切换可能丢锁;适合效率类、高并发锁 |
| 基于 ZooKeeper | 临时顺序节点 + Watch | 可靠性最高(会话断开自动释放、CP 强一致) | 性能弱于 Redis(写全走 Leader);适合正确性优先、中低并发场景 |
失效场景:分布式锁的三大失效根源
- 锁过期但业务未完成:TTL 到期锁自动释放,另一个线程拿到锁,两个线程同时持有锁。需看门狗续期缓解,但 GC 停顿(Stop-The-World)期间续期线程也会停摆,无法根治。
- 锁服务端故障:Redis 主从切换丢锁(未同步的 key 丢失);ZooKeeper 选主期间锁服务短暂不可用。
- 客户端假死:网络断连 / 长时间 GC 后客户端恢复,误以为自己仍持锁继续操作——锁只能保护"诚实的客户端",无法约束已经失联后恢复的持有者。
踩坑案例:某系统的对账定时任务在两个实例上用 Redis 锁互斥。某次实例 A 持锁期间发生 Full GC 停顿 45 秒,锁 TTL(30 秒)过期后实例 B 拿到锁开始对账;A 醒来后误以为自己还持锁继续执行,两个实例并发修改同一批待平账记录,导致部分账款被重复平账。排查:日志显示两个实例的同一批次处理时间重叠 12 秒。修复:① 关键写操作增加 fencing:带锁版本号的条件更新(UPDATE ... WHERE lock_version = ?),旧版本写入被拒;② GC 调优减少长停顿;③ 对账动作幂等化。教训:锁 TTL 和客户端 GC 是天然矛盾的,锁只能互斥"正常活着的客户端",正确性兼底必须靠 fencing 或幂等。
量化参考
- 锁超时经验值:TTL 应显著大于业务最大执行时间(常见取 3~10 倍),如业务 P99 执行 3 秒则 TTL 设 30 秒;Redisson 看门狗默认锁时长 30s、每 10s(1/3 周期)续期一次。
- 性能对比:Redis 锁可达 10 万+ TPS 量级;ZooKeeper 锁受限于写走 Leader,通常数千 TPS;数据库锁数百 TPS 即可能成为瓶颈。
- ZooKeeper 会话超时默认 2 倍 tickTime(常见 4~40s 区间),会话断开即自动删除临时节点释放锁,无需 TTL 担心死锁。
拓展追问
- 分布式锁和本地锁在失效模式上有什么本质区别?
本地锁的持有者崩溃后,操作系统会回收进程资源,锁自然释放;分布式锁的持有者崩溃 / 断网后,锁服务端无法区分"死了"和"只是网络不好",只能靠 TTL / 会话超时这种"超时即认定死亡"的启发式机制,而超时机制天然存在误判窗口——这是分布式锁一切复杂性的根源。 - 为什么"锁过期"不等于"锁被安全释放"?
锁过期只是服务端删除了 key,但原持有者对此一无所知,它醒来后仍会继续操作临界区。所以锁的安全性不能只靠锁服务,还需要临界区侧的配合:fencing token、幂等、条件更新。 - 什么场景下分布式锁是完全不必要的?
两类:① 操作本身幂等且无资源竞争(如基于唯一索引的插入);② 可以用数据库行级锁 / 乐观锁在存储层解决的并发控制。很多"分布式锁滥用"实际上是把存储层能解决的问题搬到了应用层,增加了故障面。
场景题
场景:某库存服务用 Redis 分布式锁保护"扣减库存"临界区。某天 Redis 主从切换后,监控发现同一 SKU 在 5 秒内出现两次并发扣减,产生超卖。如何排查和决策?
- 应急处理:先锁定影响面:导出切换时间窗内的该 SKU 扣减流水,核对实际超卖量,人工或补偿脚本修正库存;评估切换期间是否有其他 SKU 受影响(按时间窗扫描)。
- 根因分析:时间线与 Redis 主从切换吻合,典型的"主从异步复制丢锁":实例 A 在旧主上加锁成功,旧主未同步 key 到从库即宕机,从库提升为新主后 key 不存在,实例 B 加锁成功,两者同时持锁。这是 Redis 锁的已知失效模式,不是 bug 而是架构固有特性。
- 长期方案:① 若锁用于保正确性,迁移到 ZooKeeper / etcd 锁(CP,切换不丢锁);或② 保留 Redis 锁但把正确性兼底移到存储层:扣减改为数据库条件更新(
UPDATE ... WHERE cnt >= ?),锁退化为效率优化;③ 若坚持 Redis 且必须强互斥,评估 RedLock(但需接受其争议与复杂度)。 - 权衡:把正确性押在 Redis 锁上,等于接受"主从切换窗口内可能双持锁"的尾部风险;库存这种错了就是资损的场景,正确做法是让锁只管效率、让存储层约束保正确——锁的选型先问"它防的是效率问题还是正确性问题",答案决定一切。
【困难】实现分布式锁有哪些要点?⭐⭐
分布式锁的解决方案大致有以下几种:
- 基于数据库实现
- 基于缓存(Redis,Memcached 等)实现
- 基于 Zookeeper 实现
分布式锁的实现要点大同小异,仅在实现细节上有所不同。
分布式锁的实现要点如下:
- 互斥:分布式锁必须是独一无二的,表现形式为:向数据存储插入一个唯一的 key,一旦有一个线程插入这个 key,其他线程就不能再插入了。
- 保证 key 唯一性的最简单的方式是使用 UUID。
- 此外,可以参考 Snowflake ID(雪花算法),将机器地址(IP 地址、机器 ID、MAC 地址)、Jvm 进程 ID(应用 ID、服务 ID)、时间戳等关键信息拼接起来作为唯一标识。
- 避免死锁:在分布式锁的场景中,部分失败和异步网络这两个问题是同时存在的。如果一个进程获得了锁,但是这个进程与锁服务之间的网络出现了问题,导致无法通信,那么这个情况下,如果锁服务让它一直持有锁,就会导致死锁的发生。
- 常见的解决思路是引入超时机制,即成功申请锁后,超过一定时间,锁失效(删除 key)。超时机制解锁了死锁问题,但又引入了一个新问题:如果应用加锁时,对于操作共享资源的时长估计不足,可能会出现:操作尚未执行完,但是锁没了的尴尬情况。为了解决这个问题,需要引入锁续期机制:当持有锁的线程尚未执行完操作前,不断周期性检测锁的超时时间,一旦发现快要过期,就自动为锁续期。
- ZooKeeper 分布式锁避免死锁采用了另外一种思路—— Watch 机制。
- 可重入:可重入指的是:同一个线程在没有释放锁之前,能否再次获得该锁。其实现方案是:只需在加锁的时候,记录好当前获取锁的节点 + 线程组合的唯一标识,然后在后续的加锁请求时,如果当前请求的节点 + 线程的唯一标识和当前持有锁的相同,那么就直接返回加锁成功;如果不相同,则按正常加锁流程处理。
- 公平性:当多个线程请求同一锁时,它们必须按照请求的顺序来获取锁,即先来先得的原则。锁的公平性的实现也非常简单,对于被阻塞的加锁请求,我们只要先记录好它们的顺序,在锁被释放后,按顺序颁发就可以了。
- 重试:有时候,加锁失败可能只是由于网络波动、请求超时等原因,稍候就可以成功获取锁。为了应对这种情况,加锁操作需要支持重试机制。常见的做法是,设置一个加锁超时时间,在该时间范围内,不断自旋重试加锁操作,超时后再判定加锁失败。
- 容错:分布式锁若存储在单一节点,一旦该节点宕机或失联,就会导致锁失效。将分布式锁存储在多数据库实例中,加锁时并发写入
N个节点,只要N / 2 + 1个节点写入成功即视为加锁成功。
【中等】数据库分布式锁的工作原理是什么?⭐⭐
数据库分布式锁原理
(1)创建锁表
CREATE TABLE `distributed_lock` (
`id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`resource` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '资源',
`count` INT(10) UNSIGNED NOT NULL DEFAULT '0' COMMENT '锁次数,统计可重入锁',
`desc` TEXT DEFAULT NULL COMMENT '备注',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_resource`(`resource`)
)
ENGINE = InnoDB DEFAULT CHARSET = `utf8mb4`;(2)获取锁
想要锁住某个方法时,执行以下 SQL:
insert into distributed_lock(resource,desc) values (‘resourceA’,‘desc’)因为我们对 resource 做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
成功插入则获取锁。
(3)释放锁
当方法执行完毕之后,想要释放锁的话,需要执行以下 Sql:
delete from methodLock where resource ='resourceA'数据库分布式锁小结
数据库分布式锁的问题:
- 死锁:一旦释放锁操作失败,或持有锁的机器宕机、断连,就会导致锁记录一直存在,其他线程无法再获得锁。解决办法:为锁增加失效时间字段,启动一个定时任务,隔一段时间清除一次过期的数据。
- 非阻塞:因为
insert操作一旦失败就会报错,因此未获得锁的线程并不会进入排队队列,要想获得锁就要再次触发加锁操作。解决办法:循环重试,直到插入成功,这么做会产生一定额外开销。 - 非重入:同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。解决办法:在数据库表中加个字段,记录当前获得锁的节点信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
- 单点问题:如果数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。解决办法:单点问题可以用多数据库实例,同时写入
N个节点,N / 2 + 1个成功就加锁成功。
数据库分布式锁的利弊:
- 优点:直接借助数据库,简单易懂。
- 缺点:会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。此外,数据库性能易成为瓶颈。
【困难】ZooKeeper 分布式锁的工作原理是什么?⭐⭐
ZooKeeper 分布式锁的实现基于 ZooKeeper 的两个重要特性:
- 顺序临时节点:ZooKeeper 的存储类似于 DNS 那样的具有层级的命名空间。ZooKeeper 节点类型可以分为持久节点(
PERSISTENT)、临时节点(EPHEMERAL),每个节点还能被标记为有序性(SEQUENTIAL),一旦节点被标记为有序性,那么整个节点就具有顺序自增的特点。 - Watch 机制:ZooKeeper 允许用户在指定节点上注册一些
Watcher,并且在特定事件触发的时候,ZooKeeper 服务端会将事件通知给用户。
下面是 ZooKeeper 分布式锁的工作流程:
- 创建一个目录节点,比如叫做
/locks; - 线程 A 想获取锁,就在
/locks目录下创建临时顺序 zk 节点; - 获取
/locks目录下所有的子节点,检查是否存在比自己顺序更小的节点:若不存在,则说明当前线程创建的节点顺序最小,获取锁成功; - 此时,线程 B 试图获取锁,发现自己的节点顺序不是最小,设置监听锁号在自己前一位的节点;
- 线程 A 处理完,删除自己的节点。线程 B 监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。
ZooKeeper 分布式锁的优点是较为可靠:
- 避免死锁:ZooKeeper 通过顺序临时节点 + 监听机制,可以保证:如果持有临时节点的线程主动解锁或断连,Zk 会自动删除临时节点,这意味着锁的释放。所以,不存在锁永久不释放从而导致死锁的问题。
- 单点问题:ZooKeeper 采用主从架构,并确保主从同步是强一致的,因此不会出现单点问题。
ZooKeeper 分布式锁的缺点是:加锁、解锁操作,本质上是对 ZooKeeper 的写操作,全部由 ZooKeeper 主节点负责,然后再同步到从节点。如果加锁、解锁的吞吐量很大,容易出现单点写入瓶颈。
【困难】Redis 分布式锁的工作原理是什么?⭐⭐⭐
极简版本
我们先来看一下,如何实现一个极简版本的 Redis 分布式锁。

(1)加锁
Redis 中的 setnx 命令,表示当且仅当 key 不存在时,才会写入 key。由于其互斥性,所以可以基于此来实现分布式锁。
执行 setnx key val,若返回 1,表示写入成功,即加锁成功;若返回 0,表示该 key 已存在,写入失败,即加锁失败。
(2)解锁
Redis 分布式锁如何解锁呢?
很简单,删除 key 就意味着释放锁,即执行 del key 命令。
避免死锁
极简版本的解决方案有一个很大的问题:存在死锁的可能。持有锁的节点如果执行业务过程中出现异常或机器宕机,都可能导致无法释放锁。这种情况下,其他节点永远也无法再获取锁。
对于异常,在 Java 中,可以通过 try...catch...finally 来保证:最终一定会释放锁,其他编程语言也有相似的语法特性。
对于机器宕机这种情况,如何处理呢?通常的对策是:为锁加上超时机制,过期自动删除。
在 Redis 中,expire 命令可以为 key 设置一个超时时间,一旦过期,Redis 会自动删除 key。如此看来,setnx + expire 组合使用,就能解决死锁问题了。可惜,没那么简单。Redis 只能保证单一命令的原子性,不保证组合命令的原子性。
那么,Redis 中有没有一条命令可以实现 setnx + expire 的组合语义呢?还真有,可以通过下面的命令来实现:
# 下面两条命令是等价的
SET key val NX PX 30000
SET key val NX EX 30参数说明:
NX:该参数表示当且仅当 key 不存在,才能写入成功PX:超时时间,单位毫秒EX:超时时间,单位秒
超时续期
为了避免死锁,我们为锁添加了超时时间。但这里有一个问题,如果应用加锁时,对于操作共享资源的时长估计不足,可能会出现:操作尚未执行完,但是锁没了的尴尬情况。为了解决这个问题,很自然会想到,时间不够,就续期呗。
具体来说,如何续期呢?一种方案是:加锁后,启动一个定时任务,周期性检测锁是否快要过期,如果快要过期并且操作尚未结束,就对锁进行自动续期。自行实现这个方案似乎有点繁琐,好在开源 Redis 客户端 Redisson 中已经为锁的超时续期提供了一个成熟的机制——WatchDog(看门狗)。我们可以直接拿来主义即可。
安全解锁
前文提到了,解锁的操作,实际上就是 del key。这里存在一个问题:因为没有任何判断,任何节点都可以随意删除 key,换句话说,锁可能会被其他节点释放。如何避免这个问题呢?解决方法就是:为锁添加唯一性标识来进行互斥。唯一性标识可以是 UUID,可以是雪花算法 ID 等。
在 Redis 分布式锁中,唯一性标识的具体实现就是在 set key val 时,将唯一性标识 id 作为 val 写入。解锁前,先判断 key 的 value,必须和 set 时写入的 id 值保持一致,以此确认锁归属于自己。解锁的伪代码如下:
if (redis.get("key") == id)
redis.del("key");这里依然存在一个问题,由于需要在 Redis 中,先 get,后 del 操作,所以无法保证操作的原子性。为了保证原子性,可以将这段伪代码用 lua 脚本来实现,这么做的理由是 Redis 中支持原子性的执行 lua 脚本。下面是安全解锁的 lua 脚本代码:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end自旋重试
有时候,加锁失败可能只是由于网络波动、请求超时等原因,稍候就可以成功获取锁。为了应对这种情况,加锁操作需要支持重试机制。常见的做法是,设置一个加锁超时时间,在该时间范围内,不断自旋重试加锁操作,超时后再判定加锁失败。
下面是一个自旋重试获取锁的伪代码示例:
try {
long begin = System.currentTimeMillis();
while (true) {
String result = jedis.set(lockKey, uniqId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
// 加锁成功,执行业务操作
return true;
}
long time = System.currentTimeMillis() - begin;
if (time >= timeout) {
return false;
}
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} catch (Exception e) {
// 异常处理
} finally {
// 释放锁
}Redis 分布式锁小结
在前文中,为了实现一个靠谱的 Redis 分布式锁,我们讨论了避免死锁、超时续期、安全解锁几个问题以及应对策略。但是,依然存在一些其他问题:
- 不可重入:同一个线程无法多次获取同一把锁。
- 单点问题:Redis 主从同步存在延迟,有可能导致锁冲突。举例来说:线程一在主节点加锁,如果主节点尚未同步给从节点就发生宕机;此时,Redis 集群会选举一个从节点作为新的主节点。此时,新的主节点没有锁的数据,若有其他线程试图加锁,就可以成功获取锁,即出现同时有多个线程持有锁的情况。解决这个问题,可以使用 RedLock 算法。
Redisson 是一个流行的 Redis Java 客户端,它基于 Netty 开发,并提供了丰富的扩展功能,如:分布式计数器、分布式集合、分布式锁 等。
Redisson 支持的分布式锁有多种:Lock, FairLock, MultiLock, RedLock, ReadWriteLock, Semaphore, PermitExpirableSemaphore, CountDownLatch,可以根据场景需要去选择,非常方面。一般而言,使用 Redis 分布式锁,推荐直接使用 Redisson 提供的 API,功能全面且较为可靠。
方案权衡:单节点锁 vs 主从锁 vs RedLock
| 方案 | 可靠性 | 性能 | 适用边界 |
|---|---|---|---|
单节点 SET NX EX | 节点宕机即锁服务不可用,但不会双持锁(除非重启后 key 丢失) | 最高 | 对锁服务可用性要求不高的效率类锁 |
| 主从 / 哨兵架构锁 | 主从切换窗口可能丢锁(异步复制) | 高 | 效率类锁的主流生产选择,正确性兼底靠存储层 |
| RedLock(多独立主节点) | 理论上降低丢锁概率,但受 NPC(网络延迟、GC、时钟漂移)挑战,且存在争议 | 较低(多节点写) | 不推荐:复杂度高、收益存疑;要正确性首选 ZooKeeper/etcd |
失效场景:Redis 锁的四类失效
- 主从切换丢锁:主节点加锁后未同步到从库即宕机,从库提升为新主后锁 key 不存在,另一个客户端可加锁成功,出现双持锁。
- 锁过期双持锁:业务执行超过 TTL(或续期失败),锁自动释放后他人加锁;即使有看门狗,Full GC 停顿期间续期线程也停摆,GC 恢复后锁可能已易主。
- 误删他人锁:早期实现直接
DEL,会删掉别人刚加的锁;必须校验唯一标识 + Lua 原子执行。 - 时钟问题:Redis 的过期依赖服务端时钟,时钟回拨(NTP 校正)可能让 key 提前 / 延迟过期;RedLock 对时钟的依赖更重,也是 Kleppmann 批评的核心。
踩坑案例:某订单服务的"合并支付单"任务用 Redisson 分布式锁互斥,锁 TTL 默认 30s + 看门狗。某天该实例发生 Full GC 停顿 40 秒:看门狗线程停摆无法续期,锁 30s 过期后另一实例拿到锁开始处理同一批支付单;GC 恢复后原实例继续执行,两个实例并发拆分同一批单据,导致部分支付单被重复拆分。排查:对比两实例日志发现同一批单号的处理时间重叠 10 秒,且重叠前一刻有 GC 日志。修复:① 临界区写操作改为带单据状态的条件更新(UPDATE ... WHERE status = '待拆分'),旧持有者写入被拒;② 拆分动作幂等化;③ JVM 调优控制 GC 停顿 < 5s。教训:GC 停顿会同时冻结业务线程和续期线程,看门狗只能覆盖"业务变慢",覆盖不了"进程假死";临界区自身的幂等 / 条件更新才是最后防线。
量化参考
- Redisson 看门狗:默认锁时长
lockWatchdogTimeout = 30s,续期周期为其 1/3(10s),每次续期重置为 30s;显式指定 leaseTime 时看门狗不启动。 - 加锁命令
SET key val NX PX 30000:单次 RTT 约 0.1~1ms(同机房);自旋重试的轮询间隔常见 50~100ms。 - 主从复制延迟:同机房正常 < 1ms,但主库宕机瞬间未同步的写入(包括刚加的锁)会丢失,丢锁窗口 ≈ 复制延迟 + 故障检测时间(哨兵故障判定默认
down-after-milliseconds=30s,可下调)。 - TTL 经验值:P99 业务执行时间的 3~10 倍;太短容易双持锁,太长则持有者宕机后锁阻塞时间过长。
拓展追问
- 为什么解锁必须先校验 value 再用 Lua 脚本删除?
不校验会误删别人的锁(自己的锁过期后别人刚加的锁);分步执行 GET + DEL 则存在竞态:GET 后、DEL 前锁恰好过期易主,仍会误删。Lua 脚本在 Redis 中原子执行,把"校验归属 + 删除"变成不可分割的一步。 - 看门狗续期能不能完全避免锁过期双持锁?
不能。看门狗只能覆盖"业务执行变慢"的场景;当进程因 GC、宿主机卡死等原因整体停顿时,续期线程同样停顿,锁照样过期。这是 Kleppmann 批评 Redis 锁的核心论据之一:基于超时的锁无法防御 Process Pause。 - Redis 锁和 fencing token 如何配合?
加锁时获取一个单调递增的 token(如 RedisINCR计数器),写临界区资源时携带 token,资源侧拒绝小于已见最大 token 的写入。这样即使锁失效后旧持有者恢复操作,其旧 token 也会被资源侧拒绝。注意:普通 Redis 锁不自带 fencing,需要自行实现。
场景题
场景:大促前,团队把"每日结算"定时任务部署了 3 个实例,用 Redis 锁互斥。有同事担心 Redis 主从切换导致重复结算,提议换成 ZooKeeper 锁;另一同事认为"加个重试幂等就够了"。你怎么决策?
- 应急处理:(上线前评审阶段,无故障;若已上线,先在结算动作处确认幂等兼底是否已存在,再评估是否限流暂停任务观察。)
- 根因分析:结算属于"错了就是资损"的正确性场景,风险点有两个:① Redis 主从切换丢锁(概率低但存在);② 持锁实例 GC 停顿 / 网络分区后锁过期双持锁。单靠 Redis 锁 + 看门狗无法消除②;而 ZooKeeper 锁虽然切换不丢锁,但同样存在"会话超时前客户端假死"的窗口——换锁只能降低概率,不能消灭风险。
- 长期方案:分层设防而非单点押宝:① 锁选 Redisson(性能与易用性)承担互斥职责;② 结算动作本身幂等(结算单号唯一索引,已结算状态拒绝重复);③ 关键状态变更用条件更新(
WHERE status = '待结算')作为 fencing 等价物;④ 结算流水 T+1 对账告警。若资源有限只能选一项,选②而不是换锁。 - 权衡:把正确性全部押在锁的可靠性上(无论 Redis 还是 ZooKeeper)都是单点思维;分布式锁在极端场景下必然失效,正确姿势是"锁防效率(99% 的并发)+ 幂等/条件更新保正确(1% 的极端)"。这也回答了两位同事:换 ZooKeeper 锁有价值但不是关键,幂等兼底才是关键。
【中等】RedLock 分布式锁的工作原理是什么?⭐⭐⭐
RedLock 分布式锁,是 Redis 的作者 Antirez 提出的一种解决方案。
扩展:RedLock 官方文档
RedLock 分布式锁原理

RedLock 分布式锁在普通 Redis 分布式锁的简单上,进行了扩展,其要点在于:
- (1)加锁操作不是写入单一节点,而是同时写入多个主节点,官方推荐集群中至少有 5 个主节点。
- (2)只要半数以上的主节点写入成功,即视为加锁成功。
- (3)大多数节点加锁的总耗时,要小于锁设置的过期时间。
- (4)解锁时,要向所有节点发起请求。
下面来逐一解释以上各要点的用意:
(1)RedLock 加锁时,为什么要同时写入多个主节点?
这是为了避免单点问题,即使有部分实例出现异常,依然可以正常提供加锁、解锁能力。
(2)为什么要半数以上的主节点写入成功,才视为加锁成功?
在分布式系统中,为了达成共识,常常采用“多数派”策略来进行决策:大多数节点认可的行为,就视为整体通过。
(3)为什么加锁成功后,还要计算加锁的累计耗时?
因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久。而且,网络情况是复杂的,可能存在延迟、丢包、超时等情况。网络请求越多,异常发生的概率就越大。所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经超过了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。
(4)解锁时,为什么要向所有节点发起请求?
因为网络环境的复杂性,可能会存在这种情况:向某主节点写入锁信息,实际写入成功,但是响应超时或丢包。
所以,释放锁时,不管之前有没有加锁成功,需要释放所有节点的锁,以保证清理节点上残留的锁。
RedLock 分布式锁小结
(1)RedLock 不能完全保证安全性
分布式系统会遇到三座大山:NPC
- N:Network Delay,网络延迟;
- P:Process Pause,进程暂停(GC);
- C:Clock Drift,时钟漂移。
RedLock 在遇到以上情况时,不能保证安全性。

(2)RedLock 加锁、解锁需要处理多个节点,代价太高
总结来说,已知的分布式锁,无论采用什么解决方案,在极端情况下,都无法保证百分百的安全。
【中等】Redssion 分布式锁的工作原理是什么?⭐⭐⭐
Redssion 分布式锁解决了原生 Redis 锁(SET NX EX)的 “锁续期、死锁、单点故障” 等痛点,核心逻辑可总结为 “原子加锁→看门狗续期→安全解锁→高可用扩展”,兼顾易用性和生产级可靠性。

步骤 1:加锁(原子操作 + 可重入设计)
客户端调用lock()/lock(time, unit)时,Redisson 执行以下逻辑:
(1) 生成唯一标识:客户端 ID(UUID)+ 线程 ID,作为锁的 value(如a1b2c3:123);
(2)原子加锁命令(Redis 2.6+):
# NX=不存在才设置,PX=过期时间(毫秒),RETRY_COUNT=重试次数
SET lock_key {客户端 ID}:{线程 ID}:1 NX PX 30000- 可重入逻辑:若锁已存在且 value 匹配当前客户端,执行
HINCRBY lock_key {客户端 ID}:{线程 ID} 1(Hash 结构),增加重入次数,无需重新加锁; - 过期时间:默认 30 秒(可配置),避免死锁;若指定了
lock(time, unit),则用用户指定的过期时间,关闭看门狗。
步骤 2:加锁失败→阻塞 / 重试(公平 / 非公平策略)
若加锁失败(锁已被其他客户端持有):
- 非公平锁:客户端通过
SUBSCRIBE订阅锁的释放消息,阻塞等待,直到收到解锁通知后重试加锁; - 公平锁:客户端先将自己加入等待队列(Redis List),仅当队列头部是自己时才尝试加锁,保证 “先到先得”。
步骤 3:看门狗续期(核心痛点解决)
若加锁时未指定过期时间(调用lock()无参方法),Redisson 自动启动看门狗线程:
(1)看门狗是一个后台定时线程,每隔lockWatchdogTimeout/3(默认 10 秒)执行一次;
(2)续期逻辑:通过 Lua 脚本原子性延长锁的过期时间至 30 秒:
if redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
redis.call('pexpire', KEYS[1], ARGV[2])
return 1
else
return 0
end(3)停止条件:客户端执行unlock()解锁,或客户端宕机(看门狗线程终止,锁到期自动释放)。
步骤 4:解锁(安全校验 + 原子删除)
客户端调用unlock()时,Redisson 执行以下逻辑(Lua 脚本保证原子性):
- 校验锁归属:判断锁的 value 是否匹配当前客户端 ID + 线程 ID;
- 处理可重入:若重入次数 > 1,执行
HINCRBY lock_key {客户端 ID}:{线程 ID} -1,减少重入次数; - 删除锁:若重入次数 = 1,执行
DEL lock_key删除锁,并发布解锁通知(PUBLISH),唤醒等待的客户端; - 异常处理:若锁已过期 / 归属不匹配,抛出
IllegalMonitorStateException,避免误删锁。
【困难】分布式锁如何进行技术选型?⭐⭐⭐
下面是主流分布式锁技术方案的对比,可以在技术选型时作为参考:
| 数据库 | Redis | ZooKeeper | |
|---|---|---|---|
| 要点 | 1. 维护一张锁表,为锁的唯一标识字段添加唯一性约束。 2. 只要 insert 成功,即视为加锁成功。 | set lockKey randomValue NX PX/EX time 当且仅当 key 不存在时才可以写入,并且设定超时时间,以避免死锁。 | 加锁本质上是在 zk 中指定目录创建顺序临时接节点,序号最小即加锁成功。节点删除时,有监听通知机制告知申请锁的线程。 |
| 难度 | 实现简单、易于理解 | 较为简单,但要使其更可靠,需要有一些完善策略 | 应用简单,但 zk 内部机制并不简单 |
| 性能 | 性能最差,易成为瓶颈 | 性能最高 | 性能弱于 Redis |
| 可靠性 | 有锁表的风险 | 较为可靠(需要一些完善策略) | 可靠性最高 |
| 适用场景 | 一般不采用 | 适用于高并发的场景 | 适用于要求可靠,但并发量不高的场景 |
| 开源实现 | 无 | Redisson | Apache Curator |
【困难】RedLock 算法有什么争议?Martin Kleppmann 和 Antirez 的争论核心是什么?⭐⭐
RedLock 是 Redis 作者 Antirez 提出的多节点分布式锁算法。但《数据密集型应用系统设计》作者 Martin Kleppmann 在博客中对其提出了严厉批评,引发了一场著名的争论。
争论核心 1:时钟可靠性问题
- Kleppmann 的批评:RedLock 依赖各节点的本地时钟来计算锁的有效期。但分布式系统的时钟不可靠(时钟漂移、NTP 调整、GC 暂停),可能导致:
- 客户端 GC 暂停期间,锁已过期,其他客户端获取锁,出现两个客户端同时持锁。
- 时钟回拨,导致有效期计算错误。
- Antirez 的回应:RedLock 对时钟的依赖是"合理"的,且建议用户配置合理的 TTL 和 NTP。但 Kleppmann 认为这只是在"赌"时钟不会出问题。
争论核心 2:NPC 问题(Network Delay、Process Pause、Clock Drift)
RedLock 在以下情况下无法保证安全性:
- N(网络延迟):加锁请求因网络延迟导致耗时超过 TTL。
- P(进程暂停):GC Stop-The-World 期间锁过期。
- C(时钟漂移):不同节点时钟不同步,导致 TTL 计算错误。
争论核心 3: fencing token 问题
- Kleppmann 的建议:分布式锁应配合 fencing token(单调递增的令牌)。每次获取锁时返回一个递增的 token,客户端在访问存储时带上 token,存储层拒绝旧 token 的写入。这样即使锁过期,旧客户端的写入也会被拒绝。
- Antirez 的回应:RedLock 场景下,存储层(如 Redis)不一定支持 fencing token,且这增加了复杂度。
实践结论:
| 场景 | 推荐方案 |
|---|---|
| 对正确性要求极高 | ZooKeeper / etcd(基于共识算法,CP) |
| 对性能要求高,可容忍极端情况下的错误 | Redis 单节点锁 + Redisson 看门狗 |
| 多节点 RedLock | 不推荐,复杂度高且收益有限 |
总结
- 如果锁用于效率优化(避免重复计算),偶尔失效可接受 → Redis 锁足够。
- 如果锁用于正确性保证(避免数据损坏),必须绝对可靠 → ZooKeeper / etcd。
- 没有任何分布式锁能保证 100% 安全,应根据业务场景选择合适的方案。
【中等】Redis 分布式锁如何实现可重入?⭐⭐
可重入锁是指:同一个线程在持有锁的情况下,可以再次获取该锁而不会被阻塞。Redis 分布式锁可通过 Hash 结构 + Lua 脚本 实现可重入。
实现原理:
使用 Hash 结构存储锁信息,key 为锁名,field 为客户端 ID + 线程 ID,value 为重入次数。
加锁 Lua 脚本:
-- KEYS[1] = 锁名, ARGV[1] = 客户端ID:线程ID, ARGV[2] = 过期时间
if redis.call('exists', KEYS[1]) == 0 then
-- 锁不存在,首次加锁
redis.call('hset', KEYS[1], ARGV[1], 1)
redis.call('pexpire', KEYS[1], ARGV[2])
return nil
elseif redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
-- 锁存在且属于当前线程,重入次数 +1
redis.call('hincrby', KEYS[1], ARGV[1], 1)
redis.call('pexpire', KEYS[1], ARGV[2])
return nil
else
-- 锁被其他线程持有,加锁失败
return redis.call('pttl', KEYS[1])
end解锁 Lua 脚本:
-- KEYS[1] = 锁名, ARGV[1] = 客户端ID:线程ID
if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then
return nil -- 锁不属于当前线程
end
local count = redis.call('hincrby', KEYS[1], ARGV[1], -1)
if count > 0 then
-- 重入次数仍大于 0,刷新过期时间
redis.call('pexpire', KEYS[1], ARGV[2])
return 0
else
-- 重入次数归 0,删除锁
redis.call('del', KEYS[1])
return 1
endRedisson 的可重入锁实现
Redisson 的 RLock 就是基于上述原理实现的,核心要点:
- 使用 Hash 结构(而非 String),field 为
UUID:threadId,value 为重入次数。 - 加锁和解锁均通过 Lua 脚本保证原子性。
- 看门狗续期时,同样校验锁归属,避免为已释放的锁续期。
- 支持
tryLock(timeout)带超时的加锁,通过 Redis 的 Pub/Sub 订阅锁释放消息,避免空转。
注意事项:
- 跨进程不可重入:可重入性依赖于"客户端 ID + 线程 ID"标识,跨进程(JVM)无法重入。
- 续期与重入:看门狗续期时,需同时考虑重入次数,不能简单延长 TTL。
- 锁泄漏:如果业务异常导致解锁未执行(如未用 try-finally),重入次数不会归零,锁会直到 TTL 过期才释放。
分布式 ID
扩展
【中等】有哪些生成分布式 ID 的方式?⭐⭐
生成分布式 ID 主要有以下方式:
- UUID:UUID 是通用唯一识别码(Universally Unique Identifier)的缩写,是一种 128 位的标识符,用 16 进制表示,需要 32 个字符。UUID 会根据运行应用的计算机网卡 MAC 地址、时间戳、命令空间等元素,通过一定的随机算法产生。
- UUID 存在 5 个版本。
- UUID 不保证全局唯一性,我们需要小心 ID 冲突(尽管这种可能性很小)。
- 优点:实现简单、生成速度较快(本地生成,不依赖其他服务)。
- 缺点:无序、长度过长、不安全(基于 MAC 地址生成 UUID 的算法,可能会造成 MAC 地址泄露)。
- 数据库自增主键:大多数数据库都支持自增主键。基于此特性,可以利用事务管理控制生成唯一 ID。
- 优点:实现简单、有序、长度较小
- 缺点:性能差、存在单点问题、不安全(可以通过 ID 递增规律推算出数据量)
- 数据库号段:一次批量生成一个 segment(号段),号段的大小由 step(步长)控制。用完之后再去数据库获取新的号段。
- 原子计数器:一些 NoSQL 数据库提供了原子性的计数器原子计数器 - 利用一些 NoSQL 数据库提供的原子性计数器,来实现分布式 ID。
- Redis
incr/incrby:Redis 的 String 类型提供INCR和INCRBY命令将 key 中储存的数字原子递增。- 优点:高性能、有序
- 缺点:和数据库自增序列方案的缺点类似
- ZooKeeper 顺序节点:利用 ZooKeeper 数据模型中的顺序节点作为分布式 ID。
- 优点:简单、可靠性高
- 缺点:性能不高
- Redis
- Snowflake(雪花算法):Snowflake ID 生成过程包含多个组件:时间戳、机器 ID 和序列号。第一位未使用,以确保 ID 正确。此生成器不需要通过网络与 ID 生成器通信,因此速度快且可扩展。Snowflake 的实现各不相同。例如,可以将数据中心 ID 添加到"MachineID"组件中,以保证全局唯一性。
【困难】雪花算法的原理是什么?时钟回拨如何处理?⭐⭐⭐
雪花算法(Snowflake) 是 Twitter 公布的分布式 ID 生成算法,生成一个 64 bit 的整数,结构如下:
| 1 bit | 41 bit | 10 bit | 12 bit |
|---|---|---|---|
| 符号位 | 时间戳(毫秒) | 工作机器 ID | 序列号 |
- 符号位(1 bit):恒为 0,保证 ID 为正数。
- 时间戳(41 bit):当前时间相对于起始时间的毫秒差。可用约 69 年(2^41 / (365×24×3600×1000) ≈ 69.73 年)。
- 工作机器 ID(10 bit):可支持 1024 个节点。可根据业务拆分为数据中心 ID + 机器 ID(如 5 + 5)。
- 序列号(12 bit):同一毫秒内的自增序列,单机每毫秒最多生成 4096 个 ID。
时钟回拨问题:
雪花算法强依赖机器时钟。如果机器时间发生回拨(如 NTP 同步、人工调整),lastTimestamp 会大于当前时间,导致:
- 生成的 ID 时间戳变小,可能与之前生成的 ID 重复。
- 序列号被重置,加剧重复风险。
时钟回拨的处理策略:
| 策略 | 原理 | 适用场景 |
|---|---|---|
| 等待追上 | 回拨时间短(如 < 5ms),线程等待时钟追上 | NTP 微调、时钟抖动 |
| 拒绝服务 | 回拨时间超过阈值,抛异常拒绝生成 | 对唯一性要求严格的场景 |
| 扩展位兜底 | 利用未使用的 bit 位(如借用机器 ID 位)记录回拨次数 | 长期回拨的场景 |
| 本地时间戳 | 不依赖系统时钟,用本地维护的逻辑时钟(单调递增) | 对时钟绝对信任度低的场景 |
| Leaf-snowflake | 美团 Leaf 方案:用 ZooKeeper 持久化每个节点的最后时间戳,启动时校验 | 生产级方案 |
Leaf-snowflake 的时钟回拨处理
美团 Leaf 的 Snowflake 模式通过 ZooKeeper 解决时钟回拨:
- 每个应用启动时,在 ZK 上创建临时顺序节点,获取 workerId。
- 同时持久化该 workerId 上一次生成 ID 的时间戳到 ZK。
- 启动时对比 ZK 上的时间戳与本地时间,如果本地时间 < ZK 时间戳,说明发生回拨,拒绝启动并报警。
- 运行时定期更新 ZK 上的时间戳,作为下次启动的基准。
【中等】Leaf 和 Tinyid 的原理是什么?各有什么特点?⭐⭐
Leaf 是美团开源的分布式 ID 生成系统,支持两种模式:
1. Leaf-Segment(号段模式)
- 基于数据库号段方案,每次从数据库批量获取一段 ID(如 1-2000),缓存在本地内存。
- 双 Buffer 优化:维护两个号段 Buffer,当前 Buffer 使用到一定比例(如 20%)时,异步加载下一个 Buffer。当当前 Buffer 耗尽,无缝切换到下一个 Buffer,避免阻塞。
- 优点:高性能(内存发号)、ID 趋势递增、对数据库压力小。
- 缺点:ID 可被推测(暴露业务量);依赖数据库可用性。
2. Leaf-Snowflake(雪花模式)
- 基于雪花算法,通过 ZooKeeper 分配和持久化 workerId。
- 解决了原始雪花算法的时钟回拨问题(如上所述)。
- 优点:不依赖数据库、ID 不可推测。
- 缺点:依赖 ZooKeeper;强依赖时钟。
Tinyid 是滴滴开源的分布式 ID 生成系统,原理与 Leaf-Segment 类似:
- 基于数据库号段模式,支持多 DB 高可用。
- 提供 HTTP 和 SDK 两种接入方式。
- 特点:支持多 DB 号段分配(通过
db_type区分),增加发号能力;支持动态调整步长。
Leaf 与 Tinyid 对比:
| 特性 | Leaf-Segment | Tinyid |
|---|---|---|
| 开源方 | 美团 | 滴滴 |
| 核心原理 | 数据库号段 + 双 Buffer | 数据库号段 + 多 DB |
| 高可用 | DB 主从 + MHA | 多 DB 路由 |
| 接入方式 | HTTP / SDK | HTTP / SDK |
| 雪花模式 | 支持(Leaf-Snowflake) | 不支持 |
| 社区活跃 | 较活跃 | 较少更新 |
【中等】分布式 ID 方案如何选型?⭐⭐
| 方案 | 唯一性 | 有序性 | 性能 | 可用性 | 依赖 | 适用场景 |
|---|---|---|---|---|---|---|
| UUID | 高¹ | 无序 | 高 | 高 | 无 | 仅需唯一性,无需有序(如 TraceId) |
| 数据库自增 | 高 | 严格递增 | 低 | 低 | 数据库 | 单库、低并发 |
| 数据库号段 | 高 | 趋势递增 | 高 | 中 | 数据库 | 中高并发、需有序 ID |
| Redis INCR | 高 | 严格递增 | 高 | 中 | Redis | 高并发、可接受短暂不可用 |
| 雪花算法 | 高 | 趋势递增 | 极高 | 高 | 时钟(可选 ZK) | 高并发、需有序、long 型 |
| Leaf | 高 | 趋势递增 | 高 | 高 | DB / ZK | 生产级、需可视化运维 |
¹ UUID 不保证绝对唯一,但冲突概率极低。
选型建议:
- 订单号 / 支付流水号:雪花算法或 Leaf-Snowflake(不可推测、趋势递增、高性能)。
- 分库分表主键:雪花算法或 Leaf-Segment(全局唯一、对索引友好)。
- 链路追踪 TraceId:UUID 或雪花算法变种(仅需唯一、本地生成)。
- 用户 ID / 商品 ID:Leaf-Segment 或数据库号段(趋势递增、可读性好)。
分布式会话
【简单】Cookie 和 Session 有什么区别?⭐⭐⭐
由于 Http 是一种无状态的协议,服务器单从网络连接上无从知道客户身份。
所以服务器与浏览器为了进行会话跟踪(知道是谁在访问我),就必须主动的去维护一个状态,这个状态用于告知服务端前后两个请求是否来自同一浏览器。而这个状态需要通过 cookie 或者 session 去实现。
Cookie 实际上是存储在用户浏览器上的文本信息,并保留了各种跟踪的信息。生成 Cookie 后,用户后续每次请求都会携带 Cookie。
Cookie 通常有大小限制(4KB)。用户可以选择在浏览器中禁用 Cookie。
一个简单的 cookie 设置如下:
Set-Cookie: <cookie-name>=<cookie-value>HTTP/2.0 200 OK
Content-Type: text/html
Set-Cookie: yummy_cookie=choco
Set-Cookie: tasty_cookie=strawberry
[page content]Session 是在服务器端创建和存储的。服务器上通常会生成一个唯一的会话 ID(sessionId),sessionId 附加到特定的用户会话。sessionId 以 Cookie 的形式返回到客户端。Session 可以容纳大量数据。由于 Session 数据不直接由客户端访问,因此 Session 提供了更高的安全性。
Cookie 和 Session 的主要区别可以参考以下表格:
| Cookie | Session | |
|---|---|---|
| 作用范围 | 保存在客户端(浏览器) | 保存在服务器端 |
| 隐私策略 | 存储在客户端,比较容易遭到非法获取 | 存储在服务端,安全性相对 Cookie 要好一些 |
| 存储方式 | 只能保存 ASCII | 可以保存任意数据类型。 一般情况下我们可以在 Session 中保持一些常用变量信息,比如说 UserId 等。 |
| 存储大小 | 不能超过 4K | 存储大小远高于 Cookie |
| 生命周期 | 可设置为永久保存 比如我们经常使用的默认登录(记住我)功能 | 一般失效时间较短 客户端关闭或者 Session 超时都会失效。 |
【中等】如果禁用了 Cookie 怎么办?⭐⭐
既然服务端是根据 Cookie 中的信息判断用户是否登录,那么如果浏览器中禁止了 Cookie,如何保障整个机制的正常运转。
第一种方案,每次请求中都携带一个 SessionID 的参数,也可以 Post 的方式提交,也可以在请求的地址后面拼接
xxx?SessionID=123456...。第二种方案,Token 机制。Token 机制多用于 App 客户端和服务器交互的模式,也可以用于 Web 端做用户状态管理。
Token 的意思是“令牌”,是服务端生成的一串字符串,作为客户端进行请求的一个标识。Token 机制和 Cookie 和 Session 的使用机制比较类似。
当用户第一次登录后,服务器根据提交的用户信息生成一个 Token,响应时将 Token 返回给客户端,以后客户端只需带上这个 Token 前来请求数据即可,无需再次登录验证。
【中等】分布式 Session 有哪些实现方案?⭐⭐
在分布式场景下,一个用户的 Session 如果只存储在一个服务器上,那么当负载均衡器把用户的下一个请求转发到另一个服务器上,该服务器没有用户的 Session,就可能导致用户需要重新进行登录等操作。
分布式 Session 的几种实现策略:
- 粘性 session
- 应用服务器间的 session 复制共享
- 基于缓存的 session 共享 ✔️
推荐:基于缓存的 session 共享
粘性 Session
粘性 Session(Sticky Sessions)需要配置负载均衡器,使得一个用户的所有请求都路由到一个服务器节点上,这样就可以把用户的 Session 存放在该服务器节点中。
缺点:当服务器节点宕机时,将丢失该服务器节点上的所有 Session。

Session 复制
Session 复制共享(Session Replication)在服务器节点之间进行 Session 同步操作,这样的话用户可以访问任何一个服务器节点。
缺点:占用过多内存;同步过程占用网络带宽以及服务器处理器时间。

Session 共享
使用一个单独的存储服务器存储 Session 数据,可以存在 MySQL 数据库上,也可以存在 Redis 或者 Memcached 这种内存型数据库。
缺点:需要去实现存取 Session 的代码。

【中等】什么是 JWT?JWT 的原理和结构是什么?⭐⭐⭐
JWT(JSON Web Token) 是一种开放标准(RFC 7519),用于在各方之间安全地传输信息。JWT 通常用于无状态认证:服务端不存储会话,每次请求由客户端携带 Token,服务端验证签名即可。
JWT 的结构:
JWT 由三部分组成,用 . 分隔:Header.Payload.Signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c| 部分 | 内容 | 说明 |
|---|---|---|
| Header | {"alg": "HS256", "typ": "JWT"} | 算法类型和 Token 类型 |
| Payload | {"sub": "123", "name": "John", "iat": 1516239022} | 声明(Claims),包括标准、私有声明 |
| Signature | HMACSHA256(base64(header) + "." + base64(payload), secret) | 签名,用于验证完整性 |
JWT 的安全注意事项
- 不要在 Payload 中存放敏感信息:Payload 只是 Base64 编码,并非加密,任何人可解码。
- 签名密钥要保密:密钥泄露后,任何人都能伪造合法 Token。
- 使用 HTTPS 传输:防止 Token 在传输过程中被窃取。
- 设置合理的过期时间:Token 过期时间不宜过长,降低泄露风险。
JWT vs Session 对比:
| 维度 | Session | JWT |
|---|---|---|
| 状态 | 有状态(服务端存储) | 无状态(服务端不存储) |
| 扩展性 | 需共享存储(如 Redis) | 天然支持水平扩展 |
| 失效控制 | 服务端可随时使 Session 失效 | 难以主动失效(需黑名单机制) |
| 存储位置 | 服务端 | 客户端 |
| 大小 | SessionID 较小 | JWT 较大(含 Payload) |
| 续签 | 自动续期(访问即刷新过期时间) | 需额外机制(双 Token / 滑动过期) |
| 适用场景 | 传统 Web 应用 | 前后端分离、移动端、微服务 |
【中等】JWT Token 如何续签?如何解决无法主动失效的问题?⭐⭐
JWT 是无状态的,Token 一旦签发,在过期前始终有效,服务端无法单方面使其失效。这是 JWT 的固有局限性,需要额外机制解决。
Token 续签方案:
| 方案 | 原理 | 优缺点 |
|---|---|---|
| 双 Token(Access + Refresh) | 短期 Access Token + 长期 Refresh Token,Access 过期后用 Refresh 换新的 Access | 主流方案;Refresh Token 需存储,可主动失效 |
| 滑动过期 | 每次请求都检查 Token 剩余有效期,若不足阈值则签发新 Token | 简单;但每次请求可能返回新 Token,客户端需处理 |
| Token 黑名单 | 将失效的 Token 加入 Redis 黑名单,校验时检查 | 可主动失效;但引入了状态,失去无状态优势 |
| Token 版本号 | 用户维度维护 Token 版本号,签发时写入 Token,校验时比对 | 修改密码时版本号 +1,旧 Token 失效 |
双 Token 方案流程(推荐):
- 用户登录,服务端签发 Access Token(短期,如 30 分钟)和 Refresh Token(长期,如 7 天)。
- 客户端存储两个 Token,请求时携带 Access Token。
- Access Token 过期后,客户端用 Refresh Token 请求
/refresh接口。 - 服务端验证 Refresh Token 有效性,签发新的 Access Token(可选:同时刷新 Refresh Token)。
- Refresh Token 过期后,用户需重新登录。
Refresh Token 的存储
Refresh Token 应存储在服务端(如 Redis),原因:
- 可主动失效:用户登出或修改密码时,删除 Refresh Token,强制重新登录。
- 单设备登录:每个 Refresh Token 绑定设备,实现单端登录控制。
- 旋转机制:每次刷新时生成新的 Refresh Token 并废弃旧的,防止 Refresh Token 被盗用。
JWT 主动失效方案(Token 黑名单):
// 退出登录时,将 Token 加入黑名单
public void logout(String token) {
Claims claims = JwtUtil.parseToken(token);
long expiration = claims.getExpiration().getTime() - System.currentTimeMillis();
// 黑名单 TTL = Token 剩余有效时间,过期后自动清理
redisTemplate.opsForValue().set("jwt:blacklist:" + token, "1", expiration, TimeUnit.MILLISECONDS);
}
// 校验时检查黑名单
public boolean isValid(String token) {
if (redisTemplate.hasKey("jwt:blacklist:" + token)) {
return false; // Token 已被主动失效
}
return !JwtUtil.isExpired(token);
}【简单】Session 和 JWT 如何选型?⭐
| 考虑因素 | 选 Session | 选 JWT |
|---|---|---|
| 架构 | 单体应用 / 传统 Web | 前后端分离 / 微服务 / 移动端 |
| 是否需主动失效 | 是(用户登出、封禁账号) | 否(或可接受黑名单方案的复杂度) |
| 会话数据量 | 大(Session 可存任意数据) | 小(JWT Payload 不宜过大) |
| 跨域 | 麻烦(需处理 Cookie 跨域) | 简单(Token 放 Header) |
| 性能 | 每次需查 Redis | 仅签名校验(无网络 IO) |
| 安全性 | 较高(服务端可控) | 需额外机制保证(黑名单、HTTPS) |
实践建议:
- 传统 Web 应用:Spring Session + Redis,保持原生 HttpSession API,对业务无侵入。
- 前后端分离 / API 服务:JWT 双 Token 方案(Access + Refresh),无状态、易扩展。
- 混合场景:网关层用 JWT 鉴权,内部服务用 Session 共享用户上下文(如网关解析 JWT 后,将用户信息写入 Session 或 Header 传递给下游)。
参考资料
- 数据密集型应用系统设计 - 这可能是目前最好的分布式存储书籍,强力推荐【进阶】