Redis與Lua及Redis-py應用Lua

xbynet發表於2016-12-22

基本命令

Redis 指令碼使用 Lua 直譯器來執行指令碼。 Reids 2.6 版本通過內嵌支援 Lua 環境。執行指令碼的常用命令為 EVAL。

EVAL script numkeys key [key ...] arg [arg ...]
EVAL "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"

1 EVAL script numkeys key [key …] arg [arg …] 執行 Lua 指令碼。
2 EVALSHA sha1 numkeys key [key …] arg [arg …] 執行 Lua 指令碼。
3 SCRIPT EXISTS script [script …] 檢視指定的指令碼是否已經被儲存在快取當中。
4 SCRIPT FLUSH 從指令碼快取中移除所有指令碼。
5 SCRIPT KILL 殺死當前正在執行的 Lua 指令碼。
6 SCRIPT LOAD script 將指令碼 script 新增到指令碼快取中,但並不立即執行這個指令碼。

Redis Eval 命令使用 Lua 直譯器執行指令碼。

EVAL script numkeys key [key …] arg [arg …]
引數說明
script: 引數是一段 Lua 5.1 指令碼程式。指令碼不必(也不應該)定義為一個 Lua 函式。
numkeys: 用於指定鍵名引數的個數。
key [key …]: 從 EVAL 的第三個引數開始算起,表示在指令碼中所用到的那些 Redis 鍵(key),這些鍵名引數可以在 Lua 中通過全域性變數 KEYS 陣列,用 1 為基址的形式訪問( KEYS[1] , KEYS[2] ,以此類推)。
arg [arg …]: 附加引數,在 Lua 中通過全域性變數 ARGV 陣列訪問,訪問的形式和 KEYS 變數類似( ARGV[1] 、 ARGV[2] ,諸如此類)。

Redis Evalsha 命令根據給定的 sha1 校驗碼,執行快取在伺服器中的指令碼。
EVALSHA sha1 numkeys key [key …] arg [arg …]

redis 127.0.0.1:6379> SCRIPT LOAD "return `hello moto`"
"232fd51614574cf0867b83d384a5e898cfd24e5a"
 
redis 127.0.0.1:6379> EVALSHA "232fd51614574cf0867b83d384a5e898cfd24e5a" 0
"hello moto"

Redis Script Exists 命令用於校驗指定的指令碼是否已經被儲存在快取當中。
SCRIPT EXISTS script [script …]

redis 127.0.0.1:6379> SCRIPT LOAD "return `hello moto`"    # 載入一個指令碼
"232fd51614574cf0867b83d384a5e898cfd24e5a"
 
redis 127.0.0.1:6379> SCRIPT EXISTS 232fd51614574cf0867b83d384a5e898cfd24e5a
1) (integer) 1
 
redis 127.0.0.1:6379> SCRIPT FLUSH     # 清空快取
OK
 
redis 127.0.0.1:6379> SCRIPT EXISTS 232fd51614574cf0867b83d384a5e898cfd24e5a
1) (integer) 0

SCRIPT FLUSH 從指令碼快取中移除所有指令碼。
SCRIPT KILL 殺死當前正在執行的 Lua 指令碼。
SCRIPT LOAD script 將指令碼 script 新增到指令碼快取中,但並不立即執行這個指令碼。

詳細說明

這是從一個Lua指令碼中使用兩個不同的Lua函式來呼叫Redis的命令的例子:

redis.call()
redis.pcall()

redis.call() 與 redis.pcall()很類似, 他們唯一的區別是當redis命令執行結果返回錯誤時, redis.call()將返回給呼叫者一個錯誤,而redis.pcall()會將捕獲的錯誤以Lua表的形式返回
redis.call() 和 redis.pcall() 兩個函式的引數可以是任意的 Redis 命令:

> eval "return redis.call(`set`,`foo`,`bar`)" 0
OK

需要注意的是,上面這段指令碼的確實現了將鍵 foo 的值設為 bar 的目的,但是,它違反了 EVAL 命令的語義,因為指令碼里使用的所有鍵都應該由 KEYS 陣列來傳遞,就像這樣:

> eval "return redis.call(`set`,KEYS[1],`bar`)" 1 foo
OK

要求使用正確的形式來傳遞鍵(key)是有原因的,**因為不僅僅是 EVAL 這個命令,所有的 Redis 命令,在執行之前都會被分析,籍此來確定命令會對哪些鍵進行操作。
因此,對於 EVAL 命令來說,必須使用正確的形式來傳遞鍵,才能確保分析工作正確地執行。 **

