結論

  • Firestore のトランザクションが競合として扱うのは、その中で tx.Get した文書だけ。 トランザクションの外で読んだ値、読まずに書いた文書、クエリで「無かった」ことは守らない
  • 悲観 (Standard の既定) と楽観 (Enterprise の既定) で違うのは決着の時点だけで、守る範囲は同じ。 判定に使う値をコールバックの中で読み直せば、同じコードがどちらでも通る
  • 「1 件だけ」を守りたい値はドキュメント ID にして名指しで読む。件数を数える判定は、エミュレータでも条件次第で 2 件できた
  • SDK はコールバックの中での生のクライアントの書き込みも、外側の ctx で張った RunTransaction も止めない。どちらもトランザクションの外で確定する
  • 複数の文書を「全部か、無しか」で書くなら RunTransaction。BulkWriter はアトミックでない

以下は cloud.google.com/go/firestore v1.26.0 / Firestore エミュレータ v1.22.0 / Go 1.26.4 で実測した。 エミュレータは --database-edition=standard と --database-edition=enterprise の 2 つを立てた。 本番の Firestore では試していない。

前提: 競合として扱うのは、トランザクション内で読んだ文書だけ

トランザクションが監視するのは tx.Get で読んだ文書だけで、外で読んだ値、書くだけの文書、クエリに出なかった文書は監視しない

公式ドキュメントは、並行制御の 2 つの方式をどちらも「読んだ文書」で説明している ( Data contention in transactions )。

  • 悲観: “transactions place locks on the documents they read”
  • 楽観: 読んだ文書を追跡し、“only if none of those documents changed” のときだけ書き込みを確定する

読んでいない文書はロックも照合もされない。 書くだけの文書、トランザクションの外で読んだ値、クエリの結果に入らなかった文書は、守る対象に入らない。 以下の節は、どれもこの 1 点から出てくる。

悲観と楽観で違うのは、決着の時点だけ

悲観は読んだ時点で外の書き込みを待たせ、楽観はコミットの時点で負けた方を ABORTED にして再実行させる

悲観 (PESSIMISTIC)楽観 (OPTIMISTIC)
既定の版StandardEnterprise
決着の時点読んだ時点でロックを取るコミットの時点で照合する
外からの書き込みロックが外れるまで待つ待たずに通る
負けたトランザクションABORTED で再実行されるABORTED で再実行される

方式は版で固定ではなく、データベース単位の設定で変えられる。サーバ用のクライアントライブラリはこの設定に従う。

gcloud firestore databases update \
  --project=PROJECT_ID \
  --database=DATABASE_ID \
  --concurrency-mode=OPTIMISTIC

エミュレータ v1.22.0 は、--database-edition=enterprise でも読んだ文書をロックした。 トランザクションが読んだ文書へ外から Set すると、0.5 秒の保持では 0.5 秒待って通り、5 秒の保持では 2 秒で次のエラーになった。

Aborted: rpc error: code = Aborted desc = Transaction lock timeout.

楽観の「外の書き込みが待たずに通る」挙動は、エミュレータでは確かめられない。

  • PITFALL: Go SDK の Transaction.Get の doc コメントは “The transaction holds a pessimistic lock on the returned document” のままで、楽観のデータベースには当てはまらない

判定をトランザクションの外で読むと、2 人とも勝つ

外で読んだ判定は 2 人とも空と見て両方書き込み、中で読んだ判定は負けた方が再実行で埋まっているのを見る

「空いていれば担当にする」処理を、alice と bob が同時に呼ぶ。2 人が読み終えるまで待ち合わせてから書かせた。

// NG: 判定に使う値をトランザクションの外で読む
snap, err := ref.Get(ctx)
if err != nil {
	return err
}
if snap.Data()["assignee"] != "" {
	return errAlreadyTaken
}
return client.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
	return tx.Update(ref, []firestore.Update{{Path: "assignee", Value: me}})
})
// alice: nil, bob: nil。文書は後に書いた方になる

