原创

分库分表避坑指南:垂直分库 vs 水平分表,分片键选对才不踩雷

很多团队不是因为“不会分库分表”出问题,而是因为“分得太早、分得太随意、分片键选错、业务约束没提前设计”出问题。

分库分表本质上不是一套中间件配置,而是一次数据库访问模型的重构。只要开始分,就意味着你要重新面对主键生成、唯一约束、跨分片查询、分页、排序、聚合、扩容、迁移、事务一致性这些问题。

垂直分库和水平分表,到底在解决什么问题

很多人把这两个概念混在一起,结果一上来就“按月份分表”或者“先拆 64 张表”,最后业务越做越痛苦。

垂直分库

垂直分库是按业务域拆库。 例如用户库、订单库、商品库、库存库分别独立。

它解决的是:

  • 单库表太多,维护困难
  • 不同业务读写压力差异大
  • 不同模块耦合过高
  • 不同业务需要独立扩容、独立容灾

它的核心思路不是“分散数据量”,而是“拆业务边界”。

典型拆法:

  • user_db:用户、地址、账号、认证
  • order_db:订单、支付、售后
  • product_db:商品、类目、价格
  • inventory_db:库存、仓储、出入库

水平分表

水平分表是把同一张逻辑表的数据,按照某个规则拆到多个物理表中。

例如:

  • t_order_0000
  • t_order_0001
  • t_order_0002
  • t_order_0003

它解决的是:

  • 单表数据量过大
  • 索引膨胀
  • 热点写入集中
  • 查询性能下降
  • DDL 和归档越来越困难

它的核心思路不是“拆业务”,而是“拆数据规模”。

一句话区分

垂直分库关注的是业务解耦。 水平分表关注的是数据拆散

很多架构会同时存在:

  • 先垂直分库:按业务拆成多个库
  • 再在订单库里做水平分表:把订单表拆成多张

这不是二选一,而是不同层面的两种拆法。

什么时候该做垂直分库,什么时候该做水平分表

先说结论:

  • 业务边界不清,不要急着垂直分库
  • 数据规模不大,不要急着水平分表
  • 只因为“听说大厂都这么做”去分,基本都会踩坑

适合做垂直分库的场景

1. 业务模块边界已经稳定

比如用户、订单、商品、库存已经是相对独立的领域模型,这时候拆库能降低耦合。

2. 不同模块压力差异明显

订单写多读多,商品读多写少,用户相对平稳。拆开后可以独立做主从、缓存、扩容。

3. 团队已经按业务线协作

数据库结构和团队边界最好一致,否则最终会演变成“代码拆了,数据库没拆干净”。

适合做水平分表的场景

1. 单表数据量持续增长且已明显影响性能

比如订单、流水、日志、消息记录这类天然增长型表。

2. 查询访问模式高度固定

例如:

  • 根据 user_id 查用户订单
  • 根据 order_no 查订单详情
  • 根据时间范围查最近订单

访问模型越稳定,越适合分表。

3. 你能明确分片键,并且大多数核心查询都带上它

这点最关键。 没有稳定可靠的分片键,水平分表很容易变成“所有查询都要扫全表”。

不要把分库分表当成性能万能药

很多问题其实不需要上分库分表:

  • 没建好索引
  • SQL 写得差
  • 冷热数据没有分离
  • 大字段和高频字段混存
  • 读写分离没做
  • 缓存策略混乱
  • 归档机制缺失

这些问题没解决,分库分表之后只会变成“多个库上重复出问题”。

正确顺序通常是:

  1. 先优化 SQL 和索引
  2. 再做冷热分离、归档、缓存
  3. 单库单表确实扛不住时,再考虑分库分表

垂直分库最容易踩的坑

踩坑一:按表拆,不按业务拆

错误示例:

  • 用户基本信息一个库
  • 用户地址一个库
  • 用户积分一个库

看起来拆得很细,实际上业务强相关,最终所有接口都在跨库调用,事务和查询都会变复杂。

正确做法是按领域拆,不是按表名拆。

踩坑二:跨库 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_timestatusshop_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 作为主键。

跨分片事务,优先考虑最终一致性设计

分库分表后,下面这种“一个事务包打天下”的写法通常不现实:

  • 写订单
  • 扣库存
  • 记账户流水
  • 发优惠券
  • 改营销状态

正确做法通常是把一个大事务拆成:

  • 核心主流程:本地事务内完成
  • 非核心副作用:消息驱动异步处理
  • 全链路幂等:避免重复消费
  • 失败补偿:保证最终达成一致状态

一个更靠谱的思路

比如下单:

  1. 订单库本地事务写入订单
  2. 同事务写 outbox 消息
  3. 消息投递成功后由库存服务消费
  4. 库存服务幂等扣减库存
  5. 失败走补偿或状态回滚

重点不在“有没有分布式事务框架”,而在于你的业务能不能接受短暂不一致,以及有没有完善的补偿机制。

后台查询别硬查交易分片库

这是非常常见的坑。

交易库分片设计通常是为在线交易服务的,不是为后台多维筛选服务的。 如果后台运营、财务、风控、报表都直接查分片交易库,后面大概率出现:

  • 全分片扫描
  • SQL 极其复杂
  • 查询时间不稳定
  • 线上库被慢查询拖垮

