系统架构 · 节点协调与集群控制

一群机器怎么不打架: 心跳超时触发竞选 → 多数派承认才算主 → 租约到期自动让位 → fencing token 把脑裂挡在门外

系统架构 · 节点协调与集群控制: 竞选状态机 / 租约续期 / Fencing Token 防脑裂 rose 虚线 = 事故路径 · 箭头是状态迁移与数据流 ① 3 节点竞选状态机: 心跳超时 → term+1 拉票 → 多数派承认才算主 Quorum = ⌊N/2⌋+1: N=3 → 2 票 · N=5 → 3 票 · 不足半数宁停不出双主 选举超时到点 election timeout 获多数派选票 ≥2/3 RequestVote(term=5) × 2 → ✓ ✓ 发现更高 term → 立刻让位回 Follower, 绝不和新人抢 瓜分选票: 都不过半, term+1 再来 Follower 跟随者 默认态 · 只投票不竞选 选举超时 random 150~300ms Candidate 候选人 term+1 · 向全员拉票 自投 1 票 + 等其余节点投票 Leader 主 周期心跳 (term=5) 只当到更高 term 出现为止 3 节点: 任一节点挂掉仍能选出主(2/3); 两台挂掉 → 无多数派, 集群拒绝服务 — 宁可不可用, 不出两个主 案例: node-1 自投 + node-2 ✓ + node-3 ✗ = 2 票 → 丢 1 票仍当选 ② Lease 租约时间轴: 续期保活, 到点自动让位 (TTL=30s) 锁靠持有者善终, 租约靠服务端到点回收 — 暴毙也能自动释放 t t=10s 续期 → TTL 归位 30s t=20s 续期 → TTL 归位 30s t=25s node-A 被 OOM Kill node-A 租约 lease=ad42f7 (TTL 30s) 死租约 → t=50s 过期 0 10s 20s 30s 事故语义: 不再有续期 → 末次续期 20s + TTL 30s = t=50s 租约到期, key /jobs/settle/leader 自动删除 key 删除触发 Watch 事件 → 备位 node-B 立刻 Campaign, 让位收敛从「等 30s 超时」变成「毫秒级」 ③ Fencing Token: 脑裂的最后一道闸 — 旧主 token=6 被拒, 新主 token=7 通过 发租方发单调递增号, 存储层执行拒绝 — 客户端自检无效 带 token=6 来写 拒绝: fenced token=7 → 通过 ✓ 旧主 node-A (GC 刚醒来) 仍自认主 · 手握 token=6 resp 403: {"error":"fenced", "detail":"token 6 stale (current fencing: 7)"} 新主 node-B (多数派新选) 领到 token=7 · epoch 单调递增 存储层 (KV / DB / WAL) 写入前检查: if req.token ≤ last_fencing → 拒绝 else 接受写 + last_fencing ← req.token 当前 last_fencing = 7 HDFS NN HA / Chubby / Kafka Controller 同款 为什么必须: 新主已当选, 旧主只是「看起来死」; 没有 fencing, 它醒来后的写会与新主交错双写。 关键: 校验和拒绝必须发生在存储层, 「先查后写」放在应用层是挡不住并发的。 epoch/token 语义: 每次成功选举 +1, 旧值立即作废 同类实现: HDFS epoch / Kafka controller epoch / ZK zxid 读法: 实线 = 放行路径 · rose 虚线 = 被存储层拒绝的事故路径 · 编号/epoch 单调递增, 由协调服务统一发放

机制视角 — 三幕剧

  • • 幕一: 心跳超时只是触发竞选, term+1 拉票, 多数派承认才算当选
  • • 幕二: 租约是主的保质期, KeepAlive 续期, 进程暴毙到点自动让位
  • • 幕三: fencing token 单调递增, 存储层拒绝旧号 — 脑裂写不进去
  • • 少数派一侧宁可罢工: 没有多数派就不该有主

行为视角 — 依赖链

  • • 故障检测触发竞选, 竞选产出 epoch, epoch 被 fencing 消费, 一环扣一环
  • • Watch 把「让位收敛」从等超时(30s 级)变成事件推送(毫秒级)
  • • 控制面慢而稳, 数据面只读本地快照, 永不直接依赖协调服务
  • • Reconciliation: 声明期望状态 + 循环 diff, 天然幂等自愈

