在多執行緒環境下,為了保證資料的執行緒安全,鎖保證同一時刻,只有一個可以存取和更新共用資料。在單機系統我們可以使用synchronized
鎖或者Lock
鎖保證執行緒安全。synchronized
鎖是Java
提供的一種內建鎖,在單個JVM
程序中提供執行緒之間的鎖定機制,控制多執行緒並行。只適用於單機環境下的並行控制:
但是如果想要鎖定多個節點服務,synchronized
就不適用於了:
想要在多個節點中提供鎖定,在分散式系統並行控制共用資源,確保同一時刻
只有一個存取可以呼叫,避免多個呼叫者競爭呼叫和資料不一致問題,保證資料的一致性
。
分散式鎖就是控制分散式系統不同程序存取共用資源的一種鎖的機制。不同程序之間呼叫需要保持互斥性,任意時刻,只有一個使用者端能持有鎖。
從單體鎖到分散式鎖,只不過是將鎖的物件從一個程序的多個執行緒,轉成多個程序。
共用資源包含:
分散式鎖的加鎖和解鎖是使用不同的數值來表示不同的狀態,比如0
表示空閒狀態。
加鎖
1
表示已加鎖,返回成功。0
,則返回失敗,表示沒有獲取到鎖。解鎖
0
。以上的加鎖和解鎖操作,都要保證是一個原子操作
。
分散式鎖最基本的特性,同一時刻只能一個節點服務擁有該鎖,當有節點獲取鎖之後,其他節點無法獲取鎖,不同節點之間具有互斥性。
不考慮異常,正常情況下,請求獲取鎖之後,處理任務,處理完成之後釋放鎖。但是如果在處理任務發生服務異常,或者網路異常時,導致鎖無法釋放。其他請求都無法獲取鎖,變成死鎖。
為了防止鎖變成死鎖,需要設定鎖的超時時間。過了超時時間後,鎖自動釋放,其他請求能正常獲取鎖。
鎖設定了超時機制後,如果持有鎖的節點處理任務的時候過長超過了超時時間,就會發生執行緒未處理完任務鎖就被釋放了,其他執行緒就能獲取到該鎖,導致多個節點同時存取共用資源。對此,就需要延長超時時間。
開啟一個監聽執行緒,定時監聽任務,監聽任務執行緒還存活就延長超時時間。當任務完成、或者任務發生異常就不繼續延長超時時間。
分散式主要有三種實現:
通過模擬客戶下單操作。
先建立訂單表和商品庫存表:
--庫存表--
create table t_product(
`id` bigint(20) not null auto_increment,
`name` varchar(64) not null comment "商品名",
`store` int default 0 comment "庫存",
primary key(`id`)
)
insert into `t_product` values (1, '紅米手機', 100);
-- 訂單表 --
create table t_order(
`id` bigint(20) not null auto_increment,
`sn` varchar(64) not null comment '訂單號',
`num` int default null comment '數量',
`price` int default null comment '單價',
`product_id` bigint default null comment '商品id',
`create_time` timestamp not null default CURRENT_TIMESTAMP comment '建立時間',
primary key(`id`)
)
為了查詢時防止幻讀,我們還需要保證查詢和插入是在同一個事務中。下單先判斷是否有庫存,有庫存就減庫存1
,再新增訂單。主要程式碼如下:
@Transactional
public void addOrder(Order order) throws Exception {
Product product = productDao.selectById(order.getProductId());
int store = product.getStore() - 1;
if (store >= 0) {
// 扣庫存
product.setStore(store);
productDao.updateByPrimaryKey(product);
// 新增訂單
orderDao.insert(order);
} else {
throw new Exception("哎呦喂,庫存不足");
}
}
其中查詢庫存方法productDao.selectById
的SQL
語句是:
select id,name,store from t_product where id = xxx
使用壓測工具apache ab
開啟多個執行緒,請求50
次:
ab -n 10 -c 2 http://127.0.0.1:8080/xxxx
壓測結果:
庫存剩餘:72,訂單數量:50
**新增了 50 條訂單,庫存只扣了 28 **。
這是因為在並行環境下,多個執行緒下單操作,前面的執行緒還未更新庫存,後面的執行緒已經請求進來,並獲取到了未更新的庫存,後續扣減庫存都不是扣減最近的庫存。執行緒越多,扣減的庫存越少。這就是在高並行場景下發生的超賣問題。
Mysql
資料庫可以使用select xxx for update
來實現分散式鎖。
for update
是一種行級鎖
,也叫排它鎖
。如果一條select
語句後面加上for update
,其他事務可以讀取,但不能進進行更新操作。
將上面查詢庫存productDao.selectById
方法的SQL
語句後面加上for update
:
select id,name,store from t_product where id = xxx for update
再使用apache ab
開啟多個執行緒,請求50
次:
ab -n 10 -c 2 http://127.0.0.1:8080/xxxx
壓測結果:
庫存剩餘:50,訂單數量:50
資料庫成功實現分散式鎖
使用for update
行級鎖可以實現分散式鎖,通過行級鎖鎖住庫存,where
後條件一定要走索引,不然會觸發表鎖,會降低MySQL
的效能。
不過基於MySQL
實現的分散式鎖,存在效能瓶頸,在Repeatable read
隔離級別下select for update
操作是基於間隙鎖
鎖實現,這是一種悲觀鎖,會存線上程阻塞問題。
當有大量的執行緒請求的情況下,大部分請求會被阻塞等待,後續的請求只能等前面的請求結束後,才能排隊進來處理。
資料庫實現分散式鎖存在效能瓶頸,無法支撐高並行的請求。可以使用Zookeeper
實現分散式鎖,Zookeeper
提供一種分散式服務協調
的中心化服務,而分散式鎖的實現是基於Zookeeper
的兩個特性。
順序臨時節點:
Zookeeper
資料模型znode
是以多層節點命名的空間,每個節點都用斜槓/
分開的路徑來表示,類似檔案系統的目錄。
節點型別分成持久節點
和臨時節點
,每個節點還可以標記有序性。一旦節點被標記為有序性,那整個節點就有自動遞增的特點。利用以上的特性,建立一個持久節點作為父節點,在父節點下面建立一個臨時節點,並標記該臨時節點為有序性。
Watch 機制:
Zookeeper
還提供了另一個重要的特性:Watch
(事件監聽器),在指定節點的上註冊監聽事件。當事件觸發時,會將事件通知給對應的客戶。
瞭解了Zookeeper
的兩個特性之後,那如何使用這兩種特性來實現分散式鎖呢?
首先,建立一個持久型別的父節點,當用戶請求時,就在父節點建立臨時型別的子節點,並標記臨時節點為有序性。
建立子節點之後,對父節點下面所有臨時節點進行排序,判斷剛建立的臨時節點是否是最小的節點,如果是最小的節點,就獲取鎖。如果不最小的節點,則等待鎖,並且獲取該節點上一個順序節點,併為其註冊監聽事件,等待觸發事件並獲得鎖。
當請求完畢後,刪除該節點,並觸發監聽事件,下一個順序節點獲得鎖,流程如下所示:
curator
將上面實現分散式鎖的思路封裝好了,直接呼叫即可。
引入curator
依賴:
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>4.3.0</version>
</dependency>
使用InterProcessMutex
分散式可重入排它鎖,一般流程如下:
InterProcessMutex lock = new InterProcessMutex(client, lockPath);
// 加鎖
interProcessMutex.acquire();
// 執行程式碼xxxxxxx
// 解鎖
interProcessMutex.release();
為了避免每次請求都要建立InterProcessMutex
範例,建立InterProcessMutex
的bean
:
private String address = "xxxxx";
@Bean
public InterProcessMutex interProcessMutex() {
CuratorFramework zkClient = getZkClient();
String lockPath = "/lock";
InterProcessMutex lock = new InterProcessMutex(zkClient,lockPath);
return lock;
}
private CuratorFramework getZkClient() {
ExponentialBackoffRetry retry = new ExponentialBackoffRetry(1000,3,5000);
CuratorFramework zkClient = CuratorFrameworkFactory.builder()
.connectString(address)
.sessionTimeoutMs(5000)
.connectionTimeoutMs(5000)
.retryPolicy(retry).build();
zkClient.start();
return zkClient;
}
在高並行場景下,多個使用者請求系統,並獲取臨時節點順序:
使用interProcessMutex
獲取鎖和釋放鎖:
interProcessMutex.acquire()
interProcessMutex.release()
請求介面如下:
@RestController
public class Controller {
@Autowired
private InterProcessMutex interProcessMutex;
@GetMapping("/sec-kill")
public String secKill() throws Exception {
// 獲取鎖
interProcessMutex.acquire();
// 扣減庫存,建立訂單等操作....
interProcessMutex.release();
return "ok";
}
}
如果獲取鎖之後,系統發生異常,系統就一直持有鎖,後續請求也無法獲取鎖,導致死鎖。需要設定鎖超時機制,interProcessMutex.acquire
新增超時時間:
interProcessMutex.acquire(watiTime,TimeUnit);
超時時間設定要根據業務執行時間來設定,不能太長,也不能太短。
Zookeeper
一些特點
Zookeeper
實現的分散式鎖,相對資料庫,效能有很大的提高。Zookeeper
設定叢集,發生單點故障時、或者系統掛掉時,臨時節點會因為 session 連線斷開而自動刪除。watch
事件,對Zookeeper
服務來說壓力大。相對Redis
的效能,還存在差距。Redis 實現分散式鎖,是最複雜的,但是也是效能最高的。
SETNX key value
如果鍵不存在時,對鍵設值,返回1
。如果鍵存在,不做任何操作,返回0
。setnx
全稱是set if not exist
。DEL key
,通過刪除key
釋放鎖,刪除鍵之後,其他執行緒可以爭奪鎖。Redis
也需要考慮超時問題,一般都是用SETNX + EXPIRE
組合來實現超時設定,虛擬碼如下:
pubic boolean lock(Jedis jedis,String key,String value,long expireTime) {
long flag = jedis.setnx(key,value);
// 成功獲取鎖
if(flag) {
// 如果這裡突然崩潰,無法設定過期時間,將發生死鎖
jedis.expirt(key,expireTime);
return true;
}
return false;
}
通過setnx
方法獲取鎖,如果key
存在,就返回失敗。如果不存在,就設值成功,設值成功之後,再通過expirt
設定超時時間。
如果在設定超時時間和設定鎖之間出現系統崩潰,此時沒有給鎖設定過期時間,將會出現死鎖問題。
在Redis 2.6.12
版本後SETNX
增加了過期時間引數:
pubic boolean lock(Jedis jedis,String key,String value,long expireTime) {
long flag = jedis.setnx(key,value,expireTime);
// 成功獲取鎖
if(flag) {
return true;
}
return false;
}
解鎖,需要刪除鍵值即可,其他執行緒就能競爭鎖了:
pubic void lock(Jedis jedis,String key) {
jedis.del(key);
}
一般請求controlle
如下:
@RestController
public class Controller {
@Autowired
private InterProcessMutex interProcessMutex;
@GetMapping("/sec-kill")
public String secKill() throws Exception {
// 獲取鎖
lock();
// 扣減庫存,建立訂單等操作....
unLock();
return "ok";
}
}
Redis
設定了超時時間後,就解決死鎖的問題,但也會引發其他問題。
如果設定的超時時間比較短,而業務執行的時間比較長。比如超時時間設定5s
,而業務執行需要10s
,此時業務還未執行完,其他請求就會獲取到鎖,兩個請求同時請求業務資料,不滿足分散式鎖的互斥性
,無法保證執行緒的安全,如下流程所示:
超時解鎖導致並行:
使用者A
先獲取鎖,還未執行完業務程式碼,此時已經過了超時時間,鎖被釋放。使用者B
獲取到鎖,此時使用者A
和使用者B
並行執行業務資料,
鎖誤刪除:
使用者A
執行完業務程式碼後,執行釋放鎖操作,而此時使用者A
已經被超時釋放,鎖被使用者B
持有,此時釋放鎖,就把使用者B
的鎖誤刪了。
解決方案:
首先要將超時時間設定的長一些,滿足業務執行的時間。如果系統對吞吐量要求比較嚴格,根據具體的業務的執行時間來設定超時時間,超時時間比業務執行時間長一些,超時時間不能設定太長也不能設定太短。
針對鎖誤刪除的問題。每個執行緒在獲取鎖時,設定一個的執行緒標識,比如UUID
,作為唯一的標識,設定value
值,在解鎖時,先判斷是是否是自己執行緒的標識,如果不是,就不做刪除:
pubic void lock(Jedis jedis,String key,String value) {
if (jedis.get(key).equals(value)) {
jedis.del(key);
}
}
除了設定合理超時時間外,可能還有偶爾幾個執行緒執行業務程式碼,因為網路環境執行時間變長。這時候就需要再加一個執行緒,定時執行,自動續期鎖。
Redis
雖然作為分散式鎖來說,效能是最好的。但是也是最複雜的,上面總結Redis
主要有下面幾個問題:
線上都是用Redission
實現分散式鎖,Redisson
是一個在Redis
的基礎上實現的Java
駐記憶體資料網格(In-Memory Data Grid
)。它不僅提供了一系列的分散式的Java
常用物件,還提供了許多分散式服務。Redisson
是基於netty
通訊框架實現的,所以支援非阻塞通訊,效能優於Jedis
。
Redisson
分散式鎖四層保護:
Redisson
實現Redis
分散式鎖,支援單機和叢集模式,
引入maven
依賴:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.8.2</version>
</dependency>
新增Redission
設定:
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
// 單機模式
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setPassword("xxxx");
// 叢集模式
/*config.useClusterServers()
.setScanInterval(2000) // 叢集狀態掃描間隔時間,單位是毫秒
//可以用"rediss://"來啟用SSL連線
.addNodeAddress("redis://127.0.0.1:7000", "redis://127.0.0.1:7001")
.addNodeAddress("redis://127.0.0.1:7002");*/
return Redisson.create(config);
}
}
使用Redission
分散式鎖,分成三個步驟:
redissonClient.getLock("lock")
rLock.lock()
rLock.unlock()
請求controlle
範例如下:
@RestController
public class Controller {
@Autowired
private RedissonClient redissonClient;
@GetMapping("/sec-kill")
public String secKill() throws Exception {
// 獲取鎖
RLock rLock = redissonClient.getLock("lock");
// 加鎖
rLock.lock();
// 執行業務資料
// 解鎖
rLock.unlock();
return "ok";
}
}
Redission
實現的分散式鎖,直接呼叫,不需要鎖異常、超時並行、鎖刪除
等問題,它把處理上面的問題的程式碼都封裝好了,直接呼叫即可。
在單機模式下,Redis
發生單機故障,Redis master
宕機了該怎麼辦?是否將鎖轉移到slave
呢?答案是不行的,因為Redis
複製是非同步的,無法滿足鎖互斥性。
Redlock
演演算法可以解決上面的問題,在叢集模式下,Redission 使用Redlock演演算法,使用單機範例的方式順序獲取叢集下的鎖。如果請求超時,則認定該節點不可用。當獲取鎖的範例數超過半數時,則獲取鎖成功。如果獲取鎖失敗,即沒有獲取超過半數的範例,那麼久釋放所有節點的鎖。
Redission
通過看門狗的實現自動續期
的功能,當分散式鎖獲取到鎖後,對應的Redis
宕機了,會出現死鎖的狀態,為避免出現這種狀態,鎖一般會設定一個過期時間。預設是30s
,超過30s
後,會自動釋放鎖。
Redission
範例被關閉之前,不斷的延長鎖的有效期,拿到執行緒的鎖如果沒有完成業務操作,那麼看門狗會一直延長鎖的超時時間。預設情況下,每10s
延長一次超時時間,續期時間是30s
,可以通過Config.lockWatchdogTimeout
來設定。
Redisson
還提供了可以指定leaseTime
引數的加鎖方法來指定加鎖的時間。超過這個時間後鎖便自動解開了,不會延長鎖的有效期。
Mysql
使用排它鎖,select xxxx for update
。實現比較簡單,但是資料庫無法支撐大量請求存取,效能較差。Zookeeper
先建立一個持久型別
的節點,當多個執行緒請求時,在持久型別節點建立順序臨時節點
,先判斷自己是否是最小節點,如果是持有鎖,執行後續邏輯,如果不是就找到上一個順序節點,並新增watch
監聽事件。執行緒處理結束後。觸發監聽事件,通知下一個節點獲取鎖。Zookeeper
效能優於資料庫,但是頻繁的建立、刪除節點並且建立watch
監聽,對伺服器的壓力也大。Redis
實現分散式鎖效能是最優,也是最複雜的。SETNX key value
獲取鎖,如果key
存在,則成功獲取鎖,否則獲取鎖失敗。使用del
刪除節點釋放鎖。複雜在於需要平衡超時時間
和鎖續期
。不設定超時時間,會發生死鎖。設定了超時時間就可能出現業務處理時間大於超時時間,出現多個鎖同時存取共用資料,以及鎖誤刪的情況。解決方案是根據具體的業務時間設定合理的超時時間,鎖誤刪的話要給每個執行緒設定一個唯一的id
。此外,如果業務時間大於超時時間,開啟執行緒定時續約時間。Redis
實現分散式鎖存在的問題Redisson
提供瞭解決方案,Redisson
是一個在Redis
的基礎上實現的Java
駐記憶體資料網格(In-Memory Data Grid
),Redisson
是基於netty
通訊框架實現的,所以支援非阻塞通訊,效能也比較高。Redisson
有四種特性防死鎖
、防誤刪
、可重入
、自動續期
。支援單機和叢集模式。自動續期是使用watch dog
看門狗機制,在範例關閉前,不斷地延長鎖的有效期。預設10s
延遲一次,延長時間為30s
。