Luaスクリプティング
kevyにおけるサーバーサイドのLuaスクリプティングです。EVAL / EVALSHAでアトミックなスクリプトを走らせる方法、どんなバインディングがあるか、そして5.1より新しいLua方言をどうオプトインするかを扱います。
このドキュメントが必要になるとき
次のようなことをしたいときに、Luaスクリプティングに手を伸ばしてください。
- 1つのキーに対して、小さなマルチコマンドの列をアトミックに実行する(check-then-set、条件つきカウンタ、分散ロック)。
- 単一のコマンドでは表現できないread-modify-writeのロジックをサーバーへ押し込み、往復を消す。
- エコシステムのライブラリ(BullMQ、Sidekiq、Bee Queue、Redlock、スライディングウィンドウのレートリミッタ)が公開しているスクリプトを、無改造で走らせる。
ふつうの単一コマンドのアクセスや、明示的な楽観ロックを伴う多数キーにまたがるトランザクションには、素のコマンドかMULTI / EXECを使ってください。
中心となる考え方
EVALはLuaのソース文字列をサーバーへ送り、コンパイルし、KEYS[1]を所有するシャード上で実行します。スクリプトが走っているあいだ、そのシャード上のほかのコマンドが割り込むことはありません——スクリプト全体が1つのアトミックな単位です。スクリプトの内側では、redis.call("CMD", ...)が通常のコマンドパスを通ってディスパッチし直され、KEYSとARGVが入力への1始まり・バイナリセーフなアクセスを与え、スクリプトの戻り値はRESPへマーシャルされます。ロード済みのスクリプトはSHA1でキャッシュされるため、SCRIPT LOAD + EVALSHAにより、クライアントは呼び出しのたびに本体ではなくハッシュを送れます。Luaランタイムはluna——純Rustの5.1〜5.5インタプリタです。デフォルトの方言はLua 5.1(Redisエコシステムのスクリプトが例外なく前提とするもの)であり、1行のshebangで、個々のスクリプトを5.2、5.3、5.4、5.5へオプトインさせられます。
動かしてみる例:上限つきカウンタ
カウンタを1つ増やしますが、増やした値が上限以下にとどまる場合に限ります。新しい値を返し、上限を超えてしまう場合はnilを返します。
-- KEYS[1] = counter key
-- ARGV[1] = cap (integer)
local cur = tonumber(redis.call("GET", KEYS[1]) or "0")
local cap = tonumber(ARGV[1])
if cur + 1 > cap then
return nil
end
return redis.call("INCR", KEYS[1])EVALによるインライン実行
redis-cli -p 6004 EVAL \
"local cur = tonumber(redis.call('GET', KEYS[1]) or '0')
local cap = tonumber(ARGV[1])
if cur + 1 > cap then return nil end
return redis.call('INCR', KEYS[1])" \
1 quota:user:42 5
# (integer) 1
# … four more calls …
# (integer) 5
# next call:
# (nil)SCRIPT LOAD + EVALSHAによるキャッシュ実行
SHA=$(redis-cli -p 6004 SCRIPT LOAD \
"local cur = tonumber(redis.call('GET', KEYS[1]) or '0')
local cap = tonumber(ARGV[1])
if cur + 1 > cap then return nil end
return redis.call('INCR', KEYS[1])")
echo "$SHA"
# e.g. 7c3e0a9b1d4f...
redis-cli -p 6004 EVALSHA "$SHA" 1 quota:user:42 5
# (integer) 1
redis-cli -p 6004 SCRIPT EXISTS "$SHA"
# 1) (integer) 1サーバーが一度も見たことのないハッシュでEVALSHAが送られてくると(コールドスタート、SCRIPT FLUSHのあとなど)、kevyは-NOSCRIPTを返すので、クライアントはEVALにフォールバックすべきです。SHA1でキャッシュされるスクリプト本体にはshebang行も含まれるため、同じソースの5.1版と5.4版は、別々のキャッシュエントリになります。
shebangによるLua 5.4方言
#!lua version=5.4
-- ARGV[1] = max retries before giving up
local tries = tonumber(ARGV[1])
local i = 0
::again::
i = i + 1
local ok = redis.call("SET", KEYS[1], "owned", "NX", "PX", 3000)
if type(ok) == "table" and ok.ok == "OK" then
return i
end
if i < tries then goto again end
return redis.error_reply("LOCK_FAILED")gotoとラベル、それに整数型の算術は5.3以降の機能です。shebangは、このスクリプト1本だけを5.4のVMプールへ回し、ほかのスクリプトは5.1の上で走り続けます。
バインディング
| シンボル | 振る舞い |
|---|---|
redis.call(cmd, ...) | kevyのコマンドをディスパッチします。RESPのエラーはLuaのエラーを送出し、pcallで捕まえない限りスクリプトを中断します。 |
redis.pcall(cmd, ...) | 同じディスパッチですが、RESPのエラーは送出せず{err = "msg"}として返ります。 |
redis.error_reply(msg) | {err = msg}を作ります。スクリプトから返すと-msg\r\nにマーシャルされます。 |
redis.status_reply(msg) | {ok = msg}を作ります。+msg\r\n(simple string)にマーシャルされます。 |
redis.sha1hex(s) | 入力バイト列の、40文字・小文字16進のSHA-1。 |
redis.replicate_commands() | 何もしません。すべてのスクリプトは、すでに1単位としてアトミックにレプリケートされます。 |
KEYS | EVAL呼び出しで宣言されたnumkeys個のバイト文字列の、1始まりのテーブル。バイナリセーフ。 |
ARGV | 残りの引数の、1始まりのテーブル。バイナリセーフ。 |
cjson.encode(v) / cjson.decode(s) | 純RustのJSONコーデック。Redisのcjsonライブラリと同じ面。 |
cmsgpack.pack(v) / cmsgpack.unpack(s) | 純RustのMessagePackコーデック。Redisのcmsgpackライブラリと同じ面。 |
Luaの戻り値は、Redis標準の規則でRESPへマーシャルされます。nilとfalseはnil-bulkに、trueは:1に、整数と誤差なく整数に落ちる浮動小数点数は整数応答に、それ以外の浮動小数点数と文字列はbulk stringに、{ok=...}はsimple stringに、{err=...}はエラー応答に、そして素の配列はmulti-bulk応答になります(最初のnilで打ち切る規則つき)。
方言の選択
| スクリプトの1行目 | 使われる方言 |
|---|---|
| (shebangなし) | Lua 5.1(デフォルト。BullMQ、Sidekiq、Redlock、そして公開されているRedis-Luaスニペットの大半が前提とするもの) |
#!lua version=5.1(または51) | Lua 5.1、明示 |
#!lua version=5.2(または52) | Lua 5.2 —— goto、_ENV、ephemeronテーブル |
#!lua version=5.3(または53) | Lua 5.3 —— 整数サブタイプ、ビット演算子、//、string.pack / string.unpack |
#!lua version=5.4(または54) | Lua 5.4 —— to-be-closed変数、整数forのセマンティクス、新しいビット演算の細部 |
#!lua version=5.5(または55) | Lua 5.5 —— 公開されている最新の方言 |
#!lua version=<other> | -ERR unknown lua version: <X> |
shebang行に載るRedis 7.0 Functionsのメタデータ(flags=、name=)はパースされたうえで無視されるため、Functionsの面向けに書かれたスクリプトもEVALのもとで問題なくロードされます。
Redisエコシステムとの純粋な互換性だけを求めるデプロイでは、受け入れる方言をconfigで固定できます。[lua] allow_dialects = "5.1"は、shebangがそれより新しいVMを要求するスクリプトをすべて拒否します。複数を許すならカンマで区切ってください(allow_dialects = "5.1,5.4")。デフォルト(空)はすべての方言を受け入れます。
トレードオフと限界
- ファイルシステム、ネットワーク、OSへのアクセスはありません。
io、os、package、debug、coroutineはロードされません。スクリプトはファイルを開くことも、ソケットを作ることも、プロセスを起こすことも、環境変数を読むこともできません。 - バイトコードのロードはできません。
load(bytecode)とstring.dumpはブロックされます。VMに入れるのはLuaのソースだけであり、これが、歴史的にLuaのサンドボックスを破ってきたバイトコード検証器の抜け道を塞ぎます。 - 標準ライブラリはホワイトリスト制です。
base、math、string、table、cjson、cmsgpackが使えます。ほかの標準モジュールは存在しません。 - スクリプトごとの時間予算。 各
EVALは、[lua] time_limit_ms(デフォルト5000 ms ≈ 2億命令——Redisのデフォルトのlua-time-limitと同じ天井です。0で上限が外れます)から導かれた命令予算のもとで走ります。これを超えると、捕捉可能なLuaエラーを返してスクリプトを中断します。 - スクリプトごとのメモリ予算。 各スクリプトは、方言ごとのVMプールから種を取った、まっさらなインタプリタ状態の中で走ります。呼び出し中に作られたテーブルや文字列は、呼び出しが返るときに回収されます。呼び出しをまたいで共有される可変のLua状態は存在しません——何かを永続させたければkevyのキーを使ってください。
EVALのネストはできません。redis.call("EVAL", ...)を呼ぶスクリプトはエラーを返します。Redisの挙動と同じです。- スクリプト1本につき1シャード。 すべての
KEYSが同じスロットにハッシュされなければなりません。シャードをまたぐ複数キーに触れるスクリプトは、ディスパッチの時点で-CROSSSLOTを受け取ります。 - JITは設計上オフです。 インタプリタを直接使います。lunaのCranelift JITはkevyサーバーにリンクされていません。依存の面を最小に保ち、JIT時のポーズを避けるためです。
FAQ
手元にある既存のRedis Luaスクリプトは、無改造で動きますか? Lua 5.1(Redisのデフォルト)を対象にしていて、redis.call / redis.pcall / redis.error_reply / redis.status_reply / KEYS / ARGV / cjson / cmsgpackしか使っていないなら、動きます。debugライブラリ、OSライブラリ、あるいはプリコンパイルされたバイトコードのロードに依存しているなら、動きません——それらはサンドボックスの外に出されています。
別々のシャードにあるキーをまたいでスクリプトを走らせるには? できません——アトミック性こそが要点だからです。仕事をシャードごとのスクリプトに分割してクライアント側で調整するか、関係するキーがハッシュタグを共有するようにキーのレイアウトを設計してください({user:42}:quotaと{user:42}:counterは同じシャードにルーティングされます)。
EVALSHAが-NOSCRIPTを返します。どうすればいいですか? 同じ本体をEVALで送り直してください。サーバーはそれを再びキャッシュし、呼び出しに答えます。たいていのクライアントライブラリは、このフォールバックを自動で処理します。SCRIPT FLUSHもプロセスの再起動も、どちらもキャッシュをリセットします。
スクリプトからBLPOPなどのブロッキングコマンドを呼べますか? 呼べません。EVALの内側のブロッキングコマンドは、アトミック性の契約を破壊してしまいます——シャードは自分自身を待って凍りつくか、ほかの仕事を割り込ませるかのどちらかになるからです。ブロッキングコマンドは、スクリプトからディスパッチされるとエラーを返します。
より新しいバージョンがあるのに、なぜデフォルトがLua 5.1なのですか? Redisエコシステムで公開されているスクリプトは、すべて5.1を前提にしています。5.1をデフォルトにするということは、それらのスクリプトが、整数サブタイプやビット演算子やgotoまわりの驚きなしに、コピー&ペーストで通るということです。個々のスクリプトをshebangでより新しい方言へオプトインさせるのは、新しい機能が積極的に欲しくなったときの、1行の変更で済みます。