使用Redis實現分布式鎖及其優化

目前實現分布式鎖的方式主要有數據庫、Redis和Zookeeper三種,本文主要闡述利用Redis的相關命令來實現分布式鎖。

相關Redis命令

SETNX

如果當前中沒有值,則將其設置為并返回1,否則返回0。

EXPIRE

將設置為秒后自動過期。

GETSET

將的值設置為,并返回其原來的舊值。如果原來沒有舊值,則返回nil。

EVAL與EVALSHA

Redis 2.6之后支持的功能,可以將一段lua腳本發送到Redis服務器運行。

起——分布式鎖初探

利用SETNX命令的原子性,我們可以簡單的實現一個初步的分布式鎖(這里原理就不詳述了,直接上偽代碼):

boolean tryLock(String key, int lockSeconds) {

if (SETNX key "1" == 1) {

EXPIRE key lockSeconds

return true

} else {

return false

}

}

boolean unlock(String key) {

DEL key

}

tryLock是一個非阻塞的分布式鎖方法,在獲得鎖失敗后會立即返回。如果需要一個阻塞式的鎖方法,可以將tryLock方法包裝為輪詢(以一定的時間間隔來輪詢,這很重要,否則Redis會吃不消!)。

此種方法看似沒有什么問題,但其實則有一個漏洞:在加鎖的過程中,客戶端順序的向Redis服務器發送了SETNX和EXPIRE命令,那么假設在SETNX命令執行完成之后,在EXPIRE命令發出去之前客戶端發生崩潰(或客戶端與Redis服務器的網絡連接突然斷掉),導致EXPIRE命令沒有得到執行,其他客戶端將會發生永久死鎖!

承——分布式鎖的改進

更新:此方法解鎖存在漏洞,具體見最文后的追加內容。

為解決上面提出的問題,可以在加鎖時在key中存儲這個鎖過期的時間(當前客戶端時間戳+鎖時間),然后在獲取鎖失敗時,取出value與當前客戶端時間進行比較,如果確定是已經過期的鎖,則可以確認發生了上面描述的錯誤情況,此時可以使用DEL清掉這個key,然后再重新嘗試去獲得這個鎖。可以嗎?當然不可以!如果沒辦法保證DEL操作和下次SETNX操作之間的原子性,則還是會產生一個競態條件,比如這樣:

C1 DEL key

C1 SETNX key

C2 DEL key

C2 SETNX key

當Redis服務器收到這樣的指令序列時,C1和C2的SETNX都同時返回了1,此時C1和C2都認為自己拿到了鎖,這種情況明顯是不符合預期的。

為解決這個問題,Redis的GETSET命令就派上用場了。客戶端可以使用GETSET命令去設置自己的過期時間,然后得到的返回值與之前GET到的返回值進行比較,如果不同,則表示這個過期的鎖被其他客戶端搶占了(此時GETSET命令其實已經生效,也就是說key中的過期時間已經被修改,不過此誤差很小,可以忽略不計)。

根據上面的分析思路,可以得出一個改進后的分布式鎖,這里直接給出Java的實現代碼:

