高併發下的超賣和一人一單

善良的死神發表於2023-06-29

工具

程式碼框架: Hyperf

資料庫:mysql

快取:redis

壓測工具:JMeter

SQL

ku_goods:秒殺商品表

CREATE TABLE `ku_goods` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL COMMENT '商品標題',
  `num` int(11) NOT NULL DEFAULT '0' COMMENT '數量',
  `begin_time` datetime NOT NULL COMMENT '優惠開始時間',
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;

#建立一個秒殺商品,初始數量為100
INSERT INTO `kuke_user1`.`ku_goods` (`id`, `title`, `num`, `begin_time`, `created_at`, `updated_at`) VALUES (1, '秒殺商品', 100, '2023-06-27 14:17:21', '2023-06-27 14:17:28', '2023-06-28 16:59:32');

ku_order:訂單資訊

CREATE TABLE `ku_order` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL DEFAULT '0',
  `goods_id` int(11) NOT NULL DEFAULT '0',
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4082 DEFAULT CHARSET=utf8mb4;

超賣問題

正常邏輯

使用者搶購一個商品

/**
 * @param Request $request
 * @return bool
 */
public function test(Request $request): bool
{
    $userId = $request->input("user_id");
    //1. 獲取商品資訊
    $goods = Goods::find(1);
    if (!$goods) {
        return false;
    }
    //2. 判斷是否有庫存
    if ($goods->num < 1) {
        return false;
    }
    //3. 減庫存
    $flag = Db::update("update ku_goods set num = num - 1 where id = {$goods->id}");
    if (!$flag) {
        return false;
    }
    //4. 新增訂單資訊
    $order = new Order();
    $order->user_id = $userId;
    $order->goods_id = $goods->id;
    $order->save();
    return true;
}

按照正常的邏輯,判斷商品庫存,減少庫存,新增訂單資訊

壓測

開啟 1000 個執行緒同時請求這個介面

執行完畢,透過 SQL 檢視結果

select count(1) as "生成訂單總數" from ku_order;
select num as "商品剩餘數量" from ku_goods where id = 1;

可以看到,商品出現超賣,剩餘庫存為-10,訂單生成了 110

出現此問題的原因就在 判斷是否有庫存 和 減庫存之間,多個執行緒過來獲取到還有剩餘庫存,然後更新庫存,但是在更新庫存之前,其它執行緒已經搶完了,造成超賣

此問題可以使用悲觀鎖和樂觀鎖來解決,但是悲觀鎖會把整個業務邏輯進行加鎖,變相為序列執行了,所以考慮使用樂觀鎖

樂觀鎖解決超賣問題

樂觀鎖的概念不在此闡述了,百度一下,此場景下 庫存 就可以看做是 version,更新庫存的時候,判斷下更新的時候的庫存是否和查詢時候的庫存一致

    /**
     * 樂觀鎖實現
     * @param Request $request
     * @return bool
     */
    public function test(Request $request): bool
    {
        $userId = $request->input("user_id");
        //1. 獲取商品資訊
        $goods = Goods::find(1);
        if (!$goods) {
            return false;
        }
        //2. 判斷是否有庫存
        if ($goods->num < 1) {
            return false;
        }
        //3. 減庫存
        $flag = Db::update("update ku_goods set num = num - 1 where id = {$goods->id} and num = {$goods->num}");
        if (!$flag) {
            return false;
        }
        //4. 新增訂單資訊
        $order = new Order();
        $order->user_id = $userId;
        $order->goods_id = $goods->id;
        $order->save();
        return true;
    }

此時使用壓測後,結果

可以看到,商品未出現超賣,但是有另外的問題,100 個商品竟然都沒有強光

此時的原因是 獲取商品和更新庫存之間,其它執行緒已經更改了庫存,導致此執行緒更新失敗

