微服务系列(三) 从一张大表到五张表-WMS数据拆分实战

从一张大表到五张表:WMS 数据拆分实战

副标题:数据库垂直拆分、水平拆分与平滑迁移方案


一、那个 8 亿行的订单表

上回咱们说到,WMS 单体应用终于扛不住了。这回咱们聊一个更刺激的话题——数据库拆分

事情是这样的。去年双十一刚过,运营同事在群里发了一张截图:一个出库单查询页面,转圈圈转了 5 秒钟还没出来。我一开始还以为是前端又写了个死循环,结果一查,问题出在数据库。

那张 wms_order 表,已经膨胀到了 8 亿行

8 亿行是什么概念?咱们来感受一下:

  • 一个普通的按订单号查询,以前 200ms 能返回,现在平均 5s+
  • 运营后台的报表查询,动不动就超时
  • 加索引?加了好几个联合索引,优化器有时候根本不选
  • 夜深人静的时候,MySQL 的 IO 飙到 90%,运维大哥已经开始在群里艾特我了

我当时的第一个想法是:分库分表啊!这还不简单?

但冷静下来一想,WMS 的表关系那叫一个错综复杂。wms_order 关联着 order_detailoutbound_taskinventory_logwave_headerpicking_detail……少说也有二三十张表。你要是闭着眼睛按订单号一分片,很多跨表查询就得垮掉。

其实啊,这 8 亿行不是一夜之间长出来的。两年前这张表才 8000 万行,那时候查询还挺快,大家也没当回事。中间做过几次"优化":加索引、搞读写分离、甚至把历史数据归档到另一张表——但业务增长的速度远超我们的预期,大促期间单量翻三倍,归档的速度根本赶不上新增的速度。到最后,那张表就像一个越吹越大的气球,谁也不知道它什么时候会炸。

更头疼的是,WMS 的业务特性决定了查询模式特别杂。仓库作业人员要按单号快速查单,运营人员要按时间范围拉报表,财务要按货主对账,客服要按客户订单号反查——这些查询条件各不相同,你很难用一套分片策略满足所有人。

核心问题就来了:先拆库还是先拆表?按什么维度拆?

这俩问题没想明白,贸然动手就是给自己挖坑。咱们一点一点来分析。


二、拆分策略选择

数据库拆分,说白了就两条路:垂直拆分水平拆分

2.1 垂直拆分:按业务域拆库

垂直拆分的思路很简单——原来一个库里塞了所有表,现在按业务域拆成多个库:

  • 入库库asn_headerasn_detailreceipt_task 这些
  • 出库库wms_orderoutbound_taskwave_headerpicking_detail
  • 库存库inventoryinventory_logstock_move
  • 基础资料库skuwarehousecustomerlocation
  • 报表库:各种统计表、历史归档表

这样做的好处很明显:

  1. 业务边界清晰:入库团队改代码,基本不会影响到出库团队的数据库
  2. 资源隔离:出库库压力大,可以单独扩容,不会拖累库存库
  3. 降低单库复杂度:每个库的表数量少了,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_orderorder_detail,几乎每次查订单都会带明细,这种"父子表"必须同库。

还有个容易忽略的点:事务边界。如果两张表经常在同一个事务里被修改,那它们最好也在同一个库。比如出库确认的时候,同时要更新 wms_order 的状态和 inventory 的库存数量——但库存和订单其实是两个业务域,这就是垂直拆分后必须面对分布式事务的典型场景。我们在设计阶段把这类"高频事务组合"标成了红色,拆的时候特别谨慎。

Step 2:处理跨域的"冗余字段" vs “关联查询”

拆完库之后,问题来了:出库单上要不要带 sku_name

原来在一个库里的时候,直接 JOIN sku 表就行。现在 wms_order 在出库库,sku 在基础资料库,跨库 JOIN 不现实。

我们的处理原则:

场景处理方式例子
读多写少、且变化不频繁冗余字段sku_nameowner_name 冗余到出库单
实时性要求高、且经常变接口调用 + 本地缓存库存数量
只是偶尔用异步消息补全报表数据

说白了,能冗余的尽量冗余,不能冗余的走接口。虽然会带来一点数据一致性的开销,但比起跨库 JOIN 的性能灾难,这点代价值。

具体操作上,我们在出库单表上冗余了 sku_nameowner_namewarehouse_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);
        }
    }
}

关键点在哪?

  1. 幂等:Canal 可能会因为网络抖动重发同一条 Binlog,所以目标库的写入必须幂等。我们的做法是在目标库加一张 sync_log 表,记录 (table_name, pk, binlog_timestamp),重复的直接跳过。
  2. 时区一致:原库和新库的 server_time_zone 必须一致,否则 create_time 会差 8 小时(这个坑我们后面细说)。
  3. 异常不中断:如果某条同步失败,要进死信队列,不能卡住后面的数据。

双写期间我们还做了一件事:影子查询。就是每次业务查询原库的时候,顺便用同样的条件查一把新库,对比返回结果是否一致。不一致的记录下来,分析是数据同步延迟还是转换逻辑有 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_202401wms_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 万
按订单号查询 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_zonesystem_time_zonecharacter_set

坑 4:数据倾斜

我们一开始按 warehouse_id % 16 分片,结果有几个大仓库(比如华东一号仓、华南二号仓)订单量占了全平台的 40%,导致某几个分片特别热。

后来我们加了一层虚拟分片:给大仓库分配多个 virtual_warehouse_id,让它的数据散到多个物理分片上。查询的时候,根据订单号里的编码反推是哪个虚拟 ID,再路由到对应分片。

这个方案相当于在物理分片和业务仓库之间加了一层映射。实现起来不复杂,但效果很显著——分片之间的数据量差异从原来的 10 倍降到了 2 倍以内。


写在最后

数据库拆分这件事,说起来就是"垂直拆、水平拆、做双写、切流量"这几步,但真落地的时候,细节里全是魔鬼。

WMS 这种业务系统,表关系复杂、查询场景多、实时性要求高,拆分之前一定要把分片键、路由规则、跨片查询方案、数据一致性这几个问题想透。不然拆完你会发现,性能是好了,但代码复杂度翻了三倍,运维半夜被告警叫醒的次数也翻了三倍。

我们的经验是:

不要为了拆而拆。先垂直,后水平;热点表再拆,普通表能忍则忍。

如果你也在做类似的数据库拆分,欢迎在评论区聊聊:

  • 你们是怎么选分片键的?
  • 跨片查询是怎么解决的?
  • 迁移过程中踩过哪些难忘的坑?

咱们下篇见!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值