public class RedisLock {

private static final Logger logger = LoggerFactory.getLogger(RedisLock.class);

private final StringRedisTemplate stringRedisTemplate;

private final byte[] lockKey;

public RedisLock(StringRedisTemplate stringRedisTemplate, String lockKey) {

this.stringRedisTemplate = stringRedisTemplate;

this.lockKey = lockKey.getBytes();

}

private boolean tryLock(RedisConnection conn, int lockSeconds) throws Exception {

long nowTime = System.currentTimeMillis();

long expireTime = nowTime + lockSeconds * 1000 + 1000; // 容忍不同服務器時間有1秒內的誤差

if (conn.setNX(lockKey, longToBytes(expireTime))) {

conn.expire(lockKey, lockSeconds);

return true;

} else {

byte[] oldValue = conn.get(lockKey);

if (oldValue != null && bytesToLong(oldValue) < nowTime) {

// 這個鎖已經過期了,可以獲得它

// PS: 如果setNX和expire之間客戶端發生崩潰,可能會出現這樣的情況

byte[] oldValue2 = conn.getSet(lockKey, longToBytes(expireTime));

if (Arrays.equals(oldValue, oldValue2)) {

// 獲得了鎖

conn.expire(lockKey, lockSeconds);

return true;

} else {

// 被別人搶占了鎖(此時已經修改了lockKey中的值,不過誤差很小可以忽略)

return false;

}

}

}

return false;

}

/**

* 嘗試獲得鎖,成功返回true,如果失敗或異常立即返回false

*

* @param lockSeconds 加鎖的時間(秒),超過這個時間后鎖會自動釋放

*/

public boolean tryLock(final int lockSeconds) {

return stringRedisTemplate.execute(new RedisCallback() {

@Override

public Boolean doInRedis(RedisConnection conn) throws DataAccessException {

try {

return tryLock(conn, lockSeconds);

} catch (Exception e) {

logger.error("tryLock Error", e);

return false;

}

}

});

}

/**

* 輪詢的方式去獲得鎖,成功返回true,超過輪詢次數或異常返回false

*

* @param lockSeconds?????? 加鎖的時間(秒),超過這個時間后鎖會自動釋放

* @param tryIntervalMillis 輪詢的時間間隔(毫秒)

* @param maxTryCount?????? 最大的輪詢次數

*/

public boolean tryLock(final int lockSeconds, final long tryIntervalMillis, final int maxTryCount) {

return stringRedisTemplate.execute(new RedisCallback() {

@Override

public Boolean doInRedis(RedisConnection conn) throws DataAccessException {

int tryCount = 0;

while (true) {

if (++tryCount >= maxTryCount) {

// 獲取鎖超時

return false;

}

try {

if (tryLock(conn, lockSeconds)) {

return true;

}

} catch (Exception e) {

logger.error("tryLock Error", e);

return false;

}

try {

Thread.sleep(tryIntervalMillis);

} catch (InterruptedException e) {

logger.error("tryLock interrupted", e);

return false;

}

}

}

});

}

/**

* 如果加鎖后的操作比較耗時,調用方其實可以在unlock前根據時間判斷下鎖是否已經過期

* 如果已經過期可以不用調用,減少一次請求

*/

public void unlock() {

stringRedisTemplate.delete(new String(lockKey));

}

public byte[] longToBytes(long value) {

ByteBuffer buffer = ByteBuffer.allocate(Long.SIZE / Byte.SIZE);

buffer.putLong(value);

return buffer.array();

}

public long bytesToLong(byte[] bytes) {

if (bytes.length != Long.SIZE / Byte.SIZE) {

throw new IllegalArgumentException("wrong length of bytes!");

}

return ByteBuffer.wrap(bytes).getLong();

}

}

轉——分布式鎖的優化

更新:此方法解鎖存在漏洞,具體見本后最后的追加內容。

以上的分布式鎖實現邏輯已經較為復雜,涉及到了較多的Redis命令,并使得每一次嘗試加鎖的過程都會有至少2次的Redis命令執行,這也就意味著至少兩次與Redis服務器的網絡通信。而添加后面復雜邏輯的原因只是因為SETNX與EXPIRE這兩條命令執行的原子性無法得到保證。(有些同學會提到Redis的pipeline特性,此處明顯不適用,因為第二條指令的執行以來與第一條執行的結果,pipeline無法實現)

另外,上面的分布式鎖還有一個問題,那就是服務器之間時間同步的問題。在分布式場景中,多臺服務器之間的時間做到同步是非常困難的,所以在代碼中我加了1秒的時間容錯,但依賴服務器時間的同步還是可能會不靠譜的。

從Redis 2.6開始,客戶端可以直接向Redis服務器提交Lua腳本,也就是說可以直接在Redis服務器來執行一些較復雜的邏輯,而此腳本的提交對于客戶端來說是相對原子性的。這恰好解決了我們的問題!

