数据量涨到一定规模,团队会开始讨论要不要分库分表。这个决定几乎不可逆——一旦上线,路由逻辑、运维方式、故障排查路径都会改变。动刀之前值得先把”为什么现在要拆”和”不拆还有什么办法”想清楚。
先确认真的需要拆
单表变大带来的压力,很多时候不是非靠拆分不可。下面这些手段应当先被逐一评估:
| 手段 | 解决什么问题 | 代价 |
|---|---|---|
| 索引优化、覆盖索引 | 慢查询、回表过多 | 写放大、占用空间 |
| 读写分离 | 读压力远大于写压力 | 主从延迟带来的一致性问题 |
| 冷热数据归档 | 历史数据很少被查询 | 归档表查询路径不同,业务要适配 |
| 分区表 | 单表逻辑上过大、按时间清理 | 分区键受限,全局索引仍有代价 |
| 垂直升级硬件 | CPU / IO / 内存瓶颈 | 有上限,成本非线性增长 |
| 业务侧缓存 | 热点读集中 | 一致性维护成本 |
分区表常被忽略。如果数据的查询天然按时间范围,且历史数据只需要按固定周期删除,按时间做 RANGE 分区可以让 DROP PARTITION 变成元数据操作而不是物理删除大批量行,运维窗口从小时级降到秒级。这个收益不需要改一行业务代码。
真正需要水平拆分的信号通常是这几类:写入吞吐确实到了单机瓶颈(磁盘 IO 或 redo/undo 写入成为瓶颈);单机的Buffer Pool已经装不下热点工作集,命中率持续下降;单表的维护操作(DDL、备份、数据订正)窗口已经长到业务不可接受;或者业务本身有按租户/地域做物理隔离的诉求。
“单表多大算大”的判断依据
常见的回答是”超过 2000 万行就该分表”,这个数字缺乏依据。更有意义的判断维度是:
索引深度(B+Tree 层数)。InnoDB 非叶子节点能存放大量键值指针,扇出很高。三层高度的树可支撑千万到亿级量级记录,四层则多一次 IO。用 SHOW TABLE STATUS 结合 page 大小可粗估。高度从 3 变 4,意味着单次点查询多一次磁盘访问,这时候拆分才有直接收益。
Buffer Pool 与工作集的比例。真正决定查询延迟的是热点数据能否留在内存。热点索引页远超 buffer pool 容量时,任何查询都会退化成随机 IO。
DDL 与维护窗口。一张 1TB 的表加列,即使走 online DDL 也要重建全部数据页、磁盘占用翻倍、IO 打满数小时。换成 16 张 64GB 的表,可以错峰执行,单表失败可回滚。
备份恢复时间。单表恢复需要多久直接决定 RTO,表越大这个数字越难看。
行数本身不是指标,落在数据上的操作能否在可接受时间内完成才是。
拆分键的选择
拆分键(sharding key)一旦定下几乎无法更换,这是整个方案里最需要慎重的一步。三个取舍维度:是否能覆盖最高频的查询路径、分布是否均匀、能否避免跨分片 JOIN 与聚合。
订单表是典型例子:既能按 order_id 查详情,也要按 user_id 查列表。选 user_id 则”我的订单”是单分片查询,但”按订单号查详情”变成全分片广播;选 order_id 则相反。自增主键取模通常分布均匀,但会把同一用户的记录打散。
常见做法是把分片信息埋进 ID,这样拿到订单号就能算出分片,不需要额外映射表。代价是 ID 不再连续,且扩容时旧 ID 的计算规则与新规则不一致,需要兼容逻辑。
跨分片查询怎么处理
并行广播 + 内存归并。中间层把 SQL 发到所有分片各自执行,再把结果拉回来归并。ORDER BY create_time LIMIT 20 这种 query 会被改写成”每个分片各取前 20,中间层归并后再取前 20″,排序和聚合在内存中完成。
-- 改写前
SELECT * FROM orders WHERE status = 1 ORDER BY create_time DESC LIMIT 20;
-- 下发到每个分片
SELECT * FROM orders_${n} WHERE status = 1 ORDER BY create_time DESC LIMIT 20;
深分页是这里的陷阱。LIMIT 100000, 20 会让每个分片都拉回十万多行再丢弃,网络与内存开销随页码线性增长。通常用”上一页最大 ID + 上一页时间戳”的游标方式替代。
冗余表(异构索引)。同一份数据按另一个维度再写一份。比如订单既按 user_id 存,也按 merchant_id 存。查询变成单分片,代价是写放大与两份数据间的同步延迟。
异构到检索系统。复杂的多条件检索、模糊匹配、聚合统计不适合在分片模型上做,下沉到专门的检索系统是常见选择,同步链路用 binlog 订阅保证最终一致。
全局 ID 与分布式事务
自增主键失效后的两种 ID 方案
号段模式由中心发号器分配区间(如 [1000, 2000)),应用本地发完再取下一段。ID 是数值且趋势递增,对 B+Tree 插入友好;代价是发号器需要处理高可用,且 ID 不保证严格连续。
雪花算法本地生成,不依赖中心节点:
| 1bit 符号位 | 41bit 时间戳 | 10bit 机器id | 12bit 序列号 |
机器 ID 需由协调服务分配,另要防御时钟回拨:检测到回拨后等待时钟追上,超过阈值直接报错拒绝服务,而不是冒险生成重复 ID。
分布式事务同样绕不开。单分片事务由数据库本地保证,跨分片需要额外协议:两阶段提交保证强一致,但引入协调者依赖并把资源锁定时长拉到所有参与者中最慢的那个;基于本地消息表或事务消息的最终一致方案可用性更好,业务要接受中间态可见。判断标准其实很简单:跨分片强一致是刚需,还是说明拆分键选错了。
扩容必须提前设计
分片数量从 4 扩到 8 时,按 hash(key) % 8 计算的位置和原来完全不同,意味着近乎全量重分布。两种常见规避:
一致性哈希。把 key 哈希到环上,扩容时只迁移环上相邻区间的数据,迁移量约等于新增比例。缺点是天然存在数据倾斜,需要虚拟节点(每个物理节点映射成数百个虚拟节点)来摊平。
逻辑分片 + 物理映射。固定逻辑分片数(比如 1024 个),用一张路由表记录逻辑分片到物理节点的映射。扩容时只需迁移部分逻辑分片并修改映射,key 的计算规则始终不变。这是相对更容易做平滑的方案。
数据迁移本身采用”双写 + 追增量 + 校验 + 切读”的流程:
- 开启双写:新数据同时写入新旧两套结构,读仍走旧。
- 全量拷贝历史数据,记录拷贝开始的位点。
- 从该位点追 binlog 增量,追平。
- 对比校验(行数、抽样 checksum)。
- 灰度切读,观察。
- 停旧写的双写,下线旧结构。
每一步都要能回滚。第 5 步最容易出问题:切读后如果发现数据不一致,必须能立刻切回旧路径。
拆完之后变复杂的事
拆分解决扩展性,同时会让下面这些事情显著变难:
- 运维对象数量级增长:从 1 张表变成 N 张表 × M 个实例,备份、巡检、慢日志采集、版本升级的对象数翻了几十倍,自动化是唯一出路。
- DDL 变成批量操作:加一列要在所有分片上执行,且必须处理部分分片失败后的继续/回滚。
- 问题排查路径变长:一个慢查询要确认路由到哪个分片,跨机房网络抖动会和数据库抖动混淆。
- 分布式 join 与统计:数据出不来,BI 和报表需要单独的数据同步链路。
- 单机 SQL 优化经验部分失效:优化器、执行计划在单分片内仍然成立,但跨分片的部分完全由中间层决定,不受数据库优化器管理。
- 成本:实例数增加带来的许可、机器、网络带宽成本是线性的。
什么时候不该拆
数据量在单机可承载范围内、且瓶颈来自慢 SQL 或索引缺失,拆分只会让慢 SQL 更难优化。团队没有自动化运维能力时,先做读写分离和归档收益更高。业务逻辑高度依赖跨行事务、join 密集且难以改造时,拆分的改造成本可能超过收益。
一个务实的判断顺序是:先优化 SQL 与索引 -> 读写分离 -> 归档冷数据与分区表 -> 垂直升级 -> 水平拆分。每一步都要回到业务指标上验证是否真的解决了问题,而不是因为”数据量终有一天会涨”就提前买单。
评论(0)
还没有评论,来抢沙发。
请 登录 后参与评论。