Recent improvements to Redis Lua scripting

Salvatore Sanfilippo

Redis Luaスクリプトの最近の改善

Luaスクリプトは、Redisがすでにかなり普及していた時期に導入された機能の中で、おそらく最も成功した機能です。ですから、ユーザーが本当に望んでいることのいくつかがスクリプトに関するものなのも不思議ではありません。次の2つの機能は、この2年間に何度も提案されてきました。数週間前のRedis開発者会議でも、多くの人が私にどちらか一方へ注目するよう働きかけてきました。

  1. Redis Luaスクリプト用の本格的なデバッガー。
  2. 通常どおりスクリプトそのものをレプリケーションするのではなく、スクリプトの効果を具体化した書き込みコマンドの集合として、Luaスクリプトをレプリケーションし、AOFに保存する機能。

2つ目の機能は、スクリプトをどのようにレプリケーションするかだけの問題ではありません。後で説明するように、Luaスクリプトで何ができるかにも関わってきます。

ロンドンから戻った私は、両方の機能を実装しました。このブログ記事では、その両方について、読者の皆さんにとって興味深いかもしれない設計や実装の側面にも少し触れながら説明します。

本格的なLuaデバッガー

Luaスクリプトは当初、本当に単純なスクリプトを書くために考案されました。たとえば「キーが存在するならこれを実行する」といったものです。Redisにあらゆるコマンドのバリエーションを追加して肥大化させないための、数行程度の処理です。もちろんユーザーはそれ以上のことをたくさん行うようになり、複雑なスクリプトを書き始めました。四分木の実装から、複雑なセマンティクスを持つ本格的なメッセージングシステムまで、さまざまです。LuaスクリプトによってRedisはプログラマブルになります。そしてプログラマーは、たいていプログラマブルなものを見ると、つい使い倒したくなります。すべてのLuaスクリプトが同じインタープリターで実行され、キャッシュされるため、非常に高速なのも助けになっています。Luaスクリプトを使えば、機能面でも1秒あたりの処理数という面でも、Redisインスタンスでできることを大幅に増やせる場合がほとんどです。ですから、今では複雑なスクリプトにも十分な出番があります。スクリプト機能が登場した当初は、「データベースにスクリプトを送るなんて、そんな動的なものを!」という非常に冷ややかな反応でした。それが数年のうちに、広く使われるようになり、複雑なスクリプトを短期間で書くまでになったのです。

しかし、単純なスクリプトを書くことと複雑なスクリプトを書くことは、まったく別の話です。プログラムは大きくなるほど指数関数的に複雑になり、コードが10行から200行になるだけでも、その違いを実感できます。単純なスクリプトなら、いくつかのバリエーションを試してデータセットへの影響を観察したり、途中にログ出力を少し入れたりするだけで、力ずくでもデバッグできます。けれども複雑なスクリプトでは、デバッガーなしではつらい思いをすることになります。

最近、同僚のItamar Haberは複雑なスクリプトを書くのに多くの時間を使っていました。ある時点で、Luaのデバッグライブラリを使ってRedis Luaスクリプト用の一種のデバッガーも作りました。しかし、サンドボックス化の都合で現在はデバッグライブラリをスクリプトから利用できなくなったため、このデバッガーはもう動きません。そもそもRedisのデバッガーに必要なのは、サーバーと連携して優れたデバッグ体験を提供できる適切なクライアントを備えた、対話型のリモートデバッガーです。デバッグはそれだけでも大変な作業ですから、しっかりしたツールはどうしても必要です。この結果を実現する唯一の方法は、Redis自体に本格的なデバッグ対応を追加することでした。

そこでロンドンから戻った私は、Itamarと、デバッガーがユーザーに何を提供すべきか、それによって過去と比べてどのような本当の進歩を実現できるかについて話し始めました。Redisのエコシステム外にすでに存在するLuaデバッガーへの対応を追加する案も検討しました。しかし、すべてをRedisでうまく動くように特別に設計したほうが、ユーザー体験は向上すると強く信じています。結局、デバッガーをゼロから書くことにしました。確かなことがいくつかありました。Redisに接続してデバッグセッションを開始できるリモートデバッガーが必要です。また、スクリプトがRedisのデータセットに対して何をしているのかを十分に把握できなければなりません。私が特に気にしていたのは、もちろん色付きの出力です ;-) デバッグを楽しい体験にし、学習曲線を非常に緩やかにしたいと考えていました。この2つは関係しています。

デバッガーの仕組みをブログ記事で説明するのはもちろん可能です。しかし、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].
[l]list [line] [ctx] In this form [ctx] specifies how many lines
                     to show before/after [line].
                     line = 0 means: current position.
