月半談(一)redis-分散式鎖與應用

漫天風雨下西樓發表於2021-01-17

最近一直有在做一些搶購,限流方面的工作,這兩週就打算寫 業務中的鎖與php、golang 實踐方面的東西。

實踐一 基於 Redis 鎖的限流操作

針對於 傳送驗證碼場景

  • 要求是 單個手機號碼 60秒內,只允許傳送一次,來防止簡訊介面被無限次的惡意呼叫

先看看 一般邏輯寫法

  • 測試相關工具

    • 測試框架為 Hyperf2.0 + php 7.2
    • mysql 連結池 10-100
    • redis 連結池 10-100
      • 併發測試 工具為 Apache JMter 5.2.1
      • 測試環境 為 contOS 7.7
      • MySQL 5.6
    • 比如有一張 send_records 表
    • id code mobile created_at updated_at 欄位
不加鎖 DB 插入判定
    public function testSend()
    {
        $mobile     = $this->request->input('mobile', '13711111111');
        $sendModel  = DB::table('send_records');
        $ok         = $sendModel->where('mobile', $mobile)->where('created_at', '>', date('Y-m-d H:i:s', time() - 60))->exists();

        // 存在則說明60秒內已經傳送
        if ($ok) {
            return ['data' => [], 'msg' => 'send fail'];
        }

        $code = mt_rand(1000, 9999);
        $sendModel->insert([
            'mobile'     => $mobile,
            'code'       => $code,
            'created_at' => date('Y-m-d H:i:s'),
            'updated_at' => date('Y-m-d H:i:s'),
        ]);

        //send msg doing
        //發簡訊 一些耗時操作
        for ($i = 0; $i < 10000000; $i++) {
            //....
        }

        return ['data' => [], 'msg' => 'ok'];
    }

採用 JMter 100 執行緒 1秒內 請求一次的 方式進行壓測 ,

結果不出所料,在高併發下,資料庫出現了重複的資料

根據程式碼 邏輯來看, 就是在 第一步判定與第二步插入資料之間,出現了併發的請求,從而導致多個請求進入了傳送的邏輯。

那這種寫法有沒有改良做法呢?

有呀, 那就是利用鎖的機制 !


先看看改動最小的 MySQL 的行鎖

加 MySQL行鎖 再進行判定
    public function testSendWithDBLock()
    {
        $mobile    = $this->request->input('mobile', '13711111111');

        Db::beginTransaction();
        $sendModel = DB::table('send_records');
        $ok        = $sendModel->where('mobile', $mobile)
            ->where('created_at', '>', date('Y-m-d H:i:s', time() - 60))
            ->lockForUpdate()->first();

        if ($ok) {
            Db::rollBack();
            return ['data' => [], 'msg' => 'send fail'];
        }

        $code = mt_rand(1000, 9999);
        $sendModel->insert([
            'mobile'     => $mobile,
            'code'       => $code,
            'created_at' => date('Y-m-d H:i:s'),
            'updated_at' => date('Y-m-d H:i:s'),
        ]);
        Db::commit();

        //send msg doing
        //發簡訊 一些耗時操作
        for ($i = 0; $i < 10000000; $i++) {
            //....
        }

        return ['data' => [], 'msg' => 'ok'];
    }

上述程式碼加入了MySQL 行鎖,成功解決了併發問題!

那麼這種寫法有沒有問題呢?

優點是程式碼改動下,也很清晰,但是這種寫法的缺點也很明顯

一個是依賴MySQL的鎖機制,在高併發時這會消耗MySQL大量的資源,而MySQL作為底層儲存,一旦掛掉勢必會影響到其它業務,當鎖住的表為核心表時,那就更加會是整個系統的瓶頸了

第二個是,在使用 行鎖後,介面的TPS 出現了 50% 以上的跌幅

所以在預期有高併發時候,不推薦這種寫法。


我們驗證了鎖的好處,那麼就是想辦法讓 Redis 去模擬鎖就好了,高效能的Redis 正好可以來滿足效能與降低DB負載兩方面的要求

Redis 模擬鎖的錯誤寫法
    public function testSendByRedisLock()
    {
        $mobile = $this->request->input('mobile', '13711111111');
        $redis  = \Hyperf\Utils\ApplicationContext::getContainer()->get(\Hyperf\Redis\Redis::class);

        $existKey = "serve:mobile:{$mobile}";

        if ($redis->exists($existKey)) {
            return ['data' => [], 'msg' => 'send fail'];
        }

        if (!$redis->setex($existKey, 60, "ok")) {
            return ['data' => [], 'msg' => 'send fail'];
        }
        $code = mt_rand(1000, 9999);
        DB::table('send_records')->insert([
            'mobile'     => $mobile,
            'code'       => $code,
            'created_at' => date('Y-m-d H:i:s'),
            'updated_at' => date('Y-m-d H:i:s'),
        ]);

        //send msg doing
        //發簡訊 一些耗時操作
        for ($i = 0; $i < 10000000; $i++) {
            //....
        }

        return ['data' => [], 'msg' => 'ok'];
    }

這個寫法雖然併發上去了,但是會出現跟未使用DB 鎖時候的一樣,未能成功阻止併發的請求,因為 exist 與 setnx 兩個操作 未能合併成原子的寫法。

