从一张大表到五张表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仓库 IDowner_id货主 IDcreate_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原来在一个库里的时候直接 JOINsku表就行。现在wms_order在出库库sku在基础资料库跨库 JOIN 不现实。我们的处理原则场景处理方式例子读多写少、且变化不频繁冗余字段sku_name、owner_name冗余到出库单实时性要求高、且经常变接口调用 本地缓存库存数量只是偶尔用异步消息补全报表数据说白了能冗余的尽量冗余不能冗余的走接口。虽然会带来一点数据一致性的开销但比起跨库 JOIN 的性能灾难这点代价值。具体操作上我们在出库单表上冗余了sku_name、owner_name、warehouse_name等十几个字段。这些字段在基础资料变更时通过 MQ 异步刷新到出库单。刷新逻辑做了幂等和限流防止基础资料大批量更新时把出库库打挂。对于库存数量这种实时性要求高的字段我们采用了接口调用 本地 Caffeine 缓存的方案。缓存时间设得很短只有 5 秒但对页面渲染来说已经够用了。Step 3用 CDC 做双写逐步切流表归属理清了接下来就是怎么迁移数据。咱们的目标是业务不停机用户无感知。核心思路是CDCChange Data Capture双写。我们用的是 Canal监听原库的 Binlog把变更同步到新库。这段伪代码展示了双写的核心逻辑/** * 双写拦截器在业务代码写原库的同时通过 Canal 同步到新库 */classCanalSyncHandler{voidonBinlogEvent(BinlogEventevent){// Step 1: 判断这条变更属于哪个业务域StringdomainDomainRouter.route(event.getTableName());// Step 2: 转换成对应新库的 SQLSqlStatementnewSqlSqlConverter.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_id80% 的查询都带仓库 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 */classOrderQueryRouter{QueryResultroute(OrderQueryRequestreq){// 情况 1查询条件带了 warehouse_id - 直接路由到对应分片if(req.getWarehouseId()!null){StringshardShardRule.getShard(req.getWarehouseId());returnmysqlShard.query(req,shard);}// 情况 2按订单号查 - 从订单号里解析出 warehouse_id 再路由if(req.getOrderNo()!null){LongwarehouseIdOrderNoDecoder.parse(req.getOrderNo());StringshardShardRule.getShard(warehouseId);returnmysqlShard.query(req,shard);}// 情况 3跨仓库的汇总查询 - 走 ES// 比如今天所有仓库的出库单量、按货主统计的订单金额returnelasticsearch.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 迁移状态机伪代码/** * 迁移状态机控制整个零停机迁移流程 */classMigrationStateMachine{enumPhase{DUAL_WRITE,// 双写期VERIFY,// 校验期READ_CUTOVER,// 切读流量WRITE_CUTOVER,// 切写流量COMPLETED// 迁移完成}voidrun(){PhasecurrentPhase.DUAL_WRITE;while(current!Phase.COMPLETED){switch(current){caseDUAL_WRITE:// 启动 Canal跑满 7 天startCanalSync();sleep(Duration.ofDays(7));currentPhase.VERIFY;break;caseVERIFY:// DataX 全量校验 抽样比对booleanpassdataXVerify()sampleVerify();if(pass){currentPhase.READ_CUTOVER;}else{alertAndFix();// 告警并修复}break;caseREAD_CUTOVER:// 灰度切读流量5% - 20% - 50% - 100%cutoverReadTraffic(Arrays.asList(0.05,0.2,0.5,1.0));currentPhase.WRITE_CUTOVER;break;caseWRITE_CUTOVER:// 关键切写之前先停业务写接口 3 秒确保 Canal 追上pauseWriteApis(Duration.ofSeconds(3));cutoverWriteTraffic(1.0);stopCanalSync();currentPhase.COMPLETED;break;}}}}这里有个小技巧切写流量之前暂停写接口 3 秒钟。这 3 秒足够 Canal 把最后几条 Binlog 消费完确保原库和新库完全一致。用户端感知到的最多是点保存的时候卡了一下比停机几小时可强太多了。另外整个迁移期间我们保持了一个回滚清单每个切流步骤都有对应的回滚方案。比如读流量切到 50% 的时候发现错误率飙升可以在 30 秒内把流量切回原库。这种随时能后悔的心态让我们在动手的时候踏实了不少。六、验证与总结6.1 迁移后的性能对比折腾了两个月终于全部切完了。来看看效果指标迁移前迁移后wms_order单表行数8 亿最大分片约 5000 万按订单号查询 P995200ms120ms按仓库时间查询 P993800ms180ms运营后台列表页经常超时走 ES平均 300ms大促峰值 MySQL IO90%最高 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 这种业务系统表关系复杂、查询场景多、实时性要求高拆分之前一定要把分片键、路由规则、跨片查询方案、数据一致性这几个问题想透。不然拆完你会发现性能是好了但代码复杂度翻了三倍运维半夜被告警叫醒的次数也翻了三倍。我们的经验是不要为了拆而拆。先垂直后水平热点表再拆普通表能忍则忍。如果你也在做类似的数据库拆分欢迎在评论区聊聊你们是怎么选分片键的跨片查询是怎么解决的迁移过程中踩过哪些难忘的坑咱们下篇见