Redis分布式鎖—SETNX+Lua腳本實現篇


前言

平時的工作中,由於生產環境中的項目是需要部署在多台服務器中的,所以經常會面臨解決分布式場景下數據一致性的問題,那么就需要引入分布式鎖來解決這一問題。

針對分布式鎖的實現,目前比較常用的就如下幾種方案:

  1. 基於數據庫實現分布式鎖
  2. 基於Redis實現分布式鎖 【本文】
  3. 基於Zookeeper實現分布式鎖

接下來這個系列文章會跟大家一塊探討這三種方案,本篇為Redis實現分布式鎖篇。

Redis分布式環境搭建推薦:基於Docker的Redis集群搭建

Redis分布式鎖一覽

說到 redis 鎖,能搜到的,或者說常用的無非就下面這兩個:

  • setNX + Lua腳本 【本文】
  • Redisson + RLock可重入鎖

接下來我們一一探索這兩個的實現,本文為 setNX + Lua腳本 實現篇。

1、setNX

完整語法:SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]

必選參數說明:

  • SET:命令
  • key:待設置的key
  • value:設置的key的value,最好為隨機字符串

可選參數說明:

  • NX:表示key不存在時才設置,如果存在則返回 null

  • XX:表示key存在時才設置,如果不存在則返回NULL

  • PX millseconds:設置過期時間,過期時間精確為毫秒

  • EX seconds:設置過期時間,過期時間精確為秒

注意:其實我們常說的通過 Redis 的 setnx 命令來實現分布式鎖,並不是直接使用 Redis 的 setnx 命令,因為在老版本之前 setnx 命令語法為「setnx key value」,並不支持同時設置過期時間的操作,那么就需要再執行 expire 過期時間的命令,這樣的話加鎖就成了兩個命令,原子性就得不到保障,所以通常需要配合 Lua 腳本使用,而從 Redis 2.6.12 版本后,set 命令開始整合了 setex 的功能,並且 set 本身就已經包含了設置過期時間,因此常說的 setnx 命令實則只用 set 命令就可以實現了,只是參數上加上了 NX 等參數。

大致說一下用 setnx 命令實現分布式鎖的流程:

在 Redis 2.6.12 版本之后,Redis 支持原子命令加鎖,我們可以通過向 Redis 發送 「set key value NX 過期時間」 命令,實現原子的加鎖操作。比如某個客戶端想要獲取一個 key 為 niceyoo 的鎖,此時需要執行 「set niceyoo random_value NX PX 30000」 ,在這我們設置了 30 秒的鎖自動過期時間,超過 30 秒自動釋放。

如果 setnx 命令返回 ok,說明拿到了鎖,此時我們就可以做一些業務邏輯處理,業務處理完之后,需要釋放鎖,釋放鎖一般就是執行 Redis 的 del 刪除指令,「del niceyoo」

如果 setnx 命令返回 nil,說明拿鎖失敗,被其他線程占用,如下是模擬截圖:

image-20200920003133502

注意,這里在設置值的時候,value 應該是隨機字符串,比如 UUID,而不是隨便用一個固定的字符串進去,為什么這樣做呢?

value 的值設置為隨機數主要是為了更安全的釋放鎖,釋放鎖的時候需要檢查 key 是否存在,且 key 對應的 value 值是否和指定的值一樣,是一樣的才能釋放鎖。

感覺這樣說還是不清晰,舉個例子:例如進程 A,通過 setnx 指令獲取鎖成功(命令中設置了加鎖自動過期時間30 秒),既然拿到鎖了就開始執行業務吧,但是進程 A 在接下來的執行業務邏輯期間,程序響應時間竟然超過30秒了,鎖自動釋放了,而此時進程 B 進來了,由於進程 A 設置的過期時間一到,讓進程 B 拿到鎖了,然后進程 B 又開始執行業務邏輯,但是呢,這時候進程 A 突然又回來了,然后把進程 B 的鎖得釋放了,然后進程 C 又拿到鎖,然后開始執行業務邏輯,此時進程 B 又回來了,釋放了進程 C 的鎖,套娃開始了.....

