Redis之Lua的應用(四)

2022-06-19 06:00:36

一、什麼是Lua指令碼

Lua是一個高效的輕量級指令碼語言(和JavaScript類似),用標準C語言編寫並以原始碼形式開放, 其設計目的是為了嵌入應用程式中,從而為應用程式提供靈活的擴充套件和客製化功能。Lua在葡萄牙語中是「月亮」的意思,它的logo形式衛星,寓意是Lua是一個「衛星語言」,能夠方便地嵌入到其他語言中使用;其實在很多常見的框架中,都有嵌入Lua指令碼的功能,比如OpenResty、Redis等。

使用Lua指令碼的好處:

  1. 減少網路開銷,在Lua指令碼中可以把多個命令放在同一個指令碼中執行

  2. 原子操作,redis會將整個指令碼作為一個整體執行,中間不會被其他命令插入。換句話說,編寫指令碼的過程中無需擔心會出現競態條件

  3. 複用性,使用者端傳送的指令碼會永遠儲存在redis中,這意味著其他使用者端可以複用這一指令碼來完成同樣的邏輯

二、Lua的下載和安裝

Lua是一個獨立的指令碼語言,所以它有專門的編譯執行工具,下面簡單帶大家安裝一下。

安裝步驟

tar -zxvf lua-5.4.3.tar.gz
cd lua-5.4.3
make linux
make install

最後,直接輸入lua命令即可進入lua的控制檯。Lua指令碼有自己的語法、變數、邏輯運運算元、函數等,這塊我就不在這裡做過多的說明,可以自己進入下面連結進去看

https://www.runoob.com/lua/lua-tutorial.html

三、Redis與Lua

Redis中整合了Lua的編譯和執行器,所以我們可以在Redis中定義Lua指令碼去執行。同時,在Lua指令碼中,可以直接呼叫Redis的命令,來操作Redis中的資料。

redis.call(‘set’,'hello','world')
 
local value=redis.call(‘get’,’hello’) 

redis.call 函數的返回值就是redis命令的執行結果,前面我們介紹過redis的5中型別的資料返回的值的型別也都不一樣,redis.call函數會將這5種型別的返回值轉化對應的Lua的資料型別

在很多情況下我們都需要指令碼可以有返回值,畢竟這個指令碼也是一個我們所編寫的命令集,我們可以像呼叫其他redis內建命令一樣呼叫我們自己寫的指令碼,所以同樣redis會自動將指令碼返回值的Lua資料型別轉化為Redis的返回值型別。 在指令碼中可以使用return 語句將值返回給redis使用者端,通過return語句來執行,如果沒有執行return,預設返回為nil。

四、Redis中執行Lua指令碼相關的命令

編寫完指令碼後最重要的就是在程式中執行指令碼。Redis提供了EVAL命令可以使開發者像呼叫其他Redis內建命令一樣呼叫指令碼。

EVAL命令-執行指令碼

[EVAL] [指令碼內容] [key引數的數量] [key …] [arg …]

可以通過key和arg這兩個引數向指令碼中傳遞資料,他們的值可以在指令碼中分別使用KEYS和ARGV 這兩個型別的全域性變數存取。

比如我們通過指令碼實現一個set命令,通過在redis使用者端中呼叫,那麼執行的語句是:

eval "return redis.call('set',KEYS[1],ARGV[1])" 1 lua hello

上述指令碼相當於使用Lua指令碼呼叫了Redis的set命令,儲存了一個key=lua,value=hello到Redis中。

EVALSHA命令

考慮到我們通過eval執行lua指令碼,指令碼比較長的情況下,每次呼叫指令碼都需要把整個指令碼傳給redis,比較佔用頻寬。為了解決這個問題,redis提供了EVALSHA命令允許開發者通過指令碼內容的SHA1摘要來執行指令碼。該命令的用法和EVAL一樣,只不過是將指令碼內容替換成指令碼內容的SHA1摘要

  1. Redis在執行EVAL命令時會計算指令碼的SHA1摘要並記錄在指令碼快取中

  2. 執行EVALSHA命令時Redis會根據提供的摘要從指令碼快取中查詢對應的指令碼內容,如果找到了就執行指令碼,否則返回「NOSCRIPT No matching script,Please use EVAL」

