三亿行之后,先别急着买中间件
「回声」的订单库是一台主库加两台只读副本。订单表涨到 3.2 亿行(案例假设值)时,几个信号同时出现:单表数据文件约 1.8 TB,二级索引约 600 GB,一次全量备份从 2 小时变成 7 小时,超过维护窗口;删除历史数据要跑十几个小时,期间复制延迟反复报警;开发同学加一个字段,DDL 要排到周末。
团队开了一次会,议题是“选哪个分库分表中间件”。讨论了四十分钟,有人问了一句:按什么键分?会议室安静了。因为按用户分、按订单分、按商户分,对应的是三套完全不同的查询路径和运维流程,中间件只是最后一步。
分库分表真正困难的部分不是把 SQL 发到哪个库,而是先回答:一条数据应该和谁放在一起,以及这个决定未来还能不能改。前者是分片键选择,后者是路由函数的稳定性与版本化。
这一节先把键选择讲清楚,再把路由写成一个可测试的纯函数,最后复盘一次“改了分片数、忘了改历史数据”的真实故障。
分片键是数据分布契约,不是普通索引
很多团队把分片键当成“建个索引就能解决”的问题,这是误解。索引影响查询计划,分片键影响数据放在哪台机器、哪个文件、哪次备份里。它一旦上线,所有写入路径、报表任务、归档脚本和对账程序都会依赖它。
选择分片键时,至少要把四个问题写在同一张纸上:
- 最高频的查询带不带这个键?带,就能单片命中;不带,就要广播到所有分片再合并。
- 这个键的取值是否足够分散?如果 20% 的流量集中在一个大商户,按商户分只会把单库压力变成单分片压力。
- 未来扩容时,已有数据要移动多少?取模分片改分片数,几乎等于全量重分布。
- 这个键是否稳定?用户改手机号、订单改状态都不应该改变路由结果。
把「回声」的候选键放进同一张表,差异会非常直观:
| 候选键 | 最高频查询 | 单片命中情况 | 热点风险 | 扩容代价 |
|---|---|---|---|---|
| user_id | 我的订单、用户维度的售后 | 用户订单列表单片命中 | 大买家或爬虫账号集中 | 改模数需全量重分布 |
| order_id | 订单详情、支付回调 | 详情与回调单片命中 | 几乎无 | 改模数需全量重分布 |
| merchant_id | 商家后台订单列表 | 商家列表单片命中 | 大商户明显 | 改模数需全量重分布 |
| created_at | 运营按时间范围导出 | 时间范围可裁剪分片 | 写入永远落在最新一片 | 时间归档容易,跨期查询麻烦 |
表里的判断都基于案例假设值:假设「回声」70% 的订单读请求来自“我的订单”,20% 来自订单详情,10% 来自商家后台与运营导出。这个比例不是行业常数,每个产品都应该用自己的埋点重新算一遍。
按 user_id 分片,最诱人的地方是主路径单片命中;最危险的地方是大用户。一个做代购的账号可能有两百万订单,是普通用户的两千倍,分片再均匀也挡不住单点。按 order_id 分片,写入和详情都很均匀,但“我的订单”必须按用户查,得额外维护一张 user_id 到 order_id 的映射表,映射表本身又会成为新的规模问题。
一种常见的折中是“订单号里带用户分片位”:生成订单号时把 user_id 的哈希前缀编码进去,查询详情时从订单号解析分片,查用户列表时按 user_id 路由。代价是订单号不再连续、不能直接当自增主键用,且分片位一旦写进历史订单号,扩容时旧订单仍然按旧规则解析。这就引出了下一节要讲的版本化。
还要区分“分片键”和“主键”。订单的主键仍然是 order_id,分片键可以是 user_id。两者不重合时,必须保证 order_id 全局唯一,否则跨分片合并结果会把两笔不同订单当成同一笔。全局唯一 ID 的生成方案(号段、雪花、UUIDv7)各有取舍,但共同要求是:不能依赖数据库自增,因为自增只在单库内唯一。
分片数不是容量指标
16、32、64 是常见选择,但分片数本身不产生容量。每个分片都有固定成本:连接池、监控项、备份任务、DDL 窗口、故障转移演练。假设单分片的目标上限是 5000 万行(案例假设值),3.2 亿行至少需要 7 片,取 16 片留一倍余量是合理的;如果直接切 256 片,单表只剩 125 万行,跨分片查询的合并成本反而上升。
判断分片数时,先算单分片的目标数据量与峰值 QPS,再向上取整到便于运维的数字。分片数还应该和值班能力匹配:32 个库同时告警时,能不能在十分钟内定位到具体分片,比多留几倍余量更重要。
跨分片查询的三种收口方式
按 user_id 分片之后,商家后台按 merchant_id 查订单必然跨片。常见的收口方式有三种:
- 映射表:维护 merchant_id 到 user_id 或 order_id 的索引表,先查映射再查订单。映射表要和订单一起写,必须明确同事务还是最终一致。
- 异构索引:把订单关键字段同步到搜索引擎,复杂查询走搜索,详情回源数据库。代价是同步延迟,以及“搜索结果比订单旧”的解释成本。
- 离线宽表:把跨片结果定期汇总到数仓,只服务报表和运营,不服务在线请求。代价是时效性,换来的是在线链路简单。
三种方式都不免费:映射表增加写放大,异构索引增加同步链路,离线宽表增加延迟。选之前先问这个查询能不能降级、能不能异步、能不能只查最近 90 天。
路由版本化放在哪一层
最危险的位置是业务代码里的字面量,其次是配置文件里的单个数字。更稳的位置是路由层:调用方只传业务键,路由层根据版本决定算法。版本可以来自请求上下文、数据行上的字段,或键的前缀。无论选哪种,都要满足两个条件:旧版本函数不删除;新旧版本能同时回答同一个键。迁移完成后也不要立刻删旧函数,至少保留一个完整的回滚窗口。
什么时候不该分片
分库分表有固定成本:跨分片事务、跨分片查询、全局唯一 ID、运维复杂度、数据迁移。假设单表在 2000 万行以内、峰值 QPS 低于单库能力的一半(案例假设值),优先考虑索引优化、读写分离、分区表和历史归档,而不是直接分片。
判断是否需要分片,可以看三个信号:单表数据量导致 DDL 和备份不可维护;单库写入 QPS 接近上限且无法通过批量写缓解;索引深度导致点查 p99 持续超标。三个信号里至少两个成立,再讨论分片。只因为“未来可能增长”就提前分片,通常会把当前阶段拖进不必要的复杂度。
分片键要不要暴露给业务方
分片键最好收敛在数据访问层,业务代码只传业务 ID。暴露 shard_id 会让每个调用方都参与路由,任何一个地方算错都会写错片。数据访问层负责把业务键映射到分片,并在日志里记录路由版本和分片号,排障时才能还原“这条请求为什么去了这个库”。
如果确实需要暴露,例如批量导入工具要按分片并行,应该暴露一个封装后的 Route(key) (shard, version),而不是裸露的取模公式。封装可以演进,公式一旦复制到十个服务里,就再也改不动了。
一次改模数引发的“订单消失”
事故发生在一次看似成功的扩容。订单服务最初按 hash(user_id) % 16 分成 16 个库。大促前容量不够,团队决定扩到 32 个库。方案写得很简洁:把新库建好,把新写入按 32 取模,历史数据“慢慢迁移”。
上线当晚,客服系统开始收到投诉:用户能正常下单,但查不到三个月前的订单。订单详情页返回“订单不存在”,而数据库里明明有这条记录。监控面板上,32 个库的 QPS 很均匀,错误率也不高,因为请求确实被均匀地发到了 32 个库,只是发错了地方。
根因只有一句话:历史数据是按 16 取模写的,读路径已经换成 32 取模。hash("user:1001") % 16 = 3,hash("user:1001") % 32 = 19,读请求去了 19 号库,而数据在 3 号库。对 16 取模和 32 取模只有在哈希值能被 32 整除的少数情况下才碰巧一致,绝大多数历史订单都会读空。
更麻烦的是,这个错误不会在写入路径暴露。新订单写 19 号库,读也读 19 号库,一切正常;只有跨过切换点的历史数据会“消失”。等到发现时,新库已经写入了大量数据,回滚读路径会让新订单读不到,团队被迫同时维护两套路由:按创建时间判断,旧数据走 16,新数据走 32。这个临时方案又引出了边界问题:切换那一刻的订单按哪个规则?批量导入的历史订单用哪个时间?
正确的做法不是“小心一点”,而是把路由版本变成数据的一部分。常见方案有三种:
- 路由版本号随配置下发,读路径先查版本,再决定用哪个函数;旧版本函数永久保留,直到历史数据迁移完成。
- 在分片键前加版本前缀,例如
v1:user:1001,让键本身携带路由规则。 - 维护一张“键到分片”的目录表,扩容时更新目录,但目录表又会带来一致性和性能问题。
三种方案都承认同一个事实:分片规则会变,历史数据不会自动跟着变。路由函数必须是纯函数、可测试、可并存多个版本,而不是散落在各服务里的 % 16 字面量。
用稳定哈希把路由固定下来
配套代码 internal/shard/route.go 只做三件事:校验分片数、把键稳定映射到分片、在批量操作前检查键集合。它刻意不包含数据库连接、配置中心或重试逻辑,因为这些都会让“同一个键是否落在同一片”变得难以验证。
// file: internal/shard/route.go
// tier: L1
// verify: go test ./internal/shard/
package shard
import (
"fmt"
"hash/fnv"
)
// ShardCount 校验分片数量。分片数必须是正整数:
// 0 会让取模失去意义,负数通常意味着配置解析或上游计算已经出错。
func ShardCount(n int) error {
if n <= 0 {
return fmt.Errorf("shard: count must be positive, got %d", n)
}
return nil
}
// ShardOf 用 FNV-1a 把键稳定映射到 [0,n) 的分片号。
// 同一个键在进程重启、不同机器、不同批次任务里都必须落到同一片,
// 因此这里不使用随机种子,也不依赖 map 的迭代顺序。
func ShardOf(key string, n int) (int, error) {
if err := ShardCount(n); err != nil {
return -1, err
}
h := fnv.New64a()
_, _ = h.Write([]byte(key))
return int(h.Sum64() % uint64(n)), nil
}
// ValidateKeys 在批量导入或重分片前检查一批键。
// 空键无法稳定路由;重复键会在同一片内产生覆盖写,必须在上游拒绝。
func ValidateKeys(keys []string, n int) error {
if err := ShardCount(n); err != nil {
return err
}
seen := make(map[string]struct{}, len(keys))
for i, k := range keys {
if k == "" {
return fmt.Errorf("shard: key at index %d is empty", i)
}
if _, ok := seen[k]; ok {
return fmt.Errorf("shard: duplicate key %q at index %d", k, i)
}
seen[k] = struct{}{}
}
return nil
}
几个设计点值得展开。
ShardOf 使用 FNV-1a,而不是 Go 运行时自带的 hash/maphash。maphash 的种子默认随机,同一个键在不同进程会得到不同结果,用它做分片会导致重启后数据“搬家”。FNV-1a 不是密码学哈希,分布质量也谈不上完美,但它的确定性、跨语言可实现性和零依赖,正好适合路由这个场景。如果分片数量很大、键的分布明显偏斜,可以在键前加业务前缀再哈希,或者换成带固定种子的 xxhash,但必须把算法和种子一起版本化。
ShardCount 把 n <= 0 当成错误,而不是返回 0 或 -1。分片数为零通常意味着配置没加载、环境变量拼错或上游计算除了零;静默返回一个默认分片,会让请求悄悄写进错误的地方,比直接失败难查得多。
ValidateKeys 检查空键和重复键。空键在哈希后也会得到某个分片,但它无法回答“这条数据属于谁”,通常是上游解析失败的信号。重复键在批量导入时会造成同一主键被写两次,如果两次内容不同,最后哪条生效取决于写入顺序。把这两个检查放在入口,比在数据库唯一索引上撞墙更容易定位。
代码没有解决“大用户热点”。哈希只能保证键到分片的映射均匀,不能保证键本身均匀。如果 5% 的用户贡献了 50% 的订单,按 user_id 分片仍然会出现热分片。热点需要另一层手段:把大用户拆成子分片、给热点键加缓存、或者把读请求复制到只读副本。这些都不在路由函数里,但路由函数应该为它们留出扩展点,例如允许传入“键 + 子分片号”的组合键。
测试:稳定性与坏输入都要锁住
路由函数最怕的不是算得慢,而是“今天和昨天算得不一样”。下面的测试把稳定性、取值范围、坏分片数和坏键集合都固定下来。
// file: internal/shard/route_test.go
// tier: L1
// verify: go test ./internal/shard/
package shard
import "testing"
func TestShardCount(t *testing.T) {
for _, n := range []int{1, 2, 16, 1024} {
if err := ShardCount(n); err != nil {
t.Fatalf("ShardCount(%d) = %v, want nil", n, err)
}
}
for _, n := range []int{0, -1, -16} {
if err := ShardCount(n); err == nil {
t.Fatalf("ShardCount(%d) = nil, want error", n)
}
}
}
func TestShardOfIsStable(t *testing.T) {
keys := []string{"user:1001", "user:1002", "order:20261029", "店铺:42", ""}
for _, key := range keys {
first, err := ShardOf(key, 16)
if err != nil {
t.Fatalf("ShardOf(%q): %v", key, err)
}
for i := 0; i < 100; i++ {
got, err := ShardOf(key, 16)
if err != nil {
t.Fatalf("ShardOf(%q) round %d: %v", key, i, err)
}
if got != first {
t.Fatalf("ShardOf(%q) drifted: %d != %d", key, got, first)
}
}
if first < 0 || first >= 16 {
t.Fatalf("ShardOf(%q) = %d, out of range", key, first)
}
}
}
func TestShardOfRejectsBadCount(t *testing.T) {
if _, err := ShardOf("user:1001", 0); err == nil {
t.Fatal("want error for zero shards")
}
if _, err := ShardOf("user:1001", -4); err == nil {
t.Fatal("want error for negative shards")
}
}
func TestValidateKeys(t *testing.T) {
if err := ValidateKeys([]string{"a", "b", "c"}, 4); err != nil {
t.Fatalf("valid keys: %v", err)
}
if err := ValidateKeys([]string{"a", "b", "a"}, 4); err == nil {
t.Fatal("want error for duplicate key")
}
if err := ValidateKeys([]string{"a", "", "b"}, 4); err == nil {
t.Fatal("want error for empty key")
}
if err := ValidateKeys([]string{"a"}, 0); err == nil {
t.Fatal("want error for zero shards")
}
if err := ValidateKeys(nil, 2); err != nil {
t.Fatalf("empty batch should be allowed: %v", err)
}
}
TestShardOfIsStable 对每个键重复调用 100 次,并检查结果始终落在 [0,16)。100 次当然不能证明跨进程稳定,但它能抓住“每次调用都重新随机种子”这类低级错误。真正跨进程的验证要在集成测试里做:启动两个进程,对同一批键输出分片号,逐行比对。
TestValidateKeys 覆盖了空批次。空批次返回 nil 是有意的:迁移任务在某个时间窗口可能没有待处理数据,这不应该被当成错误。但如果批次里有一个空键或重复键,整个批次都会被拒绝,调用方需要先修数据再重试,而不是跳过坏行继续写。
测试没有覆盖哈希分布的均匀性。均匀性应该用生产样本做离线评估,而不是在单元测试里断言“每个分片误差小于 5%”,因为小样本下这个断言本身就不稳定。更实用的做法是定期跑一个采样任务,统计各分片的键数量和 QPS,偏离阈值时告警。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/shard/ -run 'TestShardOf|TestValidateKeys|TestShardCount' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestShardCount
--- PASS: TestShardCount (0.00s)
=== RUN TestShardOfIsStable
--- PASS: TestShardOfIsStable (0.00s)
=== RUN TestShardOfRejectsBadCount
--- PASS: TestShardOfRejectsBadCount (0.00s)
=== RUN TestValidateKeys
--- PASS: TestValidateKeys (0.00s)
PASS
ok webapp/internal/shard 0.242s
四个用例全部通过,退出码为 0。-run 只筛选了与路由相关的用例,同包的迁移区间测试没有被执行;需要全量确认时去掉 -run 再跑一次。
小结
分片键选择的本质,是决定“哪些数据必须在一起”。它由最高频的查询路径决定,而不是由表的主键决定;它还要接受热点、扩容和归档三种压力的检验。路由函数则是这个契约的执行者,必须是确定性的纯函数,必须能并存多个版本,必须对坏输入早失败。
“改分片数”从来不是一次配置变更,而是一次数据迁移。任何把 % 16 直接改成 % 32 而不保留旧路由的做法,都会让历史数据在逻辑上消失。版本化不是过度设计,它是分片系统的基本卫生。
下一节讨论冷热分离:当数据量继续增长,把所有数据都放在同一种存储介质上,本身就会变成成本问题。
阅读导航:上一节:目录与学习路径 · 下一节:4.2 冷热分离与归档 。
常见错误与边界
- 用主键当分片键,却不检查最高频查询是否带主键。订单详情带 order_id,但“我的订单”不带,只按 order_id 分会把用户列表查询变成全分片广播。
- 把哈希均匀当成数据均匀。哈希只保证键到分片的映射均匀,大键、大商户、大租户仍会造成热分片。
- 直接修改取模常量。没有版本化的路由变更,等于让历史数据读不到;应先双写、再迁移、最后切换读路径。
- 在路由函数里加入随机数或时间。任何非确定性输入都会让同一键在不同时刻落到不同分片。
- 用空键或重复键做批量导入。空键无法归属,重复键会静默覆盖,入口校验比数据库唯一索引更早暴露问题。
- 假设分片数永远不变。容量、合规和成本都会推动扩容,路由设计第一天就要为“多版本并存”留位置。
- 只测正常键,不测边界。分片数为 1、键为空、批次为空、键含中文或冒号,都应该有明确行为。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。