【180414】分散式鎖(redis/mysql)

林灣村龍貓發表於2018-04-14

單臺機器所能承載的量是有限的,使用者的量級上萬,基本上服務都會做分散式叢集部署。很多時候,會遇到對同一資源的方法。這時候就需要鎖,如果是單機版的,可以利用java等語言自帶的併發同步處理。如果是多臺機器部署就得要有個中間代理人來做分散式鎖了。

常用的分散式鎖的實現有三種方式。

  • 基於redis實現(利用redis的原子性操作setnx來實現)
  • 基於mysql實現(利用mysql的innodb的行鎖來實現,有兩種方式, 悲觀鎖與樂觀鎖)
  • 基於Zookeeper實現(利用zk的臨時順序節點來實現)

目前,我已經是用了redis和mysql實現了鎖,並且根據應用場景應用在不同的線上環境中。zk實現比較複雜,又無應用場景,有興趣的可以參考他山之石中的《Zookeeper實現分散式鎖》。

說說心得和體會。

沒有什麼完美的技術、沒有萬能鑰匙、不同方式不同應用場景 CAP原理:一致性(consistency)、可用性(availability)、分割槽可容忍性(partition-tolerance)三者取其二。

他山之石

基於redis快取實現分散式鎖

基於redis的鎖實現比較簡單,由於redis的執行是單執行緒執行,天然的具備原子性操作,我們可以利用命令setnx和expire來實現,java版程式碼參考如下:

package com.fenqile.creditcard.appgatewaysale.provider.util;

import com.fenqile.redis.JedisProxy;

import java.util.Date;

/**
 * User: Rudy Tan
 * Date: 2017/11/20
 *
 * redis 相關操作
 */
public class RedisUtil {

    /**
     * 獲取分散式鎖
     *
     * @param key        string 快取key
     * @param expireTime int 過期時間,單位秒
     * @return boolean true-搶到鎖,false-沒有搶到鎖
     */
    public static boolean getDistributedLockSetTime(String key, Integer expireTime) {
        try {
            // 移除已經失效的鎖
            String temp = JedisProxy.getMasterInstance().get(key);
            Long currentTime = (new Date()).getTime();
            if (null != temp && Long.valueOf(temp) < currentTime) {
                JedisProxy.getMasterInstance().del(key);
            }

            // 鎖競爭
            Long nextTime = currentTime + Long.valueOf(expireTime) * 1000;
            Long result = JedisProxy.getMasterInstance().setnx(key, String.valueOf(nextTime));
            if (result == 1) {
                JedisProxy.getMasterInstance().expire(key, expireTime);
                return true;
            }
        } catch (Exception ignored) {
        }
        return false;
    }
}
複製程式碼

包名和獲取redis操作物件換成自己的就好了。

基本步驟是

  1. 每次進來先檢測一下這個key是否實現。如果失效了移除失效鎖
  2. 使用setnx原子命令爭搶鎖。
  3. 搶到鎖的設定過期時間。

步驟2為最核心的東西, 為啥設定步驟3?可能應為獲取到鎖的執行緒出現什麼移除請求,而無法釋放鎖,因此設定一個最長鎖時間,避免死鎖。 為啥設定步驟1?redis可能在設定expire的時候掛掉。設定過期時間不成功,而出現鎖永久生效。

線上環境,步驟1、3的問題都出現過。所以要做保底攔截。

redis叢集部署

redis叢集部署.png

通常redis都是以master-slave解決單點問題,多個master-slave組成大叢集,然後通過一致性雜湊演算法將不同的key路由到不同master-slave節點上。

redis鎖的優缺點:

優點:redis本身是記憶體操作、並且通常是多片部署,因此有這較高的併發控制,可以抗住大量的請求。 缺點:redis本身是快取,有一定概率出現資料不一致請求。

線上上,之前,利用redis做庫存計數器,獎品發放理論上只發放10個的,最後發放了14個。出現了資料的一致性問題。

因此在這之後,引入了mysql資料庫分散式鎖。

基於mysql實現的分散式鎖。

實現第一版

在此之前,在網上搜尋了大量的文章,基本上都是 插入、刪除發的方式或是直接通過"select for update"這種形式獲取鎖、計數器。具體可以參考他山之石中的《分散式鎖的幾種實現方式~》關於資料庫鎖章節。

