管線、交易與 Lua:省 RTT 與原子性
· tech
📑 目錄
有三個東西常被混在一起,其實各解完全不同的問題:pipeline 解「網路來回太多」、MULTI/EXEC(交易)解「一組命令要一起執行不被插隊」、Lua 解「要原子、又要帶邏輯」。搞混它們,你會拿 pipeline 當交易用、或以為 Redis 交易像資料庫一樣能 rollback——兩個都會踩坑。這篇把三者的分工一次講清。
Pipelining:把 N 次往返壓成 1 次
先講最容易被低估的瓶頸:網路往返(RTT)。你連打 100 個命令,如果一條一條來——送出、等回應、再送下一條——那就是 100 個 RTT。命令本身在 Redis 裡快到微秒級,時間全花在網路來回上。Pipeline 就是把它們一次打包送出、回應一起收:
redis-cli --pipe 可以把大量命令灌進去,程式裡則是用 client 的 pipeline API。它跟「原子」一點關係都沒有——這是最常見的誤會。要「不被插隊」,得看下面兩個。
MULTI/EXEC:一起執行,但別叫它 ACID
MULTI 開一個交易,之後的命令先排隊不執行,直到 EXEC 才一次跑完、中間不被別的客戶端插隊。聽起來像資料庫交易,但有個關鍵差異會嚇到人:Redis 交易不能 rollback。
EXEC 前被別人改過,整個交易就作廢回 nil,由你重試——這是樂觀鎖(CAS),不是鎖住別人,是「有人搶先我就重來」落成命令,一個「安全扣款」的樂觀鎖長這樣:
WATCH balance:1 # 盯著餘額
# ...GET balance:1,在程式裡算出夠不夠扣...
MULTI
DECRBY balance:1 100
EXEC # 若 balance:1 在這期間被別人改過 → 回 nil,整批作廢,自己重試
Lua:真正「原子 + 帶邏輯」的那一個
MULTI 有個天生的限制:命令是預先排好隊的,你沒辦法「先讀一個值、再依結果決定要不要寫」——因為排隊時還沒有結果。WATCH + 重試能繞,但囉嗦。Lua 腳本才是現代的正解:EVAL 把整段腳本送到伺服器,靠 Redis 單執行緒的天性,整段原子執行、中間不被插隊,而且能在腳本裡讀值、做條件判斷、再寫:
-- 讀了再判斷再寫,整段原子(這是 MULTI 做不到的條件邏輯)
local b = tonumber(redis.call('GET', KEYS[1]))
if b >= 100 then
return redis.call('DECRBY', KEYS[1], 100) -- 夠才扣
else
return -1 -- 不夠就回報
end
一段 Lua 同時給你三件事:省 RTT(邏輯在伺服器端跑,不用來回)、原子(單執行緒保證整段不被打斷)、帶邏輯(可以 if/else)。這就是為什麼 分散式鎖釋放鎖時,一定要用 Lua 把「比對 token、相符才刪」包成一段——那正是「讀了再依結果決定寫」的原子操作,MULTI 給不了。
反思
「省 RTT」和「原子性」是兩個問題,先分清是哪個
我看過最常見的誤用,是拿 pipeline 當交易——以為把命令打包送出,它們就會「一起、不被打斷」。不會。pipeline 解的是網路(來回太多),交易/Lua 解的是並行(不想被插隊),這是兩個維度。 想通這點後,我選工具的第一問永遠是:我現在痛的是網路,還是並行? 痛網路(要打幾百條命令)就 pipeline;痛並行(這幾步不能被別人插進來)就 MULTI 或 Lua。把問題歸對類,工具自己就選好了——這比背 API 有用得多。
Redis 「交易不能 rollback」不是缺陷,是誠實
第一次知道 Redis 交易不會回滾,我有點傻眼——那還叫交易嗎?後來理解那是刻意的取捨:Redis 認為命令在執行期出錯,幾乎都是「你程式寫錯了」(對 String 用了 List 命令),那種錯就算 rollback 也救不回邏輯,不如保持引擎簡單快速、不背 rollback 這個重擔。它沒有假裝自己是關聯式資料庫。這件事教我一個看功能的習慣:別被名字唬住,去看它「實際保證什麼」。 「交易」「Secret」「鎖」這些詞都自帶光環,但光環底下的真實保證,常常比名字窄。真正要對照完整 ACID 的,還是回去看 資料庫的交易;Redis 的「交易」,就當它是「一批不被插隊的命令」來用,剛剛好。
Lua 的哲學:把邏輯搬到資料旁邊
Lua 讓我最欣賞的,是它體現了一個古老又好用的智慧——與其把資料搬到邏輯那裡(讀回客戶端、判斷、再寫回去,來回三趟還有 race),不如把邏輯搬到資料那裡,原子地一次跑完。這跟 分散式鎖用 Lua 驗 owner、跟資料庫的 stored procedure、甚至 [infra-spark|Spark 的 data locality]是同一條脈絡:移動昂貴、就地處理便宜。 Redis 的單執行緒讓這招格外乾淨——你送過去的整段邏輯,天生就是原子的,不必再操心並行。我現在遇到「讀-改-寫」又怕競態的場景,第一個念頭已經不是加鎖,而是:能不能把這整段變成一個原子操作,送到資料旁邊跑完?