📝

APIレスポンスをredisでキャッシュしてパフォーマンスを改善する

2024/08/19に公開

背景

野球"盤"型競技専用スコアブックアプリ(web アプリ)を開発・運用しています。
https://cap-scorebook.com

大規模な大会になると試合中の記録と同時に会場外からの閲覧が増えることもあります。先週開催された全国大会などではアナリティクス上では同時接続が 50 付近を推移し、高負荷時はサーバのLoad Averageが 9 を超えてしまい、そろそろ有効な対策を打たねばということで色々調べながらやってみました。

k6 による負荷テストの実施

今回の改修にあたり、ローカル上でも打った対策が有効であることを確認したかったのでk6を使って負荷テストを行いました。

https://zenn.dev/tilfin/articles/007c1f5ddbfb103ffbd7

まず想定されるシナリオを作り、k6 を短時間実行して問題となる箇所を探ります。

import { check, sleep } from "k6";
import http from "k6/http";
import { Rate } from "k6/metrics";

const failRate = new Rate("failed_requests");
export const options = {
  thresholds: {
    failed_requests: ["rate<0.05"], // リクエストのエラー率が5%未満
    http_req_duration: ["p(95)<500"], // 95%のリクエストの応答時間が500msec未満
  },
};

const ENDPOINT = `http://localhost`;

export default function () {
  const games = http.get(`${ENDPOINT}/hogehoge`, {
    headers: {
      "Content-Type": "application/json",
    },
  });
  const fetchGamesResult = check(games, {
    "fetch games": (r) => r.status === 200,
  });
  if (!fetchGamesResult) {
    failRate.add(true);
    return;
  }
}

改修前の負荷テスト

DB サーバがリソースをほぼ全部食いつぶして 100%付近に張り付いたままになっていました。
また、同時接続 50 で 5 分間負荷をかけ続けるとhttp_req_duration(応答時間)が平均 10 秒、最長 32 秒となっていました。

  ✓ fetch games
     ✓ fetch gameDetail
     ✓ fetch league Detail

     checks.........................: 100.00% ✓ 1305    ✗ 0
     data_received..................: 209 MB  642 kB/s
     data_sent......................: 197 kB  604 B/s
   ✓ failed_requests................: 0.00%   ✓ 0       ✗ 0
     http_req_blocked...............: avg=440.45µs min=0s      med=0s    max=15.91ms p(90)=0s     p(95)=0s
     http_req_connecting............: avg=71.84µs  min=0s      med=0s    max=5.93ms  p(90)=0s     p(95)=0s
   ✗ http_req_duration..............: avg=10.64s   min=60.33ms med=9.72s max=32.67s  p(90)=21.71s p(95)=26.23s
       { expected_response:true }...: avg=10.64s   min=60.33ms med=9.72s max=32.67s  p(90)=21.71s p(95)=26.23s
     http_req_failed................: 0.00%   ✓ 0       ✗ 1305
     http_req_receiving.............: avg=579.61µs min=0s      med=0s    max=6.86ms  p(90)=1.99ms p(95)=2ms
     http_req_sending...............: avg=19.62µs  min=0s      med=0s    max=1.51ms  p(90)=0s     p(95)=0s
     http_req_tls_handshaking.......: avg=0s       min=0s      med=0s    max=0s      p(90)=0s     p(95)=0s
     http_req_waiting...............: avg=10.64s   min=60.33ms med=9.72s max=32.67s  p(90)=21.71s p(95)=26.23s
     http_reqs......................: 1305    4.00152/s
     iteration_duration.............: avg=35.96s   min=14.84s  med=36.9s max=46s     p(90)=40.53s p(95)=41.77s
     iterations.....................: 435     1.33384/s
     vus............................: 1       min=1     max=50
     vus_max........................: 50      min=50    max=50


running (5m26.1s), 00/50 VUs, 435 complete and 0 interrupted iterations
default ✓ [======================================] 50 VUs  5m0s

対応策を考える

まずサーバへのリクエストを減らす方向で検討しましたが、フロントでのデータキャッシュもある程度限界があり、useSWR と refreshInterval を使った自動更新も扱っているため、速報性は多少落ちるものの、リクエストを減らすよりも DB へのアクセスを減らす方が有効と判断しました。redis では有効期限を設定できるので、有効期限が切れたら自動的に KVS から削除できるため、削除処理を意識せずに実装できると考えました。

https://gist.github.com/hdknr/44e397ac03deaa013c125c49c0d33904

redis を使ったキャッシュの実装

docker-compose に redis コンテナを導入する

詳細は割愛しますが、既に構築している他のコンテナとの通信や永続化を設定しておきます。

# redis
redis:
  image: "redis:latest"
  ports:
    - 6379:6379
  volumes:
    - redis-volume:/data
  networks:
    - backend
  restart: always

NodeJS での実装

NodeRedis も v4 以降で Promise に対応はしているのですが、早くから Promise に対応していて情報の多い ioredis を使いました。

https://github.com/redis/ioredis
https://stackoverflow.com/questions/44210755/using-redis-in-an-express-route

ログインユーザはスコアを記録していることがほとんどなので最新の情報を返すように、未ログインユーザはキャッシュがあればキャッシュを返し、なければキャッシュを作ってからレスポンスを返すように実装しました。

  const redis = new RedisClient().getClient();
  
  if (idToken) {
    // ログインユーザの処理
  } else {
    // 非ログインユーザはキャッシュを返す
    const cached = await redis.get(
      `hogehoge`
    );
    if (cached) {
      // キャッシュが存在すれば返す
      return res.json(JSON.parse(cached));
    }
  }

  ...

  if (!idToken) {
    // 有効期限を設定してkey/valueにセット
    await redis.setex(
      `hogehoge`, // key
      2 * 60, // expiration(seconds)
      JSON.stringify(responseBody) // value
    );
  }

開発中の動作確認はredis-commanderを使いました。
https://github.com/joeferner/redis-commander

redis を使って API レスポンスをキャッシュした後の負荷テスト結果

http_req_durationが平均1秒以下、最大3秒まで縮まりました。
CPU使用率がまだ若干高いですが、スロークエリ周りの問題かと思うので今回の目的はいったん達成できたと思います。

 scenarios: (100.00%) 1 scenario, 50 max VUs, 5m30s max duration (incl. graceful stop):
              * default: 50 looping VUs for 5m0s (gracefulStop: 30s)


     ✓ fetch games
     ✓ fetch gameDetail
     ✓ fetch league Detail

     checks.........................: 100.00% ✓ 10344     ✗ 0
     data_received..................: 1.7 GB  5.5 MB/s
     data_sent......................: 1.6 MB  5.1 kB/s
   ✓ failed_requests................: 0.00%   ✓ 0         ✗ 0
     http_req_blocked...............: avg=58.43µs  min=0s    med=0s     max=17.55ms p(90)=0s       p(95)=0s
     http_req_connecting............: avg=11.56µs  min=0s    med=0s     max=7.55ms  p(90)=0s       p(95)=0s
   ✓ http_req_duration..............: avg=122.36ms min=0s    med=2.44ms max=3.41s   p(90)=286.1ms  p(95)=376.59ms
       { expected_response:true }...: avg=122.36ms min=0s    med=2.44ms max=3.41s   p(90)=286.1ms  p(95)=376.59ms
     http_req_failed................: 0.00%   ✓ 0         ✗ 10344
     http_req_receiving.............: avg=572.56µs min=0s    med=0s     max=26.61ms p(90)=1.6ms    p(95)=2ms
     http_req_sending...............: avg=8.64µs   min=0s    med=0s     max=3.99ms  p(90)=0s       p(95)=0s
     http_req_tls_handshaking.......: avg=0s       min=0s    med=0s     max=0s      p(90)=0s       p(95)=0s
     http_req_waiting...............: avg=121.78ms min=0s    med=2.3ms  max=3.4s    p(90)=284.11ms p(95)=375.08ms
     http_reqs......................: 10344   34.008351/s
     iteration_duration.............: avg=4.38s    min=4.19s med=4.26s  max=7.41s   p(90)=4.48s    p(95)=5.17s
     iterations.....................: 3448    11.336117/s
     vus............................: 5       min=5       max=50
     vus_max........................: 50      min=50      max=50


running (5m04.2s), 00/50 VUs, 3448 complete and 0 interrupted iterations
default ✓ [======================================] 50 VUs  5m0s

参考

https://qiita.com/hatsu/items/a52817364160e0b6bb60

Discussion