[DDD实战] 高并发下的“一人一单”与库存扣减

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

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

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


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

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

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

❌ 方案一:放在“订单(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
19
20
21
22
23
24
25
// 伪代码示例
public class UserPurchaseHistory extends AggregateRoot {
private String userId;
private String skuId;
private int todayCount;
private int totalCount;
private Date lastPurchaseTime;

// 领域行为:校验并预占额度
// 注意:这里只修改内存状态,后续由Application Service负责持久化
public void recordPurchase() {
if (todayCount >= 1) throw new DomainException("今日限购已达上限");
if (totalCount >= 5) throw new DomainException("活动限购已达上限");

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

// 领域行为:回滚额度(用于补偿)
public void rollbackPurchase() {
if (todayCount > 0) this.todayCount--;
if (totalCount > 0) this.totalCount--;
}
}

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


三、 架构设计:Redis预扣减与异步下单

解决了“能不能买”的逻辑模型,接下来是“怎么买”的性能问题。在高并发下,直接操作数据库扣减库存是最大的瓶颈。

我们采用了 Check-Lock-Act 模式结合 Redis Lua 脚本来实现高效吞吐。

核心流程设计

第一阶段:同步校验与预占资源(HTTP接口响应)

这一步的目标是抗住高并发,确保“进来的流量一定有库存,且用户有资格买”,并快速返回。

  1. 缓存前置校验(快速失败)

    • 订单域维护一份Redis库存缓存(Key: sku_stock:{skuId})。
    • 请求进来先 get(key),如果库存 <= 0,直接抛出“商品已售罄”异常,拦截绝大部分无效流量。
    • 缓存更新机制:监听库存服务发布的 StockChangedEvent,或者通过Canal同步库存DB变化到Redis。
  2. 用户维度加锁(防刷单)

    • 为了保证 UserPurchaseHistory 聚合根在并发场景下的数据一致性(防止同一个用户同时发10个请求绕过限购校验),需要加分布式锁。
    • Lock Key: lock:purchase:{userId}
    • 注意:锁的粒度是用户,不是全局锁,所以不影响不同用户的并发购买。(考虑到一个用户不会在同一个时间进行多个秒杀所以直接锁用户即可)
  3. 加载并校验用户资格

    • 在锁内,从数据库加载 UserPurchaseHistory 聚合根。
    • 执行 userPurchaseHistory.recordPurchase()。如果此时抛出异常(如限购),释放锁并返回错误。
  4. Redis Lua 原子扣减库存

    • 用户资格校验通过后,执行 Redis Lua 脚本 进行库存扣减。
    • Lua脚本逻辑:再次检查 Redis 库存是否足够,如果够则 decr,否则返回失败。
    • 为什么还要查Redis? 因为第1步的检查和第4步之间存在时间差,且Lua能保证扣减的原子性。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    -- simple_deduct.lua
    local key = KEYS[1]
    local qty = tonumber(ARGV[1])
    local current = tonumber(redis.call('get', key))
    if current and current >= qty then
    redis.call('decrby', key, qty)
    return 1 -- 成功
    else
    return 0 -- 库存不足
    end
  5. 持久化与发布事件

    • 如果Lua返回成功:
      • 保存更新后的 UserPurchaseHistory 到数据库(记录了用户买过)。
      • 发布 PreOrderSuccessEvent(预下单成功事件)
    • 事务性:保存聚合根和发布事件应在同一个本地事务中(或使用事务性发件箱模式)。

第二阶段:异步落库与真实扣减(MQ消费者)

订单服务作为消费者监听 PreOrderSuccessEvent,负责重资源的落地。

  1. 创建订单(Order Aggregate)

    • 根据事件携带的数据,构建并保存 Order 聚合根到数据库。
  2. 数据库库存扣减

    • 调用库存服务(或在订单服务内部)执行数据库层面的 update stock set num = num - 1 where sku_id = ?
    • 注意:由于第一阶段Redis已经保证了库存足够,这里的数据库扣减理论上一定成功(除非数据不一致)。
  3. 清理操作

    • 清空用户购物车对应商品。
    • 发送“下单成功”通知给用户。

四、 异常处理与Saga补偿

虽然Redis和DB通常能保持最终一致,但在极端情况下(如Redis扣了,但保存用户记录失败;或者第一阶段全成功,第二阶段DB挂了),系统会出现数据不一致。

我们需要设计补偿策略(Compensating Transaction)

场景 A:第一阶段中,Lua扣减成功,但保存 UserPurchaseHistory 失败

  • 现象:Redis库存少了,但用户没买成。
  • 处理:在 catch 块中,捕获数据库异常,立即异步执行 Redis库存回补(incr)。

场景 B:第二阶段中,订单创建或DB库存扣减失败

  • 现象:用户额度占了,Redis库存扣了,但没有生成订单。
  • 处理
    1. 消费端重试(Retry)。
    2. 如果重试达到上限仍失败,发布 OrderCreateFailedEvent
    3. 补偿监听器收到失败事件:
      • 调用 UserPurchaseHistory.rollbackPurchase() 并落库(恢复用户限购额度)。
      • 对 Redis 库存进行 incr 操作(恢复缓存库存)。
      • 通知用户下单失败。

五、 总结

通过本次重构,我们明确了DDD在高性能场景下的落地方式:

  1. 模型分离UserProduct 聚合根专门承载复杂的限购规则,与 Order 解耦。
  2. 分层风控
    • L1 缓存层:Redis Lua 脚本作为抗并发的“看门人”,精准拦截超卖流量。
    • L2 逻辑层:用户粒度锁 + 聚合根逻辑,精准拦截违规购买。
    • L3 数据层:异步落库,削峰填谷。
  3. 读写策略
    • :优先读缓存(Redis Stock)。
    • :先写缓存(Redis Decr),后异步写库(DB Insert/Update)。

这种“Redis预扣减 + 异步最终一致性”的方案,既保留了DDD的业务清晰度,又完美适配了互联网高并发的性能要求。