トランザクションの中で何も読んでいないので、照合する文書が無く、コミットは必ず通る。 読むのも判定もコールバックの中へ入れると、再実行で読み直した遅い方が、埋まった担当を見て止まる。

// OK: 読むのも判定もコールバックの中
return client.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
	snap, err := tx.Get(ref)
	if err != nil {
		return err
	}
	if snap.Data()["assignee"] != "" {
		return errAlreadyTaken
	}
	return tx.Update(ref, []firestore.Update{{Path: "assignee", Value: me}})
})
// 片方: nil (試行 2 回)、もう片方: already taken (試行 2 回)

結果は standard と enterprise のエミュレータで同じだった。

  • PITFALL: 直列に呼ぶテストは NG 版でも全部通る。並行に読み終えてから書く形で試さないと差が出ない

読まずに書いた文書は、割り込まれた値を上書きする

tx.Get しない文書は、コミットまでに外から書き換えられても検知されず、トランザクションの値で丸ごと置き換わる

公式ドキュメントの制約は「読みは書きより前」だけで、書く文書を読む義務は無い。読まずに書くと、その文書は守られない。

_, _ = ref.Set(ctx, map[string]any{"v": "seed"})
err := client.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
	// コミット前の割り込みを、生のクライアントの書き込みで再現する
	_, _ = ref.Set(ctx, map[string]any{"v": "external", "keep": true})
	return tx.Set(ref, map[string]any{"v": "tx"})
})
// err: nil、試行 1 回。文書は {v: tx} で、keep は消える
  • PITFALL: if old != "" { tx.Get(...) } のように分岐で読みを飛ばすと、その経路だけ守られない。更新する文書は、条件に関係なくコールバックの先頭で読む

コールバックは再実行される。1 回目の読みを持ち越した試行は守られない

1 回目に読んだ値をコールバックの外に残すと、2 回目は読まずに古い判定で書き、2 人とも成功する

RunTransaction は、エラーが ABORTED のときだけコールバックを最初から呼び直す。 コミットの失敗でも、コールバックの中の読み取りが返した ABORTED でも同じ。 既定の上限は DefaultTransactionMaxAttempts = 5 回で、呼び直す前に溜めた書き込みを捨てる。 ABORTED 以外のエラーをコールバックが返したときは、ロールバックしてそのエラーを返す。呼び直さない。

守られるのは、その試行の中で tx.Get した文書だけ。前の試行で読んだ分は、次の試行の照合に入らない。 読み直しを省くつもりで 1 回目の結果をコールバックの外の変数に残すと、2 回目は何も読まずに古い判定で書く。

var cached *firestore.DocumentSnapshot // コールバックの外
err := client.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
	if cached == nil {
		snap, err := tx.Get(ref)
		if err != nil {
			return err
		}
		cached = snap
	}
	if cached.Data()["assignee"] != "" {
		return errAlreadyTaken
	}
	return tx.Update(ref, []firestore.Update{{Path: "assignee", Value: me}})
})
// alice と bob が同時に呼ぶと、両方 nil (試行 2 回ずつ)。文書は後に書いた方になる

試行ごとに起きたことは次のとおり。standard と enterprise のエミュレータで同じだった。

試行alice と bob結果
1 回目2 人とも tx.Get で空を読み、同じ文書のロックを持ったままコミットする互いのロックを待って、2 人とも Aborted desc = Transaction lock timeout.
2 回目2 人とも読まずに cached の空で判定し、Update だけする2 人とも nil。後にコミットした方の値が残る

2 回目の試行は読まずに書くだけなので、前節と同じく照合する文書が無い。 公式ドキュメントも “Transaction functions should not directly modify application state.” と書いている。 コールバックの中で毎回 tx.Get する版は、2 回目に相手の値を読み、片方が errAlreadyTaken で止まる。 楽観のデータベースでは、前提の節の照合 (“only if none of those documents changed”) に従えば、1 回目は先にコミットした方が通り、遅い方だけが ABORTED になる。本番では試していない。 遅い方の 2 回目が読まずに書くので、上書きになる点は変わらない。

  • PITFALL: 試行をまたいで残してよいのは、判定にも書き込みにも使わない値だけ (試行回数のログなど)。判定に使う値は、毎回の試行で tx.Get から作り直す

