ローカルのRedisに対して200 msで応答するページが、クライアントがVPNの背後に置かれた途端に四秒かかる一方で、サーバーのカウンターは動かないままだ。スローログには一ミリ秒を超える項目はなく、CPUグラフは平坦で、インスタンスは他のクライアントを何の問題もなく処理している。変わったのは距離であり、距離を倍加させるのはコードが停止して待つ回数だ。クライアントから外へ向かってリクエスト経路をたどれば、解決策はほぼ常に、より速いサーバーではなく直列の待ちの削減である。

サーバーの指標ではなくクライアントから始める

最初のコマンドと最後のコマンドの間のクライアントプロセスを観察しよう。トレースが応答、間隔、そして次のコマンドという並びを示すなら、コマンドごとに完全な往復を支払っており、サーバー側の調整では変わらない。ここでスローログは間違った道具で、実行時間を記録し、通信I/Oを意図的に除外しているからだ。遅いエンドポイントの隣にあるきれいなスローログは、往復の問題の兆候である。

コードに触れる前に経路そのものを測ろう。redis-cli -h redis.example.internal --latency はPINGを繰り返し送り、リクエストと応答の平均時間を表示し、これが一回の交換あたりのコストだ。一つのユーザー操作における直列コマンド数を掛ければ、そのエンドポイントの下限が出る。

一つのユーザー操作が実際に必要とする往復を数える

一つのエンドポイントを取り、ORM、セッションミドルウェア、フィーチャーフラグ検索の内部に隠れているものも含めて、すべてのRedis呼び出しを順に書き出そう。遅いエンドポイントの多くは、三回で足りるところで二十回や三十回の呼び出しを行っており、隠れたものが通常は多数を占める。

依存関係の連鎖は折り畳めず、二番目のコマンドが一番目が返すキーを必要とするなら、待ちは現実であり、選択肢は別のデータモデルか、手順全体を一度に表現するサーバー側操作だけだ。独立した読み取りは別の話で、二番目を送る前に一番目の応答を待つことを強制するものは何もない。何かを編集する前に各呼び出しを依存か独立かに分類すれば、修正の形は明らかになる。

重要な数は直列の待ちであり、コマンドの総数ではない。一つのバッチに三十のコマンドを入れても、待ちは一回で済む。

互いに依存しないコマンドだけをバッチにする

パイプラインは独立したコマンドをキューに入れ、応答を順に集めることで、N回の待ちを一回に変える。ほとんどのクライアントは別個の接続モードではなく、ブロック、コールバック、またはコンテキストマネージャとして提供するので、インストール済みのバージョンが提供するAPIを確認しよう。効果は往復時間に比例して大きくなるので、VPNで重要でlocalhostではほとんど気づかれない。

import redis

r = redis.Redis(host="redis.example.internal", port=6379, socket_timeout=2)
pipe = r.pipeline(transaction=False)
for key in ("user:1001", "user:1002", "user:1003"):
    pipe.get(key)
print(pipe.execute())

バッチだけが目的なら transaction=False を渡す。既定ではクライアントがバッチをMULTIとEXECで包むため、アトミックになり、他のクライアントが途中で観測できる内容が変わる。アトミック性は性能のつまみではなく正しさの判断であり、両者が往復を減らすからといってパイプラインがトランザクションになるわけではない。

バッチの送信だけでなく、すべての応答を読もう。キューに入れたコマンドはバッチ全体が完了してもエラーを返すことがあり、応答リストを捨てるクライアントは失敗した書き込みを黙った書き込みに変えてしまう。応答リストの長さを、キューに入れたコマンド数と比べよう。

操作の間で接続を生かしておく

新しい接続はそれぞれ、名前解決、TCPハンドシェイク、TLSハンドシェイク、AUTHのコストを払い、VPN上ではそのどれもがバッチで消せない往復だ。クライアントはプロセスごとに一度だけ作り、再利用するか、ライブラリが提供する上限付きのプールを使おう。リクエストごとの再接続は、コマンドごとの待ちに次いでこの症状の二番目に多い原因である。

設定にはリテラルのアドレスではなくプライベートなホスト名を保とう。証明書の検証とサービスの同一性はプライベートネットワークでも有効であり、IPを固定するとプロバイダがサブネットを再番号付けした瞬間に壊れる。名前解決が遅い部分なら、接続文字列ではなくリゾルバの経路を直そう。

明示的なソケットタイムアウトを設定し、止まったトンネルがワーカーをぶら下げるのではなく速やかに失敗するようにしよう。そのうえで、クライアントで接続生成をログに記録し、プールが本当に再利用されていることを確かめよう。

メモリ問題になる前にバッチの大きさを決める

Redisはバッチの間ずっとキューに入った応答を保持し、クライアントはその後で解析済みの結果を保持する。一万個の大きな値のパイプラインは、ネットワークの待ちをメモリの急増と両端での長い停止に交換する。

バッチの大きさはコマンド数ではなく応答量で選ぼう。小さなコマンド数百個なら通常は快適だが、数キロバイトのペイロード百個はそうではない。バイト数と個数の両方で分割し、各バッチをエンドポイントが実際に持つレイテンシ予算の内側に収めよう。

変更後は端から端まで測り、redis-cli -h redis.example.internal --latency-history は経路そのものが安定しているかを、自分のリクエストタイマーはエンドポイントが改善したかを示す。レイテンシ履歴が平坦なのにエンドポイントがまだ遅いなら、残りのコストはサーバー側の処理か自分のシリアライズであり、次の一手はさらなるバッチ変更ではなくハンドラのプロファイルだ。

関連記事