APIレスポンスをredisでキャッシュしてパフォーマンスを改善する
背景
野球"盤"型競技専用スコアブックアプリ(web アプリ)を開発・運用しています。
大規模な大会になると試合中の記録と同時に会場外からの閲覧が増えることもあります。先週開催された全国大会などではアナリティクス上では同時接続が 50 付近を推移し、高負荷時はサーバのLoad Averageが 9 を超えてしまい、そろそろ有効な対策を打たねばということで色々調べながらやってみました。
k6 による負荷テストの実施
今回の改修にあたり、ローカル上でも打った対策が有効であることを確認したかったのでk6を使って負荷テストを行いました。
まず想定されるシナリオを作り、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 から削除できるため、削除処理を意識せずに実装できると考えました。
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 を使いました。
ログインユーザはスコアを記録していることがほとんどなので最新の情報を返すように、未ログインユーザはキャッシュがあればキャッシュを返し、なければキャッシュを作ってからレスポンスを返すように実装しました。
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を使いました。
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
参考
Discussion