3. 分布式调度核心:分布式协调、Leader选举与任务分片

好,咱们进入第三章节。前面聊了调度模型和状态管理,那都是单机视角。一旦把调度系统放到集群里,问题就来了——谁说了算?任务怎么分?节点挂了怎么办?

说白了,分布式调度最怕的就是「脑裂」。两个调度器都觉得自己是老大,同时下发指令,那任务就乱套了。我早期参与过一个实时数仓项目,就因为协调层没做好,两个节点同时调度同一个数据源,结果数据重复计算,对账对了一星期……嗯,从那以后我对分布式协调这块就特别敏感。

3.1 分布式协调:选 ZooKeeper 还是 Etcd?

先聊聊协调层选型。目前主流就两个:ZooKeeper 和 Etcd。很多人问我哪个好,我的回答是——看场景。

对比维度 ZooKeeper Etcd
一致性算法 ZAB (ZooKeeper Atomic Broadcast) Raft
数据模型 树形 ZNode,支持临时节点 KV 存储,支持目录结构
Watch 机制 一次性触发,需重新注册 持续 Watch,支持历史版本
性能 读性能好,写性能一般 读写均衡,延迟更低
运维复杂度 依赖 Java,配置较多 Go 编写,部署简单
典型场景 Hadoop/Spark 生态、传统调度 Kubernetes、云原生调度
我的建议:如果你的调度系统跑在 K8s 上,优先选 Etcd,天然集成。如果是传统大数据集群,ZooKeeper 生态更成熟。我个人习惯在流批一体调度里用 Etcd,因为它的 Watch 机制更稳定,不会像 ZK 那样 Watch 丢了还得重连。

3.2 Leader 选举:谁当老大?

分布式调度必须有个 Leader。为什么?因为多个调度器同时决策,任务分配会冲突。Leader 选举就是解决这个问题的。

选举算法有很多,但生产环境最常用的是 Bully 算法 和 Raft 选举。我简单说下核心逻辑:

  1. 节点启动时:向协调层注册,创建临时节点(ZooKeeper)或租约(Etcd)。
  2. 竞选阶段:所有节点尝试创建同一个锁节点,谁成功谁就是 Leader。
  3. 心跳维持:Leader 定期续约,如果挂了,临时节点自动消失。
  4. 重新选举:其他节点监听到 Leader 消失,重新发起竞选。

你想想看,这个过程其实很像「抢凳子」游戏。谁先抢到锁,谁就坐上去。但有个坑——脑裂。如果网络抖动,Leader 的心跳没及时续约,其他节点以为它挂了,选出了新 Leader。结果旧 Leader 又活过来了……两个 Leader 同时存在,这就是灾难。

避坑指南:我曾经在一个项目里遇到过网络分区导致的脑裂。旧 Leader 所在的子网跟协调层断开了,但它还在继续调度任务。新 Leader 选出来后,两个调度器同时下发指令,任务状态全乱了。后来我们加了 fencing 机制——旧 Leader 必须主动释放锁才能继续工作,否则强制退出。

下面是一个基于 Etcd 的 Leader 选举伪代码,我习惯用 Go 写调度组件:

// 基于 Etcd 的 Leader 选举
func electLeader(client *etcd.Client, key string) {
    lease := client.NewLease(10) // 10秒租约
    lease.Grant()
    
    // 尝试创建锁
    txn := client.Txn().
        If(notExist(key)).
        Then(put(key, nodeID, lease)).
        Else(get(key))
    
    resp := txn.Commit()
    if resp.Succeeded {
        // 成为 Leader
        startScheduler()
        // 定期续约
        go keepAlive(lease)
    } else {
        // 成为 Follower,监听 Leader 变化
        watch(key)
    }
}
关键点:租约时间不能太长也不能太短。太短了网络波动就频繁选举,太长了 Leader 挂了恢复慢。我一般设 10-15 秒,配合 3 次重试。

3.3 任务分片:把大任务切成小任务

Leader 选出来了,接下来就是分任务。任务分片的核心思想是:把一个大任务拆成多个小片,分给不同的 Worker 执行。

分片策略有很多种,我列几个常用的:

  • Hash 分片:按数据 Key 的 Hash 值取模,分配到不同分片。简单,但数据倾斜时负载不均。
  • 范围分片:按数据范围划分,比如日期、ID 区间。适合有序数据,但热点问题明显。
  • 轮询分片:轮流分配,负载最均衡,但数据局部性差。
  • 动态分片:根据 Worker 的实时负载动态调整分片大小。复杂,但效果好。

我在流批一体调度里,最常用的是 动态分片 + 一致性 Hash。为什么?因为流任务的数据量波动大,静态分片容易导致某些 Worker 撑爆,某些 Worker 闲着。

举个例子,假设我们有 100 个数据分区,4 个 Worker。一致性 Hash 会把分区映射到一个环上,每个 Worker 负责环上的一段。当 Worker 增减时,只影响相邻的分区,不需要全量重分。

我的经验:分片粒度要适中。太细了,调度开销大;太粗了,负载不均。我一般控制在每个分片处理时间 1-5 秒左右。如果某个分片处理时间超过 10 秒,就考虑再拆细一点。

3.4 负载均衡:让每个 Worker 都不闲着

分片是静态的,负载均衡是动态的。即使分片策略再好,运行过程中也会出现负载倾斜——某个 Worker 处理的数据突然变多,或者某个 Worker 变慢了。

负载均衡的核心是 重新分配。调度器需要定期检查每个 Worker 的负载情况,如果发现某个 Worker 过载,就把它的一部分分片迁移到其他 Worker。

我常用的负载均衡策略:

  1. 最小连接数:优先把新分片分配给当前任务数最少的 Worker。
  2. 加权轮询:根据 Worker 的 CPU、内存、网络等资源权重分配。
  3. 自适应均衡:根据历史处理速度动态调整权重。

下面是一个简单的负载均衡流程图:

负载均衡核心流程 调度器 (Leader) 负载监控 过载? 触发重分配 继续监控 更新分片映射 Worker 1 Worker 2 Worker 3 Worker 4 说明 过载阈值:CPU > 80% 或内存 > 85% 或队列积压 > 1000

负载均衡不是越频繁越好。我见过有人每秒都做均衡,结果分片来回迁移,系统开销比收益还大。我的经验是:每 30-60 秒检查一次,连续 3 次过载才触发迁移。这样可以避免抖动。

核心原则:分布式调度不是追求均衡,而是追求「足够好」。只要所有 Worker 的负载在合理范围内(比如 CPU 使用率相差不超过 20%),就不需要干预。过度均衡反而会降低系统稳定性。

3.5 总结一下

分布式协调、Leader 选举、任务分片和负载均衡,这四个东西是分布式调度的骨架。没有它们,调度系统就是一堆散沙。

我个人习惯把协调层看作「神经系统」,Leader 是「大脑」,分片是「肌肉」,负载均衡是「反射调节」。四者配合好了,系统才能稳定高效地跑起来。

嗯,这一章就到这里。记住一句话:分布式调度最难的不是技术,而是应对不确定性。网络会抖,节点会挂,数据会倾斜——你的系统必须能优雅地处理这些「意外」。