Lua 資料型別和 Redis 資料型別之間轉換

當 Lua 通過 call() 或 pcall() 函式執行 Redis 命令的時候,命令的返回值會被轉換成 Lua 資料結構。 同樣地,當 Lua 指令碼在 Redis 內建的直譯器裡執行時,Lua 指令碼的返回值也會被轉換成 Redis 協議(protocol),然後由 EVAL 將值返回給客戶端。
下面兩點需要重點注意:
lua中整數和浮點數之間沒有什麼區別。因此,我們始終將Lua的數字轉換成整數的回覆,這樣將捨去小數部分。如果你想從Lua返回一個浮點數,你應該將它作為一個字串
有兩個輔助函式從Lua返回Redis的型別。

  • redis.error_reply(error_string) returns an error reply. This function simply returns the single field table with the err field set to the specified string for you.

  • redis.status_reply(status_string) returns a status reply. This function simply returns the single field table with the ok field set to the specified string for you.

return {err="My Error"}
return redis.error_reply("My Error")

指令碼的原子性

Redis 使用單個 Lua 直譯器去執行所有指令碼,並且, Redis 也保證指令碼會以原子性(atomic)的方式執行: 當某個指令碼正在執行的時候,不會有其他指令碼或 Redis 命令被執行。 這和使用 MULTI / EXEC 包圍的事務很類似。 在其他別的客戶端看來,指令碼的效果(effect)要麼是不可見的(not visible),要麼就是已完成的(already completed)。

指令碼快取和 EVALSHA

EVAL 命令要求你在每次執行指令碼的時候都傳送一次指令碼主體(script body)。Redis 有一個內部的指令碼快取機制,因此它不會每次都重新編譯指令碼
EVALSHA 命令,它的作用和 EVAL 一樣,都用於對指令碼求值,但它接受的第一個引數不是指令碼,而是指令碼的 SHA1 校驗和(sum)。
客戶端庫的底層實現可以一直樂觀地使用 EVALSHA 來代替 EVAL ,並期望著要使用的指令碼已經儲存在伺服器上了,只有當 NOSCRIPT 錯誤發生時,才使用 EVAL 命令重新傳送指令碼,這樣就可以最大限度地節省頻寬。
重新整理指令碼快取的唯一辦法是顯式地呼叫 SCRIPT FLUSH 命令,這個命令會清空執行過的所有指令碼的快取。通常只有在雲端計算環境中,才會執行這個命令。

Redis對lua指令碼做出的限制

  • 不能訪問系統時間或者其他內部狀態

  • Redis 會返回一個錯誤,阻止這樣的指令碼執行: 這些指令碼在執行隨機命令之後(比如 RANDOMKEY 、 SRANDMEMBER 或 TIME 等),還會執行可以修改資料集的 Redis 命令。如果指令碼只是執行只讀操作,那麼就沒有這一限制。

  • 每當從 Lua 指令碼中呼叫那些返回無序元素的命令時,執行命令所得的資料在返回給 Lua 之前會先執行一個靜默(slient)的字典序排序(lexicographical sorting)。舉個例子,因為 Redis 的 Set 儲存的是無序的元素,所以在 Redis 命令列客戶端中直接執行 SMEMBERS ,返回的元素是無序的,但是,假如在指令碼中執行 redis.call(“smembers”, KEYS[1]) ,那麼返回的總是排過序的元素。

  • 對 Lua 的偽隨機數生成函式 math.random 和 math.randomseed 進行修改,使得每次在執行新指令碼的時候,總是擁有同樣的 seed 值。這意味著,每次執行指令碼時,只要不使用 math.randomseed ,那麼 math.random 產生的隨機數序列總是相同的。

  • 全域性變數保護,為了防止不必要的資料洩漏進 Lua 環境, Redis 指令碼不允許建立全域性變數。如果一個指令碼需要在多次執行之間維持某種狀態,它應該使用 Redis key 來進行狀態儲存。避免引入全域性變數的一個訣竅是:將指令碼中用到的所有變數都使用 local 關鍵字定義為區域性變數。

可用庫

Redis Lua直譯器可用載入以下Lua庫:
base
table
string
math
debug
struct 一個Lua裝箱/拆箱的庫
cjson 為Lua提供極快的JSON處理
cmsgpack為Lua提供了簡單、快速的MessagePack操縱
bitop 為Lua的位運算模組增加了按位運算元。
redis.sha1hex function. 對字串執行SHA1演算法
每一個Redis例項都擁有以上的所有類庫,以確保您使用指令碼的環境都是一樣的。
struct, CJSON 和 cmsgpack 都是外部庫, 所有其他庫都是標準。

redis 127.0.0.1:6379> eval `return cjson.encode({["foo"]= "bar"})` 0
"{"foo":"bar"}"
redis 127.0.0.1:6379> eval `return cjson.decode(ARGV[1])["foo"]` 0 "{"foo":"bar"}"
"bar"

127.0.0.1:6379> eval `return cmsgpack.pack({"foo", "bar", "baz"})` 0
"x93xa3fooxa3barxa3baz"
127.0.0.1:6379> eval `return cmsgpack.unpack(ARGV[1])` 0 "x93xa3fooxa3barxa3baz"
1) "foo"
2) "bar"
3) "baz"

使用指令碼記錄Redis 日誌

在 Lua 指令碼中,可以通過呼叫 redis.log 函式來寫 Redis 日誌(log):

redis.log(loglevel,message)

其中, message 引數是一個字串,而 loglevel 引數可以是以下任意一個值:

  • redis.LOG_DEBUG

  • redis.LOG_VERBOSE

  • redis.LOG_NOTICE

  • redis.LOG_WARNING
    上面的這些等級(level)和標準 Redis 日誌的等級相對應。

只有那些和當前 Redis 例項所設定的日誌等級相同或更高階的日誌才會被散發。
以下是一個日誌示例:

redis.log(redis.LOG_WARNING, "Something is wrong with this script.")
執行上面的函式會產生這樣的資訊:
[32343] 22 Mar 15:21:39 # Something is wrong with this script.

沙箱(sandbox)和最大執行時間

指令碼應該僅僅用於傳遞引數和對 Redis 資料進行處理,它不應該嘗試去訪問外部系統(比如檔案系統),或者執行任何系統呼叫。
除此之外,指令碼還有一個最大執行時間限制,它的預設值是 5 秒鐘,一般正常運作的指令碼通常可以在幾分之幾毫秒之內完成,花不了那麼多時間,這個限制主要是為了防止因程式設計錯誤而造成的無限迴圈而設定的。
最大執行時間的長短由 lua-time-limit 選項來控制(以毫秒為單位),可以通過編輯 redis.conf 檔案或者使用 CONFIG GET 和 CONFIG SET 命令來修改它。

當一個指令碼達到最大執行時間的時候,它並不會自動被 Redis 結束,因為 Redis 必須保證指令碼執行的原子性,而中途停止指令碼的執行意味著可能會留下未處理完的資料在資料集(data set)裡面。
因此,當指令碼執行的時間超過最大執行時間後,以下動作會被執行:
Redis 記錄一個指令碼正在超時執行
Redis 開始重新接受其他客戶端的命令請求,但是隻有 SCRIPT KILL 和 SHUTDOWN NOSAVE 兩個命令會被處理,對於其他命令請求, Redis 伺服器只是簡單地返回 BUSY 錯誤。
可以使用 SCRIPT KILL 命令將一個僅執行只讀命令的指令碼殺死,因為只讀命令並不修改資料,因此殺死這個指令碼並不破壞資料的完整性
如果指令碼已經執行過寫命令,那麼唯一允許執行的操作就是 SHUTDOWN NOSAVE ,它通過停止伺服器來阻止當前資料集寫入磁碟

pipeline上下文(context)中的 EVALSHA

一旦在pipeline中因為 EVALSHA 命令而發生 NOSCRIPT 錯誤,那麼這個pipeline就再也沒有辦法重新執行了,否則的話,命令的執行順序就會被打亂。
為了防止出現以上所說的問題,客戶端庫實現應該實施以下的其中一項措施:

  • 總是在pipeline中使用 EVAL 命令

  • 檢查pipeline中要用到的所有命令,找到其中的 EVAL 命令,並使用 SCRIPT EXISTS 命令檢查要用到的指令碼是不是全都已經儲存在快取裡面了。如果所需的全部指令碼都可以在快取裡找到,那麼就可以放心地將所有 EVAL 命令改成 EVALSHA 命令,否則的話,就要在pipeline的頂端(top)將缺少的指令碼用 SCRIPT LOAD 命令加上去。

案例1-實現訪問頻率限制:

實現訪問者 $ip 在一定的時間 $time 內只能訪問 $limit 次.
非指令碼實現

private boolean accessLimit(String ip, int limit, int time, Jedis jedis) {
    boolean result = true;

    String key = "rate.limit:" + ip;
    if (jedis.exists(key)) {
        long afterValue = jedis.incr(key);
        if (afterValue > limit) {
            result = false;
        }
    } else {
        Transaction transaction = jedis.multi();
        transaction.incr(key);
        transaction.expire(key, time);
        transaction.exec();
    }
    return result;
}