生产价值 — 什么时候用

  • • 定时任务单活 / 元数据 Master / 调度器 Leader, 全是同一套
  • • 选型: 强一致选 etcd/ZK; Redis 锁只配 fencing 做弱互斥
  • • 节点数永远 3 或 5: 偶数节点是花钱买不到容错
  • • 选举 key 要配权限, 否则主会被调试脚本顶掉

💡 一句话理解

把十台机器想成一个班的值日生: 活儿只有一个人能干(跑结算/写元数据/发配置), 于是只能选一个值班班长。规矩是过半数同意才算当选(quorum), 值班有轮班时限(lease), 到点自动换班; 换班时新班长领一个递增的班次号(fencing token), 仓库只认最新班次 — 老班长从打盹里醒来, 拿旧班号连门都进不去。这套流程解决「单活组件谁来当」的冲突, 自己制造的麻烦是「误判会造成双主」, 所以选举 + 租约 + 防脑裂, 三件永远成套出现。

K8s 的 Lease 对象、etcd 的 concurrency 包、ZooKeeper 的临时节点, 都是把这三件事打包好的成品。看懂状态机(Follower→Candidate→Leader)、看懂时间轴(TTL+续期)、看懂拒绝语义(token 单调递增), 你就掌握了 K8s 控制面、Kafka Controller、所有分布式锁的共同骨架。

🧠 必知必会 必考 & 必会

