Recent improvements to Redis Lua scripting

Salvatore Sanfilippo

Redis Lua 腳本的近期改進

Lua scripting 大概是 Redis 最成功的功能之一,在 Redis 已經相當普及後才引進的眾多功能當中:使用者最想要的幾項功能與 scripting 有關,一點也不令人意外。過去兩年來,以下兩項功能被多次提出,幾週前在 Redis 開發者會議期間,許多人試圖讓我關注其中一項或另一項。

  1. 適用於 Redis Lua 腳本的完善除錯器。
  2. 將 Lua 腳本以具體化腳本「效果」的一組寫入命令來進行複寫並儲存至 AOF,而非如往常般直接複寫腳本本身。

第二項功能不僅關乎腳本如何被複寫,也會影響你能用 Lua scripting 做些什麼,稍後我們會看到。

從倫敦回來後,我實作了這兩項功能。這篇部落格文章將介紹兩者,並就設計與實作層面提供一些或許能引起讀者興趣的提示。

完善的 Lua 除錯器

Lua scripting 最初的設計構想是用來撰寫非常簡單的腳本。例如:若鍵存在就執行某個動作。僅需幾行程式碼,就能避免為了涵蓋所有可能的命令變化而讓 Redis 變得臃腫。當然,使用者用它做了更多事情,並開始撰寫複雜的腳本:從四元樹實作到具備複雜語意的完整訊息傳遞系統。Lua scripting 讓 Redis 具備可程式化能力,而程式設計師通常無法抗拒可程式化的東西。有所幫助的是,所有的 Lua 腳本都使用同一個直譯器執行並會被快取,因此速度非常快。在大多數情況下,透過使用 Lua scripting,無論是在功能上或每秒操作次數上,都能讓單一 Redis 執行個體發揮更大的作用。因此,複雜腳本如今完全有其立足之地。短短幾年內,我們對 scripting 功能的態度從一開始非常冷淡(把如此動態的腳本傳送給資料庫!),轉為廣泛使用,再到撰寫複雜腳本。

然而,撰寫簡單腳本與撰寫複雜腳本完全是兩回事。程式越大,複雜度便呈指數級增長,即使只是從 10 行增加到 200 行,你也能感受到這一點。對於簡單腳本,你大可用土法煉鋼的方式除錯,只要嘗試幾種變化並觀察對資料集的影響,或在中間加入幾行日誌指令即可;但若沒有除錯器,面對複雜腳本你將會舉步維艱。

我的同事 Itamar Haber(伊塔馬爾·哈伯)最近花了大量時間撰寫複雜腳本。在某個階段,他也曾利用 Lua debug library 撰寫了一種適用於 Redis Lua scripting 的除錯器。由於出於沙箱考量,debug library 現已不再向腳本開放,這個除錯器已無法運作;而且一般而言,你在 Redis 除錯器中想要的是一個互動式且可遠端操作的除錯器,並配有能與伺服器協同運作的適當客戶端,以提供良好的除錯體驗。除錯本身就已經是一項艱鉅的工作,擁有穩固的工具確實不可或缺。要達成這個目標,唯一的方法就是在 Redis 本身內部加入完善的除錯支援。

因此,從倫敦回來後,伊塔馬爾·哈伯和我開始討論除錯器應該向使用者提供哪些功能,才能稱得上有用,並相較於過去有真正的提升。我們也曾討論是否只需為 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>

運作原理為何?

整個除錯器基本上是一段自成一體的程式碼,共由 1,300 行程式碼組成,大部分位於 scripting.c 中,少部分位於 redis-cli.c 中,用以實作作為除錯器客戶端的 CLI 特殊模式。如前所述,這是一個伺服器-客戶端架構的遠端除錯器。

Lua C API 提供了一個相當實用的除錯介面。它本身並非除錯器,但提供了撰寫除錯器所需的各種原語。然而,在 Redis 的脈絡下撰寫除錯器,遠比撰寫獨立的 Lua 除錯器來得不那麼簡單。為了除錯腳本,你需要在腳本執行期間執行回呼。但當 Redis 正在執行腳本時,我們正處於 EVAL 的脈絡中,正在執行一個客戶端命令。在已被阻塞的情況下要如何進行 I/O?還有,Redis 伺服器會發生什麼事?即使除錯理應在開發用伺服器而非正式環境伺服器上進行,完全凍結整個執行個體也可能不是好主意。也許其他開發者也想使用該執行個體,或正在除錯腳本的單一開發者想建立新的平行除錯工作階段。最後,要如何回溯變更,讓同一個腳本無論在除錯期間做了哪些更動,都能用相同的 Redis 資料集一再測試?在除錯的脈絡中,確定性是無價的。

因此,表面上我似乎需要一個複雜的實作。或者,我需要大幅取巧,找出一個程式碼與複雜度都少得多、卻能帶來 90% 預期效益的奇特解法。這個奇特的解法最終如下:

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

在寫了 400 行程式碼後,我已讓所有基本功能運作起來,因此剩下的只是加入功能並修正錯誤與邊界情況。

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

然而,也提供了一種會阻塞伺服器的同步模式,適用於你確實需要在保留對資料集變更的情況下除錯某些東西的場合。我感覺這個模式不太會被頻繁使用,但加入此模式只需做到不進行 fork 並在結束時處理客戶端的清理工作,因此我也一併加入了它。

在此基礎上,便能利用 Lua 的「line」鉤子來加入其他所有功能,以實作逐步執行與中斷點。由於除錯器整合在 Redis 本身內部,要擷取所有 Redis 呼叫並向使用者展示發生了什麼事就變得輕而易舉。I/O 模型也非常簡單,我們只需讀取使用者輸入並將輸出附加至緩衝區。每次除錯器在某個點暫停時,輸出就會以「狀態回覆」陣列的形式刷新至客戶端。每一行的前綴會提示 redis-cli 應提供何種著色。

由於採用了此設計,除錯器在 2 天後即可運作,並在 4 天工作後完成。此外,此設計讓我能撰寫完全自成一體的程式碼,因此除錯器幾乎完全不與 Redis 的其餘部分交互。這使得它能在 12 月隨 Redis 3.2 一同發布成為可能。

一個幾乎不費成本卻能讓除錯器強大許多的方法,是新增兩個 Redis Lua 呼叫:redis.breakpoint() 與 redis.debug(),前者可在除錯器內模擬一個中斷點(作用於下一行待執行的程式碼),後者可在除錯器主控台中記錄 Lua 物件。如此一來,你就能加入僅在發生某些有趣狀況時才會觸發的中斷點:

    if some_odd_condition then redis.breakpoint() end

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

我非常期待撰寫複雜腳本的使用者會對此有何看法!讓我們拭目以待。

腳本效果複寫

在理解為何僅複寫腳本的「效果」會很有意思之前,最好先了解為何預設直接複寫腳本本身、讓 slave 重新執行,會被視為最佳選項且無論如何仍是預設值。核心問題在於:master 與 slave 之間的頻寬,以及 slave 能否「跟上」master(保持同步而不致延遲過多)而不落後。

試想這個小型的 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 將結果存入某個鍵。

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

但實際執行可能需要 2 秒的運算。將此腳本以其產生的命令來複寫會好得多。然而,在複寫腳本與複寫效果之間存在差異:兩者在不同使用情境下各有最佳或較差的表現,但複寫腳本始終可行,即使效率不佳。它絕不會造成複寫連結必須傳輸大量資料的情況,也不會造成 slave 必須比 master 做更多工作的情況。這就是為何到目前為止 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 與 slaves。你只想複寫步驟「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

原文由 Salvatore Sanfilippo 發布

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