微服务拆分不是拍脑袋,WMS 怎么拆?
用 DDD 给仓储系统划边界,我们踩过的弯路
第一次拆分的"翻车"经历
说实话,我第一次拆微服务的时候,自信满满。
那时候公司有个单体 WMS(仓储管理系统),代码量大概 15 万行,团队 20 多个人。每次发版都像在拆炸弹——你改了一个入库单的字段,测试同学要把出库、盘点、报表全跑一遍,生怕哪里连带崩了。
老板拍板:拆!必须拆成微服务!
我当时想,这还不简单?微服务嘛,就是把代码拆开部署。于是我大手一挥,按技术层拆:
wms-controller-service:放所有 Controller,负责接 HTTP 请求wms-service-service:放所有 Service,负责业务逻辑wms-dao-service:放所有 DAO 和数据库交互
拆完之后,我还挺得意:看,多清晰,标准的 MVC 三层架构,各管各的!
结果呢?上线第一天就炸了。
有个最简单的场景:用户想查一个入库单的详情。这个请求先到 controller-service,controller-service 调 service-service,service-service 再调 dao-service。一个查询,跨了 3 个服务,走了 4 次网络调用。
latency 从原来的 20ms 飙到了 400ms,高峰期直接超时。
更离谱的是,有一次 dao-service 挂了,controller-service 和 service-service 跟着全挂。本来是想解耦的,结果耦合得更深了——只不过从代码层面的耦合,变成了网络层面的耦合。
那天晚上,我和运维老哥一起 rollback 到单体版本,凌晨三点才下班。路上我就在想:微服务拆分,到底该怎么拆?
后来我读到一句话,醍醐灌顶:
微服务拆分的第一原则——按业务领域拆,不是按技术层拆。
说白了,如果你按技术层拆,只是把原来的一个应用变成了三个应用,业务逻辑还是缠在一起的。真正该拆的是业务边界。
这就引出了我们今天的主角:DDD(领域驱动设计)。
DDD 领域分析:WMS 的五大核心域
DDD 听起来很高大上,其实核心思想就一句话:先搞清楚业务里有哪些"域",再按域的边界拆服务。
咱们以 WMS 为例,跟业务专家(也就是仓库的老大哥们)聊了几轮之后,我们梳理出了五大核心域。
1. 入库域(Inbound)
这个域管的是"货怎么进来"。
核心流程:收货 → 质检 → 上架。
核心实体大概长这样:
// 入库域的核心实体classInboundOrder{StringorderNo;// 入库单号StringownerCode;// 货主编码(引用基础资料)List<InboundDetail>details;InboundStatusstatus;// 待收货、收货中、质检中、已上架、已关闭}classInboundDetail{StringskuCode;// 商品编码BigDecimalqty;// 计划入库数量BigDecimalreceivedQty;// 已收货数量BigDecimalqualifiedQty;// 质检合格数量}边界说明:入库域只关心"货从到仓到上架"这件事。它不需要知道库存最终有多少,那是库存域的事。它只需要在质检完成、上架之后,给库存域发一个事件:“这批货已上架,你记一下。”
2. 出库域(Outbound)
这个域管的是"货怎么出去"。
核心流程:波次分配 → 拣货 → 复核 → 打包 → 发货。
// 出库域的核心实体classOutboundOrder{StringorderNo;StringownerCode;List<OutboundDetail>details;OutboundStatusstatus;// 待分配、待拣货、拣货中、待复核、待打包、已发货}classWave{StringwaveNo;// 波次号List<String>orderNos;// 包含的出库单PickStrategystrategy;// 拣货策略:按单拣、边拣边分、先拣后分}边界说明:出库域的核心是"让订单高效、准确地发出去"。它会在拣货时向库存域查询可用库存,在发货时通知库存域扣减库存。但它不维护库存余额,只消费库存域的接口。
3. 库存域(Inventory)
这是 WMS 里最核心、也最敏感的域。
核心职责:库存记录、库存变动、库存冻结/释放。
// 库存域的核心实体classInventory{StringwarehouseCode;StringownerCode;StringskuCode;StringlocationCode;// 库位编码BigDecimalqty;// 可用库存BigDecimalfrozenQty;// 冻结库存BigDecimallockedQty;// 锁定库存(已被波次占用)}classInventoryTransaction{StringbizNo;// 业务单号(入库单/出库单/盘点单)TransactionTypetype;// 入库增加、出库扣减、盘点调整、移库BigDecimalbeforeQty;BigDecimalafterQty;BigDecimaldeltaQty;}边界说明:库存域是"唯一的事实来源"(Single Source of Truth)。任何域想改库存,都必须通过库存域的接口。入库域说"上架了",库存域就加;出库域说"发货了",库存域就扣;库内作业域说"盘亏了",库存域就调。
4. 库内作业域(Internal)
这个域管的是仓库里"不进货也不出货"的日常操作。
核心流程:盘点、移库、补货、加工。
// 库内作业域的核心实体classStockTakeOrder{StringorderNo;StockTakeTypetype;// 全盘、循环盘、动碰盘List<StockTakeDetail>details;}classStockTakeDetail{StringlocationCode;StringskuCode;BigDecimalsystemQty;// 系统库存(从库存域查询)BigDecimalactualQty;// 实盘数量BigDecimaldiffQty;// 差异 = 实盘 - 系统}classMoveOrder{StringorderNo;StringfromLocation;StringtoLocation;StringskuCode;BigDecimalqty;}边界说明:库内作业域会读取库存域的数据(比如盘点时查系统库存),也会产生库存变动(盘点调整、移库)。但和入库、出库不同的是,库内作业不跟外部货主直接打交道,它是仓库内部的"自我管理"。
5. 基础资料域(Master Data)
这个域是"后勤部",给其他所有域提供基础数据。
核心实体:商品(SKU)、货主(Owner)、仓库(Warehouse)、库位(Location)、承运商(Carrier)。
// 基础资料域的核心实体classSku{StringskuCode;StringskuName;StringownerCode;Stringbarcode;BigDecimalweight;BigDecimalvolume;BooleanisFragile;// 是否易碎BooleanisColdChain;// 是否冷链}classLocation{StringwarehouseCode;StringlocationCode;LocationTypetype;// 拣货位、存储位、退货位、不良品位BigDecimalmaxWeight;BigDecimalmaxVolume;}边界说明:基础资料域的数据相对静态,变更频率低,但读取频率极高。入库域要查 SKU 信息,出库域要查库位信息,库存域也要查。这个域的关键是接口稳定——一旦接口变了,下游全得跟着改。
边界划分的实战技巧
光知道五大域还不够,真正落地的时候,你会发现很多灰色地带。比如:
- 入库单里要不要带 SKU 名称?如果带,是不是耦合了基础资料域?
- 库存变动日志该放在库存域,还是单独拆一个"库存日志服务"?
- 报表该跟谁放在一起?
这些问题,我们当时争论了很久。后来总结出了三个实战技巧,分享给大家。
技巧 1:看数据归属——谁创建谁负责
这是划分边界最朴素也最有效的一条规则。
- 入库单是谁创建的?入库域创建的,那入库单的数据模型、生命周期、状态机,都由入库域负责。
- SKU 是谁创建的?基础资料域创建的,SKU 的增删改查归基础资料域管。
- 库存余额是谁创建的?库存域创建的,任何其他域只能"用"不能"直接改"。
问题来了:入库域展示入库单详情时,需要显示 SKU 名称,怎么办?
我们的做法是:入库单里只存skuCode,不存skuName。展示的时候,前端分别调入库域和基础资料域的接口,自己组装。或者由 BFF(Backend for Frontend)层去组装。
这样做的好处是:基础资料域改了 SKU 名称,不需要通知入库域去同步。数据归属清晰,边界就清晰。
技巧 2:看变更频率——高频变更的模块独立出来
不同业务模块的变更频率差别很大。如果把它俩硬塞在一个服务里,会互相拖累发版节奏。
以 WMS 为例:
- 库存域:几乎每天都在改,今天加个冻结逻辑,明天优化个锁库存性能,后天对接一个新的电商平台。
- 基础资料域:一个月可能改不了一次,SKU 新增都是运营在后台批量导入的。
如果把库存和基础资料放一个服务里,库存域每次发版都要带着基础资料域一起测、一起部署,风险大、效率低。所以必须拆开。
再举个例子:
- 报表模块:需求变化极快,老板今天想看这个维度,明天想看那个维度,后天要导出 Excel。
- 核心作业模块:出库流程一旦稳定,不能轻易改动。
所以报表一定要单独拆一个服务,甚至单独一个数据库。报表查得慢、跑挂了,都不能影响核心出库流程。
技巧 3:看团队边界——一个服务最好对应一个 2-pizza 团队
亚马逊有个著名的"2-pizza 团队"原则:一个团队的人数,不能超过两张披萨能喂饱的规模,大概 6-10 个人。
这个原则在微服务拆分里特别有用:一个服务最好由一个团队全权负责。
我们当时有 20 多个人,如果拆 5 个服务,正好每个服务配 4-5 个人,有点少;如果拆 3 个服务,每个服务 7-8 个人,比较舒服。
但注意,不要为了凑团队人数而强行合并域。比如你把入库域和出库域合并成一个"收发服务",看起来团队规模合适了,但入库和出库的业务逻辑差异很大,一个团队很难同时精通两边,最后变成"谁都不敢大改"。
我们的折中方案是:拆 6 个服务,其中库存域团队 8 个人(因为最核心、最复杂),其他域各 3-4 个人,基础资料域 2 个人就够了。
我们最终的拆分方案
聊了半天,直接上图,看看我们最后是怎么拆的。
┌─────────────────────────────────────────────────────────────┐ │ 网关层 (Gateway) │ │ 统一鉴权、路由、限流、日志 │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ BFF / 前端适配层 │ │ 组装数据、适配不同端(PC、PDA、大屏) │ └─────────────────────────────────────────────────────────────┘ ↓ ↓ ↓ ↓ ↓ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ 入库服务 │ │ 出库服务 │ │ 库存服务 │ │ 库内服务 │ │ 基础资料 │ │ Inbound │ │ Outbound│ │Inventory│ │ Internal│ │ Master │ │ Service │ │ Service │ │ Service │ │ Service │ │ Service │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │ │ │ │ └────────────┴────────────┴────────────┴────────────┘ ↓ ┌─────────────────┐ │ 消息队列 MQ │ │ (RocketMQ) │ └─────────────────┘ ↓ ┌─────────────────┐ │ 报表服务 │ │ Report Service │ └─────────────────┘为什么"报表"单独拆?
报表有几个特点:
- 查询重、计算重:一个大盘报表可能要扫几百万条记录,做聚合、分组、排序。
- 数据来源杂:要同时查入库、出库、库存、库内各个域的数据。
- 变更频繁:老板的需求你懂的,今天加个字段,明天换个图表。
如果把报表逻辑塞在库存服务或者出库服务里,轻则拖慢核心接口,重则把核心服务搞挂。所以报表必须独立,甚至可以用单独的数据库(通过 MQ 同步各域数据),用 OLAP 引擎来跑。
为什么"库存"不能拆太细?
有人可能会问:库存域那么大,能不能再拆?比如拆成"可用库存服务"、“冻结库存服务”、“库存流水服务”?
我们一开始也想过这么拆,但后来放弃了。原因是:
库存的核心是强一致性。一次出库操作,要同时扣减可用库存、增加锁定库存、记录库存流水。如果把这三件事拆成三个服务,就要引入分布式事务,复杂度指数级上升。
而且,可用库存、冻结库存、锁定库存,本质上都是同一张核心表的不同字段。拆得太细,数据库层面还是耦合的,没什么意义。
我们的结论是:库存域内部可以按模块分层(比如分成库存查询模块、库存变动模块、库存冻结模块),但对外还是一个服务。
服务间调用关系(伪代码)
// 场景:用户下了一个出库单,系统要分配库存并创建波次// Step 1: 出库服务接收请求,创建出库单outboundService.createOrder(request);// Step 2: 出库服务调用库存服务,锁定库存// 关键:这里只传 skuCode 和 qty,不耦合库存的内部实现LockResultresult=inventoryService.lockStock(newLockRequest(skuCode,qty,outboundOrderNo));// Step 3: 如果库存不足,返回错误;如果锁定成功,创建波次if(result.isSuccess()){waveService.createWave(outboundOrderNo);// 发送事件:波次已创建mqProducer.send(newWaveCreatedEvent(outboundOrderNo));}else{thrownewBizException("库存不足,无法分配");}// Step 4: 库内服务监听 WaveCreatedEvent,生成拣货任务@RocketMQMessageListener(topic="wms-wave-topic")classPickTaskConsumer{voidonMessage(WaveCreatedEventevent){internalService.createPickTask(event.getOrderNo());}}// Step 5: 拣货完成后,出库服务调用库存服务扣减库存并释放锁定inventoryService.deductAndUnlock(newDeductRequest(skuCode,qty,outboundOrderNo));// Step 6: 库存变动后,发送事件给报表服务同步数据mqProducer.send(newInventoryChangedEvent(...));关键点:
- 同步调用(OpenFeign)只用在强依赖、必须立即知道结果的场景,比如锁库存。
- 异步调用(MQ)用在最终一致、可以延迟处理的场景,比如生成拣货任务、同步报表数据。
- 每个服务只暴露最小化、稳定的接口,内部实现对外不可见。
拆分 checklist:这三个问题必须想清楚
拆微服务不是目的,目的是让系统更好维护、更好扩展。拆之前,建议用下面这个 checklist 自检一下:
1. 数据是否高度内聚?
一个服务里的数据,应该由这个服务自己创建、自己维护、自己负责一致性。如果某个表被三个服务同时读写,那说明边界还没划清楚。
2. 对外接口是否稳定?
微服务之间的接口就是"契约",一旦定了,不能轻易变。如果你的接口三天两头改,下游服务会崩溃的。基础资料域尤其要注意这一点。
3. 能否独立部署、独立扩展?
这是微服务最核心的价值。大促时出库量暴增,你可以只扩容出库服务;报表跑得太慢,你可以单独给报表服务加机器。如果拆完之后,几个服务还是必须一起打包、一起部署,那说明拆了个寂寞。
写在最后
微服务拆分没有标准答案,但有一些明确的错误答案——比如按技术层拆。
我们第一次拆 WMS 的时候,走了不少弯路。按技术层拆完又 rollback,按功能拆得太细又合并,反复折腾了两三个月,才找到现在的方案。
回过头看,我觉得最重要的收获不是"拆成了几个服务",而是整个团队对业务领域有了更深的理解。DDD 的价值,很多时候体现在"大家一起画边界图、一起讨论数据归属"的过程中。
当然,微服务也不是银弹。如果你的团队不到 10 个人,系统日活不到一万,单体应用可能更香。别为了拆而拆,合适的架构才是最好的架构。
你们团队在微服务拆分的时候踩过哪些坑?是按业务域拆的,还是按技术层拆的?WMS 或者其他业务系统,你有什么更好的拆分思路?欢迎在评论区交流,咱们一起进步!