本文へ移動

ホーム / Web / 第4部 バックエンド

第4部 バックエンド・第12章

非同期処理・キャッシュ・リアルタイム

★★☆ 重要7トピック・面接 3問

レスポンスを速く保ちながら、重い処理や大量のアクセスをさばく方法を押さえる。

ひとことで

メール送信や画像変換などはキューに入れてワーカーが後で処理する。リトライと冪等性が前提。

メール送信・画像の変換・外部 API への通知・CSV の書き出しなど、時間がかかる処理や失敗しやすい処理は、リクエストの中で行わずキューにジョブとして入れ、別のプロセス(ワーカー)が後で処理します。ユーザーへの応答が速くなり、外部が落ちていてもリクエストは失敗しません。

重い処理はキューに入れて、ワーカーが後で行うWeb サーバーすぐ 202 を返すキュージョブは ID だけワーカー冪等に処理完了入れる取る成功失敗したら間を空けてリトライデッドレター上限まで失敗したもの上限を超えた原因を直して再投入
キュー・ワーカー・リトライ・デッドレターの関係

ジョブの失敗とリトライ・デッドレター

  • リトライ:失敗したら指数バックオフで数回やり直す。直らない失敗(データが壊れている)はすぐ諦める。
  • デッドレターキュー(DLQ):上限まで失敗したジョブを別のキューに移し、捨てずに残す。件数を監視し、原因を直してから再投入する。
  • 冪等にする:多くのキューは「少なくとも1回」届ける(同じジョブが2回動くことがある)ので、2回動いても結果が同じになるように作る(送信済みの記録を見る、一意制約)。
  • DB と一緒に:「注文を保存してからジョブを入れる」の間で落ちるとずれる。コミットした後に入れる、または同じ DB の表にイベントを書いて別の処理で送る(Outbox パターン)。
  • ジョブには ID など最小限だけを入れ、処理のときに DB から最新を読む。タイムアウトと同時実行数の上限も決める。
道具仕組みよく使う場面
Amazon SQSAWS のキューのサービス。可視性タイムアウト・DLQ があるAWS 上のサービス、Lambda と組み合わせる
SidekiqRedis を使う Ruby のジョブの仕組みRails
BullMQRedis を使う Node.js のジョブの仕組みNode.js・TypeScript
その他Celery(Python)、Cloud Tasks・Pub/Sub(Google Cloud)、DB を使う仕組み(Solid Queue・good_job など)言語や基盤に合わせて

面接ではこう答える

メール送信や画像の変換のように時間がかかる処理や失敗しやすい処理は、リクエストの中で行わず、キューにジョブとして入れてワーカーが後で処理します。ユーザーへの応答が速くなり、外部が落ちていてもリクエストは失敗しません。失敗したら指数バックオフでリトライし、上限を超えたらデッドレターキューに移して監視します。多くのキューは同じジョブが2回届くことがあるので、ジョブは冪等に作ります。

別のページで開く →

DBラボを読み込んでいます…

ひとことで

DB の更新と、送りたいイベントを同じトランザクションで outbox 表に書き、別の処理がそれをキューに送る。書き込みのずれを防ぐ。

同じトランザクションで書けば、片方だけ残ることがない1つのトランザクション(同じ DB)orders 表注文を保存outbox 表送るイベントCOMMIT で両方そろって残る送り出す処理outbox を読むキューワーカーへ送れたら印を付ける。2回送ることがあるので受け手は冪等に
Outbox パターンの流れ

「注文を DB に保存して、確認メールのジョブをキューに入れる」は、2つの別のシステムへの書き込みです。間で落ちると、注文はあるのにメールが出ない、逆にジョブはあるのに注文がない(ロールバックされた)というずれが起きます。

方法やり方弱いところ
コミットの後に入れるトランザクションが成功してからキューに入れる(Rails の after_commit など)コミットとキューの間で落ちると、ジョブが失われる
Outbox パターン同じトランザクションで outbox 表にイベントを書き、別の処理が読んでキューに送る。送れたら印を付ける仕組みが増える。送る側は「少なくとも1回」になるので受け手を冪等に
DB を使うジョブの仕組みジョブ自体を同じ DB の表に入れる(Solid Queue・good_job など)大量のジョブでは DB の負荷になる

どの方法でも、受け取る側は同じジョブが2回来ても大丈夫なように作ります。

SRE の「メッセージングの配信保証」

面接ではこう答える

DB に保存してからキューにジョブを入れる処理は、2つの別のシステムへの書き込みなので、間で落ちるとずれます。Outbox パターンでは、業務のデータと一緒に、送りたいイベントを同じ DB の outbox 表に同じトランザクションで書きます。別の処理がその表を読んでキューに送り、送れたら印を付けます。送る側は少なくとも1回になるので、受け取る側は冪等に作ります。

ひとことで

よく読むデータを Redis などに置いて DB の負荷を下げる。いつ消すか(無効化)が一番難しい。

アプリのキャッシュは、DB や外部 API の結果を Redis やメモリに一時的に置き、同じ問い合わせを速く返す仕組みです。速くなる代わりに、古いデータを返す可能性を受け入れることになります。

Cache-aside と Write-through

方式流れ特徴
Cache-aside読むとき:キャッシュになければ DB から読んでキャッシュに入れる。更新するとき:DB を更新してキャッシュを消す一番よく使う。必要なものだけキャッシュされる。最初の1回は遅い
Write-through更新するとき、DB とキャッシュの両方に書く読むときは常にキャッシュにある。使われないデータもキャッシュされる
Cache-aside:なければ DB から読んで入れるアプリRedisDB① GET user:42ない(ミス)② SELECT … WHERE id = 42行③ SET user:42 … EX 300(期限つき)次からは ① で見つかる(ヒット)更新:UPDATE users …DEL user:42(消して、次に作り直す)
Cache-aside の読み書き
async function getUser(id) {
  const hit = await redis.get(`user:${id}`);
  if (hit) return JSON.parse(hit);
  const user = await db.user.findUnique({ where: { id } });
  await redis.set(`user:${id}`, JSON.stringify(user), { EX: 300 });
  return user;
}
// 更新したら消す(書き換えるより消すほうがずれにくい)
await db.user.update(...); await redis.del(`user:${id}`);

消し忘れに備えて TTL(期限)は必ず付けます。ユーザーごとに違うデータは、キーにユーザー ID を入れて混ざらないようにします。

Redis の使い方

SRE の「キャッシュ戦略の全体設計」

面接ではこう答える

アプリのキャッシュでよく使うのは cache-aside です。読むときはまずキャッシュを見て、なければ DB から読んでキャッシュに入れ、更新するときは DB を更新してからキャッシュを消します。書き換えるより消すほうがずれにくく、消し忘れに備えて TTL も必ず付けます。速くなる代わりに古いデータを返す可能性を受け入れることになるので、どのデータなら許されるかを先に決めます。

別のページで開く →

ネットワーク図を読み込んでいます…

ひとことで

人気のキーの期限が切れた瞬間に、大量のリクエストが同時に DB へ向かう現象。TTL のばらつき・1人だけ作り直す・期限前の更新で防ぐ。

Redis などのキャッシュに DB の結果を置くと、DB への問い合わせは大きく減ります。その結果、DB の台数や性能は「キャッシュが効いている前提」の大きさで決まっていることが多く、キャッシュが一斉に消えると、DB はふだんの何倍ものリクエストを一度に受けることになります。

これがスタンピード(キャッシュの一斉失効)です。アクセスの多いサービスでは実際によく起きる障害で、キャッシュの設計を聞かれたときに一緒に答えられるようにしておきます。

期限が切れた瞬間、全員が DB に向かうリクエスト 1リクエスト 2リクエスト 3リクエスト 4キャッシュ期限切れ(ない)DB同じクエリが殺到全員が読む対策:TTL にばらつき・ロックで1人だけ作り直す・期限前に裏で更新
キャッシュの一斉失効で DB に殺到する

アクセスの多いキーの期限が切れた瞬間、大量のリクエストが同時に「キャッシュにない」と判断して DB に殺到し、DB が倒れることがあります。

  • TTL をばらつかせる:300 秒 ± ランダムにして、同時に切れないようにする。
  • 1人だけ作り直す:ロック(SET key NX PX)を取った1つのリクエストだけが DB を読み、他は少し待つか古い値を返す。
  • 期限前に裏で更新:期限が近づいたら古い値を返しつつ、裏で作り直す(stale-while-revalidate の考え方)。

下の「ブラウザで試す」の図は、前半でキャッシュの基本の使い方(まず Redis を見る → なければ DB から読んで TTL 付きで入れる → 次はヒット → 更新したらキャッシュを消す)を追います。このトピックの話は後半で、人気のキー(hot:ranking)の TTL が切れた瞬間に多くのリクエストが同時に DB へ向かう様子と、SET NX でロックを取れた1つだけが作り直す対策を見られます。最後の「Redis が落ちたら」も、全部のリクエストが DB に行くという点で同じ形の障害です。

面接ではこう答える

キャッシュスタンピードは、アクセスの多いキーの期限が切れた瞬間に、大量のリクエストが同時にキャッシュにないと判断して DB に殺到する現象です。DB が倒れると、キャッシュも作り直せなくなります。対策は、TTL にランダムなばらつきを足して同時に切れないようにすること、ロックを取った1つのリクエストだけが作り直すこと、期限が近づいたら古い値を返しつつ裏で更新することです。

別のページで開く →

ネットワーク図を読み込んでいます…

ひとことで

メモリに置く速いデータストア。キャッシュ・セッション・レート制限・ランキング・キュー・ロックに使う。重いコマンドとメモリの上限に注意。

Redis は、データをメモリに置く「キー → 値」のデータストアです。1回の読み書きが 1ms 未満と速く、値には文字列のほかにハッシュ・リスト・セット・ソート済みセット・ストリームなどの型があります。Web のバックエンドでは、DB の前に置くキャッシュのほか、セッション・レート制限・ランキング・ジョブのキュー・分散ロックによく使います。

用途使う型・コマンド注意すること
キャッシュ(cache-aside)GET → 無ければ DB → SET … EX 秒更新したら DEL で消す。一斉に期限切れにならないよう TTL をばらつかせる
セッションハッシュ(HSET)+ TTL消えるとログアウトになる。追い出し(eviction)の設定に注意
レート制限INCR + EXPIRE … NX複数台のサーバーで共有できる。窓の切れ目で2倍通る問題(スライディングウィンドウ)
ランキングソート済みセット(ZADD・ZREVRANGE)DB で毎回並べ替えずに済む
ジョブのキューリスト(LPUSH・BRPOP)・ストリーム(XADD・XREADGROUP)取り出して処理中に落ちると失われる(ストリームなら ACK で再処理できる)
分散ロックSET key 値 NX PX ミリ秒期限つきで取る。消すときは自分のロックか確かめる

知っておくべき性質:Redis はコマンドを1つずつ順番に処理するので、1つ1つのコマンドは原子的(INCR が数え間違えない)ですが、KEYS * のような重いコマンドは全員を待たせます。データはメモリにあるので、上限(maxmemory)と、溢れたときの方針(maxmemory-policy)を決めておきます。キャッシュは「消えても DB から作り直せる」データだけを置くのが基本です。

運用(永続化・レプリケーション・メモリ・監視)は SRE の「Redis の運用」で。

面接ではこう答える

Redis は、データをメモリに置くキーと値のデータストアで、1回の読み書きがとても速いです。Web のバックエンドでは、キャッシュのほか、セッション、INCR を使ったレート制限、ソート済みセットのランキング、ジョブのキュー、分散ロックによく使います。コマンドを1つずつ順番に処理するので1つ1つは原子的ですが、KEYS のような重いコマンドは全員を待たせます。メモリの上限と、溢れたときの方針も決めておきます。

別のページで開く →

DBラボを読み込んでいます…

ひとことで

サーバーから画面へ届ける方式。手軽なポーリング、一方向の SSE、両方向の WebSocket を使い分ける。

HTTP は、クライアントが聞いてサーバーが答える形なので、そのままではサーバーの側から「新しいメッセージが来ました」と送り出せません。チャットや通知を作るときは、この制約をどう回避するかで方式を選びます。

選んだ方式は、サーバーの負荷・ロードバランサーの設定・再接続の作り方にまで影響します。設計面接でも、なぜその方式にしたかを聞かれます。

向きと手軽さで選ぶポーリング・定期的に API を呼ぶ・一番簡単・遅れ・無駄な通信SSE・サーバー → 画面の一方向・HTTP のまま・自動再接続・通知・AI の逐次表示WebSocket・両方向・再接続・ping を自前で・チャット・共同編集
リアルタイム通信の3つの方式

チャット・通知・共同編集・進み具合の表示など、サーバーから画面へすぐ届けたいときの方式です。

方式仕組み向き向いているもの
ポーリング数秒ごとに API を呼ぶクライアント → サーバー更新が少ない・遅れてよい。一番簡単
ロングポーリング新しいデータが来るまでサーバーが応答を待たせる同上古い環境でも動く
SSE(Server-Sent Events)1本の HTTP 応答で、サーバーから続けて送る。EventSource で受ける。切れたら自動で再接続サーバー → クライアントだけ通知・進み具合・AI の回答の逐次表示
WebSocket接続を張りっぱなしにし、両方向に送る両方向チャット・対戦・共同編集

サーバーから送るだけなら SSE が HTTP のまま使えて簡単です。WebSocket は再接続や生存確認(ping)を自分で作る必要があり、ロードバランサーのタイムアウト設定にも気をつけます。

下の「ブラウザで試す」の図では、5秒ごとに聞くポーリング、GET をつなぎっぱなしにする SSE、HTTP の Upgrade で WebSocket に切り替えて 101 が返り、両方向にメッセージを送る流れ、の順に見られます。最後の手順では、ロードバランサーがしばらく通信のない接続を切るので、サーバーが ping を送って接続を保つことが出てきます。上の「WebSocket は生存確認を自分で作る・ロードバランサーのタイムアウトに気をつける」が、この部分です。

面接ではこう答える

サーバーから画面へすぐ届けたいときの方式には、ポーリング、SSE、WebSocket があります。ポーリングは定期的に API を呼ぶだけで一番簡単ですが、遅れと無駄な通信があります。SSE は1本の HTTP の応答でサーバーから続けて送る方式で、通知や AI の回答の逐次表示に向き、切れたら自動で再接続します。WebSocket は両方向に送れるので、チャットや共同編集に使います。

別のページで開く →

ネットワーク図を読み込んでいます…

WebSocket や SSE のように接続をつなぎっぱなしにする方式は、サーバーが1台のうちは素直に動きます。ところが、ロードバランサーの後ろでサーバーを2台、3台と増やすと、「送りたい相手が、別のサーバーにつながっている」という問題が出てきます。

チャット・通知・共同編集を本番で動かすには避けて通れない点で、設計面接でも「台数を増やしたらどうするか」はよく聞かれます。

別のサーバーにつながる相手にも届けるユーザー Aユーザー Bユーザー Cサーバー 1サーバー 2送信配信配信Redis Pub/Sub全サーバーに配るpublishsubscribeWebSocket・SSE で常時接続
台数を増やしたときの配信

接続はそれぞれ1台のサーバーにつながっています。ユーザー A がサーバー1、B がサーバー2につながっているとき、サーバー1で起きたメッセージは、そのままでは B に届きません。

  • Pub/Sub で全台に配る:Redis の Pub/Sub などに流し、全サーバーが受け取って、自分につながっている相手に送る(Socket.IO の Redis アダプターなど)。
  • マネージドサービス:API Gateway の WebSocket、Pusher・Ably などに接続の管理を任せる。
  • デプロイのときは全接続が切れるので、クライアントは指数バックオフで再接続し、切れていた間の取りこぼしを API で取り直す。

接続を張りっぱなしにする方式は、1台あたりの接続数(ファイルディスクリプタ・メモリ)にも上限があります。負荷試験では同時接続数も測ります。

下の「ブラウザで試す」の図では、A さんがサーバー A、B さんがサーバー B に WebSocket でつながった状態から、A さんのメッセージが Redis の PUBLISH で全台に配られ、サーバー B から B さんに届くまでを追えます。LB が Upgrade を中継して接続を保ったままにすること、1台が持てる接続の上限に続いて、デプロイでサーバー B が Close(1001)で接続を閉じたあと、B さんが指数バックオフで再接続し、最後に受け取った ID より後のメッセージを DB から取り直すところまで、上の対策を手順で確かめられます。

別のページで開く →

ネットワーク図を読み込んでいます…

この章のまとめ

🖨 まとめシート(印刷・PDF)
  • 重い処理をキューとワーカーに逃がす設計
  • キャッシュの戦略と、無効化の難しさ

この章のクイズ

面接ではこう聞かれる

まず答えを見ずに声に出して答え、そのあと開いて比べる。

Q1キャッシュを入れるとき気をつけることは?

模範回答

一番大事なのは無効化の方法を先に決めることです。データを更新したらキャッシュを消す、消し忘れに備えて必ず TTL を付ける、の2つを基本にしています。そのうえで、どのくらい古いデータを出してよいかを画面ごとに決めます。在庫や残高のように古いと困るものはキャッシュしないか、短い TTL にします。ユーザーごとのデータはキーにユーザー ID を入れて、他人に出ないようにします。

もう1つは一斉失効です。人気のキーの期限が切れた瞬間に DB へリクエストが殺到するので、TTL にばらつきを入れる、ロックで1つのリクエストだけが作り直す、期限前に裏で更新する、といった対策をします。またキャッシュが落ちても DB が耐えられるか、ヒット率と DB の負荷を監視しておきます。

深掘りされたら

  • キャッシュを書き換えずに消すのはなぜですか?

見られているポイント無効化・一貫性・スタンピード

Q2Redis をキャッシュ以外に何に使いますか? 注意点は?

模範回答

セッション、レート制限(INCR と期限)、ランキング(ソート済みセット)、ジョブのキュー(リスト・ストリーム)、分散ロック(SET NX PX)に使います。注意点は、メモリが上限に達したときの方針(キャッシュなら追い出し、セッションなど消えて困るものは別に分けて監視)、KEYS のような重いコマンドを使わないこと(SCAN を使う)、そしてキャッシュは消えても DB から作り直せる前提にすることです。

深掘りされたら

  • キャッシュと DB の整合性はどう保ちますか?
  • Redis が落ちたらどうなりますか?

見られているポイント用途ごとの型の選び方と、シングルスレッド・メモリ上限の理解

Q3メール送信をバックグラウンドジョブにするとき、何に気をつけますか?

模範回答

3つ気をつけます。1つ目は、ジョブを入れるタイミングです。DB のトランザクションの途中で入れると、ロールバックしたのにメールだけ送られることがあるので、コミットの後に入れるか、同じ DB にイベントを書いて別の処理で送る Outbox パターンを使います。

2つ目は、二重に動いても大丈夫にすることです。多くのキューは同じジョブを2回届けることがあるので、送信済みの記録を見てから送ります。3つ目は失敗の扱いで、外部のメールサービスが一時的に落ちていたら間を空けてリトライし、上限を超えたらデッドレターに移して監視します。ジョブには ID だけを入れ、処理のときに最新のデータを読みます。

深掘りされたら

  • Outbox パターンとは何ですか?

見られているポイントトランザクションとの順番・冪等・リトライ