likes
comments
collection
share

实战来了,基于DDD实现库存扣减~

作者站长头像
站长
· 阅读数 6

大家好,让我们继续DDD&微服务系列,今天,我们看看在DailyMart项目中如何基于DDD实现库存扣减功能。

1. 库存模型

1.1 核心概念

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

实战来了,基于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领域建模,如下图所示:

实战来了,基于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)仓储中心发货以后调用库存平台扣减占用库存数。

实战来了,基于DDD实现库存扣减~

2.2.2 逆向操作

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

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

2.2 库存扣减的执行流程

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

实战来了,基于DDD实现库存扣减~

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

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

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

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

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

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

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

通过下图可以清晰看到库存扣减时相关相关数据状态的变化。 实战来了,基于DDD实现库存扣减~

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的分层模型,成功实现了预扣模式的业务逻辑。在库存的预扣接口中,通过业务流水表确保了接口的幂等性,不过更新库存的接口暂时还没实现幂等,幂等会在下篇文章中统一解决。同时,值得注意的是,本文所展示的方案采用了纯数据库实现,可能在高并发情况下性能略有下降,当然这也是我们后面需要优化的点。

DailyMart是一个基于领域驱动设计(DDD)和Spring Cloud Alibaba的微服务商城系统。我们将在该系统中整合博主其他专栏文章的核心内容。如果你对这两大技术栈感兴趣,可以关注公众号JAVA日知录并回复关键词 DDD 以获取相关源码。

转载自:https://juejin.cn/post/7299037876636696615
评论
请登录