领导说三个月上微服务,发掉了半斤,进度还在原地转圈
Заголовок и краткое изложение на выбранном языке ожидают перевода.
一位开发者复盘自己按功能模块拆微服务失败的经历:一个下单流程要调八个服务,改订单状态会连带库存、支付、物流报错,凌晨被报警叫醒成常态。同事用领域驱动设计的“拆服务四步法”重新拆分后,下单响应时间从平均1.2秒降到350毫秒。文章给出事件风暴、聚合根设计、按限界上下文拆服务等步骤,并附 Java 代码示例。
Полный текст на выбранном языке ожидает перевода. Пока показан оригинал.
大家好,我是冰河~~
去年夏天,领导把我和隔壁组的老王叫进会议室,一本正经地说:“公司决定,现有电商系统要拆成微服务,三个月上线。你俩各带一个队,PK一下。”
我当时热血沸腾,心想:微服务嘛,不就是把大项目拆成小项目?用户一个服务,订单一个服务,商品一个服务,支付一个服务——多简单!当天晚上我就画好了架构图,第二天拉着全组开干。
三个月后,老王组的微服务上线了,订单并发量翻了三倍,老板在全员大会上夸他“技术过硬”。我组的微服务呢?一个下单流程要调八个服务,动不动就超时;改了订单状态,库存、支付、物流全部跟着报错;凌晨三点被报警电话叫醒是家常便饭。领导看着我的眼神,从期待变成了“你怎么还没走”。
我坐在工位上,盯着满屏的@Service注解,心里只有一个疑问:老王到底用了什么“外挂”?
直到有一天,我厚着脸皮请老王撸串,三杯啤酒下肚,他掏心窝子说:“你没用DDD模板吧?我一开始也像你一样瞎拆,后来学了领域驱动设计的那套‘拆服务四步法’,才走上正轨。你回去看看,别再把订单和库存揉在一起了。”
那天晚上我回家翻了整整一周的资料,把什么“限界上下文”“聚合根”“领域事件”嚼碎了咽下去,然后用他说的模板重新拆了一遍服务。两个月后,新的微服务架构上线,下单响应时间从平均1.2秒降到了350毫秒,半夜再也没有报警电话。
今天我就把这套“DDD拆服务四步法”原原本本分享给你,全程大白话加Java代码,保证你不用再掉我掉过的坑。
一、为什么你拆微服务越拆越慢?两个致命错误
先别急着聊DDD,咱们把“慢”的根源挖出来。我观察了身边十几个团队,踩的坑基本一样:
错误1:“按功能拆服务” —— 听起来对,做起来全是泪
很多人(包括第一次的我)觉得微服务就是按功能模块拆:用户、订单、商品、支付……每个功能一个独立服务,多清晰。
结果呢?一个最简单的“用户下单”流程,订单服务得先调商品服务查价格,再调库存服务扣库存,再调用户服务扣积分,再调支付服务生成付款单……一个请求串了五六个服务。中间任何一个服务慢一点,整个下单就卡死;任何一个服务挂了,订单直接失败。更痛苦的是,被调用的服务接口稍微改一下,订单服务就得跟着改,发布的时候还得小心翼翼排顺序。
错误2:把DDD当成“高深莫测的玄学”
你肯定也听过“DDD能解决微服务拆分难题”,但一看到“聚合根”“值对象”“领域事件”这些词,就觉得“这东西是给理论家用的,我写代码的别碰”。于是继续凭感觉拆,拆完重构,重构完再拆,陷入无限循环。
其实DDD一点都不玄,它就是一套教你如何找到服务边界的说明书。你把它当成“做菜的菜谱”,照着步骤做,就能炒出一盘好菜;不按菜谱,全凭感觉放盐,菜不是咸了就是淡了。
二、别怕,DDD就是“餐馆管理指南”
我用开餐馆的例子,把DDD最核心的三个概念给你说明白。
假设你要开一家餐馆(做一个电商系统)。餐馆里有前厅、后厨、仓库、收银台……每个部门各司其职。DDD就是帮你把部门职责划分清楚,避免前厅的人跑进后厨炒菜。
1. 限界上下文 = 餐馆里的独立部门
“限界上下文”听起来高大上,其实就是部门的边界。前厅是一个部门,后厨是另一个部门。前厅的人只负责迎客、点菜、上菜;后厨的人只负责做菜。他们有自己的职责范围,不会跨过边界去干对方的事。
前厅和后厨怎么沟通?通过菜单和传菜口。前厅把客人点的菜单交给传菜口,后厨从传菜口取菜单,做好菜再放回传菜口,前厅端给客人。他们不直接打电话、不串门。
对应到微服务:每个限界上下文就是一个独立的服务。比如“订单上下文”只负责订单相关逻辑,“库存上下文”只负责库存计算。订单服务需要扣库存时,不是直接写库存的数据库,而是通过接口或消息通知库存服务。
我当初犯的错就是把订单和库存的逻辑混在一起,导致后来库存表结构一改,订单服务的代码也得跟着改,改一次测试半天。
2. 聚合根 = 部门里的“负责人”
每个部门都有一个负责人。后厨的负责人是厨师长,所有做菜相关的任务都要经过他:他分配谁切菜、谁掌勺、谁装盘。你不能绕过厨师长直接指挥配菜员——不然厨房就乱套了。
在代码里,聚合根就是那个“负责人实体”。比如订单上下文里,“订单”就是聚合根。所有对订单项、订单地址、订单优惠券的操作,都必须通过订单这个聚合根来进行,不能直接修改订单项。
反面案例:有人给订单服务单独写了一个“修改订单项价格”的接口,绕过订单聚合根直接改订单项。结果订单总价没有重新计算,导致订单金额和实际应收对不上,财务对账的时候发现了几百笔差异……
3. 领域事件 = 部门间的“大喇叭”
后厨做好一道菜,会喊一嗓子“鱼香肉丝好了!”前厅听到就来取。这个“好了”的消息,就是领域事件。它是一个部门发出的通知,其他部门可以选择响应。
在系统里,领域事件用来解耦服务之间的依赖。比如订单服务创建订单成功后,发布一个“订单已创建”事件,库存服务听到这个事件就扣库存,支付服务听到就生成支付单。订单服务不需要显式调用它们,大家各干各的,通过事件沟通。
三、四步模板:照着做,微服务开发提速50%
下面就是老王教我的“四步拆服务法”,每一步都配合实际案例和Java代码,保证你用完再也不瞎拆。
第一步:事件风暴——1天理清业务边界
事件风暴不是啥高深会议,就是拉着产品、开发、测试坐在白板前,贴便利贴。
具体操作(以“电商下单”为例):
写领域事件:把业务流程中所有“已经发生的事情”写下来。比如:
订单已创建
库存已扣减
支付单已生成
用户已支付
订单已发货
写命令:每个事件是由什么“动作”触发的?这个动作就是“命令”。比如:
“提交订单”命令 → 触发“订单已创建”事件
“扣减库存”命令 → 触发“库存已扣减”事件
写角色:谁发出命令?用户、订单系统、支付系统……
画边界:找出哪些事件、命令属于同一个业务领域。比如“订单已创建”“订单已支付”“订单已发货”都围绕订单,属于“订单领域”;“库存已扣减”“库存已补充”属于“库存领域”。用笔把不同领域的便利贴圈起来——每个圈就是一个限界上下文。
这一步一下午就能做完,比你瞎猜服务边界靠谱一万倍。
第二步:聚合根设计——每个领域找个“话事人”
拿到限界上下文后,每个上下文里可能有多个实体(比如订单上下文里有订单、订单项、订单地址、订单日志)。我们要找出哪一个实体是“聚合根”——也就是管理其他实体的老大。
判断标准很简单:
独立存在:没有订单,订单项就没有意义 → 订单是老大
全局唯一标识:订单有订单号,外部通过订单号引用 → 订单是老大
管理生命周期:删除订单,订单项、订单地址都要一起删 → 订单是老大
Java代码示例:订单聚合根
// 订单聚合根实体
@Entity
@Table(name = "orders")
publicclass Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNo;
private Long userId;
private LocalDateTime createTime;
private OrderStatus status;
private BigDecimal totalAmount;
// 聚合根管理子实体:订单项(不能直接被外部修改)
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY, mappedBy = "order")
private List items = new ArrayList<>();
// 聚合根负责添加订单项
public void addItem(Product product, int quantity) {
OrderItem item = new OrderItem(this, product, quantity);
items.add(item);
// 重新计算总金额
recalcTotalAmount();
}
// 聚合根负责修改订单项数量
public void updateItemQuantity(Long itemId, int newQuantity) {
OrderItem item = items.stream()
.filter(i -> i.getId().equals(itemId))
.findFirst()
.orElseThrow(() -> new RuntimeException("订单项不存在"));
item.setQuantity(newQuantity);
recalcTotalAmount();
}
// 聚合根负责取消订单
public void cancel() {
if (this.status == OrderStatus.PAID) {
thrownew RuntimeException("已支付的订单不能取消,请走退款流程");
}
this.status = OrderStatus.CANCELLED;
// 发布领域事件
DomainEventPublisher.publish(new OrderCancelledEvent(this.id));
}
private void recalcTotalAmount() {
this.totalAmount = items.stream()
.map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
// 订单项不是聚合根,它依附于Order
@Entity
@Table(name = "order_item")
publicclass OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "order_id")
private Order order;
private Long productId;
private String productName;
private BigDecimal price;
privateint quantity;
// 没有独立存在意义,构造方法私有,只能通过Order来创建
protected OrderItem() {}
public OrderItem(Order order, Product product, int quantity) {
this.order = order;
this.productId = product.getId();
this.productName = product.getName();
this.price = product.getPrice();
this.quantity = quantity;
}
// 修改数量的方法也只被Order调用
void setQuantity(int quantity) {
this.quantity = quantity;
}
}
核心思想:外部如果要修改订单的任何信息,只能通过Order聚合根的方法。没有一个单独的OrderItemService让你直接改订单项。这样保证了业务规则的一致性(比如修改订单项后自动重算总价)。
第三步:拆分微服务——一个限界上下文一个服务
有了限界上下文和聚合根,拆分服务就像切蛋糕一样清楚:
一个限界上下文可以对应一个或多个微服务(如果上下文内聚合根不多且关联紧密,一个服务就够了)
一个聚合根对应服务内的一个核心业务模块
我们用Java的Spring Boot来演示如何搭建一个清晰的订单微服务,它只暴露聚合根提供的接口,不让外部绕过聚合根。
// 订单服务的对外REST接口(门面)
@RestController
@RequestMapping("/api/orders")
publicclass OrderController {
@Autowired
private OrderService orderService;
// 创建订单(只能通过聚合根方法)
@PostMapping
public OrderDto createOrder(@RequestBody CreateOrderRequest request) {
Order order = orderService.createOrder(request.getUserId(), request.getItems());
return OrderDto.fromEntity(order);
}
// 修改订单项数量
@PutMapping("/{orderId}/items/{itemId}")
public void updateItemQuantity(@PathVariable Long orderId,
@PathVariable Long itemId,
@RequestParam int quantity) {
orderService.updateItemQuantity(orderId, itemId, quantity);
}
// 取消订单
@PostMapping("/{orderId}/cancel")
public void cancelOrder(@PathVariable Long orderId) {
orderService.cancelOrder(orderId);
}
}
// 订单服务的领域服务层
@Service
@Transactional
publicclass OrderService {
@Autowired
private OrderRepository orderRepository;
public Order createOrder(Long userId, List items) {
Order order = new Order();
order.setUserId(userId);
order.setStatus(OrderStatus.CREATED);
order.setCreateTime(LocalDateTime.now());
for (OrderItemRequest itemReq : items) {
// 通过聚合根的方法添加订单项,而不是直接new OrderItem
Product product = productService.getProduct(itemReq.getProductId());
order.addItem(product, itemReq.getQuantity());
}
order = orderRepository.save(order);
// 发布领域事件
DomainEventPublisher.publish(new OrderCreatedEvent(order.getId()));
return order;
}
public void updateItemQuantity(Long orderId, Long itemId, int newQuantity) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new RuntimeException("订单不存在"));
// 通过聚合根修改,保证业务规则
order.updateItemQuantity(itemId, newQuantity);
orderRepository.save(order);
}
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new RuntimeException("订单不存在"));
order.cancel(); // cancel内部会做状态检查并发布事件
orderRepository.save(order);
}
}
避坑提示:千万不要在图省事的时候,在Controller里直接操作OrderItem的Repository。那样你就会绕过聚合根,导致数据不一致。如果发现“绕过聚合根更方便”,那说明你的聚合根设计有问题,而不是做法正确。
第四步:领域事件解耦——服务间别直接调接口
订单服务创建订单后,需要扣库存、生成支付单、给用户加积分。如果用同步调用,订单服务会变成这样:
// 错误示范
public Order createOrder(...) {
Order order = ...;
orderRepository.save(order);
inventoryService.deductStock(...); // 同步调用库存
paymentService.createPayment(...); // 同步调用支付
userService.addPoints(...); // 同步调用用户
return order;
}
问题:任何一个下游服务慢或挂,整个下单就失败。而且订单服务和库存服务紧耦合,库存接口改了订单也得改。
正确做法:用领域事件+消息队列解耦。
首先定义事件类:
// 领域事件基类
publicabstractclass DomainEvent {
privatefinal LocalDateTime occurredAt = LocalDateTime.now();
public LocalDateTime getOccurredAt() { return occurredAt; }
}
// 订单创建事件
publicclass OrderCreatedEvent extends DomainEvent {
privatefinal Long orderId;
privatefinal Long userId;
privatefinal List items;
public OrderCreatedEvent(Long orderId, Long userId, List items) {
this.orderId = orderId;
this.userId = userId;
this.items = items;
}
// getters...
}
// 订单取消事件
publicclass OrderCancelledEvent extends DomainEvent {
privatefinal Long orderId;
public OrderCancelledEvent(Long orderId) { this.orderId = orderId; }
// getter...
}
然后实现一个简单的事件发布器(实际可用Spring的ApplicationEvent或直接发到RabbitMQ):
@Component
public class DomainEventPublisher {
@Autowired
private RabbitTemplate rabbitTemplate;
public void publish(DomainEvent event) {
// 发送到RabbitMQ交换器
rabbitTemplate.convertAndSend("domain.exchange",
event.getClass().getSimpleName(),
event);
log.info("领域事件已发布: {}", event);
}
}
在订单服务的createOrder方法中,保存订单后发布事件,不再调用其他服务:
@Service
public class OrderService {
// ...
public Order createOrder(...) {
Order order = ...;
orderRepository.save(order);
// 发布事件,让其他服务自己处理
OrderCreatedEvent event = new OrderCreatedEvent(order.getId(),
order.getUserId(),
convertItems(order.getItems()));
domainEventPublisher.publish(event);
return order;
}
}
然后在库存服务中监听该事件:
// 库存服务中的监听器
@Component
@RabbitListener(queues = "inventory.queue")
publicclass InventoryEventHandler {
@Autowired
private InventoryService inventoryService;
@RabbitHandler
public void handleOrderCreated(OrderCreatedEvent event) {
log.info("收到订单创建事件,订单ID: {}", event.getOrderId());
for (OrderItemEventData item : event.getItems()) {
inventoryService.deductStock(item.getProductId(), item.getQuantity());
}
}
@RabbitHandler
public void handleOrderCancelled(OrderCancelledEvent event) {
log.info("收到订单取消事件,归还库存");
// 归还库存逻辑
}
}
支付服务、用户服务类似。这样订单服务完全不需要知道库存、支付、用户的存在,真正解耦。而且库存服务如果暂时不可用,消息会留在队列里重试,不会导致订单创建失败。
四、实战总结:2个月上线,再也没被半夜叫醒
按照这四步,我重新设计了公司的电商微服务架构:
订单服务(订单聚合根、退款聚合根)
库存服务(库存聚合根)
商品服务(商品聚合根、分类聚合根)
用户服务(用户聚合根、地址聚合根)
支付服务(支付单聚合根)
物流服务(物流单聚合根)
每个服务只暴露聚合根提供的接口,服务间通信全部改成异步事件驱动(除了少数必须同步的场景,比如“下单时查商品价格”用Feign同步调用也能接受,因为那是数据查询而不是修改)。
结果:
开发时间:从第一次失败的3个月缩短到2个月上线核心流程。
下单响应时间:从平均1.2秒(同步调用导致串行等待)降到350毫秒(异步事件不阻塞)。
代码耦合:新增“优惠券”功能时,只增加了优惠券服务和修改了订单服务的一个事件处理器,其他服务完全不用动。
睡眠质量:半夜报警电话从每天都有变成一个月一次(那次是云服务商网络抖动)。
五、别再踩这几个坑了(我帮你踩过了)
坑1:为了DDD而DDD,简单项目也硬拆
如果你的系统业务逻辑简单(比如一个博客后台,就用户、文章、评论),用单体Spring Boot项目一周就搞定了,非要拆成三个微服务,那纯粹是自找麻烦。DDD适合复杂业务、长期演进的系统。
坑2:聚合根设计得太大或太小
太大会导致每次操作都要加载一大堆无关的子实体,性能差。比如把用户和用户的所有订单、收货地址、优惠券都塞进一个聚合根,那每次修改用户昵称都要加载几百个订单对象,没必要。
解决办法:小聚合。一个聚合根只包含必须保持一致的那些实体。用户的基本信息和用户订单是两个不同的聚合根,订单和订单项是一个聚合根。
坑3:领域事件用不好,反而增加复杂度
有些人对领域事件理解不深,把所有服务间调用都改成异步事件,结果用户下单后,页面要刷新好几秒才能看到支付按钮(因为支付服务异步处理事件有延迟)。对于需要立即反馈的场景(比如“确保库存扣减成功才能下单”),应该用同步调用+分布式事务(如Saga模式),而不是一刀切地用异步。
坑4:忽略了业务专家的作用
DDD的核心是“业务驱动”,你必须拉着产品经理、业务负责人一起画事件风暴、定义聚合根。我以前自己一个人看PRD文档闭门造车,结果画出来的限界上下文和实际业务流程对不上,后来拉上产品经理一下午就全理清了。
六、总结
现在再见到老王,我都会举杯敬他:“谢谢那顿烧烤,救了我一把头发。”
微服务不是银弹,DDD也不是玄学。它们都是工具,用对了能让开发提速,用错了比单体还痛苦。希望这篇“四步法”能帮你找到正确的方向,下次领导再说上微服务,你可以笑着告诉他:“给我两个月,稳得一批。”
Источник: 冰河技术 · mp.weixin.qq.com