read-merge-writeはFOR UPDATE無しでキーが消える
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
display_settingsをread-merge-writeするServer Actionは、SELECTをFOR UPDATEで行ロックしないと、並行保存で相乗りキーの更新がlast-writer-winsでロストする。
結論
「既存の行を読む → JS側でスプレッドしてキーを1つ差し替える → 丸ごとUPSERTで書き戻す」というread-merge-writeは、読み取りに行ロックが無いと並行保存でロストアップデートを起こす。 同じ行に複数のキーが相乗りしていて、それぞれ別のタイミングで保存されうる設計なら特に起きやすい。直し方は、既存値の読み取りを同一トランザクション内のSELECT ... FOR UPDATEに変え、書き込みが終わるまで行をロックすること。
症状になりうる状態
対象は学校スコープの表示設定(school_configsテーブル、scope='school', kind='display_settings')を保存するServer Action、saveAssignmentDeadlineFormatAction。この1行のvalueカラムには、提出物の期日表示形式(assignmentDeadlineFormat)だけでなく、学校既定デザイン(signageDesign)やエディタ既定対象日の切替時刻(editorDayCutover)といった、複数のキーが相乗りしている。UPSERTはvalue列を全置換するため、保存Actionは自分が変えたいキー以外を消さないよう、書き込み前に既存のvalueを読んでスプレッドし、対象キーだけを差し替えてから書き戻す。
修正前(2026-07-11導入時点)のコードは、この既存値の読み取りにロックの無い単純なSELECTを使っていた。
const prev = await getSchoolConfigValue(tx, "display_settings");
const operation: "insert" | "update" = prev === null ? "insert" : "update";
const before = parseAssignmentDeadlineFormat(prev);
const base =
prev && typeof prev === "object" && !Array.isArray(prev)
? (prev as Record<string, unknown>)
: {};
const id = await upsertSchoolConfig(tx, {
schoolId: actor.schoolId,
kind: "display_settings",
value: { ...base, assignmentDeadlineFormat: rawFormat },
actorUserId,
});
assignmentDeadlineFormatを保存するActionと、signageDesignやeditorDayCutoverを保存する別のActionが、ほぼ同時に同じ学校の同じ行へ向けて呼ばれたとする。どちらも独立したServer Actionなので、それぞれが自分のトランザクションで「読む→マージ→書く」を実行する。片方のトランザクションがまだコミットしていない間にもう片方が既存値を読むと、そのマージ基底には先に確定するはずのキー変更が反映されない。結果として、後からUPSERTが確定した側が、先に確定していたキーの変更を自分のvalueで丸ごと上書きし、消してしまう。
原因
これは典型的なロストアップデート問題で、PostgreSQL側の制約違反にもならなければ、アプリケーションのエラーとしても表面化しない。2つの保存はそれぞれ単体で見れば正しく完了しており、どちらの呼び出し元にも成功レスポンスが返る。壊れているのは個々のUPSERTではなく、「読み取りから書き込みまでの間、他のトランザクションに割り込まれない」という前提が保証されていないことのほうだ。デフォルトのREAD COMMITTED分離レベルでは、ロックの無いSELECTはこの前提を作らない。
直し方
2026-07-12の修正(#1264 / PR #1267)で、既存値の読み取りを同一トランザクション内のSELECT ... FOR UPDATEに変えた。
async function lockDisplaySettingsValue(tx: TenantTx): Promise<unknown | null> {
const [row] = await tx
.select({ value: schoolConfigs.value })
.from(schoolConfigs)
.where(and(eq(schoolConfigs.scope, "school"), eq(schoolConfigs.kind, "display_settings")))
.limit(1)
.for("update");
return row ? row.value : null;
}
呼び出し側はgetSchoolConfigValueの代わりにこのlockDisplaySettingsValue(tx)を呼ぶだけで、read-merge-write本体のロジックは変えていない。
const prev = await lockDisplaySettingsValue(tx);
const operation: "insert" | "update" = prev === null ? "insert" : "update";
const before = parseAssignmentDeadlineFormat(prev);
const base =
prev && typeof prev === "object" && !Array.isArray(prev)
? (prev as Record<string, unknown>)
: {};
const id = await upsertSchoolConfig(tx, {
schoolId: actor.schoolId,
kind: "display_settings",
value: { ...base, assignmentDeadlineFormat: rawFormat },
actorUserId,
});
SELECT ... FOR UPDATEは対象行にロックを掛け、トランザクションが終わるまでそのロックを保持する。同じ行を読もうとする後続のトランザクションは、ロックが解放されるまでSELECT自体がブロックされ、解放後は直前にコミットされた最新のvalueを読む。これにより、2つの並行するread-merge-writeが実質的に直列化され、片方のキー変更がもう片方に飲み込まれることがなくなる。行が存在しない場合(初回保存)はロック対象が無いためnullが返り、同時INSERTのほうは一意制約(ux_school_configs_target)とonConflictDoUpdateが別途保証する。
再発防止
同じコミットにテストが追加されているが、内容はselect().from().where().limit().for("update")というチェーンをfake txで模した上で、保存Actionがfor("update")というモード文字列を呼んだことをアサートする単体テストで、2つのトランザクションを実際に並行実行してロストアップデートを再現し、修正後に再現しなくなることを確かめる結合テストではない。ロックが「掛かっているつもり」でクエリビルダの書き方を間違えていた場合、この形のテストでは検出できない可能性がある。
同じ行に複数のキーが相乗りする設計そのものは変えていない。今後、この行に書き込む3つ目以降のActionが追加されるときは、それもlockDisplaySettingsValue経由で読むという規律が保たれて初めて、この修正の効果が続く。
よくある質問
Q1本番で実際にキー更新が消えた事故は報告されていますか?
根拠のコミットメッセージに本番障害の報告は無く、read-merge-writeのレースを塞ぐ予防的な修正として直されている。実際にキーが消えたという事故報告は根拠に見当たらない。
Q2なぜDBのエラーにもアプリのエラーにもならないのですか?
2つの保存はどちらも単体のSELECTとUPDATEとして正常に完了し、制約違反も起きない。行ロックが無いと、後から確定した書き込みが先の書き込みの結果を知らないまま自分のvalueで行ごと上書きするだけなので、両方に成功レスポンスが返る。
Q3FOR UPDATEを足すとなぜ直るのですか?
SELECT ... FOR UPDATEは対象行にロックを掛け、tx終了までロックを保持する。並行して同じ行を読もうとした後続txはロック解放まで待たされ、解放後にコミット済みの最新valueを読むため、2回のread-merge-writeがREAD COMMITTEDのもとで直列化される。
Q4このロックはテストで確認されていますか?
テストはselectチェーンをfake txで模し、保存Actionがfor("update")を呼んだことをアサートする単体テストで、2つのトランザクションを実際に並行実行してロストアップデートの再現・修正を確かめる結合テストではない。
確認した環境
- Next.js ^16.0.0 / drizzle-orm ^0.45.2
- 2026-07-11導入(#1258)→ 2026-07-12にFOR UPDATEの行ロックで修正(#1264 / PR #1267)
この記事の根拠
- TypeScriptファイル 100〜120行目コミット a68fada
- TypeScriptファイル 62〜70行目コミット eb3780b
- TypeScriptファイル 123〜145行目コミット eb3780b
- TypeScriptファイル 42〜106行目コミット eb3780b
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。