文章目錄
- 1. 一些概念
- 2. MySQL方案
- 2.1 方案一:事務特性
- 2.1.1 存在的問題
- 2.1.2 解決方案
- 2.2 方案二:樂觀鎖
- 2.3 方案三:悲觀鎖
- 3. Redis
- 3.1 實現原理
- 3.2 實現細節
- 3.2.1 問題1:持有期間鎖過期問題
- 3.2.2 問題2:判斷和釋放鎖間隙中鎖過期
- 4. Zookeeper
1. 一些概念
分布式鎖的要求:互斥(同一時刻只能一個服務實例的一個線程持有),高可用(單節點掛了其他結點頂上),高性能(讀取速度快),安全性(避免死鎖)
分布式鎖的實現:mysql(本身具備互斥性,具備高可用,讀寫性能一般,斷開連接自動釋放鎖),redis(使用setnx實現互斥,具備高可用,讀寫性能高,使用過期時間來釋放鎖),zookeeper(使用結點唯一性或者順序性實現互斥,具備高可用,讀寫性能一般,斷開連接釋放鎖)。綜合來看redis性能好可用性高,安全性略差,mysql和zookeeper安全性好,性能略差。
2. MySQL方案
2.1 方案一:事務特性
MySQL默認開啟自動提交模式,即客戶端執行的每一條 SQL 語句都會被當作一個獨立的事務,一旦語句執行完畢,就會自動提交。事務具有ACID特性,因而可以使用MySQL實現分布式鎖。
create table lock_table
(id int auto_increment comment 'primary key',value varchar(64) null comment 'resource which need be locked',constraint lock_table_pk primary key (id)
);create unique index lock_table_value_uindex
on lock_table (value);-- 注意:value字段是臨界資源。插入成功則獲得鎖,刪除記錄則釋放鎖。一個事務在執行插入時,其他事務插入時失敗。
update lock_table set value = #{newValue} where id = #{id};
2.1.1 存在的問題
- 鎖不可重入。可重入鎖的場景:在遞歸代碼中訪問臨界資源會重復請求鎖,可重入鎖可以重復請求鎖阻塞;在復雜的調用關系中使用可重入鎖來防范死鎖問題。
- 沒有過期時間,當釋放鎖失敗時會帶來死鎖問題。
- 申請鎖失敗時不會阻塞。
- 高度依賴數據庫的可用性。
2.1.2 解決方案
- 可重入:給表lock_table新增count字段和請求id字段,當同樣的請求到來時只增加count而不是新增記錄。
- 給表lock_table新增expire_time字段,通過定時任務定期清理過期的lock
- 使用while循環來創造阻塞效果。
- 創建備用數據,避免單節點數據庫,提高可用性。
評價:大量事務經常競爭鎖影響性能,一般不使用這個方法。
2.2 方案二:樂觀鎖
樂觀鎖:假設取數據時其他人不會修改數據,修改數據時檢查是否已經有人修改數據。樂觀鎖可以使用CAS(Compare And Set)算法實現。
create table lock_table
(id int auto_increment comment 'primary key',value varchar(64) null comment 'locked resource',version int default 0 null comment 'version'constraint lock_table_pk primary key (id)
);
/*
update loss
MySQL默認隔離等級為repeatable read,同一事務內并發的其他事務不能修改數據,因而可以多次讀出相同的數據。但是修改數據時,其他并發事務可能搶先一步修改了數據(已經提交),這導致從"old_value"到“new_value”的修改實際上是從"unkonwn_value"到"new_value"的修改。
*/-- 樂觀鎖應對update loss
update lock_table set value = #{newValue}, version = #{version} + 1 where id = #{id} and version = #{version};
評價:不依賴數據庫本身的設計,性能差;需要version字段,造成數據庫冗余設計;高并發場景下version字段頻繁變動,系統可用性受到影響。適合于多讀少些低并發的場景。
2.3 方案三:悲觀鎖
# 注意關閉自動提交:autocommit=0# 實現1:使用S鎖,并發事務可以通過加S鎖實現共讀,臨界資源上有S鎖則不許加X鎖。并發事務更新臨界資源時需要加X鎖(update操作默認加X鎖),只有有一個事務得到X鎖。允許共讀,有讀不寫,寫不可讀,只有一寫。
select id, value from lock_table where id = #{id} lock in share mode;
update lock_table set value = #{newValue} where id = #{id};# 實現2:使用X鎖,只有拿到X鎖才能讀,只有拿到X鎖才能寫。每次只許一個事務讀寫。
select id, value from lock_table where id = #{id} for update;
update lock_table set value = #{newValue} where id = #{id};
評價:每個數據請求都加鎖,高并發環境下大量請求獲取不到鎖會陷入阻塞,影響系統性能。表數據量小,MySQL查詢不走索引,因而可能觸發表鎖而不是行鎖,影響并發性能。
3. Redis
3.1 實現原理
setnx只有在key不存在的時候才執行,key存在則執行失敗。這意味著多個并發執行只會有一個成功,這個特點適合用來實現分布式鎖。
# Set the string value of a key only when the key doesn't exist.
SETNX key value# setnx and set expire time are two operations, use set command instead can do all stuff with one operation
SET key value [EX seconds][PX milliseconds][NX|XX]# parameter type
EX seconds: Set expiration time in seconds
PX milliseconds: Set expiration time in milliseconds
NX: Set the value only when the key does not exist
XX: Set the value only when the key exists# release lock
DEL key
3.2 實現細節
3.2.1 問題1:持有期間鎖過期問題
例如,線程1獲取了鎖,其因業務阻塞而長時間持有,結果鎖超時釋放,線程2隨后成功獲取鎖,線程1業務指向完畢會釋放線程2的鎖。
解決1:延長鎖的expire time。使用redis工具Redisson,它可以使用watchdog技術來延時釋放鎖。
解決2:線程釋放鎖時先判斷要釋放的鎖是否是自己的鎖,是再釋放。
KEY_PREFIX = 'lock'
ID_PREFIX = uuid;
LOCK_NAME = bussiness_name # 和業務相關key = KEY_PREFIX + ':' + LOCK_NAME # lock和業務相關,建議key為lock:name形式def get_current_reqt_id():cur_reqt_id = get_thread_id()reqt_id = ID_PREFIX + '-' + thread_iddef try_lock(key):reqt_id= get_current_reqt_id(reqt_id) # value為持有者唯一標識,此處用uuid + 線程id作為值r = redis_client.get(key)if r:return Truereturn Falsedef unlock(key):cur_reqt_id = get_current_reqt_id()reqt_id = redis_client.get(key)result = Falseif cur_reqt_id == reqt_id:redis_client.del(key)result = Truereturn result
3.2.2 問題2:判斷和釋放鎖間隙中鎖過期
例如,線程1要釋放鎖,先判斷鎖是否為線程1持有,判斷為真,然后線程1執行釋放鎖操作。判斷操作和釋放操作間隔較長,鎖自動釋放。線程2在線程1判斷操作后,且鎖被自動釋放后成功獲取了鎖。接著線程1執行釋放鎖操作,則線程1釋放掉線程2的鎖。
解決:使用lua腳本將判斷鎖和釋放鎖打包成原子操作。注意最好將lua腳本加載到內存,方便每次調用直接使用而不是讀文件后再操作。
# 使用redis的EVAL命令來執行lua腳本
# Executes a server-side Lua script.
EVAL script numkeys [key [key ...]] [arg [arg ...]]'''
腳本中
KEYS[1] 代表傳遞給腳本的第一個鍵名參數
ARGS[1] 代表傳遞給腳本的第一個參數值
命令中
1 指示參數個數
name 實際傳遞給腳本的第一個鍵名參數
xxx 實際代表傳遞給腳本的第一個
'''
EVAL "return redis.call('set', KEYS[1], ARGS[1])" 1 name xxx# 釋放鎖lua腳本
if(redis.call('get', KEYS[1]) == ARGS[1]) thenreturn redis.call('del', KEYS[1])
end
return 0
4. Zookeeper
待更新