BA Barış Akın~/blog

·28 分で読めます·75 閲覧

OxiDBのOxiMem:Redisプロトコルを話すインメモリのキーバリューストア

OxiDBにRESPを話すキーバリュー層を組み込みました。5つのデータ型、TTL、pub/sub、WATCH、Lua、スナップショット。redis-cliとredis-pyでの実際のセッション、Redis 8.6との比較測定、そして正直な限界を紹介します。

OxiDBのOxiMem:Redisプロトコルを話すインメモリのキーバリューストア

アプリケーションのデータはすべてが同じ種類ではありません。注文、ユーザーレコード、投稿は失われてはならないので、ドキュメントエンジンやSQLエンジンに送ります。WALに書き込み、fsyncを待ち、クラッシュ後には復元される、という流れです。しかし、どのアプリケーションにも「ホットな周縁部」があります。セッショントークン、ページビューのカウンター、レート制限のウィンドウ、短命なジョブキュー、キャッシュされたHTMLなどです。これらは1秒に何百回も変化し、消えたところで誰も困りません。こうしたデータを永続化の仕組みに通すのは無駄です。普通なら、ここでデータベースの隣にRedisを置くことになります。OxiDBでは別の方法を取りました。OxiMemというインメモリのキーバリュー層を同じプロセスの中に作り、RedisのワイヤプロトコルであるRESPを話せるようにしたのです。その結果、redis-cli、redis-py、go-redis、node-redisといった標準のクライアントがそのままOxiDBに接続できます。

この記事ではOxiMemを端から端まで扱います。有効化の方法、サポートするデータ型とコマンド、有効期限、pub/sub、MULTI/EXEC/WATCH、Luaスクリプト、永続化のオプション、内部の仕組み、Redisと比較して測定したスループットとメモリ、そして同じくらい重要な、Redisとの違いです。以下の出力はすべて、私のマシン(Apple M4、macOS 26.3)で動かしたOxiDB 0.44.2サーバーから取ったものです。

もう一つ。今読んでいるこのブログもOxiMemを使っています。www.baltavista.comで動いていて、サーバーはRill(私が作った言語)で書かれ、データはOxiDBにあります。投稿とタグはSQLとドキュメント側に、画像はS3層に置かれ、このページの下部にあるリアルタイムの閲覧数はOxiMemのINCRです。これについては最後にまた触れます。

なぜRESPなのか

インメモリストアを書くうえで簡単なのはストアそのものです。難しいのは外の世界とつなぐ部分、つまりクライアントライブラリ、コネクションプール、コマンドラインツール、フレームワークとの統合です。独自プロトコルにしていたら、それらをすべて作り直す必要がありました。RESPを話せば、そのエコシステム全体がタダで手に入ります。

RESP自体は気持ちよいほど小さなプロトコルです。各メッセージの先頭バイトが型を表します。+はシンプル文字列、-はエラー、:は整数、$は長さ付きのバルク文字列、*は配列です。クライアントのコマンドは常にバルク文字列の配列です。ワイヤ上でGET session:42は次のようになります。

*2\r\n$3\r\nGET\r\n$10\r\nsession:42\r\n

長さのプレフィックスはフレーミングの問題を解決しますが、同時に攻撃面も生みます。長さを決めるのはクライアントだからです。そこでOxiMemのパーサーは、16 MiBを超えるバルク文字列や配列のヘッダーを、1バイトも確保する前に拒否します(resp.rsのMAX_RESP_BULK_LEN)。

有効化:環境変数ひとつ

OxiMemはデフォルトでは無効です。ポートを与えるだけで有効になります。

OXIDB_ADDR=127.0.0.1:15600 \
OXIDB_DATA=./data \
OXIDB_OXIMEM_PORT=15602 \
./oxidb-server

起動時にサーバーは次のように表示します。

oxidb-server listening on 127.0.0.1:15600 (pool_size=4, data_dir=./data, idle_timeout=0s)
OxiMem RESP listening on 0.0.0.0:15602 (fast mode)

注目すべき点が2つあります。1つ目はアドレスが0.0.0.0であることです。OxiMemのリスナーはOXIDB_ADDRに関係なくすべてのインターフェースにバインドします。これがなぜ重要かは後のセキュリティの節で説明します。2つ目はfast modeです。デフォルトではOxiMemは純粋にインメモリで動きます。その他のオプション(スナップショットとドキュメントミラー)は永続化の節で扱います。ポートの代わりにautoを渡すと、カーネルが空いているポートを選び、OXIDB_READY_FILEにoximem=<port>という行が書き込まれます。私のテストはこの方法でサーバーを起動しています。

OxiMemはドキュメントエンジンに依存するリスナーとして扱われるので、OXIDB_DOC=0(ドキュメントエンジン無効)で起動したサーバーにOXIDB_OXIMEM_PORTが設定されていると、理由を表示して起動を拒否します。

接続するために何かをインストールする必要はありません。普通のredis-cliで動きます。

$ redis-cli -p 15602 PING
PONG

データ型

OxiMemはRedisの5つの基本型、つまり文字列、ハッシュ、リスト、セット、ソート済みセットをサポートします。その上に、GEOコマンド(Redisと同じくソート済みセットの上に構築)と文字列に対するビット演算があります。

文字列とカウンター

最も単純なケースです。キーと値です。

127.0.0.1:15602> SET user:42:name "Ayse"
OK
127.0.0.1:15602> GET user:42:name
"Ayse"
127.0.0.1:15602> APPEND user:42:name " Y."
(integer) 7
127.0.0.1:15602> SETNX user:42:name "Other"
(integer) 0
127.0.0.1:15602> GETRANGE user:42:name 0 3
"Ayse"
127.0.0.1:15602> MSET a 1 b 2 c 3
OK
127.0.0.1:15602> MGET a b c missing
1) "1"
2) "2"
3) "3"
4) (nil)
127.0.0.1:15602> GETDEL user:42:name
"Ayse Y."
127.0.0.1:15602> GET user:42:name
(nil)