Leader Election 选主
一群副本里选出唯一 leader, 由它执行「只有主能做的事」: 写元数据、跑定时任务、分配分片。要点: 合法性来自多数派投票, 不是「我先抢到」; 每次竞选任期号 term 单调 +1, 全集群只认最高 term。代价: 竞选期间服务不可用(亚秒到数秒), 频繁重选 = 抖动风暴。
sess, _ := concurrency.NewSession(cli, concurrency.WithTTL(15))
e := concurrency.NewElection(sess, "jobs/settle/leader")
e.Campaign(ctx)   // 阻塞, 直到 etcd 多数派把你的 key 抬到第一位
// 关键: 合法性来自多数派落盘, 不是本机自封 → 天然防双主
Consensus 共识
让 N 个会宕机、消息会丢的节点, 对「谁是主 / 这条日志是什么」达成一个不可反悔的一致决定。Raft/Paxos 是实现; 共识系统顺带送你两样东西: 按任期选主 + 日志复制。代价: 每次确认至少一次多数派 RPC 往返, 延迟下不来。
// Raft 投票规则(伪代码): term 更高才投, 同 term 先到先投, 一生一票
if req.Term > rf.currentTerm { rf.currentTerm, rf.votedFor = req.Term, req.CandidateId }
// 关键: 「term 高才投 + 一生一票」两条合起来, 数学上防住双主
Quorum 多数派
N 副本中, 任何关键决定要 ⌊N/2⌋+1 个节点确认。任意两个多数派必有交集, 所以新旧主不可能同时被承认; 这也是 etcd/ZooKeeper 要求奇数节点的根因。代价: 挂超过一半时集群拒绝服务 — 宁可不可用, 不出两个主。
n, quorum := 5, 5/2+1   // → quorum=3: 5 节点最多容忍 2 台宕机
// 关键: 两个多数派必有交集 → 双主在数学上不可能同时合法
Membership 成员管理
集群「现在有谁」的名册。增删成员必须走共识流程(etcd 的 member add / Raft 联合共识), 不能每台机器手工改配置 — 两半各改各的名册, 集群就真的裂开了。Gossip 型成员表最终一致, 共识型(etcd)强一致。
etcdctl member add infra-3 --peer-urls=http://10.0.3.11:2380
# → Member 8e9e27c5... added to cluster ef37ad9d..., 并打印新节点启动变量
# 关键: 成员变更经多数派落盘, 新节点要用 member list 的结果启动
Distributed Lock 分布式锁
互斥访问共享资源的最小工具。正确姿势三件套: NX(不存在才写入, 保证互斥) + PX(TTL, 持有者暴毙也能回收) + 唯一 owner 值(释放前校验, 防止删掉别人的锁)。局限: 锁服务无法在持有者失联时立刻收回授权 → 必须配 fencing token。
SET lock:cfg-push a1f2 NX PX 30000
# → OK (拿锁失败返回 nil, 不报错)
# 关键: NX+PX+owner 三件缺一: 没 PX 会死锁, 没 owner 会删别人的锁
Lease 租约
「未来 T 秒归你」的授权, 靠 KeepAlive 续期, 不续就到点自动回收。与锁的本质区别: 锁依赖持有者善终(记得释放), 租约不依赖(断电也自动过期), 所以「主」的身份几乎都用租约承载。TTL 是「误判率 × 接管速度」的旋钮, 生产常用 10~30s。
lease, _ := cli.Grant(ctx, 15)          // 15s TTL 的租约
kaCh, _ := cli.KeepAlive(ctx, lease.Id) // 持续续期, 约每 TTL/3 一次回执
// 关键: 进程 kill -9 也不怕 — 到点服务端自动删除, 绝不死锁
Fencing Token 防护令牌
发放租约时附带单调递增编号, 存储层拒绝不大于已见最大值的写。它不预防脑裂, 它让脑裂写不进去 — 是旧主复活事故唯一能兜底的机制, 前提是校验必须做在存储层, 应用层自检挡不住并发。
if req.Token <= store.LastFencing() {
    return ErrFenced // → 旧主 token=6 被拒: "token 6 is stale (current 7)"
}
// 关键: 校验+拒绝必须原子地发生在存储层, 客户端自检无效
Split Brain 脑裂
集群里同时存在两个自认合法的主。来源: 无 quorum 判活时分区两侧各自当选; 或误判切换后旧主没被隔离。双写错乱后的修复成本(比对/补偿/丢数据)远超切换省下的几秒 — 少数派一侧必须自动降级只读。
// 伪代码: 多数派不可达时的正确姿势是降级, 不是继续当家
if !majorityAlive() { enterReadonlyMode() }
// 关键: 少数派一侧只读, 「两个主」从源头就不成立
Coordination Service 协调服务
专做「谁活着 / 谁是主 / 配置是什么」的薄一层(etcd / ZooKeeper / Consul)。共同设计: 强一致存储 + TTL 会话 + Watch 推送。红线: 它是元数据存储不是数据库 — etcd 默认单 value ≤ 1.5MiB, ZK 默认 jute.maxbuffer=1MB, 塞大对象会拖垮 raft。
etcdctl put /meta/route.json "$(cat 8MB-route.json)"
# → etcdserver: request is too large
# 关键: 协调服务只放「指针」(URL+版本号), 大对象进对象存储
Watch / Notify 监听通知
注册「这个前缀变了叫我」, 变更以事件流推送, 成本 O(变更数) 而不是 O(实例数×轮询频率)。两个易错点: 断线重连必须带 WithRev(否则窗口内变更全漏); ZK 的 Watch 是一次性触发, 收到事件要重新注册。
wch := cli.Watch(ctx, "cfg/gateway/", clientv3.WithPrefix(), clientv3.WithRev(lastRev+1))
for wresp := range wch { apply(wresp.Events) }
// 关键: 断线重连带 WithRev(上次版本号), 断连窗口内的事件一个不漏
Control Plane / Data Plane
控制面慢而稳(选主/调谐/下发, 秒级容忍), 数据面快而薄(转发/执行, 毫秒级)。纪律: 数据面只读本地快照, 永不直接依赖协调服务 — 控制面抖一下, 不能放大成全站抖动; 控制面挂了, 数据面靠最后已知配置继续跑。
// 控制面: Watch etcd → 原子替换路由表快照; 数据面: 每请求只读快照
atomic.StorePointer(&routes, newTable)
// 关键: 数据面请求路径上没有 etcd — 协调服务挂了转发不受影响
Reconciliation 与 Desired State
声明期望状态(spec), 控制器无限循环 diff(期望, 实际) 并向期望收敛。每轮全量对比天然幂等: 漏掉的事件、执行一半的变更、控制器重启, 都会被下一轮纠正 — K8s 控制器、Sentinel、Keepalived 全是这一个模式。
for range ticker.C {              // 5s 一轮, 不依赖事件, 漏了也会补齐
    applyDiff(loadSpec(), loadActual())  // 期望 vs 实际 → 补差
}
// 关键: diff+apply 幂等, 「对账式」设计消灭了对事件可靠性的依赖

🏭 生产实战 real world

场景 1 · 结算定时任务 10 副本, 只能有一个实例真的在跑

结算 job 按 Deployment 部了 10 个副本, 全都执行一遍 = 重复扣款。用 etcd concurrency 选主: 只有 leader 进任务, 其余 9 个在 Campaign 上排队, 会话断开自动让位。

cli, _ := clientv3.New(clientv3.Config{
    Endpoints:   []string{"etcd-1:2379", "etcd-2:2379", "etcd-3:2379"},
    DialTimeout: 5 * time.Second,
})
sess, _ := concurrency.NewSession(cli, concurrency.WithTTL(15)) // 15s 会话租约
e := concurrency.NewElection(sess, "jobs/settle/leader")
if err := e.Campaign(ctx); err != nil {
    log.Fatal(err)                    // 阻塞, 直到多数派承认当选
}
defer e.Resign(context.Background())  // 退出主动让位, 不等 TTL
runSettlement(ctx)                    // 只有 leader 走到这里, 其余 9 个还堵在 Campaign
<-sess.Done()                          // etcd 端会话失效(暴毙/网络断)才返回, 进程退出

收益: 从「分布式锁 + 重试轮询」的 40 行轮询代码收敛成 10 行, 且进程被 kill -9 后接管耗时 ≤ TTL(15s)。

场景 2 · 元数据服务 Master 高可用, 直接用 client-go 的 leaderelection

自研元数据服务要求秒级接管, 手写心跳最容易写歪(忘记 renewDeadline、忘记失主回调)。k8s.io/client-go 的 leaderelection 包把三个超时和回调都定好了。

lock := &resourcelock.LeaseLock{
    LeaseMeta:  metav1.ObjectMeta{Namespace: "infra", Name: "meta-master"},
    Client:     kube.Client(),
    LockConfig: resourcelock.ResourceLockConfig{Identity: hostname},
}
leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{
    Lock:          lock,
    LeaseDuration: 15 * time.Second, // 主失联后 15s 内其他副本可接管
    RenewDeadline: 10 * time.Second, // 每 10s 内必须完成一次续约
    RetryPeriod:   2 * time.Second,  // 续约失败的重试间隔
    Callbacks: leaderelection.LeaderCallbacks{
        OnStartedLeading: startServing, // 当选: 先 warmup 再对外服务
        OnStoppedLeading: drainAndExit, // 失主: 停写 + 退出进程, 防赖着不走
    },
})

经验值: LeaseDuration > RenewDeadline > RetryPeriod × 2, 这个配比在 K8s 自家组件(kube-scheduler/controller-manager)里已验证多年。

场景 3 · 运维平台并发下发配置, Redis 锁防重复推送

配置平台 8 个实例都可能触发「下发到网关」, 并发下发造成版本交错。SET NX PX + Lua 校验 owner 释放, 三件套一个不能少。

owner := uuid.NewString() // 每个持锁者唯一身份
ok, err := rdb.SetNX(ctx, "lock:cfg-push", owner, 30*time.Second).Result()
if !ok { return ErrLocked }              // 别人持有中, 直接拒绝而不是排队
defer func() {
    // 释放必须原子: 校验 owner 后再删, 防止 A 超时后误删 B 的锁
    rdb.Eval(ctx, `if redis.call("GET", KEYS[1]) == ARGV[1] then
        return redis.call("DEL", KEYS[1]) end return 0`,
        []string{"lock:cfg-push"}, owner)
}()
pushConfig(ctx) // 锁内只做下发动作; 超过 30s 的活要靠 watchdog 续期

场景 4 · 网关配置热更新: 从 1s 全量轮询改成 Watch 推送

300 个网关实例每秒全量 GET etcd, 读 QPS 300+ 且变更生效慢 1s。改成 Watch 事件流, etcd CPU 从 60% 降到 8%, 生效从秒级变毫秒级。

