情境

想像一下

  • 現在有個A產品線上購買功能
  • 後台有100筆該產品目前的庫存數量
  • 現在同時有25個http request同時湧入進行-1 扣庫存的動作

結束後請問你後台的產品庫存真的有正確-25數量嗎?
如果沒有,該如何調整?

資料表設計

實作功能(1)

  • 第一次我們先實作簡單直覺的範例
    minus()功能流程 :
    1. 找出product id 為 1 的產品資料
    2. 判斷product數量是否大於0
    3. 若大於0, 則進行-1
    4. 否則回傳庫存不足的error
    5. -1成功後, 回傳目前庫存數量

app/Http/Controllers/StockController.php

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
use App\Models\Stocks;

class StockController extends Controller
{
    protected $productId = 1;

    public function minus()
    {
        $product = Stocks::find($this->productId);
        $number = $product->number;

        if ($number > 0) {
            $number--;
            $product->number = $number;
            $product->save();
        } else {
            return response()->json(["error"=>"stock not enough !"], 400);
        }

        return response()->json(["stock" => $number], 200);
    }
}

routes/api.php

1
2
3
4
<?php

Route::get('/stock/minus', 'StockController@minus');

Postman測試

確認API是否正常-1

JMeter壓力測試

使用JMeter開啟20個Thread
進行1輪api密集發送

結果發現庫存資料只有-12
看起來扣庫存的動作是有問題的
正確來說,我們應該得到-20的結果

使用postman一筆一筆扣庫存時並無異狀
但當同一時間內湧入大量扣庫存的程序時
同一個資源被互搶的問題就產生了
舉例來說,當A和B同時執行api時
他們倆個都是取到相同庫存數量100
結果相互執行-1的動作後
雖說有可能能sql update時間不一樣
但2筆update都是99的情況就是不正確
應該是一筆99, 另一筆98才對

所以,當A拿到庫存資源時
此資源應該處於被鎖定狀態
當A完成update資料時才會釋放資源
然後讓B繼續存取
否則B必須等待A完成釋放資源
PS : 若A沒有在時間內完成也必須釋放,避免DeadLock(但本篇沒有實作)
那如何實作呢? 讓我們繼續看下去

實作功能(2) : Transaction

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php

public function minus()
{
    // 1.開始交易
    DB::beginTransaction();
    try {
        // 2.查詢時進行鎖表
        $product = Stocks::lockForUpdate()->find($this->productId);

        // 3.鎖表後進行參數相關處理
        $number =  $product->number;
        if ($number < 0) {
            throw new HttpException(403, "stock not enough !", null, [], 0);
        }
        $number--;

        // 4.更新資料(尚未提交)
        $product->number = $number;
        $product->save();   
    } catch (\Exception $e) {
        DB::rollback();
        throw new HttpException(500, $e->getMessage(), $e, [], 0);
    }

    // 5.提交結果
    DB::commit();
    
    return response()->json(
        [
            "stock" => $product->number
        ], 200
    );
}

有關laravel transaction介紹 : 參考

lockForUpdate() 方法等於SQL中的 SELECT ... FOR UPDATE

假設A process開始進行minus()時,此時product id 1的row會進行鎖表
資料尚未提交前,其他process無法進行 UPDATE, DELETE, SELECT ... FOR UPDATE

lockForUpdate()屬於排他鎖,延伸閱讀 : 深入理解SELECT … LOCK IN SHARE MODE和SELECT … FOR UPDATE

實作功能(3) : Redis

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php

public function minus()
{
    $pKey = "product" . $this->productId;

    //嘗試獲取鎖
    $lock = Redis::setnx("lock:$pKey", 1);

    //如果獲取不到鎖
    while ($lock == 0) {
        try {
            usleep(100000); // 100ms
            //嘗試再次獲取
            $lock = Redis::setnx("lock:$pKey", 1);
        } catch (\Exception $e) {
            throw new HttpException(500, "error get lock key", $e, [], 0);
        }
    }
    //如果獲取鎖, 則設置3秒過期時間, 以防鎖死
    Redis::expire("lock:$pKey", 3000);

    $number = Redis::get($pKey);
    
    if ($number > 0) {
        $number--;
        Redis::set($pKey, $number);
        Redis::del("lock:$pKey");

        return response()->json(
            [
                "stock" => $number
            ], 200
        );
    } else {
        Redis::del("lock:$pKey");
        return response()->json(
            [
                "error" => "stock not enough !"
            ], 400
        );
    }        
}

事先在Redis存入一筆product = 100 的key value值
然後再透過Redis setnx, ttl 的特性設計互斥鎖
但有個問題,這些值是存在Redis裡
之前有看到其他文章說,可以設計一個背景程式
專門定時同步Redis與資料庫的資料
透過這個方式來同步資料
但此方法我還在評估,不確定是否適合用在業界系統功能上

補充

  • mysql transaction default timeout is 50 sec => my suggestion is between 3 ~ 5 sec

    1
    2
    3
    4
    5
    6
    
    mysql> show variables like 'innodb_lock_wait_timeout';
    +--------------------------+-------+
    | Variable_name            | Value |
    +--------------------------+-------+
    | innodb_lock_wait_timeout | 50    |
    +--------------------------+-------+
    

結論

透過mysql transaction方法是可以解決高併發問題
在多個http請求下,透過互斥鎖來確保每個process須等前一個process處理完才能進行
取值or更新值的動作才會是正確的

另外,Redis扣庫存方式也是一種方法
但關於與資料庫如何同步、是不適合用在業界系統上
這個就真的還待評估中

不過本文目前也只有考慮一個DB和一個Redis的情況下
如果後面進化成叢集分散架構,就會衍生更複雜的分散式互斥鎖問題
這就等以後實力長進一點後再來研究吧

Reference