redis 分布式阻塞鎖的實現(xiàn)(非爭搶、同步隊列機(jī)制)

提示:可跳過背景信息,直接跳到標(biāo)題三閱讀

一. 分布式鎖使用場景

在服務(wù)器后端程序開發(fā)中,分布式鎖主要用于多臺機(jī)器的多個進(jìn)程/線程的并發(fā)執(zhí)行問題(處理同一數(shù)據(jù))。比如同時用戶下單時多個并發(fā)請求,進(jìn)行扣減同一商品庫存操作。

并發(fā)執(zhí)行偽代碼
----------
//1.獲取商品庫存數(shù)量
$num = getNum($pruduct_id)
//2.庫存相關(guān)邏輯
if ($num < 10) {
   //商品購買失敗
   return false;
}
//3.扣減庫存
setNum($num - 1);
----------

上邊偽代碼在并發(fā)執(zhí)行的時候,先getNum、再setNum,這并非一個原子操作,會出現(xiàn)同時獲取到的庫存數(shù)量都滿足要求,然后都進(jìn)行減庫存的情況。

二. 并發(fā)問題解決方案

本質(zhì)上的解決思路是,把多個異步并發(fā)執(zhí)行的請求變?yōu)橥桨错樞驁?zhí)行。

1. 在數(shù)據(jù)庫層面處理

加鎖將查詢和修改兩條語句合為一個原子操作,比如mysql的select ... for update語句。

2. 在應(yīng)用程序?qū)用嫣幚?php/java/go)

一般的,有以下兩種方案:

  1. 排隊機(jī)制(異步消息隊列方案)。將并發(fā)的請求順序入消息隊列,然后開起一個單獨進(jìn)程,逐個消費隊列內(nèi)容。
并發(fā)執(zhí)行偽代碼
----------
pushMes(list,'商品1扣減庫存');
return '商品購買中'
----------

單進(jìn)程異步去消費隊列
---------
while(PopMes(list))
{
   //1.獲取商品庫存數(shù)量
   $num = getNum($pruduct_id);
   //2.庫存相關(guān)邏輯
   if ($num < 10) {
      //商品購買失敗
      return false;
   }
   //3.扣減庫存
   setNum($num - 1);
   
   //通知購買情況
   notify();
}
---------
  1. 爭搶鎖機(jī)制。多個請求同時爭搶一個分布式的鎖,拿到鎖的請求執(zhí)行完成后釋放鎖,未拿到鎖的請求循環(huán)sleep一段時間,去等待鎖釋放、爭搶鎖。
并發(fā)執(zhí)行偽代碼
----------
times = 0;
while(times < 10) {
   //獲取鎖
   if (getLock()) {
      //1.獲取商品庫存數(shù)量
      $num = getNum($pruduct_id);
      //2.庫存相關(guān)邏輯
      if ($num < 10) {
         //商品購買失敗
         return false;
      }
      //3.扣減庫存
      setNum($num - 1);
      
      //釋放鎖
      releaseLock();
      return true;
   } else {
      times = times + 1;
      //等待一段時間
      sleep(0.01);
   }
}
----------

三. 本文新方案,分布式非爭搶阻塞鎖(同步隊列機(jī)制)

1. 概念解讀

  • 首先鎖是分布式的
  • 阻塞鎖指的是,不能拿到鎖的時候,會阻塞程序的執(zhí)行直至拿到鎖
  • 非爭搶指的是,等待拿鎖的過程是不用爭搶的,通過同步隊列實現(xiàn)(相對異步消息隊列而言)

2. 實現(xiàn)原理

  • 分布式:創(chuàng)建一個redis隊列來存儲一個key,作為一個可用鎖。
  • 阻塞非爭搶拿鎖:通過redis的brpop命令來阻塞獲取一個鎖
  • 釋放鎖:拿到鎖執(zhí)行完對應(yīng)業(yè)務(wù)后,將鎖資源存入redis隊列


BRPOP 是一個阻塞的列表彈出原語。 它是 RPOP的阻塞版本,因為這個命令會在給定list無法彈出任何元素的時候阻塞連接。關(guān)于redis brpop的非爭搶和阻塞特性的實現(xiàn),在后邊的文章分析。

3. 代碼實現(xiàn)(php)

