[DDD实战] 高并发下的“一人一单”与库存扣减:领域建模与最终一致性实践

在电商系统的“秒杀”或“限购”场景中,创建订单看似简单,实则暗流涌动。特别是在面临以下两个核心约束时,传统的CRUD思维往往会陷入性能瓶颈或逻辑混乱:

  1. 购买资格校验:例如“一人一单”、“一人一天限购5单”。
  2. 高并发库存扣减:如何防止超卖,同时保证高性能。

本文将复盘我在项目中遇到的领域建模难点,探讨如何通过引入新的聚合根以及最终一致性策略来优雅解决这些问题。


一、 领域建模的困局:限购逻辑该放哪?

限购逻辑的核心在于:“判断一个用户在特定时间窗口内,对某个商品的购买历史是否满足条件。”

在设计初期,我们面临着模型归属的抉择:

❌ 方案一:放在“订单(Order)”聚合根中

思路:在创建新订单时,查询该用户所有的历史订单,统计购买数量。
问题

  • 性能极差:随着时间推移,历史订单表数据量巨大,实时统计(Count/Sum)是数据库杀手。
  • 职责不清:订单聚合根应该关注“本次交易”的状态流转,而不是背负“历史审计”的责任。

❌ 方案二:放在“用户(User)”聚合根中

思路:在用户模型中增加一个List<PurchaseHistory>
问题

  • 大对象问题:如果是高频购买用户,加载用户聚合根会将大量无关的历史订单载入内存,造成资源浪费。
  • 违背单一职责(SRP):用户领域应关注账号、画像等信息,不应包含具体的交易规则逻辑。

二、 破局:引入“用户商品(UserProduct)”聚合根

为了解决上述问题,我们决定从业务场景出发,剥离出一个独立的领域模型——UserProduct(用户购买记录)

1. 为什么不是直接查数据库?

有人可能会问:“如果只是判断买没买过,直接写一个 SQL select count(1) from order where ... 不行吗?”

  • 简单的场景:如果规则仅仅是“一人终身一单”,数据库唯一索引确实够用。
  • 复杂的场景:业务需求往往是多变的,比如“一人一天最多买一单,但活动期间总共可以买五单”。

这时候,我们需要一个聚合根(Aggregate Root)来封装这些变化的业务规则。UserProduct 聚合根不仅仅是一张表,它包含了校验逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 伪代码示例
public class UserPurchaseHistory extends AggregateRoot {
private String userId;
private String skuId;
private int todayCount;
private int totalCount;
private Date lastPurchaseTime;

// 领域行为:校验并预占额度
public void recordPurchase() {
if (todayCount >= 1) throw new DomainException("今日限购已达上限");
if (totalCount >= 5) throw new DomainException("活动限购已达上限");

this.todayCount++;
this.totalCount++;
this.lastPurchaseTime = new Date();
}
}

通过这种方式,我们将复杂的限购规则内聚在了一个轻量级的聚合根中,查询快、逻辑清晰。


三、 架构设计:最终一致性与异步化

解决了“能不能买”的问题,接下来是“怎么买”的问题。在高并发下,如果将校验、库存扣减、订单落库放在一个长事务中,数据库连接池很快会被耗尽。

我们需要将流程拆解,采用最终一致性模型。

核心流程设计

第一阶段:资格校验与预下单(同步响应)

这一步的目标是快速响应用户,拦截不合规请求。需要加锁,防止并发问题。

  1. 库存状态缓存前置校验

    • 订单域维护一份本地缓存(或Redis),只记录商品状态:AVAILABLE(可购买) 或 SOLD_OUT(已售罄)。
    • 该状态通过监听库存服务发布的领域事件(StockSoldOutEvent / StockReplenishedEvent)进行异步更新。
    • 注意:这里不校验具体数量,只拦截已明确售罄的请求,减少后续数据库压力。
  2. 用户购买资格校验

    • 加载 UserPurchaseHistory 聚合根。
    • 执行 recordPurchase() 进行规则校验。
    • 持久化:将更新后的购买记录保存到数据库(利用乐观锁或数据库行锁保证并发安全)。
  3. 生成预订单与发布事件

    • 生成全局唯一的 orderId
    • 发布 PreOrderCreatedEvent(预购买领域事件)
    • 立即返回:告诉用户“排队中”或直接返回 orderId,前端进入轮询状态。

第二阶段:真实下单与库存扣减(异步处理)

订单服务内部的 Listener 监听 PreOrderCreatedEvent,负责真正的资源落地。

  1. 再次校验库存(防超卖的关键)

    • 虽然前面有缓存校验,但缓存存在延迟。在此步骤,必须同步调用库存服务(RPC)进行真实的库存扣减。
    • 事务一致性:库存扣减与订单创建必须在同一个分布式事务(如Seata)或通过TCC、最终一致性补偿机制保障。建议此处采用RPC强一致扣减,因为库存是强资源。
  2. 创建订单

    • 如果库存扣减成功,正式创建 Order 聚合根并持久化。
  3. 发布成功事件

    • 发布 OrderCreatedEvent
    • 支付服务、购物车服务监听该事件,执行后续逻辑(如清理购物车)。

四、 异常处理与补偿机制(Saga Pattern)

在异步流程中,最大的挑战在于:第一阶段成功了(扣了用户限购额度),但第二阶段失败了(库存不足)怎么办?

这时候,我们需要Policy来进行补偿:

  1. 场景:创建订单在执行库存扣减时,库存服务返回“库存不足”。
  2. 处理
    • 订单创建流程终止。
    • 发布 OrderCreationFailedEvent
  3. 补偿逻辑
    • 定义一个 Policy(策略)或 Listener 监听失败事件。
    • 加载对应的 UserPurchaseHistory 聚合根。
    • 执行回滚逻辑:todayCount--, totalCount--

通过这种“正向执行 + 逆向补偿”的机制,我们保证了在异常情况下,用户的购买资格数据最终回归正确状态。


五、 总结

针对高并发下的“一人一单”与库存校验问题,我们并未采用传统的“上帝视角”大事务,而是采用了以下关键策略:

  1. 领域模型分离:创建 UserProduct 聚合根,专门承载复杂的限购规则,避免污染订单或用户模型。
  2. 多级库存校验
    • L1:本地缓存状态(拦截大部分流量)。
    • L2:RPC 精准扣减(防止超卖)。
  3. 读写分离与异步化
    • 同步阶段:仅做轻量级的资格锁定(持久化聚合根)和事件发布。
    • 异步阶段:处理重资源的库存扣减和订单入库。
  4. 最终一致性:利用领域事件串联流程,并通过补偿机制处理失败场景。

这种设计虽然增加了系统的复杂度(引入了异步和补偿),但极大地提升了系统的吞吐量和扩展性,是应对高并发电商场景的有效解法。