Skip to content

分布式系统设计入门

从单机到分布式的思维转变——CAP 定理、一致性模型、分布式 ID、幂等性、数据分片、共识协议、服务治理,用 Python 代码和真实案例拆解分布式系统的核心设计问题。


1. 为什么需要分布式系统

在你职业生涯的前几年,大概率所有系统都跑在一台机器上——一个 Flask/FastAPI 进程、一个 PostgreSQL 数据库、一个 Redis 缓存,全部装在同一台服务器。这种架构叫单体架构(Monolithic Architecture),它简单、直接、好调试。

但总有一天你会遇到一个不可回避的问题:一台机器不够用了。

不是说服务器不够好——是物理定律不允许。这一章帮你建立"为什么必须分布式"的直觉,以及理解"分布式不是免费午餐"这件事。

1.1 单机系统的天花板

一台机器再强,也逃不过三道硬性天花板:

天花板 1:计算能力

单机计算能力的极限(2025 年顶配服务器):

  CPU:128 核(AMD EPYC 9004 系列)
  内存:2TB DDR5
  网络:100Gbps

  这够用吗?
  ═══════════════════════════════════════
  场景                     单机能扛吗?
  ═══════════════════════════════════════
  日活 1 万的 SaaS         ✅ 绰绰有余
  日活 10 万的电商           ⚠️ 勉强,高峰期抖动
  日活 100 万的社交          ❌ 一台机器不可能
  双 11 秒杀(10 万 QPS)    ❌ 单机 ~5000 QPS 上限
  ═══════════════════════════════════════

关键不在于"平均负载够不够",而在于峰值。大促、突发新闻、热点事件——这些流量尖刺会在几秒内把一台机器打穿。

天花板 2:存储容量

单机存储的极限:

  SSD:最大 ~60TB(单块企业级)
  RAID 阵列:~200TB(实用上限)
  
  这够用吗?
  ═══════════════════════════════════════
  Gmail:每个用户 15GB → 100 万用户 = 15PB
  抖音:每天新增 ~5PB 视频数据
  基因测序:单个人类基因组 ~200GB
  ═══════════════════════════════════════
  结论:数据量级超过 TB 级,单机就不现实了

天花板 3:可用性

这是最致命的——再好的服务器也会宕机。硬盘会坏、内存会报错、电源会故障、机房会断网。

单机可用性的数学真相:

  假设服务器年可用性 = 99.9%(3 个 9)
  ══════════════════════════════════════
  99.9% = 每年约 8.7 小时的停机时间
  
  对于关键业务(支付、医疗、交通):
  → 8.7 小时的宕机 = 灾难
  → 需要 99.99%(4 个 9)= 每年只停 52 分钟
  → 需要 99.999%(5 个 9)= 每年只停 5 分钟
  
  单台机器不可能做到 4 个 9 以上
  → 必须多台机器互为备份(冗余)

💡 核心洞察:分布式系统不是"因为酷所以用"——是被计算瓶颈、存储瓶颈、可用性要求这三面墙逼出来的。如果你的系统日活不超过 10 万、数据不超过 100GB、允许偶尔宕机几小时,单体架构永远是最优解。不要为了分布式而分布式。

1.2 分布式系统的核心承诺与代价

分布式系统不是银弹——它给你三样东西,同时收走四样东西。

三大承诺:

承诺含义典型实现
水平扩展加机器就能加性能,理论上无上限Nginx 负载均衡 + 无状态服务
高可用单台机器挂了,系统照常运行主从复制、多副本、故障转移
地理分布让数据和计算离用户更近CDN、多区域部署、边缘计算
垂直扩展 vs 水平扩展:

  垂直扩展(Scale Up):给一台机器加资源
  ══════════════════════════════════════
  ┌──────────────┐     ┌──────────────────┐
  │  4 核 / 8GB  │ ──▶ │  128 核 / 2TB    │
  │  小服务器    │     │  超级服务器       │
  └──────────────┘     └──────────────────┘
  特点:简单,但有物理上限,且越高端越贵
  
  水平扩展(Scale Out):加更多普通机器
  ══════════════════════════════════════
  ┌──────────────┐     ┌────┐ ┌────┐ ┌────┐ ┌────┐
  │  4 核 / 8GB  │ ──▶ │ 4核│ │ 4核│ │ 4核│ │ 4核│
  │  小服务器    │     └────┘ └────┘ └────┘ └────┘
  └──────────────┘     4 台普通机器,总计 16 核
  特点:理论上无上限,但引入协调成本

四大代价:

  1. 网络是不可靠的:两台机器之间的网络随时可能断开、延迟、乱序。在单机系统里,函数调用是纳秒级的、100% 可靠的;在分布式系统里,一次 RPC 调用可能需要毫秒级,还可能超时、失败、重复到达。

  2. 时钟是不一致的:每台机器的系统时钟都有微小偏差(几毫秒到几十毫秒)。这意味着"事件 A 发生在事件 B 之前"这个在单机上天经地义的判断,在分布式环境中变成了一个需要特殊协议才能回答的难题(Lamport 时钟、向量时钟)。

  3. 部分失败:单机系统要么整体正常、要么整体崩溃。分布式系统最诡异的地方在于——一部分节点正常,一部分节点故障。你的数据库主节点写入成功了,但从节点因为网络分区没收到同步,这时候该怎么办?

  4. 复杂度爆炸:调试一台机器的 bug,你看日志、下断点就行。调试三台机器协作的 bug——日志分散在三个节点、时间戳还对不上、请求在节点之间跳来跳去,你甚至不知道 bug 是哪台机器引起的。

💡 记住这个比例:分布式系统中,大约 70% 的工程量花在"处理异常情况"上——超时重试、幂等保证、一致性维护、故障转移。只有 30% 的代码是业务逻辑。如果你觉得分布式很复杂,不是你的错——它本身就是复杂的。

1.3 八大谬误:分布式新手最容易踩的坑

1994 年,Sun Microsystems 的工程师 Peter Deutsch 总结了 "分布式计算的八大谬误"(Fallacies of Distributed Computing)。30 年过去了,这份清单依然精准到可怕——每一条都在生产环境中反复被验证。

