数据超过单机后怎么拆:分片键、路由、扩容迁移与一致性哈希
从短链接读写路径与支付流水实战出发,讲清分片键怎么选、全局 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、内存、日志和故障边界。分库分表把数据放到多个独立实例或节点,应用必须面对跨节点路由、查询、事务和迁移。单表分区可以改善维护与分区裁剪,不能等价替代横向扩容。
图中最容易混淆的是 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 均匀分散,列出用户全部链接需要一个用户维度的索引表。于是有两种合理模型:
- 主表按
short_code/link_id分片,保证跳转链路直接命中;另建user_link_index,按user_id保存用户与链接的关系。 - 主表按
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 的请求非常多,路由规则长期稳定”的场景,不适合频繁重组租户归属或需要对象级迁移的系统。
短码可以携带 link_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 低。
更容易推理的切换流程是:
- 旧分片仍是唯一写主,目标执行基线与 CDC。
- 增量延迟降到阈值后,对待迁桶建立短暂写屏障,记下最终源位置。
- 目标追到该位置,完成行数、校验和及业务不变量检查。
- 将 bucket 的写 owner 切到新分片,并发布更高
route_version。 - 读流量逐步切换到新分片;旧分片保留只读副本作为有限时间回退依据。
- 回退窗口结束后再删除旧数据。
写屏障可以只锁一个逻辑桶,不必停止全站。高吞吐系统也可以借助版本化写入避免长时间停顿:路由服务给新 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 分片的索引表承担。
创建请求经过以下步骤:
- 网关校验用户与 URL,幂等键标识这次创建意图。
- 服务从 Leaf 取得
link_id,编码为short_code。 - 路由器计算逻辑桶,并读取当前
route_version下的 owner。 - 目标分片用
link_id与short_code唯一约束写入主记录。 - 同一事务写入 Outbox,异步更新
user_link_index与缓存。 - 响应返回短码;后台列表在索引事件消费后可见。
跳转请求解码短码取得 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,相当于只写了系统稳定时的第一行代码。能在扩容、超时和旧客户端同时存在时仍然确定一条记录由谁接受,设计才算闭合。
参考资料
如果这篇文章对你有帮助