数据超过单机后怎么拆:分片键、路由、扩容迁移与一致性哈希

从短链接读写路径与支付流水实战出发,讲清分片键怎么选、全局 ID 为什么不能自动路由、取模与基因法怎样工作,以及扩容时如何迁移数据并验证正确性。

短链接服务有两条差异很大的查询路径。用户访问 https://s.example/1G2j 时,跳转接口必须根据短码快速找到原始 URL;创建者打开管理后台时,又希望按 user_id 分页查看自己创建的全部链接。数据还在一张表里时,这两个条件只是普通索引。拆成 32 个库以后,它们会指向一个更早的设计问题:一行数据到底由哪个字段决定去哪里。

上一篇讨论了 UUID、Snowflake 和 Leaf,它们解决多台机器怎样生成不重复的 ID。唯一 ID 只标识对象,并不会自动告诉中间件对象在哪个分片。除非路由规则能从 ID 计算出分片,或者另有一张映射表,否则拿着全局唯一的 link_id 仍可能要查询全部 32 个库。

这篇文章回答另一个问题:数据超过单机容量后,怎样把它拆到多个节点,同时让常用请求能够确定路由,让扩容期间的新旧数据保持一致。全文以关系型数据库水平分片为主,短链接作为贯穿案例,支付流水用于展示一套已经落到查询、回调与扫描路径的路由设计。算法配置参照 Apache ShardingSphere 的分片算法文档,分片键与在线重分片部分参考 Vitess Vindex 和 VReplication,一致性哈希部分回到 Amazon 的 Dynamo 论文。

先把四件事拆开:唯一、定位、分布与复制

数据库设计里有几个长得很像的问题。它们可以共用一个字段,却仍是不同职责。

问题 典型字段或机制 它给出的保证
一行怎样被数据库定位 主键 id 在一张物理表内唯一定位记录
多个节点怎样避免生成重复编号 UUID、Snowflake、Leaf 在约定作用域内生成唯一 ID
一行应该存到哪里 user_id、short_code、分片目录 从路由输入计算逻辑分片
一个分片坏了怎样继续服务 主从复制、Raft、Quorum 同一份数据有多个副本

分片(sharding)把不同数据分到不同节点。例如用户 42 的链接在 shard 2,用户 43 的链接在 shard 7。复制(replication)则让同一份数据拥有副本,例如 shard 2 有一个主库和两个从库。一个系统经常同时使用两者,所以拓扑图上会出现“8 个分片,每片 3 副本”,但扩容量和抗故障解决的是两组问题。

MySQL 单表分区也会把数据划到多个 partition,不过这些 partition 通常仍由同一个 MySQL 实例管理,共享实例的 CPU、内存、日志和故障边界。分库分表把数据放到多个独立实例或节点,应用必须面对跨节点路由、查询、事务和迁移。单表分区可以改善维护与分区裁剪,不能等价替代横向扩容。

主键、全局 ID、分片键与副本的职责关系

图中最容易混淆的是 link_id 与分片键。Leaf 生成的 link_id=300001 在全系统唯一;如果表按 user_id 分片,查询 WHERE link_id=300001 仍然缺少路由条件。中间件知道这条记录只会出现一次,却不知道应该去哪个库寻找。

一、分片之前先确认瓶颈真的来自单机

分库分表会永久改变数据模型。过去一个 SQL 能完成的 join、事务、唯一约束和分页,拆分后可能要在应用层协调。连接池、DDL、备份、监控和故障演练也会按分片数量增长。它适合解决已经被证据指向的容量或吞吐上限,不适合作为“以后可能会大”的默认模板。

准备拆分前,至少要回答这些问题:

  • 当前瓶颈是存储容量、写入吞吐、热点锁竞争,还是一条没有索引的慢查询;
  • 读流量能否先通过索引、缓存、只读副本或归档冷数据解决;
  • 单表很大与实例负载很高是否同时存在;
  • 未来两到三年的数据量和峰值 QPS 如何估算,增长是否集中在少数租户;
  • 团队是否有能力维护多套 schema、迁移任务和跨分片诊断工具。

例如,一张 3 亿行日志表因为每次查询都忘记带时间范围,拆成 32 张表后仍会扫描 32 张表。问题从一次慢查询变成了并发的 32 次慢查询。反过来,如果订单写入已经让单实例的 redo、磁盘和 CPU 长期接近安全上限,按用户或商户拆分能够让写入分散到独立故障域,扩容收益才成立。

先估算每个分片的预算

假设短链接服务预计保存 20 亿条记录,单行连同索引、页空隙和 MVCC 开销按压测得到平均 900 字节,在线数据约为 1.8 TB。若希望每个主库控制在 300 GB 左右,仅按容量需要至少 6 个分片。还要给增长、重建索引、临时表和故障转移留余量,初始部署可能选择 8 个物理分片。

容量只是下限。若创建峰值为 8 万次每秒,单库经过真实 schema、事务和刷盘策略压测后只能稳定承担 1.5 万次每秒,那么吞吐要求同样指向至少 6 个写分片。最终数量取容量、吞吐、热点隔离和运维成本的综合结果,不应只从“每张表最多放 500 万行”这样的经验数字推出。

物理库与逻辑表也不必一一对应。可以先定义 1024 个逻辑桶,把它们映射到 8 个数据库;每个库内部再按桶后缀保存多张物理表。这样将来增加数据库时,迁移部分逻辑桶即可,不需要改变 hash(key) % 1024 这一级稳定规则。

二、分片键决定了系统擅长回答什么问题