var lastRev int64
for { // 外层循环只处理「Watch 断了」, 内层消费事件
    wch := cli.Watch(ctx, "cfg/gateway/",
        clientv3.WithPrefix(), clientv3.WithRev(lastRev+1))
    for wresp := range wch {
        for _, ev := range wresp.Events {
            apply(ev.Kv.Key, ev.Kv.Value)  // PUT/DELETE 都会推过来
            lastRev = ev.Kv.ModRevision    // 记进度, 断线从这继续
        }
    }
    // 走到这里 = Watch 断了; 带 lastRev 重连, 断连窗口内的事件不丢
}

场景 5 · 控制面/数据面分离: 网关规则下发不再拖垮转发

曾把「每请求查一次 etcd 拿路由」写进数据面, etcd 一次 200ms 抖动 = 全站请求 +200ms。改成控制面维护本地快照, 数据面零依赖。

// 控制面(低频): Watch 到变更 → 解析 → 原子替换快照, 不加锁
func onConfigChange(ev *clientv3.Event) {
    tbl := parseRules(ev.Kv.Value)
    atomic.StorePointer(&routes, unsafe.Pointer(&tbl))
}
// 数据面(高频): 每请求只读快照 — etcd 故障时继续用最后已知配置
func route(req *Request) *Target {
    tbl := (*RoutingTable)(atomic.LoadPointer(&routes))
    return tbl.Match(req)
}

量化: 数据面 p99 从 42ms 降到 3ms; etcd 停机演练 10 分钟, 转发成功率 100%(用旧快照)。

场景 6 · 调度器单活: kube-scheduler 的 leaderElection 配置模板

调度器必须单活(两个调度器抢同一个 pod 会绑定两次)。K8s 官方做法是 Lease 资源锁, 双实例部署也只有一个在工作, 直接抄组件配置即可。

# kube-scheduler.yaml — 单活由 Lease 实现, 主失联后备位自动接管
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
leaderElection:
  leaderElect: true
  resourceLock: "leases"       # Lease 对象; 1.14 前的 endpoints/configmap 已废弃
  resourceName: "kube-scheduler"
  resourceNamespace: "kube-system"
  leaseDuration: 15s           # 失联 15s 后其他副本可接管
  renewDeadline: 10s           # 10s 内续不上就主动退出, 不赖位
  retryPeriod: 2s              # 续约重试间隔

场景 7 · 自研 Operator 的 Reconciliation 调谐循环

事件会丢、回调会乱序, 一旦依赖「变更触发」逻辑就会漏。改成对账式: 每 5s 全量 diff 期望与实际, 任何单轮失败都被下一轮自愈。

func (c *Controller) reconcile(ctx context.Context) error {
    want, err := c.listSpec(ctx)   // 期望状态: 用户声明的 replicas=5
    if err != nil { return err }
    got, err := c.listActual(ctx)  // 实际状态: 现在活着的实例
    if err != nil { return err }
    for _, inst := range missing(want, got) {
        c.create(ctx, inst)        // 少了就补
    }
    for _, inst := range extra(want, got) {
        c.delete(ctx, inst)        // 多了就删
    }
    return nil // 不重试不补偿: 下一轮 ticker 会再 diff 一次
}

场景 8 · 事故排查: 续期协程 panic 被吞, 主位静默易主任务双跑

凌晨对账 job 双跑告警。排查发现 KeepAlive 协程在 etcd 重连期间 panic, 被顶层 recover 吞掉 — 没有报错、没有让位, 租约 30s 后过期, 备位无感接管, 两个实例同时跑批。

// 现场日志: lease keep alive failed: etcdserver: requested lease not found
// 根因: etcd 重连期间续期报错 → 防御性 recover() 吞掉 → 无人续期 → 静默失约
go func() {
    defer func() {
        if r := recover(); r != nil {
            log.Fatalf("keepalive died: %v", r) // 修法: 续期死了必须退出进程
        }
    }()
    for range kaCh { } // 正常时每约 TTL/3 收到一次续期回执
    log.Fatal("session closed, resign")  // 通道关闭 = 会话失效 → 让位退出
}()