總之,有了隨機數的 value 后,可以通過判斷 key 對應的 value 值是否和指定的值一樣,是一樣的才能釋放鎖。

接下來我們把 setnx 命令落地到項目實例中:

代碼環境:SpringBoot2.2.2.RELEASE + spring-boot-starter-data-redis + StringRedisTemplate

源碼地址https://github.com/niceyoo/redis-setnx

StringRedisTemplate 或者 RedisTemplate 下對應的 setnx 指令的 API 方法如下:

/**
 * Set {@code key} to hold the string {@code value} if {@code key} is absent.
 *
 * @param key must not be {@literal null}.
 * @param value
 * @see <a href="http://redis.io/commands/setnx">Redis Documentation: SETNX</a>
 */
Boolean setIfAbsent(K key, V value);

這個地方再補充一下,使用 jedis 跟使用 StringRedisTemplate 對應的 senx 命令的寫法是有區別的,jedis 下就是 set 方法,而 StringRedisTemplate 下使用的是 setIfAbsent 方法 。

1)Maven 依賴,pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.2.RELEASE</version>
        <relativePath/> 
    </parent>
    <groupId>com.example</groupId>
    <artifactId>demo-redis</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo-redis</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.10</version>
        </dependency>

        <!-- Gson -->
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.8.6</version>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

這里引入了 gson、redis 依賴。

2)application.yml 配置文件
server:
  port: 6666
  servlet:
    context-path: /

spring:
  redis:
    host: 127.0.0.1
    password:
    # 數據庫索引 默認0
    database: 0
    port: 6379
    # 超時時間 Duration類型 3秒
    timeout: 3S

# 日志
logging:
  # 輸出級別
  level:
    root: info
  file:
    # 指定路徑
    path: redis-logs
    # 最大保存天數
    max-history: 7
    # 每個文件最大大小
    max-size: 5MB

這里設置的服務端口為 6666,大家可以根據自己環境修改。

3)測試的 Controller
@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {

    @Resource
    private RedisTemplate<String,Object> redisTemplate;

    @PostMapping(value = "/addUser")
    public String createOrder(User user) {

        String key = user.getUsername();
        // 如下為使用UUID、固定字符串,固定字符串容易出現線程不安全
        String value = UUID.randomUUID().toString().replace("-","");
        // String value = "123";
        /*
         * setIfAbsent <=> SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]
         * set expire time 5 mins
         */
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 20000, TimeUnit.MILLISECONDS);
        if (flag) {
            log.info("{} 鎖定成功,開始處理業務", key);
            try {
                /** 模擬處理業務邏輯 **/
                Thread.sleep(1000 * 30);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            /** 判斷是否是key對應的value **/
            String lockValue = redisTemplate.opsForValue().get(key);
            if (lockValue != null && lockValue.equals(value)) {
             redisTemplate.delete(key);
             log.info("{} 解鎖成功,結束處理業務", key);
            }
            return "SUCCESS";
        } else {
            log.info("{} 獲取鎖失敗", key);
            return "請稍后再試...";
        }
    }

}

大致流程就是,通過 RedisTemplate 的 setIfAbsent() 方法獲取原子鎖,並設置了鎖自動過期時間為 20秒,setIfAbsent() 方法返回 true,表示加鎖成功,加鎖成功后模擬了一段業務邏輯處理,耗時30秒,執行完邏輯之后調用 delete() 方法釋放鎖。

問題來了,由於鎖自動過期時間為 20秒,而業務邏輯耗時為 30秒,在不使用 random_value(隨機字符串)下,如果有多進程操作的話就會出現前面提到的套娃騷操作......

所以在刪除鎖之前,我們先再次通過 get 命令獲取加鎖 key 的 value 值,然后判斷 value 跟加鎖時設置的 value 是否一致,這就看出 UUID 的重要性了,如果一致,就執行 delete() 方法釋放鎖,否則不執行。

如下是使用「固定字符串」模擬的問題截圖:

image-20200920011841816

兩次加鎖成功的時間間隔為11秒,不足20秒,顯然不是一個進程的用戶。

image-20200920012013118