分片键是路由函数的输入。选 user_id,同一用户的数据天然聚集,用户维度查询和事务容易;选 short_code,跳转请求可以一次命中,但按用户列举链接需要额外索引或散射查询。不存在对所有访问路径都完美的字段,选择分片键就是在声明系统的主访问路径。

Vitess 的重分片指南把高 QPS 查询的 WHERE 条件、字段基数、需要同片 join 的行和需要同片事务的行列为分片键决策因素。这四项比“哪个字段看起来唯一”更接近工程现实。

五个检查项比“基数高”更完整

候选分片键首先要出现在高频请求中。订单详情接口总是同时拿到 user_id 和 order_id,按用户路由可行;第三方回调只有 order_no,而表只按 user_id 分片,就必须增加 order_no -> user_id 的目录,否则回调会广播到所有库。

其次是分布。高基数字段也可能倾斜。tenant_id 有一百万个取值,但一个超级租户占全部写入的 40%,按租户取模仍会把热点集中到一个分片。评估时应同时看请求量、数据量的分布和 COUNT(DISTINCT tenant_id)。

第三是数据局部性。订单、订单项和支付尝试若经常一起查询或提交,可以让它们使用相同的 user_id 或 order_id 路由规则,落到同一分片。跨分片 join 与事务从常态变成少数例外,系统会简单很多。

第四是稳定性。用户归属的地区、商户等级、订单状态会变化,用它们直接分片意味着字段更新可能变成跨库搬迁。分片键最好在记录生命周期内不可变;确实需要变化时,要把它设计成显式迁移流程,而不是普通 UPDATE。

第五是可传播性。入口请求、异步消息、缓存 Key、审计日志和下游 RPC 是否都能携带这个字段?只在数据库行里存在、到查询时才想起来取的分片键,无法完成第一次路由。把路由键写入消息 envelope 和接口契约,往往比中间件算法本身更重要。

候选字段 点查命中 分布风险 局部性 主要代价
user_id 用户侧请求很好 大用户可能热点 同一用户数据聚集 仅凭业务单号无法路由
order_id / link_id 对象详情很好 取决于 ID 分布 同一用户数据分散 用户列表需要二级目录或聚合
tenant_id SaaS 租户请求很好 超级租户明显 租户内事务容易 大租户需要二次拆分
创建时间 时间范围查询很好 最新分片持续写热 归档方便 点查需知道时间,当前分片热点
地区 地域访问和合规方便 地区规模差异大 地域数据聚集 用户迁区与容量不均衡

短链接应该按 short_code 还是 user_id 拆

公开跳转是短链接系统最重要、通常也是最高 QPS 的路径:

GET /1G2j

请求天然携带 short_code,不携带创建者的 user_id。若主表按 short_code 或可由短码还原的 link_id 分片,网关可以一次路由;若按 user_id 分片,缓存未命中后还需要先查一张 short_code -> user_id 映射表,再访问主分片。多一次查询并不一定错误,但映射表必须拥有独立的容量、缓存和容灾设计。

管理后台则相反:

GET /users/42/links?cursor=...

按 user_id 分片可以在一个库内使用 (user_id, created_at, link_id) 索引做游标分页。若主表按 link_id 均匀分散,列出用户全部链接需要一个用户维度的索引表。于是有两种合理模型:

  1. 主表按 short_code/link_id 分片,保证跳转链路直接命中;另建 user_link_index,按 user_id 保存用户与链接的关系。
  2. 主表按 user_id 分片,管理操作直接命中;另建全局短码目录,为跳转请求提供 short_code -> user_id 路由。

短链接通常读多写少,跳转延迟又比后台列表更敏感,我更倾向第一种。用户索引可以异步构建,但要定义创建成功后多久可见、索引丢失怎样修复;若产品要求创建后立刻在列表出现,可以使用同一消息的 Outbox、同步写索引或读主表回补。这里已经触及跨库一致性,完整方案留到分布式事务文章。

关联表尽量使用相同路由域

假设主表 short_link 按 link_id 分片,点击明细 link_visit 也按 link_id 分片,同一链接的基础信息与访问记录就能落在相同逻辑桶中。是否放到同一物理库还要考虑访问日志的写入量;数据局部性可以减少跨片查询,也可能把热门链接的写热点叠加在一起。

订单系统常把 orders 和 order_items 都按 user_id 或 order_id 路由。ShardingSphere 把路由结构一致的表称为 binding tables,中间件能够把同后缀表的 join 限定在对应分片,而不是生成所有分片的笛卡尔组合。字典、地区等小表则可复制到每个分片,作为广播表换取本地 join。

同片不是由列名相同自动获得的。两张表必须使用相同的路由输入、哈希函数、桶数和版本。一个服务用 Java hashCode(),另一个用 MurmurHash;一个把负数取绝对值,另一个使用无符号余数,即使都写着“按 user_id 哈希”,结果也可能不同。

三、路由函数至少包含两层

把路由直接写成 database = hash(user_id) % 8 很容易理解,也把逻辑分区和物理机器绑死了。更可维护的模型分为两层:

logical_bucket = stable_hash(route_key) % 1024
physical_shard = bucket_map[topology_version][logical_bucket]

第一层决定一行属于哪个稳定逻辑桶,第二层决定当前由哪个物理分片承载这个桶。扩容时只修改一部分 bucket -> shard 映射并迁移对应数据。路由表需要版本、发布和回滚机制,但它把“增加机器”从修改数据归属公式变成了调整桶的放置位置。

请求从路由键经过逻辑桶到物理分片

取模适合稳定规模,直接对物理节点取模不利于扩容

最简单的散列分片是:

int shard = Math.floorMod(stableHash(userId), shardCount);

在哈希输出足够均匀时,它能把大量不同 Key 分散到固定数量的分片。floorMod 比 Math.abs(hash) % n 更安全,因为最小负整数取绝对值仍可能为负。跨语言系统还必须明确字符串编码、哈希算法、整数宽度与有无符号规则,不能依赖语言自带且可能变化的对象哈希。

问题发生在 shardCount 改变时。hash % 8 与 hash % 9 对同一个 Key 通常得到不同结果,大量记录需要换位置。迁移期间若有的实例使用 8、有的实例使用 9,同一用户的新旧数据还会被写到不同库。把除数存在配置中心并不能解决原子发布和数据搬迁,只是让规则更容易被同时改错。

倍增分片有一个较规整的性质。桶数从 8 扩到 16 时,原 shard 0 的数据只会留在 0 或移到 8,原 shard 1 只会留在 1 或移到 9。这个规律能简化迁移筛选,但应用仍需要识别迁移状态、处理新旧位置,并确保所有写入只有一个权威落点。

范围分片方便范围查询,也容易形成尾部热点

范围分片把连续值划到节点:

[0, 10_000_000)          -> shard-0
[10_000_000, 20_000_000) -> shard-1
[20_000_000, 30_000_000) -> shard-2

按时间或递增 ID 查询连续区间时,范围规则能减少访问节点,也便于归档旧分片。代价是新写入集中到最大值所在的尾部分片。订单号和 Leaf ID 持续递增,若直接按 ID 范围切库,最新库会承担全部创建流量,其他库逐渐只读。

时间分片适合日志、流水和事件等主要按时间窗口访问的数据。查询必须带时间条件;跨月报表要访问多个分区;迟到事件的路由与保留策略也要写清。按月建表没有消除分片管理,只是把路由维度换成时间。

目录路由用一次查询换取灵活映射

目录表显式保存业务键到分片的映射:

CREATE TABLE link_directory (
    short_code   VARCHAR(16) NOT NULL,
    shard_id     SMALLINT NOT NULL,
    route_version INT NOT NULL,
    PRIMARY KEY (short_code)
);

它允许把热点租户放到专属分片,也允许迁移单个对象而不改变哈希公式。代价是目录本身进入所有请求路径,必须缓存、复制并处理一致性。目录查询超时后,调用方不能随便选择一个分片写入,否则同一业务键可能产生两份记录。

Vitess 的 Vindex 就把“列值怎样映射到 keyspace ID”抽象为可管理组件。Primary Vindex 决定插入目标;Secondary Vindex 可以让不带主分片键的查询缩小到一个或少数分片,避免每次 scatter。这个思路比把各种例外塞进一个越来越复杂的取模表达式清楚。

目录还可以分级。客户端先由 short_code 哈希到目录分片,查出主数据的 shard_id,再访问数据分片。两跳路由增加延迟,却能支持对象级迁移和不规则放置。高 QPS 系统一般会缓存目录,并让缓存值携带 route_version,目标分片返回“路由已过期”时刷新,而不是无限使用旧地址。

四、全局 ID、主键和分片键怎样配合

上一篇的短链接表可以继续使用本地自增主键、Leaf 全局 ID 和独立分片键:

CREATE TABLE short_link_017 (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    link_id     BIGINT UNSIGNED NOT NULL,
    short_code  VARCHAR(16) NOT NULL,
    user_id     BIGINT UNSIGNED NOT NULL,
    target_url  VARCHAR(2048) NOT NULL,
    created_at  DATETIME(3) NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uk_link_id (link_id),
    UNIQUE KEY uk_short_code (short_code),
    KEY idx_user_created (user_id, created_at, link_id)
) ENGINE=InnoDB;

这里的唯一索引只能保证一个物理表内唯一。short_code 由全局唯一的 link_id 一一编码而来时,生成协议提供跨片唯一性,每个分片的唯一索引负责拦截实现错误和重试冲突。若短码来自随机生成,就要有全局冲突检查或让重试落到同一确定分片,否则两个库各自接受同一个短码仍会产生重复。

唯一 ID 不携带路由信息时仍然会广播

假设 link_id 是普通 Snowflake ID,主表按 user_id 分片。以下 SQL 无法单路由:

SELECT * FROM short_link WHERE link_id = 300001;

全局唯一只说明最多有一行匹配,路由器仍要决定向谁发送 SQL。可选办法有四种:调用方同时携带 user_id;建立 link_id -> user_id/shard_id 目录;让主表改按 link_id 分片;或者在 ID 中编码可恢复的路由位。

这也解释了为什么“直接用雪花 ID 做主键”没有自动解决分库查询。Snowflake 的时间戳、worker 和序列位描述生成过程;除非 worker 与数据库分片存在长期稳定的映射,否则解析出 worker 也不等于找到数据。容器扩缩容、worker 复用和数据迁移都会让这种偶然关系失效。

基因法把路由结果写进 ID

工程里常说的“基因法”,通常是给 ID 预留若干 bit,写入由业务键计算出的路由值。例如系统有 64 个稳定逻辑桶,需要 6 bit:

static long composeId(long globalSequence, long userId) {
    long bucket = stableHash(userId) & 0b11_1111L;
    return (globalSequence << 6) | bucket;
}

static int bucketOf(long id) {
    return (int) (id & 0b11_1111L);
}

globalSequence 仍负责唯一,低 6 bit 负责恢复逻辑桶。同一用户的 ID 带有相同路由基因,收到只有 id 的请求也能先取低位,再由 bucket_map 找到当前物理分片。这里嵌入的是稳定逻辑桶,不能嵌入随扩容变化的数据库编号。