クエリで「無い」を確かめても、同時に作られた相手は守れない

クエリの判定は同時に作られた文書を結果に含められず、ID を名指しした読み取りは不在のパスも監視する

「同じ code の文書が無ければ作る」を、2 つのトランザクションで同時に走らせた。 どちらも読み終えるまで待ち合わせてから作らせた。

// NG: クエリの件数で判定する
docs, err := tx.Documents(col.Where("code", "==", code)).GetAll()
if err != nil {
	return err
}
if len(docs) > 0 {
	return errAlreadyTaken
}
return tx.Create(col.NewDoc(), map[string]any{"code": code})
// OK: code をドキュメント ID にして名指しで読む
ref := col.Doc(code)
_, err := tx.Get(ref)
if err == nil {
	return errAlreadyTaken
}
if status.Code(err) != codes.NotFound {
	return err
}
return tx.Create(ref, map[string]any{"by": me})
判定standard のエミュレータenterprise のエミュレータ
クエリの件数21 回とも 1 件初めて使うコレクションでは 11 回とも 2 件。一度使って消したコレクションでは 10 回とも 1 件
ID を名指し4 回とも 1 件4 回とも 1 件

クエリの判定は、条件によって 2 件できた。 公式ドキュメントはロックと照合の対象を「読んだ文書」としか書いておらず、クエリについての記述は無い。 相手が新しく作った文書は、こちらが読んだ文書ではないので、楽観の照合には掛からない。 standard のエミュレータで 1 件に収まった結果を、本番で守られる根拠にしない。

名指しの tx.Get は、文書が無くて NotFound を返したときもそのパスを監視する。 相手が先にそこへ作ると、こちらは再実行され、2 回目の Get で相手の文書を見て止まる。

  • PITFALL: 「トランザクションの最後でもう一度数える」は効かない。読みは書きより前でなければならず、相手の書き込みはコミットまで見えず、クエリは監視の対象にならない。理由が 3 つあるので、1 つ潰しても残りが効く

ID を自前で採番すると、親 1 件に子 1 件を DB は守れない

子の文書 ID を UUID にして、親へは parentId のフィールドで紐付けると、同時の保存で同じ親に子が 2 件できる。 競合はドキュメントのパス単位で見るので、パスが違う 2 件はそもそも競合していない。 既に重複したデータは、読む側で 2 件目の有無を確かめて失敗させる。

// 全件は読まない。2 件目があるかだけ分かればよい
docs, err := col.Where("parentId", "==", parentID).Limit(2).Documents(ctx).GetAll()
if err != nil {
	return nil, err
}
if len(docs) > 1 {
	return nil, ErrDuplicated // 業務エラーに丸めず 500 で落とす
}
  • PITFALL: 先頭の 1 件を返す実装にすると、更新も削除も片方にしか効かず、消したはずの設定が残り続ける

件数の上限は、1 つのガード文書で守る

「有効なものを N 件まで」「合計が上限を超えないなら追加」も、クエリで数える限り同じ理由で守れない。 厳密に守るなら、件数や合計を持つ 1 つの文書をトランザクションの中で名指しで読み書きする。 厳密さが要らないなら、トランザクションで包まず、上限が目安であることをコメントに書く。

Create の重複は、トランザクションの中ではコミットまで分からない

トランザクション内の Create は書き込みを溜めるだけで nil を返し、AlreadyExists はコミットで RunTransaction から返る

トランザクションの中の書き込みは、コミットまで溜めておくだけで、その場では結果を返さない。

_, _ = ref.Set(ctx, map[string]any{"v": 1})
err := client.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
	err := tx.Create(ref, map[string]any{"v": 2})
	fmt.Println(err) // <nil>
	return err
})
fmt.Println(status.Code(err)) // AlreadyExists。再実行はされない (試行 1 回)

