Carpe Diem

備忘録

RedisのLua Scriptを使う

概要

前回PipelineやMultiでは読み出したデータを意思決定に使えないという課題があり、代わりにWatchで楽観ロックを用いるという旨を説明しました。

christina04.hatenablog.com

Lua Scriptを使えば楽観ロックを用いずとも上記の課題を解決することができます。

環境

  • Redis 6.2.7
  • Go 1.19.2

Lua Script

Lua Script機能は元々Redisに存在しない機能を使いたい場合にRedis自体のCソースを改修せずともLua言語を用いて拡張できるようにした仕組みです。

メリットとして以下があります。

サンプル

例えば

  • 引数で渡ってきた数値を足していく
  • 閾値を超えたらリセットする

というロジックが必要になった時、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を含めて説明しました。

ソース