1. 程式人生 > 其它 >Redis中使用Lua指令碼

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中,這樣其他客戶端可以複用這一指令碼,而不需要使用程式碼完成相同的邏輯。
  1. 什麼是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 指令碼程式。指令碼不必(也不應該)定義為一個 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 KILL
作用:殺死當前正在執行的 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實現令牌桶演算法

redis.replicate_commands();
-- 引數中傳遞的令牌key,基於選定的限流策略來定(唯一)
local key = KEYS[1]
-- 令牌桶填充 限流單位時間
local update_len = tonumber(ARGV[1])
-- 記錄 第一次訪問的時間戳
local key_time = key..'_FRT'
-- 獲取當前時間(這裡的curr_time_arr 中第一個是 秒數,第二個是 秒數後毫秒數),由於我是按秒計算的,這裡只要curr_time_arr[1](注意:redis陣列下標是從1開始的)
-- 如果需要獲得毫秒數 則為 tonumber(arr[1]*1000 + arr[2])
local curr_time_arr = redis.call('TIME')
-- 當前時間秒數
local nowTime = tonumber(curr_time_arr[1])
-- 從redis中獲取當前key 第一次訪問的時間戳,無直接賦值0,有即value
local curr_key_time = tonumber(redis.call('get', key_time) or 0)
-- 獲取當前key對應令牌桶中的令牌數,無直接賦值-1,有即value
local token_count = tonumber(redis.call('get', key) or -1)
-- 當前令牌桶的容量,使用者自定義初始化大小
local token_size = tonumber(ARGV[2])
-- 令牌桶數量小於0 說明令牌桶沒有初始化
if token_count < 0 then
	redis.call('set',key_time,nowTime)
	redis.call('set',key,token_size -1)
	return token_size -1
else
	if token_count > 0 then -- 當前令牌桶中令牌數夠用
	    redis.call('set',key,token_count -1)
		return token_count -1   -- 返回剩餘令牌數
	else    -- 當前令牌桶中令牌數已清空
       -- 判斷一下,當前時間秒數 與上次更新時間秒數  的間隔,是否大於規定時間間隔(update_len)
		if nowTime - curr_key_time > update_len then 
			redis.call('set',key,token_size -1)
			redis.call('set',key_time,nowTime)
			return token_size - 1
		else
			return -1
		end
	end
end

五、總結

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