まとめ
- pinterest/pymemcacheは障害が発生したmemcachedを自動でリバランスして書き込みに行かないような機能を持っている
- 特定ノード間だけで起きてたりするとデータの不整合が発生するかもしれないから注意が必要
- memcachedのクライアントを選ぶときはこの辺気にする必要あるよねという学びを得た
以下からが本文
概要
おうちのラズパイは現在みんなで趣味プログラミング用のmemcacehdクラスタとして稼働しています。いろいろな言語で実装されているクライアントで使ってたのですがpinterest/pymemcacheというライブラリを使っていて「おや?」となったので調べて書いてみた記事です。
memcachedの分散
memcachedはmemcached同士での通信は行わない分散方式を採用しています。memcached同士で通信を行ってデータのやり取りなどは行われません。複数台のmemcachedへのデータの分散は全てクライアント側の実装に依存するようになっています。Perlなら"Cache::Memcached::Fast"、PHPなら"Memcached::addServers"、Pythonなら"pinterest/pymemcache"といったライブラリがあります。今回は最後のPythonのクライアントを使った話です。
クライアントがノードを決定するには「サーバのリストとkeyを使用する」というのとそれぞれが同一ならノードはどのサーバで計算しても同じになるというのがポイントです。(詳しくは以下)
クライアントのサンプル実装
引数で受け取った文字列をkeyとしてHashClientのいずれかにsetしに行く実装です。サーバは2台用意していてそれぞれ正常に起動している場合はどんなkeyをsetしても全て成功します。
import sys from pymemcache.client.hash import HashClient client = HashClient([ 'memcached01:11211', 'memcached02:11211', ]) def main(): client.set(sys.argv[1], "test") if __name__ == "__main__": main()
接続できないサーバを追加して擬似障害を発生させてみる
意図的に接続できないサーバを1台HashClientに追加した状態で実行してみます。すると以下のようにsocket.timeout
が発生することが確認できました。
Traceback (most recent call last): File "//memd_cli.py", line 14, in <module> main() File "//memd_cli.py", line 11, in main client.set(sys.argv[1], "test") File "/usr/local/lib/python3.9/site-packages/pymemcache/client/hash.py", line 361, in set return self._run_cmd('set', key, False, *args, **kwargs) File "/usr/local/lib/python3.9/site-packages/pymemcache/client/hash.py", line 336, in _run_cmd return self._safely_run_func( File "/usr/local/lib/python3.9/site-packages/pymemcache/client/hash.py", line 215, in _safely_run_func result = func(*args, **kwargs) File "/usr/local/lib/python3.9/site-packages/pymemcache/client/base.py", line 462, in set return self._store_cmd(b'set', {key: value}, expire, noreply, File "/usr/local/lib/python3.9/site-packages/pymemcache/client/base.py", line 1091, in _store_cmd self._connect() File "/usr/local/lib/python3.9/site-packages/pymemcache/client/base.py", line 420, in _connect sock.connect(sockaddr) socket.timeout: timed out
この状態はいつまで続くのかを知りたく以下のようにloopさせつつsetするよう修正してみました。実行結果の予想としては永遠にfailedが出続けるのが期待値です(詳細は後述)
import time import sys from pymemcache.client.hash import HashClient client = HashClient([ 'memcached01:11211', 'memcached02:11211',], connect_timeout=1 ) def main(): while True: time.sleep(3) try: client.set(sys.argv[1], "test") print("set") except: print("failed") if __name__ == "__main__": main()
上記の状態でmemcached02を停止させて実行!!すると実行途中からsetが成功するパスを通ってsetが出力されました。
failed failed failed failed set set set (省略)
なぜ
This will use a consistent hashing algorithm to choose which server to set/get the values from. It will also automatically rebalance depending on if a server goes down.
ドキュメントには上記のように記載があった。サーバダウンしたらリバランスするよ的な機能を搭載しているとのこと。
詳細な実装はよくわからなかったのでソースを見てみます。具体的な処理は_mark_failed_server()というメソッドでconnect失敗回数を記憶して一定回数(オプションで指定可能)でそのサーバをmemcachedのノードリストから削除するような実装となっていることがわかりました。ちなみにretry_attempts
などを初期化時に指定することで調整も可能になっているようです(ドキュメントにも書いてあった)。
クライアントがノードの障害を動的に検出すると起こりそうな問題
最初の方にも書いたがmemcachedのクラスタはサーバ間でデータのやり取りをしない。もちろんクライアントも同様。で、ここでクライアントが複数いる構成で上記のように障害ノードを動的に外すと何が起こるかを考えると一番怖いのがデータの不整合です。不整合が起こりうるシナリオは以下
- ① クライアント1とmemcached1の間のネットワークで障害が起きる
- ② クライアント1はmemcached1をnodeリストから削除してコンシステントハッシュを使ってノードを決定する
- ③ クライアント2とmemcached1の間では疎通が問題なくできる
こういう状況になってしまうとクライアント1は本来memcached1へ書き込みたいkeyをmemcached2へ書き込んでしまうような流れになってしまう。またpymemcacheはノードの復旧を検出してノードリストを書き換える仕組みも持っていてこちらもデフォルトでオンになっている。この復旧のタイミングではmemcached1とmemcached2にデータが新旧で存在しうることになってしまう。リアルタイム性を伴うキャッシュなんかだったら事故につながってしまいそうな話。
どうするのが良いのか
クライアントで機能を無効にできるなら無効にするのが良さそうかなと思っています。ただこの場合は本来memcached01に存在するべきデータはmemcachedのダウン中は一切参照も書き込みもできなくなる。これが例えばホットキーで大量のreadがある場合は全てが読み取り失敗かフォールバック処理がDBへ行くなら全てがDBへ直撃してしまう可能性があります。
ただこれもmemcachedを横にたくさん並べれば全体の数%がDBへいくだけとなるので大きく問題にはならなそう。全て直撃するとDBの負荷が気になるみたいな場合はそもそもキャッシュの戦略を変えた方が良さそうと思っています。(きちんとkeyが分散されてないる前提)
あとはキャッシュレイヤで不整合が起きても問題ないようなキャッシュ戦略を取るとかもありなのかなと思ったりもしました。(私が書いてたコードだとネガティブキャッシュとかの用途で使ってたのでこの不整合はもろに影響を受けてしまうので設計から怪しいというのにこの辺で気付きました)
前職だったらこれくらいしか案が思いつかなかっただろうけど今だとConsulというものを教えてもらったのでクライアント間で障害情報を共有することが容易になっている。例えば特定ノードで通信障害が起きたらその結果をConsulに伝えてtempleteからリストを生成してクライアントへシグナルを送信して再読み込みなんてこともできてしまう。
他にもReplica Poolを用いたダブルライトパターンとかで複数memcachedに書き込んだりするのも手としてあるようです。この辺までやるとなるとRedisとかサーバ側でのレプリケーションを提供してくれるミドルウェアへの乗りかえの検討とかも必要そうですね。