實戰來了,基於DDD實現庫存扣減~

張哥說技術發表於2023-11-03

來源:JAVA日知錄

大家好,讓我們繼續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<InventoryInventoryId{
    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的微服務商城系統。我們將在該系統中整合博主其他專欄文章的核心內容。如果你對這兩大技術棧感興趣,可以在本公眾號回覆關鍵詞 DDD 以獲取相關原始碼。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024923/viewspace-2992784/,如需轉載,請註明出處,否則將追究法律責任。

相關文章