redis 在 2.6.12 版本 對 set 進行了最佳化,增加了 EX, PX, NXXX 可選項來處理 這種情況

Redis 模擬鎖的改良寫法
$existKey = "serve:mobile:{$mobile}";

# 原來寫法
if ($redis->exists($existKey)) {
return ['data' => [], 'msg' => 'send fail'];
}

if (!$redis->setex($existKey, 60, "ok")) {
return ['data' => [], 'msg' => 'send fail'];
}

# 改良寫法
if (!$redis->set($existKey, "ok", ['NX', 'EX' => 60])) {
    return ['data' => [], 'msg' => 'send fail'];
}

自此 就使用 redis 就完美的模擬了鎖的機制。至少能最大限度的解決我們最開始提出來的問題。

但是 上述寫法還存在一些問題,如超時問題,還有 Redis 叢集間的鎖同步問題

實踐二 基於 Redis 鎖的 亂序轉順序執行操作

超時問題

問題描述為 : 如果一次只能有一個客戶端去處理,上個客戶端處理完成,下個客戶端才能進來

模擬程式碼如下

        $redis  = \Hyperf\Utils\ApplicationContext::getContainer()->get(\Hyperf\Redis\Redis::class);

        $existKey = "serve:mobile";
        if (!$redis->set($existKey, "ok", ['NX', 'EX' => 2])) {
            return ['data' => [], 'msg' => 'send fail'];
        }

        DB::table('send_records')->insert([
            'mobile'     => $mobile,
            'code'       => time(),
            'created_at' => date('Y-m-d H:i:s'),
            'updated_at' => date('Y-m-d H:i:s'),
        ]);
        //doing something 大約執行3秒
        for ($i = 0; $i < 100000000; $i++) {
            //....
        }
        // down
        $redis->del($existKey);

那如果使用上述的Redis鎖,一個客戶的處理 完成後會去解鎖,因為使用的是同一個Key,如果A客戶進入超時那麼就會解掉B的?

這樣無限套下去鎖就失效了,同時可能會存在多個程式/執行緒/協程 同時在處理。

我將壓測時間延長,就得到了上圖,349 的鎖 被 348 給解了 所以 出現了 349 跟 351 同時執行的問題。

所以對於應用來說,解鎖應該判斷是否是當初自己設定的鎖,透過設定的唯一值來判定

如:

        $redis  = \Hyperf\Utils\ApplicationContext::getContainer()->get(\Hyperf\Redis\Redis::class);

        $existKey = "serve:mobile";
        $value    = \Hyperf\Utils\Coroutine::id() . mt_rand(1000, 9999);
        if (!$redis->set($existKey, $value, ['NX', 'EX' => 2])) {
            return ['data' => [], 'msg' => 'send fail'];
        }

        DB::table('send_records')->insert([
            'mobile'     => $mobile,
            'code'       => time(),
            'created_at' => date('Y-m-d H:i:s'),
            'updated_at' => date('Y-m-d H:i:s'),
        ]);

        //doing something 大約執行3秒
        for ($i = 0; $i < 100000000; $i++) {
            //....
        }

        // down
        $script = 'if redis.call("get",KEYS[1]) == ARGV[1]
                  then
                      return redis.call("del",KEYS[1])
                  else
                      return 0
                  end';
        $redis->eval($script, $existKey, $value);

這裡用到了 lua 指令碼 來保證原子性, 透過協程號來生成唯一值(這個也不是唯一的,只是相對來說,比較安全,分散式部署的需要加入機器的mac地址之類的唯一編號來做進一步區分),但是這種操作也只是相對安全,當 A超時,A、B 客戶端同時執行時,其實就不是序列化執行了,需要評估對於業務的影響。


Redis 叢集與 Redlock 問題

上面我們討論了超時問題,那麼最後一個問題就是,當 Redis 不是單機而是叢集時,上述的鎖機制就可能因為同步機制的不穩定而導致 出現多臺Redis 上 存在 不同的?的情況。針對於 這種情況,Redis 作者提出了 Redlock 演算法來保證叢集的一致性。

當前有N個完全獨立的 Redis master 節點, 分別部署在不同的主機上,客戶端設定鎖的操作:

  1. 使用相同key和唯一值同時向這N個 Redis 節點請求鎖, 鎖的超時時間應該 遠大於 整個請求的耗時時間
  2. 計算步驟1消耗的時間, 若總消耗時間超過超時時間, 則認為鎖失敗. 客戶端需在大多數(超過一半)的節點上成功獲取鎖, 才認為是鎖成功
  3. 如果鎖成功了, 則該鎖有效時間 = 鎖原始有效時間 - 步驟1消耗的時間
  4. 如果鎖失敗了(超時或無法獲取超過一半例項的鎖), 客戶端會到每個之前加鎖失敗節點釋放鎖

當然這個演算法也有很大的爭議,分散式設計比起單機來說,都存在一定的缺陷,就目前來說單機的Redis 鎖已經能滿足業務的需求,當單機 Redis 扛不住的時候,再考慮 其它的分散式?。

本來以為寫起來應該很快,但實際上草稿都快寫了半天,這大概就是關於 Redis 鎖的相關操作,下次我們可以聊一下基於ETCD的分散式鎖 跟鎖的演算法相關的問題。

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

相關文章