一開始,我的實現方式虛擬碼如下:

public boolean getLock(String key){
     select for update
     if (記錄存在){
           update
     }else {
           insert 
   }
}
複製程式碼

這樣實現出現了很嚴重的死鎖問題,具體原因可以可以參考他山之石中的《select for update引發死鎖分析》 這個版本中存在如下幾個比較嚴重的問題:

1.通常線上資料是不允許做物理刪除的 2.通過唯一鍵重複報錯,處理錯誤形式是不太合理的。 3.如果appclient在處理中還沒釋放鎖之前就掛掉了,會出現鎖一直存在,出現死鎖。 4.如果以這種方式,實現redis中的計數器(incr decr),當記錄不存在的時候,會出現大量死鎖的情況。

因此考慮引入,記錄狀態欄位、中央鎖概念。

實現第二版

在第二版中完善了資料庫表設計,參考如下:

-- 鎖表,單庫單表
CREATE TABLE IF NOT EXISTS credit_card_user_tag_db.t_tag_lock (

    -- 記錄index
    Findex INT NOT NULL AUTO_INCREMENT COMMENT '自增索引id',

    -- 鎖資訊(key、計數器、過期時間、記錄描述)
    Flock_name VARCHAR(128) DEFAULT '' NOT NULL COMMENT '鎖名key值',
    Fcount INT NOT NULL DEFAULT 0 COMMENT '計數器',
    Fdeadline DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '鎖過期時間',
    Fdesc VARCHAR(255) DEFAULT '' NOT NULL COMMENT '值/描述',
    
    -- 記錄狀態及相關事件
    Fcreate_time DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '建立時間',
    Fmodify_time DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '修改時間',
    Fstatus TINYINT NOT NULL DEFAULT 1 COMMENT '記錄狀態,0:無效,1:有效',

    -- 主鍵(PS:總索引數不能超過5)
    PRIMARY KEY (Findex),
    -- 唯一約束
    UNIQUE KEY uniq_Flock_name(Flock_name),
    -- 普通索引
    KEY idx_Fmodify_time(Fmodify_time)

)ENGINE=INNODB DEFAULT CHARSET=UTF8 COMMENT '信用卡|鎖與計數器表|rudytan|20180412';
複製程式碼

在這個版本中,考慮到再條鎖併發插入存在死鎖(間隙鎖爭搶)情況,引入中央鎖概念。

基本方式是:

  1. 根據sql建立好資料庫
  2. 建立一條記錄Flock_name="center_lock"的記錄。
  3. 在對其他鎖(如Flock_name="sale_invite_lock")進行操作的時候,先對"center_lock"記錄select for update
  4. "sale_invite_lock"記錄自己的增刪改查。

考慮到不同公司引入的資料庫操作包不同,因此提供虛擬碼,以便於理解 虛擬碼

// 開啟事務
@Transactional
public boolean getLock(String key){
      // 獲取中央鎖
      select * from tbl where Flock_name="center_lock"    
    
     // 查詢key相關記錄
     select for update
     if (記錄存在){
           update
     }else {
           insert 
   }
}
複製程式碼

    /**
     * 初始化記錄,如果有記錄update,如果沒有記錄insert
     */
    private LockRecord initLockRecord(String key){
        // 查詢記錄是否存在
        LockRecord lockRecord = lockMapper.queryRecord(key);
        if (null == lockRecord) {
            // 記錄不存在,建立
            lockRecord = new LockRecord();
            lockRecord.setLockName(key);
            lockRecord.setCount(0);
            lockRecord.setDesc("");
            lockRecord.setDeadline(new Date(0));
            lockRecord.setStatus(1);
            lockMapper.insertRecord(lockRecord);
        }
        return lockRecord;
    }

   /**
     * 獲取鎖,程式碼片段
     */
    @Override
    @Transactional
    public GetLockResponse getLock(GetLockRequest request) {
        // 檢測引數
        if(StringUtils.isEmpty(request.lockName)) {
            ResultUtil.throwBusinessException(CreditCardErrorCode.PARAM_INVALID);
        }

        // 相容引數初始化
        request.expireTime = null==request.expireTime? 31536000: request.expireTime;
        request.desc = Strings.isNullOrEmpty(request.desc)?"":request.desc;
        Long nowTime = new Date().getTime();

        GetLockResponse response = new GetLockResponse();
        response.lock = 0;

        // 獲取中央鎖,初始化記錄
        lockMapper.queryRecordForUpdate("center_lock");
        LockRecord lockRecord = initLockRecord(request.lockName);

        // 未釋放鎖或未過期,獲取失敗
        if (lockRecord.getStatus() == 1
                && lockRecord.getDeadline().getTime() > nowTime){
            return response;
        }

        // 獲取鎖
        Date deadline = new Date(nowTime + request.expireTime*1000);
        int num = lockMapper.updateRecord(request.lockName, deadline, 0, request.desc, 1);
        response.lock = 1;
        return response;
    }
