🎨

Dalli で Memcached のメタプロトコル

に公開

モンスターストライクのバックエンドでは、Memcached に強く依存しています。主に、DB(MariaDB)から取得したデータを保存して、読み込み側のパフォーマンス改善に利用しています。

Memcached には、昔からあるバイナリプロトコルと、比較的最近(2021年7月とかですが)できたメタプロトコル(メタテキストプロトコル)というのがあります:

https://docs.memcached.org/protocols/

長いことバイナリプロトコルを使っていたのですが、バイナリプロトコルはかなり前から deprecated になっており、いつ使えなくなるかわからないのでメタプロトコルへ移行しました。

モンストのバックエンドアプリケーションは Ruby で書かれており、Memcached クライアントには Dalli というライブラリを使っています:

https://github.com/petergoldstein/dalli

本稿では、Dalli でメタプロトコルを使う方法と、移行してどうだったかを書いています。

Dalli でメタプロトコル

これ自体は簡単で、Dalli::Client.new する時に渡す :protocol オプションに :meta を指定するだけです(デフォルトが :binary)。

https://github.com/petergoldstein/dalli/blob/v3.2.8/lib/dalli/client.rb#L46-L47

ただ、2025年7月時点での最新リリースである ver3.2.8 にはバグがありました。メタプロトコルでは終端文字(terminator)として \r\n を使っていたのですが、キャッシュした値自体に \r\n が含まれるとちゃんとパースできない実装になっていたのです。モンストの場合、msgpack した値をよくキャッシュしてたので、こういう制御文字が含まれてました。

そもそも、メタプロトコルのレスポンスにはキャッシュした値のサイズが載っています:

https://github.com/memcached/memcached/blob/1.6.39/doc/protocol.txt#L517-L528

なので、それを見てちゃんとパースするように修正しました:

https://github.com/petergoldstein/dalli/pull/1007

(マージはされましたが、まだリリースはされていない点に注意してください)

移行した結果

APIによるんですが、だいたい10%ほど速くなりました。速くなると思ってなかったので、細かいプロファイルをとっておらず、なぜ速くなったのかはわからないです(Dalliが速いのか、Memcached側が速いのか)。完全に棚ぼたでした。

不具合:長めのキャッシュキーに空白を含む場合

メタプロトコルに切り替え後、開発環境で時刻偽装するとキャッシュ関連でエラーが起こるという報告が来ました。エラーの内容は、キャッシュキーが長すぎたことによるものでした。Memcached はキャッシュキーの長さに250文字の制限を設けています。しかし Dalli には、250文字を超える場合に(デフォルトでは)MD5ハッシュ化を行う機能があります:

https://github.com/petergoldstein/dalli/blob/v3.2.8/lib/dalli/key_manager.rb#L50-L55

メタプロトコルでもこれは同じです。ただ、メタプロトコルの場合にキーに関する制約がもう一つ増えてました。

https://github.com/memcached/memcached/blob/1.6.39/doc/protocol.txt#L41-L49

「the key must not include control characters or whitespace(空白や制御文字を含んではいけません)」と、その場合は Base64 エンコードして送るべきだそうで、そのためのフラグがメタプロトコルにはありました。Dalli でもそのような仕組みがあります:

https://github.com/petergoldstein/dalli/blob/v3.2.8/lib/dalli/protocol/meta/key_regularizer.rb#L15-L20

pack('m0') というのが Base64 エンコードです。そして、問題は、、、この適用順です・・・

Dalli では、250文字のチェックをしてから空白文字などのチェックをしていました。今回エラーになったのは、キャッシュキーが 250文字ギリギリでかつ空白を含むケースで、そのキーを Base64 エンコードするとギリギリ250文字を超えてしまった(Base64エンコードすると文字列が長くなるので)のです :innocent:

ちなみに、開発環境でだけ起きていたのは、このギリギリなラインになるのが時刻偽装時にキャッシュキーを伸ばしていたためでした。時刻偽装時には、別時間同士でキャッシュを共有しないように、キャッシュキーにプレフィックスを付けてキャッシュ空間を切り分けるようにしており、それでキャッシュキーが伸びるのです。もちろん、時刻偽装は本番環境ではできないため本番環境では起きていませんでした。

なぜ空白が入ったのか

Time オブジェクトをキャッシュキーに含ませていたからでした。キャッシュする値が時刻に依存する場合、時刻をキーに含ませること自体は正しいです。ただ、キャッシュキーは String に変換されるので、Time オブジェクトをそのまま渡してしまうと、"2024-10-30 04:00:00 +0900" といった文字列が含まれることになります。

修正自体は簡単で、今回は一日おきにキャッシュを変えたいキャッシュだったので 2024-10-30 のような形にフォーマットするようにしました。

また、他にもそのようなキャッシュキーがないか、テストを書いて洗い出して全て修正しました。

おしまい

MIXI DEVELOPERS Tech Blog

Discussion