尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

订单里的 User,正在拖垮你的微服务——DDD 限界上下文实战与全链路代码落地

订单里的 User,正在拖垮你的微服务——DDD 限界上下文实战与全链路代码落地 订单里的 User,正在拖垮你的微服务——DDD 限界上下文实战与全链路代码落地微服务拆分最难的不是把代码搬到两个仓库,而是让两个模型不再共享同一套含义。本文以“订单包含用户信息”为主线,还原一次事件风暴工作坊,再用 Java 21、Spring Boot 3.x、MySQL 和 Kafka 给出从聚合、Outbox、Saga、幂等消费到 CQRS 读模型的生产级落地方案。核心不只是“怎么拆”,而是讲清“为什么改,以及改完后的新问题怎么解”。1. 那场故障的根因,不在 Feign一个典型电商系统最初是单体:订单、用户、商品共用一个库,Order直接持有User,下单事务里查用户、查会员等级、读默认地址。这在一个进程、一个数据库中并不稀奇。第一轮“微服务改造”只做了物理搬迁:用户表搬到用户服务,原来的本地方法被换成三次 HTTP 调用。业务逻辑的形状没变,网络、超时和部署节奏却已经变了。大促期间,用户服务拖慢,订单服务工作线程大量阻塞;客户端和网关超时重试,又放大了流量。看起来像 Feign 或线程池参数问题,其实是订单服务从未获得业务自治性。图:从模型耦合到故障放大Order depends on the full User model | v PlaceOrder makes 3 serial calls to User Service | v User Service jitter --- Order threads block --- RT rises | v Client retries --- More traffic ^ | +-------------+这就是“假拆分”:代码库分开了,领域模型没分开;数据库分开了,业务事务还想保持本地强一致;应用部署分开了,失败域却被同步调用链捆在一起。限界上下文首先解决模型所有权,微服务才解决独立部署。顺序反了,就会把进程内耦合放大成分布式故障。1.1 案例约束后文使用一组明确的示例容量目标。它们是架构输入,不是声称已获得的实测结果。约束示例目标对设计的影响下单峰值10,000 requests/s写链路不做多次跨服务串行查询订单查询峰值50,000 requests/s读模型独立扩容,不回查用户服务下单接口 SLOp99 200 ms订单接受与库存确认解耦用户服务故障核心下单可用身份来自已验签凭证,状态策略本地投影重复请求/消息不产生重复副作用API 幂等键 + 唯一索引 + Inbox如果系统只有几百 QPS,团队也没有独立发布需求,先建模块化单体通常更合理。2. DDD 到底在切什么2.1 切的不是表,而是语义和不变式同一个现实对象,在不同上下文中会有不同模型:用户上下文中是User,有身份状态、昵称、手机号、会员权益等不变式;订单上下文中是UserId,只是聚合外引用,订单不应修改用户资料;风控上下文中可能是RiskSubject,关心设备指纹、风险分和黑名单。如果订单直接依赖用户服务的User类,用户模型每增加一个字段、状态或校验,都可能泄漏到订单内部。这不只是类依赖,而是语义所有权丢失。2.2 三个容易混淆的边界**限界上下文不等于微服务。**它是模型边界。一个服务可以先容纳多个模块化的限界上下文,等独立发布价值足够大时再拆进程。**一个聚合不应跨服务。**聚合是一致性边界,一次本地事务只修改一个聚合是更安全的默认值。**值对象归属于语义,不归属于数据来源。**用户地址簿属于用户,“下单时收货地址快照”属于订单。用户之后改地址,历史订单不能跟着变。2.3 上下文映射“解耦”不是不交互,而是显式管理交互。上游下游集成方式原因用户订单发布语言 + 防腐层订单只接收公开资料和状态事件,不引入User商品订单本地商品投影下单计价不依赖远程商品详情订单库存Saga 事件库存预留不纳入订单本地事务订单查询模型领域事件投影读路径不跨库、不跨服务图:限界上下文与集成边界+------------------+ UserProfileChanged +------------------+ | User Context | ----------------------------- | Order Context | | User Aggregate | | Order Aggregate | | Profile/Identity | | UserId + Address | +------------------+ +--------+---------+ | OrderPlaced | v +------------------+ InventoryResult +------------------+ | Query Model | --- Event Projection --+ | Inventory Context| | order_view | | Stock Aggregate | | user_profile_view| | Reserve / Release| +------------------+ +------------------+Kafka 中传输的是发布语言 DTO,不是上游 JAR 中的领域对象。即使字段相似,也应分别定义、独立演进。3. 事件风暴工作坊:不要一上来就画服务事件风暴不是架构师独自画图,而是让业务专家、开发、测试和运维共同暴露认知差异。下面是一次 4 小时工作坊的可复用过程。3.1 准备:带业务问题进场参与者包括订单业务专家、用户与库存开发、客服、测试和架构师。工作坊的问题是“如何让用户中心故障时仍可接单”,而不是“要拆几个服务”。统一便签约定:橙色是已发生的领域事件,蓝色是命令,黄色是执行规则的聚合,紫色是外部系统,红色是痛点或未决策问题。3.2 先贴事件,再补命令与规则UserRegistered - ProductListed - OrderPlaced - InventoryReserved | | | +- InventoryRejected +- OrderCancelled这一轮不急着说“哪个微服务”,先问:这是业务方真正关心的事件吗?由谁触发?失败后业务上怎么处理?命令规则/不变式聚合事件提交订单请求不可重复,地址必须完整OrderOrderPlaced确认库存只有PENDING_INVENTORY可确认OrderOrderConfirmed取消订单已发货订单不能直接取消OrderOrderCancelled修改昵称符合用户中心规则UserUserProfileChanged关键争论是“收货地址属于谁”。结论不是二选一:用户地址簿属于用户上下文,DeliveryAddressSnapshot属于订单上下文。它们可能来自同一次输入,却有不同生命周期。3.3 找一致性边界和痛点把一定要在同一事务中成立的规则圈在一起,得到聚合;再根据语言、团队和变化节奏归类,得到候选限界上下文。实用判断是:两个模型是否因为不同的业务原因而变化?工作坊必须交付:带业务语言的事件时间线;聚合、不变式和命令责任人;上下文映射及每条集成的一致性要求;超时、重复、乱序、补偿和数据所有权问题清单;明确User、UserId、“收货地址快照”的术语表;待验证假设,而不是一张不允许修改的终局架构图。4. 从事件风暴到生产架构下单命令路径只做本地规则校验、写订单聚合、写 Outbox。库存预留通过 Saga 异步确认;昵称和会员等级从用户事件构建本地投影;查询只访问订单自己的读库。Client | v Gateway --verify JWT-- Order API --- Application Service | | | +-- Order + Items | +-- Idempotency Key | +-- Outbox | | | v | Kafka | / \ | v v | Inventory Read Model | | | +-- InventoryResult | | +------------------------------+ Query API --- order_view JOIN user_profile_view (local read only)网关负责验签和粗粒度限流,但订单服务仍必须验证签名、iss、aud、过期时间和权限,不能盲信网关注入的明文X-User-Id。4.1 用户存在性还要不要同步查在下单时调用userPort.exists(userId)虽避免了 Java 类耦合,却没解决运行时耦合。更危险的是 fallback 中“默认用户存在”,它会把可用性问题变成数据和合规问题。本案例的决策是:userId取自订单服务自己验证过的 JWTsub;封禁状态通过UserStatusChanged构建订单侧本地负面清单;若状态延迟会造成法规或资金风险,改为同步调用专用授权/风控决策服务,并失败关闭;昵称、头像等非关键资料失败时用占位符降级,不阻断下单。这是业务决策,不是所有系统通用的技术定律。4.2 昵称投影不直接写进每条订单若一个用户有 10,000 条历史订单,改一次昵称就更新 10,000 行order_read,会形成写放大和热点锁。更合理的读模型是:order_view保存订单和user_id,user_profile_view按user_id保存当前昵称,查询在同一个读库中 JOIN。历史快照和当前资料的语义要区分:收货地址必须保留下单时快照;列表上昵称通常显示当前值。若审计要求下单时昵称,则另存buyer_name_snapshot,不能偷换语义。5. 工程结构:让边界在代码中可见com.acme.order |-- domain | |-- model # Aggregates and value objects | |-- event # Domain events | +-- port # Domain ports |-- application | |-- command # PlaceOrderCommand | |-- query # OrderQueryService | +-- service # Use cases and transactions |-- adapter | |-- in/web # Controllers and DTOs | |-- in/messaging # Kafka Consumer | |-- out/persistence # JDBC adapters | +-- out/messaging # Outbox |-- infrastructure # Security, config, observability +-- OrderApplication.javadomain不依赖 Spring、Kafka、MySQL 或adapter;订单代码不得依赖用户服务的领域包。这两条规则将用 ArchUnit 固化。5.1 核心依赖propertiesjava.version21/java.version/propertiesdependenciesdependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-web/artifactId/dependencydependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-validation/artifactId/dependencydependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-jdbc/artifactId/dependencydependencygroupIdorg.springframework.kafka/groupIdartifactIdspring-kafka/artifactId/dependencydependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-oauth2-resource-server/artifactId/dependencydependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-actuator/artifactId/dependencydependencygroupIdio.micrometer/groupIdartifactIdmicrometer-registry-prometheus/artifactId/dependency/dependencies版本由 Spring Boot BOM 统一管理。文章使用“3.x”而不锁定小版本;项目应选组织已验证的 BOM 并经回归后升级。6. 领域模型:订单里只有 UserIdpackagecom.acme.order.domain.model;publicrecordUserId(Stringvalue){publicUserId{if(value==null||value.isBlank()||value.length()64){thrownewIllegalArgumentException("invalid userId");}value=value.trim();}}publicrecordDeliveryAddressSnapshot(Stringreceiver,Stringmobile,Stringprovince,Stringcity,Stringdistrict,Stringdetail){publicDeliveryAddressSnapshot{require(receiver,"receiver",64);require(mobile,"mobile",32);require(province,"province",64);require(city,"city",64);require(detail,"detail",256);district=district==null?"":district.trim();}privatestaticvoidrequire(Stringvalue,Stringfield,intmax){if(value==null||value.isBlank()||value.length()max){thrownewIllegalArgumentException("invalid "+field);}}}值对象一经创建就有效。手机号在日志与输出中必须脱敏;存储层应使用 KMS 托管密钥的字段级加密,不在代码中硬编码密钥。publicenumOrderStatus{PENDING_INVENTORY,CONFIRMED,CANCELLED}publicfinalclassOrder{privatefinalStrin
返回列表