以上程式碼有兩點缺陷

  • 可能會出現競態條件: 解決方法是用 WATCH 監控 rate.limit:$IP 的變動, 但較為麻煩;

  • 以上程式碼在不使用 pipeline 的情況下最多需要向Redis請求5條指令, 傳輸過多.

Lua指令碼實現
Redis 允許將 Lua 指令碼傳到 Redis 伺服器中執行, 指令碼內可以呼叫大部分 Redis 命令, 且 Redis 保證指令碼的 原子性 :
首先需要準備Lua程式碼: script.lua

local key = "rate.limit:" .. KEYS[1]
local limit = tonumber(ARGV[1])
local expire_time = ARGV[2]

local is_exists = redis.call("EXISTS", key)
if is_exists == 1 then
    if redis.call("INCR", key) > limit then
        return 0
    else
        return 1
    end
else
    redis.call("SET", key, 1)
    redis.call("EXPIRE", key, expire_time)
    return 1
end

Java

private boolean accessLimit(String ip, int limit, int timeout, Jedis connection) throws IOException {
    List<String> keys = Collections.singletonList(ip);
    List<String> argv = Arrays.asList(String.valueOf(limit), String.valueOf(timeout));

    return 1 == (long) connection.eval(loadScriptString("script.lua"), keys, argv);
}

// 載入Lua程式碼
private String loadScriptString(String fileName) throws IOException {
    Reader reader = new InputStreamReader(Client.class.getClassLoader().getResourceAsStream(fileName));
    return CharStreams.toString(reader);
}

Lua 嵌入 Redis 優勢:

  • 減少網路開銷: 不使用 Lua 的程式碼需要向 Redis 傳送多次請求, 而指令碼只需一次即可, 減少網路傳輸;

  • 原子操作: Redis 將整個指令碼作為一個原子執行, 無需擔心併發, 也就無需事務;

  • 複用: 指令碼會永久儲存 Redis 中, 其他客戶端可繼續使用.

案例2-使用Lua指令碼重新構建帶有過期時間的分散式鎖.

案例來源: < Redis實戰 > 第6、11章, 構建步驟:

  • 鎖申請

  • 首先嚐試加鎖:

  • 成功則為鎖設定過期時間; 返回;

  • 失敗檢測鎖是否新增了過期時間;

  • wait.

  • 鎖釋放

  • 檢查當前執行緒是否真的持有了該鎖:

  • 持有: 則釋放; 返回成功;

  • 失敗: 返回失敗.

非Lua實現

String acquireLockWithTimeOut(Jedis connection, String lockName, long acquireTimeOut, int lockTimeOut) {
    String identifier = UUID.randomUUID().toString();
    String key = "lock:" + lockName;

    long acquireTimeEnd = System.currentTimeMillis() + acquireTimeOut;
    while (System.currentTimeMillis() < acquireTimeEnd) {
        // 獲取鎖並設定過期時間
        if (connection.setnx(key, identifier) != 0) {
            connection.expire(key, lockTimeOut);
            return identifier;
        }
        // 檢查過期時間, 並在必要時對其更新
        else if (connection.ttl(key) == -1) {
            connection.expire(key, lockTimeOut);
        }

        try {
            Thread.sleep(10);
        } catch (InterruptedException ignored) {
        }
    }
    return null;
}

boolean releaseLock(Jedis connection, String lockName, String identifier) {
    String key = "lock:" + lockName;

    connection.watch(key);
    // 確保當前執行緒還持有鎖
    if (identifier.equals(connection.get(key))) {
        Transaction transaction = connection.multi();
        transaction.del(key);
        return transaction.exec().isEmpty();
    }
    connection.unwatch();

    return false;
}

Lua指令碼實現
Lua指令碼: acquire

local key = KEYS[1]
local identifier = ARGV[1]
local lockTimeOut = ARGV[2]

-- 鎖定成功
if redis.call("SETNX", key, identifier) == 1 then
    redis.call("EXPIRE", key, lockTimeOut)
    return 1
elseif redis.call("TTL", key) == -1 then
    redis.call("EXPIRE", key, lockTimeOut)
end
return 0

Lua指令碼: release

local key = KEYS[1]
local identifier = ARGV[1]

if redis.call("GET", key) == identifier then
    redis.call("DEL", key)
    return 1
end
return 0

參考:http://www.redis.cn/commands/…
http://www.redis.net.cn/tutor…
http://www.oschina.net/transl…
http://www.tuicool.com/articl…

相關文章