#谬误你以为现实工程对策
1网络是可靠的请求一定能送达丢包、超时、连接中断是常态超时+重试+幂等(第 4 章)
2延迟为零RPC 和本地调用一样快同机房 ~0.5ms,跨机房 ~50ms,跨洲 ~200ms减少调用次数、批量请求、缓存
3带宽是无限的随便传数据高并发下带宽会成为瓶颈压缩、分页、CDN
4网络是安全的内网无需防御内网攻击(横向移动)是主流入侵路径零信任、mTLS、网络隔离
5拓扑不会变IP 和端口是固定的容器重启、自动扩缩容改变拓扑服务发现(Consul/etcd)
6只有一个管理员我运维我自己的机器多团队、多云、多区域IaC、统一配置中心
7传输成本为零网络传输不要钱云厂商跨区流量按 GB 计费减少跨区调用、数据本地化
8网络是同质的所有节点的网络一样好不同机房/区域网络质量差异巨大就近路由、多区域部署
一个真实的生产故障(谬误 #1:网络是可靠的):

  服务 A 调用服务 B 的下单接口:
  
  A ──── POST /orders ────▶ B
  A ◀── (超时,没收到响应) ── B(实际已成功创建订单)
  
  A 以为失败了,重试:
  A ──── POST /orders ────▶ B(又创建了一个订单!)
  
  结果:用户被扣了两次款
  
  根因:A 假设"收不到响应 = 失败"
  修复:接口必须设计为幂等的(第 4 章详述)

💡 读完这八条,你应该建立起一个直觉:分布式系统的代码中,大量的逻辑不是在处理"成功路径",而是在处理"失败路径"——网络断了怎么办?节点挂了怎么办?数据不一致怎么办?接下来的每一章,都是在回答这些问题。

第 1 章核心知识回顾:

概念一句话解释
垂直扩展 vs 水平扩展加配置(Scale Up)有物理上限;加机器(Scale Out)理论无上限但引入协调复杂度
三大天花板计算能力、存储容量、可用性——任何一个超标就必须走向分布式
四大代价网络不可靠、时钟不一致、部分失败、复杂度爆炸
八大谬误30 年前的经典清单,至今精准——"网络是可靠的"排第一,是最常踩的坑
不要为了分布式而分布式如果单体能扛住,单体永远是更好的选择

2. CAP 定理与一致性模型

如果分布式系统领域有一条"第一定律",那就是 CAP 定理。它被引用了无数次,也被误解了无数次。这一章彻底搞清楚 CAP 到底在说什么,以及工程师在实际项目中如何基于它做出权衡。

2.1 CAP 定理:被误解最多的分布式理论

2000 年,加州大学伯克利分校的 Eric Brewer 提出了 CAP 猜想,2002 年被 Seth Gilbert 和 Nancy Lynch 正式证明为定理。它说的是:

CAP 定理(简化版):

  一个分布式系统最多只能同时满足以下三个特性中的两个:
  
  C - Consistency(一致性)
      所有节点在同一时刻看到的数据完全一致
      → 你写了一笔转账,从任何节点查都能看到这笔转账
  
  A - Availability(可用性)
      每个请求都能收到一个(非错误的)响应
      → 不管找哪个节点要数据,它都不会拒绝你
  
  P - Partition tolerance(分区容错性)
      网络分区(节点之间通信中断)发生时,系统仍能运行
      → 两个机房之间的网线断了,系统不会直接崩掉

90% 的人对 CAP 的误解:

最常见的误解是把 CAP 理解为"三选二"——就像考试选择题一样,CA、CP、AP 三款随便挑一个。这是错的

为什么不是"三选二":

  在分布式系统中,P(分区容错)不是 optional 的。
  ══════════════════════════════════════════════
  
  网络分区会不会发生?
  → 一定会。网线被挖断、交换机故障、云厂商抽风——
    这不是"会不会"的问题,是"什么时候"的问题。
  
  所以 P 是必选项。
  
  真正的选择是:当网络分区发生时,你选 C 还是 A?
  
  ┌─────────────────────────────────────────────┐
  │    网络分区发生了!                            │
  │                                             │
  │    选 C(一致性):                            │
  │    拒绝服务(返回错误),直到分区恢复            │
  │    → 宁可不可用,也不能给错误数据              │
  │    → 银行转账、库存扣减                       │
  │                                             │
  │    选 A(可用性):                            │
  │    继续服务,但数据可能暂时不一致               │
  │    → 宁可数据有点旧,也不能拒绝用户            │
  │    → 社交媒体动态、商品浏览、DNS              │
  └─────────────────────────────────────────────┘

用一个银行转账的例子来感受 CP vs AP:

场景:用户在节点 A 发起转账,把 100 元转给朋友。
      但此时节点 A 和节点 B 之间的网络断了(分区)。

  CP 系统的做法:
  ═══════════════════════
  节点 A:"我没法把这笔转账同步到节点 B,
          所以我拒绝这次操作,返回错误。"
  用户:"转账失败了?……行吧,至少没扣我两次钱。"
  → 牺牲了可用性,保证了一致性 ✅

  AP 系统的做法:
  ═══════════════════════
  节点 A:"虽然同步不了,但我先把转账记下来,
          等网络恢复再同步给节点 B。"
  用户在节点 A 查余额:900 元(正确)
  用户在节点 B 查余额:1000 元(旧数据!)
  → 保证了可用性,但一致性暂时被破坏 ⚠️

💡 CAP 不是一次性的全局决策——同一个系统的不同操作可以做不同的选择。比如电商系统:下单扣库存选 CP(不能超卖),商品详情页选 AP(显示稍微旧的库存无所谓)。这种"按操作粒度选择一致性级别"的思维方式,才是 CAP 定理真正的工程价值。

2.2 一致性光谱:从强一致到最终一致

很多人把一致性理解为"要么一致、要么不一致"的二元对立。实际上,一致性是一个光谱——从最严格的"线性一致性"到最宽松的"最终一致性",中间有多个层级,每个层级在性能和正确性之间做不同的取舍。

一致性强度光谱(从强到弱):

  强 ════════════════════════════════════════ 弱
  
  线性一致     顺序一致     因果一致     最终一致
  (Linearizable) (Sequential) (Causal)  (Eventual)
       │            │           │           │
       │            │           │           │
  所有操作有      所有操作     只保证因     只保证
  全局实时顺序   有全局顺序   果相关操作   "最终"
  (最慢)      (较慢)     有序(快)    一致(最快)

各级别对比:

一致性级别通俗解释延迟代价典型系统
线性一致任何读操作都能读到最新的写入结果,仿佛只有一份数据极高(每次写入都要等多数节点确认)ZooKeeper、etcd、Spanner
顺序一致所有节点看到的操作顺序一致,但不保证实时性部分分布式数据库
因果一致有因果关系的操作保序(A 回复了 B,那任何人先看到 B 再看到 A 就是不允许的)MongoDB(部分模式)
最终一致如果不再有新的写入,所有副本最终会收敛到相同状态DynamoDB、Cassandra、DNS
用社交媒体理解因果一致性 vs 最终一致性:

  场景:小明发了一条朋友圈,小红评论了"好看!"
  
  因果一致性保证:
  ═══════════════════════
  任何人看到小红的评论时,一定能看到小明的原帖
  → "评论"依赖"原帖",有因果关系,必须保序
  → 但小刚同时发的一条无关朋友圈,顺序随意
  
  最终一致性不保证:
  ═══════════════════════
  可能有人先看到小红的评论"好看!"
  但小明的原帖还没同步过来
  → 用户困惑:"好看什么?看不到原帖啊"
  → 过几秒刷新后才看到原帖

💡 选型经验法则:如果你的业务涉及(转账、扣款、库存),用强一致或线性一致。如果涉及内容展示(动态、评论、商品详情),最终一致通常够用。如果涉及对话/协作(聊天记录、文档协同),因果一致是最佳平衡点。

2.3 BASE 原则:拥抱最终一致性

如果说 ACID 是单机数据库的设计哲学(强一致、事务隔离),那 BASE 就是分布式系统的设计哲学——它不追求完美的一致性,而是在可用性和性能之间找到甜点。

ACID vs BASE:

  ACID(传统数据库)               BASE(分布式系统)
  ══════════════════               ══════════════════
  Atomicity   原子性               Basically Available 
  Consistency 一致性                 基本可用
  Isolation   隔离性               Soft state
  Durability  持久性                 软状态(允许中间状态)
                                   Eventually consistent
                                     最终一致
  
  设计哲学:                        设计哲学:
  "要么全做,要么全不做"              "先做了再说,最终会对"
  "宁可慢,不能错"                   "宁可暂时不一致,也不能停"

BASE 三要素拆解:

  1. Basically Available(基本可用):系统在出现故障时,允许部分功能降级,但核心功能仍然可用。比如双 11 高峰期,商品详情页的推荐模块加载不出来(降级),但下单功能正常。

  2. Soft State(软状态):允许系统中的数据存在中间状态,不同节点的数据副本可以暂时不一致。比如你下单后,订单服务已经记录了订单,但库存服务还没扣减——这个中间状态是被允许的。

  3. Eventually Consistent(最终一致性):经过一段时间的同步后,所有节点的数据最终会达成一致。这个"一段时间"可能是毫秒级(Redis 主从同步),也可能是秒级(DNS 传播),但一定会收敛。

ACID vs BASE 选型(实际场景):

  ═══════════════════════════════════════════
  场景              选 ACID 还是 BASE?
  ═══════════════════════════════════════════
  银行转账           ACID(不能出错)
  订单创建           ACID(金额相关)
  库存展示           BASE(显示稍旧的库存无妨)
  用户动态 Feed       BASE(晚几秒显示可接受)
  购物车             BASE(不同设备稍有延迟)
  支付回调           ACID + 幂等(第 4 章)
  ═══════════════════════════════════════════

💡 工程师的务实态度:不要把 ACID 和 BASE 当作信仰。它们是工具,不是宗教。同一个系统里,支付模块用 ACID,内容模块用 BASE,这才是正常的架构设计。

2.4 现实中的 CAP 权衡:Redis / PostgreSQL / DynamoDB

理论讲完了,来看三个你大概率用过的真实系统是怎么做选择的:

系统CAP 倾向一致性级别权衡思路
Redis(主从模式)AP最终一致主节点异步复制到从节点,主挂了可能丢几秒数据
Redis(Cluster 模式)CP强一致(部分)写入只在主分片,分区时拒绝写入
PostgreSQL(同步复制)CP线性一致写入等待至少一个从节点确认,主从强一致
PostgreSQL(异步复制)AP最终一致写入不等从节点确认,主挂了可能丢数据
DynamoDBAP(默认)最终一致默认读取最终一致,可选强一致读(加钱+加延迟)
etcd / ZooKeeperCP线性一致Raft/ZAB 共识协议,牺牲可用性保一致性
PostgreSQL 一致性级别配置(一行代码的区别):

  -- 异步复制(AP):写入不等从节点确认
  SET synchronous_commit = off;  -- 存在数据丢失窗口
  
  -- 同步复制(CP):写入必须等至少 1 个从节点确认
  SET synchronous_commit = on;   -- 更安全但更慢(+2-5ms)
  
  → 同一个数据库,一个配置参数就能在 CP 和 AP 之间切换。
    这就是为什么说 CAP 不是"选一个阵营",而是"按需调节"。
DynamoDB 的读取一致性选项:

  # 默认:最终一致读(便宜、快)
  response = table.get_item(Key={"id": "123"})
  
  # 可选:强一致读(贵 2x、慢 ~2x)
  response = table.get_item(
      Key={"id": "123"},
      ConsistentRead=True  # 保证读到最新写入
  )
  
  → DynamoDB 让你在每次 API 调用时选择一致性级别
    "热门商品浏览"不传 ConsistentRead → 快
    "订单查询"传 ConsistentRead=True → 准

💡 核心认知转变:现代分布式数据库几乎都不是"固定 CP"或"固定 AP"——它们通常提供可调节的一致性旋钮,让你按业务场景灵活选择。理解了这一点,CAP 定理才算真正学到手。

第 2 章核心知识回顾:

概念一句话解释
CAP 定理P 是必选项,网络分区时在 C(一致性)和 A(可用性)之间做取舍
一致性光谱从线性一致(最强最慢)到最终一致(最弱最快),按业务需求选择
ACID vs BASEACID 追求强一致(适合金融),BASE 拥抱最终一致(适合内容/社交)
按操作粒度选择同一系统不同操作可以用不同一致性级别,不要一刀切
旋钮式一致性现代数据库(PG/DynamoDB)允许在 API 层面按请求选择一致性强度

3. 分布式 ID 生成

在单机数据库的世界里,AUTO_INCREMENT 是最简单、最好用的主键方案。但当你的系统拆成了多个服务、多个数据库实例,这个看似无害的小功能就成了第一个需要你亲自解决的分布式问题。

3.1 为什么自增主键在分布式环境会崩

自增主键在分布式环境有三个致命问题:

问题 1:ID 冲突

  数据库 A(自增从 1 开始):1, 2, 3, 4, 5...
  数据库 B(自增从 1 开始):1, 2, 3, 4, 5...
  
  → 两个库生成了相同的 ID!
  → 数据合并时一定冲突
  
  传统"修复"方案:
  数据库 A 只用奇数:1, 3, 5, 7...
  数据库 B 只用偶数:2, 4, 6, 8...
  
  → 能用,但如果要加第三个库呢?
  → 要全部改步长,已有数据怎么办?
  → 不可扩展,是个死胡同


问题 2:信息泄露

  你的订单号是 /orders/10086
  → 攻击者试 /orders/10085, /orders/10087
  → 轻松遍历所有订单
  → 还能推算出你的日均订单量


问题 3:性能瓶颈

  如果所有服务都去同一个数据库取自增 ID:
  → 这个数据库变成全局单点(SPOF)
  → 高并发下锁竞争严重
  → 它一挂,全系统 ID 分配停摆

3.2 UUID:简单但有代价

UUID(Universally Unique Identifier)是最简单的分布式 ID 方案——每台机器本地生成,不需要任何协调。

python
"""UUID 生成示例"""
import uuid

# 最常用:UUID v4(随机生成,128 位)
order_id = str(uuid.uuid4())
# → "f47ac10b-58cc-4372-a567-0e02b2c3d479"

# UUID v7(2024 年起推荐,时间有序 + 随机)
# Python 3.12+ 原生支持
order_id = str(uuid.uuid7())
# → "018f3e5c-4b2a-7000-8000-1a2b3c4d5e6f"
# 前 48 位是毫秒级时间戳,天然有序
维度UUID v4UUID v7自增 ID
唯一性✅ 几乎不可能冲突✅ 几乎不可能冲突❌ 多库冲突
无中心化✅ 本地生成✅ 本地生成❌ 依赖数据库
可排序❌ 完全随机✅ 按时间有序✅ 天然有序
长度❌ 36 字符 / 16 字节❌ 36 字符 / 16 字节✅ 4-8 字节
索引性能❌ B+ 树碎片化⚠️ 好于 v4,仍然偏大✅ 最优
可读性❌ 人类看不懂❌ 人类看不懂✅ 一眼就知道顺序
UUID v4 对 B+ 树索引的性能陷阱:

  自增 ID 写入 B+ 树:
  ═══════════════════════
  1, 2, 3, 4, 5, 6, 7, 8 → 顺序追加到叶子节点末尾
  → 几乎不产生页分裂(Page Split)
  → 写入极快

  UUID v4 写入 B+ 树:
  ═══════════════════════
  f47a..., 0e02..., a567..., 58cc... → 随机插入到各个叶子节点
  → 大量页分裂、随机 IO
  → 写入性能比自增 ID 差 2-5 倍
  → 表越大,性能差距越明显

  UUID v7 部分解决了这个问题:
  → 前 48 位是时间戳,保证大致有序
  → 但 16 字节长度仍然比 8 字节 bigint 占更多索引空间

💡 选型建议:如果你的表预期不超过千万行,且不需要从 ID 推断时间顺序,UUID v7 是最省心的方案——不需要任何外部依赖,本地就能生成。但如果你的系统需要**高写入吞吐(>10K/s)**且有严格的索引性能要求,继续往下看雪花算法。

3.3 雪花算法(Snowflake):原理与 Python 实现

Twitter 在 2010 年开源了 Snowflake 算法,用一个 64 位整数编码出全局唯一、按时间有序、可反解的 ID。它是目前分布式 ID 领域的"工业标准"。

Snowflake ID 的 64 位结构:

  0 | 00000000 00000000 00000000 00000000 00000000 0 | 00000 | 00000 | 000000000000
  │ ├──────────────── 41 位 ────────────────────────┤ │ 5位 │ │ 5位 │ │── 12 位 ──│
  │                                                   │       │       │
  │            毫秒级时间戳                           数据中心  机器ID   序列号
  │            (可用 69 年)                          (0-31)  (0-31)  (0-4095)

  符号位(始终为 0)

  → 同一毫秒内,同一台机器可以生成 4096 个不同 ID
  → 1024 台机器(32 个数据中心 × 32 台机器)可以并行生成
  → ID 天然按时间有序,可以直接用作 B+ 树索引

Python 完整实现:

python
"""snowflake.py - 雪花算法 Python 实现"""
import time
import threading

class SnowflakeGenerator:
    """Twitter Snowflake ID 生成器"""
    
    # 起始时间戳(2024-01-01 00:00:00 UTC 的毫秒数)
    EPOCH = 1704067200000
    
    # 各部分的位数分配
    WORKER_ID_BITS = 5       # 机器 ID
    DATACENTER_ID_BITS = 5   # 数据中心 ID
    SEQUENCE_BITS = 12       # 毫秒内序列号
    
    # 最大值
    MAX_WORKER_ID = (1 << WORKER_ID_BITS) - 1        # 31
    MAX_DATACENTER_ID = (1 << DATACENTER_ID_BITS) - 1  # 31
    MAX_SEQUENCE = (1 << SEQUENCE_BITS) - 1            # 4095
    
    # 位移量
    WORKER_SHIFT = SEQUENCE_BITS                       # 12
    DATACENTER_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS  # 17
    TIMESTAMP_SHIFT = DATACENTER_SHIFT + DATACENTER_ID_BITS  # 22

    def __init__(self, worker_id: int, datacenter_id: int = 0):
        if worker_id > self.MAX_WORKER_ID or worker_id < 0:
            raise ValueError(f"worker_id 必须在 0-{self.MAX_WORKER_ID} 之间")
        if datacenter_id > self.MAX_DATACENTER_ID or datacenter_id < 0:
            raise ValueError(f"datacenter_id 必须在 0-{self.MAX_DATACENTER_ID} 之间")
        
        self.worker_id = worker_id
        self.datacenter_id = datacenter_id
        self.sequence = 0
        self.last_timestamp = -1
        self._lock = threading.Lock()

    def generate(self) -> int:
        """生成一个全局唯一的 Snowflake ID"""
        with self._lock:
            timestamp = self._current_millis()
            
            # 🔥 时钟回拨防御
            if timestamp < self.last_timestamp:
                raise RuntimeError(
                    f"时钟回拨!拒绝生成 ID,回拨了 "
                    f"{self.last_timestamp - timestamp}ms"
                )
            
            if timestamp == self.last_timestamp:
                # 同一毫秒内,序列号递增
                self.sequence = (self.sequence + 1) & self.MAX_SEQUENCE
                if self.sequence == 0:
                    # 序列号溢出(同一毫秒超过 4096 个),等到下一毫秒
                    timestamp = self._wait_next_millis(self.last_timestamp)
            else:
                # 新的毫秒,序列号归零
                self.sequence = 0
            
            self.last_timestamp = timestamp
            
            # 组装 64 位 ID
            return (
                ((timestamp - self.EPOCH) << self.TIMESTAMP_SHIFT)
                | (self.datacenter_id << self.DATACENTER_SHIFT)
                | (self.worker_id << self.WORKER_SHIFT)
                | self.sequence
            )

    def _current_millis(self) -> int:
        return int(time.time() * 1000)

    def _wait_next_millis(self, last: int) -> int:
        ts = self._current_millis()
        while ts <= last:
            ts = self._current_millis()
        return ts

# 使用示例
gen = SnowflakeGenerator(worker_id=1, datacenter_id=0)
for _ in range(5):
    print(gen.generate())
# → 输出类似:7168912345088000001, 7168912345088000002, ...
从 Snowflake ID 反解信息:

  id = 7168912345088000001
  
  timestamp = (id >> 22) + EPOCH
  → 2025-04-11 12:30:00.123 UTC(生成时间)
  
  datacenter = (id >> 17) & 31
  → 0(数据中心编号)
  
  worker = (id >> 12) & 31
  → 1(机器编号)
  
  sequence = id & 4095
  → 1(毫秒内第 2 个 ID)
  
  → 一个小小的 8 字节整数,藏了时间、机器、序号三重信息

💡 时钟回拨是 Snowflake 最大的隐患。NTP 时间同步可能导致系统时钟突然往回跳几毫秒甚至几秒。上面的代码选择了最保守的策略——直接拒绝生成 ID。生产环境中,美团 Leaf 的做法是维护一个"最后生成时间"的持久化存储,回拨超过阈值就告警但仍然用 last_timestamp 继续生成。

3.4 生产方案对比:号段模式 / Redis 自增 / 数据库序列

除了 Snowflake,还有几种在生产中常见的分布式 ID 方案。各有适用场景:

方案 1:号段模式(Segment Mode)

核心思路:不是每次需要 ID 都去中心节点取,而是批量取一段,用完了再取。

python
"""号段模式示意"""
class SegmentIDGenerator:
    """每次从数据库取一段号段(如 1000 个),本地消费"""
    
    def __init__(self, db, biz_tag: str, step: int = 1000):
        self.db = db
        self.biz_tag = biz_tag
        self.step = step
        self.current_id = 0
        self.max_id = 0
    
    def next_id(self) -> int:
        if self.current_id >= self.max_id:
            # 号段用完,去数据库取下一批
            # UPDATE id_alloc SET max_id = max_id + step 
            #   WHERE biz_tag = ? RETURNING max_id
            self.max_id = self._fetch_next_segment()
            self.current_id = self.max_id - self.step
        
        self.current_id += 1
        return self.current_id

方案 2:Redis INCR

python
"""Redis 自增 ID"""
import redis

r = redis.Redis()

def next_id(biz: str) -> int:
    return r.incr(f"id:{biz}")  # 原子自增,天然有序

方案 3:数据库序列(PostgreSQL Sequence)

sql
-- PostgreSQL 创建全局序列
CREATE SEQUENCE order_id_seq START 1 INCREMENT 1;

-- 获取下一个 ID
SELECT nextval('order_id_seq');  -- → 1, 2, 3, ...

四种方案横向对比:

方案有序性性能可用性复杂度适用场景
Snowflake✅ 时间有序极高(本地生成)高(无中心依赖)中(需管理 worker_id)高并发、需要时间信息
号段模式✅ 严格递增高(本地消费)中(依赖 DB 但低频)对连续递增有要求
Redis INCR✅ 严格递增高(Redis 10万 QPS)中(Redis 是 SPOF)快速原型、中等规模
UUID v7✅ 大致有序极高(本地生成)极高(零依赖)极低不关心 ID 长度的场景
选型决策树:

  你需要严格递增的 ID 吗?(1, 2, 3, 4...)
  ├── 是 → 号段模式 或 Redis INCR
  │        ├── 并发 > 5万/s → 号段模式(减少网络调用)
  │        └── 并发 < 5万/s → Redis INCR(最简单)
  └── 否 → 你需要从 ID 里提取时间、机器信息吗?
            ├── 是 → Snowflake(8 字节,信息密度最高)
            └── 否 → UUID v7(零依赖,最省心)

第 3 章核心知识回顾:

概念一句话解释
自增 ID 三大问题多库冲突、信息泄露、单点瓶颈
UUID v72024 年推荐的新标准,时间有序 + 随机,零依赖但 16 字节偏大
Snowflake64 位整数 = 41 位时间 + 10 位机器 + 12 位序号,单机 4096/ms
时钟回拨Snowflake 最大隐患,NTP 同步可能导致时间倒退,需特殊处理
号段模式批量取号,本地消费,兼顾严格递增和高性能

4. 幂等性设计

第 1 章的八大谬误第一条就是"网络是可靠的"。在不可靠的网络中,重试是必然的——请求超时了你不知道服务端到底执行了没有,只能重发。但如果接口不是幂等的,重试就意味着重复执行,这在金融场景中是灾难性的。本章讲透幂等性的原理和四种实现模式。

4.1 幂等性:分布式系统的安全网

什么是幂等?

一个操作执行 1 次和执行 N 次的效果完全相同,就叫幂等。数学表达:f(f(x)) = f(x)

日常生活中的幂等:

  幂等操作:
  ═══════════════════════
  • 电梯按钮:按 1 次和按 10 次,电梯都只到那一层
  • 设置空调温度:设 26°C,不管设几次都是 26°C
  • DELETE /users/123:删除用户 123,删 1 次和删 10 次效果一样
  
  非幂等操作:
  ═══════════════════════
  • 转账 100 元:转 1 次是 -100,转 2 次是 -200
  • 点赞 +1:每次调用都会多 1
  • INSERT INTO orders:每次调用都会多一条记录

HTTP 方法的天然幂等性:

方法幂等?说明
GET✅ 天然幂等读操作,不改变状态
PUT✅ 天然幂等"替换"语义:PUT 同一个资源 N 次结果一样
DELETE✅ 天然幂等删除同一个资源 N 次,结果都是"不存在"
PATCH⚠️ 视实现如果是"设置 age=25"→ 幂等;如果是"age+1"→ 非幂等
POST❌ 天然非幂等每次调用可能创建新资源

💡 关键认知:在分布式系统中,所有可能被重试的操作都必须设计为幂等的。这不是可选项——网关层的自动重试、消息队列的至少一次投递、用户的手动双击,都会导致重复请求。

4.2 实现幂等的四种经典模式

模式 1:幂等 Token(Idempotency Key)

客户端在请求前先获取一个唯一 Token,请求时带上 Token,服务端用 Token 去重。

python
"""幂等 Token 实现(FastAPI + Redis)"""
import uuid
import redis
from fastapi import FastAPI, Header, HTTPException

app = FastAPI()
r = redis.Redis()

@app.post("/orders")
async def create_order(
    order_data: dict,
    idempotency_key: str = Header(...),  # 客户端必须传
):
    # 1. 检查这个 Key 是否已经处理过
    cached = r.get(f"idem:{idempotency_key}")
    if cached:
        return {"status": "duplicate", "order_id": cached.decode()}
    
    # 2. 第一次见到这个 Key,处理业务
    order_id = str(uuid.uuid4())
    # ... 创建订单的业务逻辑 ...
    
    # 3. 处理完后,缓存结果(TTL 24 小时)
    r.setex(f"idem:{idempotency_key}", 86400, order_id)
    
    return {"status": "created", "order_id": order_id}

模式 2:数据库唯一约束

利用数据库的 UNIQUE 约束天然防重:

sql
-- 订单表设置业务唯一键
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    order_no VARCHAR(64) UNIQUE,  -- 🔥 唯一约束
    user_id BIGINT,
    amount DECIMAL(10,2)
);

-- 重复插入会直接报错,不会产生重复数据
INSERT INTO orders (order_no, user_id, amount) 
VALUES ('ORD-2025-001', 123, 99.00)
ON CONFLICT (order_no) DO NOTHING;  -- PostgreSQL 语法:冲突则忽略

模式 3:乐观锁(版本号)

每次更新时检查版本号,防止重复更新:

sql
-- 更新时带版本号
UPDATE accounts 
SET balance = balance - 100, version = version + 1
WHERE user_id = 123 AND version = 5;  -- 只在版本匹配时执行

-- 如果第二次重试到达,version 已经变成 6,WHERE 不匹配
-- → 影响行数 = 0,不会重复扣款

模式 4:状态机防重

业务状态只能单向流转,天然防止重复操作:

订单状态机(只能向前,不能回退):

  待支付 → 已支付 → 已发货 → 已完成
                                ↘ 已取消
  
  如果订单已经是"已支付"状态,
  再次收到"支付成功"回调 → 检查状态 → 已经是"已支付"
  → 直接返回成功,不重复扣款

4.3 实战:支付系统的防重复扣款设计

支付是幂等性设计最典型的练兵场——多扣一次钱,客户投诉;少扣一次钱,公司亏钱。真实的支付系统通常同时使用多种幂等模式做多层防御:

支付回调的多层幂等防线:

  第三方支付平台(微信/支付宝)回调你的服务器:
  "订单 ORD-001 支付成功,交易号 TXN-ABC"
  
  可能回调多次(平台确保至少投递一次)

  ┌────────────────────────────────────────────┐
  │ 第 1 层:幂等 Token(交易号去重)              │
  │ Redis: SETNX "pay:TXN-ABC" → 已存在?跳过    │
  ├────────────────────────────────────────────┤
  │ 第 2 层:状态机检查                           │
  │ 订单状态 == "已支付"?→ 跳过                   │
  ├────────────────────────────────────────────┤
  │ 第 3 层:乐观锁 + 数据库原子操作               │
  │ UPDATE orders SET status='paid', version=2  │
  │ WHERE id='ORD-001' AND status='pending'     │
  │ AND version=1                               │
  │ → 影响行数 == 0?说明已被处理,跳过             │
  ├────────────────────────────────────────────┤
  │ 第 4 层:流水表唯一约束                        │
  │ INSERT INTO pay_logs (txn_no, ...) → 冲突则忽略│
  └────────────────────────────────────────────┘
python
"""支付回调的多层幂等实现"""
async def handle_payment_callback(txn_no: str, order_id: str, amount: float):
    # 第 1 层:Redis 快速去重
    if not redis.set(f"pay:{txn_no}", "1", nx=True, ex=86400):
        return {"status": "duplicate"}  # 已处理过
    
    # 第 2-3 层:状态机 + 乐观锁(原子操作)
    result = await db.execute("""
        UPDATE orders 
        SET status = 'paid', 
            paid_at = NOW(), 
            txn_no = :txn_no,
            version = version + 1
        WHERE order_id = :order_id 
          AND status = 'pending'     -- 状态机:只有待支付才能变成已支付
          AND amount = :amount       -- 金额校验
    """, {"txn_no": txn_no, "order_id": order_id, "amount": amount})
    
    if result.rowcount == 0:
        return {"status": "already_processed"}
    
    # 第 4 层:记录支付流水(唯一约束兜底)
    await db.execute("""
        INSERT INTO payment_logs (txn_no, order_id, amount, created_at)
        VALUES (:txn_no, :order_id, :amount, NOW())
        ON CONFLICT (txn_no) DO NOTHING
    """, {"txn_no": txn_no, "order_id": order_id, "amount": amount})
    
    return {"status": "success"}

💡 为什么要四层?因为每一层都可能失败。Redis 宕机了第 1 层就没了;数据库主从延迟可能导致第 3 层在从节点上读到旧数据。多层防御的思路是:任何一层单独失效,其他层都能兜住

4.4 消息队列的 Exactly-Once 语义

消息队列的投递保证有三种语义,理解它们对幂等性设计至关重要:

三种投递语义:

  At-most-once(最多一次)
  ═══════════════════════
  发了就不管了,消息可能丢失
  → 日志收集、监控指标(丢一条无所谓)
  
  At-least-once(至少一次)⭐ 最常用
  ═══════════════════════
  确保消息至少被投递一次,但可能重复投递
  → Kafka、RabbitMQ 的默认模式
  → 消费端必须做幂等处理!
  
  Exactly-once(精确一次)
  ═══════════════════════
  每条消息精确处理一次,不多不少
  → 理论上的圣杯,实践中极难实现
  → Kafka 通过"幂等生产者 + 事务"接近实现

Exactly-Once 的工程真相:

严格的端到端 Exactly-Once 几乎是不可能的。业界的实际做法是:

"伪 Exactly-Once" = At-Least-Once + 消费端幂等

  生产者 ──(至少一次)──▶ 消息队列 ──(至少一次)──▶ 消费者

                                              消费者做幂等
                                              (去重表/唯一约束)

                                              效果等价于
                                              Exactly-Once ✅
python
"""Kafka 消费端幂等处理"""
async def consume_order_message(message):
    msg_id = message.headers.get("message-id")
    
    # 用消息 ID 做去重(和支付回调一样的思路)
    result = await db.execute("""
        INSERT INTO processed_messages (msg_id, processed_at)
        VALUES (:msg_id, NOW())
        ON CONFLICT (msg_id) DO NOTHING
        RETURNING msg_id
    """, {"msg_id": msg_id})
    
    if result.rowcount == 0:
        # 已处理过,直接 ACK
        return
    
    # 首次处理,执行业务逻辑
    await process_order(message.value)

💡 务实建议:不要追求队列层面的 Exactly-Once(配置复杂、性能差)。直接用 At-Least-Once + 消费端幂等,这是工业界的标准做法,Kafka、RabbitMQ、RocketMQ 都推荐这条路。

第 4 章核心知识回顾:

概念一句话解释
幂等性操作执行 1 次和 N 次效果相同,分布式环境中所有可重试操作必须幂等
幂等 Token客户端生成唯一 Key,服务端用 Redis SETNX 去重
乐观锁用 version 字段做 CAS 更新,版本不匹配则不执行
状态机防重状态只能单向流转,重复请求命中已完成状态直接跳过
Exactly-Once实际做法是 At-Least-Once + 消费端幂等,不要追求队列层面的精确一次

5. 数据分片与路由

当你的 PostgreSQL 单表突破千万行,查询开始变慢;当你的 Redis 单实例内存逼近 64GB 上限;当你的用户数据跨越了一台机器的存储容量——你需要分片(Sharding):把一份数据拆成多份,分散到多台机器上。

5.1 分片的动机与策略

三种分片策略:

策略 1:哈希分片(Hash Sharding)

  shard_id = hash(user_id) % N
  
  user_id=101 → hash=7   → 7 % 4 = 3 → 分片 3
  user_id=202 → hash=12  → 12 % 4 = 0 → 分片 0
  user_id=303 → hash=5   → 5 % 4 = 1 → 分片 1
  
  优点:数据分布均匀
  缺点:扩容时需要大量数据迁移(rehash)
        范围查询困难("查 ID 100-200 的用户"要扫所有分片)


策略 2:范围分片(Range Sharding)

  user_id 1-100万      → 分片 0
  user_id 100万-200万   → 分片 1
  user_id 200万-300万   → 分片 2
  
  优点:范围查询高效,只需查对应分片
  缺点:热点问题(新用户都写入最后一个分片)


策略 3:目录分片(Directory Sharding)

  维护一张映射表:
  ┌──────────┬──────────┐
  │ user_id  │ shard_id │
  ├──────────┼──────────┤
  │ 101      │ 2        │
  │ 202      │ 0        │
  │ 303      │ 1        │
  └──────────┴──────────┘
  
  优点:完全灵活,可以把任何数据放到任何分片
  缺点:映射表本身成为单点瓶颈
策略数据均匀范围查询扩容成本适用场景
哈希分片✅ 均匀❌ 全片扫描❌ 大量 rehash用户数据、订单
范围分片⚠️ 可能热点✅ 高效✅ 只移一段时序数据、日志
目录分片✅ 可控✅ 查表即可✅ 改表即可多租户 SaaS

5.2 一致性哈希:原理与 Python 实现

普通哈希分片的致命问题是:当你增加或减少节点时,几乎所有数据都要重新分布。比如 hash(key) % 4 变成 hash(key) % 5,超过 80% 的 key 会映射到不同的节点。

一致性哈希解决了这个问题——增减节点时,只需迁移 1/N 的数据。

一致性哈希的核心思想:

  把哈希值空间想象成一个环(0 → 2^32-1 → 回到 0):
  
            0
          ╱    ╲
        N3      N1      ← 节点映射到环上的位置
       │          │
       │    环    │
       │          │
        N2      
          ╲    ╱
           ...
  
  数据怎么找节点?
  → hash(key) 得到一个值
  → 在环上顺时针找到第一个节点
  → 那就是这个 key 的归属节点
  
  加一个新节点 N4?
  → N4 插入环上某个位置
  → 只有 N4 和它逆时针前一个节点之间的数据需要迁移
  → 其他数据完全不动 ✅

Python 完整实现:

python
"""consistent_hash.py - 一致性哈希(含虚拟节点)"""
import hashlib
from bisect import bisect_right

class ConsistentHashRing:
    """一致性哈希环"""
    
    def __init__(self, replicas: int = 150):
        self.replicas = replicas  # 每个物理节点的虚拟节点数
        self.ring: list[int] = []       # 排序后的哈希值列表
        self.nodes: dict[int, str] = {} # 哈希值 → 物理节点名
    
    def _hash(self, key: str) -> int:
        """用 MD5 生成稳定的哈希值"""
        return int(hashlib.md5(key.encode()).hexdigest(), 16)
    
    def add_node(self, node: str):
        """添加一个物理节点(同时创建多个虚拟节点)"""
        for i in range(self.replicas):
            virtual_key = f"{node}:v{i}"
            h = self._hash(virtual_key)
            self.ring.append(h)
            self.nodes[h] = node
        self.ring.sort()
    
    def remove_node(self, node: str):
        """移除一个物理节点"""
        for i in range(self.replicas):
            h = self._hash(f"{node}:v{i}")
            self.ring.remove(h)
            del self.nodes[h]
    
    def get_node(self, key: str) -> str:
        """给定 key,返回它应该归属的物理节点"""
        if not self.ring:
            raise RuntimeError("哈希环为空")
        h = self._hash(key)
        # 在环上顺时针找到第一个节点
        idx = bisect_right(self.ring, h) % len(self.ring)
        return self.nodes[self.ring[idx]]

# 使用示例
ring = ConsistentHashRing(replicas=150)
ring.add_node("db-server-1")
ring.add_node("db-server-2")
ring.add_node("db-server-3")

print(ring.get_node("user:1001"))  # → "db-server-2"
print(ring.get_node("user:1002"))  # → "db-server-1"

# 加一台新机器,只有 ~1/4 的 key 会迁移
ring.add_node("db-server-4")

💡 为什么需要虚拟节点? 如果每个物理节点只在环上占一个点,数据分布极不均匀——可能 90% 的数据落在一个节点上。虚拟节点(上面代码中 replicas=150)让每个物理节点在环上占 150 个点,类似于"撒芝麻",分布就均匀了。

5.3 分片后的代价:跨片查询、再平衡、全局排序

分片不是免费的。一旦你把数据拆开,很多在单库上简单操作就会变得极其复杂:

代价 1:跨片查询(Scatter-Gather)

查询"最近 7 天所有用户的订单总额":

  单库时代:
  SELECT SUM(amount) FROM orders WHERE created_at > '2025-04-04';
  → 1 条 SQL,直接返回

  分片后(4 个分片):
  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
  │ Shard 0  │ │ Shard 1  │ │ Shard 2  │ │ Shard 3  │
  │ SUM=1200 │ │ SUM=800  │ │ SUM=950  │ │ SUM=1050 │
  └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
       └────────────┼────────────┼────────────┘

              聚合层:1200 + 800 + 950 + 1050 = 4000
  
  → 4 条并行 SQL + 1 次聚合,延迟取决于最慢的分片

代价 2:全局排序与分页

"查订单列表,按金额降序,第 2 页(每页 20 条)"

  单库:ORDER BY amount DESC LIMIT 20 OFFSET 20

  分片后的噩梦:
  ═══════════════════════════════════
  每个分片都要返回前 40 条(OFFSET+LIMIT)
  → 4 个分片 × 40 条 = 160 条数据传到聚合层
  → 聚合层再全局排序,取第 21-40 条
  
  翻到第 100 页时:
  → 每个分片返回前 2000 条
  → 4 × 2000 = 8000 条数据在内存中排序
  → 性能急剧恶化 📉
  
  解决方案:
  → 禁止深度分页,改用游标分页(WHERE id > last_id)
  → 或限制用户最多翻到第 50 页

代价 3:数据再平衡

当你需要扩容(3 台 → 5 台),部分数据必须从旧节点迁移到新节点。这个过程叫再平衡(Rebalancing),是分片系统中最危险的操作——迁移期间数据可能不一致、服务可能中断。

💡 分片的黄金法则:能不分就不分。先做垂直拆分(把不同的表放到不同的库),再做读写分离(主写从读),实在扛不住了再考虑水平分片。很多系统在单表 5000 万行的时候还跑得好好的——别提前焦虑。

5.4 分库分表实践指南

渐进式扩展路径(别一上来就分片):

