分库分表落地路径 中间件之外的另一种思路
单表过大是所有业务系统的必经关卡。多数团队第一反应是"上分库分表中间件",但这条路意味着分布式事务、跨分片查询、扩容迁移三座大山永久留在架构里。更常见的正解是:先用便宜的手段推迟分片,真要分片时选择让分片键暴露在业务代码里、而不是被中间件透明化。
一、先确认你真的需要
判断依据不是"数据量很大",而是三个可测量信号:
1. 单表 B+ 树层级从 3 层涨到 4 层(经验临界点约 2000 万到 5000 万行,取决于行宽)
2. DDL 与索引重建时间超过可接受窗口(例如加一个索引要 4 小时)
3. 单实例磁盘、连接数、备份恢复时长触顶
只有前两项成立才考虑分表;第三项成立说明是容量问题,应该先做读写分离与归档,而不是分片。
在分片之前,四个更便宜的手段通常就够用:
冷热归档。绝大多数业务只有 30 到 90 天的数据是热数据。归档能立刻把主表缩到原来的十分之一:
-- 归档表与主表同构,按年分区;主表只保留近期
INSERT INTO article_archive SELECT * FROM articles
WHERE created_at < DATE_SUB(CURDATE(), INTERVAL 180 DAY);
DELETE FROM articles WHERE created_at < DATE_SUB(CURDATE(), INTERVAL 180 DAY) LIMIT 1000;
DELETE 分批做,避免长事务与主键间隙锁放大;用 pt-archiver 或 gh-ost 类工具更稳。
分区表。MySQL 的原生分区能解决"大表 DDL 慢"和"按时间快速裁剪",且不改应用代码。范围分区、列表分区对报表场景友好。它的局限是所有分区仍在同一个实例上,不解决容量与写吞吐。
读写分离 + 覆盖索引。多数"表太大导致的慢"实际是缺少合适的二级索引,或读流量集中。
垂直拆分。把文章正文这类大字段拆到扩展表,主表变窄后 B+ 树层级和缓存命中率都会改善。这一条的收益常被低估,而成本极低。
二、分片键是所有决策的原点
决定分片方案的不是数据量,而是最高频查询的过滤条件。
Q1 按 id 查文章详情 ← 极高频
Q2 按 author_id 查我的文章列表 ← 高频
Q3 按 category + 时间查列表 ← 高频,跨作者
Q4 按标题全文搜索 ← 中频
如果按 id 分片,Q2、Q3 就变成全分片扫描;按 author_id 分片,Q1 需要知道作者、Q3 同样要扫全分片。三个问题不可能靠单一分片键同时满足——这是分片系统的本质约束,换任何中间件都绕不过。
工程上的处理是"接受一个主分片键,其余查询走异构索引":
主表 按 author_id 分片 满足 Q1(携带作者)与 Q2
索引表 A 按 id 分片,存 id -> author_id 满足 Q1(只有 id 时先查一次)
搜索引擎 ES/OpenSearch 满足 Q3、Q4
宽表副本 数仓或 Doris 满足运营与看板查询
一次 Q1 变成两跳,但这套结构可预测、可运维。"异构索引代替分片键"比分片键妥协更值得选,因为它把不一致风险限定在索引链路,主数据链路保持强一致。
三、透明化分片的代价
中间件宣称"不改代码即可分片",但代价只是转移,不是消失:
- 跨分片查询的性能不确定。一条
ORDER BY + LIMIT会退化为全分片扫描加内存归并,分片数越多越慢,且无法用索引优化。必须在代码评审里明令禁止; - 分布式事务。跨分片写入要么上 XA/2PC(性能差),要么走最终一致(业务复杂度上升)。多数团队低估这块;
- 扩容迁移。翻倍扩容相对容易,非翻倍(例如 16 到 20)需要大量数据搬迁。透明中间件会把这种迁移变成一次"黑盒大改",出问题时很难定位;
- 全局唯一 ID。必须由业务侧提供全局发号(号段或 Snowflake),中间件的自增模拟往往有隐藏瓶颈。
因此一个实用的立场是:分片逻辑显式可见(在 DAO 层明确带分片键),拒绝中间件"透明改写 SQL"。这样任何一条 SQL 都能一眼看出它落在哪个分片、是否会跨分片。规则写进代码而不是配置在中间件里,评审与排查才有抓手。
四、扩容易度提前设计
一旦决定分片,扩容易度必须在第一天决定:
分片数取 2 的幂并预留增长空间。按 shard = user_id % 32 起步,将来扩到 64/128 时,每个逻辑分片的数据只需要一分为二,可用"加倍 + 双写迁移"平滑推进。起步用 3 或 5 这类质数,扩容时几乎全量搬迁。
逻辑分片与物理实例解耦。32 个逻辑分片可以先落在 2 个物理实例上(每实例 16 片),后续按分片粒度整体迁移,而不是重新哈希。迁移的原子单位是"分片"而不是"行"。
库实例 A: shard0 .. shard15
库实例 B: shard16 .. shard31
扩容:把 shard8..15 整机迁到新实例,只改路由表,不重算哈希
路由版本化。路由规则(分片数、映射表)带版本号并落到配置中心,写入时把版本号一并记进数据(或至少在日志里),回滚与排障时才能确定某条数据当时的归属。
五、迁移:双写加对账
真正危险的是从单表迁到分片的那一刻。可执行的方案是四步:
- 双写:应用层同时写老表与目标分片表,以老表为准。此阶段所有读仍走老表,问题不会造成用户影响;
- 历史数据回填:分批按老表主键扫描写入分片表,注意与双写并发冲突时用
INSERT ... ON DUPLICATE KEY UPDATE并以时间戳判断新旧; - 对账:按分片键范围分段比对行数与内容指纹,差异自动修复并统计;
- 切读:先按小比例读新表并对比返回(影子读),差异率达标后按业务号段逐步切读,最后关老表写入。
if cfg.ReadPercent(userID) {
rows, err = shardDAO.List(ctx, authorID)
if cfg.ShadowCompare {
legacy, _ := legacyDAO.List(ctx, authorID)
metrics.Compare(legacy, rows) // 差异计数上报
}
} else {
rows, err = legacyDAO.List(ctx, authorID)
}
这套流程要能反复执行(幂等),并保留回退能力直到稳定期结束。
六、别忽略的三件事
唯一约束会变弱。跨分片的 UNIQUE(email) 在全局失效,需要提前用"邮箱到用户 ID 的索引表加分布式锁"来保证,这是一件必须提前设计、而不是迁移时才发现的事。
跨分片聚合与分页。"总数""排序取前 100"这类操作在分片环境下要么走数仓,要么走搜索引擎,不能指望在线库。这类需求识别得越早,架构越简单。
事务边界重划。原本一个本地事务能完成的操作,分片后可能跨库。合理的做法是围绕分片键重新划分聚合边界,让"同一用户的数据落在同一分片、同一次事务"成为不变量。这是 DDD 意义上的聚合设计与分片键设计的合流点,也是分片方案能不能长期成立的判断标准。
小结
正确的决策顺序是:先用归档、分区、垂直拆分、读写分离这些便宜手段;确实要分片时,用查询模式反推分片键,并接受"一个主分片键 + 异构索引",让分片逻辑显式写在 DAO 而不是藏在中间件里;分片数取 2 的幂、逻辑分片与物理实例解耦以保证可扩容;迁移用双写、回填、对账、影子读四步,保持可回退。多数团队最后会发现,分库分表不是第一步该做的事,而"分片键与聚合边界怎么设计"才是它真正难的部分。
评论(0)
登录 后参与评论。