【KVS】Redis Lua Scripting で簡単な集計

今回は Redis Lua Scripting で簡単な集計をしてみます。Redis はシングルスレッドで動作する高速なインメモリKVSです。

データの準備

key の形式を {timestamp}:{category} としたデータを投入する。

$ redis-cli MSET 1482671169:ctg1 "{\"count\": 10, \"item\": \"AAA\"}" 1482671160:ctg1 "{\"count\": 5, \"item\": \"BBB\"}" 1482671149:ctg2 "{\"count\": 2, \"item\": \"CCC\"}" 1482671140:ctg2 "{\"count\": 7, \"item\": \"DDD\"}"

投入した4つのデータの確認。

$ redis-cli KEYS "*:*"
1) "1482671160:ctg1"
2) "1482671169:ctg1"
3) "1482671140:ctg2"
4) "1482671149:ctg2"

各データの value は count と item を持っている JSON文字列。

$ redis-cli GET 1482671160:ctg1 | jq
{
  "count": 5,
  "item": "BBB"
}

count を合計・平均を計算し JSON で返す簡単な Lua コードを書いてみる。

Redis Lua Scripting

Lua コードは EVAL, EVALSHA コマンドにより実行・登録される。Lua コード自体が1つのコマンドとみなされ他の操作の発生を許さずに Atomic に実行される。
一方で Lua in Redis では global変数を宣言できない, table内に nil があるとそれ以降解釈されないなど制約がある。関数も例外ではなく local をつける必要がある。

Redis Lua interpreter が Load する Lua モジュールは以下となる。[1] 以下以外を使用したいときはコンパイル時に組み込む方法はある。[2]

  • base lib.
  • table lib.
  • string lib.
  • struct lib.
  • cjson lib.
  • cmsgpack lib.
  • string lib.
  • bitop lib.
  • redis.sha1hex function.

Redis への API は redis.call と redis.pcall があり, redis.pcall は Redis でエラーが発生したときに捕捉して処理を継続する。

local keys = redis.pcall("SCAN", "0", "MATCH", ARGV[1])[2]
local resp = {sum = 0, mean = 0, items = {}}

for i, key in ipairs(keys) do
    local val = redis.call("GET", key)
    if val ~= false then
        if pcall(function() cjson.decode(val) end) then
            local js = cjson.decode(val)
            resp.sum = resp.sum + js.count
            resp.mean = resp.sum / i
            table.insert(resp.items, js)
        else
            print("Invalid JSON")
        end
    end
end

return cjson.encode(resp)

先述したように cjson モジュールは標準で組み込まれており require() でモジュールをロードする必要はない。

Lua の登録は SCRIPT LOAD で行う。得られた sha1 を使い Lua Script を呼ぶ。sha1 を打ち込むのは大変なので sample を key として SET しておくと便利。

redis-cli SCRIPT LOAD "$(cat redis.lua)" | redis-cli SET sample "$(cat)"

SCAN の書式は `SCAN cursor [MATCH pattern] [COUNT count]` で pattern に以下の3パターンを指定して集計してみる。ちなみに プロダクション では KEYS の使用 (SCANも?) は避けるべきとされている。

  • “*:*”: 全てのカテゴリの集計
  • “*:ctg1”: ctg1のみの集計
  • “*:ctg2”: ctg2のみの集計

全てのカテゴリの集計。

$ redis-cli GET sample | redis-cli EVALSHA "$(cat)" 0 "*:*" | jq
{
  "items": [
    {
      "count": 10,
      "item": "AAA"
    },
    {
      "count": 2,
      "item": "CCC"
    },
    {
      "count": 5,
      "item": "BBB"
    },
    {
      "count": 7,
      "item": "DDD"
    }
  ],
  "sum": 24,
  "mean": 6
}

ctg1のみの集計

$ redis-cli GET sample | redis-cli EVALSHA "$(cat)" 0 "*:ctg1" | jq
{
  "items": [
    {
      "count": 10,
      "item": "AAA"
    },
    {
      "count": 5,
      "item": "BBB"
    }
  ],
  "sum": 15,
  "mean": 7.5
}

ctg2のみの集計

$ redis-cli GET sample | redis-cli EVALSHA "$(cat)" 0 "*:ctg2" | jq
{
  "items": [
    {
      "count": 2,
      "item": "CCC"
    },
    {
      "count": 7,
      "item": "DDD"
    }
  ],
  "sum": 9,
  "mean": 4.5
}

RedisClusterでの Lua Scripting

RedisCluster における Lua Scripting では複数の key を指定した時に異なるノードに存在する key を指定した場合はエラーとなる。
具体的には, まず以下のような key を埋め込んだ場合はエラーとなる。

EVAL "return(redis.call('GET', 'sample'))" 0 

次に単一の key を指定した場合は, key が存在するノードに移され実行されるため OK

EVAL "return(redis.call('GET', KEYS[0]))" 1 "sample"

しかし, 複数の key を指定した場合にそれぞれのデータが別のノードにある場合はエラーとなる。
例えば, “sample” と “sample2” が別のノードに存在する場合にこの2つを KEYS で渡すとエラー。

EVAL "return(redis.call('MGET', KEYS[0], KEYS[1]))" 2 "sample" "sample2"

そのため RedisCluster では, 同一ノードに明示的に保存するため hashtag の仕組みが使われる。

Lua scripts では基本的には EVAL, EVALSHA を通じて既存のデータ構造に機能を追加できるが, Redis Modules を使うと C Shared Library (.so) を用いて新たなデータ型を定義するなどより柔軟な拡張を行うことができる。 [3]


[1] Introduction to EVAL
[2] Installing additional LUA modules into Redis
[3] Writing Redis Modules
[4] RedisのLuaスクリプティング機能について
[5] More Transactional Redis (2) – Lua Scripting in Action
[6] how to define functions in redis \ lua?
[7] Redis Documentation (Japanese Translation) トランザクション
[8] Redis モジュールで遊んでみた
[9] Redisでオブジェクトの検索方法について