修复后补了一条黄金指标: lease 秒数剩余量, 剩余 < 5s 告警, 再也没发生过静默失约。

场景 9 · 协调服务选型速查: etcd / ZooKeeper / Consul / Redis

选型不是背参数, 是对齐「互斥强度」和「一致性承诺」。给团队的选型注释, 贴在架构文档里。

# 选型速查(以各官方文档为准):
# etcd      : Raft 强一致 + Watch + Lease; K8s 原生, 客户端最成熟, 首选默认
# ZooKeeper : ZAB 强一致 + 临时节点 + 一次性 Watch; 老牌稳定, 客户端 API 偏啰嗦
# Consul    : LAN/WAN Gossip, 服务发现/多数据中心强; KV 一致性语义弱于 etcd
# Redis     : SET NX PX 只能做「能容忍失效」的弱互斥, 无一致性保证, 必须配 fencing
# 规则: 选主/元数据 → etcd/ZK; 服务发现 → Consul/K8s SVC; 弱锁 → Redis + fencing

场景 10 · 事故排查: 脑裂 4 分钟后, 用 GTID 找出孤儿写

机房网络分区 4 分钟, 降级失败的旧主与新主都收过写。恢复后主从复制报冲突, 第一步永远是: 对比两侧 gtid_executed 的差集, 找出只在旧主上的孤儿事务。

-- 现场定位(新主上执行), MySQL 8.0, gtid_mode=ON
SELECT @@GLOBAL.gtid_executed;   -- 新主: 3E11FA47-...:1-82001
-- 在旧主上执行: 差集 = 只在旧主落过盘的孤儿事务
SELECT GTID_SUBTRACT('3E11FA47-...:1-81990', '3E11FA47-...:1-82001');
-- → '3E11FA47-...:81991-82001' 这 11 个事务就是脑裂窗口的孤儿写
-- 处置: 旧主先钉死只读, 孤儿事务按业务语义人工核对后补写/丢弃
SET GLOBAL read_only = ON;  SET GLOBAL super_read_only = ON;

复盘结论: 事故根因是旧主降级失败(read_only 未生效), 终极解法是存储层 fencing, 而不是更快的切换。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 锁没设 TTL, 进程一崩锁成化石 — 症状: 重建环境后某任务 key 永远「被锁定」, 新任务全部 ErrLocked. 原因: SET NX 成功后进程崩溃, 无人 DEL, 锁永生. 正解: 锁必须带 PX 过期 + 主动释放。
# 错: SET lock:job w1 NX           # 崩溃后无人释放 → 永久 ErrLocked
# 对: SET lock:job w1 NX PX 30000  # → OK, 30s 后即使崩溃也自动释放
坑 2 · 释放不校验 owner, 删掉别人的锁 — 症状: A 任务超时后顺手 DEL, 把 B 刚拿到的锁删了, C 又拿到 → 三个任务并行. 原因: DEL 无身份校验. 正解: Lua 原子「校验 owner 再删」。
# 错: DEL lock:job   # 把 B 的锁删了 → 互斥被击穿
# 对: EVAL "if redis.call('GET',KEYS[1]) == ARGV[1] then
  return redis.call('DEL',KEYS[1]) end return 0" 1 lock:job <owner>
