1. 程式人生 > >redis 限制併發訪問

redis 限制併發訪問

快取驚群現象,在各種快取中都會存在這種現象,這裡以Redis為例,提供一種解決思路,留作參考~
首先,所謂的快取過期引起的“驚群”現象是指,在大併發情況下,我們通常會用快取來給資料庫分壓,但是會有這麼一種情況發生,那就是當一個快取資料失效之後會導致同時有多個併發執行緒去向後端資料庫發起請求去獲取同一個資料,這樣如果在一段時間內同時生成了大量的快取,然後在另外一段時間內又有大量的快取失效,這樣就會導致後端資料庫的壓力突然增大,這種現象就可以稱為“快取過期產生的驚群現象”!
以下程式碼的思路,就是利用“鎖機制”來防止驚群現象。先看程式碼:
class KomaRedis{

    private $redis; //redis物件
    private static $_instance = null;

    private function __construct($config = array())
    {
        if (empty($config)) {
            return false;
        }
        $this->redis = new Redis();
        $this->redis->connect($config['server'], $config['port']);
        return $this->redis;
    }

    /**
     * @param array $config
     * @return redis操作類物件
     */
    public static function getInstance($config = array())
    {
        if (!(self::$_instance instanceof self)) {
            self::$_instance = new self ($config);
        }
        return self::$_instance;
    }

    /**
     * 獲取快取
     * @param $key string $name
     * @return array,object,number,string,boolean
     * @desc 此方法使用了鎖機制來防止防止快取過期時所產生的驚群現象,保證只有一個程序不獲取資料,可以更新,其他程序仍然獲取過期資料
     */
    public function getByLock($key)
    {
        $sth = $this->redis->get($key);
        if ($sth === false) {
            return $sth;
        } else {
            $sth = json_decode($sth, TRUE);
            if (intval($sth['expire']) <= time()) {
                $lock = $this->redis->incr($key . ".lock");
                if ($lock === 1) {
                    return false;
                } else {
                    return $sth['data'];
                }
            } else {
                return $sth['data'];
            }
        }
    }

    /**
     * 設定快取
     * @param $key string $name 快取鍵
     * @param $value $string ,array,object,number,boolean $value 快取值
     * @param null $ttl $string ,number $ttl 過期時間,如果不設定,則使用預設時間,如果為 infinity 則為永久儲存
     * @return bool
     * @desc 此方法儲存的資料會自動加入一些其他資料來避免驚群現象,如需儲存原始資料,請使用 set
     */
    public function setByLock($key, $value, $ttl = null)
    {
        if (is_numeric($ttl) && intval($ttl) > 0) {
            $ttl = intval($ttl);
            $exp = time() + $ttl;
            $arg = array("data" => $value, "expire" => $exp);
        } else {
            $ttl = 300;
            $exp = time() + $ttl;
        }
        empty($ttl) OR $ttl += 300; //增加redis快取時間,使程式有足夠的時間生成快取
        $arg = array("data" => $value, "expire" => $exp);
        $rs = $this->redis->setex($key, $ttl, json_encode($arg, TRUE));
        $this->redis->del($key . ".lock");
        return $rs;
    }

    /**
     * 返回redis物件
     * redis有非常多的操作方法,我們只封裝了一部分
     * 拿著這個物件就可以直接呼叫redis自身方法
     */
    public function redis()
    {
        return $this->redis;
    }
}
 
原理就是:
首先,在儲存資料的時候,設定資料的過期時間比實際設定的過期時間多300秒,然後儲存的資料中,通過一個數組來儲存資料,陣列中一個鍵用來存放真實的資料,另外一個鍵用來存放資料的真實過期時間,這個留到後期獲取資料的時候做校驗,然後把對應這個資料的“鎖”刪除掉。
這裡這麼做的原因和讀取資料的做法相關!
然後,在讀取資料的時候,依然像平時一樣直接讀取,如果資料已經超過了有效期(注意:這裡的有效期並非設定的有效期,而是更該之後的有效期),那麼就只能去讀後端資料庫。如果資料依然有效,則需要去判斷,判斷資料“在真正的有效期內是否失效”,如果沒有失效,則直接返回資料!
重點是,假如資料“在偽造的有效期內沒有失效,而在真正的有效期內已經失效”,那麼這時就需要去判斷“資料的鎖”!
通過程式碼“$lock = $this->redis->incr($key . ".lock");”可以獲取資料的鎖,“$lock === 1”表示資料沒有鎖,那麼這一次請求需要傳送到後端資料庫去讀取最新的資料,否則的話表示該資料已經加了鎖,也就是已經有一個執行緒去後端讀取資料了,那麼後來的執行緒也就沒有許可權再去後端取資料,需要等到前面的那個執行緒執行結束,但是這次讀取就只能讀取“舊的資料”了!
通過上面的解釋也就明白,為什麼在儲存資料的時候需要“刪除資料的鎖”!因為一旦資料被重新儲存,那麼說明已經有一個執行緒去後端得到了最新的資料,那麼該資料的鎖就可以釋放,然後下一個執行緒在獲取資料的時候如果有需要就可以得到這個鎖,然後才有許可權進入到後端去讀取新資料!


1.併發訪問限制問題
對於一些需要限制同一個使用者併發訪問的場景,如果使用者併發請求多次,而伺服器處理沒有加鎖限制,使用者則可以多次請求成功。
例如換領優惠券,如果使用者同一時間併發提交換領碼,在沒有加鎖限制的情況下,使用者則可以使用同一個換領碼同時兌換到多張優惠券。
虛擬碼如下:
if A(可以換領)
   B(執行換領)
   C(更新為已換領)
D(結束)
如果使用者併發提交換領碼,都能通過可以換領(A)的判斷,因為必須有一個執行換領(B)後,才會更新為已換領(C)。因此如果使用者在有一個更新為已換領之前,有多少次請求,這些請求都可以執行成功。
2.併發訪問限制方法
使用檔案鎖可以實現併發訪問限制,但對於分散式架構的環境,使用檔案鎖不能保證多臺伺服器的併發訪問限制。
Redis是一個開源的使用ANSI C語言編寫、支援網路、可基於記憶體亦可持久化的日誌型、Key-Value資料庫,並提供多種語言的API。 
本文將使用其setnx方法實現分散式鎖功能。setnx即Set it N**ot eX**ists。 
當鍵值不存在時,插入成功(獲取鎖成功),如果鍵值已經存在,則插入失敗(獲取鎖失敗)
RedisLock.class.php

<?php
/**
 * Redis鎖操作類
 * Date:  2016-06-30
 * Author: fdipzone
 * Ver:  1.0
 *
 * Func:
 * public lock  獲取鎖
 * public unlock 釋放鎖
 * private connect 連線
 */
class RedisLock { // class start
 
  private $_config;
  private $_redis;
 
  /**
   * 初始化
   * @param Array $config redis連線設定
   */
  public function __construct($config=array()){
    $this->_config = $config;
    $this->_redis = $this->connect();
  }
 
  /**
   * 獲取鎖
   * @param String $key  鎖標識
   * @param Int   $expire 鎖過期時間
   * @return Boolean
   */
  public function lock($key, $expire=5){
    $is_lock = $this->_redis->setnx($key, time()+$expire);
 
    // 不能獲取鎖
    if(!$is_lock){
 
      // 判斷鎖是否過期
      $lock_time = $this->_redis->get($key);
 
      // 鎖已過期,刪除鎖,重新獲取
      if(time()>$lock_time){
        $this->unlock($key);
        $is_lock = $this->_redis->setnx($key, time()+$expire);
      }
    }
 
    return $is_lock? true : false;
  }
 
  /**
   * 釋放鎖
   * @param String $key 鎖標識
   * @return Boolean
   */
  public function unlock($key){
    return $this->_redis->del($key);
  }
 
  /**
   * 建立redis連線
   * @return Link
   */
  private function connect(){
    try{
      $redis = new Redis();
      $redis->connect($this->_config['host'],$this->_config['port'],$this->_config['timeout'],$this->_config['reserved'],$this->_config['retry_interval']);
      if(empty($this->_config['auth'])){
        $redis->auth($this->_config['auth']);
      }
      $redis->select($this->_config['index']);
    }catch(RedisException $e){
      throw new Exception($e->getMessage());
      return false;
    }
    return $redis;
  }
 
} // class end
 
?>
demo.php
<?php
require 'RedisLock.class.php';
 
$config = array(
  'host' => 'localhost',
  'port' => 6379,
  'index' => 0,
  'auth' => '',
  'timeout' => 1,
  'reserved' => NULL,
  'retry_interval' => 100,
);
 
// 建立redislock物件
$oRedisLock = new RedisLock($config);
 
// 定義鎖標識
$key = 'mylock';
 
// 獲取鎖
$is_lock = $oRedisLock->lock($key, 10);
 
if($is_lock){
  echo 'get lock success<br>';
  echo 'do sth..<br>';
  sleep(5);
  echo 'success<br>';
  $oRedisLock->unlock($key);
 
// 獲取鎖失敗
}else{
  echo 'request too frequently<br>';
}
 
?>
測試方法: 
開啟兩個不同的瀏覽器,同時在A,B中訪問demo.php 
如果先訪問的會獲取到鎖 
輸出 
get lock success 
do sth.. 
success
另一個獲取鎖失敗則會輸出request too frequently
保證同一時間只有一個訪問有效,有效限制併發訪問。
為了避免系統突然出錯導致死鎖,所以在獲取鎖的時候增加一個過期時間,如果已超過過期時間,即使是鎖定狀態都會釋放鎖,避免死鎖導致的問題。