而在 value 使用 UUID 隨機字符串時沒有出現上述問題。

但隨機字符串就真的安全了嗎?

不安全...

因為還是無法保證 redisTemplate.delete(key); 的原子操作,在多進程下還是會有進程安全問題。

就有小伙伴可能鑽牛角尖,怎么就不能原子性操作了,你在刪除之前不都已經判斷了嗎?

再舉個例子,比如進程 A 執行完業務邏輯,在 redisTemplate.opsForValue().get(key); 獲得 key 這一步執行沒問題,同時也進入了 if 判斷中,但是恰好這時候進程 A 的鎖自動過期時間到了(別問為啥,就是這么巧),而另一個進程 B 獲得鎖成功,然后還沒來得及執行,進程 A 就執行了 delete(key) ,釋放了進程 B 的鎖....

我操?那你上邊巴拉巴拉那么多,說啥呢?

咳咳,解鎖正確刪除鎖的方式之一:為了保障原子性,我們需要用 Lua 腳本進行完美解鎖。

Lua腳本

可能有小伙伴不熟悉 Lua,先簡單介紹一下 Lua 腳本:

Lua 是一種輕量小巧的腳本語言,用標准 C 語言編寫並以源代碼形式開放, 其設計目的是為了嵌入應用程序中,從而為應用程序提供靈活的擴展和定制功能。

Lua 提供了交互式編程模式。我們可以在命令行中輸入程序並立即查看效果。

lua腳本優點:

  • 減少網絡開銷:原先多次請求的邏輯放在 redis 服務器上完成。使用腳本,減少了網絡往返時延
  • 原子操作:Redis會將整個腳本作為一個整體執行,中間不會被其他命令插入(想象為事務)
  • 復用:客戶端發送的腳本會永久存儲在Redis中,意味着其他客戶端可以復用這一腳本而不需要使用代碼完成同樣的邏輯

先大致了解一下,后面我會單獨寫一篇 Lua 從入門到放棄的文章。。

如下是Lua腳本,通過 Redis 的 eval/evalsha 命令來運行:

-- lua刪除鎖:
-- KEYS和ARGV分別是以集合方式傳入的參數,對應上文的Test和uuid。
-- 如果對應的value等於傳入的uuid。
if redis.call('get', KEYS[1]) == ARGV[1] 
    then 
 -- 執行刪除操作
        return redis.call('del', KEYS[1]) 
    else 
 -- 不成功,返回0
        return 0 
end

好了,看到 Lua 腳本了,然后代碼中如何使用?

為了讓大家更清楚,我們在 SpringBoot 中使用這個 Lua 腳本
1)在 resources 文件下創建 niceyoo.lua 文件

image-20200920123914568

文件內容如下:

if redis.call('get', KEYS[1]) == ARGV[1]
    then
        return redis.call('del', KEYS[1])
    else
        return 0
end
2)修改 TestController

在 SpringBoot中,是使用 DefaultRedisScript 類來加載腳本的,並設置相應的數據類型來接收 Lua 腳本返回的數據,這個泛型類在使用時設置泛型是什么類型,腳本返回的結果就是用什么類型接收。

@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {

    @Resource
    private RedisTemplate<String,Object> redisTemplate;

    private DefaultRedisScript<Long> script;

    @PostConstruct
    public void init(){
        script = new DefaultRedisScript<Long>();
        script.setResultType(Long.class);
        script.setScriptSource(new ResourceScriptSource(new ClassPathResource("niceyoo.lua")));
    }

    @PostMapping(value = "/addUser")
    public String createOrder(User user) {

        String key = user.getUsername();
        String value = UUID.randomUUID().toString().replace("-","");

        /*
         * setIfAbsent <=> SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]
         * set expire time 5 mins
         */
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 20000, TimeUnit.MILLISECONDS);
        if (flag) {
            log.info("{} 鎖定成功,開始處理業務", key);
            try {
                /** 模擬處理業務邏輯 **/
                Thread.sleep(1000 * 10);

            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            /** 業務邏輯處理完畢,釋放鎖 **/
            String lockValue = (String) redisTemplate.opsForValue().get(key);
            if (lockValue != null && lockValue.equals(value)) {
                System.out.println("lockValue========:" + lockValue);
                List<String> keys = new ArrayList<>();
                keys.add(key);
                Long execute = redisTemplate.execute(script, keys, lockValue);
                System.out.println("execute執行結果,1表示執行del,0表示未執行 ===== " + execute);
                log.info("{} 解鎖成功,結束處理業務", key);
            }
            return "SUCCESS";
        } else {
            log.info("{} 獲取鎖失敗", key);
            return "請稍后再試...";
        }
    }

}
3)測試結果

