1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

レースコンディションって何? — 1人だと動くのに2人だと壊れるバグの話

1
Last updated at Posted at 2026-04-01

はじめに

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人がやったらどうなる?」と考える習慣が一番大事

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?