用固定子集群重构 M3DB 分片:把故障半径控制在可预测范围内

2026-09-21 21 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

分布式数据库扩容并不只是“多加几台机器”。如果分片副本在整个集群内自由组合,一台节点发生故障、一次滚动维护,甚至一次普通扩容,都可能牵动大量节点和数据迁移。Uber 对 M3DB 分片放置方式的调整,核心是引入固定大小的子集群,让分片依赖、故障影响和迁移范围都变得可控。

这项设计值得关注的地方,不只是把大集群切成小组,而是同时处理了三个容易互相冲突的目标:限制故障半径、维持副本隔离,并在扩缩容时减少不必要的数据移动。

为什么全局分片关系会放大故障影响

假设一个 M3DB 集群有数十或数百台节点,每个分片保存多个副本。如果副本放置器可以从整个集群任意选择节点,那么每台节点都可能与大量其他节点共享不同分片的副本关系。

这种布局会形成一个高度连接的依赖图:

  • 节点故障后,修复流量可能散布到大量节点;
  • 维护一组节点时,需要同时检查许多远端副本关系;
  • 扩容加入新节点后,为了恢复均衡,可能触发范围很大的重分片;
  • 即使单次故障不大,恢复过程产生的网络、磁盘和 CPU 压力也可能扩大影响。

固定大小的子集群改变了依赖图。每个分片的副本被约束在一个有限节点集合内,同时继续遵守副本隔离规则。这样,一个节点能够影响的伙伴数量便有明确上限,不再随着整个集群规模线性增长。

例如,一个集群可以在逻辑上组织为:

M3DB Cluster
├── Subcluster A: a1, a2, a3
├── Subcluster B: b1, b2, b3
└── Subcluster C: c1, c2, c3

如果副本数为 3,那么每个子集群可以分别包含来自 3 个独立故障域的节点。分片 42 的三个副本只依赖某一个子集群,而不是跨越整个集群。这里的“固定大小”很重要:它为故障影响建立了结构性上界。

子集群不能以牺牲副本隔离为代价

把节点分组并不意味着简单地把相邻机器装进同一个桶。一个有效的子集群仍然需要满足副本隔离约束,例如副本不能全部位于同一个机架或可用区。

可以把放置规则概括为两层:

  1. 为一个分片选择目标子集群;
  2. 在子集群内部,把副本放到不同故障域的节点上。

因此,子集群大小、复制因子和可用故障域数量必须一起设计。若复制因子是 3,而一个子集群只有两个独立故障域,那么无论放置算法多聪明,都无法提供三副本的完整隔离。

固定子集群还会带来容量问题。每个子集群都需要保留足够余量,以承受单节点故障、修复流量和短期热点。若日常负载已经接近满载,故障范围虽然被限制住了,局部子集群仍可能因恢复压力而过载。

贪心迁移:在生成目标布局时直接考虑移动成本

扩容或调整节点时,最直接的做法是先计算一个完全均衡的新布局,再执行一次独立的重平衡。这种方式容易产生“数学上整齐、操作上昂贵”的结果:大量已经处于合法位置的数据,只为了追求全局对称而被搬走。

新的思路是在选择目标分片位置时,就把迁移成本纳入决策。贪心算法可以逐个评估候选迁移,优先选择同时满足以下条件的方案:

  • 目标子集群仍有容量;
  • 副本继续分布在独立故障域;
  • 能修复当前不符合约束的放置;
  • 需要移动的数据尽可能少;
  • 不引入新的跨子集群依赖。

这样,放置和重平衡不再是两个割裂阶段。算法生成目标状态时已经考虑现有状态,可以避免额外的全量重平衡过程。

不过,贪心不等于只看副本数量。生产环境还应把分片字节数、写入速率、查询负载、修复带宽和节点余量纳入评分。移动一个 500 GB 的热分片,与移动三个 5 GB 的冷分片,成本显然不同。

一个可运行的简化放置模型

下面的 Python 脚本不是 M3DB 的真实配置接口,而是一个可运行的概念模型。它假设每个子集群包含三个不同可用区的节点,并在容量约束下优先复用现有副本。

将代码保存为 planner.py,然后运行 python3 planner.py

from math import ceil

# 假设:每个子集群有 3 个节点,分别位于 3 个故障域。
subclusters = {
    "sc-a": ("a1", "a2", "a3"),
    "sc-b": ("b1", "b2", "b3"),
}

zones = {
    "a1": "zone-1", "a2": "zone-2", "a3": "zone-3",
    "b1": "zone-1", "b2": "zone-2", "b3": "zone-3",
}

# shard 2 当前跨越两个子集群,正是需要消除的依赖。
current = {
    0: ("a1", "a2", "a3"),
    1: ("a1", "a2", "a3"),
    2: ("a1", "b2", "a3"),
    3: ("b1", "b2", "b3"),
}

replication_factor = 3
capacity = ceil(len(current) / len(subclusters))
target_load = {name: 0 for name in subclusters}
plan = {}

for shard, old_nodes in sorted(current.items()):
    candidates = []

    for name, new_nodes in subclusters.items():
        if target_load[name] >= capacity:
            continue

        candidate_zones = {zones[node] for node in new_nodes}
        if len(candidate_zones) != replication_factor:
            continue

        moved_replicas = len(set(new_nodes) - set(old_nodes))
        score = (moved_replicas, target_load[name], name)
        candidates.append((score, name, new_nodes))

    if not candidates:
        raise RuntimeError(f"No valid placement for shard {shard}")

    score, name, new_nodes = min(candidates)
    target_load[name] += 1
    plan[shard] = (name, new_nodes, score[0])

for shard, (name, nodes, moves) in plan.items():
    print(
        f"shard={shard} subcluster={name} "
        f"nodes={','.join(nodes)} moved_replicas={moves}"
    )

示例输出如下:

shard=0 subcluster=sc-a nodes=a1,a2,a3 moved_replicas=0
shard=1 subcluster=sc-a nodes=a1,a2,a3 moved_replicas=0
shard=2 subcluster=sc-b nodes=b1,b2,b3 moved_replicas=2
shard=3 subcluster=sc-b nodes=b1,b2,b3 moved_replicas=0

这个模型展示了三个关键约束:副本不能跨子集群、每组副本覆盖三个故障域、目标容量有限。同时,评分优先保留已有副本,避免为了形式上的均衡移动所有数据。

真实系统需要进一步改造评分函数,例如按字节估算迁移成本:

migration_cost = moved_bytes / available_repair_bandwidth
score = (
    violates_isolation,
    exceeds_capacity,
    migration_cost,
    projected_cpu_load,
)

硬约束应排在评分最前面,迁移成本和负载均衡则作为后续优化目标。否则,算法可能为了少移动数据而接受不安全的副本布局。

落地时应重点验证什么

固定子集群不是免费的抽象。它用一部分全局调度自由度换取可预测的故障边界。采用类似设计时,建议重点检查以下事项:

  • 子集群尺寸:是否能够容纳复制因子,并覆盖足够多的独立故障域;
  • 容量余量:单节点失效后,剩余节点能否承受查询、写入和修复流量;
  • 热点分片:局部热点是否会被固定边界困在同一个子集群;
  • 迁移预算:每轮允许移动多少字节、占用多少网络和磁盘带宽;
  • 扩缩容单位:是否应以完整子集群为单位增加或移除容量;
  • 观测指标:除了节点利用率,还要监控跨组依赖、修复队列、迁移字节数和故障域分布;
  • 回滚能力:新布局未完成前,控制面必须能识别过渡状态,避免重复迁移或提前删除旧副本。

这次重构的价值,在于把“故障可能影响多大”从一个随集群增长而扩散的问题,变成由子集群大小直接约束的工程参数。对于大规模状态系统,完全均衡通常不是唯一目标;可预测的故障半径、有限的数据迁移和清晰的恢复路径,往往更加重要。


相关推荐