热点分片不是简单扩容:从超级租户到在线重分片

2026-09-28 22 预计阅读时间: 1 分钟
来源: planetscale.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.

预计阅读时间:10 分钟

一套分片策略在上线当天可能非常均衡,半年后却会被一个“超级租户”击穿。问题往往不在数据库总容量,而在于请求集中到了同一个分片:其他节点还很空闲,热点节点的 CPU、连接池、锁等待和尾延迟却已经失控。

更棘手的是,业务需求也会改变访问模式。原本只按租户查询,后来可能需要跨租户报表、按订单状态检索,或让某个大客户独享资源。来源摘要指出,Neki 面向这类热点分片和需求变化提供了解决路径;由于摘要没有给出具体 API,下面使用厂商无关的模型说明如何识别和处理这类问题,实际落地时应映射到 Neki 的路由与迁移能力。

为什么增加分片不一定有用

假设订单按 tenant_id 哈希:

shard = hash(tenant_id) % shard_count

这种策略有两个明显优点:同一租户的数据天然聚合,按租户查询通常只访问一个分片;租户内事务也比较容易维持。但它隐含了一个假设:不同租户的负载大致接近。

一旦某个租户的写入量比其他租户高几个数量级,它的全部请求仍会进入同一分片。把节点数从 8 增加到 16,只会重新分配其他租户,无法把这个大租户拆开。热点分片因此不是单纯的容量问题,而是路由键粒度过粗的问题。

判断热点时,不要只看每台机器的数据量。至少应同时观察:

  • 每个分片的 QPS、写入吞吐和连接数;
  • P95、P99 延迟以及锁等待时间;
  • 每个租户或路由键贡献的请求比例;
  • 数据增长速度、复制延迟和后台任务积压;
  • 单个大租户内部是否还有稳定的二级键,例如订单 ID、用户 ID 或时间桶。

如果某个分片很忙,但其中的数据量并不大,继续扩容存储通常不会解决问题。

用虚拟桶拆开超级租户

常见做法是在租户键之后增加一个桶编号:

route_key = tenant_id + bucket_id
bucket_id = hash(order_id) % bucket_count

这样,一个租户可以分散到多个物理分片。代价也很明确:只提供 tenant_id 的查询需要访问多个桶,再聚合结果。因此,没有必要让所有租户都承担这种复杂度。更实用的方案是保留普通租户的单分片路由,只为热点租户配置多个虚拟桶。

下面是一个可直接运行的 Python 模拟。它比较“只按租户分片”和“只拆分热点租户”两种策略。示例假设租户 42 是超级租户;实际系统应从监控和路由元数据中识别热点租户,而不是写死在代码里。

# hot_shard_demo.py
import hashlib
from collections import Counter

SHARD_COUNT = 8
HOT_TENANT = 42
HOT_BUCKETS = 64


def stable_hash(value: str) -> int:
    digest = hashlib.sha256(value.encode("utf-8")).digest()
    return int.from_bytes(digest[:8], "big")


def tenant_only(tenant_id: int, order_id: int) -> int:
    return stable_hash(str(tenant_id)) % SHARD_COUNT


def split_hot_tenant(tenant_id: int, order_id: int) -> int:
    if tenant_id != HOT_TENANT:
        return tenant_only(tenant_id, order_id)

    bucket = stable_hash(str(order_id)) % HOT_BUCKETS
    return stable_hash(f"{tenant_id}:{bucket}") % SHARD_COUNT


def simulate(router):
    load = Counter()

    # 99 个普通租户,每个产生 1,000 个订单
    for tenant_id in range(1, 101):
        if tenant_id == HOT_TENANT:
            continue
        for order_id in range(1_000):
            load[router(tenant_id, order_id)] += 1

    # 一个超级租户产生 100,000 个订单
    for order_id in range(100_000):
        load[router(HOT_TENANT, order_id)] += 1

    return [load[shard] for shard in range(SHARD_COUNT)]


def report(name, loads):
    average = sum(loads) / len(loads)
    print(name)
    print("  loads:", loads)
    print(f"  max/avg: {max(loads) / average:.2f}x")


if __name__ == "__main__":
    report("tenant-only routing", simulate(tenant_only))
    report("split hot tenant", simulate(split_hot_tenant))

运行:

python hot_shard_demo.py

这个示例不是 Neki API,而是一个可运行的路由模型。改造时可调整 SHARD_COUNT、HOT_TENANT 和 HOT_BUCKETS,观察负载倾斜如何变化。生产环境还需要使用可持久化的路由元数据,不能让每个应用实例自行猜测桶数。

在线重分片的难点在迁移,而不在哈希函数

改变公式很容易,安全地移动已有数据才是核心问题。推荐在应用与物理分片之间增加一层逻辑路由,使路由规则可以版本化。例如,元数据可以表达为:

routing:
  default:
    strategy: tenant_hash
    shard_count: 8
  tenants:
    "42":
      strategy: bucketed
      bucket_count: 64
      route_version: 2
      migration_state: dual_write

这同样是厂商无关的示意配置,不代表 Neki 的实际语法。关键不在字段名称,而在于路由状态必须集中管理、可审计,并能让所有客户端一致地看到版本变化。

一次稳妥的在线迁移通常包含以下阶段:

  1. 建立新路由:为热点租户创建虚拟桶和目标分片映射,但暂不切换读取。
  2. 复制历史数据:限速回填,避免迁移任务再次制造热点。
  3. 追平增量:通过变更日志、CDC 或受控双写同步迁移期间的新数据。
  4. 校验一致性:比较记录数、主键集合、校验和以及业务聚合值。
  5. 切换读取:将路由版本切到新布局,并短期保留旧路径作为回退。
  6. 停止旧写入并清理:等待事务、重试请求和缓存过期后,再删除旧副本。

双写并不是免费的保险。任一目标写入失败时,都需要定义重试、幂等键和冲突处理策略。如果系统无法可靠双写,更适合采用单写加变更日志复制,并在切换前确认复制延迟已经归零。

新需求会反过来约束分片键

产品经理提出的新查询,可能让原有分片键失去优势。例如,按租户分片非常适合查看某个客户的订单,却不适合扫描所有租户的“待退款订单”。此时不要立刻更换主分片键,可以先区分业务路径:

  • 点查询和租户内事务继续访问主存储;
  • 跨租户检索写入独立索引或搜索系统;
  • 分析报表通过 CDC 进入数据仓库;
  • 超级租户采用专属分片或多桶路由;
  • 突发任务通过租户级限流和队列削峰。

分片数据库不应该同时承担事务处理、全文检索和大规模分析。为了一个新报表重排全部在线数据,通常比建立派生视图风险更高。

上线前的检查清单

如果已经使用 Neki,应重点确认其具体能力如何对应下面这些问题,而不是假设平台会自动做出所有决策:

  • 能否定位到造成热点的租户或路由键,而不仅是热点节点;
  • 是否支持单独拆分一个租户,而不迁移全部租户;
  • 路由变更期间如何处理读写一致性;
  • 客户端缓存旧路由时会发生什么;
  • 迁移能否限速、暂停、恢复和回滚;
  • 校验失败时是否保留旧副本;
  • 多桶查询的扇出上限、超时和分页语义是否明确;
  • 独享分片、虚拟桶和查询扇出的成本如何计量。

好的分片策略不是一次性找到完美的哈希函数,而是保留重新布局的能力。把路由从应用代码中解耦,用真实流量识别超级租户,并将迁移设计成可观察、可暂停、可回滚的状态机,才能在负载和产品需求变化时避免被某个热点分片拖住。


相关推荐