数据库扩展的正确顺序:

  第 1 步:单库优化(能撑到千万级)
  ═══════════════════════════════════
  → 加索引、优化慢 SQL、升级硬件
  → 单表 1000 万行以内通常没问题
  
  第 2 步:读写分离(能撑到亿级读)
  ═══════════════════════════════════
  → 主库写、从库读
  → 读多写少的场景效果极好
  
  第 3 步:垂直拆分(按业务拆库)
  ═══════════════════════════════════
  → 用户表、订单表、商品表拆到不同数据库
  → 减少单库的表数量和连接压力
  
  第 4 步:水平分片(终极方案)
  ═══════════════════════════════════
  → 同一张表按分片键拆到多个库
  → 复杂度最高,只在前三步都撑不住时使用

分片键选择原则:

原则说明示例
高基数分片键的值域要足够大,分布才均匀✅ user_id(几百万种),❌ gender(只有 2 种)
高频查询绝大多数查询都应该带分片键订单表按 user_id 分片 → "查我的订单"只查一个分片
避免跨片关联查询的表用同一个分片键订单表和订单详情表都按 user_id 分片 → JOIN 不跨片

💡 最重要的一条建议:分片是不可逆的。一旦选错了分片键,想换就意味着全量数据迁移。所以在做分片之前,先花足够多的时间分析你的查询模式——90% 的查询都是怎么查的?按用户?按时间?按地区?那个字段就是你的分片键。

第 5 章核心知识回顾:

概念一句话解释
哈希分片hash(key) % N,均匀但扩容要 rehash
范围分片按值域段划分,范围查询友好但有热点问题
一致性哈希环形空间 + 虚拟节点,增删节点只迁移 1/N 数据
跨片查询Scatter-Gather 模式,并行查所有分片再聚合,深度分页性能差
分片键选择高基数、高频查询、避免跨片,选错不可逆

6. 分布式共识与协调

前面几章讲了数据分片、幂等性、一致性模型——这些都依赖一个更底层的能力:多个节点如何对某件事达成一致。这就是分布式共识(Consensus)问题,它是分布式系统中最深刻也最优雅的理论。

6.1 共识问题:多节点如何达成一致

共识问题的本质:

场景:3 台数据库服务器需要决定"谁是主节点"

  Server A:"我延迟最低,我当主!"
  Server B:"我的数据最全,凭什么你当主?"
  Server C:"我跟 A 网络断了,不知道 A 说了什么……"

  问题:
  ═══════════════════════════════════
  1. 如何让所有正常节点达成一致的决定?
  2. 即使部分节点挂了,剩下的还能继续决策?
  3. 决定一旦做出,永远不会被推翻?

这就是共识算法要解决的三个核心属性:

  • 安全性(Safety):所有节点最终同意同一个值
  • 活性(Liveness):系统最终一定能做出决定(不会永远卡住)
  • 容错性(Fault Tolerance):少数节点挂了不影响共识达成

Paxos vs Raft:

维度PaxosRaft
提出者Leslie Lamport(1989)Diego Ongaro(2014)
难度极难理解(Lamport 自己用 25 年才让人理解)专为可理解性设计
工业实现Google Chubby、Spanneretcd、Consul、TiKV
核心差异无 Leader,多 Proposer 并发提案强 Leader 模式,简化了状态管理

💡 实际工程中几乎没人直接实现 Paxos。Raft 是"给工程师设计的 Paxos",我们重点讲 Raft。

6.2 Raft 协议:工程师能看懂的共识算法

Raft 把共识问题拆解成三个子问题,每个都可以独立理解:

子问题 1:Leader 选举

Raft 的三种角色:

  ┌──────────┐    ┌──────────┐    ┌──────────┐
  │  Leader  │    │ Follower │    │ Follower │
  │  (领导者) │───▶│  (追随者) │    │  (追随者) │
  │  唯一    │───▶│          │    │          │
  └──────────┘    └──────────┘    └──────────┘

                   收不到心跳?
                   超时后变成 Candidate


                  ┌──────────┐
                  │Candidate │
                  │ (候选者)  │ → 向其他节点拉票
                  └──────────┘ → 获得多数票 → 成为新 Leader

  选举规则:
  • 每个任期(Term)最多一个 Leader
  • 候选者需获得超过半数节点(N/2+1)的投票
  • 每个 Follower 每个 Term 只投一票(先到先得)
  • 心跳超时时间随机化(150-300ms),避免多人同时发起选举

子问题 2:日志复制

Leader 收到客户端写请求后的流程:

  客户端 ──── "SET x=5" ────▶ Leader
  
  Leader 的操作:
  ═══════════════════════════════════
  1. 把 "SET x=5" 写入自己的日志(但不执行)
  2. 并行发送 AppendEntries RPC 给所有 Follower
  3. 等待超过半数 Follower 确认收到(Quorum = N/2+1)
  4. 确认收到多数回复后,把日志标记为"已提交"
  5. 应用到状态机(执行 SET x=5)
  6. 返回客户端 "成功"
  
  3 节点集群:Leader + 2 Follower
  → 只要 1 个 Follower 确认(加上 Leader 自己 = 2 = 多数)
  → 即使另一个 Follower 挂了,也不影响

子问题 3:脑裂(Split Brain)防御

脑裂场景:

  网络分区把 5 个节点分成两组:
  
  ┌─────────────────┐    ┌──────────────┐
  │ A(Leader) B C    │    │    D  E      │
  │ 3 个节点         │    │ 2 个节点     │
  └─────────────────┘    └──────────────┘
  
  D 和 E 收不到 Leader A 的心跳
  → D 发起选举,但只有 2 票(D+E),不够 3 票
  → 选举失败,D 和 E 只能等待
  
  A 这边 3 个节点仍然是多数
  → A 继续当 Leader,正常处理写入
  → D/E 不能读到最新数据,但不会产生脑裂
  
  Raft 的安全保证:
  → 任何决策都需要 N/2+1 票
  → 两个分区不可能同时形成多数
  → 因此不可能出现两个 Leader 同时写入 ✅

💡 为什么是奇数节点? 3 节点容忍 1 个故障(Quorum=2),5 节点容忍 2 个故障(Quorum=3)。4 节点也只容忍 1 个故障(Quorum=3),和 3 节点一样——你白白多了一台机器的成本。所以共识集群通常部署 3 或 5 个节点。

6.3 分布式锁:Redis RedLock vs etcd Lease

分布式锁是共识算法最常见的应用场景——多个进程竞争同一个资源(库存扣减、定时任务去重),必须保证同一时刻只有一个进程能操作。

方案 1:Redis SETNX(最简单,但有风险)

python
"""Redis 分布式锁(基础版)"""
import redis
import uuid

r = redis.Redis()

def acquire_lock(name: str, ttl: int = 10) -> str | None:
    """尝试获取锁,返回锁标识"""
    token = str(uuid.uuid4())
    if r.set(f"lock:{name}", token, nx=True, ex=ttl):
        return token
    return None

def release_lock(name: str, token: str):
    """释放锁(必须验证 token,防止误删别人的锁)"""
    # 🔥 必须用 Lua 脚本保证原子性
    lua = """
    if redis.call('get', KEYS[1]) == ARGV[1] then
        return redis.call('del', KEYS[1])
    end
    return 0
    """
    r.eval(lua, 1, f"lock:{name}", token)
Redis 单节点锁的隐患:

  Master 上获取了锁 → Master 宕机
  → Sentinel 把 Slave 提升为 Master
  → 但 Slave 还没同步到这把锁
  → 另一个客户端在新 Master 上也获取了同名锁
  → 两个客户端同时持有锁!💥

方案 2:Redis RedLock(Antirez 设计)

为了解决单节点故障问题,Redis 作者 Antirez 提出了 RedLock:在 5 个独立的 Redis 实例上同时加锁,获得 3 个以上(多数)的锁才算成功。

方案 3:etcd Lease(基于 Raft 共识,最安全)

python
"""etcd 分布式锁(基于 Lease)"""
import etcd3

client = etcd3.client()

# 创建一个 10 秒的租约
lease = client.lease(ttl=10)

# 用 put_if_not_exists 实现锁
# etcd 基于 Raft 共识,写入是线性一致的
success, _ = client.transaction(
    compare=[client.transactions.create("lock/my-task") == 0],
    success=[client.transactions.put("lock/my-task", "holder-1", lease)],
    failure=[],
)

if success:
    print("获取锁成功")
    # ... 做业务 ...
    client.delete("lock/my-task")  # 释放
else:
    print("锁已被占用")

三种方案对比:

方案安全性性能复杂度适用场景
Redis SETNX⚠️ 主从切换可能丢锁极高缓存更新、非关键资源
Redis RedLock⚠️ 存在争议有一定安全要求但非金融级
etcd Lease✅ Raft 共识保证金融级、绝对不能出错的场景

💡 RedLock 的争议:分布式系统专家 Martin Kleppmann 发表了著名文章 "How to do distributed locking",指出 RedLock 在 GC 暂停和时钟漂移场景下仍然不安全。如果你的锁保护的是钱相关的操作,用 etcd 或 ZooKeeper,不要赌 RedLock。

6.4 实战:基于 etcd 的服务注册与发现

服务注册与发现是共识系统最核心的应用场景之一——微服务架构中,服务实例动态上下线,调用方怎么知道"该请求哪个 IP:Port"?

服务注册与发现的流程:

  服务启动时(注册):
  ═══════════════════════════════════
  用户服务实例 A 启动
  → 把自己的地址写入 etcd:
    PUT /services/user-service/instance-1 = "10.0.0.5:8080"
  → 附带一个 10 秒的 Lease(租约)
  → 每 3 秒续约一次(心跳)
  
  服务崩溃时(自动注销):
  ═══════════════════════════════════
  实例 A 宕机 → 无法续约
  → 10 秒后 Lease 过期
  → etcd 自动删除该实例的注册信息
  → 调用方发现实例 A 不在了,自动切到实例 B
python
"""service_registry.py - 基于 etcd 的服务注册"""
import etcd3
import threading

class ServiceRegistry:
    """服务注册(服务提供方使用)"""
    
    def __init__(self, service_name: str, instance_addr: str):
        self.client = etcd3.client()
        self.key = f"/services/{service_name}/{instance_addr}"
        self.addr = instance_addr
        self.lease = self.client.lease(ttl=10)
    
    def register(self):
        """注册服务并启动心跳"""
        self.client.put(self.key, self.addr, lease=self.lease)
        # 后台线程定期续约
        self._heartbeat_thread = threading.Thread(
            target=self._keep_alive, daemon=True
        )
        self._heartbeat_thread.start()
    
    def _keep_alive(self):
        """每 3 秒续约一次"""
        while True:
            self.lease.refresh()
            import time; time.sleep(3)
    
    def deregister(self):
        """优雅下线"""
        self.client.delete(self.key)


class ServiceDiscovery:
    """服务发现(调用方使用)"""
    
    def __init__(self, service_name: str):
        self.client = etcd3.client()
        self.prefix = f"/services/{service_name}/"
    
    def get_instances(self) -> list[str]:
        """获取所有可用实例地址"""
        instances = []
        for value, metadata in self.client.get_prefix(self.prefix):
            instances.append(value.decode())
        return instances

💡 为什么用 etcd 而不是 Redis? 因为 etcd 基于 Raft 共识,写入是线性一致的——不会出现"主节点注册成功但从节点看不到"的情况。服务发现是基础设施级别的组件,一旦出错就是全站故障,必须用最可靠的方案。

第 6 章核心知识回顾:

概念一句话解释
共识三属性安全性(意见一致)、活性(终会决定)、容错性(少数挂了不影响)
Raft 三子问题Leader 选举(随机超时 + 多数票)、日志复制(Quorum 确认)、脑裂防御(多数派不重叠)
奇数节点3 节点容 1 故障,5 节点容 2 故障;4 节点和 3 节点容错能力一样
分布式锁非关键用 Redis SETNX,金融级用 etcd Lease,RedLock 有争议
服务发现注册(PUT + Lease)→ 心跳续约 → 崩溃自动注销

7. 分布式事务

在单库时代,一个 BEGIN ... COMMIT 就能包裹所有操作。但当订单服务在数据库 A,库存服务在数据库 B,积分服务在数据库 C——一个"下单"动作横跨三个数据库,COMMIT 再也管不住了。

7.1 分布式事务的困境:为什么 ACID 不再够用

单库事务(简单、可靠):

  BEGIN;
    INSERT INTO orders (...);      -- 创建订单
    UPDATE inventory SET stock = stock - 1;  -- 扣库存
    UPDATE accounts SET points = points + 10; -- 加积分
  COMMIT;  -- 一起成功,或一起回滚
  → 数据库保证 ACID,你什么都不用操心


分布式事务(痛苦):

  订单服务 → 数据库 A:INSERT INTO orders (...)   ✅ 成功
  库存服务 → 数据库 B:UPDATE inventory SET ...   ✅ 成功
  积分服务 → 数据库 C:UPDATE accounts SET ...    ❌ 失败!
  
  问题:
  → 订单和库存已经写入了,积分失败了
  → 数据库 A 和 B 不知道 C 失败了
  → 没有一个全局的 COMMIT / ROLLBACK 来协调
  → 数据不一致了!

7.2 两阶段提交(2PC):理论优美,实践痛苦

2PC(Two-Phase Commit)是解决分布式事务最经典的协议。它引入一个**协调者(Coordinator)**来统一指挥所有参与者。

2PC 的两个阶段:

  第一阶段:准备(Prepare / Vote)
  ═══════════════════════════════════
  协调者 ──"你准备好了吗?"──▶ 参与者 A → "YES"
  协调者 ──"你准备好了吗?"──▶ 参与者 B → "YES"
  协调者 ──"你准备好了吗?"──▶ 参与者 C → "YES"
  
  → 每个参与者锁定资源、写 redo/undo 日志,但不提交
  
  第二阶段:提交(Commit / Abort)
  ═══════════════════════════════════
  如果全部 YES:
  协调者 ──"COMMIT!"──▶ A, B, C → 全部提交 ✅
  
  如果有一个 NO:
  协调者 ──"ABORT!"──▶ A, B, C → 全部回滚 ✅

2PC 的三个致命问题:

问题说明后果
同步阻塞第一阶段所有参与者锁定资源后等待协调者指令,这段时间资源不可用高并发下性能急剧下降
协调者单点协调者挂了,参与者不知道该提交还是回滚,永远卡住系统不可用
数据不一致协调者发出 COMMIT 后自己挂了,A 收到了但 B 没收到A 提交了,B 没提交
2PC 最可怕的场景:

  协调者发送 COMMIT 给 A → A 提交了
  协调者发送 COMMIT 给 B → 网络中断,B 没收到
  协调者宕机
  
  此时:
  A:已提交 ✅
  B:还在等(已锁定资源,不知该提交还是回滚)⏳
  协调者:死了 💀
  
  → B 只能无限等待,导致资源锁死
  → 这就是为什么 2PC 在互联网公司几乎没人用

💡 2PC 的工程现状:虽然 MySQL(XA 事务)和 PostgreSQL 都支持 2PC,但互联网公司极少在生产中使用它——延迟太高、锁太重。它更适合传统金融行业的批量结算场景(非实时)。互联网公司更倾向下面的 Saga 和事务消息。

7.3 Saga 模式:长事务的优雅解法

Saga 模式的核心思想非常朴素:不追求全局事务,而是把大事务拆成一串小事务,每个小事务都有对应的补偿操作。如果某一步失败,就按反方向逐个执行补偿。

Saga 的补偿链:

  正向操作(从左到右):
  ═══════════════════════════════════
  T1: 创建订单 → T2: 扣库存 → T3: 扣款 → T4: 加积分
  
  如果 T3(扣款)失败,反向补偿:
  ═══════════════════════════════════
  C2: 恢复库存 ← C1: 取消订单
  
  注意:
  → 每个 Ti 都有一个对应的 Ci(补偿操作)
  → 补偿不是"回滚",而是"反向业务操作"
  → T1 = 创建订单,C1 = 取消订单(不是 DELETE,是状态变更)

两种实现方式:

方式 1:编排式(Orchestration)— 有中央编排器

  ┌──────────┐
  │  编排器   │ ← 统一调度所有步骤
  └────┬─────┘
       ├──▶ 订单服务:创建订单
       ├──▶ 库存服务:扣库存
       ├──▶ 支付服务:扣款 ← 失败!
       ├──▶ 库存服务:恢复库存(补偿)
       └──▶ 订单服务:取消订单(补偿)
  
  优点:流程清晰、易调试、好监控
  缺点:编排器是单点、所有服务都耦合到编排器


方式 2:协同式(Choreography)— 事件驱动

  订单服务 ──发布事件──▶ "订单已创建"
  库存服务 ──监听事件──▶ 扣库存 → 发布 "库存已扣减"
  支付服务 ──监听事件──▶ 扣款 → 失败 → 发布 "扣款失败"
  库存服务 ──监听事件──▶ 恢复库存 → 发布 "库存已恢复"
  订单服务 ──监听事件──▶ 取消订单
  
  优点:无中心化、服务间松耦合
  缺点:流程分散、调试困难、容易出现环形依赖
方式适合场景步骤数复杂度
编排式流程明确、步骤 < 10 步3-7 步
协同式服务间高度解耦、团队独立开发2-4 步高(调试难)

💡 工程建议:如果你的团队不超过 30 人,业务流程不超过 5 步,用编排式。可读性和可维护性远比"架构优雅"重要。

7.4 事务消息与最终一致性方案

如果你的场景是"本地做了一件事,然后需要通知另一个服务也做一件事"——比如"订单创建成功后,通知积分服务加积分"——事务消息是最简单的最终一致性方案。

方案 1:本地消息表(最通用)

核心思路:把"发消息"和"本地事务"放在同一个数据库事务里

  BEGIN; -- 本地事务
    INSERT INTO orders (...);           -- 1. 业务操作
    INSERT INTO outbox (topic, payload);  -- 2. 写消息到本地表
  COMMIT;
  
  后台定时任务:
  → 扫描 outbox 表,把未发送的消息投递到消息队列
  → 投递成功后标记为已发送
  
  为什么这样做?
  → 步骤 1 和 2 在同一个事务里,要么都成功要么都失败
  → 即使消息队列暂时不可用,消息也不会丢(在数据库里)
  → 最终消息一定会被投递出去(定时任务重试)

方案 2:RocketMQ 事务消息(半消息)

RocketMQ 的事务消息流程:

  1. 生产者发送"半消息"到 MQ
     → MQ 收到但不投递给消费者(暂存)
  
  2. 生产者执行本地事务
     → 成功 → 告诉 MQ "COMMIT",MQ 投递消息
     → 失败 → 告诉 MQ "ROLLBACK",MQ 丢弃消息
  
  3. 如果生产者挂了(没回复 COMMIT/ROLLBACK)?
     → MQ 主动回查生产者:"你那笔事务到底成功没?"
     → 生产者根据本地事务状态回答

分布式事务选型决策树:

你的业务需要强一致性吗?(错一笔都不行)
├── 是 → 2PC / XA(接受高延迟和低吞吐)
│        或改为同一个数据库(消除分布式需求)
└── 否 → 你的业务流程有几步?
          ├── 2 步(A 做完通知 B)→ 事务消息/本地消息表
          └── 3+ 步 → Saga 模式
                      ├── 流程清晰 → 编排式 Saga
                      └── 团队独立 → 协同式 Saga

第 7 章核心知识回顾:

概念一句话解释
2PC协调者统一指挥 Prepare + Commit,理论完美但阻塞严重、有单点问题
Saga大事务拆小事务 + 补偿操作,失败时反方向逐个补偿
编排式 vs 协同式有中央编排器 vs 事件驱动,小团队优先选编排式
事务消息本地事务 + 消息发送绑定在一起,保证最终一致性
本地消息表最通用方案:业务数据和消息写在同一个事务里,后台异步投递

8. 容错与高可用

前面讨论了一致性、事务、锁——它们都假设"问题发生了,怎么处理"。本章关注的是更底层的问题:故障本身。在分布式系统中,问题不是"会不会出故障",而是"什么时候出故障"。设计系统时必须假设一切都会坏。

8.1 故障是常态:分布式系统的故障模型

分布式系统的故障分类:

  崩溃故障(Crash Failure)
  ═══════════════════════════════════
  节点突然停止工作,不再响应任何请求
  → 进程 OOM、硬件故障、内核 Panic
  → 最好处理:检测到不响应就切换
  
  拜占庭故障(Byzantine Failure)
  ═══════════════════════════════════
  节点给出错误/恶意的响应
  → 数据损坏、被入侵的节点
  → 最难处理:你不知道它在"骗你"
  → 区块链场景才需要考虑;内网通常忽略
  
  灰色故障(Gray Failure)⭐ 最常见也最阴险
  ═══════════════════════════════════
  节点"半死不活":有时候能响应,有时候超时
  → 网络抖动、GC 暂停、磁盘 IO 偶发高延迟
  → 健康检查说"正常",但实际请求频繁超时
  → 需要更精细的探活机制(不只是 ping)
故障类型检测难度处理方式典型场景
崩溃低(超时即可)故障转移服务器宕机
网络分区CAP 权衡机房间网线故障
灰色故障高(时好时坏)自适应超时 + 熔断GC 暂停、慢查询
拜占庭极高BFT 协议区块链、军事系统

8.2 超时、重试与指数退避

超时是分布式系统中最重要的参数——设短了会把正常的慢请求误判为失败;设长了会导致资源被长时间占用。

超时设置的经验法则:

  连接超时(Connect Timeout):
  → 同机房:100-500ms
  → 跨机房:500-2000ms
  → 超过这个时间还建不了连接 → 对端大概率有问题
  
  读取超时(Read Timeout):
  → 快接口(缓存查询):500ms-1s
  → 慢接口(数据库聚合):3-5s
  → 极慢接口(报表生成):10-30s(考虑异步化)
  
  黄金法则:超时 = P99 延迟 × 2
  → 如果 99% 的请求在 200ms 内完成
  → 超时设 400ms(覆盖 99% 场景 + 安全余量)

指数退避 + 随机抖动(Exponential Backoff with Jitter):

python
"""带抖动的指数退避重试"""
import asyncio
import random

async def retry_with_backoff(
    func,
    max_retries: int = 3,
    base_delay: float = 0.5,   # 首次重试等 0.5 秒
    max_delay: float = 30.0,   # 最大等待时间
):
    for attempt in range(max_retries + 1):
        try:
            return await func()
        except Exception as e:
            if attempt == max_retries:
                raise  # 最后一次尝试仍然失败,抛出异常
            
            # 指数退避:0.5s → 1s → 2s → 4s → ...
            delay = min(base_delay * (2 ** attempt), max_delay)
            
            # 🔥 加随机抖动,防止所有客户端同时重试
            jitter = random.uniform(0, delay * 0.5)
            actual_delay = delay + jitter
            
            print(f"第 {attempt+1} 次失败,{actual_delay:.1f}s 后重试")
            await asyncio.sleep(actual_delay)
为什么要加随机抖动?

  没有抖动时(1000 个客户端同时超时):
  ═══════════════════════════════════
  t=0s: 1000 个客户端同时请求 → 服务器过载 → 全部超时
  t=1s: 1000 个客户端同时重试 → 又过载 → 又超时
  t=2s: 1000 个客户端同时重试 → 还是过载 → 雪崩 💥
  
  这叫"惊群效应"(Thundering Herd)
  
  加了随机抖动后:
  ═══════════════════════════════════
  t=0.8s: 约 100 个客户端重试
  t=1.2s: 约 100 个客户端重试
  t=1.5s: 约 100 个客户端重试
  ...
  → 请求被打散,服务器逐步恢复 ✅

