Redis之Lua的應用(四)

一、什麼是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腳本有自己的語法、變數、邏輯運算符、函數等,這塊我就不在這裡做過多的說明,可以自己進入下面鏈接進去看

//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不會進行持久化操作,意味著發生在上一次快照後的資料庫修改都會丟失。

Tags: