天天看點

Redis中使用Lua腳本

Redis中使用Lua腳本

一、簡介

  1. Redis中為什麼引入Lua腳本?

    Redis是高性能的key-value記憶體資料庫,在部分場景下,是對關系資料庫的良好補充。

    Redis提供了非常豐富的指令集,官網上提供了200多個指令。但是某些特定領域,需要擴充若幹指令原子性執行時,僅使用原生指令便無法完成。

    Redis 為這樣的使用者場景提供了 lua 腳本支援,使用者可以向伺服器發送 lua 腳本來執行自定義動作,擷取腳本的響應資料。Redis 伺服器會單線程原子性執行 lua 腳本,保證 lua 腳本在處理的過程中不會被任意其它請求打斷。

  2. Redis意識到上述問題後,在2.6版本推出了 lua 腳本功能,允許開發者使用Lua語言編寫腳本傳到Redis中執行。使用腳本的好處如下:
  • 減少網絡開銷。可以将多個請求通過腳本的形式一次發送,減少網絡時延。
  • 原子操作。Redis會将整個腳本作為一個整體執行,中間不會被其他請求插入。是以在腳本運作過程中無需擔心會出現競态條件,無需使用事務。
  • 複用。用戶端發送的腳本會永久存在redis中,這樣其他用戶端可以複用這一腳本,而不需要使用代碼完成相同的邏輯。

3. 什麼是Lua?

Lua是一種輕量小巧的腳本語言,用标準C語言編寫并以源代碼形式開放。

其設計目的就是為了嵌入應用程式中,進而為應用程式提供靈活的擴充和定制功能。因為廣泛的應用于:遊戲開發、獨立應用腳本、Web 應用腳本、擴充和資料庫插件等。

比如:Lua腳本用在很多遊戲上,主要是Lua腳本可以嵌入到其他程式中運作,遊戲更新的時候,可以直接更新腳本,而不用重新安裝遊戲。

Lua腳本的基本文法可參考:菜鳥教程

二、Redis中Lua的常用指令

指令不多,就下面這幾個:

- EVAL

- EVALSHA

- SCRIPT LOAD - SCRIPT EXISTS

- SCRIPT FLUSH

- SCRIPT KILL

2.1 EVAL指令

指令格式:

EVAL script numkeys key [key …] arg [arg …]

script

參數是一段 Lua5.1 腳本程式。腳本不必(也不應該[^1])定義為一個 Lua 函數

numkeys

指定後續參數有幾個key,即:key [key …]中key的個數。如沒有key,則為0

key [key …]

 從 EVAL 的第三個參數開始算起,表示在腳本中所用到的那些 Redis 鍵(key)。在Lua腳本中通過KEYS[1], KEYS[2]擷取。

arg [arg …]

 附加參數。在Lua腳本中通過ARGV[1],ARGV[2]擷取。

// 例1:numkeys=1,keys數組隻有1個元素key1,arg數組無元素
127.0.0.1:6379> EVAL "return KEYS[1]" 1 key1
"key1"

// 例2:numkeys=0,keys數組無元素,arg數組元素中有1個元素value1
127.0.0.1:6379> EVAL "return ARGV[1]" 0 value1
"value1"

// 例3:numkeys=2,keys數組有兩個元素key1和key2,arg數組元素中有兩個元素first和second 
//      其實{KEYS[1],KEYS[2],ARGV[1],ARGV[2]}表示的是Lua文法中“使用預設索引”的table表,
//      相當于java中的map中存放四條資料。Key分别為:1、2、3、4,而對應的value才是:KEYS[1]、KEYS[2]、ARGV[1]、ARGV[2]
//      舉此例子僅為說明eval指令中參數的如何使用。項目中編寫Lua腳本最好遵從key、arg的規範。
127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second 
1) "key1"
2) "key2"
3) "first"
4) "second"


// 例4:使用了redis為lua内置的redis.call函數
//      腳本内容為:先執行SET指令,在執行EXPIRE指令
//      numkeys=1,keys數組有一個元素userAge(代表redis的key)
//      arg數組元素中有兩個元素:10(代表userAge對應的value)和60(代表redis的存活時間)
127.0.0.1:6379> EVAL "redis.call('SET', KEYS[1], ARGV[1]);redis.call('EXPIRE', KEYS[1], ARGV[2]); return 1;" 1 userAge 10 60
(integer) 1
127.0.0.1:6379> get userAge
"10"
127.0.0.1:6379> ttl userAge
(integer) 44           

