ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

高可用组件如何做分片:规划、分配与故障接管

2026/8/11 0:30:11 拓冰建站 浏览量
高可用组件如何做分片:规划、分配与故障接管

摘要:本文不讨论复杂的调度算法,只解决一个实际问题:多个节点共同处理数据时,怎样分工,节点宕机后怎样接管,扩容时又怎样避免全量迁移。全文使用同一个例子:256 个逻辑分片,运行在 4 个节点上。

假设我们有一个订单处理服务。订单越来越多,一台机器处理不过来,于是服务扩容到 4 个节点。

问题随之而来:一笔订单应该交给哪个节点?两个节点会不会重复处理?其中一台机器宕机以后,剩下的节点如何接手?

分片就是为了解决这类问题。

先弄清楚:分片是什么

可以把分片理解成一组编号固定的工作区。这里先创建 256 个逻辑分片,编号为 0255

每笔订单根据 orderId 进入其中一个分片:

static int shardOf(String orderId) {int hash = orderId.hashCode();return Math.floorMod(hash, 256);
}

相同的 orderId 每次都会得到相同的分片编号。比如订单 A10086 始终属于 37 号分片。

这里的 256 不是固定答案。它应该明显大于节点数,让系统有足够细的单位可以分配。对于几十个节点以内的中小型服务,可以先从 128、256 或 512 开始,再根据单分片负载和管理成本调整。

为什么不能直接映射到节点

最直观的写法是:

int nodeIndex = Math.floorMod(orderId.hashCode(), aliveNodeCount);

当系统有 4 个节点时,它对 4 取模;扩容到 5 个节点后,它改成对 5 取模。因为公式中的 aliveNodeCount 变了,在哈希值分布均匀的情况下,大约 80% 的订单会得到不同的节点编号。本地缓存、处理进度和连接也会随之大面积重建。

新增节点以后,一部分订单必然要换节点,否则新节点就分担不到工作。我们真正要避免的,不是任何订单都不移动,而是节点数量一变化,就让大量订单被动移动。

更稳妥的做法是增加一层固定的逻辑分片:

订单 -> 固定的逻辑分片 -> 当前负责该分片的节点

第一步长期不变:A10086 始终属于 37 号分片。第二步不是根据节点数临时计算,而是一份保存在数据库或配置中心的分配关系。新增节点 E 时,这份关系不会自动变化。

为了让 E 分担工作,系统可以从 256 个分片中挑选大约 51 个,分批把它们的 owner 改成 E。只有这些分片中的订单会换节点,其余约 80% 的分片仍由原节点处理。迁移无法完全消除,但迁移哪些分片、一次迁移多少、什么时候迁移,都变得可以控制。

订单先映射到固定逻辑分片,再由分配器把分片交给节点

Caption: 新增节点不会自动改变全部映射;系统只把选中的分片迁移给新节点。

这层间接关系看起来多了一步,却把一次不可控的大范围重映射,变成了按分片执行的可控迁移。

256 个分片怎样分给 4 个节点

最简单的初始分配可以很直白:

节点 A:0   - 63
节点 B:64  - 127
节点 C:128 - 191
节点 D:192 - 255

生产环境通常会把 shard -> node 的分配结果保存在数据库或配置中心。节点启动后读取自己负责的分片,而不是根据“当前有几台机器”临时猜测。

如果不同分片的工作量接近,按数量平均分就够了。如果 20 号分片的流量是其他分片的 10 倍,就应该按实际负载分配,而不是只追求每台机器的分片数量相同。

到这里,我们只解决了“希望由谁处理”,还没有解决“此刻谁真正有权处理”。仅靠一份分配配置,无法避免旧节点和新节点同时工作。

Lease:一张会过期的处理许可证

每个分片在共享数据库中保存一条 lease。它可以理解成一张会过期的处理许可证:

shard_id       = 37
owner_node_id  = A
lease_until    = 12:00:15
fencing_token  = 18

这条记录表示:节点 A 可以处理 37 号分片,有效期到 12:00:15,本次持有许可证的编号是 18。

节点 A 必须定期续租。续租时不能只检查分片编号,还要同时检查 owner、token 和过期时间:

update shard_lease
set lease_until = :new_lease_until
where shard_id = :shard_idand owner_node_id = :node_idand fencing_token = :tokenand lease_until >= current_timestamp;

如果更新行数为 0,说明节点已经失去处理权。它必须立刻停止接收这个分片的新任务,而不是继续运行,等待下一次续租碰运气。

一个容易理解的时间配置是:lease 有效期 15 秒,每 5 秒续租一次。这样一次短暂抖动不会立即触发接管,同时还剩两次续租机会。具体数值仍要根据数据库延迟、网络抖动和允许的故障恢复时间压测确定。

Fencing token:阻止旧节点继续写

只有 lease 还不够。

假设节点 A 因为长时间 GC 暂停了 20 秒。它的 lease 已经过期,节点 B 接管了 37 号分片。此时 A 又恢复运行,但 A 并不知道自己暂停了这么久,可能继续提交旧任务。

这就是 fencing_token 的作用。每次新的节点接管分片时,token 都加 1:

12:00:00  A 持有分片 37,token = 18
12:00:15  A 的 lease 过期
12:00:16  B 接管分片 37,token = 19
12:00:20  A 恢复,但它手里的 token 仍然是 18

修改共享状态时,要验证当前 lease 仍属于这个节点,并且 token 相同:

update shard_checkpoint c
set checkpoint = :checkpoint
where c.shard_id = :shard_idand exists (select 1from shard_lease lwhere l.shard_id = c.shard_idand l.owner_node_id = :node_idand l.fencing_token = :tokenand l.lease_until >= current_timestamp);

节点 A 带着 token 18 写入时,更新行数会是 0,因为数据库中的 token 已经变成 19。A 即使还活着,也不能再修改 37 号分片的受保护状态。

节点失去 lease 后由新节点接管,旧 token 的写入被拒绝

Caption: lease 决定何时可以接管,fencing token 负责拦住恢复运行的旧节点。

需要注意,token 只能保护会验证它的写入。发送邮件、调用第三方接口这类外部副作用,还需要业务幂等键或去重记录。

节点宕机后,接管过程是什么

现在把故障流程完整走一遍。节点 A 原本负责 64 个分片,随后突然宕机:

  1. A 无法继续续租,它持有的 lease 陆续过期。
  2. 其他节点发现这些分片已经没有有效 owner。
  3. 系统按照分配计划选择新的 owner,例如让 B、C、D 各接管一部分。
  4. 新 owner 在数据库中原子地取得 lease,并获得更大的 token。
  5. 新 owner 从数据库加载处理进度和必要状态,然后开始工作。
  6. A 如果恢复,旧 token 无法通过写入检查,只能重新读取分配结果。

接管 SQL 的核心条件是“旧 lease 已经过期”。同时完成 owner 替换和 token 递增:

update shard_lease
set owner_node_id = :new_owner,lease_until = :new_lease_until,fencing_token = fencing_token + 1
where shard_id = :shard_idand lease_until < current_timestamp;

多个节点同时尝试接管时,只有一个节点能成功更新。其他节点看到更新行数为 0,就放弃本轮竞争。

分片多了,续租不要逐条往返

一个节点持有 64 个分片,如果每个分片都单独开启事务续租,会产生很多重复的网络往返。

更实用的方式是按小批次续租。例如一次续租 32 个分片,共用一个数据库连接和一次事务,但每个分片仍然检查自己的 owner 和 token。

逐分片续租与小批量续租的对比

Caption: 批量续租减少数据库往返,但不能省略每个分片的权限检查。

批次也不能无限增大。事务太大会延长锁持有时间,还可能让一次失败影响过多分片。实际实现中要记录每个分片的更新结果:没有续租成功的分片,立即从本地 owner 列表中移除。

从 4 个节点扩容到 5 个节点

扩容时,256 个逻辑分片不变。为了让 5 个节点各自负责大约 51 个分片,只需要把约 51 个分片从 A、B、C、D 迁移到新节点 E,而不是重新映射全部订单。

迁移不要一次完成。可以每轮只移动 5 个分片:

  1. E 为目标分片准备本地资源。
  2. 旧 owner 停止接收新任务并完成在途任务。
  3. 旧 lease 被释放或到期。
  4. E 取得新 lease 和新 token。
  5. E 从共享存储恢复进度,开始处理。

限制每轮迁移数量很重要。一次迁移太多分片,会同时触发缓存预热、数据库读取和连接创建,扩容反而可能把正常服务拖慢。