tx.Create の戻り値で「登録済みなら 409」を返すハンドラは、その分岐に入らず、コミットの AlreadyExists を 500 にする。 先に tx.Get で有無を確かめ、無ければ Create する。この Get は前節のとおり、不在のパスも監視する。

書いた後には読めない

トランザクションの中で書いた後に読むと、SDK がその場でエラーにする。

err := client.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
	_ = tx.Set(col.Doc("b"), map[string]any{"v": 1})
	_, err := tx.Get(col.Doc("a"))
	fmt.Println(err) // firestore: read after write in transaction
	return nil
})
fmt.Println(err) // firestore: read after write in transaction。b は書かれない

コールバックが nil を返しても、RunTransaction は同じエラーを返し、書き込みを捨てる。 「作ってから重複を数え直す」形は、そもそも書けない。

トランザクションの外の Create は、並行すると片方が AlreadyExists になる

遅れて作るキャッシュ文書を、2 つのリクエストが同時に初めて読むと、両方 NotFound を見て両方 Create する。 実測では片方が nil、もう片方が AlreadyExists だった。

if _, err := ref.Create(ctx, data); err != nil {
	if status.Code(err) != codes.AlreadyExists {
		return err
	}
	// 相手が先に作った。正常系として扱う
}
  • PITFALL: 「Get で無かったから Create は通る」は並行時に成り立たない。AlreadyExists を 500 にすると、正常なリクエストが落ちる

Create と Set の違いは Go の Firestore SDK で消える項目と通らない値 の表にまとめた。

SDK が止めない書き間違い

コールバックの中で生のクライアントや外側の ctx を使うと、その書き込みはトランザクションの外で確定し、外側が失敗しても残る

コールバックの中で生のクライアントに書くと、その場で確定する

トランザクションの実体は tx が持つ書き込みのバッファで、ctx には何も載っていない。 コールバックの ctx を渡しても、client.Collection(...).Doc(...).Set はトランザクションの外で即座に確定する。

err := client.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
	_, _ = col.Doc("raw").Set(ctx, map[string]any{"v": 1}) // 生のクライアント
	_ = tx.Set(col.Doc("tx"), map[string]any{"v": 1})
	return errors.New("validation failed")
})
// raw は残り、tx は NotFound

コンパイルも lint も通る。原子性を持たないインメモリの代役で試すテストでも差が出ない。 規約でなく型で防ぐ。生のクライアントを持てる型を 1 つに絞り、他の層はそれを経由させる。

入れ子の RunTransaction は、渡す ctx でエラーにも別トランザクションにもなる

err := client.RunTransaction(ctx, func(txctx context.Context, tx *firestore.Transaction) error {
	err := client.RunTransaction(txctx, func(ctx context.Context, tx2 *firestore.Transaction) error {
		return nil
	})
	fmt.Println(err) // firestore: nested transaction

	err = client.RunTransaction(ctx, func(ctx context.Context, tx2 *firestore.Transaction) error {
		return tx2.Set(col.Doc("inner"), map[string]any{"v": 1})
	})
	fmt.Println(err) // <nil>

	_ = tx.Set(col.Doc("outer"), map[string]any{"v": 1})
	return errors.New("outer failed")
})
// inner は残り、outer は NotFound

SDK はコールバックに渡す ctx に印を付け、その ctx での RunTransaction だけを errNestedTransaction にする。 外側の ctx を渡すと印が無いので、別のトランザクションとして確定し、外側が失敗しても戻らない。 RunTransaction の doc コメントも “f should use the context it is passed, not the first argument to RunTransaction” と書いている。

ctx でトランザクションを持ち回すと、読みが書きの後ろに回る

各メソッドが自分で RunTransaction を張っていると、上位でまとめられない。 ctx にトランザクションを載せ、あればそれを使い、無ければ自分で張る形にする。

type txKey struct{}

func (r *Repo) run(ctx context.Context, f func(context.Context, *firestore.Transaction) error) error {
	if tx, ok := ctx.Value(txKey{}).(*firestore.Transaction); ok {
		return f(ctx, tx)
	}
	return r.client.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
		return f(context.WithValue(ctx, txKey{}, tx), tx)
	})
}