Lua 腳本替換 RedisTemplate 執行 delete() 方法,測試結果如下:

image-20200920124635965

最后總結

1、所謂的 setnx 命令來實現分布式鎖,其實不是直接使用 Redis 的 setnx 命令,因為 setnx 不支持設置自動釋放鎖的時間(至於為什么要設置自動釋放鎖,是因為防止被某個進程不釋放鎖而造成死鎖的情況),不支持設置過期時間,就得分兩步命令進行操作,一步是 setnx key value,一步是設置過期時間,這種情況的弊端很顯然,無原子性操作。

2、 Redis 2.6.12 版本后,set 命令開始整合了 setex 的功能,並且 set 本身就已經包含了設置過期時間,因此常說的 setnx 命令實則只用 set 命令就可以實現了,只是參數上加上了 NX 等參數。

3、經過分析,在使用 set key value nx px xxx 命令時,value 最好是隨機字符串,這樣可以防止業務代碼執行時間超過設置的鎖自動過期時間,而導致再次釋放鎖時出現釋放其他進程鎖的情況(套娃)

4、盡管使用隨機字符串的 value,但是在釋放鎖時(delete方法),還是無法做到原子操作,比如進程 A 執行完業務邏輯,在准備釋放鎖時,恰好這時候進程 A 的鎖自動過期時間到了,而另一個進程 B 獲得鎖成功,然后 B 還沒來得及執行,進程 A 就執行了 delete(key) ,釋放了進程 B 的鎖.... ,因此需要配合 Lua 腳本釋放鎖,文章也給出了 SpringBoot 的使用示例。

至此,帶大家一塊查看了 setnx 命令如何實現分布式鎖,但是下面還是要潑一下冷水...

經過測試,在單機 Redis 模式下,這種分布式鎖,簡直是無敵(求生欲:純個人看法),咳咳,沒錯,你沒看錯,單機下的 Redis 無敵...

所以在那些主從模式、哨兵模式、或者是 cluster 模式下,可能會出現問題,出現什么問題呢?

setNX 的缺陷

setnx 瑣最大的缺點就是它加鎖時只作用在一個 Redis 節點上,即使 Redis 通過 Sentinel(哨崗、哨兵) 保證高可用,如果這個 master 節點由於某些原因發生了主從切換,那么就會出現鎖丟失的情況,下面是個例子:

  1. 在 Redis 的 master 節點上拿到了鎖;
  2. 但是這個加鎖的 key 還沒有同步到 slave 節點;
  3. master 故障,發生故障轉移,slave 節點升級為 master節點;
  4. 上邊 master 節點上的鎖丟失。

有的時候甚至不單單是鎖丟失這么簡單,新選出來的 master 節點可以重新獲取同樣的鎖,出現一把鎖被拿兩次的場景。

鎖被拿兩次,也就不能滿足安全性了...

缺陷看完了,怎么解決嘛~

然后 Redis 的作者就提出了著名遠洋的 RedLock 算法...

下節講。


在寫這篇文章過程中,本來計划將 Redis 里的 setnx、redisson、redLock 一塊寫出來發一篇文章;

但由於文章中貼了一些代碼片段,會讓文章整體的節奏偏長,不適用於后面自己的復習,所以拆分成兩篇文章,

下一篇我們一塊探索 Redisson + RedLock 的分布式鎖的實現。

2、Redisson + RedLock

請跳轉該鏈接:https://www.cnblogs.com/niceyoo/p/13736140.html

博客園持續更新,訂閱關注,未來,我們一起成長。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM