Redis 事務與 Lua 腳本詳解
Redis 事務詳解
Redis 的事務和 MySQL 的事務在概念上是類似的,都是把一系列操作綁定成一組,讓這一組能夠批量執行。不過,Redis 事務與 MySQL 事務存在以下區別:
-
弱化的原子性:Redis 沒有回滾機制,只能做到批量執行,不能做到一個失敗就恢復到初始狀態。如果事務中若干個操作存在失敗的,那就失敗,不會有回滾操作。
-
不保證一致性:Redis 事務不涉及約束,也沒有回滾,事務執行過程中如果某個修改操作出現失敗,就可能引起不一致的情況。而 MySQL 的一致性體現在運行事務前後,結果都是合理有效的,不會出現中間非法狀態。
-
不需要隔離性:Redis 也沒有隔離級別,因爲不會併發執行事務(Redis 單線程處理請求)。
-
不需要持久性:Redis 事務是保存在內存的,是否開啓持久化是 redis-server 自己的事情,和事務無關。並且,如果 Redis 按照集羣模式部署,就不支持事務。
Redis 事務本質上是在服務器上創建了一個事務隊列。每次客戶端在事務中進行一個操作,都會把命令先發給服務器,放到事務隊列中(但是不會立即執行),而是在真正收到 EXEC 命令之後,才真正執行隊列中的所有操作。因此,Redis 事務的功能相比於 MySQL 來說是弱化很多的,只能保證事務中的這幾個操作是連續的,不會被別的客戶端 “加塞”。Redis 事務的意義就在於 “打包”,避免其他客戶端的命令插隊到中間。
Redis 事務的主要操作如下:
-
MULTI:開啓一個事務,執行成功返回 OK。
-
EXEC:真正執行事務。每次添加一個操作,都會提示 “QUEUED”,說明命令已經進入服務端的隊列了。真正執行 EXEC 的時候,服務器纔會真正執行命令。
-
DISCARD:放棄當前事務,此時直接清空事務隊列,之前的操作都不會真正執行到。
-
WATCH:監控一組具體的 key。當開啓事務的時候,如果對 WATCH 的 key 進行修改,就會記錄當前 key 的版本號。在真正提交事務的時候,如果發現當前服務器上的 key 的版本號已經超過了事務開始時的版本號,就會讓事務執行失敗。WATCH 本質上是給 EXEC 加了個判定條件,屬於 “樂觀鎖”。
-
UNWATCH:取消對 key 的監控,相當於 WATCH 的逆操作。
Lua 腳本詳解
Lua 是一種輕量小巧的腳本語言,用標準 C 語言編寫並以源代碼形式開放,其設計目的是爲了嵌入應用程序中,從而爲應用程序提供靈活的擴展和定製功能。Lua 語言的特點包括:
-
輕量級:編譯後僅僅一百餘 K,可以很方便的嵌入別的程序裏。
-
可擴展:Lua 提供了非常易於使用的擴展接口和機制,由宿主語言(通常是 C 或 C++)提供功能,Lua 可以使用它們,就像是內置的功能一樣。
-
支持多種編程範式:支持面向過程(procedure-oriented)編程和函數式編程(functional programming)。
-
自動內存管理。
-
數據類型簡單:只提供了一種通用類型的表(table),用它可以實現數組、哈希表、集合、對象等。
Lua 語言的應用場景包括遊戲開發、獨立應用腳本、Web 應用腳本、擴展和數據庫插件(如 MySQL Proxy 和 MySQL WorkBench)、安全系統(如入侵檢測系統)等。
案例分析
以下是一個 Redis 整合 Lua 腳本實現限流效果的案例:
限流是指某應用模塊需要限制指定 IP(或指定模塊、指定應用)在單位時間內的訪問次數。例如,在某高併發場景裏,會員查詢模塊對風險控制模塊的限流需求是在 10 秒裏最多允許有 1000 個請求。Lua 腳本天然具有原子性,而且執行 Lua 腳本的 Redis 服務器是以單線程模式處理命令,所以用 Lua 腳本能有效地實現限流。
以下是一個實現基於計數模式的限流功能的 Lua 腳本:
local obj=KEYS[1]
local limitNum=tonumber(redis.call('get',obj)or"0")
local curVisitNum=tonumber(redis.call('get',obj) or "0") + 1
if curVisitNum>limitNum then
return 0
else
redis.call("INCRBY",obj,"1")
redis.call("EXPIRE",obj,tonumber(ARGV[2]))
return curVisitNum
end
該腳本共有 3 個參數:
-
KEYS[1] 用來接收待限流的對象。
-
ARGV[1] 表示限流的次數。
-
ARGV[2] 表示限流的時間單位。
腳本的功能是限制 KEYS[1] 對象在 ARGV[2] 時間範圍內只能訪問 ARGV[1] 次。以下是腳本的詳細解釋:
-
第 1 行:用 KEYS[1] 接收待限流的對象,比如模塊或應用等,並把它賦給 obj 變量。
-
第 2 行:用 ARGV[1] 參數接收表示限流次數的對象賦給 limitNum,這裏需要用 tonumber 方法把包含限流次數的 ARGV[1] 參數轉爲數值類型。不過,此行代碼邏輯有誤,它重新獲取了一次待限流對象的當前訪問次數,而沒有使用 ARGV[1] 的值。正確的做法應該是去掉 redis.call('get',obj) 部分,直接使用 ARGV[1] 的值,或者使用一個不同的變量名來接收 ARGV[1] 的值。但考慮到後續代碼中需要用到當前訪問次數 + 1 的值進行判斷,因此此處的目的是獲取當前訪問次數以便進行 + 1 操作,然後誤將這部分代碼寫入了第 2 行。爲了保持腳本邏輯的連貫性,在解釋時將其理解爲 “準備進行當前訪問次數的 + 1 操作前的獲取步驟”(儘管在實際編寫時應避免這種容易引起誤解的寫法),並在後續用 curVisitNum 變量正確進行 + 1 操作。不過,在解釋後的腳本中,我會明確指出並修正這一邏輯錯誤。
-
第 3 行:通過 redis.call 方法調用 get 命令去獲取待限流對象當前的訪問次數,並賦給 curVisitNum 變量,如果獲取不到,表示當前對象還沒有訪問,就把 curVisitNum 變量設置爲 0(但在這個腳本的上下文中,由於緊接着有 + 1 操作,所以初始爲 0 是合理的,不過這一行實際上在邏輯上是冗餘的,因爲緊接着就會對 curVisitNum 進行 + 1 操作。然而,爲了保持與原始腳本的一致性並解釋其意圖,我仍保留這一行,但指出其冗餘性。在更簡潔的腳本中,可以直接從 0 開始並立即 + 1 進入判斷邏輯)。然後,對 curVisitNum 進行 + 1 操作,以便進行後續的判斷。注意,這裏的解釋對原始腳本的第 3 行進行了邏輯上的調整和解釋上的澄清,以指出其實際意圖和冗餘性。在實際編寫時,應更清晰地表達邏輯,避免這種冗餘和誤解。修正後的邏輯應該在判斷之前就已經完成了 curVisitNum 的 + 1 操作。但爲了與原文保持一致並解釋清楚,我在此保留了原始步驟的說明,並在括號中給出了修正建議。
-
第 4 行:通過 if 語句判斷待限流對象的訪問次數是否達到限流標準。如果是,就執行第 5 行的代碼,通過 return 語句返回 0。如果沒有達到限流標準,就執行第 6 行到第 8 行的代碼。
-
第 6 行:通過 INCRBY 命令對訪問次數加 1。
-
第 7 行:通過 EXPIRE 命令設置表示訪問次數的鍵值對的生存時間,即限流的時間範圍。
-
第 8 行:通過 return 語句返回當前對象的訪問次數。
也就是說,在調用該 Lua 腳本時,如果返回值是 0,就說明當前訪問量已經達到限流標準,否則還可以繼續訪問。
以下是一個調用上述腳本實現限流效果的 Java 代碼示例:
import redis.clients.jedis.Jedis;
public class LuaLimitByCount extends Thread {
@Override
public void run() {
Jedis jedis = new Jedis("192.168.159.33", 6379);
// 在本線程內,模擬在單位時間內發5個請求
for (int visitNum = 0; visitNum < 5; visitNum++) {
boolean visitFlag = LimitByCount.canVisit(jedis, Thread.currentThread().getName(), "10", "3");
if (visitFlag) {
System.out.println(Thread.currentThread().getName() + " can visit.");
} else {
System.out.println(Thread.currentThread().getName() + " can not visit.");
}
}
}
public static void main(String[] args) {
// 開啓3個線程
for (int cnt = 0; cnt < 3; cnt++) {
new LuaLimitByCount().start();
}
}
}
// 封裝是否需要限流的方法
class LimitByCount {
// 判斷是否需要限流
public static boolean canVisit(Jedis jedis, String modelName, String limitTime, String limitNum) {
String script = "local obj=KEYS[1] " +
"local limitNum=tonumber(ARGV[1]) " +
"local curVisitNum=tonumber(redis.call('get',obj) or \"0\")+1 " +
"if curVisitNum>limitNum then " +
" return 0 " +
"else " +
" redis.call(\"incrby\",obj,\"1\") " +
" redis.call(\"expire\",obj,ARGV[2])) " +
" return curVisitNum " +
" end";
Object result = jedis.eval(script, 1, modelName, limitNum, limitTime);
if (result.equals(0L)) {
return false;
} else {
return true;
}
}
解釋
在LimitByCount
類中,canVisit
方法用於判斷指定模塊(modelName
)是否能在指定時間(limitTime
)內訪問指定次數(limitNum
)。
-
jedis.eval
方法用於執行 Lua 腳本。該方法的第一個參數是 Lua 腳本,第二個參數是 key 的數量,後面跟隨的是 key 和參數列表。 -
KEYS[1]
對應的是modelName
,表示需要限流的模塊名稱。 -
ARGV[1]
對應的是limitNum
,表示在指定時間內的最大訪問次數。 -
ARGV[2]
對應的是limitTime
,表示限流的時間範圍,單位爲秒。
Lua 腳本的邏輯是:
-
獲取當前模塊的訪問次數(如果不存在則默認爲 0),並加 1。
-
如果加 1 後的訪問次數超過了最大訪問次數,則返回 0,表示不能訪問。
-
否則,使用
INCRBY
命令將訪問次數加 1,並使用EXPIRE
命令設置鍵值對的生存時間(即限流時間),然後返回當前的訪問次數,表示可以訪問。
在 Java 代碼中,LuaLimitByCount
類繼承自Thread
,並在run
方法中模擬了多個請求。每個請求都會調用LimitByCount.canVisit
方法來判斷是否可以訪問。根據返回值,打印相應的信息。
注意事項
-
Lua 腳本的原子性:Redis 在執行 Lua 腳本時,會將腳本作爲一個整體執行,期間不會被其他命令打斷,從而保證了原子性。
-
參數傳遞:在調用 Lua 腳本時,需要注意參數的傳遞順序和類型。在 Java 代碼中,使用
jedis.eval
方法時,需要按照script, keyCount, key1, arg1, arg2, ...
的順序傳遞參數。 -
異常處理:在實際應用中,需要添加異常處理邏輯,以處理可能發生的 Redis 連接異常、執行異常等。
-
性能考慮:雖然 Lua 腳本在 Redis 中執行具有較高的性能,但在高併發場景下,仍需要考慮腳本的執行時間和 Redis 服務器的負載情況。
通過上述案例,我們瞭解瞭如何使用 Redis 事務和 Lua 腳本實現限流功能。在實際應用中,可以根據具體需求對腳本進行定製和優化。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/aby4sJA4e0ik5142sdXIpA