一、背景:單體架構中使用同步訪問解決多線程並發問題,分布式中需要有其他方案。
二、分布式鎖的考量:
1.可以保證在分布式部署的應用集群中,同一個方法在同一時間只能被一台機器-上的一個線程執行。
2.這把鎖要是一把可重入鎖(避免死鎖)
3.這把鎖最好是一把阻塞鎖(根據業務需求考慮要不要這條)
4.這把鎖最好是一把公平鎖(根據業務需求考慮要不要這條)
5.有高可用的獲取鎖和釋放鎖功能
保證了只有鎖的持有者才能來解鎖,否則任何競爭者都能解鎖
6.獲取鎖和釋放鎖的性能要好
7.如果做的好一點,需要有監控的平台。
三、分布式鎖的三種實現方式
1.基於數據庫實現排他鎖:利用version字段和for update操作獲取鎖。
優點:易於理解
問題:
(1)鎖沒有失效時間,解鎖失敗時(宕機等原因),其他線程獲取不到鎖。
解決:做一個定時任務實現自動釋放鎖。
(2)鎖屬於非阻塞,因為獲取鎖的是insert操作,一旦獲取失敗就報錯,沒有獲得鎖的線程並不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作。
解決:搞一個while循環,直到insert成功再返回成功。
(3)不是可重入鎖。
解決:加入鎖的機器字段,實現同一機器可重復加鎖。
另外在解鎖時,必須是鎖的持有者來解鎖,其他競爭者無法解鎖
(4)由於是數據庫,對性能要求高的應用不合適用此實現。
解決:數據庫本身特性決定。
(5)在 MySQL 數據庫中采用主鍵沖突防重,在大並發情況下有可能會造成鎖表現象。
解決:比較好的辦法是在程序中生產主鍵進行防重
(6)這把鎖是非公平鎖,所有等待鎖的線程憑運氣去爭奪鎖
解決:再建一張中間表,將等待鎖的線程全記錄下來,並根據創建時間排序,只有最先創建的允許獲取鎖。
(7)考慮到數據庫單點故障,需要實現數據庫的高可用。
注意:InnoDB 引擎在加鎖的時候,只有通過索引進行檢索的時候才會使用行級鎖,否則會使用表級鎖
另外存在問題:
(1)行級鎖並不一定靠譜:雖然我們對方法字段名使用了唯一索引,並且顯示使用 for update 來使用行級鎖。
但是,MySQL 會對查詢進行優化,即便在條件中使用了索引字段,但是否使用索引來檢索數據是由 MySQL 通過判斷不同執行計划的代價來決定的,
如果MySQL 認為全表掃效率更高,比如對一些很小的表,它就不會使用索引,這種情況下 InnoDB 將使用表鎖,而不是行鎖。這種情況是致命的。
(2)我們要使用排他鎖來進行分布式鎖的 lock,那么一個排他鎖長時間不提交,就會占用數據庫連接。
一旦類似的連接變得多了,就可能把數據庫連接池撐爆
2.基於redis實現(單機版):需要自己實現 一定要用 SET key value NX PX milliseconds 命令,而不要使用setnx 加expire
優點: 性能高、超時失效比數據庫簡單。
開源實現:Redis官方提出一種算法,叫Redlock,認為這種實現比普通的單實例實現更安全。
RedLock有多種語言的實現包,其中Java版本:Redisson。
缺點:
(1)失效時間無法把控。可能設置過短或者過長的情況.如果設置過短,其他線程可能會獲取到鎖,無法保證情況。過長時其他線程獲取不到鎖。
解決:Redisson的思路:客戶端起一個后台線程,快到期時自動續期,如果宕機了,后台線程也沒有了。
(2)如果采用 Master-Slave 模式,如果 Master 節點故障了,發生主從切換,主從切換的一瞬間,可能出現鎖丟失的問題。
解決:Redisson ,但存在爭議的,不過應該問題不大。
3.基於zookeeper實現(推薦):可靠性好,使用最廣泛。實現:Curator
4.基於etcd的實現:優於zookeeper實現,如果項目中應用了etcd,那么使用etcd。
5.Spring Integration 實現了分布式鎖:
Gemfire
JDBC
Redis
Zookeeper
基於數據庫實現排他鎖
方案1
獲取鎖
INSERT INTO method_lock (method_name, desc) VALUES ('methodName', 'methodName');
對method_name做了唯一性約束,這里如果有多個請求同時提交到數據庫的話,數據庫會保證只有一個操作可以成功。
方案2
1 DROP TABLE IF EXISTS `method_lock`; 2 CREATE TABLE `method_lock` ( 3 `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵', 4 `method_name` varchar(64) NOT NULL COMMENT '鎖定的方法名', 5 `state` tinyint NOT NULL COMMENT '1:未分配;2:已分配', 6 `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 7 `version` int NOT NULL COMMENT '版本號', 8 `PRIMARY KEY (`id`), 9 UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE 10 ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法';
先獲取鎖的信息
select id, method_name, state,version from method_lock where state=1 and method_name='methodName';
占有鎖
update t_resoure set state=2, version=2, update_time=now() where method_name='methodName' and state=1 and version=2;
如果沒有更新影響到一行數據,則說明這個資源已經被別人占位了。
缺點:
1、這把鎖強依賴數據庫的可用性,數據庫是一個單點,一旦數據庫掛掉,會導致業務系統不可用。
2、這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在數據庫中,其他線程無法再獲得到鎖。
3、這把鎖只能是非阻塞的,因為數據的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的線程並不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作。
4、這把鎖是非重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖。因為數據中數據已經存在了。
解決方案:
1、數據庫是單點?搞兩個數據庫,數據之前雙向同步。一旦掛掉快速切換到備庫上。
2、沒有失效時間?只要做一個定時任務,每隔一定時間把數據庫中的超時數據清理一遍。
3、非阻塞的?搞一個while循環,直到insert成功再返回成功。
4、非重入的?在數據庫表中加個字段,記錄當前獲得鎖的機器的主機信息和線程信息,那么下次再獲取鎖的時候先查詢數據庫,如果當前機器的主機信息和線程信息在數據庫可以查到的話,直接把鎖分配給他就可以了。
基於redis實現
獲取鎖:
SET resource_name my_random_value NX PX 30000
解鎖方式一:不可用。
if( (GET user_id) == "XXX" ){ //獲取到自己鎖后,進行取值判斷且判斷為真。此時,這把鎖恰好失效。
DEL user_id
}
由於GET取值判斷和DEL刪除並非原子操作,當程序判通過該鎖的值判斷發現這把鎖是自己加上的,准備DEL。
此時該鎖恰好失效,而另外一個請求恰好獲得key值為user_id的鎖。
此時程序執行了了DEL user_id,刪除了別人加的鎖,尷尬!
解鎖方式二(推薦):為了保證查詢和刪除的原子性操作,需要引入lua腳本支持。
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end
真實代碼:
/** * 獲取Jedis實例 * * @return */ protected synchronized static Jedis getJedis() { try { if (jedisPool != null) { Jedis resource = (Jedis) jedisPool.getResource(); return resource; } else { return null; } } catch (Exception e) { e.printStackTrace(); return null; } }
/** * 嘗試獲取分布式鎖 * @param jedis Redis客戶端 * @param lockKey 鎖 * @param requestId 請求唯一標識,可以用UUID * 目的:為了釋放鎖的時候,只能釋放自己的鎖而不能釋放別人的鎖。 * 場景:A任務在超時時間內沒有完成任務,超時釋放鎖后B成功獲取了鎖,待A任務執行完成釋放鎖會釋放B已獲取的鎖。 * 其實A還沒有處理完成,B已經獲取了鎖,此時A和B是同時執行的,已經是異常的了!不讓A釋放B的鎖,只是減少異常的進一步擴散。 * 所以,過期時間一定要大於正常業務處理時間。當然太長了也不行,因為怕宕機等情況一直長時間占用鎖影響業務。 * * @param expireSeconds 超期時間 :EX的單位是秒 * @return 是否獲取成功 */ public static boolean lock( String lockKey, String requestId, int expireSeconds) { String result = ""; if (jedisPool != null) { Jedis jedis = getJedis(); try { //NX,不存在才設置, EX表示單位秒 而 PX表示單位毫秒 result = jedis.set(lockKey, requestId, "NX", "EX", expireSeconds); }finally { returnResource(jedis); } }else{ result = jedisCluster.set(lockKey, requestId, "NX", "EX", expireSeconds); } if (LOCK_SUCCESS.equals(result)) { return true; } return false; } /** * 釋放分布式鎖 * @param jedis Redis客戶端 * @param lockKey 鎖 * @param requestId 請求標識 * @return 是否釋放成功 */ public static boolean releaseLock(String lockKey, String requestId) { Object result = null; String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; if (jedisPool != null) { Jedis jedis = getJedis(); try{ result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); } finally { returnResource(jedis); } }else{ result = jedisCluster.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); } if (RELEASE_SUCCESS.equals(result)) { return true; } return false; }
使用zookeeper實現分布式鎖
zookeeper分布式鎖應用了臨時順序節點
獲取鎖
首先,在Zookeeper當中創建一個持久節點ParentLock。當第一個客戶端想要獲得鎖時,需要在ParentLock這個節點下面創建一個臨時順序節點 Lock1。
之后,Client1查找ParentLock下面所有的臨時順序節點並排序,判斷自己所創建的節點Lock1是不是順序最靠前的一個。如果是第一個節點,則成功獲得鎖。
這時候,如果再有一個客戶端 Client2 前來獲取鎖,則在ParentLock下載再創建一個臨時順序節點Lock2。
Client2查找ParentLock下面所有的臨時順序節點並排序,判斷自己所創建的節點Lock2是不是順序最靠前的一個,結果發現節點Lock2並不是最小的。
於是,Client2向排序僅比它靠前的節點Lock1注冊Watcher,用於監聽Lock1節點是否存在。這意味着Client2搶鎖失敗,進入了等待狀態。
這時候,如果又有一個客戶端Client3前來獲取鎖,則在ParentLock下載再創建一個臨時順序節點Lock3。
Client3查找ParentLock下面所有的臨時順序節點並排序,判斷自己所創建的節點Lock3是不是順序最靠前的一個,結果同樣發現節點Lock3並不是最小的。
於是,Client3向排序僅比它靠前的節點Lock2注冊Watcher,用於監聽Lock2節點是否存在。這意味着Client3同樣搶鎖失敗,進入了等待狀態。
這樣一來,Client1得到了鎖,Client2監聽了Lock1,Client3監聽了Lock2。這恰恰形成了一個等待隊列,很像是Java當中ReentrantLock所依賴的AQS(AbstractQueuedSynchronizer)。
獲得鎖的過程大致就是這樣,那么Zookeeper如何釋放鎖呢?
釋放鎖的過程很簡單,只需要釋放對應的子節點就好。
釋放鎖
釋放鎖分為兩種情況:
1.任務完成,客戶端顯示釋放
當任務完成時,Client1會顯示調用刪除節點Lock1的指令。
2.任務執行過程中,客戶端崩潰
獲得鎖的Client1在任務執行過程中,如果Duang的一聲崩潰,則會斷開與Zookeeper服務端的鏈接。根據臨時節點的特性,相關聯的節點Lock1會隨之自動刪除。
由於Client2一直監聽着Lock1的存在狀態,當Lock1節點被刪除,Client2會立刻收到通知。這時候Client2會再次查詢ParentLock下面的所有節點,確認自己創建的節點Lock2是不是目前最小的節點。如果是最小,則Client2順理成章獲得了鎖。
同理,如果Client2也因為任務完成或者節點崩潰而刪除了節點Lock2,那么Client3就會接到通知。
最終,Client3成功得到了鎖。