这段组合成立的前提是 globalSequence 在移位前已经全局唯一且不会溢出可用位宽。不能直接覆盖 Snowflake 的低 6 bit:经典 Snowflake 的低位属于同毫秒序列,随意改写会让原本不同的 ID 变成相同值。若要在 Snowflake 中加入业务基因,需要重新分配时间、节点、序列和基因的位宽,并证明单节点同毫秒容量与唯一性。

基因法还会把一个路由决策写进长期数据格式。64 个逻辑桶将来够不够,ID 是否要跨系统交换,JavaScript 能否安全表示,历史 ID 的格式版本怎样识别,都要提前设计。它适合“只有 ID 的请求非常多,路由规则长期稳定”的场景,不适合频繁重组租户归属或需要对象级迁移的系统。

短链接若直接把 Leaf ID 做 Base62 编码,跳转入口可以反解 link_id:

short_code = base62(link_id)
link_id     = base62Decode(short_code)
bucket      = stableHash(link_id) % 1024

这样无需从短码查目录即可得到逻辑桶,再通过桶映射找到数据库。创建链路与跳转链路使用同一份确定性路由函数:

long linkId = leaf.nextId("short-link");
String shortCode = Base62.encode(linkId);
int bucket = Math.floorMod(murmur3(linkId), 1024);
Shard shard = bucketMap.resolve(bucket, topologyVersion);
repository.insert(shard, linkId, shortCode, userId, targetUrl);

Base62 只缩短展示,不隐藏递增关系。若不希望外部推测业务量,可以在编码前做可逆置换,或生成独立随机短码;一旦改成不可逆哈希或随机码,就可能需要目录来定位。安全需求和路由需求应分别设计。

实战:支付流水怎样落到 1000 张物理表

支付流水提供了另一种组合方式。它的高频访问围绕用户展开:发起支付时查询该用户同一业务目标的最新 attempt,处理中继续查询原 attempt,用户查看结果时也天然携带 user_id。因此主流水按 user_id % 1000 分表:

int tableIndex(long userId) {
    return Math.floorMod(userId, 1000);
}

String tableName(long userId) {
    return "internal_pay_flow_" + tableIndex(userId);
}

这里的 1000 来自长期容量预算。支付行为可能很低频,流水却会按年累积;同时,现有数据源若已经采用 1000 张表,复用它的路由、主从配置、监控和运维脚本,通常比再建立一套相近拓扑便宜。在线 QPS 并不是决定这个数字的指标。具体数量仍要以保留周期、单行与索引大小、增长率和团队运维能力为依据,不能把 1000 当作通用经验值。

一行流水同时拥有四种标识:

字段 作用域 负责什么
id 单张物理表 InnoDB 主键、CAS 更新和表内游标
user_id 全链路 分片键,决定物理表
biz_key 一个业务目标 把同一次续签或任务的多个支付 attempt 归在一起
trade_order_no 全局 标识一次支付 attempt,并为钱包幂等提供稳定单号

id 使用物理表自增即可。查询已经由 user_id 定位到一张表,主键只需在该表内唯一;对外支付单号则可以由场景、用户和表内主键确定性组成:

trade_order_no = h2r_{base36(user_id)}_{base36(id)}
refund_order_no = r_{trade_order_no}

这种做法直接把路由输入写成业务单号里的可解析字段,没有改写 Snowflake 的 bit 布局。固定路由规则下,全局唯一性来自 user_id + id:同一用户始终进入同一张表,表内自增 id 不重复;不同用户即使拿到相同的局部 id,单号里的用户部分也不同。场景前缀还可以区分续签、付费跳过等订单域。

这个唯一性证明把“同一用户不更换局部 ID 命名空间”作为前提。以后若把某个用户迁移到新表,迁移工具必须保留历史 id,并让新表的自增水位越过已使用最大值;否则同一用户可能再次获得旧 id,重新生成相同支付单号。另一种办法是让订单号使用独立全局序列。路由迁移会不会破坏发号前提,也属于扩容协议的一部分。

它解决了异步入口缺少分片键的问题。钱包回调或 MQ 消息若只有 trade_order_no,服务可以解析出 user_id,再计算表后缀,而不需要广播 1000 张表:

PayOrderNo orderNo = PayOrderNo.parse(rawOrderNo);
long userId = orderNo.userId();
int table = tableIndex(userId);

PayFlow flow = repository.findByTradeOrderNo(
    table,
    userId,
    rawOrderNo
);

解析后的 user_id 不能只用于选表,SQL 仍应带上它。这样查询契约与分片规则一致,也能防止格式解析错误后在错误表中误命中其他记录。若业务单号已经公开且格式将长期存在,还要为前缀和编码保留版本,避免将来调整路由时无法识别历史订单。

退款单号直接包住原支付单号,因此也能恢复相同的 user_id。biz_key 则没有承担路由职责,因为同步入口原本就有用户上下文,而且业务目标的格式可能随场景变化。一个字段是否适合作为分片键,要看所有入口能否稳定获得它,不取决于名字里是否带 key。

Scanner 为什么要遍历 1000 张表

在线请求和消息针对一笔具体订单,可以单路由;DB Scanner 的任务是发现全局所有长时间没有收敛的非终态流水,它在查询前没有某个 user_id。这时遍历物理表是需求本身,不属于误广播:

SELECT id, user_id, status, version
FROM internal_pay_flow_017
WHERE status IN (1, 2, 4, 6)
  AND update_time < :threshold
  AND id >= :cursor
ORDER BY id
LIMIT 100;