8.3 熔断器模式:防止雪崩的断路器

你家的电闸就是一个熔断器——电流过大时自动断开,保护整条线路不被烧毁。分布式系统中的熔断器做的事情一模一样:当下游服务故障率超过阈值时,暂时停止向它发请求,防止故障扩散到整个系统。

熔断器的三种状态:

  ┌──────────┐    失败率 > 阈值    ┌──────────┐
  │  CLOSED  │ ──────────────────▶ │   OPEN   │
  │  关闭    │                     │  打开    │
  │ 正常放行  │ ◀────────────────── │ 拒绝请求  │
  └──────────┘    探测成功          └────┬─────┘

                                   等待超时

                                   ┌────▼─────┐
                                   │HALF-OPEN │
                                   │ 半开     │
                                   │ 试探性放行│
                                   └──────────┘
                                   │         │
                              成功 → CLOSED  失败 → OPEN

  CLOSED:一切正常,所有请求正常转发
  OPEN:下游已崩,所有请求直接返回错误(快速失败)
  HALF-OPEN:尝试放过少量请求,试探下游是否恢复
python
"""circuit_breaker.py - 熔断器 Python 实现"""
import time
from enum import Enum

class State(Enum):
    CLOSED = "closed"
    OPEN = "open" 
    HALF_OPEN = "half_open"

class CircuitBreaker:
    def __init__(
        self,
        failure_threshold: int = 5,    # 连续失败 5 次触发熔断
        recovery_timeout: float = 30,  # 熔断 30 秒后尝试恢复
    ):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.state = State.CLOSED
        self.failure_count = 0
        self.last_failure_time = 0
    
    async def call(self, func):
        if self.state == State.OPEN:
            if time.time() - self.last_failure_time > self.recovery_timeout:
                self.state = State.HALF_OPEN  # 尝试恢复
            else:
                raise RuntimeError("熔断器打开,拒绝请求")
        
        try:
            result = await func()
            self._on_success()
            return result
        except Exception as e:
            self._on_failure()
            raise
    
    def _on_success(self):
        self.failure_count = 0
        self.state = State.CLOSED
    
    def _on_failure(self):
        self.failure_count += 1
        self.last_failure_time = time.time()
        if self.failure_count >= self.failure_threshold:
            self.state = State.OPEN

💡 熔断 vs 重试的关系:重试是"我再试一次",熔断是"我不试了"。正确的组合:先重试 2-3 次,如果连续多个请求都重试失败了,触发熔断。熔断后所有请求直接快速失败,不再浪费时间重试。

8.4 限流算法:令牌桶与滑动窗口

限流(Rate Limiting)是保护系统的最后一道防线——当请求量超过系统承载能力时,主动拒绝多余请求,避免所有人都得不到服务。

令牌桶算法(Token Bucket):

python
"""token_bucket.py - 令牌桶限流器"""
import time

class TokenBucket:
    """
    令牌桶:按固定速率往桶里放令牌,
    每个请求取一个令牌,桶空了就拒绝。
    允许一定程度的突发流量(桶里可以攒令牌)。
    """
    def __init__(self, rate: float, capacity: int):
        self.rate = rate           # 每秒放几个令牌
        self.capacity = capacity   # 桶的容量(最大突发量)
        self.tokens = capacity     # 当前令牌数
        self.last_refill = time.time()
    
    def allow(self) -> bool:
        now = time.time()
        # 补充令牌
        elapsed = now - self.last_refill
        self.tokens = min(
            self.capacity, 
            self.tokens + elapsed * self.rate
        )
        self.last_refill = now
        
        if self.tokens >= 1:
            self.tokens -= 1
            return True  # 放行
        return False     # 限流

# 使用:每秒 100 个请求,允许突发 200
limiter = TokenBucket(rate=100, capacity=200)

滑动窗口算法(Sliding Window):

滑动窗口 vs 固定窗口的区别:

  固定窗口(有边界效应):
  ═══════════════════════════════════
  窗口 1: [00:00 - 01:00] 限制 100 请求
  窗口 2: [01:00 - 02:00] 限制 100 请求
  
  → 在 00:59 来了 100 个,01:01 又来了 100 个
  → 短短 2 秒内涌入 200 个请求!超出预期 2 倍
  
  滑动窗口(平滑):
  ═══════════════════════════════════
  任意连续 60 秒内不超过 100 个请求
  → 没有边界效应,真正保证限流效果

两种算法对比:

算法突发友好精确度内存典型使用
令牌桶✅ 允许突发极低(几个变量)API 网关限流、接口限流
滑动窗口❌ 严格限制中(需要记录每个请求时间)登录限制、短信验证码
漏桶❌ 强制匀速极低消息队列消费、固定速率处理

💡 工程实践:大部分场景用 令牌桶 就够了。如果你用 Nginx,limit_req 指令就是漏桶算法。如果你用 Redis,redis-cell 模块提供了现成的令牌桶实现,一行命令搞定。

第 8 章核心知识回顾:

概念一句话解释
灰色故障最难缠的故障类型——节点半死不活,时好时坏
超时 = P99 × 2连接超时 100-500ms,读取超时看业务,设短不设长
指数退避 + 抖动防止惊群效应,打散重试流量
熔断器三状态机(Closed/Open/Half-Open),连续失败超阈值则快速失败
令牌桶最通用的限流算法,允许突发、实现简单

9. 可观测性与分布式调试

单机系统出 bug,你打个断点、看个日志就能定位。分布式系统出 bug——请求经过了 5 个服务、3 个消息队列、2 个数据库——日志散在 10 台机器上,时间戳还对不齐。没有可观测性的分布式系统就是在裸奔。

9.1 可观测性三大支柱:Metrics / Logs / Traces

三大支柱各解决什么问题:

  Metrics(指标)—— "出了什么问题?"
  ═══════════════════════════════════
  数值型时序数据:CPU 使用率、请求延迟 P99、错误率
  → 用于监控仪表盘、告警触发
  → 工具:Prometheus + Grafana
  
  Logs(日志)—— "为什么出了问题?"
  ═══════════════════════════════════
  离散的事件记录:请求参数、错误堆栈、业务状态变化
  → 用于事后排查、审计
  → 工具:ELK(Elasticsearch + Logstash + Kibana)
  
  Traces(链路追踪)—— "问题出在哪个环节?"
  ═══════════════════════════════════
  请求在多个服务间的流转路径和耗时
  → 用于延迟分析、瓶颈定位
  → 工具:Jaeger / Zipkin / Tempo

三者的关系:

维度MetricsLogsTraces
数据类型数值(计数器、直方图)文本(非结构化/半结构化)结构化(Span 树)
粒度聚合级(每分钟的平均值)事件级(每条请求)请求级(一次完整链路)
存储成本
告诉你什么什么出了问题为什么出了问题在哪里出了问题
典型工具PrometheusELK / LokiJaeger / Tempo

9.2 分布式链路追踪:TraceID 的旅程

链路追踪的原理非常直觉:给每个请求一个唯一的 TraceID,传遍所有服务,最后把所有服务的日志/耗时用 TraceID 串起来。

一次电商下单请求的链路追踪:

  TraceID: abc-123(整条链路的唯一标识)

  ┌─ API Gateway (SpanID: 1) ────────────────────────────┐
  │ 总耗时 350ms                                          │
  │                                                       │
  │  ┌─ 订单服务 (SpanID: 2, ParentSpan: 1) ──────────┐  │
  │  │ 耗时 200ms                                      │  │
  │  │                                                 │  │
  │  │  ┌─ 数据库 INSERT (SpanID: 3, Parent: 2) ──┐  │  │
  │  │  │ 耗时 15ms                                │  │  │
  │  │  └──────────────────────────────────────────┘  │  │
  │  │                                                 │  │
  │  │  ┌─ 库存服务 RPC (SpanID: 4, Parent: 2) ───┐  │  │
  │  │  │ 耗时 120ms  ← 🔥 瓶颈在这里!            │  │  │
  │  │  │                                          │  │  │
  │  │  │  ┌─ Redis 查询 (SpanID: 5, Parent: 4)┐  │  │  │
  │  │  │  │ 耗时 2ms                           │  │  │  │
  │  │  │  └────────────────────────────────────┘  │  │  │
  │  │  │  ┌─ DB 更新 (SpanID: 6, Parent: 4) ──┐  │  │  │
  │  │  │  │ 耗时 110ms ← 慢查询!              │  │  │  │
  │  │  │  └────────────────────────────────────┘  │  │  │
  │  │  └──────────────────────────────────────────┘  │  │
  │  └─────────────────────────────────────────────────┘  │
  └───────────────────────────────────────────────────────┘
  
  → 一眼看出:总耗时 350ms 中,库存服务占了 120ms
  → 进一步:库存服务的 110ms 花在了数据库更新上
  → 行动:优化库存表的 UPDATE 语句或加索引

OpenTelemetry 接入(Python 示例):

python
"""用 OpenTelemetry 自动注入链路追踪"""
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

# 初始化(通常在应用启动时执行一次)
provider = TracerProvider()
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint="localhost:4317"))
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)

tracer = trace.get_tracer("order-service")

# 在业务代码中使用
async def create_order(user_id: int, items: list):
    with tracer.start_as_current_span("create_order") as span:
        span.set_attribute("user.id", user_id)
        span.set_attribute("order.items_count", len(items))
        
        # 子 Span 自动关联到父 Span
        with tracer.start_as_current_span("validate_inventory"):
            await check_inventory(items)
        
        with tracer.start_as_current_span("insert_order_db"):
            order_id = await db.insert_order(user_id, items)
        
        return order_id

💡 OpenTelemetry 是行业标准。它统一了 OpenTracing 和 OpenCensus,几乎所有现代可观测性工具都支持。如果你还在犹豫选什么追踪框架——不用犹豫,选 OpenTelemetry。

9.3 延迟分析与性能瓶颈定位

不要看平均延迟——看百分位数。

平均值的骗局:

  10 个请求的延迟:
  2ms, 3ms, 2ms, 3ms, 2ms, 3ms, 2ms, 3ms, 2ms, 5000ms

  平均延迟 = (2×5 + 3×4 + 5000) / 10 = 502.2ms
  → "平均 502ms?系统很慢啊!"
  → 实际上 90% 的请求在 3ms 内完成
  → 只有 1 个请求是 5 秒(可能是 GC 暂停)

  正确的指标:
  ═══════════════════════════════════
  P50(中位数)= 2.5ms  ← 50% 的请求在这个时间内
  P95 = 3ms            ← 95% 的请求在这个时间内
  P99 = 5000ms         ← 99% 的请求在这个时间内
  
  → P50 告诉你"正常情况"
  → P99 告诉你"最坏情况"
  → 两者差距越大,说明系统越不稳定

Prometheus 查询延迟指标:

# 查询 order-service 的 P99 延迟
histogram_quantile(0.99, 
  rate(http_request_duration_seconds_bucket{
    service="order-service"
  }[5m])
)

