分散式鎖的實現方案
什麼是分散式鎖
當多個程序在同一個系統中,用分散式鎖控制多個程序對資源的訪問
分散式鎖應用場景
- 傳統的單體應用單機部署情況下,可以使用 java 併發處理相關的 API 進行互斥控制。
- 分散式系統後由於多執行緒,多程序分佈在不同機器上,使單機部署情況下的併發控制鎖策略失效,為了解決跨 JVM 互斥機制來控制共享資源的訪問,這就是分散式鎖的來源;分散式鎖應用場景大都是高併發、大流量場景。
分散式鎖實現
1、基於 redis 的分散式鎖
redis 分散式鎖的實現
加鎖機制:根據 hash 節點選擇一個客戶端執行 lua 指令碼
鎖互斥機制:再來一個客戶端執行同樣的 lua 指令碼會提示已經存在鎖,然後進入迴圈一直嘗試加鎖
可重入機制
watch dog 自動延期機制
釋放鎖機制
測試用例
單機
private RedissonClient getClient(){
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");//.setPassword("");//.setConnectionMinimumIdleSize(10).setConnectionPoolSize(10);//.setConnectionPoolSize();//172.16.10.164
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
private ExecutorService executorService = Executors.newCachedThreadPool();
@Test
public void test() throws Exception {
int[] count = {0};
for (int i = 0; i < 10; i++) {
RedissonClient client = getClient();
final RedisLock redisLock = new RedisLock(client,"lock_key");
executorService.submit(() -> {
try {
redisLock.lock();
count[0]++;
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
redisLock.unlock();
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.HOURS);
System.out.println(count[0]);
}
RedLock
public static RLock create (String url, String key){
Config config = new Config();
config.useSingleServer().setAddress(url);
RedissonClient redissonClient = Redisson.create(config);
return redissonClient.getLock(key);
}
RedissonRedLock redissonRedLock = new RedissonRedLock(
create("redis://redis://127.0.0.1:6379","lock_key1"),
create("redis://redis://127.0.0.1:6380","lock_key2"),
create("redis://redis://127.0.0.1:6381","lock_key3"));
RedisRedLock redLock = new RedisRedLock(redissonRedLock);
private ExecutorService executorService = Executors.newCachedThreadPool();
@Test
public void test() throws Exception {
int[] count = {0};
for (int i = 0; i < 2; i++) {
executorService.submit(() -> {
try {
redLock.lock();
count[0]++;
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
redLock.unlock();
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.HOURS);
System.out.println(count[0]);
}
2、基於 ETCD 實現分散式鎖分析
ETCD 分散式鎖的實現
Lease 機制:租約機制(TTL,Time To Live),Etcd 可以為儲存的 key-value 對設定租約,
當租約到期,key-value 將失效刪除;同時也支援續約,透過客戶端可以在租約到期之前續約,
以避免 key-value 對過期失效。Lease 機制可以保證分散式鎖的安全性,為鎖對應的 key 配置租約,
即使鎖的持有者因故障而不能主動釋放鎖,鎖也會因租約到期而自動釋放。Revision 機制:每個 key 帶有一個 Revision 號,每進行一次事務加一,它是全域性唯一的,
透過 Revision 的大小就可以知道進行寫操作的順序。在實現分散式鎖時,多個客戶端同時搶鎖,
根據 Revision 號大小依次獲得鎖,可以避免 “羊群效應” ,實現公平鎖。Prefix 機制:即字首機制。例如,一個名為 /etcdlock 的鎖,兩個爭搶它的客戶端進行寫操作,
實際寫入的 key 分別為:key1="/etcdlock/UUID1",key2="/etcdlock/UUID2",
其中,UUID 表示全域性唯一的 ID,確保兩個 key 的唯一性。寫操作都會成功,但返回的 Revision 不一樣,
那麼,如何判斷誰獲得了鎖呢?透過字首 /etcdlock 查詢,返回包含兩個 key-value 對的的 KeyValue 列表,
同時也包含它們的 Revision,透過 Revision 大小,客戶端可以判斷自己是否獲得鎖。Watch 機制:即監聽機制,Watch 機制支援 Watch 某個固定的 key,也支援 Watch 一個範圍(字首機制),
當被 Watch 的 key 或範圍發生變化,客戶端將收到通知;在實現分散式鎖時,如果搶鎖失敗,
可透過 Prefix 機制返回的 KeyValue 列表獲得 Revision 比自己小且相差最小的 key(稱為 pre-key),
對 pre-key 進行監聽,因為只有它釋放鎖,自己才能獲得鎖,如果 Watch 到 pre-key 的 DELETE 事件,
則說明 pre-key 已經釋放,自己已經持有鎖。
基於 ETCD 分散式鎖
步驟 1、建立連線
客戶端連線 Etcd,以 /etcd/lock 為字首建立全域性唯一的 key,
假設第一個客戶端對應的 key="/etcd/lock/UUID1",第二個為 key="/etcd/lock/UUID2";
客戶端分別為自己的 key 建立租約 - Lease,租約的長度根據業務耗時確定;
步驟 2、建立定時任務作為租約的 “心跳”
當一個客戶端持有鎖期間,其它客戶端只能等待,為了避免等待期間租約失效,客戶端需建立一個定時任務作為 “心跳” 進行續約。此外,如果持有鎖期間客戶端崩潰,心跳停止,key 將因租約到期而被刪除,從而鎖釋放,避免死鎖。
步驟 3、客戶端將自己全域性唯一的 key 寫入 Etcd
執行 put 操作,將步驟 1 中建立的 key 繫結租約寫入 Etcd,根據 Etcd 的 Revision 機制,假設兩個客戶端 put 操作返回的 Revision 分別為 1、2,客戶端需記錄 Revision 用以接下來判斷自己是否獲得鎖
步驟 4、客戶端判斷是否獲得鎖
客戶端以字首 /etcd/lock/ 讀取 keyValue 列表,判斷自己 key 的 Revision 是否為當前列表中最小的,如果是則認為獲得鎖;否則監聽列表中前一個 Revision 比自己小的 key 的刪除事件,一旦監聽到刪除事件或者因租約失效而刪除的事件,則自己獲得鎖。
步驟 5、執行業務
獲得鎖後,操作共享資源,執行業務程式碼
步驟 6、釋放鎖
完成業務流程後,刪除對應的 key 釋放鎖
測試用例
public class EtcdDistributeLock extends AbstractLock{
private Client client;
private Lock lockClient;
private Lease leaseClient;
private String lockKey;
private String lockPath;
/** 鎖的次數 */
private AtomicInteger lockCount;
/** 租約有效期,防止客戶端崩潰,可在租約到期後自動釋放鎖;另一方面,正常執行過程中,會自動進行續租,單位 ns */
private Long leaseTTL;
/** 續約鎖租期的定時任務,初次啟動延遲,單位預設為 s,預設為1s,可根據業務定製設定*/
private Long initialDelay = 0L;
/** 定時任務執行緒池類 */
ScheduledExecutorService service = null;
/** 儲存執行緒與鎖物件的對映,鎖物件包含重入次數,重入次數的最大限制為Int的最大值 */
private final ConcurrentMap<Thread, LockData> threadData = Maps.newConcurrentMap();
public EtcdDistributeLock(){}
public EtcdDistributeLock(Client client, String lockKey, long leaseTTL,TimeUnit unit){
this.client = client;
lockClient = client.getLockClient();
leaseClient = client.getLeaseClient();
this.lockKey = lockKey;
// 轉納秒
this.leaseTTL = unit.toNanos(leaseTTL);
service = Executors.newSingleThreadScheduledExecutor();
}
@Override
public void lock() {
// 檢查重入性
Thread currentThread = Thread.currentThread();
LockData oldLockData = threadData.get(currentThread);
if (oldLockData != null && oldLockData.isLockSuccess()) {
// re-entering
int lockCount = oldLockData.lockCount.incrementAndGet();
if(lockCount < 0 ){
throw new Error("超出可重入次數限制");
}
return;
}
// 記錄租約 ID
Long leaseId = 0L;
try{
leaseId = leaseClient.grant(TimeUnit.NANOSECONDS.toSeconds(leaseTTL)).get().getID();
// 續租心跳週期
long period = leaseTTL - leaseTTL / 5;
// 啟動定時任務續約
service.scheduleAtFixedRate(new EtcdDistributeLock.KeepAliveRunnable(leaseClient, leaseId),
initialDelay,period,TimeUnit.NANOSECONDS);
LockResponse lockResponse = lockClient.lock(ByteSequence.from(lockKey.getBytes()), leaseId).get();
if(lockResponse != null){
lockPath = lockResponse.getKey().toString(Charset.forName("utf-8"));
log.info("獲取鎖成功,鎖路徑:{},執行緒:{}",lockPath,currentThread.getName());
}
}catch (InterruptedException | ExecutionException e){
log.error("獲取鎖失敗",e);
return;
}
// 獲取鎖成功,鎖物件設定
LockData newLockData = new LockData(currentThread, lockKey);
newLockData.setLeaseId(leaseId);
newLockData.setService(service);
threadData.put(currentThread, newLockData);
newLockData.setLockSuccess(true);
}
@Override
public void lockInterruptibly() throws InterruptedException {
super.lockInterruptibly();
}
@Override
public boolean tryLock() {
return super.tryLock();
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return super.tryLock(time,unit);
}
@Override
public void unlock() {
Thread currentThread = Thread.currentThread();
LockData lockData = threadData.get(currentThread);
if (lockData == null){
throw new IllegalMonitorStateException("You do not own the lock: " + lockKey);
}
int newLockCount = lockData.lockCount.decrementAndGet();
if ( newLockCount > 0 ) {
return;
}
if ( newLockCount < 0 ) {
throw new IllegalMonitorStateException("Lock count has gone negative for lock: " + lockKey);
}
try {
// 釋放鎖
if(lockPath != null){
lockClient.unlock(ByteSequence.from(lockPath.getBytes())).get();
}
if(lockData != null){
// 關閉定時任務
lockData.getService().shutdown();
// 刪除租約
if (lockData.getLeaseId() != 0L) {
leaseClient.revoke(lockData.getLeaseId());
}
}
} catch (InterruptedException | ExecutionException e) {
log.error("解鎖失敗",e);
}finally {
// 移除當前執行緒資源
threadData.remove(currentThread);
}
}
@Override
public Condition newCondition() {
return super.newCondition();
}
/**
* 心跳續約執行緒類
*/
public static class KeepAliveRunnable implements Runnable {
private Lease leaseClient;
private long leaseId;
public KeepAliveRunnable(Lease leaseClient, long leaseId) {
this.leaseClient = leaseClient;
this.leaseId = leaseId;
}
@Override
public void run() {
// 對該leaseid進行一次續約
leaseClient.keepAliveOnce(leaseId);
}
}
public class EtcdLockTest {
private Client client;
private String key = "/etcd/lock";
private static final String server = "http://xxxx:xxxx";
private ExecutorService executorService = Executors.newFixedThreadPool(10000);
@Before
public void before() throws Exception {
initEtcdClient();
}
private void initEtcdClient(){
client = Client.builder().endpoints(server).build();
}
@Test
public void testEtcdDistributeLock() throws InterruptedException {
int[] count = {0};
for (int i = 0; i < 100; i++) {
executorService.submit(() -> {
final EtcdDistributeLock lock = new EtcdDistributeLock(client, key,20,TimeUnit.SECONDS);
try {
lock.lock();
count[0]++;
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
lock.unlock();
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.HOURS);
System.err.println("執行結果: " + count[0]);
}
}
3、基於 Zookeeper 分散式鎖
實現原理
- 啟動客戶端,確認連結到了伺服器
- 多個客戶端併發的在特定路徑下建立臨時性順序節點
- 客戶端判斷自己的建立的順序節點是否是最小的,如果是最小的,則獲取鎖成功
- 第三步若判定失敗,則採用 zk 的 watch 機制監聽自己的前一個順序節點,等待前一個節點的刪除(放鎖)事件,再開始第三步判定。
zookeeper 作為高效能分散式協調框架,可以把其看做一個檔案系統,其中有節點的概念,並且分為 4 種:1.永續性節點 2.永續性順序節點 3.臨時性節點 4.臨時性順序節點。
分散式鎖的實現主要思路就是:監控其他客戶端的狀態,來判斷自己是否可以獲得鎖。
採用臨時性順序節點的原因:
- zk 伺服器維護了客戶端的會話有效性,當會話失效的時候,其會話所建立的臨時性節點都會被刪除,透過這一特點,可以透過 watch 臨時節點來監控其他客戶端的情況,方便自己做出相應動作。
- 因為 zk 對寫操作是順序性的,所以併發建立的順序節點會有一個唯一確定的序號,當前鎖是公平鎖的一種實現,所以依靠這種順序性可以很好的解釋—節點序列小的獲取到鎖並且可以採用 watch 自己的前一個節點來避免驚群現象(這樣 watch 事件的傳播是線性的)。
測試用例
public class ZKLock extends AbstractLock {
/**
* 1.Connect to zk
*/
private CuratorFramework client;
private InterProcessLock lock ;
public ZKLock(String zkAddress,String lockPath) {
// 1.Connect to zk
client = CuratorFrameworkFactory.newClient(
zkAddress,
new RetryNTimes(5, 5000)
);
client.start();
if(client.getState() == CuratorFrameworkState.STARTED){
log.info("zk client start successfully!");
log.info("zkAddress:{},lockPath:{}",zkAddress,lockPath);
}else{
throw new RuntimeException("客戶端啟動失敗。。。");
}
this.lock = defaultLock(lockPath);
}
private InterProcessLock defaultLock(String lockPath ){
return new InterProcessMutex(client, lockPath);
}
@Override
public void lock() {
try {
this.lock.acquire();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public boolean tryLock() {
boolean flag ;
try {
flag=this.lock.acquire(0,TimeUnit.SECONDS);
} catch (Exception e) {
throw new RuntimeException(e);
}
return flag;
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
boolean flag ;
try {
flag=this.lock.acquire(time,unit);
} catch (Exception e) {
throw new RuntimeException(e);
}
return flag;
}
@Override
public void unlock() {
try {
this.lock.release();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
private ExecutorService executorService = Executors.newCachedThreadPool();
@Test
public void testLock() throws Exception{
ZKLock zkLock = new ZKLock("xxxx:xxxx","/lockPath");
int[] num = {0};
long start = System.currentTimeMillis();
for(int i=0;i<200;i++){
executorService.submit(()->{
try {
zkLock.lock();
num[0]++;
} catch (Exception e){
throw new RuntimeException(e);
} finally {
zkLock.unlock();
}
});
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.HOURS);
log.info("耗時:{}",System.currentTimeMillis()-start);
System.out.println(num[0]);
}
總結
- redis 的分散式鎖中 redisson 一般為單例項,當單例項不可用時,會阻塞業務流程。主從方式、主從資料非同步,會存在鎖失效的問題。RedLock 一般要求至少 3 臺以上的 redis 主從例項,維護成本相對來說比較高。
- ZK 鎖具備高可用、可重入、阻塞鎖特性,可解決失效死鎖問題。但是因為需要頻繁的建立和刪除節點,效能上不如 Redis 方式。
- ETCD 分散式鎖的實現原理與 zk 鎖類似,但是 ETCD 分散式鎖更加可靠強大。其 Lease 功能保證分散式鎖的安全性;watch 功能支援監聽某個固定的 key,也支援 watch 一個範圍的 key(字首機制);revision 功能可透過 Revision 的大小就可以知道進行寫操作的順序。可以避免 “羊群效應”(也稱 “驚群效應”),實現公平鎖。字首機制與 watch 功能配合使用解決了死鎖問題。總之 ETCD 的靈感來源於 Zookeeper,但實現的時候做了很多的改進,如:高負載下的穩定讀寫、資料模型的多版本併發控制、穩定的 watch 功能,通知訂閱者監聽值得變化、可以容忍腦裂現場的發生、客戶端的協議使用 gRPC 協議,支援 go、c++、java 等。