DDD领域驱动设计内容分享(二十八):基于DDD实现库存扣减

目录

1. 库存模型

1.1 核心概念

1.2 领域模型

2. 库存扣减

2.1 库存扣减的三种模式

2.2 预扣模式核心链路

2.2.1 正向流程

2.2.2 逆向操作

2.2 库存扣减的执行流程

3. 核心代码实现

3.1 接口层

3.2 应用层

3.3 领域层

3.4 基础设施层

小结


1. 库存模型

1.1 核心概念

库存是一个非常复杂的概念,涉及在仓库存,计划库存,渠道库存等多个领域实体,在我们《DailyMart微服务&DDD》实战中,主要关注的是在仓库存模型。

在这个模型中有三个重要的概念:可售库存、预售库存、占用库存,他们的定义如下:

可售库存数(Sellable Quantity,SQ)可售库存即用户在客户端所见的商品可销售数量。当SQ为0时,用户不能下单。

预扣库存数(Withholding Quantity,WQ)预扣库存是指被未付款的订单占用的库存数量。这种库存的存在是因为用户在下单后可能不会立刻付款。预扣库存的作用是为用户保留库存,直到用户完成付款,才会从中扣减相应数量的库存。如果用户未能在规定时间内付款,预扣库存WQ将被释放回可售库存SQ上。

占用库存数(Occupy Quantity,OQ)占用库存是指已经完成付款,但尚未发货的订单所占用的库存数量。这种库存与仓库相关,并且牵涉到履约流程。一旦订单发货,占用库存会相应减少。

根据上述定义,对于一个商品,可售库存数量与预扣库存数量之间的关系是:可售库存(SQ) + 预扣库存(WQ) = 可用库存。

由于每种商品通常包含多个不同的 SKU,在商品交易链路中,无法通过商品id来精确定位库存。为了更高效地管理库存查询和更新请求,我们需要设计一个具有唯一标识能力的 ID,即库存 ID(inventory_id)。此外,在库存扣减操作中还需要存储库存扣减记录,一旦用户取消订单或退货时,可以根据扣减记录返还相应的库存数量。

1.2 领域模型

通过对库存领域概念的分析,我们很容易完成DDD领域建模,如下图所示:

库存 (Inventory): 库存对象充当库存领域的聚合根,负责管理和跟踪商品的可售库存、预扣库存和占用库存等信息。库存对象也具备唯一标识能力,使用库存 ID(inventory_id)来标识不同库存。

库存记录 (InventoryRecord): 库存记录是一个实体,用于记录库存的各种操作,例如扣减、占用、释放、退货等。每个库存记录都有一个唯一的记录 ID(record_id)来标识。

库存 ID(InventoryId)和记录 ID(RecordId): 这两者都是值对象,它们负责提供唯一标识,分别用于标识库存和库存记录。

库存扣减状态(InventoryRecordStateEnum):这也是个值对象,用于标识扣减库存的状态。

2. 库存扣减

库存扣减看似简单,只需在用户支付后减少库存即可,但实际情况要复杂得多。不同的扣减顺序可能导致不同的问题。比如我们先减库存后付款,可能会出现用户下单后放弃支付,导致商品少买或未售出。另一方面,如果我们先付款后减库存,可能出现用户成功支付但商家没有足够的库存来满足订单,这又非常影响用户体验。

一般来说,库存扣减有三种主要模式:

2.1 库存扣减的三种模式

  • 拍减模式:在用户下单时,直接扣减可售库存(SQ)。这种模式不会出现超卖问题,但它的防御能力相对较弱。如果用户大量下单而不付款,库存会一直被占用,从而影响正常交易,导致商家少卖。

  • 预扣模式:在用户下单时,会预先扣减库存,如果订单在规定时间内未完成支付,系统将释放库存。具体来说,当用户下单时,会预扣库存(SQ-、WQ+),此时库存处于预扣状态;一旦用户完成付款,系统会减少预扣库存(WQ-、OQ+),此时库存进入扣减状态

  • 付减模式:在用户完成付款时,直接扣减可售库存(SQ)。这种模式存在超卖风险,因为无法确保用户付款后一定有足够的库存。

对于实物商品,库存扣减主要采用拍减模式预扣模式,付减模式应用较少,在我们DailyMart系统中采用的正是预扣模式。

2.2 预扣模式核心链路