调度器把 1000 张表拆成多轮,限制每轮表数、batch、RPC 并发和总处理量。每张表使用 (status, update_time, id) 索引,只扫描超过等待窗口的非终态记录;取到 user_id 后,后续状态推进重新回到单表路径。Scanner 的命中量还能反映实时 MQ 链路是否退化,正常情况下大部分流水应在进入扫描窗口前收敛。

这个例子也说明“禁止广播”不是绝对规则。面向用户的在线点查缺少分片键,通常表示接口或索引设计有问题;覆盖全部分片的运维任务、对账和兜底扫描可以显式 fan-out,但必须有批次、索引、限流、进度和失败恢复。支付状态如何由 MQ 与 Scanner 共同推进,在分布式事务文章中继续展开。

五、SQL 能否单路由取决于查询形状

中间件拿到 WHERE user_id = 42 时,可以计算一个目标分片。IN (42, 43, 44) 可以按值拆成多个目标集合。缺少分片键、对分片键做无法识别的函数、使用非路由列范围查询时,中间件只能广播或拒绝。

ShardingSphere 的 INLINE 算法明确支持单键的 = 与 IN;文档也提示,允许范围查询时可能忽略 inline 路由并执行全路由。配置一行 t_user_$->{u_id % 8} 很简单,难点是所有线上 SQL 是否真的符合这条规则。

-- 单分片
SELECT * FROM orders WHERE user_id = 42 AND order_id = 9001;

-- 有限多分片
SELECT * FROM orders WHERE user_id IN (42, 43, 44);

-- 缺少路由键,可能广播
SELECT * FROM orders WHERE order_no = 'O202610040001';

-- 中间件未必能从表达式反推原值
SELECT * FROM orders WHERE CAST(user_id AS CHAR) = '42';

路由放在客户端、代理还是服务端

客户端 SDK 可以直接计算目标,少一次代理转发,适合语言和框架比较统一的团队。代价是路由算法、拓扑缓存和迁移状态散落在每个应用;旧版本客户端可能长期使用旧规则。

数据库代理让应用继续面向逻辑表写 SQL,集中处理路由、结果归并和拓扑切换。它也进入所有数据库请求的延迟和故障路径,需要横向扩展、限流和可观测性。代理能解析 SQL,不代表它能把任意跨片操作还原成单机语义。

服务端路由常见于存储系统自身,例如 Redis Cluster 的节点和客户端共同维护 slot 到节点的映射。客户端可以缓存映射;访问错误节点时收到 MOVED 或迁移期的 ASK 重定向。Redis Cluster 规范定义了 16384 个 hash slot,并通过搬迁 slot 完成扩缩容。它没有直接对物理节点做一致性哈希,而是使用固定 slot 这一层间接映射。

选择哪一层并不会删除其他职责。即使使用代理,应用仍要让请求携带分片键,知道哪些事务跨片,并对广播查询设置预算;即使客户端直连,存储侧仍要拒绝过期拓扑的危险写入。

跨分片 join、聚合和分页会放大成本

按用户分片后,查询“全站过去一小时点击量最高的 100 个链接”需要每个分片先算局部 Top 100,聚合层再归并。若直接从每片拉回全部记录再排序,网络与内存成本会随数据量增长。在线分析更适合 CDC 到专门的 OLAP 系统,不要让交易库承担无限 scatter-gather。

全局分页尤其容易写错:

SELECT * FROM short_link
ORDER BY created_at DESC
LIMIT 100000, 20;

每个分片可能都要读取十万级候选,代理再做全局排序并丢掉绝大部分。游标分页应使用稳定复合游标,例如 (created_at, link_id),每个分片从自己的游标位置继续;全局归并层还要保存各分片进度。若查询天然属于某个用户,按 user_id 单路由会省掉整套全局游标。

聚合值也要辨别能否合并。COUNT 和 SUM 可以先局部计算再相加;平均值需要传递 sum + count,不能平均各分片的平均值;精确去重、全局排序和窗口函数成本更高。中间件支持某个 SQL 语法,只代表它能执行,不代表延迟适合在线接口。

全局唯一约束需要重新选择归属

单库里可以创建 UNIQUE(email)。用户按 user_id 分到多个库后,各库只能阻止本库重复邮箱。要维持全局唯一,可以让邮箱本身决定一个唯一性索引分片:

email -> hash(email) -> unique_index_shard -> user_id

注册先在索引分片占位,再创建用户数据。两个资源位于不同数据库,失败恢复、超时重试和补偿随之出现。也可以让业务接受租户内唯一,把约束改成 (tenant_id, email) 并确保同租户数据同片。分片前看似普通的 unique key,拆分后会暴露它真正的业务作用域。

六、一致性哈希解决节点变化时的放置问题

传统取模把节点数放进公式,节点变化会让大量 Key 的结果改变。一致性哈希把哈希输出看成首尾相接的环,数据 Key 与节点都映射到环上;Key 沿顺时针找到第一个节点。加入或移除节点时,只有相邻区间改变归属。

Dynamo 论文使用一致性哈希支持增量扩容,同时指出基础算法的两个问题:节点随机位置会造成分布不均,也无法表达机器容量差异。Dynamo 为每台物理机分配多个 virtual node,让负载从多个环区间汇聚到同一机器;新增机器也可以从多台旧机器接收一部分区间。

虚拟节点改善统计分布,不会消灭业务热点。若一个明星短码承担 30% 流量,它仍然是一个 Key,环上只有一个主归属。要靠缓存、多副本读、请求合并或业务拆分处理。Dynamo 论文也明确说明,均匀 Key 分布能够带来均匀负载的前提是访问倾斜没有被少数 Key 完全支配。

哈希环、虚拟节点和固定 slot 不是同一个实现

