はじめに
PRレビューで「これ、レースコンディションになりますね」と言われた。
正直、初めて聞いた言葉だった。調べてみると、自分のコードのあちこちに潜んでいた。テストは全部通っていたのに。
この記事では、レースコンディションを知らなかった自分が、レビューで指摘されて理解するまでに学んだことをまとめる。
この記事でわかること:
- レースコンディションとは何か(一言で)
- なぜ開発中に見つからないのか
- 危ない3つのパターンと、それぞれの防ぎ方
- Go + GORM + PostgreSQL での実例
想定読者:
- レースコンディションという言葉を初めて聞いた人
- Web APIを書いていて「同時アクセス」をあまり意識したことがない人
- 言語は問わない(実例セクションのみ Go)
レースコンディションとは
2つの処理が同時に同じデータを触ると、結果が壊れること。
日常の例え
銀行口座に10万円入っている。あなたとパートナーが同時にATMで3万円引き出す。
あなた パートナー
──────────────────────────────────────────
10:00:00 残高を確認 → 10万円
10:00:01 残高を確認 → 10万円
10:00:02 10万 - 3万 = 7万で更新
10:00:03 10万 - 3万 = 7万で更新
──────────────────────────────────────────
結果: 残高 7万円(本来は4万円のはず!)
6万円引き出したのに、3万円しか減っていない。
ポイント: 1人ずつやれば正しい結果になる。「同時」が問題を起こす。
なぜ開発中に見つからないのか
- 開発環境 = 自分1人がポチポチ触る世界
- 本番 = 100人が同時にリクエストを送る世界
100回中99回は「たまたま」タイミングがずれて正しく動く。1回だけ壊れる。
だからテストを書いても見つからない。レビュアーが「これ同時に来たらどうなる?」と頭の中でシミュレーションして初めて気づく。
3つの「危ないパターン」
ここからは、自分が実際にレビューで指摘を受けたリアルタイム通話(ハドル)機能を例に説明する。仕組みはシンプルで、登場人物は4つだけ:
- セッション: 通話部屋。目標ごとに1つ作られる
- 参加者: 誰が通話中かを表すレコード。入室・退出を記録する
- Heartbeat: 「まだ通話中だよ」と定期的に送る生存確認
- クリーンアップ: 一定時間Heartbeatがない参加者を自動退出させるバッチ処理
この4つの処理が同時に走ることで、問題が起きた。
パターン① 上書き: 読んで → 変えて → 全部戻す
HeartbeatとLeave(退出)が同時に走るケース。
Heartbeat Leave
──────────────────────────────────────────────────────
10:05:00 DBから参加者を取得
→ { heartbeat: 10:00:00,
left_at: NULL }
10:05:01 DBから参加者を取得
→ { heartbeat: 10:00:00,
left_at: NULL }
10:05:02 left_at = 10:05:02 にセット
Save() → DB反映 ✅
10:05:03 heartbeat = 10:05:03 に更新
Save() → DB反映
→ { heartbeat: 10:05:03,
left_at: NULL } ← 退出が消えた!
──────────────────────────────────────────────────────
Heartbeat は heartbeat だけ変えたかった。でも Save() は全カラムを上書きするから、10:05:00 時点で読んだ古い left_at: NULL まで書き戻してしまう。
退出したはずなのに、退出していないことになる。
このパターンの本質: 自分が変えたいカラム以外も巻き添えで上書きする
パターン② すれ違い: 確認して → 判断して → 実行する
クリーンアップとJoin(入室)が同時に走るケース。
クリーンアップ join(suzukiが入室)
──────────────────────────────────────────────────────
10:30:00 tanakaを放置扱いで退出させる
10:30:01 残り参加者を数える → 0人
10:30:02 suzukiを参加者として追加 ✅
10:30:03 0人だったのでセッション終了!
10:30:04 suzuki「入れたのに
セッション終了してる…?」
──────────────────────────────────────────────────────
クリーンアップは10:30:01の時点で正しい判断をしている。「0人だから終了」は論理的に正しい。
でも実行する10:30:03の時点では、もう1人いる。
このパターンの本質: 確認した事実が、実行する時にはもう変わっている
パターン③ 重複: 「ないから作る」を2人同時にやる
2人が同時にJoinし、どちらも「セッションがない」と判断するケース。
tanaka suzuki
──────────────────────────────────────────────────────
10:00:00 セッションある? → ない
10:00:01 セッションある? → ない
10:00:02 ないので作る → room-A ✅
10:00:03 ないので作る → room-B ✅
──────────────────────────────────────────────────────
結果: 同じ通話なのにセッションが2つできた
パターン②と共通するのは「確認」と「実行」の間に隙間があること。ただし結果が逆方向になる:
- ②すれ違い → まだあるのに消してしまう
- ③重複 → 1つでいいのに2つできてしまう
対策もそれぞれ違うので、分けて考える価値がある。
どう防ぐ? 3つのアプローチ
防ぎ方① 必要な部分だけ変える(パターン①に効く)
全部上書きしなければいい。
-- ❌ 全部上書き → 他の変更を巻き込む
UPDATE participants
SET heartbeat = '10:05:03', left_at = NULL, name = 'tanaka', ...
WHERE id = 'tanaka';
-- ✅ 変えたいカラムだけ
UPDATE participants
SET heartbeat = '10:05:03'
WHERE id = 'tanaka' AND left_at IS NULL;
AND left_at IS NULL がポイント。もう退出済みなら更新が空振りする。「自分が触っていいのは、まだ退出してないレコードだけ」というルールをSQLレベルで強制できる。
防ぎ方② 順番待ちさせる(パターン②に効く)
隙間が問題なら、隙間に割り込ませない。
レストランの例:
❌ 隙間あり:
あなた「空いてる?」→ 店員「1席あります」
友達 「空いてる?」→ 店員「1席あります」
→ 2人とも予約できてしまう
✅ 順番待ち(ロック):
あなた「空いてる?」→ 店員「確保しますね」→ 予約完了
友達 「空いてる?」→ 店員「埋まりました」
「確認」から「実行」まで他の人を待たせる。これがロック。
PostgreSQLでは Advisory Lock で「この部屋に関する操作は1人ずつ」を強制できる:
クリーンアップ join(suzuki)
──────────────────────────────────────────────────────
10:30:00 🔒 room-Aのロック取得
10:30:01 参加者を数える → 0人
10:30:02 🔒 ロック取得したい → 待ち ⏳
10:30:03 0人なのでセッション終了
🔓 ロック解放
10:30:04 🔒 取得できた!
セッション確認 → 終了済み
→ 新しいセッションを作る ✅
──────────────────────────────────────────────────────
防ぎ方③ 物理的に弾く(パターン③に効く)
DBのUNIQUE制約で「2つ作る」を物理的に不可能にする。
-- 「アクティブなセッションは1つの目標につき1つだけ」
CREATE UNIQUE INDEX idx_active_session
ON sessions (objective_id)
WHERE status = 'active';
2人目が作ろうとした瞬間、DBが弾く:
tanaka suzuki
──────────────────────────────────────────────────────
10:00:02 INSERT → ✅ 成功
10:00:03 INSERT → ❌ UNIQUE違反!
→ 既存のセッションを取得して参加
──────────────────────────────────────────────────────
アプリのコードではなく、DBが「2つ存在させない」ことを保証する。
なぜパターン②と③で対策が違うのか
どちらも「確認と実行の間に隙間がある」のが原因だが、性質が違う:
| パターン② すれ違い | パターン③ 重複 | |
|---|---|---|
| 結果 | あるのに消す | ないのに2つ作る |
| 起きる場面 | 複数の処理が1つのリソースを操作 | 複数のリクエストが同じリソースを作成 |
| 最適な対策 | ロック(処理を直列化) | DB制約(物理的に弾く) |
ロックは「操作の順番」を保証する。DB制約は「結果の一意性」を保証する。守りたいものが違うから、対策も違う。
対応表
| パターン | 問題 | 防ぎ方 |
|---|---|---|
| ① 上書き | 変更が消える | 必要なカラムだけ更新する |
| ② すれ違い | 判断が嘘になる | ロックで順番待ちさせる |
| ③ 重複 | 2つできてしまう | DB制約で物理的に弾く |
自分のコードで起きてないかチェックする視点
以下に当てはまるコードがあったら、レースコンディションを疑ってみてほしい。
-
ORMの
Save()やupdate()でレコード全体を上書きしていないか? - 読み取り → 判定 → 書き込みの間に、別の処理が割り込む余地はないか?
- 「なければ作る」パターンで、同時実行時に2つできないか?
- 「これ、同時に2人がやったらどうなる?」とシナリオを想像してみる
実例: Go + GORM + PostgreSQL
ここからは実際のコードで見る。Go に馴染みがなくても、コメントと SQL を追えば雰囲気はわかるはず。
パターン①の修正: Save() → Targeted UPDATE
Before(危険):
// heartbeat.go — Save()は全カラム更新
func (uc *heartbeatUseCase) Execute(ctx context.Context, req HeartbeatReq) error {
participant, err := uc.participantRepo.FindActiveByMemberID(ctx, memberID)
if err != nil {
return err
}
participant.UpdateHeartbeat() // heartbeat だけ変えたつもり
return uc.participantRepo.Save(ctx, participant) // ← 全カラム上書き!
}
Save() が発行する SQL:
UPDATE huddle_participants
SET last_heartbeat_at = '10:05:03',
left_at = NULL, -- ← 触るつもりないのに含まれる
... -- ← 全カラム
WHERE id = 'xxx';
After(安全):
// heartbeat.go — 変えたいカラムだけ更新
func (uc *heartbeatUseCase) Execute(ctx context.Context, req HeartbeatReq) error {
participant, err := uc.participantRepo.FindActiveByMemberID(ctx, memberID)
if err != nil {
return err
}
if participant == nil {
return nil // 参加していない → 何もしない
}
// ピンポイントUPDATE(last_heartbeat_at のみ、left_at IS NULL ガード付き)
return uc.participantRepo.UpdateHeartbeat(ctx, participant.ID())
}
リポジトリの実装:
func (r *participantRepository) UpdateHeartbeat(ctx context.Context, id string) error {
result := db.Model(&HuddleParticipant{}).
Where("id = ? AND left_at IS NULL", id).
Updates(map[string]interface{}{
"last_heartbeat_at": time.Now(),
"updated_at": time.Now(),
})
return result.Error
}
発行される SQL:
UPDATE huddle_participants
SET last_heartbeat_at = NOW(), updated_at = NOW()
WHERE id = 'xxx' AND left_at IS NULL;
-- left_at を触らない。退出済みなら空振りする。
パターン②の修正: Advisory Lock で直列化
Before(危険):
// cleanup.go — ロックなし
func (uc *cleanupUseCase) cleanupStaleParticipants(ctx context.Context) error {
// stale な参加者を退出させる
for _, p := range staleParticipants {
p.Leave()
uc.participantRepo.Save(ctx, p)
}
// 残り参加者を数える
count, _ := uc.participantRepo.CountActiveBySessionID(ctx, sessionID)
// ↑ ここで 0 を確認
// ← この隙間で join が割り込む可能性 →
if count == 0 {
session.End() // ← 実はもう1人いるのに終了してしまう
uc.sessionRepo.Save(ctx, session)
}
return nil
}
After(安全):
// cleanup.go — Advisory Lock で排他制御
func (uc *cleanupUseCase) cleanupStaleSession(
ctx context.Context,
session *huddle.Session,
staleParticipants []*huddle.Participant,
) (bool, error) {
var ended bool
// 🔒 同一セッション(目標単位)のロックを取得
// join / leave / cleanup すべてこのキーで直列化される
err := uc.txManager.WithAdvisoryLock(
ctx,
"huddle:session:"+session.ObjectiveID(),
func(txCtx context.Context) error {
// ロック内なので、他の処理は割り込めない
for _, p := range staleParticipants {
if err := p.Leave(); err != nil {
continue
}
uc.participantRepo.Save(txCtx, p)
}
count, _ := uc.participantRepo.CountActiveBySessionID(txCtx, session.ID())
if count > 0 {
return nil // まだ参加者がいる
}
session.End()
uc.sessionRepo.Save(txCtx, session)
ended = true
return nil
},
)
return ended, err
}
join 側も同じロックキーを使う:
// join.go — 同じキーでロック
err := uc.txManager.WithAdvisoryLock(
ctx,
"huddle:session:"+objectiveID, // ← 同じキー
func(txCtx context.Context) error {
// セッション取得 or 作成 + 参加者追加
// cleanup と同時に実行されることはない
},
)
パターン③の修正: UNIQUE制約 + フォールバック
マイグレーション:
-- アクティブなセッションは目標ごとに1つだけ
CREATE UNIQUE INDEX idx_huddle_sessions_active_objective
ON huddle_sessions (objective_id)
WHERE ended_at IS NULL;
アプリ側(UNIQUE違反時のフォールバック):
// join.go — UNIQUE違反を検知して既存セッションに合流
if saveErr := uc.sessionRepo.Save(txCtx, newSession); saveErr != nil {
if domainErrors.IsDuplicate(saveErr) {
// 別のリクエストが先にセッションを作った
// → 既存セッションを取得して、そちらに参加する
existing, _ := uc.sessionRepo.FindActiveByObjectiveID(txCtx, objectiveID)
if existing != nil {
session = existing
}
}
}
DBが弾く → アプリがリカバリする。この2段構えで、重複は絶対に起きない。
まとめ
- レースコンディション = 2つの処理が同時に同じデータを触って結果が壊れること
- 開発中に見つからない。本番の同時アクセスで初めて起きる
- 危ないパターンは3つ: 上書き、すれ違い、重複
- 防ぎ方も3つ: 必要な部分だけ更新、ロックで直列化、DB制約で物理的に弾く
- 「これ、同時に2人がやったらどうなる?」と考える習慣が一番大事