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 节)。后来新人入职看这张表就能理解所有架构决策的来龙去脉,比任何文档都管用。选型这事没有银弹,但「写下否决理由」这件事本身就能避免团队在半年后反复推翻重来,值得一直坚持。

延伸阅读