第 15 章:集群与分布式入门 — 服务发现 / 负载均衡 / 分布式锁
示例工程:
examples-middleware/cluster_demo(etcd + Redis,Docker 启动方式见第 14 章)
学习目标
- 理解从「单实例」到「集群」要改造什么:无状态设计、外置状态
- 说清服务发现解决什么问题,能用 etcd 实现注册、心跳、发现、watch
- 理解负载均衡的两种位置(入口层 / 客户端),能写最小的轮询选择器
- 会用 Redis 实现分布式锁,知道它的适用边界和不可靠场景
- 对分布式经典问题(幂等、脑裂、分布式事务)建立第一印象,知道往哪深挖
15.1 从单实例到集群:先把服务变「无状态」
单实例的 todo_api 想水平扩展成 3 个实例,第一个拦路虎不是技术选型,而是状态:
- 内存里的状态(session、本地缓存、内存队列):请求被负载均衡到不同实例,上一次的状态就"丢"了 → 全部外置:session 进 Redis、缓存进 Redis(第 14.5 节)、队列进 RabbitMQ(第 14.6 节)
- 本地文件(SQLite、上传的文件):实例间不共享 → 数据库进 Postgres(第 14.3 节)、文件进对象存储
- 定时任务:3 个实例会跑 3 遍 → 用分布式锁保证只有一个实例执行(15.4)
一句话:实例本身只剩代码,随时可以起一个新的、杀一个旧的——这是集群一切玩法的前提,也是「12-factor 应用」的核心主张。
15.2 服务发现:实例动态来去,调用方怎么找到它们
集群里实例的 IP 是动态的:扩容、缩容、故障重启,地址随时变。把地址写进配置文件的做法在第一次扩容时就会崩溃。服务发现的本质是一张带 TTL 的注册表:
实例启动 → 注册:写入 key(/services/todo-api/10.0.0.7:3000),绑定租约(lease)
存活期间 → 心跳:定期续租(keepalive)
实例崩溃 → 不再续租 → 租约过期 → key 自动消失 → 调用方看不到它了
调用方 → 发现:按前缀读出全部实例;或 watch 前缀,上下线实时推送cluster_demo/src/discovery.rs 用 etcd 实现了这四步。两个设计点值得注意:
- 为什么是租约而不是「下线时主动删除」:进程被 kill -9 / 断电时没机会执行清理代码,租约过期是唯一可靠的死亡检测
- 轮询 vs watch:轮询实现简单但有延迟和无效请求;watch 由 etcd 主动推送变更,上下线秒级感知
生产上你更可能直接用 Kubernetes:Service + DNS 就是开箱即用的服务发现(k8s 内部恰好就是用 etcd 存的状态)。原理相通,学会 etcd 这套模型,k8s 的行为就不神秘了。国内栈还常见 Nacos / Consul,模型同样是「注册 + 心跳 + 订阅」。
15.3 负载均衡:谁来决定请求去哪个实例
两种位置,生产上常常同时存在:
| 位置 | 形态 | 例子 |
|---|---|---|
| 入口层(服务端) | 独立组件,调用方无感知 | Nginx、云厂商 SLB、k8s Service |
| 客户端 | 调用方自己从注册表选实例 | gRPC 客户端 LB、服务网格 sidecar |
cluster_demo 里的 pick_round_robin(原子计数器取模)就是客户端负载均衡的最小形态——生产级实现补充的是权重、健康剔除、粘性会话这些工程细节,骨架是一样的。
15.4 分布式锁:集群里「只能有一个人做」的事
定时任务去重、库存扣减、幂等控制……单机用 Mutex(第 9 章),跨实例就需要所有实例都能看到的锁。Redis 方案两条命令就能讲清(cluster_demo/src/dlock.rs):
加锁:SET lock:job:report {随机token} NX PX 30000
NX → 只有第一个到的人能设置成功(抢锁)
PX → 30 秒自动过期(持有者崩溃也不会死锁)
解锁:Lua 脚本原子执行「GET 比对 token,一致才 DEL」解锁必须比对 token 再删,这是最常见的踩坑点:如果自己的锁已经过期、别人已经抢到,直接 DEL 会删掉别人的锁。token 用随机值,就是为了「只能解自己加的锁」。
边界也要清楚:TTL 内活没干完锁就飘了(对策:看门狗续期);Redis 主从切换瞬间锁可能"复活"(Redlock 算法有争议)。需要绝对正确的场景(钱!)用 etcd 锁或数据库约束 + fencing token,Redis 锁适合「偶尔重复可容忍、但要尽量避免」的场景。
15.5 分布式经典问题速览(知道存在,知道关键词)
- 幂等:网络超时后的重试、消息队列的重投,都会让同一操作到达两次。对策:唯一请求 ID + 去重表 /
SET NX(第 14.6 的消费幂等是同一件事) - 脑裂:网络分区让两半集群都以为自己是主。对策:多数派(quorum)——etcd 用 Raft 就是为了这个,所以 etcd 集群总是奇数台
- 分布式事务:跨两个库/服务的「要么都成要么都败」没有免费方案。关键词:Saga(补偿)、outbox(本地事务 + 消息表);能用「一个库的本地事务」解决就不要拆
- 时钟不可信:两台机器的时间戳不能比较先后。关键词:逻辑时钟、fencing token
每一条都能写一本书,本章的目标是让你遇到时叫得出名字、搜得到方案。
本章小结
- 集群第一步是无状态化:session/缓存/队列/文件全部外置到中间件
- 服务发现 = 带 TTL 的注册表:注册写 key、心跳续租、崩溃即过期、watch 推送变更
- k8s 的 Service/DNS 是服务发现的开箱形态,底层同样是 etcd
- 负载均衡分入口层与客户端两种位置,轮询选择器是客户端 LB 的最小形态
- Redis 锁 =
SET NX PX抢 + Lua 比对 token 解;绝对正确的场景要换 etcd/数据库锁 - 幂等、脑裂、分布式事务:先记住名字和关键词,按需深挖
自测清单
- [ ] 我能说出把 todo_api 扩成 3 实例前必须外置的三类状态
- [ ] 我能解释为什么服务发现用「租约过期」而不是「主动注销」检测实例死亡
- [ ] 我能说出入口层与客户端负载均衡各自的位置和例子
- [ ] 我能写出 Redis 抢锁的完整命令并解释 NX、PX、随机 token 各自的作用
- [ ] 我能解释为什么解锁必须用 Lua 脚本比对 token
- [ ] 我能说出至少两个「重试/重投导致重复执行」的场景和幂等对策
练习
- 起两个
todo_api_pg实例(PORT=3001/3002),手动把它们注册进 etcd,用discover+pick_round_robin写一个最小的「网关」循环转发请求。 - 给第 14 章练习 2 的消费幂等改用本章的
try_lock/unlock实现,对比SET NX裸用与带 token 锁的差别。 - 用
watch_service观察:kill 掉一个注册的实例进程,测量从进程死亡到 watch 收到删除事件的延迟,和你设置的租约 TTL 对照。
动手验证:
cd examples-middleware
docker compose up -d etcd redis
cargo run -p cluster_demo # 注册/发现/轮询/抢锁演示
cargo test -p cluster_demo -- --ignored # 集成测试