withTenantContextのトランザクション内で別接続を取るレート制限が、プール最大接続数で詰まった
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
応答完了までトランザクションが1本の接続を握り続ける構成で、その中から別の処理が同じ接続プールへ2本目を取りに行くと、同時リクエスト数がプール上限に達した瞬間に全リクエストが互いの接続解放を待つ自己デッドロックになる。
結論
応答完了まで1本のDB接続を握り続けるトランザクションの内側から、別の処理が同じ接続プールへ2本目の接続を取りに行く構成は、同時リクエスト数がプールの最大接続数に達した瞬間に全リクエストが互いの接続解放を待つ自己デッドロックになる。 SSEで回答を返すチャットハンドラが、RLSコンテキストを張ったトランザクションをReadableStreamのstart()内で開き、ストリームが終わるまで接続を保持する作りだったところに、学校単位の日次生成回数の上限判定をそのトランザクションの内側から呼んでいた。判定処理自体はgetDb()経由で同じ接続プール(最大10)から接続を取ろうとするため、ちょうど同時10件のリクエストが来た瞬間、全員が外側の接続を握ったまま内側の接続待ちになり、誰も解放されずに詰まった。直し方は、トランザクションを開く前に上限判定を済ませる順序に変えることだった。
症状
生徒・教員向けのチャットQ&AはSSE(Server-Sent Events)で1問1答を返す。回答の生成からDBへの永続化までを1つのRLSトランザクションに閉じるため、トランザクションはストリームが終わるまで開いたままになる。
// sse-handler.ts(修正前の構成の要旨。18a5f7b7時点)
async start(controller) {
const send = (event: string, data: unknown) =>
controller.enqueue(encoder.encode(sseFrame(event, data)));
try {
await withTenantContext(getDb(), args.tenantContext, async (tx) => {
const result = await executeChat({
tx,
schoolId: args.schoolId,
identity: args.identity,
rawQuestion: question,
piiEntries: [],
contextProvider,
modelClient,
locale,
// 学校単位の日次上限(全インスタンス共有、従量の上限を構造で固定する)。
dailyQuota: getChatDailyQuota(),
});
// ...
withTenantContextが呼ばれた時点で、このリクエストは接続プール(postgresドライバ、max: 10)から1本の接続を確保し、txとして保持する。executeChatはそのtxを受け取って処理を進めるが、渡したdailyQuota(学校単位・1日あたりの生成回数上限)の判定はtxを使わず、内部で独自にgetDb()を呼んで別の接続を取りに行く実装になっていた。
// chat-service.ts(修正前。18a5f7b7時点、executeChat内)
// 4.5) 学校単位の日次上限(全インスタンス共有、daily-quota.ts)。ここから先は embedding / Gemini の
// 従量が発生するので、その直前で数える(決定論の範囲外拒否は数えない)。判定できないときは
// Vertex を呼ばずに止める(fail-closed)。
if (dailyQuota) {
let allowed: boolean;
try {
allowed = await dailyQuota.tryAcquire(schoolId, nowMs);
} catch {
return {
kind: "rejected",
status: 503,
reason: "quota_unavailable",
message: "ただいま応答できません。しばらくしてから再度お試しください。",
};
}
if (!allowed) {
return {
kind: "rejected",
status: 429,
reason: "rate_limited_school_daily",
message: "本日の質問の受付上限に達しました。明日またお試しください。",
};
}
}
dailyQuota.tryAcquireはCloudSqlRateLimiter経由で、getChatDailyQuota()が内部で保持するgetDb()の接続(daily-quota.ts)を使う。これはsse-handler.tsが握っているtxとは別の接続で、同じプールから新たに確保される。
// daily-quota.ts(18a5f7b7時点)
let memo: RateLimiter | null = null;
export function getChatDailyQuota(): RateLimiter {
if (memo) return memo;
memo = createChatDailyQuota(new CloudSqlRateLimiter(getDb()));
return memo;
}
プールの最大接続数は10(packages/db/src/client.ts)、かつ待ちタイムアウトの設定は無い。
// packages/db/src/client.ts
const sqlClient = postgres(url, { max: 10, onnotice: () => {} });
9件以下の同時リクエストでは、各リクエストが外側の接続(tx)を1本、内側の判定用に1本、合計2本を使っても10本の枠に収まるため何も起きない。だが同時10件のリクエストが来た瞬間、全員が先に外側のtxで1本ずつ確保してしまい、プールの枠を使い切る。その後に各リクエストが内側の判定用に2本目を要求しても、空いている接続は無い。誰も外側のtxを解放できない(解放は判定の後に続く処理の完了を待っている)ため、全リクエストが互いの接続解放を待つ形で止まる。待ちタイムアウトが無いため、この状態は自然には解消しない。
原因
このバグの構造は「負荷が高いと起きる」のではなく「ちょうどプールの上限と同じ同時実行数で起きる」という点が特徴的で、負荷テストでは見落としやすい。9件までは正常に動き、10件になった瞬間だけ全件が詰まるため、段階的に負荷を上げるテストでは「まだ余裕がある」という結果しか得られず、上限と一致する回数を厳密に試さない限り再現しない。
根本原因は、1リクエストの処理中に同じ接続プールへ2回、別々の目的で接続を要求する構造になっていたことだった。1本目(RLSトランザクション)は応答完了まで保持される長命の接続で、2本目(レート制限判定)は1本目の内側から要求される短命の接続だが、プールはどちらも区別しない。プールの視点では「1本目を保持したまま2本目を要求するリクエストがN件並ぶ」状態であり、Nがプール上限に達すれば、1本目を解放できる条件(2本目の取得と後続処理の完了)が永久に満たせなくなる。
直し方
修正は、学校単位の日次上限判定をトランザクションを開く前に行う順序へ変えることだった(4f34f573)。
// sse-handler.ts(修正後。4f34f573時点)
// 2.5) 学校単位の日次上限(daily-quota.ts、全インスタンス共有)。**RLS tx を開く前に**数える
// (tx は回答の最後まで接続を握るので、その中で別接続を取るとプールが詰まる・Reviewer #1401)。
// 拒否は他の拒否と同じく 200 SSE の `error` フレーム 1 枚で返す(クライアントの扱いを揃える)。
const quotaRejection = await checkChatDailyQuota(args.schoolId, question);
if (quotaRejection) {
return errorOnlySse(quotaRejection, args.setCookieHeader ?? null);
}
// 3) SSE ストリーム。RLS tx は start() 内で開き、チャンク送出 + done(assistant 永続化) まで保持する。
checkChatDailyQuotaをwithTenantContextより前に呼ぶことで、判定用の接続要求とtx用の接続要求が時間的に分離される。判定で拒否されたリクエストはtxを一度も開かずに終わるため、プールへの要求は常に「このリクエストにつき同時に1本まで」になり、2本目を待つ状態そのものが構造的に作れなくなる。executeChat側からはdailyQuota引数とその判定ロジックを削除し(chat-service.tsから32行削除)、判定はsse-handler.tsに一本化した。
拒否の返し方も揃えた。txを開く前の拒否はHTTPレベルで返すのではなく、既存の他の拒否(不正なスコープなど)と同じく200のSSEでerrorフレームを1枚返すerrorOnlySseを新設し、クライアント側のハンドリングを分岐させずに済むようにしている。
まとめ
1リクエストの処理の中で接続プールへ複数回アクセスする実装は、各アクセスを個別に見ると正しくても、外側のアクセスが接続を保持したまま内側のアクセスを呼ぶ構造になっていないかを確認する必要がある。保持している接続と新規に要求する接続が同じプールから取られる限り、同時実行数がプールの上限に達した瞬間に自己デッドロックが起きる。対策は接続数を増やすことではなく、1リクエストが同時に要求する接続本数を常に1本に保つ順序へ処理を並べ直すことだった。
よくある質問
Q1なぜ通常の負荷テストでは見つからないのですか?
プールの最大接続数より少ない同時リクエスト数では、どのリクエストも空いている接続をすぐに取得できるため再現しない。同時リクエスト数がちょうどプールの上限に達した瞬間だけ、全リクエストが外側の接続を握ったまま内側の接続待ちになり、互いに解放を待ち合う状態になる。
Q2レート制限の判定自体に問題があったのですか?
判定ロジックそのものは正しい。問題は判定を行う『場所』で、すでに1本の接続を握っているトランザクションの内側から、同じ接続プールへ別の接続を取りに行く処理を呼んでいたことが原因。
Q3直し方はレート制限を速くすることですか?
いいえ。速度は無関係で、トランザクションを開く前にレート制限を判定する順序に変えることが直し方になる。判定してから拒否ならトランザクションを開かずに済み、許可ならその後でトランザクションを開くので、1リクエストが同時に2本の接続を要求する状態がそもそも発生しない。
Q4プールの最大接続数を増やせば回避できますか?
一時的に閾値が上がるだけで、同時リクエスト数が新しい上限と一致した瞬間に同じ現象が再発する。原因は『1リクエストが同時に2本の接続を要求する構造』そのものなので、接続数を増やしても構造は直らない。
確認した環境
- postgres (porsager/postgres) ^3.4.5 / Next.js ^16.3.8(apps/web)
- 接続プール max: 10(packages/db/src/client.ts)
- 2026-10-01 セキュリティ監査レビュー(PR #1401 Reviewer指摘)で発見・同日中に修正
この記事の根拠
- TypeScriptファイル 170〜195行目コミット 18a5f7b
- TypeScriptファイル 349〜372行目コミット 18a5f7b
- TypeScriptファイル 1〜46行目コミット 18a5f7b
- TypeScriptファイル 70〜78行目コミット 18a5f7b
- TypeScriptファイル 173〜210行目コミット 4f34f57
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。