概要
前回PipelineやMultiでは読み出したデータを意思決定に使えないという課題があり、代わりにWatchで楽観ロックを用いるという旨を説明しました。
Lua Scriptを使えば楽観ロックを用いずとも上記の課題を解決することができます。
環境
- Redis 6.2.7
- Go 1.19.2
Lua Script
Lua Script機能は元々Redisに存在しない機能を使いたい場合にRedis自体のCソースを改修せずともLua言語を用いて拡張できるようにした仕組みです。
メリットとして以下があります。
- Redisに存在する機能を組み合わせた独自機能が作れる
- Pipeline同様1回のRTTで済むのでオーバーヘッドが少ない
- atomicに実行されるので、他のクライアントの操作が割り込まずデータ不整合が発生しない
サンプル
例えば
- 引数で渡ってきた数値を足していく
- 閾値を超えたらリセットする
というロジックが必要になった時、Pipelineでは1つ目の結果が不明なので2つ目の判定ができません。
しかしLua Scriptであれば上記の要件をクリアできます。
-- KEYS[1]: key name. -- ARGV[1]: increment value. -- ARGV[2]: threshold value. local key = KEYS[1] local change = tonumber(ARGV[1]) local threshold = tonumber(ARGV[2]) local value = redis.call("GET", key) if not value then value = 0 end value = value + change if value > threshold then value = 0 end redis.call("SET", key, value) return value
上記をincr.lua
というファイルとして保存します。
動作確認
EVAL
のシンタックスは以下なので
EVAL script numkeys [key [key ...]] [arg [arg ...]]
以下のように実行します。
$ redis-cli EVAL "$(cat incr.lua)" 1 foo 8 100 (integer) 8 $ redis-cli EVAL "$(cat incr.lua)" 1 foo 8 100 (integer) 16 $ redis-cli EVAL "$(cat incr.lua)" 1 foo 8 100 (integer) 24 ... $ redis-cli EVAL "$(cat incr.lua)" 1 foo 8 100 (integer) 96 $ redis-cli EVAL "$(cat incr.lua)" 1 foo 8 100 (integer) 0 $ redis-cli EVAL "$(cat incr.lua)" 1 foo 8 100 (integer) 8
期待通りの挙動になりました。
Goでの実装
Goの場合は以下のように実装します。
var incrBy = redis.NewScript(` local key = KEYS[1] local change = tonumber(ARGV[1]) local threshold = tonumber(ARGV[2]) local value = redis.call("GET", key) if not value then value = 0 end value = value + change if value > threshold then value = 0 end redis.call("SET", key, value) return value `) type client struct { cli *redis.Client } func (c *client) run(key string, val int) error { keys := []string{key} values := []interface{}{val, threshold} v, err := incrBy.Run(context.TODO(), c.cli, keys, values...).Int() if err != nil { return err } fmt.Println(v) return nil } func main() { cli := redis.NewClient(&redis.Options{ Addr: "localhost:6379", }) c := &client{cli: cli} for i := 0; i < limit; i++ { err := c.run("hoge", 8) if err != nil { log.Fatal(err) } } }
結果
$ go run main.go 8 16 24 32 40 48 56 64 72 80 88 96 0 8 16
Tips
EVALSHAフォールバック
Luaスクリプトは毎回送信するとオーバーヘッドが大きいため、通常Redis側に保存します。
SCRIPT LOAD
でスクリプトを渡すと戻り値にスクリプトのハッシュ値(sha1)が返るため、今後はそれをEVALSHA
で呼び出すと毎回スクリプトを送信&パースせずとも実行できます。
redis> SCRIPT LOAD "return 'Immabe a cached script'" "c664a3bf70bd1d45c4284ffebb65a6f2299bfc9f" redis> EVALSHA c664a3bf70bd1d45c4284ffebb65a6f2299bfc9f 0 "Immabe a cached script"
ただEVAL
も戻り値にsha1は返さないものの内部で保存はしており、
- RedisはオンメモリDBなので保存されたスクリプトは再起動によって揮発する
- Cluster構成では全shardに保存しておく必要があるが、どのshardが保存済かそうでないかが分からない
といったことがあるので、実際の運用では以下のようにフォールバックする事が多いです。
GoのSDKはデフォルトで上記の処理が入っています。
func (s *Script) Run(ctx context.Context, c Scripter, keys []string, args ...interface{}) *Cmd { r := s.EvalSha(ctx, c, keys, args...) if err := r.Err(); err != nil && strings.HasPrefix(err.Error(), "NOSCRIPT ") { return s.Eval(ctx, c, keys, args...) } return r }
ref: https://github.com/go-redis/redis/blob/cae67723092cac2cb441bc87044ab9edacb2484d/script.go#L59-L65
戻り値のデータ型について
文字列や整数以外のデータ型(boolean、floatなど)を返そうとすると曖昧になる(例えば少数は切り下げられたり)ので、可能な限りどちらかに寄せた方が良いです。
以下はLua x Goの場合のデータ変換表です。
ref: https://redis.uptrace.dev/guide/lua-scripting.html#lua-and-go-types
SLOWLOGの表示
Redis Lua script(EVAL)はSLOWLOGにはどう出るのか - be-hase blog
を参考に試したところ、
EVALの場合
$ redis-cli EVAL "$(cat slow.lua)" 0 100000 (nil) $ redis-cli SLOWLOG GET 1 1) 1) (integer) 0 2) (integer) 1666644715 3) (integer) 19996 4) 1) "EVAL" 2) "local tempKey = \"temp-key\"\nlocal cycles\n\nredis.call(\"SET\", tempKey, \"1\")\nredis.call(\"PEXPIRE\", tempKey, 20)\n\nfor i = 0, ARGV[1] ... (112 more bytes)" 3) "0" 4) "100000" 5) "172.18.0.1:65146" 6) ""
EVALで表示されました。
EVALSHAの場合
$ shasum slow.lua 8347c117b7472160494efa8ad3f747aef78ad8e8 slow.lua $ redis-cli SCRIPT LOAD "$(cat slow.lua)" "8347c117b7472160494efa8ad3f747aef78ad8e8" $ redis-cli EVALSHA 8347c117b7472160494efa8ad3f747aef78ad8e8 0 100000 (nil) lua $ redis-cli SLOWLOG GET 1 1) 1) (integer) 1 2) (integer) 1666644794 3) (integer) 19717 4) 1) "EVALSHA" 2) "8347c117b7472160494efa8ad3f747aef78ad8e8" 3) "0" 4) "100000" 5) "172.18.0.1:63520" 6) ""
バージョンによって挙動が変わったのか、前述のブログ結果と異なりEVALではなくEVALSHAとして表示されました。
なので複数のLuaスクリプトがある中で特定のLuaスクリプトが遅い場合、どのLuaスクリプトなのか判別するのに苦労しそうです。
Redis Clusterの場合、keysは同一slotに属さないといけない
They can run in cluster mode, and are not able to run commands accessing keys of different hash slots.
ref: https://redis.io/docs/manual/programmability/lua-api/
とあるように、keyは同一slotに属さないといけません。
なので複数keyがある場合は、ハッシュタグ{}
で同じslotのものにする必要があります。
> EVAL "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 {hash1}foo {hash1}bar hoge fuga
単純にkeyを使ったり、異なるハッシュタグを使うと以下のエラーが発生します。
(error) CROSSSLOT Keys in request don't hash to the same slot
まとめ
RedisのLua ScriptについてTipsを含めて説明しました。