跳转到内容

Module 6: 事务与一致性

📖 深度参考手册 — 本模块属于理论参考,非主线必读。 主线学习路径见 README.md。 当你在项目实战中遇到相关问题时,回来查阅。

来源:DDIA Ch7 (Transactions), Ch9 (Consistency and Consensus)

数据正确性是一切系统的基石。当多个操作需要”要么全部成功,要么全部失败”,当多个用户同时修改同一条数据,当数据分布在多个节点上时——事务和一致性机制就是保障数据正确的核心武器。本模块从单机事务到分布式一致性,逐步深入。


编号知识点核心概念
6.1ACID属性事务四大保证
6.2隔离级别并发控制的强度选择
6.3脏读/不可重复读/幻读数据异常现象
6.4乐观锁 vs 悲观锁并发控制策略
6.5分布式事务 (2PC)跨服务原子性
6.6Saga模式长事务的替代方案
6.7线性一致性最强一致性模型
6.8因果一致性因果关系保序
6.9最终一致性高可用优先的一致性
6.10共识算法概述分布式协调基础

定义: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。

定义:隔离级别定义了并发事务之间的可见性规则,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涉及金钱和实体资源(房间),要求绝对的数据正确性——超卖一间房意味着有一个顾客到了酒店没有房住,这是不可接受的。所以场景的"错误成本"决定了隔离级别的选择:错误成本低(搜索结果稍旧)→弱隔离级别+高性能;错误成本高(超卖、资损)→强隔离级别+低并发。

定义:这三种是不同隔离级别下可能出现的数据异常现象。脏读(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算法自动转换操作使结果收敛。这种方式允许多人"同时编辑"而不阻塞任何人,完全绕开了传统事务隔离级别的限制。这也说明了一个重要原则:不是所有并发问题都需要用数据库事务来解决,有些场景需要应用层的专门算法。

定义:**悲观锁(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操作是原子的,天然不需要显式加锁。

定义:两阶段提交(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的主要原因。

定义: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`,出错概率低于"扣减库存"。实际上,工程中要求补偿操作比正向操作更可靠——正向操作可以失败触发补偿,但补偿操作必须最终成功,否则就需要人工兜底。

定义:线性一致性(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被多次缩短也没关系。此时连最终一致性都够用,因为短链一旦创建后是不可变的。

定义:因果一致性(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) 在读取时,如果发现评论引用了尚未可见的帖子,要么等待帖子到达,要么主动从源节点拉取帖子。

定义:最终一致性(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和缓存 → 用户请求时从最近的缓存节点读取。不同地区的用户可能在不同时间看到更新后的营业时间,但最终所有人都会看到最新的数据。这种场景下追求强一致性是得不偿失的——为了让全球用户同时看到一个营业时间的更新,需要跨洲际的同步等待,延迟会从毫秒级飙升到百毫秒级,影响所有用户的地图加载速度。

定义:共识算法(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个场景判断隔离级别/一致性级别”

对于以下每个场景,判断最合适的隔离级别或一致性级别,并说明理由:

#场景你的选择
1Hotel Reservation 扣减房间库存?
2News Feed 展示朋友的新帖子?
3Gaming Leaderboard 实时排名展示?
4YouTube 视频播放次数统计?
5Chat System 群聊消息展示?
6Google Drive 文件列表展示?
7Search Engine 搜索结果排序?
8Hotel Reservation 查看订单详情?
9Web Crawler 已抓取URL去重?
10Proximity Service 附近餐厅列表?
点击查看参考答案
#场景选择理由
1Hotel Reservation 扣减库存可重复读 + 悲观锁(或可串行化)超卖=直接经济损失,必须最强保护
2News Feed 展示新帖子最终一致性延迟几秒展示完全可接受
3Gaming Leaderboard 实时排名线性一致性排名必须实时准确,关乎公平性
4YouTube 播放次数统计最终一致性显示”约100万次播放”和”约100万零3次播放”无区别
5Chat System 群聊消息因果一致性回复必须在原消息之后,但不需要全局实时同步
6Google Drive 文件列表读已提交需要看到已保存的文件,但不需要看到其他人正在编辑中的临时状态
7Search Engine 搜索结果最终一致性索引更新延迟分钟级可接受
8Hotel Reservation 查看订单读已提交用户期望看到自己已提交的订单最新状态
9Web Crawler URL去重最终一致性(可容忍偶尔重复抓取)重复抓取浪费资源但不会出错,用布隆过滤器近似去重
10Proximity 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 = 0
INSERT INTO orders (user=A)
COMMIT ✓
available_rooms = 0
T4 UPDATE SET available_rooms = -1
INSERT 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 → 获得行锁,读到 1
T2 用户B: SELECT available_rooms FOR UPDATE → 被阻塞,等待A释放锁
T3 用户A: UPDATE SET available_rooms = 0, COMMIT → 释放锁
T4 用户B: 锁释放,读到 available_rooms = 0 → 判断无房,预订失败

悲观锁保证了同一时间只有一个事务能操作库存行,从根本上杜绝了超卖。