カウンターはキーバリューストアの最も便利な顔です。読み取り・変更・書き込みを1つのアトミックなコマンドで行うからです。同時に来た2つのリクエストが互いのインクリメントを飲み込んでしまうことはありません。

127.0.0.1:15602> INCR page:views
(integer) 1
127.0.0.1:15602> INCRBY page:views 10
(integer) 11
127.0.0.1:15602> DECR page:views
(integer) 10
127.0.0.1:15602> INCRBYFLOAT price 2.5
"2.5"
127.0.0.1:15602> SET counter abc
OK
127.0.0.1:15602> INCR counter
(error) ERR value is not an integer or out of range
127.0.0.1:15602> SETBIT flags 7 1
(integer) 0
127.0.0.1:15602> BITCOUNT flags
(integer) 1

値はバイト列で、STRLENはRedisと同じくバイト数を数えます。redis-pyから"Ayşe"を書き込むとSTRLENは5になりました。UTF-8では「ş」が2バイトだからです。

ハッシュ

1つのオブジェクトのフィールドを1つのキーの下にまとめ、フィールド単位でアトミックにインクリメントできます。

127.0.0.1:15602> HSET user:42 name Ayse city Izmir visits 0
(integer) 3
127.0.0.1:15602> HINCRBY user:42 visits 1
(integer) 1
127.0.0.1:15602> HGETALL user:42
1) "city"
2) "Izmir"
3) "visits"
4) "1"
5) "name"
6) "Ayse"
127.0.0.1:15602> HEXISTS user:42 email
(integer) 0
127.0.0.1:15602> TYPE user:42
hash

フィールドの順序は挿入順ではありません。ハッシュは内部的にはHashMapで、順序は保証されません(Redisでも保証されません)。

リスト

VecDequeで実装されているので、両端でのプッシュとポップはO(1)です。右にプッシュして左からポップすれば、FIFOキューになります。

127.0.0.1:15602> RPUSH jobs job-1 job-2 job-3
(integer) 3
127.0.0.1:15602> LPUSH jobs job-0
(integer) 4
127.0.0.1:15602> LRANGE jobs 0 -1
1) "job-0"
2) "job-1"
3) "job-2"
4) "job-3"
127.0.0.1:15602> LPOP jobs
"job-0"
127.0.0.1:15602> LMOVE jobs done LEFT RIGHT
"job-1"
127.0.0.1:15602> LRANGE done 0 -1
1) "job-1"

ブロッキング版もあります。BLPOP、BRPOP、BZPOPMIN、BLMOVE、BRPOPLPUSHです。空のリストに対して一方のクライアントがBLPOP queue 5で待ち、もう一方のクライアントが1秒後にRPUSH queue job-42を実行したときの測定結果です。

1) "queue"
2) "job-42"
waited 0.98s

空のままのリストでは、タイムアウトが切れると(nil)を返します(1秒の待機で1.02秒かかりました)。待機者はCondvarで眠っていて、LPUSH、RPUSH、ZADDといったリストやソート済みセットへの書き込みがあるたびに通知されます。

セット

メンバーシップ、積集合、和集合です。

127.0.0.1:15602> SADD online 42 77 91
(integer) 3
127.0.0.1:15602> SISMEMBER online 77
(integer) 1
127.0.0.1:15602> SMISMEMBER online 42 13
1) (integer) 1
2) (integer) 0
127.0.0.1:15602> SADD admins 42 5
(integer) 2
127.0.0.1:15602> SINTER online admins
1) "42"
127.0.0.1:15602> SUNION online admins
1) "42"
2) "5"
3) "77"
4) "91"

ソート済みセット

リーダーボード、優先度キュー、板情報(オーダーブック)などに使います。内部には2つの構造があります。メンバーからスコアへのHashMap(O(1)のZSCORE)と、(score, member)ペアのBTreeSet(順序付き範囲クエリ)です。スコアはf64でtotal_cmpで順序付けされるため、NaNでさえツリーを壊すことはできません。

127.0.0.1:15602> ZADD scores 1500 ayse 1800 mehmet 1200 zeynep
(integer) 3
127.0.0.1:15602> ZINCRBY scores 400 zeynep
"1600"
127.0.0.1:15602> ZREVRANGE scores 0 2 WITHSCORES
1) "mehmet"
2) "1800"
3) "zeynep"
4) "1600"
5) "ayse"
6) "1500"
127.0.0.1:15602> ZRANGEBYSCORE scores 1550 +inf
1) "zeynep"
2) "mehmet"
127.0.0.1:15602> ZPOPMAX scores
1) "mehmet"
2) "1800"

GEOコマンドはRedisと同じくソート済みセットの上に重ねられていて、TYPEはzsetを返します。

127.0.0.1:15602> GEOADD cities 27.1428 38.4237 izmir 28.9784 41.0082 istanbul 32.8597 39.9334 ankara
(integer) 3
127.0.0.1:15602> GEODIST cities izmir istanbul km
"327.4546"
127.0.0.1:15602> GEOSEARCH cities FROMMEMBER izmir BYRADIUS 400 km ASC
1) "izmir"
2) "istanbul"
127.0.0.1:15602> TYPE cities
zset

本格的な空間処理(ポリゴン、インデックス)については、OxiDBの地理空間クエリとグラフクエリの記事を参照してください。ここでのGEOコマンドはRedis互換のためのものです。

有効期限

キーバリュー層の最も特徴的な点は、データが自然に消えていけることです。セッショントークンのためにクリーンアップジョブを書く必要はありません。寿命を与えればよいのです。

127.0.0.1:15602> SETEX session:abc 900 "user=42"
OK
127.0.0.1:15602> TTL session:abc
(integer) 900
127.0.0.1:15602> SET session:xyz "user=7" PX 1500
OK
127.0.0.1:15602> PTTL session:xyz
(integer) 1995
127.0.0.1:15602> EXPIRE user:42 2
(integer) 1
127.0.0.1:15602> PERSIST session:abc
(integer) 1
127.0.0.1:15602> TTL session:abc
(integer) -1
127.0.0.1:15602> TTL no:such:key
(integer) -2

2.5秒後:

127.0.0.1:15602> GET session:xyz
(nil)
127.0.0.1:15602> EXISTS user:42
(integer) 0

ここには2つの細かい点があります。1つ目は、user:42はハッシュですが、これも期限切れになったことです。以前のバージョンでは期限は文字列エントリのフィールドだったため、TTLは黙って文字列にしか効いていませんでした。これは0.43.2で修正しました。現在、期限はキー名をキーとする別のexpiresマップに置かれ、5つの型すべてに同じように適用されます。Redisも同じ分離をしています(キースペースの隣に別のexpires辞書があります)。

2つ目は、PTTL session:xyzが1500ではなく1995を返したことです。SET ... PXはミリ秒を秒単位に切り上げる(ms.div_ceil(1000))ため、1.5秒は2秒になります。コード上、PEXPIREはミリ秒精度です。ミリ秒単位で正確な寿命に依存するなら、このことを知っておく必要があります。

仕組みは2層になっています。

  • 遅延期限切れ(Lazy expiry): コマンドの実行前に、そのコマンドが触れようとしているキーをチェックし、期限切れのものはその場で削除します。期限切れの値が見えることはありません。ストア内に期限付きのものが1つもないときは、このチェックのコストは競合のない読み取りロック1回分だけです。
  • スイーパースレッド: 誰も読まないキーがメモリに永遠に残らないように、1つのスレッドが1秒ごとに起きて、期限切れのキーをまとめて削除します。キーごとにWATCHのバージョンカウンターを進め、有効であればexpired通知を発行します。

キースペース通知

CONFIG SET notify-keyspace-eventsで有効にします。有効にすると、すべての書き込みが既存のpub/subを通じて__keyspace@0__:<key>と__keyevent@0__:<event>に発行されます。クライアントがPSUBSCRIBE '__key*__:*'で待ち受けている状態でSET k1 v EX 1とINCR views:demoを実行しました。1秒後にスイーパーがk1を削除しています。

1) "pmessage"
2) "__key*__:*"
3) "__keyspace@0__:k1"
4) "set"
...
1) "pmessage"
2) "__key*__:*"
3) "__keyspace@0__:views:demo"
4) "incr"
...
1) "pmessage"
2) "__key*__:*"
3) "__keyevent@0__:expired"
4) "k1"

Redisとの違いが1つあります。OxiMemはイベントクラス(K、E、g、x...)を区別しません。空でない値を設定すると通知が完全に有効になり、空の値で無効になります。

Pub/Sub

SUBSCRIBE、PSUBSCRIBE(globパターン)、PUBLISHをサポートしています。一方のサブスクライバーがSUBSCRIBE news、もう一方がPSUBSCRIBE 'price.*'で待ち受けている状態です。

127.0.0.1:15602> PUBLISH price.BTC 68500
(integer) 1
127.0.0.1:15602> PUBLISH news "hello"
(integer) 1
127.0.0.1:15602> PUBLISH nobody x
(integer) 0

パターンのサブスクライバーが受け取ったもの:

1) "pmessage"
2) "price.*"
3) "price.BTC"
4) "68500"

PUBLISHが返す数値は、メッセージを受け取ったサブスクライバーの数です。誰も聞いていなければ0です。これは撃ちっぱなし(fire-and-forget)なので、その瞬間に接続していないサブスクライバーはメッセージを取りこぼします。永続的なキューが必要なら、OxiDBにはMQTTとAMQPの層があります。

うれしい副作用があります。OxiDBのMQTTブローカーは同じOxiMemStoreのpub/subの上に乗っています。そのため、MQTTで発行されたメッセージをRedisクライアントが受け取れます。redis-cli SUBSCRIBE sensors/tempを開いた状態でmosquitto_pub -p 15603 -t sensors/temp -m 21.5を実行しました。

1) "subscribe"
2) "sensors/temp"
3) (integer) 1
1) "message"
2) "sensors/temp"
3) "21.5"

IoTデバイスがMQTTで測定値を発行し、Webサーバーがredis-pyで待ち受ける。その間にブリッジソフトウェアはありません。

MULTI、EXEC、WATCH

MULTIはキューを開きます。その後のコマンドは実行されず、QUEUEDとともに積まれていきます。EXECでそれらがすべて連続して実行され、間に他のトランザクションが入ることはありません。

127.0.0.1:15602> MULTI
OK
127.0.0.1:15602> SET counter 5
QUEUED
127.0.0.1:15602> INCRBY counter 10
QUEUED
127.0.0.1:15602> EXEC
1) OK
2) (integer) 15

Redisはシングルスレッドなので、この分離をタダで得ています。OxiMemはマルチスレッドなので、分離を明示的に手に入れています。EXECはストアのtx_lockを取得し、自分のキューを処理している間だけ保持します。2つのEXECブロックが交互に入り混じることは決してありません。

コマンドはキューに積む時点でも検証されます。不明なコマンドや引数の不足があるとトランザクションは汚染され、EXECは何も実行せずに拒否します。

127.0.0.1:15602> MULTI
OK
127.0.0.1:15602> SET z 1
QUEUED
127.0.0.1:15602> NOPE z
(error) ERR unknown command 'NOPE'
127.0.0.1:15602> GET z
QUEUED
127.0.0.1:15602> EXEC
(error) ERR EXECABORT Transaction discarded because of previous errors.
127.0.0.1:15602> GET z
(nil)

SET z 1は一度も実行されませんでした。タイプミスのあるトランザクションはデータに触れません。

WATCH:楽観的ロック

MULTI/EXECはアトミック性を与えますが、「自分が読んだ値はまだ有効か?」は保証しません。「残高を読み、十分なら引き落とす」といったロジックは、読み取りと書き込みの間に誰かが書き込むと更新の消失(lost update)にさらされます。そのためにWATCHがあります。監視しているキーがEXECの前に変更されていれば、トランザクションは実行されず、EXECはnull配列を返します。2つのクライアントでの例です。

2つのredis-cliセッション:クライアントBが監視中のキーを変更し、クライアントAのEXECがnilを返す。DECRBYFLOATGEが残高不足の引き落としを拒否する

Aはwallet:42を監視し、100を読みました。Bが割り込んで999を書き込みました。AのEXECは(nil)を返し、Aの書き込みは行われませんでした。

変更をどう検出するかは、この設計で私が一番気に入っている部分です。値のコピーは取りません。大きなソート済みセットを監視するたびにコピーしていたら、O(n)のコストですべてが台無しになります。代わりに、すべてのキーに変更カウンターがあり、WATCHのスナップショットは3つの小さな数値です。

/// O(1) fingerprint of a key for WATCH change detection: the store epoch
/// (bumped by FLUSHALL), the key's mutation counter (bumped by every write
/// command touching it), and whether the key currently exists (so a lazy
/// TTL expiry between WATCH and EXEC still registers as a change).
type KeySnap = (u64, u64, bool);

成功した書き込みはすべて、触れたキーのカウンターを進めます。FLUSHALLはストア全体のepochを進め、すべてのWATCHを一度に無効にします。「存在する」フラグは、2回の読み取りの間に黙って期限切れになったキーを捉えます。EXECはtx_lockの下でこれらの三つ組を再計算して比較します。

意図的な非対称性があります。書き込みが何も変えない場合(同じ値を書き直すなど)でもカウンターは進みます。そのため誤検知はあり得て、不要なリトライのコストがかかります。しかし変更の見逃しはあり得ません。誤検知のコストは1往復ですが、見逃しのコストはデータの破損です。

カウンターマップが無限に大きくならないよう、ガベージコレクションも行います。エントリが100,000を超え、その時点でWATCHを保持しているセッションがなければ、マップはクリアされます(しきい値はOXIMEM_VERSIONS_GC)。誰も監視していなければ、カウンターを失っても何も壊れません。次のWATCHはまっさらな状態から始まります。

redis-pyで競合下のWATCH

負荷をかけた状態でも試しました。2つの口座、acct:a = 1000とacct:b = 0です。8つのスレッドがそれぞれ、WATCH + MULTIで「aからbへ1を移す」操作を250回行い、WatchErrorならリトライします。試行は2000回ですが、aには1000単位しかないので、ちょうど1000回だけ成功するはずです。

def transfer(n):
    c = redis.Redis(host="127.0.0.1", port=15602, decode_responses=True, protocol=2)
    for _ in range(n):
        with c.pipeline() as p:
            while True:
                try:
                    p.watch("acct:a", "acct:b")
                    a = int(p.get("acct:a"))
                    if a < 1:
                        p.unwatch()
                        break
                    p.multi()
                    p.decrby("acct:a", 1)
                    p.incrby("acct:b", 1)
                    p.execute()
                    break
                except WatchError:
                    retries += 1   # (counter guarded by a lock)

結果:

after 2000 transfers: a=0 b=1000 sum=1000 watch_retries=3695

合計は保たれ、マイナスになったものはなく、失われた更新もありません。3695回のリトライは、8つのスレッドが同じ2つのキーをどれだけ激しく奪い合っているかを示しています。これがまさに楽観的ロックの代償です。

Luaスクリプト:EVAL、EVALSHA、SCRIPT

競合が激しいと、WATCHのループは空回りします。別の方法は、読み取り・判断・書き込みのロジック全体をサーバー上で1ステップで実行することです。mluaを通じてLua 5.4を組み込みました。スクリプトはredis.callとredis.pcallでコマンドを呼び出し、cjsonも使えます。

127.0.0.1:15602> SET wallet:7 100
OK
127.0.0.1:15602> EVAL "local b = tonumber(redis.call('GET', KEYS[1]) or '0') if b >= tonumber(ARGV[1]) then return redis.call('DECRBY', KEYS[1], ARGV[1]) end return -1" 1 wallet:7 30
(integer) 70
127.0.0.1:15602> EVAL "...same script..." 1 wallet:7 500
(integer) -1
127.0.0.1:15602> SCRIPT LOAD "...same script..."
"777ac28203607bd0918ff085cb356478607980cb"
127.0.0.1:15602> EVALSHA 777ac28203607bd0918ff085cb356478607980cb 1 wallet:7 20
(integer) 50
127.0.0.1:15602> EVAL "return cjson.encode({n=redis.call('INCR', KEYS[1])})" 1 hits
"{\"n\":1}"

ここのスクリプトは並列に実行できるため、並行性モデルはRedisと異なります。

  • 各スクリプトはeval_gateロックの読み取り側を取ります(多数のスクリプトが同時に動けます)。EXECは書き込み側を取り、すべてのスクリプトを排除します。
  • スクリプトは、宣言されたキー(KEYS)がハッシュされる先のストライプ(128個のうち)をソートされた順序でロックします。ソート順に取得するのでデッドロックは起きず、互いに重ならないキーを扱うスクリプトは完全に並列に動きます。
  • スクリプト内から宣言されていないキーに触れた場合、アトミック性はスクリプト単位ではなくコマンド単位になります。RedisもスクリプトにすべてのキーをKEYSで宣言するよう求めていますが、ここではそのルールがロックを直接決めます。
  • Lua VMは接続スレッドごとに一度だけ作られ、再利用されます(thread_local)。EVALのたびに新しいVMを作ってスクリプトを再パースしていたことが、私が測定した決済レイテンシーの大部分を食っていました。
  • 20,000 Lua命令ごとにフックが走ります。200 msを超えたスクリプト、またはSCRIPT KILLを受けたスクリプトは停止されます。

無限ループを試しました。EVAL "while true do end" 0は約200 ms後に停止し、サーバーは健全なままでした。ただし、そこで粗削りな部分を見つけました。エラーメッセージにLuaの複数行にわたるスタックトレースが含まれるのですが、RESPのシンプルエラー行には改行を含められません。redis-cliは応答をパースできず、Error: Bad simple string valueと表示しました。接続は生き残りますが、これはバグであり、私のリストに載っています。

DECRBYFLOATGE:Redisにない唯一のコマンド

「残高が十分なら引き落とす」はあまりによくある処理なので、Luaすら不要なネイティブコマンドを追加しました。DECRBYFLOATGE key amountです。値が金額以上であればアトミックに減算して新しい値を返し、そうでなければ何も触らずにnilを返します。上のターミナル画像の下半分がそれを示しています。100から30が引き落とされ、200の引き落としは拒否され、残高は70のままです。競合状態の下でも、与信限度を破る引き落としは起こり得ません。これはOxiMem独自のコマンドで、標準のRedisクライアントからは生のコマンドとして呼び出します。たとえばexecute_command("DECRBYFLOATGE", ...)です。

無改造のクライアント:redis-cliとredis-py

この記事のターミナル出力はすべて、Homebrewの標準redis-cli 8.6.1で取りました。Pythonについては、別のvenvにpip install redisでredis-py 8.1.0をインストールし、次のスクリプト(短縮版)を実行しました。

import redis

r = redis.Redis(host="127.0.0.1", port=15602, decode_responses=True, protocol=2)

r.set("user:42:name", "Ayşe")
print("get:", r.get("user:42:name"), "strlen:", r.strlen("user:42:name"))
r.hset("user:42", mapping={"name": "Ayşe", "city": "İzmir"})
print("hgetall:", r.hgetall("user:42"))

pipe = r.pipeline(transaction=False)
for _ in range(1000):
    pipe.incr("views:hello")
print("pipeline last:", pipe.execute()[-1])

debit = r.register_script("""
local b = tonumber(redis.call('GET', KEYS[1]) or '0')
if b >= tonumber(ARGV[1]) then return redis.call('DECRBY', KEYS[1], ARGV[1]) end
return -1
""")
r.set("wallet:7", 100)
r.script_load(debit.script)
print("lua debit 30:", debit(keys=["wallet:7"], args=[30]),
      " debit 500:", debit(keys=["wallet:7"], args=[500]))

ps = r.pubsub()
ps.subscribe("news")
ps.get_message(timeout=1)
r.publish("news", "OxiMem says hi")
msg = ps.get_message(timeout=2)
print("pubsub:", msg["channel"], msg["data"])

出力:

get: Ayşe strlen: 5
hgetall: {'name': 'Ayşe', 'city': 'İzmir'}
pipeline last: 1000
after 2000 transfers: a=0 b=1000 sum=1000 watch_retries=3695
lua debit 30: 70  debit 500: -1
pubsub: news OxiMem says hi

正直に言っておかなければならないことが2つあります。どちらもこの記事を書いている最中にぶつかったものです。

  1. protocol=2が必要です。 redis-py 8はデフォルトで接続時にHELLO 3を送り、RESP3を要求します。OxiMemはRESP2しか話さないのでエラーを返し、redis-pyはフォールバックせずに失敗します:redis.exceptions.ResponseError: NOPROTO unsupported protocol version。引数1つ(protocol=2)で直りますが、「変更不要」とは言えません。
  2. register_scriptには事前にscript_loadが必要です。 redis-pyはEVALSHAを試し、NoScriptErrorならスクリプトをロードしてリトライします。OxiMemのエラーヘルパーはすべてのメッセージの先頭にERR を付けるため、応答は-ERR NOSCRIPT ...となり、redis-pyはこれをNoScriptErrorと認識しません。同じプレフィックスはERR EXECABORTとERR NOPROTOにも現れます。修正は小さなもの(これらのコードにはプレフィックスを付けない)で、私のリストに載っています。

永続化:3つのモード

「インメモリ」とは「失われ得る」という意味で、ほとんどの用途ではそれが正しい選択です。しかし、「再起動を乗り越えられれば十分」という中間地点もあります。

1. 高速モード(デフォルト)

ディスクには何も書きません。プロセスが終了すると、キースペースは消えます。キャッシュやセッションに最適です。

2. スナップショット:OXIDB_OXIMEM_SNAPSHOT_SECS

この変数を設定すると、OxiMemは起動時に<OXIDB_DATA>/_oximem.snapを読み込み、その後N秒ごとにストア全体をそこへ書き出します。書き込みは一時ファイルに対して行われ、fsyncされ、その後リネームで置き換えられるので、書き込み途中でクラッシュしても前のスナップショットが壊れることはありません。ファイルは素のJSONです。

{"s":[{"k":"session:s1","v":"x"},{"k":"views:hello","v":"42"}],"h":[{"k":"user:1","m":{"name":"Ayse"}}],"l":[{"k":"q","v":["a","b","c"]}],"t":[],"z":[{"k":"board","z":[["a",10.0],["b",20.0]]}],"x":[["session:s1",1791754990]]}

xセクションには期限付きキーの絶対的な期限が入っているので、再起動後も残りのTTLは正しいままです。OXIDB_OXIMEM_SNAPSHOT_SECS=2でサーバーを動かし、いくつかキーを書き込み、停止して再起動しました。

OxiMem RESP listening on 0.0.0.0:15602 (fast mode)
OxiMem snapshot loaded
127.0.0.1:15602> GET views:hello
"42"
127.0.0.1:15602> TTL session:s1
(integer) 593
127.0.0.1:15602> LRANGE q 0 -1
1) "a"
2) "b"
3) "c"

次は限界です。スナップショットの後にINCR views:helloを実行し(43が返りました)、2秒経つ前にサーバーを停止しました。

127.0.0.1:15602> INCR views:hello
(integer) 43
# server stopped and restarted
127.0.0.1:15602> GET views:hello
"42"

最後のスナップショット以降の区間は失われます。シャットダウン時に最終スナップショットは取られません。通常のkill(SIGTERM)でもその区間は失われます。RedisのAOFのようなコマンドログもありません。つまりこのモードは「再起動をおおむね乗り越える」ためのもので、「書き込みを決して失わない」ためのものではありません。

3. ドキュメントミラー:OXIDB_OXIMEM_SQL

この名前は歴史的な経緯によるものです。このモードを有効にすると(OXIDB_OXIMEM_SQL=1)、すべての書き込みがドキュメントエンジンの_kv、_hash、_list、_set、_zsetコレクションにもミラーされ、起動時にはそれらのコレクションからメモリが再構築されます。サーバーはこのモードを告知します。

OxiMem RESP listening on 0.0.0.0:15602 (SQL mirroring)

いくつかキーを書き込んだ後、OxiDB自身のPythonクライアント(ネイティブプロトコル、ポート15600)でコレクションを見てみました。

_kv {"_key": "session:abc", "_value": "{\"user\":42}", "_ttl": 900, "_exp": 1791755332, "_version": 1}
_kv {"_key": "session:def", "_value": "{\"user\":7}", "_version": 1}
_kv {"_key": "views:hello", "_value": "1", "_version": 1}
_hash {"_key": "user:42", "name": "Ayse", "city": "Izmir", "_version": 1}
_list {"_key": "jobs", "_items": ["a", "b"], "_version": 1}
_set {"_key": "tags", "_members": ["rust", "db"], "_version": 1}
_zset {"_key": "board", "_members": [{"member": "b", "score": 20.0}, {"member": "a", "score": 10.0}], "_version": 1}
prefix query: ['session:abc', 'session:def']

突然、ホットなデータがクエリ可能になります。find("_kv", {"_key": {"$regex": "^session:"}})ですべてのセッションを一覧でき、その上で集約パイプラインを実行することもできます。再起動後、DBSIZEは7を返し、TTL session:abcは正しい残り時間(879)を返しました。

名前について正直に書いておきます。ソースの古いコメントには、SQLからSELECT * FROM _kvでクエリできると書かれていますが、現在のSQLエンジンは別のcrateで、これらのコレクションは見えません。試してみました:sql error: no such table: _kv。ミラーはドキュメント側にあり、SQLからはアクセスできません。変数名は、SQLの機能がドキュメントエンジンの中にあった頃の名残です。

コストは明白で、しかも大きいです。すべての書き込みがドキュメントエンジンに対する削除と挿入にもなり、デフォルトのstrictモードではそれぞれがfsyncされます。測定しました。このモードでredis-benchmark -t set -n 2000 -c 50は毎秒72リクエスト(p50 462 ms)でした。読み取りには影響がありません。同じモードでのGETは毎秒200,803でした。つまりこのモードは、書き込みはまれで、読み取りは多く、再起動後に戻ってこなければならない小さなデータセット向けです。カウンター向けではありません。

内部:データ構造、並行性、メモリ

OxiMemのアーキテクチャ:RESPリスナー、接続ごとのセッション、ディスパッチャー、5つのRwLockマップ、有効期限、バージョン、Luaゲート、pub/sub、そしてスイーパー、スナップショット、ミラーの各スレッド

すべてはoxidb-server/src/oximem.rsにあり、約6,700行です。中心となる構造体はOxiMemStoreです。

pub struct OxiMemStore {
    strings: RwLock<HashMap<String, KvEntry>>,
    hashes: RwLock<HashMap<String, HashMap<String, String>>>,
    lists: RwLock<HashMap<String, VecDeque<String>>>,
    sets: RwLock<HashMap<String, HashSet<String>>>,
    sorted_sets: RwLock<HashMap<String, SortedSet>>,
    expires: RwLock<HashMap<String, Instant>>,
    // pubsub, psubs, tx_lock, eval_gate, eval_stripes, versions, epoch, ...
}

JSONもWALもインデックスもありません。コマンドは標準のRustコレクションを直接操作します。

接続モデル。 すべてのTCP接続は専用のOSスレッドを持ちます(TCP_NODELAY付き)。スレッドはRESPの値を読み、接続ごとのSession(MULTIキュー、WATCHリスト)を通じて実行します。Redisのシングルスレッドのイベントループと違い、ここでは異なる接続が本当に並列に動き、5つの型マップはそれぞれ別のRwLockの背後にあります。GETとHSETは互いを待ちません。

パイプラインの高速パス。 読み取りバッファに複数のコマンドがたまっているとき(クライアントがパイプライン化しているとき)、それらはバッチとして取り出されます。バッチ内のコマンドがすべて同じで、それがGET、SET、INCR、LPUSH、RPUSH、HSET、PINGのいずれかであれば、該当するロックを一度だけ取ってバッチ全体をその下で実行し、応答は1回のflushで送り出します。混在したバッチや、トランザクションのコマンドを含むバッチは、状態を持つパスで1つずつ実行されます。後のベンチマークでパイプライン化したZADDが遅れをとるのはこのためです。高速パスのリストに入っていないのです。

ロック順序。 expiresマップは最も内側のロックです。型マップを保持している間に取ってもかまいませんが、expiresを保持している間に型マップを取ることは決してありません。期限を変更するコマンドはすべて、まず型マップで存在を確認してからexpiresに書き込むので、この順序は自然に守られます。コードはこのルールを重要な箇所すべてにコメントとして繰り返し書いています。一度でも破ると、めったに起きずきわめて捕まえにくいデッドロックになるからです。

ソート済みセット。 HashMap<String, f64> + BTreeSet<(Score, String)>です。挿入と削除はO(log n)、スコアの検索はO(1)、範囲はツリーの順序付き走査です。弱点は、ZRANKがツリーの先頭から数えるのでO(n)になることです。Redisのスキップリストは各ノードにスパン幅を保存していて、これをO(log n)で行います。数百万メンバーのセットでZRANKを頻繁に呼ぶと、違いに気づくでしょう。

メモリ。 これも測定しました。両方のサーバーをまっさらな状態で起動し、それぞれにredis-cli --pipeで100万件のSET key:NNNNNNN <32-byte value>を投入しました。

起動時のRSS100万キー投入後のRSS増加量キーあたり
Redis 8.6.16.8 MB107.9 MB101 MB約101バイト
OxiDB 0.44.2 (OxiMem)14.6 MB169.6 MB155 MB約155バイト

同じデータに対して、OxiMemはRedisの約1.5倍のメモリを使います。理由ははっきりしています。各キーは個別のString(24バイトのヘッダーと専用のヒープ確保)で、各値も同様であり、さらにHashMapのエントリが加わります。Redisは小さな文字列に埋め込みエンコーディング(embstr)とコンパクトな構造を使います。OxiMemの起動時RSSが高いのは、このプロセスが完全なドキュメントデータベースでもあるからです。maxmemoryの上限やLRUによる追い出しもありません。メモリがいっぱいになっても、プロセスは大きくなり続けます。サイズを抑えるのはあなたの責任です(TTL、キーの数)。

単一のキースペース。 OxiDBはマルチデータベースのエンジンですが、OxiMemのキースペースは意図的にグローバルです(ADR-0012)。SELECT 3はOKを返しますが何もしません。どのデータベースにいても、同じキースペースに書き込みます。セッショントークンやカウンターは論理的なデータベースの境界を気にしません。分離したいならキーのプレフィックスを使ってください(customer:7:session:abc)。クラスターモードでも、OxiMemはRaftでレプリケーションされません。各ノードが自分のキースペースを持ちます。

コマンド一覧

READMEには「50以上のコマンド」と書かれていますが、今ではあまりに控えめです。ディスパッチャーは約135のコマンド名を知っています。

系統コマンド
接続 / サーバーPING ECHO QUIT SELECT AUTH HELLO CLIENT COMMAND INFO CONFIG DBSIZE FLUSHDB FLUSHALL
文字列SET (EX/PX/NX/XX) GET GETSET SETNX SETEX PSETEX MSET MGET APPEND STRLEN GETRANGE SETRANGE GETDEL GETEX
カウンターINCR DECR INCRBY DECRBY INCRBYFLOAT DECRBYFLOATGE
ビットSETBIT GETBIT BITCOUNT
キー / TTLDEL EXISTS TYPE KEYS SCAN RANDOMKEY RENAME COPY EXPIRE PEXPIRE EXPIREAT PEXPIREAT PERSIST TTL PTTL
ハッシュHSET HMSET HSETNX HGET HMGET HGETALL HDEL HEXISTS HKEYS HVALS HLEN HINCRBY HRANDFIELD HSCAN
リストLPUSH RPUSH LPOP RPOP LLEN LRANGE LINDEX LSET LREM LTRIM LMOVE RPOPLPUSH LMPOP
セットSADD SREM SMEMBERS SISMEMBER SMISMEMBER SCARD SPOP SRANDMEMBER SINTER SUNION SDIFF SINTERSTORE SUNIONSTORE SDIFFSTORE SSCAN
ソート済みセットZADD ZREM ZSCORE ZCARD ZCOUNT ZINCRBY ZRANK ZREVRANK ZRANGE ZREVRANGE ZRANGEBYSCORE ZREVRANGEBYSCORE ZRANGEBYLEX ZPOPMIN ZPOPMAX ZREMRANGEBYRANK ZREMRANGEBYSCORE ZUNIONSTORE ZINTERSTORE ZMPOP ZSCAN
GEOGEOADD GEOPOS GEODIST GEOHASH GEOSEARCH
ブロッキングBLPOP BRPOP BZPOPMIN BLMOVE BRPOPLPUSH
Pub/SubPUBLISH SUBSCRIBE PSUBSCRIBE UNSUBSCRIBE PUNSUBSCRIBE
トランザクションMULTI EXEC DISCARD WATCH UNWATCH
スクリプトEVAL EVALSHA SCRIPT (LOAD, EXISTS, FLUSH, KILL)

何が欠けているかも重要です。試してみてunknown commandが返ったコマンドは、UNLINK、MSETNX、HINCRBYFLOAT、ZRANGESTORE、OBJECT、WAIT、SAVE、BGSAVEです。ストリーム(XADD系)もHyperLogLog(PFADD)もなく、ACL、SLOWLOG、MEMORYもありません。CONFIGはほとんど形だけです。実際に何かをするのはnotify-keyspace-eventsだけで、それ以外のCONFIG GETは空の値を返し、CONFIG SETはOKを返します。AUTH、SELECT、CLIENT、COMMANDは、クライアントのハンドシェイクが壊れないようにOKを返します。

INFOも短いものです。

# Server
oxidb_version:0.44.2
oximem_mode:raw
resp_compat:true

# Keyspace
db0:keys=19,expires=0

Redisとの性能比較

Redis互換の層にとって意味のある物差しは、Redis自身だけです。同じマシン(Apple M4、10コア、24 GB、macOS 26.3)で、HomebrewのRedis 8.6.1(--save "" --appendonly no、つまり永続化なし)を高速モードのOxiMemと並べて動かしました。どちらもlocalhostで、redis-benchmark 8.6.1を使って測定しました。200,000リクエスト、50クライアント、64バイトの値で、まずパイプラインなし、次にパイプライン16で実行しました。

Redis 8.6.1とOxiMemのredis-benchmark結果(パイプラインなし、およびパイプライン16)

読み方:

  • パイプラインなし(-P 1) では、両サーバーとも毎秒18万から24.5万リクエストの帯域に収まり、差はノイズの範囲です。2回目の実行では一部のコマンドで順位が入れ替わりました。RedisのGETは17.9万から23.9万に上がり、OxiMemのINCRは23万から18.5万に下がりました。このモードでのボトルネックはサーバーではなく、localhostの往復です。
  • パイプラインあり(-P 16) では、ほとんどのコマンドでOxiMemがリードします(SETは290万対230万、HSETは290万対140万)。理由は前述の2点です。50の接続が別々のスレッドで並列に動き、同じコマンドのバッチが1つのロックの下で実行されます。Redisは1スレッドのままです。2回目の実行でも同じ傾向でした。
  • 遅れをとるところ: PING(240万対320万)、そして特にZADD(140万対180万、2回目は135万対200万)です。ZADDはパイプラインの高速パスに入っていないので、各コマンドが個別にディスパッチャーを通ります。MSET(10キー)もパイプライン時に遅れをとります(39.3万対47.3万)。
  • これらは1台のマシン上、localhost経由で、構成ごとに1〜2回実行した結果です。実際のネットワーク、異なるコア数、異なる負荷では数値が変わります。特に、Redisのスレッド化I/O(io-threads)は有効にしていません。

まとめると、生の速度ではOxiMemはRedisと同じリーグにいて、マルチコアをより活用します。その代わりにより多くのメモリを使います。ただ、私にとって本当の勝利は数字ではなく、この速度がドキュメントエンジンやSQLエンジンのすぐ隣、同じプロセスの中にあることです。インストールし、監視し、アップグレードしなければならない別のRedisはありません。

限界と正直なトレードオフ

ツールの限界を知らないことは、それを間違った場所で使う一番の近道です。現時点でのOxiMemの限界は次のとおりです。

型をまたいだWRONGTYPEチェックがありません。 5つの型が別々のマップにあるため、1つの名前が同時に文字列でもありセットでもあり得ます。Redisでは、セットに対するINCRはWRONGTYPEで失敗します。OxiMemでは、その横に文字列が作られます。

127.0.0.1:15602> INCR online
(integer) 1
127.0.0.1:15602> TYPE online
string
127.0.0.1:15602> SCARD online
(integer) 3
127.0.0.1:15602> DEL online
(integer) 1
127.0.0.1:15602> EXISTS online
(integer) 1

DELは文字列を削除しましたが、セットはまだ残っています。キー名に型のプレフィックスを付ければ(set:online、cnt:online)実際には回避できますが、Redisから来た人はこれを予想しないでしょう。修正が必要な非互換性です。

認証がなく、リスナーはすべてのインターフェースにあります。 AUTHは何に対してもOKと言います。OxiDBのSCRAM認証とロールベースのアクセス制御はOxiMemのポートには適用されません。ポートは0.0.0.0にバインドされるので、正しい運用方法は1つだけです。OxiMemのポートをファイアウォールで外部から遮断し、同じマシンか信頼できるプライベートネットワークからのみアクセスすることです。このブログもそのように運用しています。認証はこの層で最も重要な欠落です。

RESP3がありません。 HELLO 3はエラーになります。そのため一部のクライアント(redis-py 8)ではprotocol=2が必要です。

エラーのプレフィックス。 NOSCRIPT、EXECABORT、NOPROTOはERRのプレフィックス付きで届くため、それらのコードに依存するクライアントのロジック(redis-pyのregister_scriptなど)が発動しません。

ロスのない永続化はありません。 AOFのようなログもなく、シャットダウン時の最終スナップショットもありません。ドキュメントミラーは永続的ですが、書き込みは非常に遅いです。

メモリ上限や追い出しはなく、メモリ使用量はRedisの約1.5倍です。 レプリケーションはありません: キースペースはノードローカルです。

では、いつOxiMemを使い、いつ別のものを使うべきでしょうか。それを決めるのは1つの問いです。このデータを失ったらどうなるか? 答えが「何も起きない、どうせ再計算できるか短命なものだ」(セッション、キャッシュ、カウンター、レート制限、短いキュー、リアルタイム価格)であれば、OxiMemが適所です。答えが「お金を失う、監査証跡に穴が空く」であれば、ドキュメントエンジン、SQLエンジン、またはレジャーエンジンに置くべきです。最も強力な構成は両方を使うことです。高速なホットパスと永続的なコールドパスです。

このブログでのOxiMem

このブログのサーバーはRillで書かれ、3つのプロトコルでOxiDBと通信しています。投稿とタグ(SQLとドキュメント)にはネイティブプロトコル、画像にはS3、ホットなデータにはRESPです。OxiMemに置かれているものは次のとおりです。

  • 閲覧数カウンター。 投稿が開かれると、サーバーはINCR views:<slug>を実行し、返ってきた数値をページ内のプレースホルダーの位置に埋め込みます。つまり、このページの下部にある数値は、まさにその瞬間にOxiMemから来たものです。ボットはカウンターを増やさず、GETで読むだけです。
  • ページキャッシュ。 レンダリング済みのHTMLはpage:<lang>:post:<slug>キーの下に5分の寿命で置かれます(SETEX ... 300)。キャッシュにあればページはそこから直接X-Cache: hitヘッダー付きで送り出され、なければSQLからレンダリングしてOxiMemに書き込みます。投稿が編集されると、KEYS page:*ですべてのページキーを破棄します。ブログは書かれることがまれで読まれることが多いので、これで十分に安上がりです。
  • セッションとレート制限。 管理者のセッションは寿命7日のsess:<token>キーです。コメントとログイン試行は固定ウィンドウのレート制限を通ります。最初のINCRが1を返したとき、キーにEXPIREでウィンドウの長さを設定し、上限を超えたカウントは拒否され、ウィンドウが終わるとキーは自然に消えます。

詳細なアクセス解析はOxiMemにはありません。すべての閲覧は、専用のstrand(Rillの軽量スレッド)でドキュメントエンジンのeventsコレクションにも書き込まれます。こうすることで、ページはドキュメントエンジンのfsyncを待たずに済み、それでいて解析データは永続的です。管理画面の総閲覧数は、OxiMemではなくこのコレクションを数えて出しています。再起動でOxiMemのカウンターが失われても、本当の記録はeventsに残ります。これは、この記事の問い「このデータを失ったらどうなるか?」の生きた実例です。高速な数値はOxiMemに、信頼できる記録はドキュメントエンジンに、というわけです。

試してみる

OxiDBは単一のバイナリです。GitHubのリリースページからお使いのプラットフォーム向けのoxidb-serverをダウンロードするか、リポジトリのDockerfileからDockerイメージをビルドして、次を実行します。

OXIDB_OXIMEM_PORT=6380 OXIDB_OXIMEM_SNAPSHOT_SECS=30 ./oxidb-server
redis-cli -p 6380 PING

すでにお持ちのRedisクライアントなら、どれでも動くはずです(redis-py 8ではprotocol=2を付けてください)。私のリストの次の項目ははっきりしています。型をまたいだWRONGTYPEチェック、ERRプレフィックスなしのエラーコード、複数行のLuaエラーの修正、シャットダウン時の最終スナップショット、そして認証です。シリーズの次の記事では、OxiDBのS3互換オブジェクトストレージを扱います。

BA
Barış Akın

25年以上の経験を持つソフトウェアソリューションアーキテクト。FinTechと分散システム。OxiDB、Rill、Cobraの開発者。