複製程式碼

到此,該方案,能夠滿足我的分散式鎖的需求。

但是該方案,有一個比較致命的問題,就是所有記錄共享一個鎖,併發並不高。

經過測試,開啟50*100個執行緒併發修改,5次耗時平均為8秒。

實現第三版

由於方案二,存在共享同一把中央鎖,併發不高的請求。參考concurrentHashMap實現原理,引入分段鎖概念,降低鎖粒度。

concurrentHashMap分段鎖概念

基本方式是:

  1. 根據sql建立好資料庫
  2. 建立100條記錄Flock_name="center_lock_xx"的記錄(xx為00-99)。
  3. 在對其他鎖(如Flock_name="sale_invite_lock")進行操作的時候,根據crc32演算法找到對應的center_lock_02,先對"center_lock_02"記錄select for update
  4. "sale_invite_lock"記錄自己的增刪改查。

虛擬碼如下:

// 開啟事務
@Transactional
public boolean getLock(String key){
      // 獲取中央鎖
      select * from tbl where Flock_name="center_lock"    
    
     // 查詢key相關記錄
     select for update
     if (記錄存在){
           update
     }else {
           insert 
   }
}
複製程式碼
    /**
     * 獲取中央鎖Key
     */
    private boolean getCenterLock(String key){
        String prefix = "center_lock_";
        Long hash = SecurityUtil.crc32(key);
        if (null == hash){
            return false;
        }
        //取crc32中的最後兩位值
        Integer len = hash.toString().length();
        String slot = hash.toString().substring(len-2);

        String centerLockKey = prefix + slot;
        lockMapper.queryRecordForUpdate(centerLockKey);
        return true;
    }

      /**
     * 獲取鎖
     */
    @Override
    @Transactional
    public GetLockResponse getLock(GetLockRequest request) {
        // 檢測引數
        if(StringUtils.isEmpty(request.lockName)) {
            ResultUtil.throwBusinessException(CreditCardErrorCode.PARAM_INVALID);
        }

        // 相容引數初始化
        request.expireTime = null==request.expireTime? 31536000: request.expireTime;
        request.desc = Strings.isNullOrEmpty(request.desc)?"":request.desc;
        Long nowTime = new Date().getTime();

        GetLockResponse response = new GetLockResponse();
        response.lock = 0;

        // 獲取中央鎖,初始化記錄
        getCenterLock(request.lockName);
        LockRecord lockRecord = initLockRecord(request.lockName);

        // 未釋放鎖或未過期,獲取失敗
        if (lockRecord.getStatus() == 1
                && lockRecord.getDeadline().getTime() > nowTime){
            return response;
        }

        // 獲取鎖
        Date deadline = new Date(nowTime + request.expireTime*1000);
        int num = lockMapper.updateRecord(request.lockName, deadline, 0, request.desc, 1);
        response.lock = 1;
        return response;
    }
複製程式碼

經過測試,開啟50*100個執行緒併發修改,5次耗時平均為5秒。相較於版本二幾乎有一倍的提升。

至此,完成redis/mysql分散式鎖、計數器的實現與應用。

最後

根據不同應用場景,做出如下選擇:

  1. 高併發、不保證資料一致性:redis鎖/計數器
  2. 低併發、保證資料一致性:mysql鎖/計數器
  3. 低併發、不保證資料一致性:你隨意
  4. 高併發。保證資料一致性:redis鎖/計數器 + mysql鎖/計數器。

表資料和記錄:

多段中央鎖記錄

其他鎖記錄

歡迎關注我的簡書部落格,一起成長,一起進步。

www.jianshu.com/u/5a327aab7…

相關文章