实战来了,基于DDD实现库存扣减~
大家好,让我们继续DDD&微服务系列,今天,我们看看在DailyMart项目中如何基于DDD实现库存扣减功能。
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 {
@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 {
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的分层模型,成功实现了预扣模式的业务逻辑。在库存的预扣接口中,通过业务流水表确保了接口的幂等性,不过更新库存的接口暂时还没实现幂等,幂等会在下篇文章中统一解决。同时,值得注意的是,本文所展示的方案采用了纯数据库实现,可能在高并发情况下性能略有下降,当然这也是我们后面需要优化的点。
来源:juejin.cn/post/7299037876636696615