注: 下面代碼僅為事例代碼,具體應(yīng)用還要考慮其他問題。比如加鎖后程序異常退出,釋放鎖失效的問題。

<?php

   $redis = new Redis();
   $redis->connect('127.0.0.1', 6379);

   //商品id
   $pruduct_id = 1;
   //鎖的名稱
   $lock_key = 'lock_' . $pruduct_id;
   //產(chǎn)品庫存在redis中存儲的key
   $store_key = 'product_' . $pruduct_id;
   //初始設(shè)置商品庫存為2000
   $redis->setnx($store_key, 2000);
   //獲取鎖最多阻塞10s
   $lock = getLock($lock_key, 10);
   //記錄請求數(shù)量
   $redis->incr('request_num');
   if ($lock) {
      $num = $redis->get($store_key);
      if (is_numeric($num) && $num > 10) {
         //減庫存
         $num--;
         $redis->set($store_key, $num);
      }
      //釋放鎖資源
      releaseLock($lock_key);
   }

   /**
   * 阻塞非爭搶獲取一個鎖
   * @param string $key 鎖的名稱
   * @param string $timeout 最大阻塞時間(秒),超過時間將不再等待拿鎖
   * @return bool 獲取鎖成功/失敗
   */
   function getLock($key = 'lock1', $timeout) 
   {
      global $redis;
      //第一次請求, 鎖標(biāo)識不存在的情況,直接拿到鎖
      $lock =  $redis->setnx($key, 1);
      if (!$lock) {
        //非第一次請求,阻塞等待拿到鎖
        $lock = $redis->brpop($key . '_list', $timeout);
      }
     return (bool)$lock;
   }

   /**
   * 爭搶獲取一個鎖(使用setnx實現(xiàn) 拿不到鎖最多重試100次)
   * @param string $key 鎖的名稱
   * @param string $timeout 最大阻塞時間(秒),超過時間將不再等待拿鎖
   * @return bool 獲取鎖成功/失敗
   */
   function getLock2($key = 'lock1') 
   {
      global $redis;
      $lock =  $redis->setnx($key, 1);
      if (!$lock) {
        for ($i=0; $i < 100; $i++) {
          //記錄拿鎖重試次數(shù)
          $redis->incr('retry');
          usleep(1);
          if ($redis->setnx($key, 1)) {
            return true;
          }
        }
        //記錄拿鎖失敗次數(shù)
        $redis->incr('get_lock_fail');
      }
     return (bool)$lock;
   }

   /**
     * 釋放鎖
     * @param string $key 鎖的名稱
     * @return bool 釋放鎖成功/失敗
     */
   function releaseLock($key = 'lock1')
   {
      global $redis;
      //返回可用資源到隊列
      $ret = $redis->rpush($key . '_list', 'lock_item1');
      return $ret;
   }

   /**
     * 釋放爭搶鎖
     * @param string $key 鎖的名稱
     * @return bool 釋放鎖成功/失敗
     */
   function releaseLock2($key = 'lock1')
   {
      global $redis;
      //刪除鎖
      $ret = $redis->del($key);
      return $ret;
   }

4. 測試

(1)正確性測試

使用ab測試工具,模擬并發(fā)請求


測試結(jié)果正確,一共成功執(zhí)行2000個請求,庫存只減到10。


測試結(jié)果正確,一共成功執(zhí)行1000個請求,庫存扣減到1000。

(2)和redis爭搶鎖對比測試

提示:示例代碼中的getLock2、releaseLock2即為爭搶鎖例子

  • 效率對比
    兩種加鎖方式,分別ab測試2000個請求 100個并發(fā), php-fpm開啟50個進(jìn)程
    ab -n 2000 -c 100 -k http://127.0.0.1/test.php (linux或mac命令行執(zhí)行)
  • 爭搶鎖執(zhí)行結(jié)果
    將初始庫存改為3000
    ab -n 2000 -c 100 -k http://127.0.0.1/test.php (linux或mac命令行執(zhí)行)

四. 最后

本文是提供了一個新的思路,不完善的地方歡迎在評論區(qū)討論。

五. 廣告

云服務(wù)器練手推薦

3月份騰訊云在打折促銷,新用戶1核2G云服務(wù)器99/年,非新用戶可以注冊新賬號或者續(xù)費也有優(yōu)惠。沒有云服務(wù)器的同學(xué)可以趁著打折去來一臺

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容