Module 6: 事务与一致性
📖 深度参考手册 — 本模块属于理论参考,非主线必读。 主线学习路径见 README.md。 当你在项目实战中遇到相关问题时,回来查阅。
来源:DDIA Ch7 (Transactions), Ch9 (Consistency and Consensus)
数据正确性是一切系统的基石。当多个操作需要”要么全部成功,要么全部失败”,当多个用户同时修改同一条数据,当数据分布在多个节点上时——事务和一致性机制就是保障数据正确的核心武器。本模块从单机事务到分布式一致性,逐步深入。
| 编号 | 知识点 | 核心概念 |
|---|---|---|
| 6.1 | ACID属性 | 事务四大保证 |
| 6.2 | 隔离级别 | 并发控制的强度选择 |
| 6.3 | 脏读/不可重复读/幻读 | 数据异常现象 |
| 6.4 | 乐观锁 vs 悲观锁 | 并发控制策略 |
| 6.5 | 分布式事务 (2PC) | 跨服务原子性 |
| 6.6 | Saga模式 | 长事务的替代方案 |
| 6.7 | 线性一致性 | 最强一致性模型 |
| 6.8 | 因果一致性 | 因果关系保序 |
| 6.9 | 最终一致性 | 高可用优先的一致性 |
| 6.10 | 共识算法概述 | 分布式协调基础 |
6.1 ACID属性
Section titled “6.1 ACID属性”定义:ACID是数据库事务的四个核心属性。原子性(Atomicity)——事务中的所有操作要么全部执行成功,要么全部回滚,不存在部分执行的中间状态;一致性(Consistency)——事务执行前后,数据必须满足所有业务约束和完整性规则;隔离性(Isolation)——并发执行的多个事务之间互不干扰,每个事务感觉自己是独占数据库在操作;持久性(Durability)——事务一旦提交,其结果永久保存,即使系统崩溃也不会丢失。
为什么重要:ACID是关系型数据库的核心承诺,是构建可靠系统的基石。没有原子性,一笔银行转账可能”扣了A但没加到B”,导致钱凭空消失。没有隔离性,两个并发操作可能读到彼此的中间状态,做出错误决策。没有持久性,“交易成功”的提示对用户来说毫无意义,因为数据可能随时丢失。在系统设计中,理解ACID不仅是为了使用单机数据库,更是为了理解”当我们离开单机数据库、走向分布式系统时,哪些保证会被弱化,以及如何弥补”。
案例:在 Hotel Reservation 系统中,一次预订操作涉及多个数据库操作:(1) 检查该房型在指定日期是否还有空房(读库存表);(2) 扣减库存(更新库存表 available_rooms -= 1);(3) 创建订单记录(插入订单表);(4) 记录支付信息(插入支付表)。这四步必须作为一个原子事务执行——如果步骤2成功但步骤3失败,库存扣了但没有对应订单,这间房就”消失”了。ACID的原子性保证:如果任何一步失败,所有步骤都回滚到事务开始前的状态。
先想一想 🤔 NoSQL数据库(如MongoDB、Cassandra)通常不支持完整的ACID事务。那这些数据库适合用在Hotel Reservation的哪些场景?
点击查看解析
NoSQL数据库适合用在不需要强事务保证的场景。例如:(1) 酒店详情页展示(酒店名称、描述、图片、评分)——读多写少,不涉及并发修改同一条数据,用MongoDB存储文档型数据很合适;(2) 用户搜索历史和浏览记录——丢失一条记录无伤大雅;(3) 缓存热门酒店信息——Redis作为缓存层。但涉及库存扣减、订单创建、支付记录的核心预订流程,仍然需要关系型数据库(如PostgreSQL、MySQL)来保证ACID事务。这就是为什么大多数复杂系统采用"混合存储"架构——核心交易用关系型数据库,辅助数据用NoSQL。
6.2 隔离级别
Section titled “6.2 隔离级别”定义:隔离级别定义了并发事务之间的可见性规则,SQL标准定义了四个级别,从弱到强:读未提交(Read Uncommitted)——可以读到其他事务未提交的修改;读已提交(Read Committed)——只能读到其他事务已提交的修改;可重复读(Repeatable Read)——同一事务内多次读取同一数据结果一致;可串行化(Serializable)——并发事务的效果等价于某种串行执行顺序,是最强隔离级别。隔离级别越强,并发性能越差。
为什么重要:隔离级别的选择是”正确性”和”性能”之间的核心权衡。大多数生产系统使用”读已提交”或”可重复读”,因为可串行化虽然最安全但性能太差(相当于所有事务排队执行)。关键是:你必须理解你选择的隔离级别不能防止哪些问题,然后在应用层用其他手段(如显式加锁、乐观锁)来弥补。面试中最常见的考点是:在某个业务场景下,哪个隔离级别够用,哪个不够。
案例:在 Hotel Reservation 系统中,假设只剩最后一间大床房。用户A和用户B几乎同时发起预订。在”读已提交”隔离级别下:用户A的事务读到 available_rooms = 1(可以预订),用户B的事务也读到 available_rooms = 1(也可以预订),两个事务都认为有房并尝试扣减库存,最终 available_rooms = -1,出现超卖。在”可重复读”隔离级别下配合行锁:用户A的事务先获得该行的锁,读到 available_rooms = 1 并扣减为0;用户B的事务等待A释放锁后才能读到 available_rooms = 0,发现无房,预订失败。在”可串行化”隔离级别下,数据库自动保证两个事务等价于某种串行执行,效果和上面类似但性能开销更大。
先想一想 🤔 Search Engine 的搜索索引更新使用什么隔离级别合适?它和Hotel Reservation为什么不一样?
点击查看解析
Search Engine的搜索索引更新用"读已提交"甚至"读未提交"就足够了。原因是搜索结果本身就是"近似"的——用户搜索时,索引正在被并发更新,搜索结果晚几秒反映最新数据完全可以接受(没有人期望搜索结果是"实时精确"的)。而Hotel Reservation涉及金钱和实体资源(房间),要求绝对的数据正确性——超卖一间房意味着有一个顾客到了酒店没有房住,这是不可接受的。所以场景的"错误成本"决定了隔离级别的选择:错误成本低(搜索结果稍旧)→弱隔离级别+高性能;错误成本高(超卖、资损)→强隔离级别+低并发。
6.3 脏读/不可重复读/幻读
Section titled “6.3 脏读/不可重复读/幻读”定义:这三种是不同隔离级别下可能出现的数据异常现象。脏读(Dirty Read)——事务A读到了事务B尚未提交的修改,如果B随后回滚,A读到的数据就是”脏的”(从未真正存在过的数据)。不可重复读(Non-Repeatable Read)——事务A在同一事务内两次读取同一行数据,中间事务B修改并提交了该行,导致A两次读取结果不同。幻读(Phantom Read)——事务A在同一事务内两次执行相同的范围查询,中间事务B插入了新行满足查询条件,导致A第二次查询”多出”了之前不存在的行(像”幻影”一样出现)。
为什么重要:这三种异常是理解隔离级别的关键。“读未提交”无法防止任何异常;“读已提交”防止脏读但无法防止不可重复读和幻读;“可重复读”防止脏读和不可重复读但无法完全防止幻读(MySQL的InnoDB通过间隙锁在可重复读级别下可以防止大部分幻读);“可串行化”防止所有异常。面试中你需要能够针对具体场景判断”哪种异常会发生”以及”用什么手段防止”。
案例:在 Hotel Reservation 系统中,多用户同时预订同一房型时,三种异常可以这样具体理解:
脏读场景:用户A发起预订,事务将 available_rooms 从5更新为4(但尚未提交)。此时用户B的查询读到了 available_rooms = 4,看到有4间房。然后用户A的事务因为支付失败而回滚,available_rooms 恢复为5。用户B基于 available_rooms = 4 做出的任何决策都是基于”脏数据”。
不可重复读场景:用户A的事务开始时读到 available_rooms = 5,决定预订。在A还没执行扣减之前,用户B的事务成功预订并提交,available_rooms 变为4。用户A再次读取时发现 available_rooms = 4,与第一次读取不一致,可能导致业务逻辑混乱(比如A的预订确认页面显示”剩余5间”但实际只有4间)。
幻读场景:管理员A查询”2026年4月1日所有大床房的订单”,得到10条记录。此时用户B恰好完成了一笔新预订,插入了一条新订单。管理员A再次执行同样的查询,突然变成了11条记录——第11条就像”幻影”一样出现了。如果管理员A基于”共10条”做了后续操作(如”10间房全部分配完毕”),就会出错。
先想一想 🤔 Google Drive 中,两个用户同时编辑同一份文档,会遇到上述哪种异常?Google Drive是如何解决的?
点击查看解析
Google Drive的协同编辑场景最可能遇到的是"不可重复读"——用户A读到文档内容为V1,正在编辑时用户B把文档内容改成了V2并保存,A再读时内容变了。但Google Drive不是用数据库隔离级别来解决这个问题的。它使用**OT(Operational Transformation)**或**CRDT**算法:每个用户的编辑操作被表示为"操作"(如"在位置5插入字符'x'"),所有操作实时同步到其他用户,冲突时通过OT算法自动转换操作使结果收敛。这种方式允许多人"同时编辑"而不阻塞任何人,完全绕开了传统事务隔离级别的限制。这也说明了一个重要原则:不是所有并发问题都需要用数据库事务来解决,有些场景需要应用层的专门算法。
6.4 乐观锁 vs 悲观锁
Section titled “6.4 乐观锁 vs 悲观锁”定义:**悲观锁(Pessimistic Locking)**假设冲突经常发生,在操作数据前先加锁,其他事务必须等待锁释放才能访问该数据。典型实现是数据库的 SELECT ... FOR UPDATE。**乐观锁(Optimistic Locking)**假设冲突很少发生,不加锁直接操作,在提交时检查数据是否被其他事务修改过(通常通过版本号或时间戳实现)。如果检测到冲突,则回滚并重试。
为什么重要:选择哪种锁直接影响系统的并发能力和用户体验。悲观锁简单可靠但并发度低(其他事务被阻塞等待),在锁持有时间长或竞争激烈时容易导致死锁和性能瓶颈。乐观锁并发度高(不阻塞其他事务),但在冲突频繁时大量事务需要回滚重试,反而浪费资源。选择策略的经验法则:冲突频繁(如抢购热门资源)用悲观锁,冲突罕见(如各自编辑不同文档)用乐观锁。
案例:Hotel Reservation 适合用悲观锁——热门酒店的热门房型在节假日前夕会有大量并发预订,冲突频率很高。使用悲观锁 SELECT available_rooms FROM inventory WHERE hotel_id=? AND room_type=? AND date=? FOR UPDATE,锁定该行后再检查库存并扣减,保证同一时间只有一个事务在操作同一房型的库存,不会出现超卖。虽然其他预订请求需要排队等待,但这种等待时间通常只有几毫秒,可以接受。
相反,Google Drive 适合用乐观锁——虽然文档可能被多人同时编辑,但通常不同用户编辑的是文档的不同部分,真正冲突的概率很低。每次保存时检查文档版本号:如果版本号与读取时一致,说明没有冲突,直接更新并将版本号+1;如果版本号已经变化,说明有其他人在你编辑期间保存了修改,此时系统进行冲突合并(自动合并不冲突的部分,冲突部分提示用户手动解决)。
先想一想 🤔 Gaming Leaderboard 的分数更新应该用乐观锁还是悲观锁?
点击查看解析
取决于排行榜的更新模式。如果是"累加分数"(每次游戏结束后 `score += game_score`),同一玩家的分数更新频率不高(一局游戏至少几分钟),冲突概率低,用乐观锁即可:读取当前分数和版本号 → 计算新分数 → 提交时检查版本号 → 冲突则重试。但如果是"全服排名计算"(需要原子地更新多个玩家的排名),涉及批量操作,用悲观锁更安全,或者干脆用Redis的ZADD原子操作来更新排名,绕开传统锁机制。实际上,Gaming Leaderboard最佳实践是使用Redis Sorted Set——它的ZADD和ZINCRBY操作是原子的,天然不需要显式加锁。
6.5 分布式事务 (2PC)
Section titled “6.5 分布式事务 (2PC)”定义:两阶段提交(Two-Phase Commit, 2PC)是实现分布式事务的经典协议,用于保证跨多个服务/数据库的操作要么全部提交,要么全部回滚。第一阶段(准备阶段):协调者向所有参与者发送”准备提交”请求,每个参与者执行本地事务但不提交,将结果(可以提交/无法提交)回复给协调者。第二阶段(提交阶段):如果所有参与者都回复”可以提交”,协调者发送”提交”指令,所有参与者正式提交;如果有任何参与者回复”无法提交”,协调者发送”回滚”指令,所有参与者回滚本地事务。
为什么重要:在微服务架构中,一个业务操作往往涉及多个服务各自的数据库,单个数据库的ACID事务无法跨越服务边界。2PC是解决这个问题的标准方案,但它有明显的缺点:(1) 同步阻塞——所有参与者在第一阶段结束后必须等待协调者的指令,期间持有锁不释放;(2) 单点故障——如果协调者在第二阶段崩溃,参与者不知道该提交还是回滚,陷入”不确定状态”;(3) 性能差——多次网络往返+锁持有时间长。因此2PC适合参与者少、延迟要求不高的场景,大规模微服务中更多使用Saga模式替代。
案例:在 Hotel Reservation 系统中,预订操作可能涉及两个独立的服务:库存服务(管理房间库存)和支付服务(管理用户扣款)。用2PC实现:“协调者”(预订服务)向库存服务发送”准备扣减1间大床房”,向支付服务发送”准备扣款500元”。库存服务检查库存充足,锁定1间房但不真正扣减,回复”准备完毕”。支付服务检查余额充足,冻结500元但不真正扣款,回复”准备完毕”。协调者收到两个”准备完毕”后,发送”提交”。库存服务真正扣减库存,支付服务真正扣款。如果支付服务回复”余额不足,无法准备”,协调者发送”回滚”,库存服务释放之前锁定的房间。
先想一想 🤔 如果在2PC的第二阶段,协调者已经发出了”提交”指令,但库存服务收到了而支付服务因为网络问题没收到,会怎样?
点击查看解析
这就是2PC最致命的问题之一。库存服务收到"提交"指令后正式扣减了库存,但支付服务没收到指令,它不知道该提交还是回滚,一直持有冻结的500元不释放。此时系统处于不一致状态:库存扣了但钱没扣。解决方案:(1) 协调者必须将"提交"决定持久化到日志中,崩溃恢复后重新发送"提交"指令;(2) 支付服务如果长时间没收到指令,主动向协调者查询事务状态;(3) 引入超时机制——如果支付服务超过一定时间未收到指令,进入"不确定状态",等待人工介入或协调者恢复。这个问题也是为什么很多系统选择Saga模式而非2PC的主要原因。
6.6 Saga模式
Section titled “6.6 Saga模式”定义:Saga模式是一种长事务的解决方案,将一个跨服务的大事务拆分成一系列本地事务(步骤),每个步骤都有对应的补偿操作(逆向操作)。执行时按顺序依次执行每个步骤,如果某个步骤失败,则按反序执行之前已完成步骤的补偿操作,将系统状态回滚到初始状态。Saga有两种协调方式:编排式(Choreography)——各服务通过事件驱动自行触发下一步;协调式(Orchestration)——由一个中心协调器统一调度每个步骤的执行。
为什么重要:Saga模式是2PC在微服务架构中的主要替代方案。与2PC相比,Saga不需要所有参与者同时持有锁(每个步骤是独立的本地事务,执行完就释放锁),性能更好,不存在”不确定状态”的问题。但代价是:(1) 不保证隔离性——中间状态对外可见(步骤2已完成但步骤3还没开始时,其他事务可以看到部分完成的状态);(2) 补偿操作的设计可能很复杂——有些操作天然不可逆(如已发出的邮件无法”撤回”)。
案例:在 Hotel Reservation 系统中,用Saga模式实现预订流程:
| 步骤 | 正向操作 | 补偿操作 |
|---|---|---|
| 1 | 创建订单(状态=待确认) | 取消订单(状态=已取消) |
| 2 | 扣减库存 | 恢复库存 |
| 3 | 扣款(调用支付服务) | 退款(调用支付服务) |
| 4 | 确认订单(状态=已确认) | —(最后一步无需补偿) |
正常流程:步骤1→2→3→4依次成功,预订完成。 异常流程:假设步骤3扣款失败(余额不足),触发补偿:先执行步骤2的补偿(恢复库存),再执行步骤1的补偿(取消订单)。每个补偿操作本身也是一个本地事务,保证原子性。
先想一想 🤔 Saga模式中,如果补偿操作本身也失败了怎么办?
点击查看解析
这是Saga模式中最棘手的问题。补偿操作失败意味着系统无法自动回滚到一致状态。处理策略:(1) 补偿操作必须设计为可重试的(幂等的)——失败后不断重试直到成功,因为补偿操作"必须成功";(2) 如果多次重试仍然失败(如服务长时间不可用),将失败记录写入"补偿失败队列",触发告警,由人工介入处理;(3) 在设计补偿操作时要尽量简单可靠——比如"恢复库存"只是一个简单的 `available_rooms += 1`,出错概率低于"扣减库存"。实际上,工程中要求补偿操作比正向操作更可靠——正向操作可以失败触发补偿,但补偿操作必须最终成功,否则就需要人工兜底。
6.7 线性一致性
Section titled “6.7 线性一致性”定义:线性一致性(Linearizability)是分布式系统中最强的一致性模型。它要求:系统对外表现得好像只有一个数据副本,所有操作都按照它们在真实时间线上的顺序生效。具体来说:一旦某个读操作返回了新值,后续所有的读操作(不管在哪个节点上)都必须返回该新值或更新的值,不允许”读到旧值”的情况。线性一致性让分布式系统的行为等价于单机系统,对应用开发者来说是最容易理解和使用的模型。
为什么重要:线性一致性是最直觉的正确性标准——“我写了什么,你就应该立刻读到什么”。它是实现分布式锁、选主、唯一性约束等功能的基础。但代价极高:根据CAP定理,在网络分区时不可能同时保证线性一致性和可用性。因此,提供线性一致性的系统(如ZooKeeper、etcd)通常用于存储少量关键的协调数据(配置、锁、选主结果),而不是存储大规模业务数据。
案例:在 Gaming Leaderboard 系统中,排名更新需要线性一致性。假设玩家A在时刻T1得了高分升到第1名,此后任何人在时刻T2(T2 > T1)查看排行榜都应该看到A在第1名。如果因为不同副本之间的延迟,有些用户在T2时刻仍然看到旧的排行榜(A不在第1名),就会造成混乱——比如其他玩家以为自己还是第1名而截图炫耀,随后排名”突然”变了。在竞技场景中,排名的实时准确性直接影响公平性和用户信任。因此,排行榜服务通常使用单一权威数据源(如单个Redis主节点的Sorted Set),所有读写都经过这个主节点,天然保证线性一致性。
先想一想 🤔 URL Shortener 需要线性一致性吗?如果两个用户”几乎同时”为同一个长URL生成短链,需要保证什么?
点击查看解析
URL Shortener不需要完整的线性一致性,但需要"唯一性保证"。两个用户同时为同一个长URL生成短链时,系统可以选择:(1) 返回相同的短链(如果设计为"同一长URL映射到固定短链"),这需要用唯一约束或CAS操作保证不会生成两个不同的短链指向同一个长URL;(2) 返回不同的短链(允许同一长URL有多个短链),这种设计更简单,无需任何一致性保证。大多数URL Shortener选择方案(2)——每次生成一个新的唯一ID作为短链,即使同一长URL被多次缩短也没关系。此时连最终一致性都够用,因为短链一旦创建后是不可变的。
6.8 因果一致性
Section titled “6.8 因果一致性”定义:因果一致性(Causal Consistency)保证有因果关系的操作在所有节点上都按因果顺序被观察到。如果操作A是操作B的”因”(比如A是一个问题,B是对A的回复),那么任何能看到B的节点一定也能看到A,并且A在B之前。但没有因果关系的操作(并发操作)则没有顺序要求。因果一致性比线性一致性弱(不要求按实时时间排序),但比最终一致性强(保证有因果关系的操作有序)。
为什么重要:因果一致性在很多场景中是”刚好够用”的一致性级别——它避免了线性一致性的高昂代价,又解决了最终一致性中最令人困惑的问题(因果倒置)。用户能接受”消息延迟到达”,但无法接受”先看到回复,后看到原消息”这种违反因果的情况。因果一致性的实现通常通过向量时钟(Vector Clock)或兰伯特时间戳(Lamport Timestamp)来追踪操作之间的因果关系。
案例:在 Chat System 中,因果一致性至关重要。考虑以下对话:
- 用户A(9:00:01):“明天聚餐去哪?”
- 用户B(9:00:03):“老地方怎么样?”
- 用户A(9:00:05):“好的就这么定了”
在这个对话中,B的回复因果依赖于A的问题,A的确认因果依赖于B的回复。如果用户C由于网络延迟看到的顺序是”好的就这么定了” → “老地方怎么样?” → “明天聚餐去哪?“,虽然最终所有消息都收到了(满足最终一致性),但因果关系完全混乱,对话不可理解。因果一致性保证:任何能看到”好的就这么定了”的用户,一定已经看到了前面两条消息,并且顺序正确。实现方式:每条消息携带”依赖的前一条消息的ID”,客户端在展示时确保前置消息已到达后才展示后续消息。
先想一想 🤔 News Feed 中,用户A发帖后用户B评论了这个帖子。如果某个粉丝先看到了B的评论但看不到A的帖子,这违反了什么一致性?
点击查看解析
这违反了因果一致性。B的评论因果依赖于A的帖子——没有帖子就不可能有评论。如果粉丝C看到了评论但看不到帖子,就会感到困惑("评论了什么?帖子在哪里?")。这种情况可能发生在:帖子和评论存储在不同的服务/数据库中,评论的复制速度快于帖子。解决方案:(1) 评论携带帖子ID,客户端展示评论前先确认帖子已加载;(2) 在读取时,如果发现评论引用了尚未可见的帖子,要么等待帖子到达,要么主动从源节点拉取帖子。
6.9 最终一致性
Section titled “6.9 最终一致性”定义:最终一致性(Eventual Consistency)是最弱的一致性模型,它承诺:如果没有新的写入操作,最终所有副本的数据会收敛到相同的状态。但”最终”可能是几毫秒,也可能是几秒甚至几分钟,没有时间上限的保证。在收敛之前,不同用户可能从不同副本读到不同的值。
为什么重要:最终一致性是实现高可用、高性能分布式系统的基础。根据CAP定理,在网络分区不可避免的情况下,如果选择可用性(所有请求都能得到响应),就必须接受一致性的弱化。大多数面向用户的互联网服务(社交网络、内容平台、电商商品列表)采用最终一致性,因为用户对数据实时性的要求没有对服务可用性的要求高——“晚几秒看到朋友的新帖”远比”服务不可用”能接受。关键是识别哪些数据可以最终一致(展示类数据),哪些必须强一致(交易类数据)。
案例:在 News Feed 系统中,用户发帖后粉丝不需要立即看到这条新帖子。假设用户A在9:00:00发了一条帖子,粉丝B在9:00:01打开Feed可能还看不到(帖子还在通过消息队列分发中),但在9:00:05刷新后就看到了——这几秒的延迟完全可以接受。采用最终一致性的好处是:(1) 发帖操作可以快速返回,不需要等待所有粉丝的Feed都更新完;(2) Feed数据可以分布在多个数据中心,就近读取,不需要跨数据中心同步;(3) 系统可以容忍部分节点故障,其他节点继续提供服务。如果非要保证”发帖后所有粉丝立即看到”(线性一致性),系统的延迟和可用性会大幅下降,对用户体验反而更差。
先想一想 🤔 Google Maps 上商家更新了营业时间,所有用户需要多快看到新时间?这是什么一致性?
点击查看解析
这是典型的最终一致性场景。商家营业时间不是实时敏感信息——几分钟甚至几小时的延迟都可以接受(用户不会因为Google Maps上的营业时间延迟了5分钟更新就认为服务有问题)。Google Maps的做法是:商家更新营业时间 → 写入主数据库 → 异步同步到全球各地的CDN和缓存 → 用户请求时从最近的缓存节点读取。不同地区的用户可能在不同时间看到更新后的营业时间,但最终所有人都会看到最新的数据。这种场景下追求强一致性是得不偿失的——为了让全球用户同时看到一个营业时间的更新,需要跨洲际的同步等待,延迟会从毫秒级飙升到百毫秒级,影响所有用户的地图加载速度。
6.10 共识算法概述
Section titled “6.10 共识算法概述”定义:共识算法(Consensus Algorithm)是分布式系统中让多个节点对某个值达成一致的协议。最著名的两个算法是 Paxos(理论基础,以难以理解著称)和 Raft(Paxos的工程友好版本,以易于理解和实现著称)。核心流程:(1) 选出一个领导者(Leader);(2) 领导者提议一个值;(3) 多数节点(超过半数)同意后该值被”提交”,成为最终决定。共识算法保证:即使部分节点故障(不超过半数),系统仍能正常运作并做出决定。
为什么重要:共识算法是分布式系统中很多核心功能的基石:选主(谁是主节点?需要所有从节点达成共识)、配置同步(集群配置变更需要所有节点一致)、分布式锁(多个节点同时申请锁,需要共识决定谁获得锁)、状态机复制(主节点的操作日志需要复制到从节点,且所有节点对日志顺序达成共识)。etcd(Kubernetes的核心存储)使用Raft,ZooKeeper(Kafka的协调器)使用ZAB(类Paxos协议)。面试中不需要实现共识算法,但需要理解其作用和基本原理。
案例:在任何使用主从复制的系统中(如Chat System的消息存储、News Feed的数据库、Search Engine的索引集群),当主节点宕机时需要从从节点中选举新的主节点。这个选举过程就是共识问题:所有存活的从节点需要就”谁成为新主节点”达成一致。Raft算法的选主过程:(1) 从节点发现主节点心跳超时,进入候选者(Candidate)状态;(2) 候选者增加任期号(Term),向其他节点发送投票请求;(3) 每个节点在同一任期内只能投一票(先到先得);(4) 获得超过半数投票的候选者成为新的领导者;(5) 新领导者开始接受写请求并将日志复制到从节点。整个选主过程通常在几百毫秒内完成,保证系统的高可用性。
先想一想 🤔 如果一个5节点的Raft集群中有2个节点同时宕机,系统还能正常工作吗?如果3个节点宕机呢?
点击查看解析
5节点集群的多数派是3个节点(5/2 + 1 = 3)。2个节点宕机后剩3个存活节点,仍然满足多数派要求,系统可以正常选主和处理请求。3个节点宕机后只剩2个存活节点,不满足多数派要求(2 < 3),系统无法达成共识——无法选出新的领导者,无法提交新的写操作,系统处于只读或不可用状态。这就是为什么分布式系统通常部署奇数个节点(3、5、7)——同样容忍1个节点故障,3节点比4节点更经济(3节点多数派=2,4节点多数派=3,但4节点也只能容忍1个故障)。5节点可以容忍2个故障,7节点可以容忍3个故障。
练习一:10个场景判断隔离级别/一致性级别
Section titled “练习一:10个场景判断隔离级别/一致性级别”对于以下每个场景,判断最合适的隔离级别或一致性级别,并说明理由:
| # | 场景 | 你的选择 |
|---|---|---|
| 1 | Hotel Reservation 扣减房间库存 | ? |
| 2 | News Feed 展示朋友的新帖子 | ? |
| 3 | Gaming Leaderboard 实时排名展示 | ? |
| 4 | YouTube 视频播放次数统计 | ? |
| 5 | Chat System 群聊消息展示 | ? |
| 6 | Google Drive 文件列表展示 | ? |
| 7 | Search Engine 搜索结果排序 | ? |
| 8 | Hotel Reservation 查看订单详情 | ? |
| 9 | Web Crawler 已抓取URL去重 | ? |
| 10 | Proximity Service 附近餐厅列表 | ? |
点击查看参考答案
# 场景 选择 理由 1 Hotel Reservation 扣减库存 可重复读 + 悲观锁(或可串行化) 超卖=直接经济损失,必须最强保护 2 News Feed 展示新帖子 最终一致性 延迟几秒展示完全可接受 3 Gaming Leaderboard 实时排名 线性一致性 排名必须实时准确,关乎公平性 4 YouTube 播放次数统计 最终一致性 显示”约100万次播放”和”约100万零3次播放”无区别 5 Chat System 群聊消息 因果一致性 回复必须在原消息之后,但不需要全局实时同步 6 Google Drive 文件列表 读已提交 需要看到已保存的文件,但不需要看到其他人正在编辑中的临时状态 7 Search Engine 搜索结果 最终一致性 索引更新延迟分钟级可接受 8 Hotel Reservation 查看订单 读已提交 用户期望看到自己已提交的订单最新状态 9 Web Crawler URL去重 最终一致性(可容忍偶尔重复抓取) 重复抓取浪费资源但不会出错,用布隆过滤器近似去重 10 Proximity Service 附近餐厅 最终一致性 餐厅位置信息更新频率低,延迟数分钟可接受 规律总结:涉及金钱/资源的写操作→强隔离+强一致;展示类读操作→弱一致性够用;有因果关系的交互→因果一致性。
练习二:Hotel Reservation 不加锁的后果
Section titled “练习二:Hotel Reservation 不加锁的后果”题目:Hotel Reservation 预订流程如果不加锁会怎样?请画出两个用户同时预订最后一间房的时序图,展示超卖是如何发生的。
点击查看参考答案
假设当前
available_rooms = 1,用户A和用户B同时预订:时间线 用户A的事务 数据库 用户B的事务─────────────────────────────────────────────────────────────────────────────────────T1 SELECT available_rooms→ 读到 available_rooms = 1(判断:有房,可以预订)T2 SELECT available_rooms→ 读到 available_rooms = 1(判断:有房,可以预订)T3 UPDATE SET available_rooms = 0INSERT INTO orders (user=A)COMMIT ✓available_rooms = 0T4 UPDATE SET available_rooms = -1INSERT INTO orders (user=B)COMMIT ✓available_rooms = -1 ❌ 超卖!问题分析:T1和T2时刻,两个事务各自独立读到
available_rooms = 1,都认为有房。T3时A先提交了,库存变为0。T4时B仍然基于T2时读到的旧值执行扣减,available_rooms变成了 -1。最终创建了两个订单但只有一间房,超卖发生。加悲观锁后:
T1 用户A: SELECT available_rooms FOR UPDATE → 获得行锁,读到 1T2 用户B: SELECT available_rooms FOR UPDATE → 被阻塞,等待A释放锁T3 用户A: UPDATE SET available_rooms = 0, COMMIT → 释放锁T4 用户B: 锁释放,读到 available_rooms = 0 → 判断无房,预订失败悲观锁保证了同一时间只有一个事务能操作库存行,从根本上杜绝了超卖。