單臺機器所能承載的量是有限的,使用者的量級上萬,基本上服務都會做分散式叢集部署。很多時候,會遇到對同一資源的方法。這時候就需要鎖,如果是單機版的,可以利用java等語言自帶的併發同步處理。如果是多臺機器部署就得要有個中間代理人來做分散式鎖了。
常用的分散式鎖的實現有三種方式。
- 基於redis實現(利用redis的原子性操作setnx來實現)
- 基於mysql實現(利用mysql的innodb的行鎖來實現,有兩種方式, 悲觀鎖與樂觀鎖)
- 基於Zookeeper實現(利用zk的臨時順序節點來實現)
目前,我已經是用了redis和mysql實現了鎖,並且根據應用場景應用在不同的線上環境中。zk實現比較複雜,又無應用場景,有興趣的可以參考他山之石中的《Zookeeper實現分散式鎖》。
說說心得和體會。
沒有什麼完美的技術、沒有萬能鑰匙、不同方式不同應用場景 CAP原理:一致性(consistency)、可用性(availability)、分割槽可容忍性(partition-tolerance)三者取其二。
他山之石
- Zookeeper實現分散式鎖:www.jianshu.com/p/5d12a0101…
- 分散式鎖的幾種實現方式~:www.hollischuang.com/archives/17…
- select for update引發死鎖分析:www.cnblogs.com/micrari/p/8…
基於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操作物件換成自己的就好了。
基本步驟是
- 每次進來先檢測一下這個key是否實現。如果失效了移除失效鎖
- 使用setnx原子命令爭搶鎖。
- 搶到鎖的設定過期時間。
步驟2為最核心的東西, 為啥設定步驟3?可能應為獲取到鎖的執行緒出現什麼移除請求,而無法釋放鎖,因此設定一個最長鎖時間,避免死鎖。 為啥設定步驟1?redis可能在設定expire的時候掛掉。設定過期時間不成功,而出現鎖永久生效。
線上環境,步驟1、3的問題都出現過。所以要做保底攔截。
redis叢集部署
通常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';
複製程式碼
在這個版本中,考慮到再條鎖併發插入存在死鎖(間隙鎖爭搶)情況,引入中央鎖概念。
基本方式是:
- 根據sql建立好資料庫
- 建立一條記錄Flock_name="center_lock"的記錄。
- 在對其他鎖(如Flock_name="sale_invite_lock")進行操作的時候,先對"center_lock"記錄select for update
- "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實現原理,引入分段鎖概念,降低鎖粒度。
基本方式是:
- 根據sql建立好資料庫
- 建立100條記錄Flock_name="center_lock_xx"的記錄(xx為00-99)。
- 在對其他鎖(如Flock_name="sale_invite_lock")進行操作的時候,根據crc32演算法找到對應的center_lock_02,先對"center_lock_02"記錄select for update
- "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分散式鎖、計數器的實現與應用。
最後
根據不同應用場景,做出如下選擇:
- 高併發、不保證資料一致性:redis鎖/計數器
- 低併發、保證資料一致性:mysql鎖/計數器
- 低併發、不保證資料一致性:你隨意
- 高併發。保證資料一致性:redis鎖/計數器 + mysql鎖/計數器。
表資料和記錄:
歡迎關注我的簡書部落格,一起成長,一起進步。