分布式鎖系列(2) 基于Redis & lua無鎖化

1.概述

1.1 背景

分布式鎖在很多場景中是非常有用的原語, 不同的進程必須以獨占資源的方式實現(xiàn)資源共享就是一個典型的例子。

由于外圍的實現(xiàn)存在著各種各樣的問題, Redis 作者提出了一種 RedLock算法來約定分布式鎖需要注意的事項。

當前java版的實現(xiàn)是 Redisson 框架。

1.2 Redis分布式鎖的基本原則

>> 安全屬性(Safety property): 獨享(相互排斥)。
在任意一個時刻,只有一個客戶端持有鎖。
>> 活性A(Liveness property A): 無死鎖。
即便持有鎖的客戶端崩潰(crashed)或者網(wǎng)絡被分裂(gets partitioned),鎖仍然可以被獲取。
>> 活性B(Liveness property B): 容錯。 
只要大部分Redis節(jié)點都活著,客戶端就可以獲取和釋放鎖.

1.3 單點問題 & Master-Slave問題

#基本實現(xiàn)
#加鎖
實現(xiàn)Redis分布式鎖的最簡單的方法就是在Redis中創(chuàng)建一個key,
這個key有一個失效時間(TTL),以保證鎖最終會被自動釋放掉(這個對應特性2)。
#解鎖
當客戶端釋放資源(解鎖)的時候,會刪除掉這個key。

#單點問題 & Master-Slave問題
從表面上看,似乎效果還不錯,但是這里有一個問題:
這個架構中存在一個嚴重的單點失敗問題。如果Redis掛了怎么辦?
你可能會說,可以通過增加一個slave節(jié)點解決這個問題。
但這通常是行不通的。這樣做,我們不能實現(xiàn)資源的獨享,因為Redis的主從同步通常是異步的。

#Master-Slave問題
在這種場景(主從結構)中存在明顯的競態(tài):
>> 客戶端A從master獲取到鎖
>> 在master將鎖同步到slave之前,master宕掉了。
>> slave節(jié)點被晉級為master節(jié)點
>> 客戶端B取得了同一個資源被客戶端A已經(jīng)獲取到的另外一個鎖。安全失效!

1.4 Redis單機版(version > 2.6)的正確實現(xiàn)方法

1.4.1 加鎖

SET resource_name my_random_value NX PX 30000

這是一個原子命令(redis客戶端已支持)。
需注意key對應的value是“my_random_value”(一個隨機值),這個值在所有的客戶端必須是唯一的。

1.4.2 解鎖

value的值必須是隨機數(shù)主要是為了更安全的釋放鎖,釋放鎖的時候使用腳本告訴Redis:
只有key存在并且存儲的值和我指定的值一樣才能告訴我刪除成功。

#為保證兩個操作的原子性, 這里需要使用 lua 腳本實現(xiàn)。
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

#解鎖時, 校驗 value 是否一致的原因
假設客戶端A取得資源鎖,但是緊接著被一個其他操作阻塞了,當客戶端A運行完畢其他操作后要釋放鎖時,
原來的鎖早已超時并且被Redis自動釋放,并且在這期間資源鎖又被客戶端B再次獲取到。
如果僅使用DEL命令將key刪除,那么這種情況就會把客戶端B的鎖給刪除掉。
使用Lua腳本就不會存在這種情況,因為腳本僅會刪除value等于客戶端A的value的key(value相當于客戶端的一個簽名)。

1.5 Redis 官網(wǎng)關于鎖的探討

1.5.1 加鎖

1.5.1.1 Redlock算法

假設有5個Redis master(防止單點故障)。這些節(jié)點完全互相獨立,不存在主從復制或者其他集群協(xié)調機制。
在每個實例上使用與在Redis單實例下獲取和釋放鎖獲取和釋放鎖的方法。

