分布式协同面试
分布式协同面试
复制
【简单】什么是复制?复制有什么作用?⭐⭐
复制是将同一份数据存储在多台机器上,以提升系统的可用性、可靠性和性能。
复制数据,可能出于各种各样的原因:
- 提高可用性:当部分组件出现位障,系统依然可以继续工作,系统依然可以继续工作。
- 降低访问延迟:使数据在地理位置上更接近用户。
- 提高读吞吐量:扩展至多台机器以同时提供数据访问服务。
【中等】复制有哪些模式?⭐⭐
复制的模式有以下几种:
- 主从复制:所有的写入操作都发送到主节点,由主节点负责将数据更改事件发送到从节点。每个从节点都可以接收读请求,但内容可能是过期值。支持主从复制的系统:
- 数据库: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 理论指出:在一个分布式系统中,以下三个特性最多只能同时满足两个:
- 一致性(Consistency):所有节点在同一时刻看到的数据是相同的(等同线性化)。
- 可用性(Availability):每个请求都能收到非错误的响应(不保证是最新数据)。
- 分区容错性(Partition Tolerance):网络分区时系统仍能运行。
CAP 的正确理解
CAP 并非简单的"三选二",更准确的理解是:当网络分区(P)发生时,系统必须在一致性(C)和可用性(A)之间做出选择。由于网络分区在分布式系统中不可避免,实际上是在 CP 和 AP 之间取舍:
- CP 系统:如 ZooKeeper、etcd、HBase。分区时拒绝写入以保证一致性。
- AP 系统:如 Cassandra、Eureka、DynamoDB。分区时继续提供服务,允许数据暂时不一致。
BASE 理论是 CAP 的延伸,是大规模互联网系统的实践总结,是即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性:
- 基本可用(Basically Available):分布式系统在出现故障时,保证核心可用,允许损失部分可用性(如响应时间增加、降级服务)。
- 软状态(Soft State):允许系统中的数据存在中间状态,即允许系统不同节点的数据副本之间进行同步的过程存在延时。
- 最终一致性(Eventually Consistent):系统中所有的数据副本,在经过一段时间的同步后,最终能达到一致的状态。
【中等】什么是 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 将日志应用到状态机。
【中等】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?什么是 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 的工作原理是什么?⭐⭐⭐
二阶段提交协议(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 消息)
- 业务处理服务需要实现消息状态回查接口
【中等】什么是最大努力通知?它和本地消息表有什么区别?⭐
最大努力通知是一种柔性事务方案,事务发起方在完成本地事务后,尽最大努力(通过消息队列等)通知事务接收方处理,通知可能有多次重试,但不保证接收方一定能处理成功。接收方可以根据通知结果自行核对、补偿。
典型场景:支付回调通知。支付平台完成支付后,通知商户系统支付结果。如果商户系统没有收到或处理失败,支付平台会按递增的时间间隔多次重试(如 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 模式的一个局限。
分布式锁
【简单】什么是分布式锁?为什么需要分布式锁?⭐⭐
在计算机科学中,锁是在并发场景下用于强行限制资源访问的一种同步机制,即用于在并发控制中通过互斥手段来保证数据同步安全。
在 Java 进程中,可以使用 Lock、synchronized 等来支持并发锁。如果是同一台机器的不同进程,想要同时操作一个共享资源(例如修改同一个文件),可以使用操作系统提供的「文件锁」或「信号量」来做互斥。这些发生在同一台机器上的互斥操作,可以称为本地锁。

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

【困难】实现分布式锁有哪些要点?⭐⭐
分布式锁的解决方案大致有以下几种:
- 基于数据库实现
- 基于缓存(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,功能全面且较为可靠。
【中等】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 传递给下游)。
参考资料
- 数据密集型应用系统设计 - 这可能是目前最好的分布式存储书籍,强力推荐【进阶】