更合理的做法通常是分层:

  • 交易库:服务在线业务
  • 搜索引擎/检索库:服务复杂筛选
  • 报表库/数仓:服务统计分析
  • 缓存/汇总表:服务高频指标

热点问题要在设计阶段提前识别

即使分表了,也可能还是热点。

常见热点来源

1. 热门用户

比如大客户、主播、超级商家、头部租户,数据和访问会远高于普通用户。

2. 时间热点

按时间分片时,当前时间分片压力最大。

3. 顺序写入热点

如果主键或索引过于集中,也会造成局部热点。

常见处理方式

  • 引入更均匀的分片规则
  • 对超大租户单独拆分
  • 对热点账号做特殊路由
  • 写入前加散列因子
  • 热数据缓存化
  • 将极端热点业务隔离到专属集群

扩容迁移一定要预留方案

很多系统第一次分表时只考虑“现在能跑”,没考虑“以后怎么从 8 扩到 32”。

扩容本质上是一次在线数据迁移工程,通常要考虑:

  • 新旧路由规则并存
  • 双写或影子写
  • 存量数据回填
  • 一致性校验
  • 灰度切流
  • 回滚方案

一个常见迁移步骤

  1. 新分片结构提前建好
  2. 应用支持双路由
  3. 新数据双写或按新规则写入
  4. 后台任务迁移历史数据
  5. 对账校验
  6. 灰度切读
  7. 完成后下线旧路由

没有迁移预案的分表设计,后期改起来代价极高。

分库分表后,程序层要跟着变

这不是 DBA 独立能解决的事,应用代码必须配套调整。

必须改的点通常包括

  • 主键生成方式
  • SQL 路由层
  • DAO/Repository 查询约束
  • 分页策略
  • 批量写入策略
  • 幂等设计
  • 事务边界
  • 审计与监控
  • 数据校验和对账工具

如果程序层还按单库思维写代码,数据库分了也没有意义。

几个特别容易忽略的细节

1. COUNT(*) 不再廉价

分表后,全局总数通常意味着聚合所有分片。高频场景要考虑缓存或异步汇总。

2. IN 查询要谨慎

如果一个请求里带很多分片键,可能会打到多个分片,导致 SQL 数量暴涨。

3. 批量更新和批量删除要控制范围

单次操作跨大量分片时,对数据库和路由层都是压力。

4. DDL 管理复杂度会上升

改一张逻辑表,可能要改几十张甚至几百张物理表。必须有统一变更机制。

5. 监控要能下钻到分片维度

不能只看“订单库整体正常”,还要能看到:

  • 哪个分片慢
  • 哪个分片数据量异常
  • 哪个分片写入偏斜
  • 哪个分片连接池吃紧

一个实用判断:先问自己这几个问题

在决定分库分表之前,至少先回答清楚下面这些问题:

垂直分库前要回答

  • 业务边界是否稳定
  • 哪些表必须放在一个事务内
  • 哪些查询会变成跨库查询
  • 哪些字段需要冗余
  • 应用层聚合是否可接受

水平分表前要回答

  • 单表增长速度到底多快
  • 核心高频查询有哪些
  • 这些查询是否天然带分片键
  • 后台复杂查询怎么解决
  • 唯一键怎么保证全局唯一
  • 扩容迁移怎么做
  • 分片热点怎么监控

只要这几类问题答不清楚,就不要急着上。

一套相对稳妥的落地建议

如果你正在做订单、流水、日志这类高增长业务,比较稳妥的思路通常是:

第一阶段:先把单库单表用到极致

  • 索引优化
  • SQL 优化
  • 缓存
  • 读写分离
  • 归档
  • 冷热分离

第二阶段:先垂直拆业务,再考虑水平拆数据

  • 先把用户、订单、商品、库存这些业务边界拆清楚
  • 再针对订单、流水这类大表做水平分表

第三阶段:围绕主路径选分片键

  • 不追求万能
  • 只服务最核心查询路径
  • 次要查询通过路由表、宽表、检索系统补足

第四阶段:提前设计迁移与治理能力

  • 全局 ID
  • 路由规则
  • 扩容方案
  • 对账工具
  • 监控告警
  • 自动化 DDL 管理

结论

分库分表真正难的,从来不是“怎么配中间件”,而是你是否真的理解自己的业务访问模型。

垂直分库解决的是业务边界和系统耦合问题。 水平分表解决的是数据规模和单表瓶颈问题。 两者不是互斥关系,但也绝不能混着乱用。

而在所有决策里,最关键的不是“分多少库、多少表”,而是分片键到底怎么选

因为分片键一旦定错,后面你会同时遇到:

  • 数据倾斜
  • 热点分片
  • 全路由扫描
  • 分页聚合困难
  • 扩容迁移痛苦
  • 唯一约束失效
  • 运维复杂度暴增

说到底,分库分表不是数据库层的小优化,而是一次面向未来业务规模的架构设计。 先看业务边界,再看访问路径,最后再定分法和分片键,这个顺序不能反。

正文到此结束
评论插件初始化中...
Loading...