Recent improvements to Redis Lua scripting

Salvatore Sanfilippo

Redis Luaスクリプティングの最近の改善

原文は Salvatore Sanfilippo により に公開されました。 このブログを購読する

Luaスクリプティングは、Redisがすでにかなり普及してから導入された機能の中では、おそらく最も成功した機能でしょう。ユーザーが最も求めているものの中にスクリプティングに関する要望がいくつかあるのも不思議ではありません。次の2つの機能は、この2年間に何度も要望が寄せられてきたもので、数週間前に開かれたRedis開発者ミーティングでも、多くの人がどちらかの機能に私の注意を向けようとしていました。

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

2つ目の機能は、単にスクリプトをどのようにレプリケーションするかという問題にとどまらず、後述するようにLuaスクリプティングで何ができるかということにも関わってきます。

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

本格的なLuaデバッガ

Luaスクリプティングは当初、本当に些細なスクリプトを書くために考えられました。例えば「キーが存在すればこれを実行する」といったものです。考えられるあらゆるコマンドのバリエーションでRedisを肥大化させないようにするための、数行のコードです。もちろんユーザーはそれを遥かに超えて活用し、複雑なスクリプトを書き始めました。quad-treeの実装から、nontrivialなセマンティクスを持つ本格的なメッセージングシステムまで。LuaスクリプティングはRedisをプログラム可能にしますが、プログラマはプログラム可能なものに抗えないのが常です。すべてのLuaスクリプトが同じインタプリタで実行されキャッシュされるため非常に高速であることも助けになりました。ほとんどの場合、Luaスクリプティングを使うことで、機能的にも1秒あたりの操作数という点でも、Redisインスタンスでより多くのことが可能になります。ですから今日では、複雑なスクリプトにも確かな居場所があります。スクリプティング機能に対する非常に冷ややかな初期の反応(データベースにスクリプトを送るなんて、なんて動的なんだ!)から、大規模な利用、そして複雑なスクリプトの作成へと、わずか数年で移り変わったのです。

しかし、単純なスクリプトを書くことと複雑なスクリプトを書くことは全く別問題です。プログラムは大きくなれば指数関数的に複雑になり、それは10行から200行に増えるだけでも実感できます。単純なスクリプトであれば、いくつかのパターンを試してデータセットへの影響を観察したり、途中にログ出力を数行入れたりといった力業でデバッグできますが、複雑なスクリプトではデバッガなしでは苦労することになります。

私の同僚であるItamar Haberは最近、複雑なスクリプトを書くことに多くの時間を費やしてきました。彼は一時期、Luaのdebugライブラリを使ってRedis Luaスクリプティング用のデバッガのようなものも書いていました。このデバッガは、サンドボックス化の観点からdebugライブラリがスクリプトに公開されなくなったため、現在は動作しません。一般的に、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].
                     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         Show the value of the specified variable.
                     Can also show global vars KEYS and ARGV.
[b]reak              Show all breakpoints.
[b]reak        Add a breakpoint to the specified line.
[b]reak -      Remove breakpoint from the specified line.
[b]reak 0            Remove all breakpoints.
[t]race              Show a backtrace.
[e]eval        Execute some Lua code (in a different callframe).
[r]edis         Execute a Redis command.
[m]axlen [len]       Trim logged Redis replies and Lua var dumps to len.
                     Specifying zero as  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内にありますが、デバッガのクライアントとして機能する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の「line」フックを使って、他のすべてを追加することができました。デバッガは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で利用可能になる予定で、2015年12月中旬にRCとしてリリースされます。Redis 3.2には、ユーザーに公開されるAPIレベルでの興味深い新機能が多数搭載される予定です。運用や内部の成熟に重点を置いた期間の後、ユーザーベースの大部分がこれを求めていました。

さらに知りたいことや疑問がある場合は、コメント欄で気軽に質問してください。

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

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント