[TOC]
# 簡單實現
## 命令
使用Redis的 SETNX 命令可以實現分布式鎖,下文介紹其實現方法。
SETNX命令簡介
命令格式
SETNX key value
將 key 的值設為 value,當且僅當 key 不存在。
若給定的 key 已經存在,則 SETNX 不做任何動作。
SETNX 是SET if Not eXists的簡寫。
返回值
返回整數,具體為
- 1,當 key 的值被設置
- 0,當 key 的值沒被設置
~~~
redis> SETNX mykey “hello”
(integer) 1
redis> SETNX mykey “hello”
(integer) 0
redis> GET mykey
“hello”
redis>
~~~
## 使用SETNX實現分布式鎖
多個進程執行以下Redis命令:
~~~
SETNX lock.foo <current Unix time + lock timeout + 1>
~~~
如果 SETNX 返回1,說明該進程獲得鎖,SETNX將鍵 lock.foo 的值設置為鎖的超時時間(當前時間 + 鎖的有效時間)。
如果 SETNX 返回0,說明其他進程已經獲得了鎖,進程不能進入臨界區。進程可以在一個循環中不斷地嘗試 SETNX 操作,以獲得鎖。
然而,鎖超時時,我們不能簡單地使用 DEL 命令刪除鍵 lock.foo 以釋放鎖。考慮以下情況,進程P1已經首先獲得了鎖 lock.foo,然后進程P1掛掉了。進程P2,P3正在不斷地檢測鎖是否已釋放或者已超時,執行流程如下:
* P2和P3進程讀取鍵 lock.foo 的值,檢測鎖是否已超時(通過比較當前時間和鍵 lock.foo 的值來判斷是否超時)
* P2和P3進程發現鎖 lock.foo 已超時
* P2執行 DEL lock.foo命令
* P2執行 SETNX lock.foo命令,并返回1,即P2獲得鎖
* P3執行 DEL lock.foo命令將P2剛剛設置的鍵 lock.foo 刪除(這步是由于P3剛才已檢測到鎖已超時)
* P3執行 SETNX lock.foo命令,并返回1,即P3獲得鎖
* P2和P3同時獲得了鎖
從上面的情況可以得知,在檢測到鎖超時后,進程不能直接簡單地執行 DEL 刪除鍵的操作以獲得鎖。
為了解決上述算法可能出現的多個進程同時獲得鎖的問題,我們再來看以下的算法。
我們同樣假設進程P1已經首先獲得了鎖 lock.foo,然后進程P1掛掉了。接下來的情況:
* 進程P4執行 SETNX lock.foo 以嘗試獲取鎖
* 由于進程P1已獲得了鎖,所以P4執行 SETNX lock.foo 返回0,即獲取鎖失敗
* P4執行 GET lock.foo 來檢測鎖是否已超時,如果沒超時,則等待一段時間,再次檢測
* 如果P4檢測到鎖已超時,即當前的時間大于鍵 lock.foo 的值,P4會執行以下操作
* GETSET lock.foo <current Unix timestamp + lock timeout + 1>
* 由于 GETSET 操作在設置鍵的值的同時,還會返回鍵的舊值,通過比較鍵 lock.foo 的舊值是否小于當前時間,可以判斷進程是否已獲得鎖
* 假如另一個進程P5也檢測到鎖已超時,并在P4之前執行了 GETSET 操作,那么P4的 GETSET 操作返回的是一個大于當前時間的時間戳,這樣P4就不會獲得鎖而繼續等待。注意到,即使P4接下來將鍵 lock.foo 的值設置了比P5設置的更大的值也沒影響。
另外,值得注意的是,在進程釋放鎖,即執行 DEL lock.foo 操作前,需要先判斷鎖是否已超時。如果鎖已超時,那么鎖可能已由其他進程獲得,這時直接執行 DEL lock.foo 操作會導致把其他進程已獲得的鎖釋放掉。
## 程序代碼
用以下python代碼來實現上述的使用 SETNX 命令作分布式鎖的算法。
~~~
LOCK_TIMEOUT = 3
lock = 0
lock_timeout = 0
lock_key = 'lock.foo'
# 獲取鎖
while lock != 1:
now = int(time.time())
lock_timeout = now + LOCK_TIMEOUT + 1
lock = redis_client.setnx(lock_key, lock_timeout)
if lock == 1 or (now > int(redis_client.get(lock_key))) and now > int(redis_client.getset(lock_key, lock_timeout)):
break
else:
time.sleep(0.001)
# 已獲得鎖
do_job()
# 釋放鎖
now = int(time.time())
if now < lock_timeout:
redis_client.delete(lock_key)
~~~
# 配合lua實現
## 簡介
在分布式系統當中, Redis鎖是一個很常用的工具. 舉個很常見的例子就是: 某個接口需要去查詢數據庫的數據, 但是請求量卻又很大, 所以我們一般會加一層緩存, 并且設定過期時間. 但是這里存在一個問題就是當并發量很大的情況下, 在緩存過期的瞬間, 會有大量的請求穿透去數據庫請求數據, 造成緩存雪崩效應. 這時候如果有鎖的機制, 那么就可以控制單個請求去更新緩存.
其實對于Redis鎖的看法, 網上已經有很多了, 只是大部分都是基于Java來實現的, 這里給出一個PHP實現的版本. 這里考慮的只是單機部署Redis的情況, 相對會簡單好理解, 而且也更加的實用. 如果有分布式Redis部署的情況, 可以參考下Redlock算法的實現.
## 基本要求
實現一個分布式鎖定, 我們至少要考慮它能滿足一下的這些需求:
* 互斥, 就是要在任何的時刻, 同一個鎖只能夠有一個客戶端用戶鎖定.
* 不會死鎖, 就算持有鎖的客戶端在持有期間崩潰了, 但是也不會影響后續的客戶端加鎖
* 誰加鎖誰解鎖, 很好理解, 加鎖和解鎖的必須是同一個客戶端
## 加鎖
我們這里使用的是Predis這個這個PHP的客戶端, 其他客戶端也是同理. 先來看看代碼:
~~~
class RedisTool {
const LOCK_SUCCESS = 'OK';
const IF_NOT_EXIST = 'NX';
const MILLISECONDS_EXPIRE_TIME = 'PX';
const RELEASE_SUCCESS = 1;
/**
* 嘗試獲取鎖
* @param \Predis\Client $redis redis客戶端
* @param String $key 鎖
* @param String $requestId 請求id
* @param int $expireTime 過期時間
* @return bool 是否獲取成功
*/
public static function tryGetLock(\Predis\Client $redis, String $key, String $requestId, int $expireTime) {
$result = $redis->set($key, $requestId, self::MILLISECONDS_EXPIRE_TIME, $expireTime, self::IF_NOT_EXIST);
return self::LOCK_SUCCESS === (string)$result;
}
}
~~~
定義一些Redis的操作符作為常量, 加鎖的代碼其實很簡單, 一行代碼即可. 簡單解釋下這個set方法的五個參數:
* 第一個key是鎖的名字, 這個由具體業務邏輯控制, 保證唯一即可
* 第二個是請求ID, 可能不好理解. 這樣做的目的主要是為了保證加解鎖的唯一性. 這樣我們就可以知道該鎖是哪個客戶端加的.
* 第三個參數是一個標識符, 標識時間戳以毫秒為最小單位
* 具體的過期時間
* 這個參數是NX, 表示當key不存在時我們才進行set操作
PS. 請求的唯一性ID生成方式很多, 可以參考下這個[chronos](https://github.com/XiaoMi/chronos), 該庫是Java版本的
簡單解釋下上面的那段代碼, 設置NX保證了只能有一個客戶端獲取到鎖, 滿足互斥性; 加入了過期時間, 保證在客戶端崩潰后不會造成死鎖; 請求ID的作用是用來標識客戶端, 這樣客戶端在解鎖的時候可以進行校驗是否同一個客戶端.
# 解鎖
當鎖擁有的客戶端完成了對共享資源的操作后, 釋放鎖需要用到Lua腳本, 也很簡單:
~~~
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
~~~
PHP代碼實現:
~~~
class RedisTool {
const RELEASE_SUCCESS = 1;
public static function releaseLock(\Predis\Client $redis, String $key, String $requestId) {
$lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
$result = $redis->eval($lua, 1, $key, $requestId);
return self::RELEASE_SUCCESS === $result;
}
}
~~~
沒想到一個簡單的解鎖操作也要用到Lua腳本, 待會會說說常見的幾種錯誤解鎖的方式. 其實為什么要用Lua腳本來實現, 主要是為了保證原子性. Redis的eval可以保證原子性, 主要還是源于Redis的特性, 可以看看官網的介紹
## 常見錯誤#
1. 錯誤加鎖
~~~
public static function wrong1(\Predis\Client $redis, String $key, String $requestId, int $expireTime) {
$result = $redis->setnx($key, $requestId);
if ($result == 1) {
// 這里程序掛了或者expire操作失敗,則無法設置過期時間,將發生死鎖
$redis->expire($key, $expireTime);
}
}
~~~
這是比較常見的一種錯誤實現, 先通過setnx加鎖, 然后在通過expire設置過期時間. 這樣乍一看和上面的不都一樣嗎? 其實不然, 這是兩條Redis命令, 不具有原子性, 如果在setnx之后程序掛了, 會使得鎖沒有設置過期時間, 這樣就會發生死鎖定.
2. 錯誤加鎖
~~~
public static function wrong2(\Predis\Client $redis, String $key, int $expireTime) {
$expires = floor(microtime(true) * 1000) + $expireTime;
// 如果當前鎖不存在,返回加鎖成功
if ($redis->setnx($key, $expires) == 1) {
return true;
}
// 如果鎖存在,獲取鎖的過期時間
$currentValue = floor($redis->get($key));
if ($currentValue != null && $currentValue < floor(microtime(true) * 1000)) {
// 鎖已過期,獲取上一個鎖的過期時間,并設置現在鎖的過期時間
$oldValue = floor($redis->getSet($key, $expires));
if ($oldValue != null && $oldValue === $currentValue) {
// 考慮并發的情況,只有設置值和當前值相同,它才有權利加鎖
return true;
}
}
// 其他情況,一律返回加鎖失敗
return false;
}
~~~
這個例子實現原理是使用setnx來加鎖, 如果鎖已經存在的話則獲取鎖的過期時間并且與當前的時間比較, 過期則設置新的時間, 并且返回加鎖成功. 雖然這樣也可以加鎖, 但是會存在幾個問題:
* 因為時間是客戶端生成的, 這樣就必須要保證在分布式環境下客戶端的時間必須要同步
* 當鎖過期后, 多個客戶端同時執行getSet方法, 雖然可以保證互斥性, 只適合這個鎖的過期時間在高并發或者多線程的情況下有一定的可能被其他客戶端給覆蓋
* 鎖沒有客戶端的標識, 這樣任何一個客戶端都能夠解鎖
3. 錯誤解鎖
~~~
public static function wrongRelease1(\Predis\Client $redis, String $key) {
$redis->del([$key]);
}
~~~
這是最典型的錯誤了, 這樣的做法沒判斷鎖的擁有者, 會使得任何一個客戶端都可以解鎖, 甚至會把別人的鎖給解除了.
4. 錯誤解鎖
~~~
public static function wrongRelease2(\Predis\Client $redis, String $key, String $requestId) {
// 判斷加鎖與解鎖是不是同一個客戶端
if ($requestId === $redis->get($key)) {
// 若在此時,這把鎖突然不是這個客戶端的,則會誤解鎖
$redis->del([$key]);
}
~~~
上面的解鎖也是沒有保證原子性, 注釋說的很明白了, 有這樣的場景來復現:
客戶端A加鎖成功后一段時間再來解鎖, 在執行刪除del操作的時候鎖過期了, 而且這時候又有其他客戶端B來加鎖(這時候加鎖是肯定成功的, 因為客戶端A的鎖過期了), 這是客戶端A再執行刪除del操作, 會把客戶端B的鎖給清了.
- SQL
- 名詞
- mysql
- 初識mysql
- 備份和恢復
- 存儲引擎
- 數據表損壞和修復
- mysql工具
- 數據庫操作
- 增
- 刪
- 改
- 查
- 數據類型
- 整數類型
- 小數類型
- 日期時間類型
- 字符和文本型
- enum類型
- set類型
- 時間類型
- null與not null和null與空值''的區別
- 數據表操作
- 創建
- 索引
- 約束
- 表選項列表
- 表的其他語句
- 視圖
- sql增刪改查
- sql增
- sql刪
- sql改
- sql查
- sql語句練習
- 連接查詢和更新
- 常用sql語句集錦
- 函數
- 字符函數
- 數值運算符
- 比較運算符與函數
- 日期時間函數
- 信息函數
- 聚合函數
- 加密函數
- null函數
- 用戶權限管理
- 用戶管理
- 權限管理
- pdo
- 與pdo相關的幾個類
- 連接數據庫
- 使用
- pdo的錯誤處理
- pdo結果集對象
- pdo結果集對象常用方法
- pdo預處理
- 常用屬性
- mysql編程
- 事務
- 語句塊
- mysql中的變量
- 存儲函數
- 存儲過程
- 觸發器
- mysql優化
- 存儲引擎
- 字段類型
- 三范式和逆范式
- 索引
- 查詢緩存
- limit分頁優化
- 分區
- 介紹
- 分區算法
- list分區
- range范圍
- Hash哈希
- key鍵值
- 分區管理
- 特別注意
- 分表
- 數據碎片與維護
- innodb表壓縮
- 慢查詢
- explain執行計劃
- count和max,groupby優化
- 子查詢優化
- mysql鎖機制
- 介紹
- 演示
- 總結
- 樂觀鎖和悲觀鎖
- 扛得住的mysql
- 實例和故事
- 系統參數優化
- mysql體系結構
- mysql基準測試
- 索引
- mysql的復制
- win配置MySQL主從
- mysql5.7新特性
- 常見問題
- general log
- 忘記密碼
- uodo log與redo log
- 事務隔離級別
- mysql8密碼登錄
- explain
- 高效的Tree表
- on delete cascade 總結
- mongod
- 簡介
- 集合文檔操作語句
- 增刪改查
- 索引
- 數據導入和導出
- 主從復制
- php7操作mongod
- 權限管理
- redis
- redis簡介
- 3.2版本配置文件
- 3.0版本配置文件
- 2.8版本配置文件
- 配置文件總結
- 外網連接
- 持久化
- RDB備份方式保存數據
- AOF備份方式保存數據
- 總結
- win安裝redis和sentinel部署
- 事務
- Sentinel模式配置
- 分布式鎖
- 管道
- php中redis代碼
- 發布訂閱
- slowlog
- Redis4.0
- scan和keys
- elasticsearch
- 配置說明
- 啟動
- kibana
- kibana下載
- kibana配置文件
- kibana常用功能
- 常用術語
- Beats
- Beats簡介
- Filebeat
- Packetbeat
- Logstash
- 配置
- elasticsearch架構
- es1.7
- head和bigdesk插件
- 插件大全
- 倒排索引
- 單模式下API增刪改查
- mget獲取多個文檔
- 批量操作bulk
- 版本控制
- Mapping映射
- 基本查詢
- Filter過濾
- 組合查詢
- es配置文件
- es集群優化和管理
- logstash
- kibana
- es5.2
- 安裝
- 沖突處理
- 數據備份
- 缺陷不足
- 集群管理api
- 分布式事務
- CAP理論
- BASE模型
- 兩階段提交(2PC)
- TCC (Try-Confirm-Cancle)
- 異步確保型
- 最大努力通知型
- 總結