坑 3 · 任务跑得比锁寿命长, 过期后双执行 — 症状: TTL 30s 的锁, 批处理跑 3 分钟, 30s 后锁易主, 同一批数据被处理两次. 原因: 默认把「锁的授权期」当成「任务执行期」. 正解: 锁内做看门狗续期, 或把任务切成小于 TTL 的片。
// 错: acquire(30s); runLongBatch()  // 任务 3min ≫ 锁 30s, 中途已易主
// 对: go watchdog(lock, 10*time.Second) // 每 10s 续期, 失败立刻停任务
坑 4 · 心跳一超时就选主, 把 GC 当死亡 — 症状: 每次 Full GC 后都出现一次主切换, 流量来回横跳. 原因: 判死阈值小于最大 STW 停顿. 正解: 连续 N 次失败才竞选, 超时必须大于最大停顿。
// 错: if !ping() { elect() }  // 单次超时 → Full GC 就误切
// 对: if miss >= 3 { elect() }  // 连续 3 次失败才进入竞选
坑 5 · 没有 quorum 判活, 分区两侧各自当选 — 症状: 机房级网络分区后出现两个「主」, 各自下发调度. 原因: 每个节点本地判活, 谁也说服不了谁. 正解: 当选必须经协调服务多数派承认, 少数派一侧自动降级只读。
// 错: if !ping(master) { becomeMaster() } // 两半分区都成立 → 双主
// 对: e.Campaign(ctx) // 只有 etcd 多数派承认的一侧能当选
坑 6 · Watcher 只建不关, 重连风暴泄漏连接 — 症状: etcd 客户端报 too many open files, fd 数每天涨. 原因: 断线重连逻辑里新建 Watch 流, 旧的从不 Cancel. 正解: Watch 流用 ctx 管生命周期, 重连前先 cancel 上一轮。
// 错: for { cli.Watch(ctx, key, ...) } // 每次重连新建, 旧流不释放
// 对: wctx, cancel := context.WithCancel(ctx)
//     每轮循环先 cancel() 上一轮的 Watch 流, 再重建
坑 7 · 续期协程 panic 被吞, 租约静默过期 — 症状: 无任何报错, 主在 30s 后悄然易主, 任务双跑. 原因: KeepAlive 协程 panic 被 recover 吞掉, 无续期也无告警. 正解: 续期失败要 fatal/让位, 并把「租约剩余 TTL」做成告警指标。
// 错: defer func() { recover() }() // 吞 panic → 静默失约
// 对: log.Fatalf("keepalive died: %v", r) // 续期线程死了就喊出来
坑 8 · 重选主风暴: 一分钟易主六次 — 症状: leader 反复切换, 下游回调被打爆. 原因: TTL 太短 + 竞选失败无退避, 一次抖动全体共振. 正解: TTL ≥ 10 倍探测周期, 竞选失败加随机退避。
# 错: ttl=3s, 失败立即重试   # 一次抖动 → 全体一起重选
# 对: ttl=15s + rand(0,2s) 退避 # 抖动期间大家错峰, 不共振
坑 9 · 用本机时钟判断租约是否过期 — 症状: 机器时钟慢 20s, 主明明已过期易主, 它却认为还在任, 继续对外写. 原因: 各机时钟会漂会回拨, 客户端无权裁决租约. 正解: 以协调服务端过期为准, 客户端只监听 sess.Done()。
// 错: if time.Now().After(expireAt) { resign() } // 本地时钟会说谎
// 对: <-sess.Done() // etcd 服务端裁决, 到点关闭通道
坑 10 · Raft 集群配 2/4 偶数节点 — 症状: 2 节点 etcd 挂 1 台, 集群整体不可写, 高可用白做. 原因: 2 节点多数派 = 2, 挂 1 即失去多数派; 4 节点容忍 1, 和 3 节点一样却多花钱. 正解: 3 或 5 节点。
# 错: cluster: n1,n2      # 挂 1 台 → 无多数派 → 全停
# 对: cluster: n1,n2,n3    # 挂 1 台仍 2/3, 服务继续
坑 11 · 有脑裂无 fencing, 旧主复活照写 — 症状: 旧主 GC 20s 复活后写成功, 与新主数据交叉错乱. 原因: 存储层不校验 epoch, 来者不拒. 正解: 协调服务发单调 token, 存储层拒绝旧 token。
// 错: store.Put(req)  // 谁的请求都收 → 双写错乱
// 对: if req.Token <= store.LastFencing() { return ErrFenced }
坑 12 · 把业务大对象塞进协调服务 — 症状: etcd 报 etcdserver: request is too large, raft 日志膨胀, 快照越传越慢. 原因: etcd/ZK 是元数据存储不是对象存储(etcd 默认单 value ≤ 1.5MiB). 正解: 协调服务只放指针, 大对象进 OSS/S3。
# 错: etcdctl put /meta/route.json "$(cat 8MB.json)"
#     → etcdserver: request is too large
# 对: etcdctl put /meta/route "s3://cfg/route@v17" # 放指针
坑 13 · 用 1s 轮询替代 Watch — 症状: 300 实例 × 1 QPS 全量 GET, etcd 读 QPS 300+ 且「感觉卡」, 变更生效还慢 1s. 原因: 轮询成本是 O(实例数×频率), Watch 是 O(变更数). 正解: Watch + 断线带 revision 重连。
// 错: for { time.Sleep(time.Second); cli.Get(ctx, key) } // QPS 随实例数涨
// 对: wch := cli.Watch(ctx, key, clientv3.WithRev(rev+1)) // 推送, 0 轮询
坑 14 · 以为分布式锁可重入 — 症状: 同一实例内两层函数各 acquire 一次同一把锁, 第二层永远失败或死等. 原因: Redis 锁只是 key 存在性判断, 无持有者线程概念, 天然不可重入. 正解: 同一执行链用 ctx 传递锁凭证, 或应用层维护持有计数。
// 错: l1 := acquire("lock:job"); l2 := acquire("lock:job") // 第二次失败
// 对: ctx 带上锁凭证向下传, 内层函数见到凭证直接复用
坑 15 · 当选后未完成初始化就接流量 — 症状: 新主刚当选就收到请求, 缓存/连接池未就绪, 大量 5xx. 原因: 把「当选」当「就绪」. 正解: OnStartedLeading 里先 warmup, 再原子翻转 ready 标志, 就绪探针通过才接流。
// 错: e.Campaign(ctx); serve()  // 当选即服务, 缓存是冷的
// 对: warmup(); atomic.StoreInt32(&ready, 1); serve() // 探针先过
坑 16 · 多把锁获取顺序不一致 → 死锁 — 症状: 迁移任务偶发卡死, 堆栈显示两个协程互等. 原因: 协程 A 按 (r1,r2) 加锁, 协程 B 按 (r2,r1). 正解: 全局规定锁序(如按 key 字典序), 破坏循环等待条件。
// 错: A: lock(r1); lock(r2)   B: lock(r2); lock(r1)  // 互等 → 卡死
// 对: keys := sortKeys(need); for k := range keys { lock(k) }
//     (全局按字典序加锁)
坑 17 · 租约 TTL 过短, 主位天天抖 — 症状: TTL=3s 时每次网络抖 2s 就重选; 改 TTL=300s 后主挂了要 5 分钟才接管. 原因: TTL 同时控制误判率与接管速度. 正解: TTL 取 10 倍探测周期(10~30s 起步), 接管速度交给 Watch 事件。
# 错: TTL 3s  # 抖动就重选, 稳定性崩
# 对: TTL 15s + Watch DELETE 事件触发竞选 # 稳且接管毫秒级
坑 18 · 跨协调服务双写主状态 — 症状: etcd 和 Redis 各存一份「谁是主」, 两边不一致, 两个实例都认为自己是主. 原因: 两个真相源必然漂移. 正解: 单一真相源(etcd), 其余一律只读缓存。
// 错: etcd 写 leader=nodeA, 同时 redis.set("leader", nodeA) // 会漂移
// 对: 只信 etcd; Redis 里的是只读缓存, 冲突时以 etcd 为准
坑 19 · 把服务发现当协调用 — 症状: 用注册中心在线列表的第一个实例当主, AP 模型下列表两台都「在册」, 双主. 原因: 发现服务(Eureka 这类)设计目标是可用性, 不给互斥承诺. 正解: 互斥/选主走 CP 协调服务, 发现只回答「谁在线」。
# 错: registry.list("svc")[0] 当主用 # AP 列表无互斥 → 双主
# 对: 选主走 etcd Campaign; 注册中心只回答「有哪些实例在线」
坑 20 · 选举 key 没配权限, 主被路人顶掉 — 症状: 新同事的调试脚本写了同名选举前缀, 生产主被顶掉, 任务双跑. 原因: etcd 未启用 auth/role, 任何客户端可写选举目录. 正解: 开 auth, 按业务命名空间分 role, 选举 key 限授权用户。
# 错: etcd 无 auth, 谁都能 put /jobs/settle/leader/ # 主被顶掉
# 对: etcdctl role grant job-svc readwrite /jobs/settle/ # 限定命名空间