news 2026/4/16 0:50:57

微服务系列(二) 微服务拆分不是拍脑袋-WMS怎么拆

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
微服务系列(二) 微服务拆分不是拍脑袋-WMS怎么拆

微服务拆分不是拍脑袋,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 │ └─────────────────┘

为什么"报表"单独拆?

报表有几个特点:

  1. 查询重、计算重:一个大盘报表可能要扫几百万条记录,做聚合、分组、排序。
  2. 数据来源杂:要同时查入库、出库、库存、库内各个域的数据。
  3. 变更频繁:老板的需求你懂的,今天加个字段,明天换个图表。

如果把报表逻辑塞在库存服务或者出库服务里,轻则拖慢核心接口,重则把核心服务搞挂。所以报表必须独立,甚至可以用单独的数据库(通过 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 或者其他业务系统,你有什么更好的拆分思路?欢迎在评论区交流,咱们一起进步!

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/16 0:48:49

NTC热敏电阻在开关电源中的关键作用与选型指南

1. NTC热敏电阻在开关电源中的核心作用 第一次接触开关电源设计时&#xff0c;我被上电瞬间的浪涌电流吓了一跳。当时测试台上火花四溅&#xff0c;保险丝直接熔断&#xff0c;后来导师递给我一颗黑色的小元件说&#xff1a;"试试这个"。这颗不起眼的NTC热敏电阻&…

作者头像 李华
网站建设 2026/4/16 0:47:51

技术测试驱动开发的先测试后编码

技术测试驱动开发&#xff1a;先测试后编码的智慧 在软件开发领域&#xff0c;技术测试驱动开发&#xff08;TDD&#xff09;以其独特的“先测试后编码”理念&#xff0c;逐渐成为高效开发的重要方法论。TDD的核心思想是在编写功能代码之前&#xff0c;先定义测试用例&#xf…

作者头像 李华
网站建设 2026/4/16 0:44:12

LaserGRBL:开源激光控制软件的技术架构与工程实践

LaserGRBL&#xff1a;开源激光控制软件的技术架构与工程实践 【免费下载链接】LaserGRBL Laser optimized GUI for GRBL 项目地址: https://gitcode.com/gh_mirrors/la/LaserGRBL 模块化架构设计&#xff1a;从图像处理到运动控制的完整技术栈 LaserGRBL作为一款专为激…

作者头像 李华
网站建设 2026/4/16 0:41:18

别再只会用SPI了!手把手教你用STM32的QSPI驱动外部Flash(附完整代码)

突破SPI性能瓶颈&#xff1a;STM32 QSPI驱动外部Flash的实战优化指南 在嵌入式系统开发中&#xff0c;外部Flash存储器已成为存储固件、图形资源和日志数据的标配组件。许多开发者习惯使用传统的SPI接口与Flash通信&#xff0c;但当遇到高分辨率图形加载、实时数据记录或OTA升级…

作者头像 李华
网站建设 2026/4/16 0:36:02

LaserGRBL:免费开源的激光雕刻控制软件终极指南

LaserGRBL&#xff1a;免费开源的激光雕刻控制软件终极指南 【免费下载链接】LaserGRBL Laser optimized GUI for GRBL 项目地址: https://gitcode.com/gh_mirrors/la/LaserGRBL 在数字制造和DIY创作的世界中&#xff0c;LaserGRBL作为一款专为激光雕刻优化的开源控制软…

作者头像 李华
网站建设 2026/4/16 0:34:08

Kali Linux U盘系统制作全攻略:从镜像下载到持久化存储配置

Kali Linux U盘便携系统实战指南&#xff1a;安全研究者的移动工作站解决方案 在数字化安全领域&#xff0c;快速响应和现场分析能力往往决定了一次渗透测试或安全审计的成败。传统虚拟机方案受限于主机性能&#xff0c;而常规安装又无法适应多设备切换需求——这正是便携式Kal…

作者头像 李华