通過上面的例4,我們可以發現,腳本中使用redis.call()去調用redis的指令。

在 Lua 腳本中,可以使用兩個不同函數來執行 Redis 指令,它們分别是: 

redis.call() 和 redis.pcall()

這兩個函數的唯一差別在于它們使用不同的方式處理執行指令所産生的錯誤,差别如下:

錯誤處理

當 redis.call() 在執行指令的過程中發生錯誤時,腳本會停止執行,并傳回一個腳本錯誤,錯誤的輸出資訊會說明錯誤造成的原因:

127.0.0.1:6379> lpush foo a
(integer) 1

127.0.0.1:6379> eval "return redis.call('get', 'foo')" 0
(error) ERR Error running script (call to f_282297a0228f48cd3fc6a55de6316f31422f5d17): ERR Operation against a key holding the wrong kind of value           

和 redis.call() 不同, redis.pcall() 出錯時并不引發(raise)錯誤,而是傳回一個帶 err 域的 Lua 表(table),用于表示錯誤:

127.0.0.1:6379> EVAL "return redis.pcall('get', 'foo')" 0
(error) ERR Operation against a key holding the wrong kind of value           

2.2 SCRIPT LOAD指令 和 EVALSHA指令

SCRIPT LOAD指令格式:

SCRIPT LOAD script

EVALSHA指令格式:

EVALSHA sha1 numkeys key [key …] arg [arg …]

這兩個指令放在一起講的原因是:

EVALSHA

 指令中的sha1參數,就是

SCRIPT LOAD

 指令執行的結果。

SCRIPT LOAD

 将腳本 script 添加到Redis伺服器的腳本緩存中,并不立即執行這個腳本,而是會立即對輸入的腳本進行求值。并傳回給定腳本的 SHA1 校驗和。如果給定的腳本已經在緩存裡面了,那麼不執行任何操作。

在腳本被加入到緩存之後,在任何用戶端通過

EVALSHA

指令,可以使用腳本的 SHA1 校驗和來調用這個腳本。腳本可以在緩存中保留無限長的時間,直到執行

SCRIPT FLUSH

為止。

## SCRIPT LOAD加載腳本,并得到sha1值
127.0.0.1:6379> SCRIPT LOAD "redis.call('SET', KEYS[1], ARGV[1]);redis.call('EXPIRE', KEYS[1], ARGV[2]); return 1;"
"6aeea4b3e96171ef835a78178fceadf1a5dbe345"

## EVALSHA使用sha1值,并拼裝和EVAL類似的numkeys和key數組、arg數組,調用腳本。
127.0.0.1:6379> EVALSHA 6aeea4b3e96171ef835a78178fceadf1a5dbe345 1 userAge 10 60
(integer) 1
127.0.0.1:6379> get userAge
"10"
127.0.0.1:6379> ttl userAge
(integer) 43           

2.3 SCRIPT EXISTS 指令

SCRIPT EXISTS sha1 [sha1 …]

作用:給定一個或多個腳本的 SHA1 校驗和,傳回一個包含 0 和 1 的清單,表示校驗和所指定的腳本是否已經被儲存在緩存當中

127.0.0.1:6379> SCRIPT EXISTS 6aeea4b3e96171ef835a78178fceadf1a5dbe345
1) (integer) 1
127.0.0.1:6379> SCRIPT EXISTS 6aeea4b3e96171ef835a78178fceadf1a5dbe346
1) (integer) 0
127.0.0.1:6379> SCRIPT EXISTS 6aeea4b3e96171ef835a78178fceadf1a5dbe345 6aeea4b3e96171ef835a78178fceadf1a5dbe366
1) (integer) 1
2) (integer) 0           

2.4 SCRIPT FLUSH 指令

SCRIPT FLUSH

作用:清除Redis服務端所有 Lua 腳本緩存

127.0.0.1:6379> SCRIPT EXISTS 6aeea4b3e96171ef835a78178fceadf1a5dbe345
1) (integer) 1
127.0.0.1:6379> SCRIPT FLUSH
OK
127.0.0.1:6379> SCRIPT EXISTS 6aeea4b3e96171ef835a78178fceadf1a5dbe345
1) (integer) 0           

2.5 SCRIPT KILL 指令

SCRIPT FLUSH

作用:殺死目前正在運作的 Lua 腳本,當且僅當這個腳本沒有執行過任何寫操作時,這個指令才生效。 這個指令主要用于終止運作時間過長的腳本,比如一個因為 BUG 而發生無限 loop 的腳本,諸如此類。