哈希环通常由有序 token 构成,查找 Key 的 hash 后寻找顺时针 token。虚拟节点让一个物理节点持有多个 token。固定 slot 则先把整个空间划成有限且稳定的分区,再用显式映射决定每个 slot 属于哪个节点。

两者都在解耦 Key 与物理节点,但操作方式不同。固定 slot 更容易列出“本次把 73、88、102 号桶从 A 搬到 D”,限制迁移速率并做逐桶校验;哈希环适合节点成员频繁变化、系统能自动维护 token 与副本放置的场景。关系数据库分片通常需要谨慎的 schema、事务和迁移窗口,我更偏向稳定逻辑桶加映射表,而不是让数据库实例直接在环上自动接管范围。

Redis Cluster 是很好的对照。它将 Key 计算为 CRC16(key) mod 16384,物理节点持有若干 slot;扩容时搬 slot,而不是改变 16384 这个除数。hash tag 允许 user:{42}:profile 与 user:{42}:account 只对花括号里的 42 计算 slot,从而支持同 slot 的多 Key 操作。这与数据库让关联表共享分片键的目标相似。

一致性哈希没有处理数据复制和迁移正确性

算法算出新归属之后,旧节点上的数据不会自行移动。系统还要复制历史数据、追赶增量、切换写入、处理路由缓存,并决定旧副本何时删除。一致性哈希减少“哪些 Key 改变归属”,没有定义“改变过程中读写看到什么”。

它也没有自动提供副本。Dynamo 在环上另外定义 preference list,把一个 Key 复制到多个不同物理节点;数据库系统则可能为每个分片建立独立主从组。分片算法、成员管理、复制协议和一致性模型可以协同实现,但不能用“一致性哈希”一个词代替全部设计。

七、扩容迁移是一套有状态协议

假设当前有 1024 个逻辑桶,8 个 MySQL 分片,每片约 128 个桶。新增 4 个分片后,计划从每个旧分片迁出约三分之一的桶。路由表会同时存在旧版本和新版本:

route v17: bucket 417 -> shard-3
route v18: bucket 417 -> shard-9

如果直接发布 v18,shard-9 还没有历史数据;如果先复制历史数据,复制过程中 shard-3 仍在接受写入,目标会落后。因此迁移要管理桶的状态、数据进度和路由版本,一次配置修改并不够。

逻辑桶在线迁移的复制、追赶、校验与切换

基线复制之后必须追赶增量

第一阶段在旧分片上记录一致的快照位置,然后复制属于 bucket 417 的历史行到新分片。复制任务要限速,避免把主库 I/O、buffer pool 和复制链路打满。按主键游标分批比大 OFFSET 更稳定,并为每批记录起止主键、行数、耗时和重试次数。

基线开始后的新增、更新和删除需要通过 binlog/CDC 追到目标。删除事件很容易遗漏:只做“扫描当前存在的行并 upsert”无法删除目标中的旧数据。事件还应带源位置或版本,使目标能幂等应用并拒绝倒序覆盖。

Vitess 的 VReplication 就把源 shard 到目标 shard 的复制流作为在线重分片基础,并提供可追踪、可取消、可回退的工作流。具体产品可以不同,协议仍需覆盖基线、增量、位置追踪和失败重试。

切换时只保留一个权威写入点

长期双写看起来能同时维护新旧库,实际会把每次超时变成两边状态未知:旧库成功、新库失败怎么办;第一次调用两边都成功但响应丢失,重试是否覆盖新值;两个库谁的版本更新。没有统一事务时,双写需要幂等键、版本比较、补偿队列和对账,复杂度并不比 CDC 低。

更容易推理的切换流程是:

  1. 旧分片仍是唯一写主,目标执行基线与 CDC。
  2. 增量延迟降到阈值后,对待迁桶建立短暂写屏障,记下最终源位置。
  3. 目标追到该位置,完成行数、校验和及业务不变量检查。
  4. 将 bucket 的写 owner 切到新分片,并发布更高 route_version。
  5. 读流量逐步切换到新分片;旧分片保留只读副本作为有限时间回退依据。
  6. 回退窗口结束后再删除旧数据。

写屏障可以只锁一个逻辑桶,不必停止全站。高吞吐系统也可以借助版本化写入避免长时间停顿:路由服务给新 owner 发放更高 epoch,存储端拒绝旧 epoch 的写请求。这与 Fencing Token 的思想相同,判断权要落在真正接收写入的资源上。

读路径要认识每个迁移状态

迁移状态可以显式建模:

状态 权威写入 默认读取 允许动作
STABLE 当前 owner 当前 owner 正常读写
COPYING 源 源 目标接收基线和 CDC
VERIFYING 源 源,影子读目标 对账但不返回目标结果
CUTOVER 新 owner 新 owner,必要时回源 拒绝旧版本写入
CLEANUP 新 owner 新 owner 旧数据只读保留后删除

双读不是把两个结果随便选一个。迁移期间可以先读新库,未找到时回源,并异步修复;但“新库查不到”可能代表合法删除,也可能代表尚未复制。记录版本或墓碑才能区分。影子读更安全:线上结果仍来自源库,后台比较目标结果,不影响用户。

路由缓存也要参与协议。应用实例可能缓存 bucket 417 -> shard-3。切换后,shard-3 应返回带当前版本的重定向或明确错误,客户端刷新映射并重试;不能悄悄接受写入。缓存 TTL 只能限制旧映射大致存活时间,版本校验才能阻止它造成旧库新增数据。

校验不能只比较总行数