#為了取到鎖,客戶端應該執(zhí)行以下操作:
>> 1.獲取當前Unix時間,以毫秒為單位。
>> 2.依次嘗試從N個實例,使用相同的key和隨機值獲取鎖。
當向Redis設置鎖時,客戶端應該設置一個網(wǎng)絡連接和響應超時時間,這個超時時間應該小于鎖的失效時間。
例如你的鎖自動失效時間為10秒,則超時時間應該在5-50毫秒之間。
這樣可以避免服務器端Redis已經(jīng)掛掉的情況下,客戶端還在死死地等待響應結果。
如果服務器端沒有在規(guī)定時間內響應,客戶端應該盡快嘗試另外一個Redis實例。
>> 3.客戶端使用當前時間減去開始獲取鎖時間(步驟1記錄的時間)就得到獲取鎖使用的時間。
當且僅當從大多數(shù)(這里是3個節(jié)點)的Redis節(jié)點都取到鎖,
并且使用的時間小于鎖失效時間時,鎖才算獲取成功。
>> 4.如果取到了鎖,key的真正有效時間等于有效時間減去獲取鎖所使用的時間(步驟3計算的結果)。
>> 5.如果因為某些原因,獲取鎖失敗(沒有在至少N/2+1個Redis實例取到鎖或者取鎖時間已經(jīng)超過了有效時間), 
客戶端應該在所有的Redis實例上進行解鎖(即便某些Redis實例根本就沒有加鎖成功)。

1.5.1.2 系統(tǒng)時鐘的影響 & 自動續(xù)約機制

#算法基于這樣一個假設
雖然多個進程之間沒有時鐘同步,但每個進程都以相同的時鐘頻率前進,
時間差相對于失效時間來說幾乎可以忽略不計。
每個計算機都有一個本地時鐘,我們可以容忍多個計算機之間有較小的時鐘漂移。

#注意點 (時鐘漂移 & 閏秒現(xiàn)象 ---> 正確配置NTP):
只有在鎖的有效時間(在步驟3計算的結果)范圍內客戶端能夠做完它的工作,
鎖的安全性才能得到保證(鎖的實際有效時間通常要比設置的短,因為計算機之間有時鐘漂移的現(xiàn)象)。

#自動續(xù)約(Redisson使用了watchdog機制來實現(xiàn))
>> 在工作進行的過程中,當發(fā)現(xiàn)鎖剩下的有效時間很短時,
可以再次向redis的所有實例發(fā)送一個Lua腳本,讓key的有效時間延長一點(前提還是key存在并且value是之前設置的value)。
>> 客戶端擴展TTL時必須像首次取得鎖一樣在大多數(shù)實例上擴展成功才算再次取到鎖,
并且是在有效時間內再次取到鎖(算法和獲取鎖是非常相似的)。
>> 這樣做從技術上將并不會改變算法的正確性,所以擴展鎖的過程中
仍然需要達到獲取到N/2+1個實例這個要求,否則活性特性之一就會失效。

1.5.1.3 失敗重試(注意腦裂現(xiàn)象)

當客戶端無法取到鎖時,應該在一個隨機延遲后重試,
防止多個客戶端在同時搶奪同一資源的鎖(這樣會導致腦裂,沒有人會取到鎖)。
同樣,客戶端取得大部分Redis實例鎖所花費的時間越短,腦裂出現(xiàn)的概率就會越低(必要的重試),
所以,理想情況一下,客戶端應該同時(并發(fā)地)向所有Redis發(fā)送SET命令。

需要強調,當客戶端從大多數(shù)Redis實例獲取鎖失敗時,應該盡快地釋放(部分)已經(jīng)成功取到的鎖,
這樣其他的客戶端就不必非得等到鎖過完“有效時間”才能取到。
然而,如果已經(jīng)存在網(wǎng)絡分裂,客戶端已經(jīng)無法和Redis實例通信,
此時就只能等待key的自動釋放了,等于被懲罰了。

1.5.2 釋放鎖

#這個釋放鎖指的是已當前獲取到鎖的客戶端向所有實例發(fā)送解鎖命令
釋放鎖比較簡單,向所有的Redis實例發(fā)送釋放鎖命令即可,不用關心之前有沒有從Redis實例成功獲取到鎖.

1.5.3 一些問題

#redis沒設置 slave 節(jié)點
假設我們的redis沒用使用備份。一個客戶端獲取到了3個實例的鎖。
此時,其中一個已經(jīng)被客戶端取到鎖的redis實例被重啟,
在這個時間點,就可能出現(xiàn)3個節(jié)點沒有設置鎖,此時如果有另外一個客戶端來設置鎖,
鎖就可能被再次獲取到,這樣鎖的互相排斥的特性就被破壞掉了。

#如果我們啟用了AOF持久化,情況會好很多。
我們可用使用SHUTDOWN命令關閉然后再次重啟。
因為Redis到期是語義上實現(xiàn)的,所以當服務器關閉時,實際上還是經(jīng)過了時間,
所有(保持鎖)需要的條件都沒有受到影響. 沒有受到影響的前提是redis優(yōu)雅的關閉。
停電了怎么辦?
如果redis是每秒執(zhí)行一次fsync,那么很有可能在redis重啟之后,key已經(jīng)丟棄。
理論上,如果我們想在Redis重啟地任何情況下都保證鎖的安全,我們必須開啟fsync=always的配置。
這反過來將完全破壞與傳統(tǒng)上用于以安全的方式實現(xiàn)分布式鎖的同一級別的CP系統(tǒng)的性能.