# 查询错误率
sum(rate(http_requests_total{status=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))

瓶颈定位三步法:

步骤动作工具
1. 发现问题P99 延迟突然升高,触发告警Prometheus + Grafana
2. 定位服务用 TraceID 找到慢请求,看 Span 树哪个服务耗时最长Jaeger / Tempo
3. 定位根因到具体服务看日志/慢查询/CPU 火焰图ELK / pg_stat_statements

💡 SLO 建议:对于用户直接感知的接口,P99 延迟不超过 500ms 是一个合理的起点。如果 P99 和 P50 差距超过 10 倍(比如 P50=5ms,P99=500ms),说明系统有"长尾延迟"问题,通常是 GC、锁竞争或慢查询引起的。

9.4 告警设计:什么值得报警,什么不值得

一个由 50 条告警规则组成、每天响 200 次的告警系统,等同于没有告警——因为所有人都习惯了忽略它。告警的终极目标:每一条告警都值得一个人放下手头工作去处理。

告警设计的两大原则:

  原则 1:基于症状(Symptom),不基于原因(Cause)
  ═══════════════════════════════════
  ❌ 坏告警:"CPU 使用率 > 80%"
  → CPU 80% 可能完全正常(业务高峰期)
  → 不一定有用户受影响
  
  ✅ 好告警:"P99 延迟 > 500ms 持续 5 分钟"
  → 直接反映用户体验
  → 一定有人在受影响
  
  原则 2:只告警可行动的(Actionable)
  ═══════════════════════════════════
  ❌ 坏告警:"某个旧节点磁盘 90%"
  → 收到了能干什么?如果不需要人工干预就别报
  
  ✅ 好告警:"订单创建错误率 > 5% 持续 3 分钟"
  → 收到后立刻排查订单服务

告警分级体系:

级别含义响应时间通知方式示例
P0 Critical核心业务不可用5 分钟内电话 + 短信支付全面失败
P1 High核心业务严重受损15 分钟内短信 + IM下单成功率 < 95%
P2 Medium非核心功能异常1 小时内IM 群消息推荐系统无响应
P3 Low需关注但不紧急下个工作日邮件/工单磁盘使用率 > 80%

💡 Google SRE 的经验:如果一条告警连续 30 天没有导致任何人采取行动,就删掉它。告警疲劳(Alert Fatigue)是运维事故的第一大元凶——当"狼来了"喊多了,真的狼来了你也不会在意。

第 9 章核心知识回顾:

概念一句话解释
三大支柱Metrics(什么出了问题)、Logs(为什么)、Traces(在哪里)
TraceID / SpanIDTraceID 贯穿整条链路,SpanID 标识每个操作,ParentSpan 建立父子关系
OpenTelemetry行业标准追踪框架,统一了 OpenTracing 和 OpenCensus
P99 > P50看百分位数而非平均值,P99/P50 差距大说明有长尾延迟
告警原则基于症状、可行动、分级响应,30 天无人响应就删除

10. 实战:从单体到分布式的重构路径

前 9 章讲了理论和模式,这一章把所有知识串起来——用一个电商系统的真实重构案例,演示从单体到分布式的完整路径。每一步都会标注"用到了第 X 章的什么知识"。

10.1 案例:单体电商系统的架构瓶颈

初始架构:经典单体应用

  ┌──────────────────────────────────────────────┐
  │              Flask/Django 单体应用              │
  │                                              │
  │  ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
  │  │用户模块│ │商品模块│ │订单模块│ │支付模块│ │
  │  └────────┘ └────────┘ └────────┘ └────────┘ │
  │                                              │
  │              共享一个数据库                     │
  └──────────────────┬───────────────────────────┘

              ┌──────▼──────┐
              │ PostgreSQL  │
              │ 单实例      │
              │ 50+ 张表    │
              └─────────────┘
  
  团队:5 人
  日活:3 万
  数据量:订单表 500 万行
  
  → 这个阶段,单体架构完全没问题 ✅

什么时候必须拆?当这四个信号中任何一个出现时:

信号具体表现对应瓶颈
开发效率暴跌改一行代码要测整个系统、分支合并冲突频发、部署一次需要半小时团队协作瓶颈(> 10 人共用一个代码仓库)
性能瓶颈大促期间 QPS 打满、数据库连接池耗尽、单表查询超过 1 秒计算/存储瓶颈(第 1 章)
可用性不达标一个模块的 bug 导致整个系统宕机、无法独立扩缩容故障隔离缺失(第 8 章)
发布频率受限支付模块要紧急修复但必须等商品模块一起发布部署耦合

10.2 拆分策略:识别服务边界

怎么拆?用 DDD(领域驱动设计)的限界上下文:

识别服务边界的方法:

  步骤 1:画出所有业务能力
  ═══════════════════════════════════
  用户管理、商品管理、库存管理、订单管理、
  支付处理、物流跟踪、营销活动、数据分析
  
  步骤 2:找出高内聚、低耦合的分组
  ═══════════════════════════════════
  用户管理 → 用户服务
  商品管理 + 库存管理 → 商品服务(库存和商品强关联)
  订单管理 → 订单服务
  支付处理 → 支付服务(独立安全域)
  
  步骤 3:识别服务间的通信模式
  ═══════════════════════════════════
  下单 = 订单服务 + 库存服务 + 支付服务(同步)
  物流通知 = 订单服务 → 物流服务(异步消息)
  数据分析 = 各服务 → 数据服务(异步消息)

拆分优先级(不要一次全拆):

渐进式拆分路径:

  Phase 1:先拆出最独立的服务
  ═══════════════════════════════════
  → 用户服务(注册/登录/鉴权)
  → 几乎不和其他业务耦合
  → 风险最低,练手用
  
  Phase 2:拆出业务瓶颈最大的模块
  ═══════════════════════════════════
  → 订单服务 + 库存服务
  → 大促时压力集中在这里
  → 拆出来后可以独立扩缩容
  
  Phase 3:拆出安全要求最高的模块
  ═══════════════════════════════════
  → 支付服务
  → 独立数据库、独立审计
  → PCI-DSS 合规要求
  
  ❌ 绝对不要做的事:
  → 第一天就拆成 20 个微服务
  → "因为大厂这么做所以我也这么做"

💡 Martin Fowler 的建议:"先把单体做好,再考虑拆分。"如果你的单体都写得一团糟(没有清晰的模块边界、没有接口抽象),拆成微服务只会把混乱分布到更多地方。

10.3 分布式改造:ID / 事务 / 缓存 / 消息

服务拆出来之后,你会立刻面对这些问题——每一个都对应前面某一章的知识:

改造 1:分布式 ID(第 3 章)

改造前:每个表用 PostgreSQL 自增 ID
改造后:订单 ID 用 Snowflake 算法

  为什么?
  → 订单服务独立数据库后,自增 ID 会和其他服务冲突
  → 订单号需要包含时间信息(方便按时间查询)
  → Snowflake 8 字节,索引性能好
  
  实施:
  → 部署一个 Snowflake ID 生成器(worker_id 从 etcd 获取)
  → 订单号格式:snowflake_id → 展示时转为 "ORD-20250411-XXXX"

改造 2:分布式事务(第 7 章)

改造前:下单逻辑在一个事务里
  BEGIN;
    INSERT INTO orders (...);
    UPDATE inventory SET stock = stock - 1;
    UPDATE accounts SET points = points + 10;
  COMMIT;

改造后:Saga 编排模式(最终一致性)
  ┌──────────┐
  │  编排器   │
  └────┬─────┘
       │ 1. 创建订单(订单服务)
       │ 2. 扣库存(库存服务)→ 失败则补偿:取消订单
       │ 3. 扣款(支付服务)→ 失败则补偿:恢复库存 + 取消订单
       │ 4. 加积分(积分服务)→ 失败则只记日志(允许最终一致)

  
  关键设计:
  → 每个步骤的接口都是幂等的(第 4 章)
  → 支付回调用事务消息保证可靠投递(第 7 章)
  → 积分是"尽力而为",允许延迟到账

改造 3:缓存策略(第 2 章一致性)

热门商品缓存(AP 模式):
═══════════════════════════════════
Redis 缓存商品详情,TTL = 60 秒
→ 最终一致性,60 秒内可能看到旧数据
→ 对商品浏览完全可接受

库存缓存(CP 模式):
═══════════════════════════════════
扣库存必须走数据库(不走缓存)
→ 缓存只用于"展示库存"(允许不精确)
→ 下单扣库存用数据库乐观锁(第 4 章)

改造 4:异步消息(第 4 章 + 第 7 章)

拆分后引入消息队列的场景:

  同步调用(RPC/HTTP):        异步消息(Kafka/RabbitMQ):
  用户下单 → 扣库存              订单完成 → 发送物流通知
  用户登录 → 鉴权                支付成功 → 发送短信/邮件
                                订单数据 → 同步到数据仓库
  
  原则:核心链路同步,非核心链路异步
  → 用户感知的操作用同步(快速反馈)
  → 后台任务用异步(解耦 + 削峰)

10.4 上线后的治理:监控、限流与容灾

服务拆完、改造完,上线后才是真正考验的开始——分布式系统的 70% 工作量在运维和治理。

治理清单(综合运用第 8-9 章):

治理项具体动作对应章节
链路追踪接入 OpenTelemetry,所有服务传递 TraceID第 9 章
核心指标配置 P99 延迟、错误率、QPS 的 Grafana 仪表盘第 9 章
告警P0: 支付失败率>1%;P1: 下单 P99>500ms第 9 章
限流API 网关令牌桶限流,单用户 100 QPS第 8 章
熔断库存服务不可用时,下单接口快速失败而非等待第 8 章
降级大促期间关闭推荐/评论等非核心功能第 8 章
超时所有 RPC 调用超时 = P99 × 2第 8 章
最终分布式架构:

  ┌─────────┐
  │  客户端  │
  └────┬────┘

  ┌────▼────┐
  │API 网关 │ ← 限流、鉴权、路由
  └────┬────┘

  ┌────┼────────────┬──────────────┐
  │    │            │              │
  ▼    ▼            ▼              ▼
┌────┐┌──────┐ ┌──────┐      ┌──────┐
│用户││订单  │ │库存  │      │支付  │
│服务││服务  │ │服务  │      │服务  │
└─┬──┘└──┬───┘ └──┬───┘      └──┬───┘
  │      │        │             │
  ▼      ▼        ▼             ▼
┌────┐┌──────┐ ┌──────┐      ┌──────┐
│DB-1││DB-2  │ │DB-3  │      │DB-4  │
└────┘└──────┘ └──────┘      └──────┘
  
  + Redis Cluster(缓存 + 分布式锁)
  + Kafka(异步消息:物流通知、数据同步)
  + etcd(服务发现 + Snowflake worker_id 分配)
  + Prometheus + Grafana + Jaeger(可观测性)

💡 最后的忠告:分布式系统不是目的,是手段。它解决了规模和可用性的问题,但代价是复杂度的数量级增长。永远先问自己:单体真的不行了吗? 如果答案是"还行",那就不要动。等到单体真的撑不住那天,你带着这 10 章的知识去改造,会从容得多。

第 10 章核心知识回顾:

概念一句话解释
拆分四信号开发效率暴跌、性能瓶颈、可用性不达标、发布频率受限
渐进式拆分先拆最独立的 → 再拆瓶颈最大的 → 最后拆安全要求最高的
核心链路同步用户感知的操作用 RPC 同步调用,后台任务用消息队列异步
前 9 章串联ID → Snowflake,事务 → Saga,锁 → etcd,监控 → OTel
先做好单体Martin Fowler:"单体都写不好,微服务只会是分布式的混乱"

全书总结:分布式系统设计的 10 条军规

  1. 不要为了分布式而分布式——单体能扛住就不拆(第 1 章)
  2. CAP 不是三选二——P 是必选项,按操作粒度选 C 或 A(第 2 章)
  3. ID 方案选型——UUID v7 最省心,Snowflake 信息密度最高(第 3 章)
  4. 所有可重试操作必须幂等——幂等 Token + 唯一约束 + 状态机多层防御(第 4 章)
  5. 能不分片就不分片——先优化 SQL → 读写分离 → 垂直拆分 → 最后水平分片(第 5 章)
  6. 共识集群用奇数节点——3 或 5 个,raft 保证多数派安全(第 6 章)
  7. 拥抱最终一致性——Saga + 事务消息,不要迷信 2PC(第 7 章)
  8. 为失败设计——超时 + 重试 + 熔断 + 限流,四件套缺一不可(第 8 章)
  9. 没有可观测性就是裸奔——Metrics + Logs + Traces 三根支柱(第 9 章)
  10. 渐进式重构——一次拆一个服务,先拆最独立的,别一天跪 20 个微服务(第 10 章)

坚持是一种品格