源和目标总行数相同,仍可能一边缺 A、一边多 B。迁移校验至少分三层:

  • 分段统计:按主键或时间窗口比较 count、最小/最大 ID、删除数;
  • 内容校验:对规范化后的业务列计算分段 checksum,排除更新时间等预期不同字段;
  • 业务不变量:短码唯一、状态转换合法、用户索引都能指回存在的主记录。

全量 checksum 可能给在线库带来过高负载,可以先全量轻量统计,再按比例抽样内容,并对差异区间做精确扫描。校验任务自身要可重入,记录使用的快照位置和路由版本,否则两次校验观察到不同时间点会制造假差异。

回滚也要在切换前演练。新库成为写 owner 后若又产生数据,简单把路由切回旧库会丢掉这部分写入。回滚流程必须反向复制增量,或在短回退窗口内让旧库持续接收来自新库的 CDC。所谓“旧库还留着,所以随时能回滚”只覆盖了静态数据。

八、数据均匀不代表负载均匀

均匀哈希可以让每个分片行数接近,却不能保证 QPS、写放大和单行竞争接近。短链接系统中,一个爆款短码可能获得百万级跳转;SaaS 系统中,一个大租户可能占据大部分写入;时间分片会让当前月份持续发热。监控只看磁盘占用,会错过这些热点。

热点 Key 的读通常先由多级缓存和 CDN 吸收。缓存失效时要防止大量请求同时回源,可以使用请求合并、逻辑过期或提前刷新。热门链接的统计写入不要每次同步更新同一行计数器,可以在内存或消息流中聚合后批量落库。

热点租户比单 Key 更复杂。可把超大租户提升为独立路由类型:普通租户走 hash(tenant_id) % buckets,大租户由目录指向专属分片,甚至再按 object_id 二次拆分。代价是路由不再是单一公式,所有入口都要读取同一份租户放置策略。

加盐能拆写热点,也会增加读取扇出

例如某个计数器按 link_id 总会落到一个桶,可以加入 16 个 salt:

counter:{link_id}:0
counter:{link_id}:1
...
counter:{link_id}:15

写入随机选择或按请求 ID 选择一个 salt,吞吐分散;读取总数必须查询并求和 16 份。加盐适合可交换、可合并的统计值,不适合需要单行强约束的余额和库存。把热点打散之后,业务不变量是否仍能在局部检查,是判断方案能否成立的关键。

分片再多也无法并行修改同一个必须串行的状态。遇到单账户余额、单商品库存或单文档版本时,需要条件更新、队列化、分段库存或业务模型调整。分库分表扩展的是独立 Key 的并行度,不会突破单个不变量本身的串行边界。

九、故障时路由结果也可能“未知”

路由服务超时,应用不能自行选一个“看起来空闲”的库写入。同一 Key 的两个请求若选择不同分片,会形成双主数据。安全策略通常是停止该写入,使用最后一份仍在有效期内的已签名路由表,或由目标分片校验路由版本后接受。

拓扑发布也可能部分成功。100 个应用实例中,90 个拿到 v18,10 个仍使用 v17。只靠配置中心最终推送不能保证切换瞬间一致。把 route_version 放进请求,旧 owner 在 cutover 后拒绝 v17 写入,客户端刷新后重试,才能把部分发布变成可恢复错误。

迁移任务超时同样是结果未知。批次写入目标后连接断开,任务不能假定整批失败并做非幂等插入。使用源主键作为目标唯一键,采用带版本条件的 upsert,记录 CDC position,使批次可以安全重放。迁移吞吐可以慢,重复或覆盖新值更危险。

分片故障与路由故障要分开处理

bucket -> shard 告诉请求应该访问哪个复制组;复制组内部的主从切换决定具体访问哪台数据库。分片主库故障时,不应把 bucket 临时路由到另一个装有不同数据的分片。正确动作是在 shard-3 的副本中选新主,保持 bucket 417 仍属于 shard-3 这个逻辑复制组。

如果把每台 MySQL 实例直接当成分片编号,主从切换会被误写为“重新分片”,路由层需要理解太多存储细节。更清楚的命名是 bucket -> shard group -> current primary endpoint:第一层变化意味着数据搬迁,第二层变化意味着同一数据副本的故障转移。

读副本还会带来陈旧读,这属于下一篇“主从复制、读写分离与 Quorum”的主题。当前文章只要求路由层知道请求访问的是主库还是副本,并把一致性要求显式传递,不能把 replica 当作无差别的读扩容节点。

十、把分片做成可观测、可验证的系统

分片故障很少表现为“所有请求都失败”。更常见的是 shard-7 延迟升高、某个 bucket 持续热点、一次查询意外广播、迁移差异只集中在删除记录。指标必须保留 shard、bucket、route version 和 query shape 这些维度。

线上至少需要以下观测:

维度 指标或日志 用途
物理分片 QPS、连接数、CPU、磁盘、p95/p99、错误率 找到容量和故障节点
逻辑桶 数据量、读写 QPS、迁移状态 发现热点与放置不均
路由 单路由/多路由/广播比例、版本落后次数 找出缺少分片键的 SQL
查询 fan-out 数、归并行数、丢弃行数、总耗时 识别昂贵分页和聚合
迁移 快照进度、CDC lag、checksum 差异、重试数 判断是否允许切换
目录 命中率、过期版本、回源延迟、冲突数 评估二跳路由健康度

日志里只记 SQL 模板还不够。一次慢查询可能是 shard-2 自身变慢,也可能是 fan-out 到 32 个分片后等待最慢的那个。Trace 应拆出路由计算、每个分片调用和归并阶段,标记是否发生目录查询、重定向或迁移回源。

路由函数适合做性质测试

路由代码短,却非常适合 property-based testing。与其只测试几个手选用户,不如随机生成大量 Key 验证这些性质:

