實戰來了,基於DDD實現庫存扣減~
來源:JAVA日知錄
大家好,讓我們繼續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<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的微服務商城系統。我們將在該系統中整合博主其他專欄文章的核心內容。如果你對這兩大技術棧感興趣,可以在本公眾號回覆關鍵詞 DDD 以獲取相關原始碼。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024923/viewspace-2992784/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- Go 庫存扣減的幾種實現方法Go
- Redis使用lua指令碼實現庫存扣減Redis指令碼
- 關於訂單庫存扣減的最佳實踐
- Redis 如何實現庫存扣減操作和防止被超賣?Redis
- 搶購 庫存扣減
- 筆記:如何使用postgresql做順序扣減庫存筆記SQL
- Knative 實戰:基於 Kafka 實現訊息推送Kafka
- DDD實戰課(實戰篇)--學習筆記筆記
- 秒殺系統中的扣減庫存和流量削峰
- 使用業務能力方法實現DDD戰略建模 - pulse
- 高併發業務下的庫存扣減技術方案設計
- DDD實戰分享-訊息中心
- 微信小程式實戰,基於vue2實現瀑布流微信小程式Vue
- 實戰與原理:如何基於RocketMQ實現分散式事務?MQ分散式
- 第六節:庫存扣減避免超賣少買、排行版實現分數相同按時間排序、如何查詢附近的人排序
- 基於DRBD實現資料庫高可用資料庫
- NOSQL儲存的基於事件的事務實現SQL事件
- 電商庫存系統的防超賣和高併發扣減方案
- 微信雲託管 WebSocket 實戰:基於模版實現訊息推送Web
- Java實現DDD中UnitOfWorkJava
- 使用Akka實現Reactive DDDReact
- 基於Sklearn機器學習程式碼實戰機器學習
- 用Scala和Akka實現DDD
- 實踐ddd,太讓人沮喪了。。
- 200 行程式碼實現基於 Paxos 的 KV 儲存行程
- 實現基於zoom平臺上的oss額外儲存OOM
- 【Python 實戰基礎】如何繪製餅狀圖分析商品庫存Python
- Qt基於SDL庫簡單實現YUV影片播放QT
- 併發減庫存
- 你們要的MyCat實現MySQL分庫分表來了MySql
- DDD實戰課-歐創新-極客時間-返現24元
- 基於sklearn的分類器實戰
- Knative 實戰:基於 Knative Serverless 技術實現天氣服務-下篇Server
- Go實戰 | 基於本地記憶體的快取的應用及實現Go記憶體快取
- 7.實現加減
- 解決庫存扣減及訂單建立時防止併發死鎖的問題
- 分散式鎖與實現(一)基於Redis實現!分散式Redis
- 福利來了,現“免費”贈送Spring微服務實戰書籍Spring微服務