假如目前正在運作的腳本已經執行過寫操作,那麼即使執行

SCRIPT KILL

,也無法将它殺死,因為這是違反 Lua 腳本的原子性執行原則的。在這種情況下,唯一可行的辦法是使用

SHUTDOWN NOSAVE

指令,通過停止整個 Redis 程序來停止腳本的運作,并防止不完整(half-written)的資訊被寫入資料庫中。

三、Redis執行Lua腳本檔案

在第二章中介紹的指令,是在redis用戶端中使用指令進行操作。該章節介紹的是直接執行 Lua 的腳本檔案。

3.1 編寫Lua腳本檔案

local key = KEYS[1]
local val = redis.call("GET", key);

if val == ARGV[1]
then
        redis.call('SET', KEYS[1], ARGV[2])
        return 1
else
        return 0
end           

3.2 執行Lua腳本檔案

執行指令: redis-cli -a 密碼 --eval Lua腳本路徑 key [key …] ,  arg [arg …] 
如:redis-cli -a 123456 --eval ./Redis_CompareAndSet.lua userName , zhangsan lisi            

此處敲黑闆,注意啦!!!

"--eval"而不是指令模式中的"eval",一定要有前端的兩個-

腳本路徑後緊跟key [key …],相比指令行模式,少了numkeys這個key數量值

key [key …] 和 arg [arg …] 之間的“ , ”,英文逗号前後必須有空格,否則死活都報錯

## Redis用戶端執行
127.0.0.1:6379> set userName zhangsan 
OK
127.0.0.1:6379> get userName
"zhangsan"

## linux伺服器執行
## 第一次執行:compareAndSet成功,傳回1
## 第二次執行:compareAndSet失敗,傳回0
[root@vm01 learn_lua]# redis-cli -a 123456 --eval Redis_CompareAndSet.lua userName , zhangsan lisi
(integer) 1
[root@vm01 learn_lua]# redis-cli -a 123456 --eval Redis_CompareAndSet.lua userName , zhangsan lisi
(integer) 0           

四、執行個體:使用Lua控制IP通路頻率

需求:實作一個通路頻率控制,某個IP在短時間内頻繁通路頁面,需要記錄并檢測出來,就可以通過Lua腳本高效的實作。

小聲說明:本執行個體針對固定視窗的通路頻率,而動态的非滑動視窗。即:如果規定一分鐘内通路10次,記為超限。在本執行個體中前一分鐘的最後一秒通路9次,下一分鐘的第1秒又通路9次,不計為超限。

腳本如下:

local visitNum = redis.call('incr', KEYS[1])

if visitNum == 1 then
        redis.call('expire', KEYS[1], ARGV[1])
end

if visitNum > tonumber(ARGV[2]) then
        return 0
end

return 1;           
## LimitIP:127.0.0.1為key, 10 3表示:同一IP在10秒内最多通路三次
## 前三次傳回1,代表未被限制;第四、五次傳回0,代表127.0.0.1這個ip已被攔截
[root@vm01 learn_lua]# redis-cli -a 123456 --eval Redis_LimitIpVisit.lua LimitIP:127.0.0.1 , 10 3
 (integer) 1
[root@vm01 learn_lua]# redis-cli -a 123456 --eval Redis_LimitIpVisit.lua LimitIP:127.0.0.1 , 10 3
 (integer) 1
[root@vm01 learn_lua]# redis-cli -a 123456 --eval Redis_LimitIpVisit.lua LimitIP:127.0.0.1 , 10 3
 (integer) 1
[root@vm01 learn_lua]# redis-cli -a 123456 --eval Redis_LimitIpVisit.lua LimitIP:127.0.0.1 , 10 3
 (integer) 0
[root@vm01 learn_lua]# redis-cli -a 123456 --eval Redis_LimitIpVisit.lua LimitIP:127.0.0.1 , 10 3
 (integer) 0           

五、總結

  1. 通過上面一系列的介紹,對Lua腳本、Lua基礎文法有了一定了解,同時也學會在Redis中如何去使用Lua腳本去實作Redis指令無法實作的場景
  2. 回頭再思考文章開頭提到的Redis使用Lua腳本的幾個優點:減少網絡開銷、原子性、複用

參考資料

  1. 菜鳥教程 -> Lua教程:https://www.runoob.com/lua/lua-data-types.html
  2. Redis官方指令參考:http://redisdoc.com/script/eval.html
  3. 《Redis設計與實作》-黃健宏著
  4. 掘金小冊 -> Redis 深度曆險:核心原理與應用實踐