1.2 选型对比:垂直拆、水平拆与分库
确认了「数据量是根因」之后,面对的第一道选择题是:垂直拆还是水平拆,分表还是分库。这三组词经常被混着说,对应的方案和代价完全不同。我们团队在白板上列了三天,最后的选择和最初直觉相反,这一节把对比过程写清楚。
场景:三种拆法各自解决什么问题
订单系统里有三类对象:用户、店铺、订单。用户表小但访问极频繁,店铺表中等,订单表巨大无比。三类表的症状不同,自然不能用同一种药。还有一个隐藏对象是「订单明细」,一个订单可能挂 5 到 20 条明细行,行数膨胀速度是订单的 8 倍,后来它被单独拆到明细分片,与主订单分片通过绑定表规则保持同库,避免下单一次写两个库。
垂直拆是「按业务或字段拆」:把订单表里的大字段(商品快照、备注、物流详情)拆到副表,把订单核心字段留在主表。水平拆是「按行拆」:把 OrderID 为 1 到 1 亿的行放库 A,1 亿到 2 亿的放库 B。分库与分表的区别只在物理部署:分表可以同库,分库则是独立实例。
| 拆法 | 解决的问题 | 实施成本 | 典型副作用 |
|---|---|---|---|
| 垂直拆(字段) | 单行过大、热冷字段混杂 | 低,改表即可 | 副表查询要二次拼装 |
| 垂直拆(业务) | 单库连接数与 IO 争抢 | 中,要改连接路由 | 跨业务 join 消失 |
| 水平分表(同库) | 单表行数过大 | 中,加路由层 | 同库 IO 仍是瓶颈 |
| 水平分库 | 连接、IO、单点容量全突破 | 高,全链路改造 | 跨库 join、事务、聚合 |
| 冷热归档 | 主表数据量压缩 | 中,但收益最直观 | 历史数据查询要去副表 |
方案:我们最终选了「水平分库 16 片 + 冷热分离」
水平分表(同库)是很多人的第一选择,代价小,但我们实测后放弃了:单库 16 张分表后,表行数降了,磁盘 IO 利用率从 97% 只降到 88%——因为瓶颈在实例层面而不是表层面,同一块磁盘和同一组 buffer pool 仍在被所有分表瓜分。这个教训说明,先做瓶颈观测再选型是对的(见 1.1 节)。
最终方案是按订单号哈希做水平分库,16 个物理分片,每个分片独立实例。理由有三:订单号是绝大多数核心查询的过滤条件;订单增长快,独立实例才能水平扩展 IO;每个分片独立备份与扩容,互不影响。这与 Java 实战:订单系统 里「订单状态机」一节的结构一致:先定领域模型,再选基础设施。
分库之后,跨库事务、全局聚合、全局唯一 ID 三件事全部落到应用层解决,这是方案真正贵的地方。如果业务里大量查询都是跨维度的范围统计,分库前最好先把统计需求改造成读汇总表或离线计算,否则会上线即返工。
一个常被忽略的中间路线是「分库不分图」:单库实例内部按业务垂直拆库(订单库、用户库、店铺库),每个业务库内再做水平分片。这样跨业务 join 变成跨库 join(仍然要做应用层拼装),但同一个业务域内的统计查询还能正常走。我们的店铺统计最后就是这样保留的:店铺库不分片,订单库分 16 片,两者通过店铺 ID 关联,关联动作放应用层做。这个折中在三年后的今天看仍然成立。
配置:ShardingSphere 的最小分库分表规则
中间件我们选 ShardingSphere-JDBC,侵入小、随应用部署、不需要额外 Proxy 集群。下面是最小可用的分片规则配置,按订单号哈希分 16 库,订单表每库一张:
dataSources:
ds_0: {dataSourceClassName: com.zaxxer.hikari.HikariDataSource, jdbcUrl: 'jdbc:mysql://ds0.internal:3306/order', username: root, password: '***'}
ds_1: {dataSourceClassName: com.zaxxer.hikari.HikariDataSource, jdbcUrl: 'jdbc:mysql://ds1.internal:3306/order', username: root, password: '***'}
rules:
- !SHARDING
tables:
t_order:
actualDataNodes: ds_${0..15}.t_order
databaseStrategy:
standard:
shardingColumn: order_no
shardingAlgorithmName: order_mod
shardingAlgorithms:
order_mod:
type: MOD
props: {sharding-count: 16}
这份配置只演示分库路由,表策略、广播表、绑定表在后续章节展开。配置上线前务必在测试环境用 16 个库灌入翻倍数据验证路由正确性,我们第一次上预发就因为 sharding-count 写错导致数据全部落进 8 个库。
验证路由有个简单办法:写入 100 条 OrderID 连续的测试数据,然后到每个分片上 SELECT COUNT(*),看分布是否均匀。再写 10 条 OrderID 相邻的数据,确认它们被分散到不同分片(这说明哈希是按值散列而不是顺序堆积)。这两步能挡住九成的配置错误。
踩坑:分库数量拍脑袋
第一个坑是分片数选太少。最初觉得 8 个库够用,结果上线半年订单量翻倍,扩容到 16 个库时数据迁移花了整个国庆假期。分片数最好一次到位(见 2.3 节的容量估算)。
第二个坑是垂直拆做太早。我们在分库前先做了字段垂直拆,结果分库又拆了一遍,期间维护两套路由逻辑,痛苦加倍。正确顺序是先定水平拆分方案,再在分片内部做字段垂直拆。
第三个坑是低估了跨库聚合的需求。上线第一周就有运营找过来:以前一条 SELECT shop_id, COUNT(*) FROM t_order GROUP BY shop_id 就能出的商家销量榜,现在要遍历 16 个库再归并。紧急方案是引入汇总表,订单写入时异步更新商家维度汇总,查询走汇总表。所有 GROUP BY 类需求都要在分库前重新设计,否则业务方会觉得新系统比老的还难用。
第四个坑是忘了维护广播表。像 t_channel(支付渠道字典)这种小表,分库前所有 join 都依赖它,分库后每个分片都需要一份副本,写时要同时广播到 16 个库。ShardingSphere 提供广播表机制自动处理同步,但需要提前规划,上线后再补成本很高。
隐性成本:分库之后多出来的四件事
选型的最后一关是「算清隐性成本」。直接成本(实例费)只是零头,真正贵的是下面四件长期工程:
| 隐性成本 | 具体内容 | 我们的解法 |
|---|---|---|
| 跨库 join 消失 | 订单与店铺不能一条 SQL 关联 | 绑定表加应用层拼装,见 3.1 节 |
| 全局聚合改写 | 所有 GROUP BY 变成跨库归并 | 汇总表加离线计算兜底 |
| 分布式事务 | 单机事务不再保证全局一致 | 本地消息表加对账,见 3.2 节 |
| 运维复杂度 | 16 套监控、备份、扩容流程 | 全部脚本化与平台化 |
这四件事在单体架构里都是数据库白送的,分库之后由应用层全部接手。如果团队人力不足,更务实的选择可能是「单库加机器到极限加冷热归档」,把分库往后推一两年。这不是技术问题,是组织问题。
小结
垂直拆治字段、水平分表治行数、水平分库治实例容量,三者不是替代关系而是可以叠加。选型顺序应当是:先确认瓶颈层级(表、实例还是连接),再选对应的拆法;分片数量一次规划到位,扩容成本远高于初期多买几台实例。如果团队里没有人做过分库,第一次选型建议直接选有成熟中间件支撑的水平分表路线。
选型决策推荐落成一张「决策表」,把每个选项的关键指标与否决理由写清楚。我们当时的决策表核心是三行:分片数 16(否决 8:三年容量不够)、JDBC 模式(否决 Proxy:多一跳延迟与代理单点)、按订单号分片(否决按用户:跨片查询太多,见 2.1 节)。后来新人入职看这张表就能理解所有架构决策的来龙去脉,比任何文档都管用。选型这事没有银弹,但「写下否决理由」这件事本身就能避免团队在半年后反复推翻重来,值得一直坚持。
延伸阅读
- Java 实战:订单系统:单体订单系统到拆分之间的业务建模对照
- PHP 实战:SaaS 系统:多租户场景下「按租户分库」的另一种拆分实践
- MySQL 实战:分库分表订单系统:回到本书,1.3 节讲分片实例规格怎么选