本地状态丢了也应该能恢复

节点为了性能,通常会在内存中保存缓存、索引或处理进度。这些数据应该被当作“可以重建的副本”,数据库等共享存储才是事实来源。

例如节点先更新了内存,随后数据库写入失败,此时内存状态已经不可信。正确做法不是把失败对象简单塞回本地队列,而是暂停这个分片,从共享存储重新加载:

try {repository.save(result, shardId, token);
} catch (Exception e) {shardState.markReloadRequired();shardState.stopAcceptingNewWork();shardState.reloadFrom(repository);
}

本地状态不确定时,从事实来源重新加载

Caption: 本地状态用于提速,不应该成为故障恢复时唯一可信的数据。

这样无论是节点重启、分片迁移,还是一次不确定的写入失败,都有同一条恢复路径。

实现时先把这些事情做对

如果要从零实现一套分片组件,我会先完成下面这组最小闭环:

  • 选择稳定的业务键,并固定逻辑分片数。
  • 持久化 shard -> node 分配关系。
  • 使用 lease 表示有期限的处理权。
  • 每次接管递增 fencing token,并在共享状态写入时验证它。
  • 续租失败后立即停止该分片的新任务。
  • 接管后从共享存储恢复进度,不相信旧节点的内存状态。
  • 扩容时限制每轮迁移的分片数量。

至少还要测试以下故障:进程被直接终止、节点暂停超过 lease 时间、数据库短暂不可用、两个节点同时竞争一个分片,以及旧节点恢复后继续写入。

监控方面,最有用的不是“服务是否存活”,而是每个节点持有多少分片、续租失败次数、故障接管耗时、旧 token 被拒绝的次数,以及每轮迁移了多少分片。

小结

高可用分片可以先记住四句话:

  1. 业务数据先进入固定的逻辑分片,不要直接对节点数取模。
  2. 分配关系决定“希望谁处理”,lease 决定“现在谁有权处理”。
  3. 新节点接管时递增 token,旧节点即使恢复也不能继续写。
  4. 扩容只移动一部分分片,本地状态始终可以从共享存储重建。

取模只是分片的起点。真正让它具备高可用能力的,是一套明确的所有权、接管和恢复规则。

摘要:本文不讨论复杂的调度算法,只解决一个实际问题:多个节点共同处理数据时,怎样分工,节点宕机后怎样接管,扩容时又怎样避免全量迁移。全文使用同一个例子:256 个逻辑分片,运行在 4 个节点上。

假设我们有一个订单处理服务。订单越来越多,一台机器处理不过来,于是服务扩容到 4 个节点。

问题随之而来:一笔订单应该交给哪个节点?两个节点会不会重复处理?其中一台机器宕机以后,剩下的节点如何接手?

分片就是为了解决这类问题。

先弄清楚:分片是什么

可以把分片理解成一组编号固定的工作区。这里先创建 256 个逻辑分片,编号为 0255

每笔订单根据 orderId 进入其中一个分片:

static int shardOf(String orderId) {int hash = orderId.hashCode();return Math.floorMod(hash, 256);
}

相同的 orderId 每次都会得到相同的分片编号。比如订单 A10086 始终属于 37 号分片。

这里的 256 不是固定答案。它应该明显大于节点数,让系统有足够细的单位可以分配。对于几十个节点以内的中小型服务,可以先从 128、256 或 512 开始,再根据单分片负载和管理成本调整。

为什么不能直接映射到节点

最直观的写法是:

int nodeIndex = Math.floorMod(orderId.hashCode(), aliveNodeCount);

当系统有 4 个节点时,它对 4 取模;扩容到 5 个节点后,它改成对 5 取模。因为公式中的 aliveNodeCount 变了,在哈希值分布均匀的情况下,大约 80% 的订单会得到不同的节点编号。本地缓存、处理进度和连接也会随之大面积重建。

新增节点以后,一部分订单必然要换节点,否则新节点就分担不到工作。我们真正要避免的,不是任何订单都不移动,而是节点数量一变化,就让大量订单被动移动。

更稳妥的做法是增加一层固定的逻辑分片:

订单 -> 固定的逻辑分片 -> 当前负责该分片的节点

