分库分表避坑指南:垂直分库 vs 水平分表,分片键选对才不踩雷
很多团队不是因为“不会分库分表”出问题,而是因为“分得太早、分得太随意、分片键选错、业务约束没提前设计”出问题。
分库分表本质上不是一套中间件配置,而是一次数据库访问模型的重构。只要开始分,就意味着你要重新面对主键生成、唯一约束、跨分片查询、分页、排序、聚合、扩容、迁移、事务一致性这些问题。
垂直分库和水平分表,到底在解决什么问题
很多人把这两个概念混在一起,结果一上来就“按月份分表”或者“先拆 64 张表”,最后业务越做越痛苦。
垂直分库
垂直分库是按业务域拆库。 例如用户库、订单库、商品库、库存库分别独立。
它解决的是:
- 单库表太多,维护困难
- 不同业务读写压力差异大
- 不同模块耦合过高
- 不同业务需要独立扩容、独立容灾
它的核心思路不是“分散数据量”,而是“拆业务边界”。
典型拆法:
user_db:用户、地址、账号、认证order_db:订单、支付、售后product_db:商品、类目、价格inventory_db:库存、仓储、出入库
水平分表
水平分表是把同一张逻辑表的数据,按照某个规则拆到多个物理表中。
例如:
t_order_0000t_order_0001t_order_0002t_order_0003
它解决的是:
- 单表数据量过大
- 索引膨胀
- 热点写入集中
- 查询性能下降
- DDL 和归档越来越困难
它的核心思路不是“拆业务”,而是“拆数据规模”。
一句话区分
垂直分库关注的是业务解耦。 水平分表关注的是数据拆散。
很多架构会同时存在:
- 先垂直分库:按业务拆成多个库
- 再在订单库里做水平分表:把订单表拆成多张
这不是二选一,而是不同层面的两种拆法。
什么时候该做垂直分库,什么时候该做水平分表
先说结论:
- 业务边界不清,不要急着垂直分库
- 数据规模不大,不要急着水平分表
- 只因为“听说大厂都这么做”去分,基本都会踩坑
适合做垂直分库的场景
1. 业务模块边界已经稳定
比如用户、订单、商品、库存已经是相对独立的领域模型,这时候拆库能降低耦合。
2. 不同模块压力差异明显
订单写多读多,商品读多写少,用户相对平稳。拆开后可以独立做主从、缓存、扩容。
3. 团队已经按业务线协作
数据库结构和团队边界最好一致,否则最终会演变成“代码拆了,数据库没拆干净”。
适合做水平分表的场景
1. 单表数据量持续增长且已明显影响性能
比如订单、流水、日志、消息记录这类天然增长型表。
2. 查询访问模式高度固定
例如:
- 根据
user_id查用户订单 - 根据
order_no查订单详情 - 根据时间范围查最近订单
访问模型越稳定,越适合分表。
3. 你能明确分片键,并且大多数核心查询都带上它
这点最关键。 没有稳定可靠的分片键,水平分表很容易变成“所有查询都要扫全表”。
不要把分库分表当成性能万能药
很多问题其实不需要上分库分表:
- 没建好索引
- SQL 写得差
- 冷热数据没有分离
- 大字段和高频字段混存
- 读写分离没做
- 缓存策略混乱
- 归档机制缺失
这些问题没解决,分库分表之后只会变成“多个库上重复出问题”。
正确顺序通常是:
- 先优化 SQL 和索引
- 再做冷热分离、归档、缓存
- 单库单表确实扛不住时,再考虑分库分表
垂直分库最容易踩的坑
踩坑一:按表拆,不按业务拆
错误示例:
- 用户基本信息一个库
- 用户地址一个库
- 用户积分一个库
看起来拆得很细,实际上业务强相关,最终所有接口都在跨库调用,事务和查询都会变复杂。
正确做法是按领域拆,不是按表名拆。
踩坑二:跨库 Join 还想像单库一样自然
单库时代你可以:
SELECT o.id, o.order_no, u.nickname
FROM t_order o
JOIN t_user u ON o.user_id = u.id
WHERE o.id = 1001;
垂直分库后,这种写法通常做不到。因为订单和用户不在同一个库。
此时要么:
- 应用层聚合
- 做宽表/冗余字段
- 通过搜索引擎或数仓解决分析类查询
不要一边拆库,一边还按单库思维写查询。
踩坑三:分布式事务预期过高
垂直分库后,很多原来本地事务能解决的问题,会变成跨库一致性问题。
比如:
- 下单成功后扣库存
- 支付成功后更新订单状态和账户流水
- 退款后恢复库存并写资金明细
这时候不能默认依赖强一致分布式事务兜底。更常见、更实际的做法是:
- 本地事务 + 消息最终一致性
- Outbox 模式
- 幂等消费
- 补偿机制
- 状态机驱动
水平分表最容易踩的坑
踩坑一:分片键选错
分片键一旦选错,后面几乎所有问题都会爆出来:
- 数据分布不均
- 热点分片
- 查询路由失败
- 扩容困难
- 无法支持核心查询
这不是一个索引字段选择问题,而是整个数据访问模型选择问题。
后面会单独展开。
踩坑二:查询不带分片键
例如订单按 user_id 分表,但后台管理经常按订单状态、支付方式、创建时间做筛选。
这时如果 SQL 不带 user_id,路由层无法定位到具体分片,只能:
- 扫所有分表
- 在应用层合并结果
- 再做排序和分页
性能会迅速失控。
踩坑三:分页、排序、聚合变复杂
单表里这很简单:
SELECT *
FROM t_order
ORDER BY create_time DESC
LIMIT 20 OFFSET 0;
分表后如果不带分片键,问题马上出现:
- 每个分片都要先查
- 再在应用层归并排序
- 最后再截取分页结果
页码越往后,代价越大。
踩坑四:唯一约束失效
单表时代:
UNIQUE KEY uk_order_no (order_no)
分表后,这个唯一性只在单个物理表内有效,不再是全局唯一。
所以订单号、流水号、消息号这类业务主键,必须在生成策略上保证全局唯一,不能依赖数据库局部唯一索引兜底。
踩坑五:扩容不是改个配置就完事
例如最开始按 4 张表分:
user_id % 4
后来数据量暴涨,想改成 8 张表:
user_id % 8
这不是无损升级。因为原有路由规则变化后,历史数据的位置全变了,必须做数据迁移。
分表规则设计时就要考虑未来扩容,否则后面代价很大。
分片键为什么是分库分表成败关键
很多系统不是死在“没上分库分表”,而是死在“分片键拍脑袋决定”。
一个合格的分片键,至少要满足四个条件
1. 分布要足够均匀
如果分片键分布不均,某几个分片会成为热点。
典型反例:
- 按省份分片,但用户集中在少数省份
- 按业务状态分片,但大部分订单都处于同一种状态
- 按时间分片,但最新表永远是热点
2. 核心查询必须高频带上它
分片键不是看“理论上能不能分”,而是看“业务请求是否天然带这个字段”。
例如订单表:
- C 端“我的订单”天然带
user_id - 订单详情天然带
order_no - 后台运营查询往往带
create_time、status、shop_id
你必须先看高频查询路径,再决定分片键。
3. 尽量稳定,不要频繁变更
如果分片键可能变化,数据迁移成本会非常高。
例如按“所属门店”分片,但订单可能改归属门店;按“业务部门”分片,但组织结构经常调整,这都不是好选择。
4. 要考虑未来扩容和路由算法
只看当前够不够用不行,还要考虑以后怎么平滑扩容、怎么做双写迁移、怎么保持路由稳定。
常见分片键怎么选
按 user_id 分片
适合场景:
- 用户维度查询很多
- “查我的数据”是主路径
- 用户数据天然归属明确
优点:
- 路由简单
- 数据相对均匀
- C 端查询友好
缺点:
- 后台全局查询差
- 商家维度、时间维度分析类查询麻烦
- 大客户或超级活跃用户容易形成热点
按 order_no 分片
适合场景:
- 订单详情查询特别多
- 每次查询基本都通过订单号命中
- 订单号本身可路由
优点:
- 精确查询性能很好
- 路由明确
缺点:
- “我的订单”按用户查会很痛苦
- 用户维度列表查询要依赖二级索引表或额外路由表
按时间分片
适合场景:
- 日志、流水、审计、监控这类强时间序列数据
- 大量归档、冷热分离需求
优点:
- 清理历史数据方便
- 范围查询自然
- 归档和分区思路一致
缺点:
- 当前时间段是绝对热点
- 跨月跨年查询复杂
- 用户维度查询通常不友好
按 tenant_id 分片
适合场景:
- SaaS 多租户系统
- 租户隔离诉求强
- 大部分读写都围绕租户展开
优点:
- 数据隔离清晰
- 便于租户维度迁移和治理
缺点:
- 大租户可能压垮单分片
- 租户规模差异大时容易倾斜
订单系统分片键,为什么常常两难
订单系统最典型,也最容易选错。
因为它天然存在多种查询路径:
- 用户查我的订单:按
user_id - 系统查订单详情:按
order_no - 商家查店铺订单:按
shop_id - 运营查最近订单:按
create_time - 风控查异常订单:按多条件组合
这意味着不存在一个对所有查询都完美的分片键。
所以正确思路不是“找一个万能分片键”,而是:
- 先确定核心主路径
- 再为次要查询建立辅助能力
例如电商 C 端订单系统,主路径通常是:
- 用户维度的订单列表
- 订单号维度的详情查询
这时候常见做法是:
- 主分片键选
user_id - 订单号全局唯一
- 建立
order_no -> 分片信息的路由能力 - 后台复杂查询走 ES、OLAP、报表库或异构索引
这比强行让主库承担所有分析查询更现实。
一个相对合理的订单分表示例
假设订单主路径是“按用户查订单”,那可以按 user_id 做分表。
逻辑表:
t_order
物理表:
t_order_0000
t_order_0001
t_order_0002
t_order_0003
...
t_order_0063
路由规则:
table_index = user_id % 64
完整建表 SQL
先给出逻辑设计对应的完整表结构示例。
用户表
CREATE TABLE `t_user` (
`id` BIGINT NOT NULL COMMENT '用户ID',
`username` VARCHAR(64) NOT NULL COMMENT '用户名',
`nickname` VARCHAR(64) DEFAULT NULL COMMENT '昵称',
`mobile` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1正常 0禁用',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
UNIQUE KEY `uk_mobile` (`mobile`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
订单逻辑结构对应的单个物理分表
下面以 t_order_0000 为例,其余分表结构完全一致。
CREATE TABLE `t_order_0000` (
`id` BIGINT NOT NULL COMMENT '订单ID',
`order_no` VARCHAR(64) NOT NULL COMMENT '订单号,全局唯一',
`user_id` BIGINT NOT NULL COMMENT '用户ID,分片键',
`shop_id` BIGINT NOT NULL COMMENT '店铺ID',
`total_amount` DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '订单总金额',
`pay_amount` DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '实付金额',
`status` TINYINT NOT NULL COMMENT '订单状态',
`pay_status` TINYINT NOT NULL DEFAULT 0 COMMENT '支付状态',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
`pay_time` DATETIME DEFAULT NULL COMMENT '支付时间',
`remark` VARCHAR(255) DEFAULT NULL COMMENT '备注',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除 0否 1是',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_ctime` (`user_id`, `create_time`),
KEY `idx_shop_ctime` (`shop_id`, `create_time`),
KEY `idx_status_ctime` (`status`, `create_time`),
KEY `idx_pay_status_ctime` (`pay_status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单分表0000';
订单路由表
如果详情查询经常只有 order_no,可以增加一张路由表,避免无法定位分片。
CREATE TABLE `t_order_route` (
`order_no` VARCHAR(64) NOT NULL COMMENT '订单号',
`order_id` BIGINT NOT NULL COMMENT '订单ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`db_index` INT NOT NULL COMMENT '分库下标',
`table_index` INT NOT NULL COMMENT '分表下标',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`order_no`),
UNIQUE KEY `uk_order_id` (`order_id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单路由表';
为什么这里还需要路由表
因为你按 user_id 分片后:
- 查“我的订单”很顺
- 但拿到
order_no查详情时,不一定知道它在哪张表
这时有两种思路:
方案一:订单号编码中包含分片信息
例如订单号里编码了库表位。 优点是无需额外路由表。 缺点是生成规则变复杂,后续演进受约束。
方案二:维护独立路由表
优点是灵活、清晰。 缺点是多一次查询,且路由表本身也要考虑容量和高可用。
订单系统里,这两种方案都很常见。
分片键选择时,必须先盘清楚的四类 SQL
很多团队是先定分片键,再看 SQL 能不能兼容。顺序反了。
正确做法是先把高频 SQL 盘出来。
第一类:精确查询
例如:
- 根据订单号查详情
- 根据用户 ID 查最近订单
这类最适合做路由命中。
第二类:列表查询
例如:
- 我的订单列表
- 店铺订单列表
- 某状态订单列表
列表查询决定了你的分片键是否贴合主路径。
第三类:统计聚合
例如:
- 今日支付订单数
- 某店铺 GMV
- 某时段退款率
这类查询天然不适合直接在分库分表后的交易库上硬算,应该尽早考虑:
- 异步汇总
- 宽表
- 搜索引擎
- OLAP / 数仓
第四类:后台复杂筛选
例如:
- 状态 + 时间 + 店铺 + 支付方式 + 渠道来源
这类查询最容易触发全分片扫描。 如果后台运营需求很多,不要把所有指望压在主交易库上。
分片算法,不只是“取模”这么简单
最常见的是取模:
shard = user_id % N
它简单、好实现,但扩容时问题明显,因为 N 一变,历史路由就可能整体变化。
常见算法对比
1. 取模
优点:
- 简单
- 性能好
- 实现成本低
缺点:
- 扩容迁移成本高
- 数据重分布明显
2. 一致性哈希
优点:
- 扩容时迁移量相对更小
- 路由更平滑
缺点:
- 实现和维护更复杂
- 对数据库分库分表场景未必总是最优
3. 范围分片
例如按时间、ID 区间分。
优点:
- 范围查询友好
- 易于归档
缺点:
- 热点非常明显
- 新增分片后治理难度高
工程上没有绝对最优算法,只有最适合业务查询模型的算法。
全局主键生成,必须提前设计
单库单表可以用数据库自增主键。 分库分表后,自增主键只能保证局部唯一,不能保证全局唯一。
常见方案有:
1. 雪花算法
优点:
- 趋势递增
- 全局唯一
- 不依赖数据库
缺点:
- 依赖机器号管理
- 对时钟回拨要有防护
2. 号段模式
优点:
- 生成性能高
- 可控性强
缺点:
- 需要独立号段服务或号段表
- 运维治理稍复杂
3. UUID
优点:
- 实现简单
- 全局唯一
缺点:
- 太长
- 不利于索引
- 对存储和查询性能不友好
交易类核心表一般更常见的是雪花算法或号段模式,不建议直接用随机 UUID 作为主键。
跨分片事务,优先考虑最终一致性设计
分库分表后,下面这种“一个事务包打天下”的写法通常不现实:
- 写订单
- 扣库存
- 记账户流水
- 发优惠券
- 改营销状态
正确做法通常是把一个大事务拆成:
- 核心主流程:本地事务内完成
- 非核心副作用:消息驱动异步处理
- 全链路幂等:避免重复消费
- 失败补偿:保证最终达成一致状态
一个更靠谱的思路
比如下单:
- 订单库本地事务写入订单
- 同事务写 outbox 消息
- 消息投递成功后由库存服务消费
- 库存服务幂等扣减库存
- 失败走补偿或状态回滚
重点不在“有没有分布式事务框架”,而在于你的业务能不能接受短暂不一致,以及有没有完善的补偿机制。
后台查询别硬查交易分片库
这是非常常见的坑。
交易库分片设计通常是为在线交易服务的,不是为后台多维筛选服务的。 如果后台运营、财务、风控、报表都直接查分片交易库,后面大概率出现:
- 全分片扫描
- SQL 极其复杂
- 查询时间不稳定
- 线上库被慢查询拖垮
更合理的做法通常是分层:
- 交易库:服务在线业务
- 搜索引擎/检索库:服务复杂筛选
- 报表库/数仓:服务统计分析
- 缓存/汇总表:服务高频指标
热点问题要在设计阶段提前识别
即使分表了,也可能还是热点。
常见热点来源
1. 热门用户
比如大客户、主播、超级商家、头部租户,数据和访问会远高于普通用户。
2. 时间热点
按时间分片时,当前时间分片压力最大。
3. 顺序写入热点
如果主键或索引过于集中,也会造成局部热点。
常见处理方式
- 引入更均匀的分片规则
- 对超大租户单独拆分
- 对热点账号做特殊路由
- 写入前加散列因子
- 热数据缓存化
- 将极端热点业务隔离到专属集群
扩容迁移一定要预留方案
很多系统第一次分表时只考虑“现在能跑”,没考虑“以后怎么从 8 扩到 32”。
扩容本质上是一次在线数据迁移工程,通常要考虑:
- 新旧路由规则并存
- 双写或影子写
- 存量数据回填
- 一致性校验
- 灰度切流
- 回滚方案
一个常见迁移步骤
- 新分片结构提前建好
- 应用支持双路由
- 新数据双写或按新规则写入
- 后台任务迁移历史数据
- 对账校验
- 灰度切读
- 完成后下线旧路由
没有迁移预案的分表设计,后期改起来代价极高。
分库分表后,程序层要跟着变
这不是 DBA 独立能解决的事,应用代码必须配套调整。
必须改的点通常包括
- 主键生成方式
- SQL 路由层
- DAO/Repository 查询约束
- 分页策略
- 批量写入策略
- 幂等设计
- 事务边界
- 审计与监控
- 数据校验和对账工具
如果程序层还按单库思维写代码,数据库分了也没有意义。
几个特别容易忽略的细节
1. COUNT(*) 不再廉价
分表后,全局总数通常意味着聚合所有分片。高频场景要考虑缓存或异步汇总。
2. IN 查询要谨慎
如果一个请求里带很多分片键,可能会打到多个分片,导致 SQL 数量暴涨。
3. 批量更新和批量删除要控制范围
单次操作跨大量分片时,对数据库和路由层都是压力。
4. DDL 管理复杂度会上升
改一张逻辑表,可能要改几十张甚至几百张物理表。必须有统一变更机制。
5. 监控要能下钻到分片维度
不能只看“订单库整体正常”,还要能看到:
- 哪个分片慢
- 哪个分片数据量异常
- 哪个分片写入偏斜
- 哪个分片连接池吃紧
一个实用判断:先问自己这几个问题
在决定分库分表之前,至少先回答清楚下面这些问题:
垂直分库前要回答
- 业务边界是否稳定
- 哪些表必须放在一个事务内
- 哪些查询会变成跨库查询
- 哪些字段需要冗余
- 应用层聚合是否可接受
水平分表前要回答
- 单表增长速度到底多快
- 核心高频查询有哪些
- 这些查询是否天然带分片键
- 后台复杂查询怎么解决
- 唯一键怎么保证全局唯一
- 扩容迁移怎么做
- 分片热点怎么监控
只要这几类问题答不清楚,就不要急着上。
一套相对稳妥的落地建议
如果你正在做订单、流水、日志这类高增长业务,比较稳妥的思路通常是:
第一阶段:先把单库单表用到极致
- 索引优化
- SQL 优化
- 缓存
- 读写分离
- 归档
- 冷热分离
第二阶段:先垂直拆业务,再考虑水平拆数据
- 先把用户、订单、商品、库存这些业务边界拆清楚
- 再针对订单、流水这类大表做水平分表
第三阶段:围绕主路径选分片键
- 不追求万能
- 只服务最核心查询路径
- 次要查询通过路由表、宽表、检索系统补足
第四阶段:提前设计迁移与治理能力
- 全局 ID
- 路由规则
- 扩容方案
- 对账工具
- 监控告警
- 自动化 DDL 管理
结论
分库分表真正难的,从来不是“怎么配中间件”,而是你是否真的理解自己的业务访问模型。
垂直分库解决的是业务边界和系统耦合问题。 水平分表解决的是数据规模和单表瓶颈问题。 两者不是互斥关系,但也绝不能混着乱用。
而在所有决策里,最关键的不是“分多少库、多少表”,而是分片键到底怎么选。
因为分片键一旦定错,后面你会同时遇到:
- 数据倾斜
- 热点分片
- 全路由扫描
- 分页聚合困难
- 扩容迁移痛苦
- 唯一约束失效
- 运维复杂度暴增
说到底,分库分表不是数据库层的小优化,而是一次面向未来业务规模的架构设计。 先看业务边界,再看访问路径,最后再定分法和分片键,这个顺序不能反。