然而情況總比一開始想象的好一些。
當一個redis節(jié)點重啟后,只要它不參與到任意當前活動的鎖,
沒有被當做“當前存活”節(jié)點被客戶端重新獲取到,算法的安全性仍然是有保障的。

為了達到這種效果,我們只需要將新的redis實例,在一個TTL時間內,
對客戶端不可用即可,在這個時間內,所有客戶端鎖將被失效或者自動釋放.

使用"延遲重啟"可以在不采用持久化策略的情況下達到同樣的安全,
然而這樣做有時會讓系統(tǒng)轉化為徹底不可用。
比如大部分的redis實例都崩潰了,系統(tǒng)在TTL時間內任何鎖都將無法加鎖成功。

Martin Kleppmann 與 antirez 關于 RedLock 算法的互懟
http://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
http://antirez.com/news/101

1.6 關于redis分布式鎖的結論

#使用建議
1.分布式鎖的redis采用單機部署,分布式鎖專用
2.根據(jù)RedLock算法思想,意思是不能只在一個redis實例上創(chuàng)建鎖,應該是在多個redis實例上創(chuàng)建鎖,**n / 2 + 1**,
必須在大多數(shù)redis節(jié)點上都成功創(chuàng)建鎖,才能算這個整體的RedLock加鎖成功,避免說僅僅在一個redis實例上加鎖而帶來的問題。
要求: 搭建幾臺獨立的redis機器, 互相之間不通信, 不構成主從/哨兵/集群關系.
3.如果對鎖比較關注,一致性要求比較高,可以使用ZK實現(xiàn)的分布式鎖

#其他方案
1.如果考慮各種網(wǎng)絡、宕機等原因,很多問題需要考慮,問題會變的復雜,
其實分布式鎖的應用場景不多,很多情況可以繞開分布式鎖,使用其他方式解決,比如 隊列,異步,響應式
2.分布式鎖的場景,更多的應用是一個操作不能同時多處進行,不能短時間內重復執(zhí)行,需要冪等操作等場景,
比如:防止快速的重復提交,mq與定時任務雙線更改狀態(tài),防止消息重復消費 等等。
這些情況一般使用setNx即可解決。
3.減庫存其實也用不到分布式鎖, 可用redis+lua實現(xiàn)。

2.分布式鎖的開源實現(xiàn)框架-Redisson

2.1 概述

redisson 是 redis 官方的分布式鎖組件。

#Redisson的一些特點
1.redisson所有指令都通過lua腳本執(zhí)行,redis支持lua腳本原子性執(zhí)行
2.redisson設置一個key的默認過期時間為30s,如果某個客戶端持有一個鎖超過了30s怎么辦?
>> redisson中有一個watchdog的概念,翻譯過來就是看門狗,
它會在你獲取鎖之后,每隔10秒幫你把key的超時時間設為30s
>> 這樣的話,就算一直持有鎖也不會出現(xiàn)key過期了,其他線程獲取到鎖的問題了。
3.redisson的“看門狗”邏輯保證了沒有死鎖發(fā)生。
如果機器宕機了,看門狗也就沒了。
此時就不會延長key的過期時間,到了30s之后就會自動過期了,其他線程可以獲取到鎖。

#ps
lua 腳本的執(zhí)行是原子性的,再加上 Redis 執(zhí)行命令是單線程的,
所以在 lua 腳本執(zhí)行完之前,其他的命令都得等著。
Redisson中的watchdog.png

https://www.cnblogs.com/thisiswhy/p/12596069.html (這里有Redisson 實現(xiàn)分布式鎖的分析, 挺好的, 本文不再分析)

2.2 基于lua腳本的無鎖化 or 基于 Redisson 的分布式鎖控制并發(fā)

package com.zy.redis5.single;

import org.assertj.core.util.Lists;
import org.junit.Test;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RScript;
import org.redisson.api.RedissonClient;
import org.redisson.client.codec.Codec;
import org.redisson.client.codec.StringCodec;
import org.redisson.config.Config;