同一 key + 同一 routing version -> 永远得到同一 bucket
所有 bucket 都在 [0, bucketCount) 内
不同语言实现对同一字节序列得到相同结果
同一路由域的 orders 与 order_items 得到相同 shard
从 v17 到 v18,未迁移 bucket 的 owner 不变
旧版本写入已切换 bucket 时必须被拒绝

分布测试还要使用接近真实业务的数据。连续整数、UUID、带固定前缀的字符串和真实租户分布可能呈现不同结果。比较最大桶与平均桶的数据量、QPS 和尾延迟;不要只看哈希函数对理想随机输入是否均匀。

迁移测试应主动注入进程重启、CDC 重复事件、网络超时、路由缓存不刷新和 cutover 中途失败。任务显示成功只是一项证据;验收还要确认数据没有丢失或重复,旧版本不能继续写,回滚能够包含切换后的增量。

Schema 与 DDL 也需要全局编排

32 个分片上的表结构必须兼容同一套应用代码。DDL 发布应记录每个分片的版本与状态,支持分批、暂停和重试。先增加可兼容的新列,再发布读写新列的应用,最后删除旧列;不要依赖一次命令在全部数据库同时完成。

迁移期间源和目标 schema 还可能不同。CDC 转换必须明确默认值、字符集、时区和精度;checksum 要基于统一规范化结果。一个分片 DDL 失败若没有被发现,问题往往只影响该分片的少数请求,很容易拖到数周后才暴露。

十一、短链接从创建到跳转的完整路径

把前面的选择合在一起,可以得到一套明确但不唯一的方案:Leaf 生成全局 link_id,Base62 得到短码;stableHash(link_id) % 1024 选择逻辑桶;桶映射到 12 个物理分片。主表以 link_id 为路由键,用户列表由按 user_id 分片的索引表承担。

创建请求经过以下步骤:

  1. 网关校验用户与 URL,幂等键标识这次创建意图。
  2. 服务从 Leaf 取得 link_id,编码为 short_code。
  3. 路由器计算逻辑桶,并读取当前 route_version 下的 owner。
  4. 目标分片用 link_id 与 short_code 唯一约束写入主记录。
  5. 同一事务写入 Outbox,异步更新 user_link_index 与缓存。
  6. 响应返回短码;后台列表在索引事件消费后可见。

跳转请求解码短码取得 link_id,计算相同逻辑桶。缓存命中直接返回 302;缓存未命中则访问该桶当前 owner,读取目标 URL 并回填缓存。请求无需知道 user_id,也不需要广播数据库。

用户后台按 user_id 路由到索引分片,得到一页 link_id。若列表只展示短码、标题和创建时间,这些字段可以冗余在索引表里,避免逐条回主分片;进入详情页时再按 link_id 精确路由。冗余字段允许短暂陈旧,删除和权限状态则要定义以哪一份为准。

扩容时同一条跳转请求会经历什么

假设 link_id=300001 属于 bucket 417,正在从 shard-3 搬到 shard-9。COPYING 阶段仍由 shard-3 响应,shard-9 接收快照与 CDC;VERIFYING 阶段,少量请求影子读取 shard-9,只记录差异;切换后路由版本升到 v18,shard-9 成为 owner。

某个旧应用仍缓存 v17,把请求发到 shard-3。shard-3 看到 bucket 417 已迁移,返回包含 v18 的路由过期错误;应用刷新映射并重试 shard-9。若这是一次写入,旧 shard 绝不能为了“兼容”继续接受。若是读取,可以在有限过渡期返回重定向,但不能无限掩盖客户端不更新的问题。

整个过程中,link_id、short_code 和逻辑 bucket 都没有变化。变化的是 bucket 的物理 owner。把稳定身份、稳定逻辑分区和可变物理放置拆开后,扩容不需要重新生成 ID,也不会让外部 URL 改变。

十二、选型时可以从访问路径倒推

主要需求 更合适的起点 需要接受的代价
用户维度查询与事务占绝大多数 按 user_id/tenant_id 哈希 业务单号查询需要携带用户或查目录
对象 ID 点查占绝大多数 按全局 ID 哈希 用户列表需要二级索引
时间范围扫描与归档 按时间范围 当前窗口热点,跨窗口查询多片
热点对象需要单独搬迁 目录路由 目录成为核心基础设施
节点频繁增减的 KV 存储 一致性哈希与虚拟节点 仍需副本、迁移和热点方案
关系库需要可控在线扩容 固定逻辑桶加版本化映射 维护桶表和迁移状态机
只有 ID 的请求很多 ID 内嵌稳定 bucket 位宽、格式版本和长期兼容成本

如果系统尚未到单机边界,先保留普通主键和清晰的访问索引;可以让全局业务 ID 从一开始存在,为未来合并与事件传递留出空间,但不必提前部署几十个空分片。若确定要拆,先拿真实请求日志统计每种查询条件和 QPS,再选择主路由键。分片键不是从表结构里猜出来的,它来自访问路径。

落地设计文档至少应该写清:路由键从哪里获得,哈希字节与算法是什么,逻辑桶数量是否固定,关联表怎样同片,缺少路由键的 SQL 是拒绝还是广播,拓扑版本怎样发布,迁移时谁是唯一写 owner,回滚如何同步切换后的增量,以及哪些指标证明可以进入下一状态。

分库分表会增加一组需要长期管理的状态:数据归属、路由版本、迁移进度和跨片结果。只画出 user_id % 8,相当于只写了系统稳定时的第一行代码。能在扩容、超时和旧客户端同时存在时仍然确定一条记录由谁接受,设计才算闭合。

参考资料