Recent improvements to Redis Lua scripting

Salvatore Sanfilippo

Redis Lua 腳本的近期改進

原文由 Salvatore Sanfilippo 發布,訂閱此部落格

Lua 腳本大概是 Redis 最成功的功能之一,算是 Redis 已經相當受歡迎之後才引進的功能中:難怪使用者最想要的功能裡,有好幾項都跟腳本有關。過去兩年來,以下兩項功能被反覆提出,而幾週前的 Redis 開發者會議上,也有許多人試著把我的注意力引向其中一項或另一項。

  1. 適用於 Redis Lua 腳本的正規除錯器。
  2. 將 Lua 腳本以一組寫入指令的形式來進行複寫與寫入 AOF,藉此具體化腳本的 *效果*,而不是像我們平常那樣直接複寫腳本本身。

第二項功能不只是腳本複寫方式的問題,後面會看到,它也關係到你能用 Lua 腳本做些什麼。

從倫敦回來後,我把這兩項功能都實作了。這篇部落格文章會介紹這兩者,並就設計與實作層面提供一些可能讓讀者感興趣的細節。

像樣的 Lua 除錯器

Lua 腳本最初的構想是讓人寫些非常簡單的腳本。像是:如果這個 key 存在就做這件事。就那麼幾行,為了避免 Redis 因為要支援指令的所有可能變化而變得臃腫。當然,使用者拿它做了更多,開始寫起複雜的腳本:從四元樹實作到具備不簡單語意、功能完整的訊息系統都有。Lua 腳本讓 Redis 變得可程式化,而程式設計師通常無法抗拒可程式化的東西。加上所有的 Lua 腳本都使用同一個直譯器執行並會被快取,所以速度非常快。很多時候透過 Lua 腳本,一個 Redis 執行個體無論在功能上或每秒操作數上都能做到更多。因此,複雜的腳本在今天完全有其立足之地。短短幾年內,我們對腳本功能的態度就從非常冷淡(把這麼動態的東西——腳本——送進資料庫!)轉為大量使用,再到撰寫複雜的腳本。

然而,寫簡單腳本跟寫複雜腳本完全是兩回事。程式越大,複雜度就呈指數成長,就算只是從 10 行增加到 200 行,你也能感受到差異。簡單的腳本你大可用土法煉鋼的方式除錯,試幾個變體、觀察對資料集的影響,或在中間加幾行日誌指令;但沒有除錯器的話,面對複雜腳本可就難受了。

我的同事 Itamar Haber 最近花了很多時間寫複雜的腳本。有一段時間,他還用 Lua 的 debug 函式庫為 Redis Lua 腳本寫了某種除錯器。這個除錯器現在已經無法使用,因為基於沙箱化的考量,debug 函式庫已不再對腳本開放,而且一般來說,你會希望 Redis 除錯器是一個互動式、遠端的除錯器,搭配一個能與伺服器協同運作的合適客戶端,才能提供良好的除錯體驗。除錯本身就已經夠辛苦了,擁有穩固的工具絕對是必要的。要達到這個目標,唯一的方法就是在 Redis 本身內部加入正規的除錯支援。

所以從倫敦回來後,我和 Itamar 開始討論,除錯器應該向使用者提供哪些功能,才算得上有用、才算是相較於過去真正的升級。我們也討論過是否直接支援 Redis 生態系之外現有的 Lua 除錯器。不過我堅信,當一切都是專為與 Redis 良好協作而設計時,使用者體驗會更好,所以最後我決定從頭開始寫一個除錯器。有幾件事是確定的:我們需要一個遠端除錯器,可以附加到 Redis、啟動除錯工作階段,並清楚觀察腳本對 Redis 資料集做了什麼。當然,我特別在意要有彩色輸出 ;-) 我想讓除錯成為有趣的體驗,並擁有非常短的學習曲線,這兩者是相關的。

現在要用寫部落格文章的方式來展示除錯器如何運作,當然是可能的,但就算像我這樣堅持用 courier 字型寫文章的純粹主義者,偶爾還是會用影片來呈現。所以這裡有一段有點長的影片,展示 Redis 除錯器的主要功能。一開始我講話聽起來有點沒精神,因為那是一大清早,不過幾分鐘後咖啡生效,你就會看到我變得比較有精神了。

(提示:請用全螢幕觀看影片,互動工作階段的字級才會夠大、看得清楚。影片畫質夠高,足以讓文字清晰可讀)

如果你不想看影片,Lua 除錯器本身的說明畫面就簡要總結了你能做哪些事:

$ ./redis-cli --ldb --eval /tmp/script.lua
Lua debugging session started, please use:
quit    -- End the session.
restart -- Restart the script in debug mode again.
help    -- Show Lua script debugging commands.

* Stopped at 1, stop reason = step over
-> 1   local src = KEYS[1]
lua debugger> help
Redis Lua debugger help:
[h]elp               Show this help.
[s]tep               Run current line and stop again.
[n]ext               Alias for step.
[c]continue          Run till next breakpoint.
[l]list              List source code around current line.
[l]list [line]       List source code around [line].
                     line = 0 means: current position.
