單臺(tái)機(jī)器所能承載的量是有限的,用戶的量級(jí)上萬(wàn),基本上服務(wù)都會(huì)做分布式集群部署。很多時(shí)候,會(huì)遇到對(duì)同一資源的方法。這時(shí)候就需要鎖,如果是單機(jī)版的,可以利用java等語(yǔ)言自帶的并發(fā)同步處理。如果是多臺(tái)機(jī)器部署就得要有個(gè)中間代理人來(lái)做分布式鎖了。
常用的分布式鎖的實(shí)現(xiàn)有三種方式。
- 基于redis實(shí)現(xiàn)(利用redis的原子性操作setnx來(lái)實(shí)現(xiàn))
- 基于mysql實(shí)現(xiàn)(利用mysql的innodb的行鎖來(lái)實(shí)現(xiàn),有兩種方式, 悲觀鎖與樂(lè)觀鎖)
- 基于Zookeeper實(shí)現(xiàn)(利用zk的臨時(shí)順序節(jié)點(diǎn)來(lái)實(shí)現(xiàn))
目前,我已經(jīng)是用了redis和mysql實(shí)現(xiàn)了鎖,并且根據(jù)應(yīng)用場(chǎng)景應(yīng)用在不同的線上環(huán)境中。zk實(shí)現(xiàn)比較復(fù)雜,又無(wú)應(yīng)用場(chǎng)景,有興趣的可以參考他山之石中的《Zookeeper實(shí)現(xiàn)分布式鎖》。
說(shuō)說(shuō)心得和體會(huì)。
沒(méi)有什么完美的技術(shù)、沒(méi)有萬(wàn)能鑰匙、不同方式不同應(yīng)用場(chǎng)景
CAP原理:一致性(consistency)、可用性(availability)、分區(qū)可容忍性(partition-tolerance)三者取其二。
他山之石
- Zookeeper實(shí)現(xiàn)分布式鎖:http://www.lxweimin.com/p/5d12a01018e1
- 分布式鎖的幾種實(shí)現(xiàn)方式~:http://www.hollischuang.com/archives/1716
- select for update引發(fā)死鎖分析:https://www.cnblogs.com/micrari/p/8029710.html
基于redis緩存實(shí)現(xiàn)分布式鎖
基于redis的鎖實(shí)現(xiàn)比較簡(jiǎn)單,由于redis的執(zhí)行是單線程執(zhí)行,天然的具備原子性操作,我們可以利用命令setnx和expire來(lái)實(shí)現(xiàn),java版代碼參考如下:
/**
* User: Rudy Tan
* Date: 2017/11/20
*
* redis 相關(guān)操作
*/
public class RedisUtil {
/**
* 獲取分布式鎖
*
* @param key string 緩存key
* @param expireTime int 過(guò)期時(shí)間,單位秒
* @return boolean true-搶到鎖,false-沒(méi)有搶到鎖
*/
public static boolean getDistributedLockSetTime(String key, Integer expireTime) {
try {
// 移除已經(jīng)失效的鎖
String temp = JedisProxy.getMasterInstance().get(key);
Long currentTime = (new Date()).getTime();
if (null != temp && Long.valueOf(temp) < currentTime) {
JedisProxy.getMasterInstance().del(key);
}
// 鎖競(jìng)爭(zhēng)
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操作對(duì)象換成自己的就好了。
基本步驟是
- 每次進(jìn)來(lái)先檢測(cè)一下這個(gè)key是否實(shí)現(xiàn)。如果失效了移除失效鎖
- 使用setnx原子命令爭(zhēng)搶鎖。
- 搶到鎖的設(shè)置過(guò)期時(shí)間。
步驟2為最核心的東西,
為啥設(shè)置步驟3?可能應(yīng)為獲取到鎖的線程出現(xiàn)什么移除請(qǐng)求,而無(wú)法釋放鎖,因此設(shè)置一個(gè)最長(zhǎng)鎖時(shí)間,避免死鎖。
為啥設(shè)置步驟1?redis可能在設(shè)置expire的時(shí)候掛掉。設(shè)置過(guò)期時(shí)間不成功,而出現(xiàn)鎖永久生效。
線上環(huán)境,步驟1、3的問(wèn)題都出現(xiàn)過(guò)。所以要做保底攔截。
redis集群部署
通常redis都是以master-slave解決單點(diǎn)問(wèn)題,多個(gè)master-slave組成大集群,然后通過(guò)一致性哈希算法將不同的key路由到不同master-slave節(jié)點(diǎn)上。
redis鎖的優(yōu)缺點(diǎn):
優(yōu)點(diǎn):redis本身是內(nèi)存操作、并且通常是多片部署,因此有這較高的并發(fā)控制,可以抗住大量的請(qǐng)求。
缺點(diǎn):redis本身是緩存,有一定概率出現(xiàn)數(shù)據(jù)不一致請(qǐng)求。
在線上,之前,利用redis做庫(kù)存計(jì)數(shù)器,獎(jiǎng)品發(fā)放理論上只發(fā)放10個(gè)的,最后發(fā)放了14個(gè)。出現(xiàn)了數(shù)據(jù)的一致性問(wèn)題。
因此在這之后,引入了mysql數(shù)據(jù)庫(kù)分布式鎖。
基于mysql實(shí)現(xiàn)的分布式鎖。
實(shí)現(xiàn)第一版
在此之前,在網(wǎng)上搜索了大量的文章,基本上都是 插入、刪除發(fā)的方式或是直接通過(guò)"select for update"這種形式獲取鎖、計(jì)數(shù)器。具體可以參考他山之石中的《分布式鎖的幾種實(shí)現(xiàn)方式~》關(guān)于數(shù)據(jù)庫(kù)鎖章節(jié)。
一開(kāi)始,我的實(shí)現(xiàn)方式偽代碼如下:
public boolean getLock(String key){
select for update
if (記錄存在){
update
}else {
insert
}
}
這樣實(shí)現(xiàn)出現(xiàn)了很嚴(yán)重的死鎖問(wèn)題,具體原因可以可以參考他山之石中的《select for update引發(fā)死鎖分析》
這個(gè)版本中存在如下幾個(gè)比較嚴(yán)重的問(wèn)題:
1.通常線上數(shù)據(jù)是不允許做物理刪除的
2.通過(guò)唯一鍵重復(fù)報(bào)錯(cuò),處理錯(cuò)誤形式是不太合理的。
3.如果appclient在處理中還沒(méi)釋放鎖之前就掛掉了,會(huì)出現(xiàn)鎖一直存在,出現(xiàn)死鎖。
4.如果以這種方式,實(shí)現(xiàn)redis中的計(jì)數(shù)器(incr decr),當(dāng)記錄不存在的時(shí)候,會(huì)出現(xiàn)大量死鎖的情況。
因此考慮引入,記錄狀態(tài)字段、中央鎖概念。
實(shí)現(xiàn)第二版
在第二版中完善了數(shù)據(jù)庫(kù)表設(shè)計(jì),參考如下:
-- 鎖表,單庫(kù)單表
CREATE TABLE IF NOT EXISTS test_db.t_lock (
-- 記錄index
Findex INT NOT NULL AUTO_INCREMENT COMMENT '自增索引id',
-- 鎖信息(key、計(jì)數(shù)器、過(guò)期時(shí)間、記錄描述)
Flock_name VARCHAR(128) DEFAULT '' NOT NULL COMMENT '鎖名key值',
Fcount INT NOT NULL DEFAULT 0 COMMENT '計(jì)數(shù)器',
Fdeadline DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '鎖過(guò)期時(shí)間',
Fdesc VARCHAR(255) DEFAULT '' NOT NULL COMMENT '值/描述',
-- 記錄狀態(tài)及相關(guān)事件
Fcreate_time DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '創(chuàng)建時(shí)間',
Fmodify_time DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '修改時(shí)間',
Fstatus TINYINT NOT NULL DEFAULT 1 COMMENT '記錄狀態(tài),0:無(wú)效,1:有效',
-- 主鍵(PS:總索引數(shù)不能超過(guò)5)
PRIMARY KEY (Findex),
-- 唯一約束
UNIQUE KEY uniq_Flock_name(Flock_name),
-- 普通索引
KEY idx_Fmodify_time(Fmodify_time)
)ENGINE=INNODB DEFAULT CHARSET=UTF8 COMMENT '鎖與計(jì)數(shù)器表';
在這個(gè)版本中,考慮到再條鎖并發(fā)插入存在死鎖(間隙鎖爭(zhēng)搶)情況,引入中央鎖概念。
基本方式是:
- 根據(jù)sql創(chuàng)建好數(shù)據(jù)庫(kù)
- 創(chuàng)建一條記錄Flock_name="center_lock"的記錄。
- 在對(duì)其他鎖(如Flock_name="sale_invite_lock")進(jìn)行操作的時(shí)候,先對(duì)"center_lock"記錄select for update
- "sale_invite_lock"記錄自己的增刪改查。
考慮到不同公司引入的數(shù)據(jù)庫(kù)操作包不同,因此提供偽代碼,以便于理解
偽代碼
// 開(kāi)啟事務(wù)
@Transactional
public boolean getLock(String key){
// 獲取中央鎖
select * from tbl where Flock_name="center_lock"
// 查詢key相關(guān)記錄
select for update
if (記錄存在){
update
}else {
insert
}
}
到此,該方案,能夠滿足我的分布式鎖的需求。
但是該方案,有一個(gè)比較致命的問(wèn)題,就是所有記錄共享一個(gè)鎖,并發(fā)并不高。
經(jīng)過(guò)測(cè)試,開(kāi)啟50*100個(gè)線程并發(fā)修改,5次耗時(shí)平均為8秒。
實(shí)現(xiàn)第三版
由于方案二,存在共享同一把中央鎖,并發(fā)不高的請(qǐng)求。參考concurrentHashMap實(shí)現(xiàn)原理,引入分段鎖概念,降低鎖粒度。
基本方式是:
- 根據(jù)sql創(chuàng)建好數(shù)據(jù)庫(kù)
- 創(chuàng)建100條記錄Flock_name="center_lock_xx"的記錄(xx為00-99)。
- 在對(duì)其他鎖(如Flock_name="sale_invite_lock")進(jìn)行操作的時(shí)候,根據(jù)crc32算法找到對(duì)應(yīng)的center_lock_02,先對(duì)"center_lock_02"記錄select for update
- "sale_invite_lock"記錄自己的增刪改查。
偽代碼如下:
// 開(kāi)啟事務(wù)
@Transactional
public boolean getLock(String key){
// 根據(jù)key計(jì)算哈希值
centerKey = "center_lock_xx";
// 獲取中央鎖
select * from tbl where Flock_name="center_lock_xx"
// 查詢key相關(guān)記錄
select for update
if (記錄存在){
update
}else {
insert
}
}
經(jīng)過(guò)測(cè)試,開(kāi)啟50*100個(gè)線程并發(fā)修改,5次耗時(shí)平均為5秒。相較于版本二幾乎有一倍的提升。
至此,完成redis/mysql分布式鎖、計(jì)數(shù)器的實(shí)現(xiàn)與應(yīng)用。
最后
根據(jù)不同應(yīng)用場(chǎng)景,做出如下選擇:
- 高并發(fā)、不保證數(shù)據(jù)一致性:redis鎖/計(jì)數(shù)器
- 低并發(fā)、保證數(shù)據(jù)一致性:mysql鎖/計(jì)數(shù)器
- 低并發(fā)、不保證數(shù)據(jù)一致性:你隨意
- 高并發(fā)。保證數(shù)據(jù)一致性:redis鎖/計(jì)數(shù)器 + mysql鎖/計(jì)數(shù)器。