Module 7: 分布式系统基础
📖 深度参考手册 — 本模块属于理论参考,非主线必读。 主线学习路径见 README.md。 当你在项目实战中遇到相关问题时,回来查阅。
来源:DDIA Ch8 (The Trouble with Distributed Systems), Ch9 (部分)
分布式系统的核心挑战不是”怎么让多台机器一起工作”,而是”当事情出错时,怎么让系统还能正常运行”。这个模块覆盖9个关键知识点,帮你建立对分布式环境中各种”不可靠”的系统性认知。
7.1 网络不可靠
Section titled “7.1 网络不可靠”定义:在分布式系统中,节点之间通过网络通信,而网络本质上是不可靠的——消息可能丢失、延迟、重复、乱序到达。这不是异常情况,而是分布式系统必须面对的常态。任何基于网络通信的系统设计,都必须以”网络会出问题”为基本假设。
为什么重要:如果你假设网络是可靠的,那你的系统在生产环境中一定会出问题。数据中心内部网络丢包率通常在 0.01%~0.1%,听起来很小,但在每秒百万级请求的系统中,意味着每秒有上百到上千个请求可能出问题。跨数据中心、跨地域的网络更不可靠。
案例:在 Chat System 中,网络不可靠直接导致以下真实场景:
- 消息丢失:用户A发了一条消息,服务器收到了但ACK回包丢了,用户A以为没发成功,又发了一次——产生重复消息。
- 消息延迟:用户A先发”我同意”,再发”这个方案”。但由于网络延迟,服务器可能先收到”这个方案”再收到”我同意”——消息乱序。
- 消息重复:网络超时触发客户端重试,服务器实际上已经收到并处理了第一次请求,结果同一条消息被存储了两次。
这就是为什么所有即时通信系统都需要:消息ID去重、客户端序列号排序、送达确认机制(ACK)。
先想一想 🤔 如果你设计Chat System,用户A发了一条消息,客户端等了3秒没收到服务器ACK。此时应该自动重发还是提示用户”发送失败”?两种策略各有什么问题?
点击查看解析
自动重发:可能导致消息重复(服务器其实收到了,只是ACK丢了)。需要服务端做幂等去重——给每条消息分配唯一的client_message_id,服务端收到重复ID直接返回成功但不再存储。
提示用户失败:用户体验差,且用户手动重发也会有同样的重复问题。
最佳实践:自动重发 + 服务端幂等。客户端本地生成唯一消息ID,重发时用相同ID;服务端根据ID去重。这样用户无感知,也不会产生重复消息。微信、WhatsApp等都是这样做的。
7.2 超时与故障检测
Section titled “7.2 超时与故障检测”定义:在分布式系统中,当一个节点向另一个节点发送请求后,如果长时间没有收到响应,它无法区分对方是”处理很慢”还是”已经挂了”——两者的表现完全一样(都是没有回复)。超时(Timeout)是故障检测的基本手段:设定一个等待时间,超过就认为对方可能故障。但超时时间的选择本身就是一个权衡——设太短会误判(把慢当成挂),设太长会延迟故障发现。
为什么重要:超时设置直接影响系统的可用性和正确性。超时太短:正常但较慢的请求被取消,白白浪费了已经消耗的计算资源,还可能触发不必要的重试,形成”重试风暴”。超时太长:故障节点长时间无法被发现,请求持续堆积在故障节点上,导致级联故障。
案例:在 Web Crawler 中,爬虫向目标网站发送HTTP请求获取页面。不同网站的响应速度差异极大:
- 快速CDN站点可能50ms就返回
- 小型个人博客可能需要5秒
- 某些动态页面可能需要30秒才能渲染完成
- 某些服务器可能已经挂了,永远不会响应
如果统一设置2秒超时,大量正常但较慢的页面会被误判为超时,爬取覆盖率下降。如果设置60秒超时,一个挂掉的服务器会让爬虫线程被阻塞60秒,大幅降低吞吐量。
实际做法是自适应超时:根据目标域名的历史响应时间动态调整超时。比如某站点过去100次请求的P99延迟是3秒,就把超时设为3秒的2倍=6秒。同时设置一个全局最大超时(如30秒)兜底。
先想一想 🤔 Web Crawler同时爬取1000个站点,其中10个站点已经宕机但爬虫不知道。如果每个请求的超时是30秒,爬虫有100个并发线程,会发生什么?
点击查看解析
10个宕机站点会持续”吃掉”爬虫线程——每个请求都要等30秒才超时。如果这10个站点各有很多URL待爬,很快100个线程中大部分都会被阻塞在这10个站点上,导致其余990个正常站点的爬取被”饿死”。
解决方案:
- 域名级熔断:如果某域名连续N次请求超时,暂停对该域名的爬取一段时间(如5分钟),释放线程给其他域名。
- 域名级并发限制:每个域名最多占用K个线程(如2个),防止单个域名垄断所有资源。
- 优先级队列:正常站点的URL优先级高于多次超时站点的URL。
这就是”故障检测+故障隔离”的组合拳。
7.3 时钟问题
Section titled “7.3 时钟问题”定义:分布式系统中的每台机器都有自己的物理时钟(石英振荡器),但这些时钟不可能完全同步。石英钟有”漂移”(drift),不同机器的时钟漂移速率不同,随时间推移,两台机器的时钟差距会越来越大。即使通过NTP(Network Time Protocol)定期同步,由于网络延迟的不确定性,同步后仍然可能有几毫秒到几十毫秒的误差。因此,不能依赖物理时钟来判断分布式系统中事件的先后顺序。
为什么重要:很多bug的根源是开发者不自觉地假设”不同机器上的时间是一样的”。比如用timestamp做并发控制——“时间戳更大的写入覆盖时间戳更小的”(Last Write Wins, LWW)。如果两台机器的时钟差了500ms,那么实际上先发生的事件可能反而有更大的时间戳,导致后发生的事件被错误地覆盖。
案例:在 Chat System 中,考虑这个场景:
- 用户A的手机时钟比标准时间快了2秒,在 12:00:02(本地时间)发送消息”我同意”
- 用户B的手机时钟是准确的,在 12:00:01(本地时间)发送消息”我反对”
- 实际上B发送在后(真实世界12:00:01 vs A的真实世界12:00:00)
- 但如果用客户端时间戳排序,A的消息(12:00:02)会排在B的消息(12:00:01)之后
服务器收到这两条消息后,如果简单地按时间戳排序,就会显示错误的消息顺序。
正确做法:消息的排序应该由服务器统一分配的序列号(或服务器接收时间戳)来决定,而不是客户端的本地时间。客户端时间只作为UI上”显示时间”的参考,不作为排序依据。
先想一想 🤔 如果Chat System使用服务器时间戳来排序消息,就完全没问题了吗?如果是多个服务器实例呢?
点击查看解析
单个服务器用自己的时间戳排序是可以的,因为同一台机器上的时钟是单调递增的(
CLOCK_MONOTONIC)。但如果是多个服务器实例(分布式部署),不同服务器的时钟同样有偏差问题。解决方案:
- 全局递增序列号:用Redis INCR或数据库自增ID生成全局唯一递增的消息序列号。缺点是有性能瓶颈(单点)。
- Snowflake ID:Twitter的做法——把时间戳+机器ID+本机递增序号组合成64位ID。同一毫秒内同一机器有序列号保证顺序,不同机器之间大致有序(受时钟偏差影响,但毫秒级偏差在即时通信场景下可接受)。
- 逻辑时钟:完全不依赖物理时间,用因果关系来定序(见下一节7.4)。
实际上大多数聊天系统使用Snowflake风格的ID——在可接受的精度范围内,既高性能又大致有序。
7.4 逻辑时钟 (Lamport/Vector Clock)
Section titled “7.4 逻辑时钟 (Lamport/Vector Clock)”定义:逻辑时钟是一种不依赖物理时间、而是基于事件因果关系来标记事件顺序的机制。Lamport时钟为每个事件分配一个递增的逻辑计数器:发送消息时计数器+1,接收消息时取max(本地计数器, 消息中的计数器)+1。这保证了如果事件A因果地先于事件B(A happened before B),那么A的逻辑时间一定小于B。Vector Clock(向量时钟)进一步扩展,为每个节点维护一个计数器向量,能识别出哪些事件是并发的(无因果关系)。
为什么重要:在分布式系统中,物理时钟不可靠(如7.3所述),但我们仍然需要知道事件的先后关系。逻辑时钟解决的就是这个问题——不依赖”几点几分”,而是依赖”谁先于谁”。Lamport时钟能判断因果顺序(如果A→B,则L(A)<L(B)),但反过来不成立(L(A)<L(B)不能推出A→B)。Vector Clock更强大,能准确判断两个事件是因果有序还是并发的。
案例:在 Google Drive 中,多个用户可能同时编辑同一个文件。每次编辑产生一个新版本:
- 用户A在版本3的基础上做了修改,生成版本4a
- 用户B也在版本3的基础上做了修改,生成版本4b
- 4a和4b是并发的——没有因果关系,需要冲突处理
Vector Clock可以精确判断这种情况:
版本3的向量时钟: {A:2, B:1}版本4a的向量时钟: {A:3, B:1} // A的计数器增加版本4b的向量时钟: {A:2, B:2} // B的计数器增加4a和4b互相比较:A的计数器 3>2,但B的计数器 1<2——没有一个完全支配另一个,说明它们是并发修改,系统需要提示用户解决冲突(或自动合并)。
如果只用Lamport时钟(单个数字),4a=4,4b=4,虽然知道它们计数相同,但无法区分”并发”和”恰好同时”——而Vector Clock可以。
先想一想 🤔 Google Drive的文件版本用Vector Clock记录因果关系,如果有100个协作者,Vector Clock会变成100维向量。这有什么问题?如何解决?
点击查看解析
每个版本都要携带一个100维的向量时钟,存储和传输开销很大。而且很多维度可能长期不变(大部分人只是读不写),造成浪费。
解决方案:
- 修剪(Pruning):只保留最近活跃的协作者的时钟条目,长期不活跃的条目可以安全删除(因为他们不会产生并发冲突)。
- Dotted Version Vector:Riak使用的优化版本,减少向量时钟膨胀。
- 服务端中心化排序:Google Docs实际上使用的是Operational Transformation(OT)或CRDT,由服务端统一编排操作顺序,不依赖Vector Clock。
实际上,纯Vector Clock更常见于去中心化的存储系统(如Amazon Dynamo),而不是协作编辑场景。Google Drive/Docs使用更专门的冲突解决机制。
7.5 拜占庭容错
Section titled “7.5 拜占庭容错”定义:拜占庭容错(Byzantine Fault Tolerance, BFT)处理的是最极端的故障类型——节点不仅可能”挂掉”或”变慢”,还可能主动发送错误信息、伪造数据、或表现出任意恶意行为。名字来源于”拜占庭将军问题”:多个将军需要达成一致行动(进攻或撤退),但其中有叛徒会故意发送矛盾的消息。在N个节点中,如果恶意节点不超过 (N-1)/3 个,拜占庭容错算法仍能保证正确节点达成共识。
为什么重要:拜占庭容错的代价极高——通信复杂度至少O(N^2),需要至少3f+1个节点来容忍f个恶意节点。因此绝大多数内部分布式系统不需要拜占庭容错。我们通常假设节点可能崩溃但不会恶意行为(Crash-Fault Tolerance即可)。拜占庭容错主要用于:区块链(节点互不信任)、航空航天(传感器可能硬件故障发送错误数据)等场景。
案例:在 Gaming Leaderboard 中,存在一种”弱拜占庭”场景——玩家可能作弊:
- 修改客户端发送的分数数据(声称得了100万分)
- 使用外挂加速游戏、穿墙等
- 多账号刷分
这不是完整的拜占庭问题(服务器节点之间是互信的),而是客户端不可信的问题。但思路类似——不能信任任何来自客户端的数据。
解决方案:
- 关键计算在服务端完成(不信任客户端发来的分数,由服务端根据游戏事件计算)
- 多重校验(分数增长速率是否合理、操作频率是否超过人类极限)
- 重放验证(客户端上传操作序列,服务端重新模拟验证结果)
先想一想 🤔 区块链使用拜占庭容错来防止恶意节点篡改数据。为什么普通的分布式数据库(如MySQL主从集群)不需要拜占庭容错?
点击查看解析
关键区别在于信任模型:
- 区块链:节点由不同的参与者(互不信任的陌生人)控制,任何节点都可能恶意行为。必须用拜占庭容错来在不信任的环境中达成共识。
- MySQL集群:所有节点由同一个组织控制和运维。我们信任节点不会恶意行为(虽然可能崩溃)。用Crash-Fault Tolerance(如Raft协议)就够了——只需处理节点崩溃,不需处理节点撒谎。
通俗地说:区块链是”和陌生人做生意”,需要防骗;内部系统是”和同事合作”,只需防止意外。成本差异巨大——Raft只需2f+1个节点容忍f个崩溃,BFT需要3f+1。
7.6 Quorum 读写
Section titled “7.6 Quorum 读写”定义:Quorum(法定人数)读写是无主复制(Leaderless Replication)系统的核心机制。系统有N个副本,写入时要求至少W个副本确认成功,读取时要求至少从R个副本读取。只要满足 W + R > N,读取操作一定能读到至少一个包含最新值的副本。这是因为W个写入副本和R个读取副本之间一定有交集(鸽巢原理)。
为什么重要:Quorum机制让我们在一致性和可用性之间灵活权衡:
- W=N, R=1:写入要求所有副本确认,读取任意一个即可。写入慢但读取快,适合读多写少。
- W=1, R=N:写入一个就成功,读取要读所有。写入快但读取慢,适合写多读少。
- W=⌊N/2⌋+1, R=⌊N/2⌋+1:平衡配置,常见于通用系统。
但Quorum不是万能的——它只保证”能读到最新值的某个副本”,但如果读取到多个副本的值不一致(有的新有的旧),客户端还需要选择最新的那个(通常通过版本号或时间戳)。
案例:Cassandra和DynamoDB都使用Quorum读写。假设一个5副本的系统(N=5):
- 配置 W=3, R=3(3+3=6 > 5 ✓)
- 写入时,数据发送到5个副本,其中3个确认成功即返回
- 读取时,从5个副本中读取3个的数据,取版本号最大的值
写入 key="score", value=100, version=5 副本1: ✓ 写入成功 (score=100, v5) 副本2: ✓ 写入成功 (score=100, v5) 副本3: ✓ 写入成功 (score=100, v5) 副本4: ✗ 暂时不可达 (仍然是 score=90, v4) 副本5: ✗ 暂时不可达 (仍然是 score=90, v4) → W=3 满足,写入成功返回
读取 key="score", R=3 读副本1: score=100, v5 读副本2: score=100, v5 读副本4: score=90, v4 ← 旧值 → 取 max(v5, v5, v4) = v5 → 返回 score=100 ✓即使读到了一个旧副本,因为 W+R > N,至少有一个读到的副本是最新的。
先想一想 🤔 在N=3, W=2, R=2的Quorum配置中,如果有一个节点永久故障(只剩2个节点可用),系统还能正常读写吗?
点击查看解析
- 写入:需要W=2个确认,只有2个节点可用,恰好满足。只要这2个都正常就能写入成功。但没有任何容错余量——再挂一个就完全不可写。
- 读取:需要R=2个响应,同理恰好满足。
所以系统”勉强能工作”,但已经是零容错状态——任何一个节点的临时故障都会导致不可用。这就是为什么生产系统通常选择N=5或N=7等奇数,提供更大的容错空间。
另外,由于永久故障的节点上有旧数据,当它被修复重新上线时,需要通过反熵(Anti-entropy) 或 读修复(Read Repair) 来同步最新数据。
7.7 脑裂 (Split Brain)
Section titled “7.7 脑裂 (Split Brain)”定义:脑裂是指在主从复制系统中,由于网络分区(Network Partition),集群被分成两个(或多个)互不相通的子集,每个子集都选举出自己的”主节点”,各自独立接受写入。当网络恢复后,两个”主”上的数据出现冲突和分叉,难以合并。这是分布式系统中最危险的故障之一。
为什么重要:脑裂可能导致数据永久损坏。例如两个”主”同时接受了对同一行数据的不同修改,网络恢复后无法自动决定哪个修改是对的。更糟糕的是,脑裂可能悄无声息地发生——系统看起来两边都在”正常工作”,直到网络恢复后才发现数据已经分叉。
案例:任何主从复制系统都可能遭遇脑裂。以一个3节点的主从集群为例:
正常状态: [主节点A] ←→ [从节点B] ←→ [从节点C]
网络分区发生: 子网1: [主节点A] | 子网2: [从节点B] [从节点C] | A认为B和C都挂了 | B和C认为A挂了 A继续接受写入 | B被选举为新主,也开始接受写入 | → 两个主节点同时存在 = 脑裂!网络恢复后,A和B都有各自的新数据,产生冲突。
常见防护机制:
- Fencing(隔离):旧主节点发现自己无法联系到多数节点时,自动降级为只读或关闭。
- Quorum选举:新主只能在拥有多数节点(>N/2)的分区中被选举出来。上例中A单独一个无法形成多数(1/3 < 50%),所以A应该自行降级。
- STONITH (Shoot The Other Node In The Head):通过带外机制(如电源管理)强制关闭旧主节点。
先想一想 🤔 如果是一个2节点的主从系统(1主1从),发生网络分区后,能否用”多数派”策略来防止脑裂?
点击查看解析
不能。2个节点中的多数是2个——也就是说,任何一个子网(每个子网只有1个节点)都无法凑够”多数”。结果是两边都不会选举出新主,系统完全不可写。
这就是为什么分布式集群通常使用奇数个节点(3、5、7)——在2f+1个节点中,可以容忍f个节点故障,剩余f+1个仍构成多数。
- 3节点:容忍1个故障,2个构成多数
- 5节点:容忍2个故障,3个构成多数
- 2节点:容忍0个故障——任何一个节点故障都导致系统不可用
所以2节点的主从系统本质上没有高可用性。如果预算只够2台机器,通常引入一个轻量级”见证节点”(Witness)作为第三票来打破平局。
7.8 幂等性设计
Section titled “7.8 幂等性设计”定义:一个操作是幂等的(Idempotent),意味着执行一次和执行多次的效果完全相同。数学上:f(f(x)) = f(x)。在分布式系统中,由于网络不可靠(7.1),客户端经常需要重试请求。如果操作不是幂等的,重试可能导致错误结果(如重复扣款、重复创建)。幂等性设计是让重试安全(retry-safe)的基础。
为什么重要:在网络不可靠的环境中,重试是不可避免的。如果你的API不是幂等的,那么每次重试都是一次冒险。幂等性设计把”至少一次”(at-least-once delivery)安全地转化为”恰好一次”(exactly-once semantics)的效果。
案例:
Hotel Reservation 支付回调幂等:用户预订酒店后,支付网关会回调你的系统确认支付成功。但支付网关可能因为没收到你的响应而重复回调。如果每次回调都把订单标记为”已支付”并触发确认邮件:
第1次回调: 订单#123 标记为已支付 ✓,发送确认邮件 ✓第2次回调(重试): 订单#123 再次标记为已支付(幂等,状态不变),但再次发邮件 ✗ 重复!幂等设计:
每次回调带有唯一的 payment_id处理逻辑: 1. 查询 payment_id 是否已处理过 2. 如果是 → 直接返回"成功",不做任何操作 3. 如果否 → 标记订单已支付、发送邮件、记录 payment_id 已处理URL Shortener 同一URL返回同一短码:用户提交 https://example.com/very-long-url 生成短链接。如果不做幂等,每次提交相同URL都生成不同的短码(abc123、def456…),浪费存储空间且不符合直觉。
幂等设计:
1. 对长URL计算哈希2. 查询是否已存在该哈希对应的短码3. 如果存在 → 返回已有的短码4. 如果不存在 → 生成新短码并存储这样无论用户提交多少次相同的URL,总是返回同一个短码——天然幂等。
先想一想 🤔 设计一个”转账”接口:从账户A转100元到账户B。如何让这个接口幂等?注意:转账涉及两个账户的余额变更。
点击查看解析
核心思路:用唯一的转账请求ID(idempotency key)做去重。
POST /transfer{"idempotency_key": "txn_abc123", // 客户端生成的唯一ID"from": "A","to": "B","amount": 100}服务端处理逻辑:
- 查询
idempotency_key = txn_abc123是否已存在于已处理记录表- 如果已存在 → 返回之前的处理结果(成功/失败),不再扣款
- 如果不存在 → 在数据库事务中:
- A余额 -= 100
- B余额 += 100
- 插入记录
{key: txn_abc123, status: success}- 提交事务
关键点:
idempotency_key的检查和转账操作必须在同一个数据库事务中,否则可能在检查和操作之间发生并发问题。- 或者使用
INSERT ... ON CONFLICT DO NOTHING+ 查询的方式,利用数据库的唯一约束来保证幂等。- Stripe、支付宝等支付系统都要求客户端传入
idempotency_key,就是这个原理。
7.9 服务发现
Section titled “7.9 服务发现”定义:服务发现(Service Discovery)是指在微服务架构中,服务实例动态注册自己的网络地址,其他服务通过查询注册中心来找到它的地址。常见的服务发现组件有 ZooKeeper、etcd、Consul、Nacos 等。与传统的静态配置(硬编码IP地址)不同,服务发现支持动态扩缩容、故障节点自动摘除、负载均衡等能力。
为什么重要:在微服务架构中,服务实例的地址是不固定的——容器重启IP会变、自动扩容会新增实例、故障实例会被移除。如果用硬编码配置地址,每次变化都需要手动修改配置并重启所有依赖方,这在大规模系统中是不可行的。服务发现让系统具备自愈能力——故障节点自动被摘除,新节点自动被发现。
案例:在所有微服务系统中,服务发现都是基础设施。以一个典型的 Search Engine 为例:
搜索系统的微服务: - Query Service (解析用户查询) → 需要调用 Index Service - Index Service (查询倒排索引) → 需要调用 Ranking Service - Ranking Service (排序结果) → 需要调用 Snippet Service - Snippet Service (生成摘要)
Query Service 如何知道 Index Service 在哪里?方案1:客户端发现(Client-side Discovery)
Index Service 启动时 → 向 etcd 注册: "index-service, 10.0.1.5:8080"Index Service 扩容 → 新实例注册: "index-service, 10.0.1.6:8080"Query Service 查询 etcd → 得到 [10.0.1.5:8080, 10.0.1.6:8080]Query Service 自己做负载均衡(轮询/随机/加权)方案2:服务端发现(Server-side Discovery)
所有请求发到负载均衡器(如Nginx/Envoy)负载均衡器从注册中心获取最新的服务列表负载均衡器转发请求到具体实例服务本身不需要知道其他服务的地址方案3:DNS-based Discovery
Kubernetes 中每个 Service 自动有 DNS 名称index-service.default.svc.cluster.local → 自动解析到健康的Pod IPKubernetes 让服务发现变得几乎透明——你只需要用服务名调用,K8s 自动处理发现和负载均衡。
先想一想 🤔 服务发现的注册中心(如etcd)本身也是一个分布式服务。如果注册中心挂了,所有微服务的相互调用都会失败吗?
点击查看解析
不一定。好的服务发现设计有多层保护:
注册中心自身高可用:etcd/ZooKeeper/Consul都是多节点集群,用Raft等共识协议保证自身的高可用。单节点故障不影响整体。
客户端缓存:服务消费方通常会在本地缓存一份服务地址列表。即使注册中心暂时不可用,客户端仍然可以用缓存的地址继续调用。缺点是缓存可能过时(已下线的实例还在缓存中)。
优雅降级:如果注册中心完全不可用且缓存也过期了,系统降级为”使用最后已知的地址列表”,直到注册中心恢复。
DNS TTL:如果用DNS做服务发现,DNS缓存(TTL通常30秒到几分钟)提供了天然的缓冲。
所以注册中心挂了不是世界末日——短时间内系统仍能正常运行。但如果长时间不恢复,服务列表会越来越过时,最终影响可用性。这就是为什么注册中心本身的可用性要求非常高(通常要求99.99%以上)。
练习1:Chat System 3节点主从故障推演
Section titled “练习1:Chat System 3节点主从故障推演”Chat System 使用3节点主从复制:主节点M、从节点S1、从节点S2。 某一时刻,M和S1之间的网络断了(M和S2之间正常,S1和S2之间正常)。
请推演所有可能发生的情况并回答:
- S1和S2会认为M挂了吗?
- 会触发新的主选举吗?如果会,谁会成为新主?
- 如果S1被选为新主,会发生什么?
- 如何防止最坏的情况?
点击查看解析
分析网络连通性:
M ←→ S2 ✓M ←✗→ S1S1 ←→ S2 ✓情况推演:
-
S1认为M挂了(因为S1无法和M通信),但S2知道M还活着(S2和M之间网络正常)。
-
是否触发选举取决于故障检测机制:
- 如果使用”多数派判断”:S1认为M挂了,但S2认为M没挂。投票:1票认为M挂了(S1),1票认为M没挂(S2)。不足多数 → 不触发选举。M继续当主。
- 如果使用”任何从节点超时就触发选举”:S1发起选举,但需要多数票(2/3=至少2票)才能成为新主。S2知道M还活着,会拒绝投票给S1 → 选举失败。
-
如果S1被错误地选为新主(假设故障检测有bug):
- M继续接受写入(它认为自己还是主)
- S1也开始接受写入(它认为自己是新主)
- → 脑裂! 两个主节点各自接受不同的写入
- 网络恢复后数据冲突,消息顺序混乱
-
防护措施:
- Raft/Paxos共识算法:选举必须获得多数票(2/3),M自己也参与(M不投票给S1),所以S1最多1票无法当选。
- Lease机制:M持有一个有时效的”主租约”,在租约到期前M确信自己是主。S1必须等租约过期后才能尝试选举。
- Fencing Token:每次选举产生递增的token,旧主的写入请求带旧token会被拒绝。
练习2:Hotel Reservation 支付幂等设计
Section titled “练习2:Hotel Reservation 支付幂等设计”Hotel Reservation 系统的支付流程如下:
- 用户选好房间,点击”支付”
- 系统调用支付网关(如Stripe)扣款
- 支付成功后,系统确认预订、锁定房间、发送确认邮件
请设计这个流程的幂等方案,需要处理以下故障场景:
- 场景A:调用Stripe成功,但保存订单状态时数据库超时
- 场景B:Stripe回调通知发送了两次
- 场景C:用户在支付页面点了两次”支付”按钮
点击查看解析
核心设计:Idempotency Key + 状态机
订单状态机: PENDING → PAYING → PAID → CONFIRMED → PAYMENT_FAILED场景A:调用Stripe成功,保存状态失败
1. 创建订单时生成 idempotency_key (如 order_123_pay_v1)2. 调用 Stripe 时传入 idempotency_key → Stripe保证:同一key只会扣款一次3. Stripe返回成功,更新订单状态为PAID → 数据库超时!4. 客户端重试整个流程: → 再次调用Stripe,同一key → Stripe直接返回上次的结果(不重复扣款) → 再次更新订单状态为PAID → 这次成功关键点:Stripe自身支持idempotency_key,确保不会重复扣款。
场景B:Stripe回调重复
回调处理逻辑: 1. 收到回调 {payment_id: "pi_xxx", status: "succeeded"} 2. 查询: SELECT status FROM orders WHERE stripe_payment_id = "pi_xxx" 3. 如果 status 已经是 PAID 或 CONFIRMED → 直接返回200,不做任何操作 4. 如果 status 是 PAYING → 更新为 PAID,触发后续流程(锁房间、发邮件) 5. 后续流程也要幂等: - 锁房间: UPDATE rooms SET locked_by = order_123 WHERE id = room_456 AND locked_by IS NULL (如果已锁定就不会再锁) - 发邮件: 查询 email_sent_for_order_123 是否已发送场景C:用户双击支付按钮
前端防御: 点击后立即禁用按钮后端防御: 1. 第一次点击 → 创建支付意向,订单状态 PENDING → PAYING 2. 第二次点击 → 查询订单状态,已经是 PAYING → 返回"支付进行中,请等待" 3. 或者:两次点击用同一个 idempotency_key 调用Stripe → Stripe去重完整的幂等设计要点:
- 每个操作都有唯一标识(idempotency_key / payment_id / order_id)
- 每一步都先检查”是否已完成”,已完成则跳过
- 状态转换是单向的(状态机),不会倒退
- 利用数据库唯一约束 + 外部服务的幂等能力(如Stripe的idempotency_key)
本模块核心收获:分布式系统的设计本质是在”不可靠的基础设施上构建可靠的服务”。网络不可靠、时钟不准确、节点会故障——接受这些现实,然后用超时检测、逻辑时钟、Quorum、幂等性、服务发现等工具来应对,才是正确的分布式系统思维方式。