Skip to content

第 15 章:集群与分布式入门 — 服务发现 / 负载均衡 / 分布式锁

示例工程:examples-middleware/cluster_demo(etcd + Redis,Docker 启动方式见第 14 章)

学习目标

  1. 理解从「单实例」到「集群」要改造什么:无状态设计、外置状态
  2. 说清服务发现解决什么问题,能用 etcd 实现注册、心跳、发现、watch
  3. 理解负载均衡的两种位置(入口层 / 客户端),能写最小的轮询选择器
  4. 会用 Redis 实现分布式锁,知道它的适用边界和不可靠场景
  5. 对分布式经典问题(幂等、脑裂、分布式事务)建立第一印象,知道往哪深挖

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 的注册表

text
实例启动 → 注册:写入 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):

text
加锁: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
  • [ ] 我能说出至少两个「重试/重投导致重复执行」的场景和幂等对策

练习

  1. 起两个 todo_api_pg 实例(PORT=3001/3002),手动把它们注册进 etcd,用 discover + pick_round_robin 写一个最小的「网关」循环转发请求。
  2. 给第 14 章练习 2 的消费幂等改用本章的 try_lock/unlock 实现,对比 SET NX 裸用与带 token 锁的差别。
  3. watch_service 观察:kill 掉一个注册的实例进程,测量从进程死亡到 watch 收到删除事件的延迟,和你设置的租约 TTL 对照。

动手验证

bash
cd examples-middleware
docker compose up -d etcd redis
cargo run -p cluster_demo                  # 注册/发现/轮询/抢锁演示
cargo test -p cluster_demo -- --ignored    # 集成测试

导航:上一章 | 返回目录 | 下一章