接下来我们重点介绍库存预扣模式的核心链路,包括正向流程和逆向操作。

2.2.1 正向流程

正向流程涉及用户下单、付款和发货的关键步骤。以下是正向流程的具体步骤:

1)用户将商品加入购物车,点击结算后进入订单确认页,点击提交订单后,订单中心服务端发起交易逻辑。

2)调用库存服务执行库存预扣逻辑

3)调用支付服务发起支付请求

4)用户付款完成以后,调用库存平台扣减库存

5)订单服务发送消息给仓储中心,仓储中心收到消息后创建订单,并准备配货发货

6)仓储中心发货以后调用库存平台扣减占用库存数。

 

2.2.2 逆向操作

逆向操作包括取消订单或退货等情况,我们需要考虑如何回补库存。逆向操作的步骤如下:

1)用户取消订单或退货。2)更新扣减记录行,状态为释放状态。3)同时更新库存行,以回补库存。

2.2 库存扣减的执行流程

每一件商品的库存扣减都至少涉及两次数据库写操作:更新库存表(inventory_item)和扣减记录表(inventory_record)。

为了确保库存扣减操作的幂等性,通常需要在扣减记录表中给业务流水号字段创建唯一索引。此外,为了保证数据一致性,修改库存数量与操作流水记录的两个步骤必须在同一个事务中。

关于系统的幂等性实现方案,我在知识星球进行了详细介绍,感兴趣的可以通过文末链接加入。

在数据库层面,库存扣减操作包括以下关键步骤:

  • 用户下单时:insert 扣减记录行,状态为预扣中,同时 update 库存行(减少可销售库存,增加预扣库存,sq-,wq+);

  • 用户付款时:update 扣减记录行,状态为扣减状态,同时update库存行(减少预扣库存,增加占用库存,wq-,oq+);

  • 仓库发货时:update 扣减记录行,状态为发货状态,同时update库存行(减少占用库存数,oq-);

  • 逆向操作时:update 扣减记录行,状态为释放状态,同时update库存行(增加可销售库存,sq+);

通过下图可以清晰看到库存扣减时相关相关数据状态的变化。

 

3. 核心代码实现

接下来,让我们从接口层、应用层、领域层和基础设施层的角度来分析库存扣减的代码实现。(考虑到篇幅原因,省略了部分代码。)

3.1 接口层

接口层是库存操作的入口,定义了库存操作的接口,如下所示:

@RestController
@Tag(name = "InventoryController", description = "库存API接口")
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
@Slf4j
public class InventoryController {
 ...
 
    @Operation(summary = "库存预扣",description = "sq-,wq+,创建订单时调用")
    @PostMapping("/api/inventory/withholdInventory")
    public void withholdInventory(@Valid @RequestBody InventoryLockRequest lockRequest)  {
        inventoryService.withholdInventory(lockRequest);
    }

    @Operation(summary = "库存扣减",description = "wq-,oq+,付款时调用")
    @PutMapping("/api/inventory/deductionInventory")
    public void deductionInventory(@RequestParam("transactionId") Long transactionId)  {
        inventoryService.deductionInventory(transactionId);
    }

    @Operation(summary = "库存发货",description = "oq-,发货时调用")
    @PutMapping("/api/inventory/shipInventory")
    public void shipInventory(@RequestParam("transactionId") Long transactionId)  {
        inventoryService.shipInventory(transactionId);
    }

    @Operation(summary = "释放库存")
    @PutMapping("/api/inventory/releaseInventory")
    public void releaseInventory(@RequestParam("transactionId") Long transactionId)  {
        inventoryService.releaseInventory(transactionId);
    }
    ...
}

3.2 应用层

应用层负责协调领域服务和基础设施层,完成库存扣减的业务逻辑。库存服务不涉及跨聚合操作,因此只需调用基础设施层的能力,并让领域层完成一些直接的业务逻辑。