# 將指令碼加入快取並生成sha1命令
script load "return redis.call('get','lua')"
# ["13bd040587b891aedc00a72458cbf8588a27df90"]
# 傳遞sha1的值來執行該命令
evalsha "13bd040587b891aedc00a72458cbf8588a27df90" 0

五、自己通過Redisson執行Lua指令碼

通過lua指令碼來實現一個存取頻率限制功能。

思路,定義一個key,key中包含ip地址。 value為指定時間內的存取次數,比如說是10秒內只能存取3次。

定義Lua指令碼

local times=redis.call('incr',KEYS[1])
-- 如果是第一次進來,設定一個過期時間
if times == 1 then
   redis.call('expire',KEYS[1],ARGV[1])
end
-- 如果在指定時間記憶體取次數大於指定次數,則返回0,表示存取被限制
if times > tonumber(ARGV[2]) then
   return 0
end
-- 返回1,允許被存取
return 1

定義controller,提供存取測試方法

@RestController
public class RedissonLuaController {
    @Autowired
    RedissonClient redissonClient;

    private final String LIMIT_LUA="local times=redis.call('incr',KEYS[1])\n" +
            "if times==1 then\n" +
            "    redis.call('expire',KEYS[1],ARGV[1])\n" +
            "end\n" +
            "if times > tonumber(ARGV[2]) then\n" +
            "    return 0\n" +
            "end \n" +
            "return 1";

    @GetMapping("/lua/{id}")
    public String lua(@PathVariable("id") Integer id) throws ExecutionException, InterruptedException {
        RScript rScript=redissonClient.getScript();
        List<Object> keys= Arrays.asList("LIMIT:"+id);
        RFuture<Object> future=rScript.evalAsync(RScript.Mode.READ_WRITE,LIMIT_LUA, RScript.ReturnType.INTEGER,keys,10,3);
        return future.get().toString();
    }

}

要注意,上述指令碼執行的時候會有問題,因為redis預設的序列化方式導致value的值在傳遞到指令碼中時,轉成了物件型別,需要修改redisson.yml檔案,增加codec的序列化方式。

application.yml

spring:
  redis:
    redisson:
      file: classpath:redisson.yml

redisson.yml

singleServerConfig:
  address: redis://192.168.221.128:6379
 
codec: !<org.redisson.codec.JsonJacksonCodec> {}

六、Lua指令碼的原子性

redis的指令碼執行是原子的,即指令碼執行期間Redis不會執行其他命令。所有的命令必須等待指令碼執行完以後才能執行。為了防止某個指令碼執行時間過程導致Redis無法提供服務。Redis提供了lua-time-limit引數限制指令碼的最長執行時間。預設是5秒鐘。

非事務性操作

當指令碼執行時間超過這個限制後,Redis將開始接受其他命令但不會執行(以確保指令碼的原子性),而是返回BUSY的錯誤,下面演示一下這種情況。

開啟兩個使用者端視窗,在第一個視窗中執行lua指令碼的死迴圈

eval "while true do end" 0

在第二個視窗中執行get lua,會得到如下的異常。

(error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.

我們會發現執行結果是Busy, 接著我們通過script kill 的命令終止當前執行的指令碼,第二個視窗的顯示又恢復正常了。

存在事務性操作

如果當前執行的Lua指令碼對Redis的資料進行了修改(SET、DEL等),那麼通過SCRIPT KILL 命令是不能終止指令碼執行的,因為要保證指令碼執行的原子性,如果指令碼執行了一部分終止,那就違背了指令碼原子性的要求。最終要保證指令碼要麼都執行,要麼都不執行

同樣開啟兩個視窗,第一個視窗執行如下命令

eval "redis.call('set','name','ljx') while true do end" 0

在第二個視窗執行

get lua

結果一樣,仍然是busy,但是這個時候通過script kill命令,會發現報錯,沒辦法kill。遇到這種情況,只能通過shutdown nosave命令來強行終止redis。shutdown nosave和shutdown的區別在於 shutdown nosave不會進行持久化操作,意味著發生在上一次快照後的資料庫修改都會丟失。