ただし「読んで書く」メソッドを 2 つ続けて呼ぶと、2 つ目の読みが 1 つ目の書きの後ろに回る。

err := r.run(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
	if err := r.Assign(ctx, a, "alice"); err != nil { // tx.Get(a) => tx.Update(a)
		return err
	}
	return r.Assign(ctx, b, "alice") // tx.Get(b) が書きの後になる
})
fmt.Println(err) // firestore: read after write in transaction

まとめる側で先に全部読み、判定してから書くメソッドを呼ぶ。読みと書きを 1 つのメソッドに閉じた形は、そのままでは組み合わせられない。

複数の文書をまとめて書く

1 文書に収まるなら 1 回の書き込み、複数の文書を全部か無しかで書くなら RunTransaction、BulkWriter は途中の失敗を残す

1 文書に収まるなら、1 回の書き込みで足りる

1 つの文書への 1 回の Create / Set は、それ自体がアトミック。 「登録 + 紐付け + 同意」が同じ文書のフィールドに収まるなら、揃ってから 1 回で書けばよく、トランザクションも巻き戻しも要らない。 要件に「トランザクションで」とあっても、保存先が 1 文書かを先に確かめる。

BulkWriter はアトミックでない

WriteBatch (Client.Batch) は非推奨で、doc コメントは置き換え先を 2 つ挙げている。

For atomic transaction operations, use Transaction. For bulk read and write operations, use BulkWriter.

BulkWriter の doc コメントは “BulkWriter cannot promise atomicity: individual writes can fail or succeed independent of each other.” と書く。 既存の b を含む 3 件を Create すると、a と c だけが書かれた。

_, _ = col.Doc("b").Set(ctx, map[string]any{"v": 0})
bw := client.BulkWriter(ctx)
ja, _ := bw.Create(col.Doc("a"), map[string]any{"v": 1})
jb, _ := bw.Create(col.Doc("b"), map[string]any{"v": 1})
jc, _ := bw.Create(col.Doc("c"), map[string]any{"v": 1})
bw.End()
_, errA := ja.Results() // nil
_, errB := jb.Results() // AlreadyExists
_, errC := jc.Results() // nil

同じ 3 件を RunTransaction の中で tx.Create すると、AlreadyExists で全体が失敗し、a も c も書かれない。

BulkWriter は 1 リクエストに最大 20 件 (maxBatchSize = 20) の書き込みを束ねて送るので、1 件ずつ書くより往復が減る。 テストデータの投入や移行スクリプトのように、途中の失敗を後から拾えばよい用途に使う。

  • PITFALL: bw.Create などの登録自体もエラーを返す。登録に失敗した分を飛ばして job を積むと、入力と job の添字がずれるので、job と対象の ID を組で持つ
  • PITFALL: 非推奨の警告を消すために WriteBatch を BulkWriter へ置き換えると、全部か無しかが失われる

まとめ

やりたいこと守られない書き方守られる書き方
判定してから更新する判定の値をトランザクションの外で読むコールバックの中で tx.Get して判定する
更新する文書を守る読まずに tx.Set / tx.Update する条件に関係なく先頭で tx.Get する
再実行で同じ結果にする1 回目の読みを外の変数に残す毎回すべて読み直す
同じ値の文書を 1 件だけにするクエリで数えて NewDoc で作る値を ID にして名指しで tx.Get する
件数の上限を守るクエリで数える件数を持つガード文書を読み書きする
重複の登録を 409 にするtx.Create の戻り値を見る先に tx.Get して NotFound なら作る
遅れて作る文書を並行で作るAlreadyExists を 500 にするAlreadyExists を正常系にする
トランザクションに書き込みを含める生のクライアントや外側の ctx を使うtx とコールバックの ctx だけを使う
メソッドを 1 つのトランザクションにまとめる読んで書くメソッドを続けて呼ぶ先に全部読み、書くメソッドを後で呼ぶ
複数の文書を全部か無しかで書くBulkWriter1 文書にまとめるか RunTransaction

参考