分布式系统设计入门
从单机到分布式的思维转变——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 核
特点:理论上无上限,但引入协调成本四大代价:
网络是不可靠的:两台机器之间的网络随时可能断开、延迟、乱序。在单机系统里,函数调用是纳秒级的、100% 可靠的;在分布式系统里,一次 RPC 调用可能需要毫秒级,还可能超时、失败、重复到达。
时钟是不一致的:每台机器的系统时钟都有微小偏差(几毫秒到几十毫秒)。这意味着"事件 A 发生在事件 B 之前"这个在单机上天经地义的判断,在分布式环境中变成了一个需要特殊协议才能回答的难题(Lamport 时钟、向量时钟)。
部分失败:单机系统要么整体正常、要么整体崩溃。分布式系统最诡异的地方在于——一部分节点正常,一部分节点故障。你的数据库主节点写入成功了,但从节点因为网络分区没收到同步,这时候该怎么办?
复杂度爆炸:调试一台机器的 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 三要素拆解:
Basically Available(基本可用):系统在出现故障时,允许部分功能降级,但核心功能仍然可用。比如双 11 高峰期,商品详情页的推荐模块加载不出来(降级),但下单功能正常。
Soft State(软状态):允许系统中的数据存在中间状态,不同节点的数据副本可以暂时不一致。比如你下单后,订单服务已经记录了订单,但库存服务还没扣减——这个中间状态是被允许的。
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 | 最终一致 | 写入不等从节点确认,主挂了可能丢数据 |
| DynamoDB | AP(默认) | 最终一致 | 默认读取最终一致,可选强一致读(加钱+加延迟) |
| etcd / ZooKeeper | CP | 线性一致 | 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 BASE | ACID 追求强一致(适合金融),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 方案——每台机器本地生成,不需要任何协调。
"""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 v4 | UUID 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 完整实现:
"""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 都去中心节点取,而是批量取一段,用完了再取。
"""号段模式示意"""
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
"""Redis 自增 ID"""
import redis
r = redis.Redis()
def next_id(biz: str) -> int:
return r.incr(f"id:{biz}") # 原子自增,天然有序方案 3:数据库序列(PostgreSQL Sequence)
-- 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 v7 | 2024 年推荐的新标准,时间有序 + 随机,零依赖但 16 字节偏大 |
| Snowflake | 64 位整数 = 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 去重。
"""幂等 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 约束天然防重:
-- 订单表设置业务唯一键
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:乐观锁(版本号)
每次更新时检查版本号,防止重复更新:
-- 更新时带版本号
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, ...) → 冲突则忽略│
└────────────────────────────────────────────┘"""支付回调的多层幂等实现"""
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 ✅"""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 完整实现:
"""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:
| 维度 | Paxos | Raft |
|---|---|---|
| 提出者 | Leslie Lamport(1989) | Diego Ongaro(2014) |
| 难度 | 极难理解(Lamport 自己用 25 年才让人理解) | 专为可理解性设计 |
| 工业实现 | Google Chubby、Spanner | etcd、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(最简单,但有风险)
"""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 共识,最安全)
"""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"""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):
"""带抖动的指数退避重试"""
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:尝试放过少量请求,试探下游是否恢复"""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):
"""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三者的关系:
| 维度 | Metrics | Logs | Traces |
|---|---|---|---|
| 数据类型 | 数值(计数器、直方图) | 文本(非结构化/半结构化) | 结构化(Span 树) |
| 粒度 | 聚合级(每分钟的平均值) | 事件级(每条请求) | 请求级(一次完整链路) |
| 存储成本 | 低 | 高 | 中 |
| 告诉你什么 | 什么出了问题 | 为什么出了问题 | 在哪里出了问题 |
| 典型工具 | Prometheus | ELK / Loki | Jaeger / 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 示例):
"""用 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 / SpanID | TraceID 贯穿整条链路,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 章)
- CAP 不是三选二——P 是必选项,按操作粒度选 C 或 A(第 2 章)
- ID 方案选型——UUID v7 最省心,Snowflake 信息密度最高(第 3 章)
- 所有可重试操作必须幂等——幂等 Token + 唯一约束 + 状态机多层防御(第 4 章)
- 能不分片就不分片——先优化 SQL → 读写分离 → 垂直拆分 → 最后水平分片(第 5 章)
- 共识集群用奇数节点——3 或 5 个,raft 保证多数派安全(第 6 章)
- 拥抱最终一致性——Saga + 事务消息,不要迷信 2PC(第 7 章)
- 为失败设计——超时 + 重试 + 熔断 + 限流,四件套缺一不可(第 8 章)
- 没有可观测性就是裸奔——Metrics + Logs + Traces 三根支柱(第 9 章)
- 渐进式重构——一次拆一个服务,先拆最独立的,别一天跪 20 个微服务(第 10 章)