@Service
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
@Slf4j
public class InventoryServiceImpl implements InventoryService {    
    ...
    @Override
    @Transactional
    public void withholdInventory(InventoryLockRequest inventoryLockRequest) {
        Long inventoryId = inventoryLockRequest.getInventoryId();
        //1. 获取库存
        Inventory inventory = Optional.ofNullable(inventoryRepository.find(new InventoryId(inventoryId)))
                .orElseThrow(()->new BusinessException("No inventory found with id:" + inventoryId));

        // 2. 幂等校验
        boolean exists = inventoryRepository.existsWithTransactionId(inventoryLockRequest.getTransactionId());

        if(exists ){
            log.error("Inventory record with transaction ID {} already exists, no deduction will be made.", inventoryLockRequest.getTransactionId());
            return;
        }

        //3. 库存预扣
        inventory.withholdInventory(inventoryLockRequest.getQuantity());

        //4. 生成扣减记录
        InventoryRecord inventoryRecord = InventoryRecord.builder()
                .inventoryId(inventoryId)
                .userId(inventoryLockRequest.getUserId())
                .deductionQuantity(inventoryLockRequest.getQuantity())
                .transactionId(inventoryLockRequest.getTransactionId())
                .state(InventoryRecordStateEnum.PRE_DEDUCTION.code())
                .build();

        inventory.addInventoryRecord(inventoryRecord);

        inventoryRepository.save(inventory);
    }
    ...
}

3.3 领域层

领域层负责处理直接涉及业务规则和逻辑的操作,将库存预扣、扣减、库存释放等操作封装在聚合对象 Inventory 中。同时,领域层定义了仓储接口,如下所示:

@Data
public class Inventory implements Aggregate<InventoryId> {
    @Serial
    private static final long serialVersionUID = 2139884371907883203L;
    private InventoryId id;
 
 ...

    /**
     * 库存预扣 sq-,wq+
     * @param quantity  数量
     */
    public void withholdInventory(int quantity){
        if (quantity <= 0) {
            throw new BusinessException("扣减库存数量必须大于零");
        }

        if (getInventoryQuantity() - quantity < 0) {
            throw new BusinessException("库存不足,无法扣减库存");
        }

        sellableQuantity -= quantity;
        withholdingQuantity += quantity;
    }
  
    /**
     * 释放库存
     * @param currentState 当前状态
     * @param quantity 数量
     */
    public void releaseInventory(int currentState, Integer quantity) {
        InventoryRecordStateEnum stateEnum = InventoryRecordStateEnum.of(currentState);
        switch (stateEnum){
            //sq+,wq-
            case PRE_DEDUCTION -> {
                sellableQuantity += quantity;
                withholdingQuantity -= quantity;
            }
            //sq+,oq-
            case DEDUCTION -> {
                sellableQuantity += quantity;
                occupyQuantity -= quantity;
            }
            //sq+
            case SHIPPED -> {
                sellableQuantity += quantity;
            }
        }
    }
 ...
}

/**
* 仓储接口定义
*/
public interface InventoryRepository extends Repository<Inventory, InventoryId> {
    boolean existsWithTransactionId(Long transactionId);

    Inventory findByTransactionId(Long transactionId);
}

3.4 基础设施层

基础设施层负责数据库操作,持久化库存状态,如下所示:

@Repository
@Slf4j
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class InventoryRepositoryImpl implements InventoryRepository {
    ...
    @Override
    public Inventory find(InventoryId inventoryId) {

        InventoryItemDO inventoryItemDO = inventoryItemMapper.selectById(inventoryId.getValue());
        return itemInventoryConverter.fromData(inventoryItemDO);
    }

    @Override
    public Inventory save(Inventory aggregate) {
        InventoryItemDO inventoryItemDO = itemInventoryConverter.toData(aggregate);

        if(inventoryItemDO.getId() == null){
            inventoryItemMapper.insert(inventoryItemDO);
        }else{
            inventoryItemMapper.updateById(inventoryItemDO);
        }

        InventoryRecord inventoryRecord = aggregate.getInventoryRecordList().get(0);
        InventoryRecordDO inventoryRecordDO = inventoryRecordConverter.toData(inventoryRecord);

        if(inventoryRecordDO.getId() == null){
            inventoryRecordMapper.insert(inventoryRecordDO);
        }else{
            inventoryRecordMapper.updateById(inventoryRecordDO);
        }

        return aggregate;
    }
    ...
}

小结

本文详细介绍了库存领域的关键概念以及库存扣减的三种模式,同时基于DDD的分层模型,成功实现了预扣模式的业务逻辑。在库存的预扣和库存释放接口中,通过业务流水表和状态机的方式确保了接口的幂等性。然而,值得注意的是,本文所展示的方案采用了纯数据库实现,可能在高并发情况下性能略有下降,当然这也是我们后面需要优化的点。

  • 25
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

之乎者也·

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值