如何设计数据库分库分表与数据再平衡
不要先背 hash 和 range;先解释“按 entry_id 分片后,用户查本月账单为什么要广播所有库”
这一页只解决一个问题: 单库快到边界时,业务表怎样拆,路由怎样设计,旧数据怎样搬,扩容时怎样不写错库。分库分表不是把表拆小,而是把业务访问模式写进数据布局。
Web 记账系统把 ledger_entries 按 entry_id % 4 分成 4 个库。写入均匀了,但用户查“本月账单”时不知道这个用户的数据在哪个库,只能 4 个库都查再合并排序。
bad shard key:
shard = entry_id % 4
common query:
WHERE tenant_id = ? AND occurred_at BETWEEN ? AND ?
result:
query all shards
merge and sort in application
| 看似解决 | 实际失败 | 逼出的设计 |
| 写入均匀 | 高频查询变广播 | shard key 要服务访问模式 |
| 表变小 | 跨 shard 排序分页变复杂 | 常见列表必须带路由键 |
| 扩容简单 | 旧路由和新路由可能同时写 | 路由表要有版本和校验 |
| 问题 | 例子 |
| 是不是真的到了分片边界 | 先排除坏 SQL、缺索引、报表扫明细、归档缺失 |
| 最高频查询按什么找数据 | 账目通常按 tenant_id / ledger_id / month 查 |
| 哪些事务必须在一个库内 | 新增账目、分类校验、预算占用最好同 shard |
| 哪些查询允许异步 | 全站统计、运营报表不要继续打分片 OLTP |
| 超级租户怎么办 | tenant_id 分片后仍可能有大租户热点 |
| 扩容迁移时旧路由怎么拒绝 | route_version、slot ownership、旧 shard 写保护 |
shard_map
- route_version
- slot_start
- slot_end
- shard_id
- status: active | moving | retired
- updated_at
shard_ownership
- shard_id
- slot_id
- route_version
- writable
吸收点: 不要只在代码里写 hash 函数。真正的分片要有可变更、可审计、可拒绝旧版本的路由事实。
| 边界 | 错误做法 | 更稳做法 |
| shard key | 按 entry_id 均匀写 | 按 tenant_id / ledger_id 让高频查询和事务共址 |
| 全局 ID | 继续用单库自增 | 号段、Snowflake、ULID 或带定位能力的 ID |
| 跨 shard 查询 | 在线广播所有库 | 限制、异步化,或转到搜索 / 报表 / OLAP |
| 再平衡 | 停服搬库 | 历史复制、增量追平、影子校验、灰度切路由 |
关键判断: 如果大量高频写入都需要跨 shard 事务,优先怀疑 shard key 选错,而不是先上复杂 2PC。
| 样本 | 通过标准 |
| 查某租户本月账单 | 命中单 shard,不广播 |
| 新增账目并占用预算 | 账目、分类、预算在同 shard 内完成 |
| 运营查全站 Top 分类 | 不扫所有 shard 明细,走异步报表 |
| 旧应用拿旧 route_version 写入 | 被拒绝或转发,不静默写错库 |
| 迁移期间持续新增账目 | 新 shard 最终记录数和金额汇总一致 |
| 超级租户写入放大 | 能识别热点并迁出或二级分片 |
总结: 分库分表最重要的不是 hash,而是 shard key、路由版本、共址事务、跨 shard 查询边界和再平衡证据。只要用户最常见查询还要广播所有库,分片设计就还没对。