[l]list [line] [ctx] In this form [ctx] specifies how many lines
                     to show before/after [line].
[w]hole              List all source code. Alias for 'list 1 1000000'.
[p]rint              Show all the local variables.
[p]rint <var>        Show the value of the specified variable.
                     Can also show global vars KEYS and ARGV.
[b]reak              Show all breakpoints.
[b]reak <line>       Add a breakpoint to the specified line.
[b]reak -<line>      Remove breakpoint from the specified line.
[b]reak 0            Remove all breakpoints.
[t]race              Show a backtrace.
[e]eval <code>       Execute some Lua code (in a different callframe).
[r]edis <cmd>        Execute a Redis command.
[m]axlen [len]       Trim logged Redis replies and Lua var dumps to len.
                     Specifying zero as <len> means unlimited.
[a]abort             Stop the execution of the script. In sync
                     mode dataset changes will be retained.

Debugger functions you can call from Lua scripts:
redis.debug()        Produce logs in the debugger console.
redis.breakpoint()   Stop execution like if there was a breakpoing.
                     in the next line of code.
lua debugger>

它是怎麼運作的?

整個除錯器基本上是一塊自成一體的程式碼,總共約 1300 行,大部分在 scripting.c 裡,少部分在 redis-cli.c 裡,用來實作作為除錯器客戶端的 CLI 特殊模式。如前所述,這是一個伺服器-客戶端架構的遠端除錯器。

Lua C API 有一個頗有用的除錯介面。它本身不是除錯器,但提供了撰寫除錯器所需的各種原語。不過在 Redis 的脈絡下寫除錯器,比寫一個獨立的 Lua 除錯器要稍微不那麼簡單。為了除錯腳本,你需要在腳本執行期間執行回呼。但當 Redis 正在執行腳本時,我們是處在 EVAL 的脈絡中,正在執行一個客戶端指令。如果我們被阻塞了,要怎麼進行 I/O?還有,Redis 伺服器會怎麼樣?就算除錯應該在開發用的伺服器上進行,而不是在正式環境的伺服器上,完全卡住整個執行個體可能也不是好主意。也許其他開發者也想使用這個執行個體,或者正在除錯腳本的單一開發者想再開啟一個平行的除錯工作階段。最後,還有如何回滾變更的問題,才能讓同一個腳本在相同的 Redis 資料集上反覆測試,而不受除錯期間所做變更的影響?在除錯的脈絡下,可重現性是非常寶貴的。

所以看起來我需要一個複雜的實作。或者,我需要大幅取巧,找一個有點奇怪但程式碼與複雜度少得多、卻能帶來九成我想要效益的解法。這個奇怪的解法最後是這樣的:

  • 當除錯工作階段啟動時,對 Redis 執行 fork()。
  • 擷取客戶端的檔案描述符,並在除錯工作階段進行期間直接做阻塞式 I/O。
  • 使用 Redis 協定,但只用一個幾行程式碼就能實作的極簡子集,這樣我們就完全不需要重新進入 Redis 的事件迴圈。I/O 是除錯器的一部分。

寫了 400 行程式碼後,基本功能就都動起來了,剩下的就只是加入功能、修掉錯誤與邊界情況。

這給了我們需要的一切:伺服器不會被阻塞,因為每個除錯工作階段都是獨立的行程。我們不需要在 EVAL 呼叫的中途重新進入事件迴圈,而且還免費獲得了回滾能力。

不過,也提供了一種會阻塞伺服器的同步模式,適用於你真的需要在保留資料集變更的情況下除錯的情境。我覺得這個模式應該不太會被用到,但要加入它,只需要做到不 fork 並在最後處理客戶端的清理工作,所以我也一併加上了。

除此之外,利用 Lua 的「行」鉤子就能實作單步執行與中斷點等其他所有功能。由於除錯器整合在 Redis 本身內部,要擷取所有 Redis 呼叫並展示給使用者看發生了什麼事,就變得非常簡單。I/O 模型也很單純,我們只是從使用者讀取輸入,並把輸出附加到緩衝區。每當除錯器在某個點停下來時,輸出就會以「狀態回覆」陣列的形式 flush 到客戶端。每一行的前綴會提示 redis-cli 該套用哪種上色。

得益於這個設計,除錯器在 2 天後就能運作,4 天後就完成了。此外,這個設計讓我能寫出完全自成一體的程式碼,所以除錯器幾乎完全不與 Redis 的其他部分互動。這也讓它有可能在 12 月隨 Redis 3.2 一起發布。

有個幾乎不費成本、卻能讓除錯器強大許多的方法,就是新增兩個 Redis Lua 呼叫:redis.breakpoint() 與 redis.debug(),前者能在除錯器中模擬一個中斷點(停在下一行要執行的程式碼),後者則能在除錯器主控台中記錄 Lua 物件。這樣你就能加入只在發生有趣狀況時才觸發的中斷點:

    if some_odd_condition then redis.breakpoint() end

這實際上取代了你在除錯器中可能會加入的許多複雜功能。不過我們在除錯器裡也直接提供了所有常見的功能,像是靜態中斷點、觀察狀態的能力等等。

我很期待寫複雜腳本的使用者會怎麼看待它!到時就知道了。

腳本效果複寫

在理解為何只複寫腳本的 *效果* 很有意思之前,最好先理解為什麼預設直接複寫腳本本身、讓備節點重新執行,會被認為是最佳選項,而且至今仍是預設值。核心問題在於:主節點與備節點之間的頻寬,以及備節點能否「跟上」主節點(保持同步而不延遲太多)、不至於落後。

想想這個小型的 Redis Lua 腳本:

    local i;
    for i=0,1000000 do
        redis.call(“lpush”,KEYS[1],ARGV[1])
    end

它會把 100 萬個元素附加到指定的 list 上,在我的測試環境中執行時間為 0.75 秒。它本身只有幾個位元組,又在伺服器內部執行,所以把這個腳本當作腳本來複寫,而不是複寫腳本執行後產生的 100 萬個指令,是非常合理的。

也有些腳本的情況完全相反。在光譜的另一端,有一個腳本會計算儲存在 list 中 100 萬個整數元素的平均值,並用 SET 把結果存到某個 key。

腳本的效果可能就只是:SET somekey 94.29

但實際執行可能需要 2 秒的運算。把這個腳本以結果指令的形式來複寫會好得多。不過,複寫腳本與複寫效果之間有一個差別:兩者在不同使用情境下各有優劣,但複寫腳本永遠都可行,即使效率不佳。它永遠不會造成複寫連線必須傳輸大量資料的情況,也不會讓備節點必須比主節點做更多工作。這就是為什麼到目前為止 Redis 總是逐字複寫腳本。

不過,或許最有趣的部分不只是效率問題。當我們複寫腳本時,需要每個腳本都是一個 *純函式*。以相同初始資料集執行的腳本,必須永遠產生相同的結果。這個要求會阻止使用者撰寫使用例如 TIME 指令或 SRANDMEMBER 的腳本。Redis 會偵測到這種危險狀況,並在第一個寫入指令即將被呼叫時就中止腳本。

然而,使用目前時間、隨機數或隨機元素的腳本仍有許多使用情境。複寫腳本的效果也能克服這個限制。

所以最後,多虧過去幾個月在 Redis 內部進行的重構,才得以實作可選擇啟用的腳本效果複寫支援。只要在腳本開頭呼叫以下指令就好:

    redis.replicate_commands()
    … do something with the script …

腳本將只會以一組寫入指令的形式被複寫。其實不需要把 replicate_commands() 當作第一個指令來呼叫。只要在任何寫入之前呼叫它就夠了,所以 Lua 腳本甚至可以先檢查要做的工作,再選擇合適的複寫模式。如果在呼叫 replicate_commands() 時已經執行過寫入,它就只會回傳 false,並改用正常的完整腳本複寫,因此即使誤用,這個指令也永遠不會阻止腳本執行。

不過,我們還是抵擋不住想做更進階、可能也更危險功能的誘惑。我和來自 Redis Labs 的同事 Yossi Gottleib 一起設計了這個功能,而他有一個非常有說服力的使用情境,需要一個能讓特定指令排除在複寫串流之外的危險功能。

想法是,你的腳本可能會做類似這樣的事:

  1. 呼叫某些會寫入暫時性數值的指令。可以把集合之間的交集當作心智模型來想像。
  2. 執行某些彙整運算。
  3. 將一個小的結果作為腳本的效果儲存起來。
  4. 丟棄暫時性的數值。

上述模式有幾個合理的使用情境,而且你猜怎麼著,你不會想把暫時性的寫入複寫到 AOF 和備節點。你只想複寫步驟「3」。所以最後我們決定,當啟用腳本效果複寫時,勇於嘗試的使用者可以透過以下 API 來選擇要複寫什麼、不要複寫什麼:

    redis.set_repl(redis.REPL_ALL); -- The default
    redis.set_repl(redis.REPL_NONE); -- No replication at all
    redis.set_repl(redis.REPL_AOF); -- Just AOF replication
    redis.set_repl(redis.REPL_SLAVE); -- Just slaves replication

這當然有很大的誤用空間,但非專業的使用者本來就很不可能去碰這個功能,而懂得如何駕馭強大工具的人則能從中受益。

預計時程

這兩項功能都將在 Redis 3.2 中提供,預計在 2015 年 12 月中推出 RC 版。Redis 3.2 在 API 層面將會有許多對使用者開放、令人期待的新功能。這是很大一部分使用者所要求的,之前一段時間我們更專注於維運與內部成熟度的提升。

如果你想更深入了解或有任何疑問,歡迎在留言區提問。

Hacker News 討論串在此:https://news.ycombinator.com/item?id=10594236

本文章由 muse-spark-1.2-contributor 進行翻譯

留言