第一步长期不变:A10086 始终属于 37 号分片。第二步不是根据节点数临时计算,而是一份保存在数据库或配置中心的分配关系。新增节点 E 时,这份关系不会自动变化。

为了让 E 分担工作,系统可以从 256 个分片中挑选大约 51 个,分批把它们的 owner 改成 E。只有这些分片中的订单会换节点,其余约 80% 的分片仍由原节点处理。迁移无法完全消除,但迁移哪些分片、一次迁移多少、什么时候迁移,都变得可以控制。

订单先映射到固定逻辑分片,再由分配器把分片交给节点

Caption: 新增节点不会自动改变全部映射;系统只把选中的分片迁移给新节点。

这层间接关系看起来多了一步,却把一次不可控的大范围重映射,变成了按分片执行的可控迁移。

256 个分片怎样分给 4 个节点

最简单的初始分配可以很直白:

节点 A:0   - 63
节点 B:64  - 127
节点 C:128 - 191
节点 D:192 - 255

生产环境通常会把 shard -> node 的分配结果保存在数据库或配置中心。节点启动后读取自己负责的分片,而不是根据“当前有几台机器”临时猜测。

如果不同分片的工作量接近,按数量平均分就够了。如果 20 号分片的流量是其他分片的 10 倍,就应该按实际负载分配,而不是只追求每台机器的分片数量相同。

到这里,我们只解决了“希望由谁处理”,还没有解决“此刻谁真正有权处理”。仅靠一份分配配置,无法避免旧节点和新节点同时工作。

Lease:一张会过期的处理许可证

每个分片在共享数据库中保存一条 lease。它可以理解成一张会过期的处理许可证:

shard_id       = 37
owner_node_id  = A
lease_until    = 12:00:15
fencing_token  = 18

这条记录表示:节点 A 可以处理 37 号分片,有效期到 12:00:15,本次持有许可证的编号是 18。

节点 A 必须定期续租。续租时不能只检查分片编号,还要同时检查 owner、token 和过期时间:

update shard_lease
set lease_until = :new_lease_until
where shard_id = :shard_idand owner_node_id = :node_idand fencing_token = :tokenand lease_until >= current_timestamp;

如果更新行数为 0,说明节点已经失去处理权。它必须立刻停止接收这个分片的新任务,而不是继续运行,等待下一次续租碰运气。

一个容易理解的时间配置是:lease 有效期 15 秒,每 5 秒续租一次。这样一次短暂抖动不会立即触发接管,同时还剩两次续租机会。具体数值仍要根据数据库延迟、网络抖动和允许的故障恢复时间压测确定。

Fencing token:阻止旧节点继续写

只有 lease 还不够。

假设节点 A 因为长时间 GC 暂停了 20 秒。它的 lease 已经过期,节点 B 接管了 37 号分片。此时 A 又恢复运行,但 A 并不知道自己暂停了这么久,可能继续提交旧任务。

这就是 fencing_token 的作用。每次新的节点接管分片时,token 都加 1:

12:00:00  A 持有分片 37,token = 18
12:00:15  A 的 lease 过期
12:00:16  B 接管分片 37,token = 19
12:00:20  A 恢复,但它手里的 token 仍然是 18

修改共享状态时,要验证当前 lease 仍属于这个节点,并且 token 相同:

update shard_checkpoint c
set checkpoint = :checkpoint
where c.shard_id = :shard_idand exists (select 1from shard_lease lwhere l.shard_id = c.shard_idand l.owner_node_id = :node_idand l.fencing_token = :tokenand l.lease_until >= current_timestamp);

节点 A 带着 token 18 写入时,更新行数会是 0,因为数据库中的 token 已经变成 19。A 即使还活着,也不能再修改 37 号分片的受保护状态。

节点失去 lease 后由新节点接管,旧 token 的写入被拒绝

Caption: lease 决定何时可以接管,fencing token 负责拦住恢复运行的旧节点。

需要注意,token 只能保护会验证它的写入。发送邮件、调用第三方接口这类外部副作用,还需要业务幂等键或去重记录。

节点宕机后,接管过程是什么

