はじめに
私は、通話にボットを送り込まず画面上に常駐するミーティングアシスタント、LiveSuggest を開発しています。この記事で扱うのは製品の紹介ではなく、課金まわりのRedis実装で踏んだバグです。
先に結論を書きます。Redisのバグではありません。原因は、私たちがコマンドの副作用を見落としていたことでした。
前提: セッションキーと課金カウンタ
セッションごとにRedisのキーを持ち、TTLは15分にしています。セッションが動いている間はTTLが更新され続け、止まれば15分後に自然に消える、という設計です。
課金対象の秒数は、このキーのカウンタに積み上げています。加算はLuaスクリプトで INCRBY と EXPIRE をまとめて実行するので、加算とTTLの更新は必ずセットでアトミックに行われます。
読み取りとリセットにGETSETを使っていた
積み上がった秒数は、別の処理が定期的に回収します。ここで使っていたのが GETSET です。現在値を読み、同時に0へ戻す処理を1コマンドで書けるので、アトミックで手軽に見えました。
見落としていたのは、GETSET が値を上書きするときにTTLも消してしまう点です。回収した直後のキーは、TTLのない永続キーになります。
なぜ普段は表に出なかったのか
セッションが生きていれば、次の加算でLuaスクリプトが EXPIRE を実行し、TTLは元に戻ります。消えたTTLはすぐ付け直されるので、通常の流れでは問題が見えません。
問題になるのは、回収の直後にセッションが止まった場合です。そのあと EXPIRE が一度も呼ばれないゾンビセッションになると、課金秒数のキー(billableSecondsPending)はTTLを失ったまま残り続けます。15分で消えるはずのキーが、いつまでも消えません。
原因の特定
issue #601 で、このリークの原因が GETSET であることを突き止めました。加算側のスクリプトは正しく、TTLを外していたのは回収側でした。
修正: GETとSET KEEPTTLをLuaで
回収処理もLuaスクリプトに置き換えました。中身は次の2ステップだけです。
-
GETで現在値を読む -
SETにKEEPTTLを付けて値を戻す
KEEPTTL を付けると、値を書き換えても既存のTTLは残ります。Luaスクリプト内で実行するので、読み取りと書き戻しの間に加算が割り込むこともありません。
もう一つ決めたのが、キーが存在しなければ何もしない、というルールです。セッションのキーがすでに期限切れで消えているのに回収側が SET すると、TTLのないキーを新しく作ってしまいます。これでは同じリークがまた起きます。GET の結果が空なら書き込まずに終了します。
修正後の整理
- 加算: Luaで
INCRBYとEXPIREをアトミックに実行し、TTLを更新する - 回収: Luaで
GETとSET KEEPTTLを実行し、TTLには触れない - キーがなければ作らない
TTLを更新するのは加算側だけになりました。回収側はTTLに一切影響しません。
学んだこと
TTLで寿命を管理しているキーに書き込むときは、そのコマンドがTTLを残すのか消すのかを確認する必要があります。GETSET のように、読み取りとリセットを1コマンドで済ませられる便利なコマンドほど、副作用を見落としやすいです。
今回のリークは正常なセッションでは起きず、止まったセッションでだけ起きていました。TTLに頼った設計では「次の更新が来ない場合」を想定し、そのときキーにTTLが残っているかを確認するようにしています。