从一张大表到五张表:WMS 数据拆分实战
副标题:数据库垂直拆分、水平拆分与平滑迁移方案
一、那个 8 亿行的订单表
上回咱们说到,WMS 单体应用终于扛不住了。这回咱们聊一个更刺激的话题——数据库拆分。
事情是这样的。去年双十一刚过,运营同事在群里发了一张截图:一个出库单查询页面,转圈圈转了 5 秒钟还没出来。我一开始还以为是前端又写了个死循环,结果一查,问题出在数据库。
那张 wms_order 表,已经膨胀到了 8 亿行。
8 亿行是什么概念?咱们来感受一下:
- 一个普通的按订单号查询,以前 200ms 能返回,现在平均 5s+
- 运营后台的报表查询,动不动就超时
- 加索引?加了好几个联合索引,优化器有时候根本不选
- 夜深人静的时候,MySQL 的 IO 飙到 90%,运维大哥已经开始在群里艾特我了
我当时的第一个想法是:分库分表啊!这还不简单?
但冷静下来一想,WMS 的表关系那叫一个错综复杂。wms_order 关联着 order_detail、outbound_task、inventory_log、wave_header、picking_detail……少说也有二三十张表。你要是闭着眼睛按订单号一分片,很多跨表查询就得垮掉。
其实啊,这 8 亿行不是一夜之间长出来的。两年前这张表才 8000 万行,那时候查询还挺快,大家也没当回事。中间做过几次"优化":加索引、搞读写分离、甚至把历史数据归档到另一张表——但业务增长的速度远超我们的预期,大促期间单量翻三倍,归档的速度根本赶不上新增的速度。到最后,那张表就像一个越吹越大的气球,谁也不知道它什么时候会炸。
更头疼的是,WMS 的业务特性决定了查询模式特别杂。仓库作业人员要按单号快速查单,运营人员要按时间范围拉报表,财务要按货主对账,客服要按客户订单号反查——这些查询条件各不相同,你很难用一套分片策略满足所有人。
核心问题就来了:先拆库还是先拆表?按什么维度拆?
这俩问题没想明白,贸然动手就是给自己挖坑。咱们一点一点来分析。
二、拆分策略选择
数据库拆分,说白了就两条路:垂直拆分 和 水平拆分。
2.1 垂直拆分:按业务域拆库
垂直拆分的思路很简单——原来一个库里塞了所有表,现在按业务域拆成多个库:
- 入库库:
asn_header、asn_detail、receipt_task这些 - 出库库:
wms_order、outbound_task、wave_header、picking_detail - 库存库:
inventory、inventory_log、stock_move - 基础资料库:
sku、warehouse、customer、location - 报表库:各种统计表、历史归档表
这样做的好处很明显:
- 业务边界清晰:入库团队改代码,基本不会影响到出库团队的数据库
- 资源隔离:出库库压力大,可以单独扩容,不会拖累库存库
- 降低单库复杂度:每个库的表数量少了,DBA 维护起来也轻松
但缺点也很现实:
- 跨库 JOIN 没了:原来一个 SQL 能查的,现在得调多个服务再拼装
- 分布式事务:如果出库扣库存,得保证两边数据一致
- 历史包袱重:WMS 里大量表都有冗余字段,拆之前得先梳理清楚
2.2 水平拆分:按某个维度分片
水平拆分就是单表数据量太大,把行拆到多个表或多个库里。常见的分片键有:
warehouse_id(仓库 ID)owner_id(货主 ID)create_time(按时间,比如按月分表)
优点是能把单表数据量压下来,查询性能回升。但 WMS 有个特殊之处:跨仓库查询很常见。
比如总部运营要查"所有仓库今天的出库单量",如果你按仓库分了 16 个片,这个查询就得扫 16 张表,性能可能还不如不分。
2.3 我们的选择:先垂直,后水平
折腾了一周,我们最终定了这个策略:
第一步:先做垂直拆分,把大库按业务域拆成 5 个库。
第二步:对垂直拆分后的热点表(比如出库库的wms_order),再按warehouse_id做水平拆分。
为什么这么选?
我一开始其实想直接水平拆 wms_order,觉得这样见效快。但做了几轮 POC 后发现:
- 垂直拆分能解决 80% 的问题(库太大、表太多、不同业务互相拖累)
- 水平拆分虽然能解决单表过大的问题,但会引入跨片查询、分片键设计、路由逻辑等一系列复杂度
- 如果先垂直拆,每个业务域独立演进,后续哪个库热了再单独做水平拆分,风险可控得多
说白了,垂直拆分是"治本",水平拆分是"治标"。咱们先治本,再对特别热的表治标。
三、垂直拆分的落地步骤
Step 1:梳理表归属,画"表-领域"映射图
垂直拆分最怕什么?最怕拆到一半发现两张表拆错了库,又得迁回来。
我们的做法是:先把所有表列出来,按照"谁创建、谁主写、谁强依赖"的原则,一张一张地归到对应的领域。
【出库域】
wms_order (主表)
order_detail
outbound_task
wave_header
wave_detail
picking_detail
packing_record
【库存域】
inventory
inventory_log
stock_move
stock_adjust
stock_take
【入库域】
asn_header
asn_detail
receipt_task
putaway_task
【基础资料域】
warehouse
owner
sku
location
【报表域】
daily_report
order_archive
这里有个小技巧:如果两张表 90% 的查询都是一起出现的,那就尽量放同一个库。比如 wms_order 和 order_detail,几乎每次查订单都会带明细,这种"父子表"必须同库。
还有个容易忽略的点:事务边界。如果两张表经常在同一个事务里被修改,那它们最好也在同一个库。比如出库确认的时候,同时要更新 wms_order 的状态和 inventory 的库存数量——但库存和订单其实是两个业务域,这就是垂直拆分后必须面对分布式事务的典型场景。我们在设计阶段把这类"高频事务组合"标成了红色,拆的时候特别谨慎。
Step 2:处理跨域的"冗余字段" vs “关联查询”
拆完库之后,问题来了:出库单上要不要带 sku_name?
原来在一个库里的时候,直接 JOIN sku 表就行。现在 wms_order 在出库库,sku 在基础资料库,跨库 JOIN 不现实。
我们的处理原则:
| 场景 | 处理方式 | 例子 |
|---|---|---|
| 读多写少、且变化不频繁 | 冗余字段 | sku_name、owner_name 冗余到出库单 |
| 实时性要求高、且经常变 | 接口调用 + 本地缓存 | 库存数量 |
| 只是偶尔用 | 异步消息补全 | 报表数据 |
说白了,能冗余的尽量冗余,不能冗余的走接口。虽然会带来一点数据一致性的开销,但比起跨库 JOIN 的性能灾难,这点代价值。
具体操作上,我们在出库单表上冗余了 sku_name、owner_name、warehouse_name 等十几个字段。这些字段在基础资料变更时,通过 MQ 异步刷新到出库单。刷新逻辑做了幂等和限流,防止基础资料大批量更新时把出库库打挂。
对于库存数量这种实时性要求高的字段,我们采用了接口调用 + 本地 Caffeine 缓存的方案。缓存时间设得很短,只有 5 秒,但对页面渲染来说已经够用了。
Step 3:用 CDC 做双写,逐步切流
表归属理清了,接下来就是怎么迁移数据。咱们的目标是:业务不停机,用户无感知。
核心思路是 CDC(Change Data Capture)双写。我们用的是 Canal,监听原库的 Binlog,把变更同步到新库。
这段伪代码展示了双写的核心逻辑:
/**
* 双写拦截器:在业务代码写原库的同时,通过 Canal 同步到新库
*/
class CanalSyncHandler {
void onBinlogEvent(BinlogEvent event) {
// Step 1: 判断这条变更属于哪个业务域
String domain = DomainRouter.route(event.getTableName());
// Step 2: 转换成对应新库的 SQL
SqlStatement newSql = SqlConverter.convert(event, domain);
// Step 3: 写入目标库(这里要做幂等处理!)
// 关键点:用主键+时间戳做幂等,防止重复同步
if (idempotentCheck(event.getPrimaryKey(), event.getTimestamp())) {
targetDataSource.execute(newSql);
}
}
}
关键点在哪?
- 幂等:Canal 可能会因为网络抖动重发同一条 Binlog,所以目标库的写入必须幂等。我们的做法是在目标库加一张
sync_log表,记录(table_name, pk, binlog_timestamp),重复的直接跳过。 - 时区一致:原库和新库的
server_time_zone必须一致,否则create_time会差 8 小时(这个坑我们后面细说)。 - 异常不中断:如果某条同步失败,要进死信队列,不能卡住后面的数据。
双写期间我们还做了一件事:影子查询。就是每次业务查询原库的时候,顺便用同样的条件查一把新库,对比返回结果是否一致。不一致的记录下来,分析是数据同步延迟还是转换逻辑有 Bug。这个机制帮我们提前发现了不少问题,比如某个字段在同步时被 Canal 的类型转换搞丢了精度。
双写跑起来之后,原库和新库的数据会保持实时一致。这时候我们就可以按接口逐个切流了——先把读流量切到新库,观察几天没问题,再切写流量。
四、水平拆分的关键决策
垂直拆分做完,出库库的压力确实小了很多。但 wms_order 这张表,在出库库里仍然有 8 亿行,还是得做水平拆分。
4.1 分片键选什么?
我们对比了三个候选:
| 分片键 | 优点 | 缺点 |
|---|---|---|
warehouse_id | 80% 的查询都带仓库 ID,天然避免跨片 | 跨仓库汇总查询麻烦 |
owner_id | 货主维度也常用 | 一个货主可能数据量不均,出现数据倾斜 |
create_time | 按时间分表简单 | 热点集中在最近几个月,老数据几乎不查但占空间 |
最终我们选了 warehouse_id 作为一级分片键,create_time 作为二级分表键。
具体规则:
- 按
warehouse_id % 16分 16 个库 - 每个库里再按
create_time按月分表,比如wms_order_202401、wms_order_202402
这样,一个仓库的订单会固定落在某个库的某几张月表里。查询时如果带了仓库 ID,路由非常明确。
你可能会问:按月分表,那跨月查询怎么办?比如查"最近三个月的订单"。我们的做法是:如果查询范围跨了 3 个月以内,就并行查这几张月表,然后在内存里合并排序;如果跨了 3 个月以上,直接走 ES。这样既能保证近期查询的性能,又不会因为历史查询把分片库压垮。
4.2 WMS 的特殊性:跨仓库查询怎么办?
这是水平拆分最让人头疼的问题。比如总部运营要查"全平台今天发了多少单",按仓库分了 16 个片,你总不能发 16 个 SQL 吧?
我们的方案是:80% 的单仓库操作走分片库,20% 的汇总查询走 Elasticsearch。
/**
* 查询路由:根据查询条件决定走 MySQL 分片库还是 ES
*/
class OrderQueryRouter {
QueryResult route(OrderQueryRequest req) {
// 情况 1:查询条件带了 warehouse_id -> 直接路由到对应分片
if (req.getWarehouseId() != null) {
String shard = ShardRule.getShard(req.getWarehouseId());
return mysqlShard.query(req, shard);
}
// 情况 2:按订单号查 -> 从订单号里解析出 warehouse_id 再路由
if (req.getOrderNo() != null) {
Long warehouseId = OrderNoDecoder.parse(req.getOrderNo());
String shard = ShardRule.getShard(warehouseId);
return mysqlShard.query(req, shard);
}
// 情况 3:跨仓库的汇总查询 -> 走 ES
// 比如:今天所有仓库的出库单量、按货主统计的订单金额
return elasticsearch.query(req);
}
}
ES 的数据从哪来? 还是靠 Canal。我们在 Canal 的消费者里加了一个分支:除了同步到 MySQL 分片库,还同步一份到 ES。这样 MySQL 和 ES 的数据基本保持实时一致。
当然,ES 不适合做精确到行的事务操作,所以它只承担汇总统计、列表查询、运营后台这类场景。真正的下单、改单、扣库存,还是走 MySQL。
这里多说一句 ES 同步的延迟问题。Canal 到 ES 的链路比到 MySQL 稍长一些,高峰期可能会有 1-3 秒的延迟。对于运营后台来说完全能接受,但如果是客服系统查单,3 秒延迟可能就出事了。所以客服查单这种场景,我们还是强制走 MySQL 分片库。
五、数据迁移的"零停机"方案
好,策略定好了,分片规则也设计好了,接下来就是最刺激的环节:怎么把 8 亿行数据搬过去,还不让业务停下来?
我们把整个迁移过程拆成了四个阶段,画了一个简单的状态机:
┌─────────┐ 启动双写 ┌─────────┐ 全量迁移 ┌─────────┐
│ 原库 │ ────────────> │ 双写期 │ ────────────> │ 校验期 │
│ 单库 │ │ │ │ │
└─────────┘ └─────────┘ └────┬────┘
│
切读流量 │
观察 3-7 天 │
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ 切流期 │ ─切写流量──> │ 停写期 │
│ 读走新库 │ │ 只写新库 │
└─────────────┘ └─────────────┘
5.1 各阶段做什么
双写期:
- 业务代码还是读写原库
- Canal 启动,把 Binlog 实时同步到新库(垂直拆分后的库,以及水平拆分后的分片库)
- 这个阶段要跑至少一周,观察同步延迟、数据一致性
校验期:
- 用 DataX 做全量数据校验,对比原库和新库的关键指标
- 比如:总记录数、按仓库统计的订单数、最近 7 天的金额汇总
- 如果有差异,排查是 Canal 丢数据还是转换逻辑有 Bug
切流期:
- 先把读流量按接口逐步切到新库
- 每个接口切 5% -> 20% -> 50% -> 100%,灰度放量
- 观察错误率、响应时间、业务指标,没问题了再切下一个接口
停写期:
- 最后把写流量切到新库
- 原库保留只读状态一段时间,作为"后悔药"
- Canal 反向同步可以停了,原库进入归档模式
5.2 工具组合
- DataX:做全量迁移和全量校验。它的优势是配置简单,支持 MySQL 到 MySQL 的高效批量同步。
- Canal:做增量同步。监听 Binlog,实时把变更打到新库和 ES。
- 自研校验工具:按业务维度抽样对比,比如随机抽 1 万条订单,逐字段比对。
5.3 迁移状态机伪代码
/**
* 迁移状态机:控制整个零停机迁移流程
*/
class MigrationStateMachine {
enum Phase {
DUAL_WRITE, // 双写期
VERIFY, // 校验期
READ_CUTOVER, // 切读流量
WRITE_CUTOVER, // 切写流量
COMPLETED // 迁移完成
}
void run() {
Phase current = Phase.DUAL_WRITE;
while (current != Phase.COMPLETED) {
switch (current) {
case DUAL_WRITE:
// 启动 Canal,跑满 7 天
startCanalSync();
sleep(Duration.ofDays(7));
current = Phase.VERIFY;
break;
case VERIFY:
// DataX 全量校验 + 抽样比对
boolean pass = dataXVerify() && sampleVerify();
if (pass) {
current = Phase.READ_CUTOVER;
} else {
alertAndFix(); // 告警并修复
}
break;
case READ_CUTOVER:
// 灰度切读流量:5% -> 20% -> 50% -> 100%
cutoverReadTraffic(Arrays.asList(0.05, 0.2, 0.5, 1.0));
current = Phase.WRITE_CUTOVER;
break;
case WRITE_CUTOVER:
// 关键:切写之前先停业务写接口 3 秒,确保 Canal 追上
pauseWriteApis(Duration.ofSeconds(3));
cutoverWriteTraffic(1.0);
stopCanalSync();
current = Phase.COMPLETED;
break;
}
}
}
}
这里有个小技巧:切写流量之前,暂停写接口 3 秒钟。这 3 秒足够 Canal 把最后几条 Binlog 消费完,确保原库和新库完全一致。用户端感知到的最多是"点保存的时候卡了一下",比停机几小时可强太多了。
另外,整个迁移期间我们保持了一个"回滚清单":每个切流步骤都有对应的回滚方案。比如读流量切到 50% 的时候发现错误率飙升,可以在 30 秒内把流量切回原库。这种"随时能后悔"的心态,让我们在动手的时候踏实了不少。
六、验证与总结
6.1 迁移后的性能对比
折腾了两个月,终于全部切完了。来看看效果:
| 指标 | 迁移前 | 迁移后 |
|---|---|---|
wms_order 单表行数 | 8 亿 | 最大分片约 5000 万 |
| 按订单号查询 P99 | 5200ms | 120ms |
| 按仓库+时间查询 P99 | 3800ms | 180ms |
| 运营后台列表页 | 经常超时 | 走 ES,平均 300ms |
| 大促峰值 MySQL IO | 90%+ | 最高 45% |
数据说话,效果还是挺明显的。尤其是单仓库的查询,因为路由明确,基本都能命中单张分片表,索引效率也回来了。
除了性能提升,还有一个意外收获:发布风险降低了。以前改出库逻辑,生怕影响到库存或者报表的查询。现在库拆开了,各团队可以独立发布、独立扩容,互相之间的耦合小了很多。虽然代码层面还是单体应用,但数据库层面的边界已经清晰了。
6.2 踩过的坑
当然,过程中也踩了不少坑,挑几个印象深的分享给你:
坑 1:外键约束
垂直拆分之后,很多表不在一个库了,外键自然就没法用了。但我们原库里有几十个外键约束,拆之前没清理干净,导致 Canal 同步到目标库的时候疯狂报错。
教训:拆分前先把外键约束去掉,改成应用层校验。
坑 2:自增 ID 冲突
水平拆分后,16 个库如果都用自增 ID,很容易出现主键冲突。我们后来改成了 雪花算法 生成分布式 ID,把 warehouse_id 编码进 ID 的高位,这样既能保证唯一,又能从 ID 里反推出仓库。
坑 3:时区问题
Canal 同步的时候,create_time 到新库莫名其妙差了 8 小时。排查了半天,发现是原库的 time_zone 是 +08:00,而新库默认是 SYSTEM,但新库服务器的系统时区被运维大哥改成 UTC 了。
教训:迁移前统一检查所有实例的 time_zone、system_time_zone、character_set。
坑 4:数据倾斜
我们一开始按 warehouse_id % 16 分片,结果有几个大仓库(比如华东一号仓、华南二号仓)订单量占了全平台的 40%,导致某几个分片特别热。
后来我们加了一层虚拟分片:给大仓库分配多个 virtual_warehouse_id,让它的数据散到多个物理分片上。查询的时候,根据订单号里的编码反推是哪个虚拟 ID,再路由到对应分片。
这个方案相当于在物理分片和业务仓库之间加了一层映射。实现起来不复杂,但效果很显著——分片之间的数据量差异从原来的 10 倍降到了 2 倍以内。
写在最后
数据库拆分这件事,说起来就是"垂直拆、水平拆、做双写、切流量"这几步,但真落地的时候,细节里全是魔鬼。
WMS 这种业务系统,表关系复杂、查询场景多、实时性要求高,拆分之前一定要把分片键、路由规则、跨片查询方案、数据一致性这几个问题想透。不然拆完你会发现,性能是好了,但代码复杂度翻了三倍,运维半夜被告警叫醒的次数也翻了三倍。
我们的经验是:
不要为了拆而拆。先垂直,后水平;热点表再拆,普通表能忍则忍。
如果你也在做类似的数据库拆分,欢迎在评论区聊聊:
- 你们是怎么选分片键的?
- 跨片查询是怎么解决的?
- 迁移过程中踩过哪些难忘的坑?
咱们下篇见!

998

被折叠的 条评论
为什么被折叠?