我們可以用一個這樣的lua腳本來描述加鎖的邏輯(關于腳本的提交命令和Redis的相關規則可以看https://redis.io/commands/eval):

if (redis.call('setnx', KEYS[1], ARGV[1]) == 1) then

redis.call('expire', KEYS[1], tonumber(ARGV[2]))

return true

else

return false

end

注意:此腳本中命令的執行并不是嚴格意義上的原子性,如果其中第二條指令EXPIRE執行失敗,整個腳本執行會返回錯誤,但是第一條指令SETNX仍然是已經生效的!不過此種情況基本可以認為是Redis服務器已經崩潰(除非是開發階段就可以排除的參數錯誤之類的問題),那么鎖的安全性就已經不是這里可以關注的點了。這里認為對客戶端來說是相對原子性的就足夠了。

這個簡單的腳本在Redis服務器得到執行,并返回是否得到鎖。因為腳本的提交執行只有一條Redis命令,就避免了上面所說的客戶端異常問題。

使用腳本優化了鎖的邏輯和性能,這里給出最終的Java實現代碼:

public class RedisLock {

private static final Logger logger = LoggerFactory.getLogger(RedisLock.class);

private final StringRedisTemplate stringRedisTemplate;

private final String lockKey;

private final List keys;

/**

* 使用腳本在redis服務器執行這個邏輯可以在一定程度上保證此操作的原子性

* (即不會發生客戶端在執行setNX和expire命令之間,發生崩潰或失去與服務器的連接導致expire沒有得到執行,發生永久死鎖)

*

* 除非腳本在redis服務器執行時redis服務器發生崩潰,不過此種情況鎖也會失效

*/

private static final RedisScript SETNX_AND_EXPIRE_SCRIPT;

static {

StringBuilder sb = new StringBuilder();

sb.append("if (redis.call('setnx', KEYS[1], ARGV[1]) == 1) then\n");

sb.append("\tredis.call('expire', KEYS[1], tonumber(ARGV[2]))\n");

sb.append("\treturn true\n");

sb.append("else\n");

sb.append("\treturn false\n");

sb.append("end");

SETNX_AND_EXPIRE_SCRIPT = new RedisScriptImpl(sb.toString(), Boolean.class);

}

public RedisLock(StringRedisTemplate stringRedisTemplate, String lockKey) {

this.stringRedisTemplate = stringRedisTemplate;

this.lockKey = lockKey;

this.keys = Collections.singletonList(lockKey);

}

private boolean doTryLock(int lockSeconds) throws Exception {

return stringRedisTemplate.execute(SETNX_AND_EXPIRE_SCRIPT, keys, "1", String.valueOf(lockSeconds));

}

/**

* 嘗試獲得鎖,成功返回true,如果失敗立即返回false

*

* @param lockSeconds 加鎖的時間(秒),超過這個時間后鎖會自動釋放

*/

public boolean tryLock(int lockSeconds) {

try {

return doTryLock(lockSeconds);

} catch (Exception e) {

logger.error("tryLock Error", e);

return false;

}

}

/**

* 輪詢的方式去獲得鎖,成功返回true,超過輪詢次數或異常返回false

*

* @param lockSeconds?????? 加鎖的時間(秒),超過這個時間后鎖會自動釋放

* @param tryIntervalMillis 輪詢的時間間隔(毫秒)

* @param maxTryCount?????? 最大的輪詢次數

*/

public boolean tryLock(final int lockSeconds, final long tryIntervalMillis, final int maxTryCount) {

int tryCount = 0;

while (true) {

if (++tryCount >= maxTryCount) {

// 獲取鎖超時

return false;

}

try {

if (doTryLock(lockSeconds)) {

return true;

}

} catch (Exception e) {

logger.error("tryLock Error", e);

return false;

}

try {

Thread.sleep(tryIntervalMillis);

} catch (InterruptedException e) {

logger.error("tryLock interrupted", e);

return false;

}

}

}

/**

* 如果加鎖后的操作比較耗時,調用方其實可以在unlock前根據時間判斷下鎖是否已經過期

* 如果已經過期可以不用調用,減少一次請求

*/

public void unlock() {

stringRedisTemplate.delete(lockKey);

}

private static class RedisScriptImpl implements RedisScript {

private final String script;

private final String sha1;

private final Class resultType;

public RedisScriptImpl(String script, Class resultType) {

this.script = script;

this.sha1 = DigestUtils.sha1DigestAsHex(script);

this.resultType = resultType;

}

@Override

public String getSha1() {

return sha1;

}

@Override

public Class getResultType() {

return resultType;

}

@Override

public String getScriptAsString() {

return script;

}

}

}

合——小節

最后,此文內容只是筆者自己學習折騰出來的結果,如果還有什么筆者沒有考慮到的bug存在,還請不吝指出,大家一起學習進步~

追——解鎖漏洞(更新)

經過慎重考慮,發現以上實現的分布式鎖有一個較為嚴重的解鎖漏洞:因為解鎖操作只是做了簡單的DEL KEY,如果某客戶端在獲得鎖后執行業務的時間超過了鎖的過期時間,則最后的解鎖操作會誤解掉其他客戶端的操作。

為解決此問題,我們在創建RedisLock對象時用本機時間戳和UUID來創建一個絕對唯一的lockValue,然后在加鎖時存入此值,并在解鎖前用GET取出值進行比較,如果匹配才做DEL。這里依然需要用LUA腳本保證整個解鎖過程的原子性。

這里給出修復此漏洞并做了一些小優化之后的代碼:

import java.util.Collections;

import java.util.UUID;

import org.slf4j.Logger;

import org.slf4j.LoggerFactory;

import org.springframework.data.redis.core.StringRedisTemplate;

import org.springframework.data.redis.core.script.DigestUtils;

import org.springframework.data.redis.core.script.RedisScript;

/**

* Created On 10/24 2017

* Redis實現的分布式鎖(不可重入)

* 此對象非線程安全,使用時務必注意

*/

public class RedisLock {

private static final Logger logger = LoggerFactory.getLogger(RedisLock.class);

private final StringRedisTemplate stringRedisTemplate;

private final String lockKey;

private final String lockValue;

private boolean locked = false;

/**

* 使用腳本在redis服務器執行這個邏輯可以在一定程度上保證此操作的原子性

* (即不會發生客戶端在執行setNX和expire命令之間,發生崩潰或失去與服務器的連接導致expire沒有得到執行,發生永久死鎖)

*

* 除非腳本在redis服務器執行時redis服務器發生崩潰,不過此種情況鎖也會失效

*/

private static final RedisScript SETNX_AND_EXPIRE_SCRIPT;

static {

StringBuilder sb = new StringBuilder();

sb.append("if (redis.call('setnx', KEYS[1], ARGV[1]) == 1) then\n");

sb.append("\tredis.call('expire', KEYS[1], tonumber(ARGV[2]))\n");

sb.append("\treturn true\n");

sb.append("else\n");

sb.append("\treturn false\n");

sb.append("end");

SETNX_AND_EXPIRE_SCRIPT = new RedisScriptImpl(sb.toString(), Boolean.class);

}

private static final RedisScript DEL_IF_GET_EQUALS;

static {

StringBuilder sb = new StringBuilder();

sb.append("if (redis.call('get', KEYS[1]) == ARGV[1]) then\n");

sb.append("\tredis.call('del', KEYS[1])\n");

sb.append("\treturn true\n");

sb.append("else\n");

sb.append("\treturn false\n");

sb.append("end");

DEL_IF_GET_EQUALS = new RedisScriptImpl(sb.toString(), Boolean.class);

}

public RedisLock(StringRedisTemplate stringRedisTemplate, String lockKey) {

this.stringRedisTemplate = stringRedisTemplate;

this.lockKey = lockKey;

this.lockValue = UUID.randomUUID().toString() + "." + System.currentTimeMillis();

}

private boolean doTryLock(int lockSeconds) throws Exception {

if (locked) {

throw new IllegalStateException("already locked!");

}

locked = stringRedisTemplate.execute(SETNX_AND_EXPIRE_SCRIPT, Collections.singletonList(lockKey), lockValue,

String.valueOf(lockSeconds));

return locked;

}

/**

* 嘗試獲得鎖,成功返回true,如果失敗立即返回false

*

* @param lockSeconds 加鎖的時間(秒),超過這個時間后鎖會自動釋放

*/

public boolean tryLock(int lockSeconds) {

try {

return doTryLock(lockSeconds);

} catch (Exception e) {

logger.error("tryLock Error", e);

return false;

}

}

/**

* 輪詢的方式去獲得鎖,成功返回true,超過輪詢次數或異常返回false

*

* @param lockSeconds?????? 加鎖的時間(秒),超過這個時間后鎖會自動釋放

* @param tryIntervalMillis 輪詢的時間間隔(毫秒)

* @param maxTryCount?????? 最大的輪詢次數

*/

public boolean tryLock(final int lockSeconds, final long tryIntervalMillis, final int maxTryCount) {

int tryCount = 0;

while (true) {

if (++tryCount >= maxTryCount) {

// 獲取鎖超時

return false;

}

try {

if (doTryLock(lockSeconds)) {

return true;

}

} catch (Exception e) {

logger.error("tryLock Error", e);

return false;

}

try {

Thread.sleep(tryIntervalMillis);

} catch (InterruptedException e) {

logger.error("tryLock interrupted", e);

return false;

}

}

}

/**

* 解鎖操作

*/

public void unlock() {

if (!locked) {

throw new IllegalStateException("not locked yet!");

}

locked = false;

// 忽略結果

stringRedisTemplate.execute(DEL_IF_GET_EQUALS, Collections.singletonList(lockKey), lockValue);

}

private static class RedisScriptImpl implements RedisScript {

private final String script;

private final String sha1;

private final Class resultType;

public RedisScriptImpl(String script, Class resultType) {

this.script = script;

this.sha1 = DigestUtils.sha1DigestAsHex(script);

this.resultType = resultType;

}

@Override

public String getSha1() {

return sha1;

}

@Override

public Class getResultType() {

return resultType;

}

@Override

public String getScriptAsString() {

return script;

}

}

}


轉自:

http://mzorro.me/2017/10/25/redis-distributed-lock/

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,923評論 18 139
  • 一、分布式鎖的作用: redis寫入時不帶鎖定功能,為防止多個進程同時進行一個操作,出現意想不到的結果,so......
    魔法師_閱讀 2,075評論 0 6
  • package com.seckill.lock; import com.seckill.redis.RedisC...
    wuyuan0127閱讀 614評論 0 0
  • 1. Java基礎部分 基礎部分的順序:基本語法,類相關的語法,內部類的語法,繼承相關的語法,異常的語法,線程的語...
    子非魚_t_閱讀 31,765評論 18 399
  • 春夏秋冬,四季的花兒點綴生命,是人生最美的風景。大大小小,或濃或淡,或雅或媚,香淡各異,各有自己的風姿。 各花有語...
    實鑫玉坊閱讀 437評論 0 0