import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
 * 此處 demo 以 扣減庫存為例, 給出了兩種分布式解決方案
 * 方案1:
 *  先將商品及庫存數(shù)全量加載到 redis 中, 然后借助 lua 腳本實現(xiàn)原子性的扣減庫存, 注意這里的原子性是從 redis 中扣減庫存
 * 方案2:
 *  借助 redisson 的分布式鎖框架, 獲取全局資源操作權限, 然后操作 DB 庫存, 由于首先于 DB 的 qps, 所以并發(fā)效果并不會很好
 *  Redis當做分布式鎖服務器時,可使用獲取鎖和釋放鎖的響應時間,每秒鐘可用執(zhí)行多少次 acquire / release 操作作為性能指標。
 *  
 * 說明:
 *  可以自行寫一個 controller, 啟動一個項目, 借助 jmeter 等工具, 驗證下并發(fā)情況
 */
public class RedisSingleAtomicLuaOrDistributedLock {

    private static RedissonClient client;
    private static Codec codec;
    private static final String KEY = "apple";
    private static final String LOCK_KEY = "lockKey";
    private static List<Object> keyList = Lists.newArrayList();
    private int count = 20;

    static {
        Config config = new Config();
        config.useSingleServer()
                .setDatabase(10)
                .setAddress("redis://192.168.0.156:6379");

        client = Redisson.create(config);
        // FIXME 這里定義了 StringCodec 類型的編解碼器, 是因為其默認的編解碼器是: MarshallingCodec
        // FIXME 而當使用 lua 腳本時, 要調用 lua 的 tonumber 函數(shù) 將庫存(string類型) 轉為 number 類型時,
        // FIXME 如果用默認的編解碼器, 將會得到 nil 的結果, 會出錯.
        // FIXME 故這里使用了 StringCodec 來解決, 也可以用 IntegerCodec 或 LongCodec.
        codec = StringCodec.INSTANCE;
        keyList.add(KEY);
    }

    /**************************** 方案1: 將數(shù)據(jù)全量加載至 redis 中, 在 redis 中扣減庫存, 借助 lua 腳本控制并發(fā) *******************************/
    @Test
    public void step01() {
        String luaScript = "return redis.call('set',KEYS[1],ARGV[1]);";
        Object result = client.getScript(codec).eval(RScript.Mode.READ_WRITE, luaScript, RScript.ReturnType.VALUE, keyList, 999);

        System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>..");
        System.out.println(result);
        System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>..");
    }

    @Test
    public void step02() {
        String luaScript = "return redis.call('get', KEYS[1]);";
        Object result = client.getScript(codec).eval(RScript.Mode.READ_ONLY, luaScript, RScript.ReturnType.VALUE, keyList);

        System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>..");
        System.out.println(result);
        System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>..");
    }

    @Test
    public void step03() {
        String luaScript =
                "if (redis.call('exists', KEYS[1]) == 0) then " +
                        "return 0; " +
                        "end;" +
                        "local count = redis.call('get', KEYS[1]); " +
                        "local decrementCount = ARGV[1]; " +
                        "local a = tonumber(count); " +
                        "local b = tonumber(decrementCount); " +
                        "if (a < b) then " +
                        "return 0; " +
                        "end; " +
                        "redis.call('set', KEYS[1], (a - b)); " +
                        "return 1; ";
        Object result = client.getScript(codec).eval(RScript.Mode.READ_ONLY, luaScript, RScript.ReturnType.VALUE, keyList, 3);

        System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>..");
        System.out.println(result);
        System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>..");
    }

    /**************************** 方案2: 借助 redis 分布式鎖 腳本控制并發(fā) *******************************/
    @Test
    public void fn04() throws InterruptedException {
        ExecutorService executor = Executors.newCachedThreadPool();
        int tobeDecreasedCount = 3;

        for (int i = 0; i < 10; i++) {
            executor.submit(() -> {
                RLock lock = client.getLock(LOCK_KEY);
                boolean b = lock.tryLock();
                if (b) {
                    try {
                        int count = getCount();
                        if (count > tobeDecreasedCount) {
                            decreaseCount(count, tobeDecreasedCount);
                        }
                    } finally {
                        lock.unlock();
                    }
                }
            });
        }

        TimeUnit.SECONDS.sleep(10L);
        System.out.println("剩余庫存量是: " + getCount());
    }

    private int getCount() {
        return count;
    }

    private void decreaseCount(int count, int no) {
        this.count = count - no;
    }
}

參考資料
http://redis.cn/topics/distlock.html
https://redis.io/topics/distlock

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容