[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にありますが、デバッガーのクライアントとして動作するCLIの特殊モードを実装するため、redis-cli.cにも少しコードがあります。すでに述べたとおり、これはサーバーとクライアントから成るリモートデバッガーです。

LuaのC APIには、非常に便利なデバッグ用インターフェースがあります。それ自体はデバッガーではありませんが、デバッガーを書くために必要な基本機能を提供しています。ただし、Redisのコンテキストでデバッガーを書くのは、単体で動くLuaデバッガーを書くより少し簡単ではありませんでした。スクリプトをデバッグするには、スクリプトの実行中にコールバックを実行する必要があります。しかしRedisがスクリプトを実行しているとき、私たちはEVALのコンテキスト、つまりクライアントコマンドを実行している最中にいます。そこでブロックされているのに、どうやってI/Oを行えばよいのでしょうか。Redisサーバーはどうなるのでしょう。デバッグは本番サーバーではなく開発用サーバーで行うべきだとしても、インスタンスを完全に停止させるのは得策ではありません。ほかの開発者がそのインスタンスを使いたいかもしれませんし、スクリプトをデバッグしている開発者自身が、新しい並列デバッグセッションを開始したいかもしれません。さらに、デバッグ中にスクリプトが行った変更に関係なく、同じRedisデータセットで同じスクリプトを何度もテストできるように、変更をロールバックするにはどうすればよいのでしょうか。デバッグの文脈では、決定性が何より重要です。

どうやら複雑な実装が必要なようでした。しかし、ここで大幅に手を抜き、コードと複雑さを大幅に減らしながら、求めていたメリットの90%を得られる奇妙な解決策を見つけることもできました。その風変わりな解決策は、次のようなものでした。

  • デバッグセッションが始まったら、Redisをfork()する。
  • クライアントのファイルディスクリプターを取得し、デバッグセッション中は直接ブロッキングI/Oを行う。
  • Redisプロトコルを使う。ただし、数行で実装できる単純なサブセットに限定し、Redisのイベントループには再入しない。I/Oはデバッガーの一部として処理する。

コードを400行書いたところで基本機能はすべて動くようになりました。残りは機能を追加し、バグや隅のケースを修正するだけでした。

これで必要なものはすべて得られます。各デバッグセッションは別プロセスで動くため、サーバーはブロックされません。EVAL呼び出しの途中からイベントループに再入する必要もなく、ロールバックも自動的に得られます。

ただし、データセットへの変更を保持したまま何かをデバッグしたい場合に備えて、サーバーをブロックする同期モードも用意されています。このモードが実際に使われることはあまりない気がします。しかし、forkせず、最後にクライアントをクリーンアップすれば追加できるものだったので、このモードも実装しました。

そのうえで、Luaの「行」フックを使えば、ステップ実行やブレークポイントなど、残りの機能もすべて追加できました。デバッガーはRedis自体に組み込まれているため、Redisの呼び出しをすべて捕捉して、何が起きているかをユーザーに表示するのも簡単でした。I/Oモデルも単純です。ユーザーから入力を読み取り、出力をバッファーに追加します。デバッガーがどこかで停止するたびに、「status replies」の配列として出力をクライアントにフラッシュします。各行の先頭に付けたプレフィックスによって、redis-cliはどのように色付けすべきかを判断できます。

この設計のおかげで、デバッガーは2日後には動作し、4日間の作業で完成しました。さらに、この設計によって完全に自己完結したコードを書くことができたので、デバッガーがRedisの他の部分とやり取りすることはほとんどありません。このおかげで、12月にRedis 3.2とともにリリースできるようになります。

デバッガーをほとんどコストをかけずに大幅に強化する簡単な方法として、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万個の要素を追加し、私のテスト環境では0.75秒で実行されます。数バイトにすぎず、サーバー内で実行されるので、このスクリプトを実行結果である100万個のコマンドとしてではなく、スクリプトのままレプリケーションするのは理にかなっています。

まったく逆のスクリプトもあります。対極にあるのは、リストに格納された100万個の整数の平均を計算し、その結果をSETでキーに保存するスクリプトです。

スクリプトの効果は、たとえば次のようになるでしょう。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で利用できるようになります。Redis 3.2は2015年12月中旬にRCとしてリリースされる予定です。Redis 3.2には、ユーザー向けに公開されるAPIレベルの興味深い新機能が多数搭載されます。これまで運用面や内部の成熟度により重点を置いてきた時期を経て、ユーザーベースの大部分から求められていたものです。

もっと詳しく知りたいことや疑問があれば、コメントで気軽に質問してください。

Hacker Newsのスレッドはこちらです:https://news.ycombinator.com/item?id=10594236

原文は Salvatore Sanfilippo により に公開されました。

この記事は「gpt-5.6-luna」を使用して翻訳されました。