现在把故障流程完整走一遍。节点 A 原本负责 64 个分片,随后突然宕机:

  1. A 无法继续续租,它持有的 lease 陆续过期。
  2. 其他节点发现这些分片已经没有有效 owner。
  3. 系统按照分配计划选择新的 owner,例如让 B、C、D 各接管一部分。
  4. 新 owner 在数据库中原子地取得 lease,并获得更大的 token。
  5. 新 owner 从数据库加载处理进度和必要状态,然后开始工作。
  6. A 如果恢复,旧 token 无法通过写入检查,只能重新读取分配结果。

接管 SQL 的核心条件是“旧 lease 已经过期”。同时完成 owner 替换和 token 递增:

update shard_lease
set owner_node_id = :new_owner,lease_until = :new_lease_until,fencing_token = fencing_token + 1
where shard_id = :shard_idand lease_until < current_timestamp;

多个节点同时尝试接管时,只有一个节点能成功更新。其他节点看到更新行数为 0,就放弃本轮竞争。

分片多了,续租不要逐条往返

一个节点持有 64 个分片,如果每个分片都单独开启事务续租,会产生很多重复的网络往返。

更实用的方式是按小批次续租。例如一次续租 32 个分片,共用一个数据库连接和一次事务,但每个分片仍然检查自己的 owner 和 token。

逐分片续租与小批量续租的对比

Caption: 批量续租减少数据库往返,但不能省略每个分片的权限检查。

批次也不能无限增大。事务太大会延长锁持有时间,还可能让一次失败影响过多分片。实际实现中要记录每个分片的更新结果:没有续租成功的分片,立即从本地 owner 列表中移除。

从 4 个节点扩容到 5 个节点

扩容时,256 个逻辑分片不变。为了让 5 个节点各自负责大约 51 个分片,只需要把约 51 个分片从 A、B、C、D 迁移到新节点 E,而不是重新映射全部订单。

迁移不要一次完成。可以每轮只移动 5 个分片:

  1. E 为目标分片准备本地资源。
  2. 旧 owner 停止接收新任务并完成在途任务。
  3. 旧 lease 被释放或到期。
  4. E 取得新 lease 和新 token。
  5. E 从共享存储恢复进度,开始处理。

限制每轮迁移数量很重要。一次迁移太多分片,会同时触发缓存预热、数据库读取和连接创建,扩容反而可能把正常服务拖慢。

本地状态丢了也应该能恢复

节点为了性能,通常会在内存中保存缓存、索引或处理进度。这些数据应该被当作“可以重建的副本”,数据库等共享存储才是事实来源。

例如节点先更新了内存,随后数据库写入失败,此时内存状态已经不可信。正确做法不是把失败对象简单塞回本地队列,而是暂停这个分片,从共享存储重新加载:

try {repository.save(result, shardId, token);
} catch (Exception e) {shardState.markReloadRequired();shardState.stopAcceptingNewWork();shardState.reloadFrom(repository);
}

本地状态不确定时,从事实来源重新加载

Caption: 本地状态用于提速,不应该成为故障恢复时唯一可信的数据。

这样无论是节点重启、分片迁移,还是一次不确定的写入失败,都有同一条恢复路径。

实现时先把这些事情做对

如果要从零实现一套分片组件,我会先完成下面这组最小闭环:

  • 选择稳定的业务键,并固定逻辑分片数。
  • 持久化 shard -> node 分配关系。
  • 使用 lease 表示有期限的处理权。
  • 每次接管递增 fencing token,并在共享状态写入时验证它。
  • 续租失败后立即停止该分片的新任务。
  • 接管后从共享存储恢复进度,不相信旧节点的内存状态。
  • 扩容时限制每轮迁移的分片数量。

至少还要测试以下故障:进程被直接终止、节点暂停超过 lease 时间、数据库短暂不可用、两个节点同时竞争一个分片,以及旧节点恢复后继续写入。

监控方面,最有用的不是“服务是否存活”,而是每个节点持有多少分片、续租失败次数、故障接管耗时、旧 token 被拒绝的次数,以及每轮迁移了多少分片。

小结

高可用分片可以先记住四句话:

  1. 业务数据先进入固定的逻辑分片,不要直接对节点数取模。
  2. 分配关系决定“希望谁处理”,lease 决定“现在谁有权处理”。
  3. 新节点接管时递增 token,旧节点即使恢复也不能继续写。
  4. 扩容只移动一部分分片,本地状态始终可以从共享存储重建。

取模只是分片的起点。真正让它具备高可用能力的,是一套明确的所有权、接管和恢复规则。