此時可以稍微變通一下,就可以解決,更新庫存時只要 num > 0,都可以更新成功,並建立訂單

    /**
     * 樂觀鎖實現
     * @param Request $request
     * @return bool
     */
    public function test(Request $request): bool
    {
        $userId = $request->input("user_id");
        //1. 獲取商品資訊
        $goods = Goods::find(1);
        if (!$goods) {
            return false;
        }
        //2. 判斷是否有庫存
        if ($goods->num < 1) {
            return false;
        }
        //3. 更新庫存,更改 num > 0
        $flag = Db::update("update ku_goods set num = num - 1 where id = {$goods->id} and num > 0");
        if (!$flag) {
            return false;
        }
        //4. 建立訂單
        $order = new Order();
        $order->user_id = $userId;
        $order->goods_id = $goods->id;
        $order->save();
        return true;
    }

從壓測結果可以看到,未出現超賣,並且正常搶購完畢

一人一單問題

搶購商品,每個使用者限購一單

正常邏輯

/**
 * 一人一單
 * @param Request $request
 * @return bool
 */
public function test1(Request $request): bool
{
    $userId = $request->input("user_id");
    //1. 獲取商品資訊
    $goods = Goods::find(1);
    if (!$goods) {
        return false;
    }
    //2. 判斷庫存
    if ($goods->num < 1) {
        return false;
    }
    //3. 判斷當天使用者是否購買
    $isBuy = Db::select("select id from ku_order where user_id = {$userId} and goods_id = 1 limit 1");
    if ($isBuy) {
        return false;
    }
    //4. 減少庫存
    $flag = Db::update("update ku_goods set num = num - 1 where id = {$goods->id} and num > 0");
    if (!$flag) {
        return false;
    }
    //5. 新增訂單資訊
    $order = new Order();
    $order->user_id = $userId;
    $order->goods_id = $goods->id;
    $order->save();

    return true;
}

在樂觀鎖的程式碼基礎之上,新增了使用者是否購買的判斷,來限定一個使用者只能買一個商品

壓測結果

可以看到,當一個使用者併發請求的時候,會出現一人一單的超賣問題

原因就在於 判斷使用者是否購買,和 最後新增訂單之間有間隔,併發過來時,這個使用者的訂單資訊還沒來得及建立,此時是可以正常減庫存和建立訂單的

Redis setnx 解決

Redis 的 setnx

SETNX key value

將 key 的值設為 value ,當且僅當 key 不存在。

若給定的 key 已經存在,則 SETNX 不做任何動作。

獲取鎖

設定有效期的目的在於程式意外終止,鎖得不到釋放,設定有效期保證一定會在未來某個時間失效

使用 lua 指令碼來保證 setnx 和 expire 操作是原子性操作

釋放鎖

  1. 判斷鎖是否還存在

  2. 刪除鎖

程式碼實現

ILock 介面

<?php

declare(strict_types=1);

namespace App\Tool\Redis;

interface ILock
{
    /**
     * 獲取鎖
     * @param int $waitTime 當獲取鎖失敗後,在次時間段內嘗試重新獲取鎖
     * @param int $expireTime 獲取鎖成功後,鎖的有效期
     * @return bool
     */
    public function tryLock(int $waitTime, int $expireTime):bool;

    /**
     * 釋放鎖
     * @return bool
     */
    public function unlock():bool;
}

RedisLock 實現

<?php

declare(strict_types=1);

namespace App\Tool\Redis;

use Hyperf\Redis\Exception\InvalidRedisProxyException;
use Hyperf\Redis\Redis;
use Hyperf\Redis\RedisFactory;
use Hyperf\Utils\ApplicationContext;

class RedisLock implements ILock
{
    private string $name;
    private string $value;
    private Redis $redisClient;

    public function __construct($name, ?Redis $redisClient = null)
    {
        $this->name = $name;
        $this->value = "1";
        $this->redisClient = $this->initRedisClient($redisClient);
    }

    private function initRedisClient(?Redis $redisClient = null): Redis
    {
        if (!$redisClient) {
            try {
                $redisClient = ApplicationContext::getContainer()->get(RedisFactory::class)->get("default");
            } catch (\Throwable $e) {
                //初始化redis客戶端失敗
                throw new InvalidRedisProxyException("初始化redis客戶端失敗");
            }
        }
        return $redisClient;
    }

    /**
     * 獲取鎖
     * @param int $waitTime
     * @param int $expireTime
     * @return bool
     */
    public function tryLock(int $waitTime, int $expireTime): bool
    {
        //秒轉換為毫秒
        $waitTime = $waitTime * 1000;
        $expireTime = $expireTime * 1000;
        //獲取超時時間
        $currentTime = $this->getMillisecond() + $waitTime;
        //編寫 lua 指令碼
        $script =<<<SCRIPT
local lockKey = KEYS[1]
local lockValue = ARGV[1]
local lockExpireTime = ARGV[2] 
if redis.call('SETNX', lockKey, lockValue) == 1 then
    redis.call('PEXPIRE', lockKey, lockExpireTime)
    return 1
else
    return 0
end
SCRIPT;
        do {
            $result = $this->redisClient->eval($script, array($this->name, $this->value, $expireTime), 1);
            if ($result == 1) {
                return true;
            }
        } while ($this->getMillisecond() < $currentTime);
        return false;
    }

    public function unlock(): bool
    {
        //編寫 lua 指令碼
        $script =<<<SCRIPT
local lockKey = KEYS[1]
local lockValue = ARGV[1]
if redis.call('get', lockKey) == lockValue then
    if redis.call('del', lockKey) == 1 then
        return 1
    end
end
return 0
SCRIPT;
        return (boolean)$this->redisClient->eval($script, array($this->name, $this->value), 1);
    }

    /**
     * 獲取當前毫秒
     * @return float|int
     */
    private function getMillisecond()
    {
        return microtime(true) * 1000;
    }
}
  1. 建立物件的時候,傳入 redis 的鍵,並對 redis 訪問客戶端進行初始化

  2. 獲取鎖的時候使用 do while 結構進行重試獲取鎖

  3. 釋放鎖的時候直接刪除

業務程式碼

/**
 * 一人一單
 * @param Request $request
 * @return bool
 */
public function test(Request $request): bool
{
    $userId = $request->input("user_id");
    $goods = Goods::find(1);
    if (!$goods) {
        return false;
    }
    if ($goods->num < 1) {
        return false;
    }
    //使用使用者id來當做鍵,保證同個使用者併發過來只有一把鎖
    $redisLock = new RedisLock($userId);
    //設定等待時間為0s,就是獲取一次鎖操作,10s的有效期
    if ($redisLock->tryLock(0, 10)) {
        try {
            $isBuy = Db::select("select id from ku_order where user_id = {$userId} and goods_id = 1 limit 1");
            if ($isBuy) {
                return false;
            }
            $flag = Db::update("update ku_goods set num = num - 1 where id = {$goods->id} and num > 0");
            if (!$flag) {
                return false;
            }
            $order = new Order();
            $order->user_id = $userId;
            $order->goods_id = $goods->id;
            $order->save();
            return true;
        } catch (\Throwable $e) {

        } finally {
            //釋放鎖
            $redisLock->unlock();
        }
    }
    return false;
}

壓測結果

可以看到完美解決一人一單問題

但是這種設計還是有其他問題的

  1. 釋放鎖問題: 當執行緒 1 業務邏輯出現阻塞時,沒有在 10s 內解決,這時候鎖超時,自動釋放,此時執行緒 2 可以正常獲取鎖的,執行緒 2 獲取鎖之後,執行緒 1 正常業務執行完畢,釋放鎖,這時候執行緒 1 釋放的就是 執行緒 2 的鎖

  2. 可重入問題:執行緒 1 獲取鎖之後執行業務,在業務中有其他方法也要獲取鎖處理邏輯,但是此時獲取鎖是失敗的,就會等待執行緒 1 釋放,但是執行緒 1 在等待業務執行結束,造成死鎖

public function a(Request $request): bool
{
    $userId = $request->input("user_id");
    //使用使用者id來當做鍵,保證同個使用者併發過來只有一把鎖
    $redisLock = new RedisLock($userId);
    if ($redisLock->tryLock(0, 10)) {
        try {
            //處理業務
            $this->b($userId);
        } catch (\Throwable $e) {

        } finally {
            $redisLock->unlock();
        }
    }
    return false;
}

private function b($userId)
{
    //使用使用者id來當做鍵,保證同個使用者併發過來只有一把鎖
    $redisLock = new RedisLock($userId);
    if ($redisLock->tryLock(60, 10)) {
        try {
            //處理業務

        } catch (\Throwable $e) {

        } finally {
            $redisLock->unlock();
        }
    }
}

Redis Hash 解決

針對第一個問題可以使用在儲存的時候把當前執行緒 id 儲存到 redis 的 value 裡面,當刪除的時候,判斷是否是當前執行緒,是的話就刪除。

針對第二個問題,借鑑 Java 裡面的 Redisson 元件的實現方案,透過 redis 的 hash 結構儲存,只要在一個執行緒內獲取鎖,就對 value+1,釋放鎖 value-1,當釋放到最外層,value = 0,此時刪除鎖

獲取鎖

local lockKey = KEYS[1]
local lockField = ARGV[1]
local lockExpireTime = ARGV[2]
if redis.call('exists', lockKey) == 0 then
    -- redis 裡面不存在,設定 hash 資訊
    redis.call('hincrby', lockKey, lockField, 1)
    redis.call('pexpire', lockKey, lockExpireTime)
    return 1
end
-- 判斷當前 key 裡面的 field 是否是當前協程的
if redis.call('hexists', lockKey, lockField) == 1 then
    -- 是當前協程的,value + 1
    redis.call('hincrby', lockKey, lockField, 1)
    redis.call('pexpire', lockKey, lockExpireTime)
    return 1
end
-- 存在 key,但是 field 不是當前協程,其它協程已經鎖了,獲取鎖失敗 
return 0
  1. 判斷 key 是否存在,不存在設定 key,field,value,key 就是使用者 id,field 就是執行緒 id,value 第一次是 1,此後當前執行緒獲取一次+1,同時設定過期時間

  2. 存在 key,判斷是否是當前執行緒,不是獲取鎖失敗,是的話 value+1

釋放鎖

local lockKey = KEYS[1]
local lockField = ARGV[1]
if redis.call('hexists', lockKey, lockField) == 0 then
    return 0
end
local counter = redis.call('hincrby', lockKey, lockField, -1)
if counter > 0 then
    return 1
end
redis.call('del', lockKey)
return 1
  1. 判斷當前 key,執行緒 id 存在不存在

  2. 存在 value-1,當 value 大於 0 的時候不用管,小於等於 0,刪除鎖

程式碼實現

<?php

declare(strict_types=1);

namespace App\Tool\Redis;

use Hyperf\Redis\Exception\InvalidRedisProxyException;
use Hyperf\Redis\Redis;
use Hyperf\Redis\RedisFactory;
use Hyperf\Utils\ApplicationContext;

/**
 * 可重入鎖
 */
class RedisReentrantLock implements ILock
{
    private string $name;
    private string $value;
    private Redis $redisClient;

    public function __construct($name, $requestId, ?Redis $redisClient = null)
    {
        $this->name = $name;
        $this->value = $requestId;
        $this->redisClient = $this->initRedisClient($redisClient);
    }

    private function initRedisClient(?Redis $redisClient = null): Redis
    {
        if (!$redisClient) {
            try {
                $redisClient = ApplicationContext::getContainer()->get(RedisFactory::class)->get("default");
            } catch (\Throwable $e) {
                //初始化redis客戶端失敗
                throw new InvalidRedisProxyException("初始化redis客戶端失敗");
            }
        }
        return $redisClient;
    }

    /**
     * 獲取鎖
     * @param int $waitTime
     * @param int $expireTime
     * @return bool
     */
    public function tryLock(int $waitTime, int $expireTime): bool
    {
        //秒轉換為毫秒
        $waitTime = $waitTime * 1000;
        $expireTime = $expireTime * 1000;

        //獲取超時時間
        $currentTime = $this->getMillisecond() + $waitTime;
        //編寫 lua 指令碼
        $script =<<<SCRIPT
local lockKey = KEYS[1]
local lockField = ARGV[1]
local lockExpireTime = ARGV[2]
if redis.call('exists', lockKey) == 0 then
    -- redis 裡面不存在,設定 hash 資訊
    redis.call('hincrby', lockKey, lockField, 1)
    redis.call('pexpire', lockKey, lockExpireTime)
    return 1
end
-- 判斷當前 key 裡面的 field 是否是當前協程的
if redis.call('hexists', lockKey, lockField) == 1 then
    -- 是當前協程的,value + 1
    redis.call('hincrby', lockKey, lockField, 1)
    redis.call('pexpire', lockKey, lockExpireTime)
    return 1
end
-- 存在 key,但是 field 不是當前協程,其它協程已經鎖了,獲取鎖失敗 
return 0
SCRIPT;
        do {
            $result = $this->redisClient->eval($script, array($this->name, $this->value, $expireTime), 1);
            if ($result == 1) {
                return true;
            }
        } while ($this->getMillisecond() < $currentTime);
        return false;
    }

    public function unlock(): bool
    {
        //編寫 lua 指令碼
        $script =<<<SCRIPT
local lockKey = KEYS[1]
local lockField = ARGV[1]
if redis.call('hexists', lockKey, lockField) == 0 then
    return 0
end
local counter = redis.call('hincrby', lockKey, lockField, -1)
if counter > 0 then
    return 1
end
redis.call('del', lockKey)
return 1
SCRIPT;
        return (boolean)$this->redisClient->eval($script, array($this->name, $this->value), 1);
    }

    /**
     * 獲取當前毫秒
     * @return float|int
     */
    private function getMillisecond()
    {
        return intval(microtime(true) * 1000);
    }
}

在 Hyperf 中每次請求是建立協程處理的,本來考慮使用協程 id,但是測試後有問題,改為中介軟體來對每次請求賦值

<?php

declare(strict_types=1);

namespace App\Middleware;

use Hyperf\Utils\Context;
use Hyperf\Utils\Coroutine;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class ApiMiddleware implements MiddlewareInterface
{

    /**
     * @var ContainerInterface
     */
    protected $container;

    public function __construct(ContainerInterface $container)
    {
        $this->container = $container;
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        // 生成唯一標識
        $requestId = $this->generateRequestId();
        // 將唯一標識儲存到請求頭中
        $request = $request->withAddedHeader('request-id', $requestId);
        Context::set(ServerRequestInterface::class, $request);
        return $handler->handle($request);
    }

    private function generateRequestId(): string
    {
        $current = microtime(true) * 10000;
        $coroutineId = Coroutine::id();
        //簡單生成
        return $coroutineId . $current . mt_rand(999999999, 99999999999);
    }
}
/**
 * 可重入性
 * Redis鎖具備可重入特性,它可以讓多個執行緒同時進入相同的鎖,而不會引起死鎖問題。
 *
 * Redis鎖也是基於無訊號量機制實現的,它能夠控制多使用者對共享資源的訪問,而可重入特性又可以保證同一個使用者可以重新獲取已經被他自己佔用的鎖。
 *
 * 例如,Java應用程式可以在方法A中加鎖,而當方法A呼叫方法B時,執行緒仍然可以獲取它已經持有的鎖,從而避免死鎖的發生。要實現該功能,就必須確保鎖的可重入性,以下是兩個使用 Java 編寫的樣例程式,可以演示 Java 中鎖的可重入性:
 * @param Request $request
 * @return bool
 */
public function test(Request $request): bool
{
    $userId = $request->input("user_id");
    $requestId = $request->getHeaderLine("request-id");
    $goods = Goods::find(1);
    if (!$goods) {
        return false;
    }
    if ($goods->num < 1) {
        return false;
    }
    $redisLock = new RedisReentrantLock($userId, $requestId);
    if ($redisLock->tryLock(0, 6)) {
        try {
            $isBuy = Db::select("select id from ku_order where user_id = {$userId} and goods_id = 1 limit 1");
            if ($isBuy) {
                return false;
            }
            $flag = Db::update("update ku_goods set num = num - 1 where id = {$goods->id} and num > 0");
            if (!$flag) {
                return false;
            }
            $order = new Order();
            $order->user_id = $userId;
            $order->goods_id = $goods->id;
            $order->save();
            return true;
        } catch (\Throwable $e) {

        } finally {
            $redisLock->unlock();
        }
    }

    return true;
}

壓測結果

其他細節,可以在使用時根據具體業務調整

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章