はじめに
この記事は フューチャー Advent Calendar 2025 の 9 日目の記事です。
最近パフォーマンスの問題に重点的に取り組んでおり、DB 関連の問題に取り組むことが多いです。そんな中で、Update のパフォーマンスに影響を及ぼす各項目について、実際に検証してみたくなりました。
そこで本記事では、以下の 3 点について検証した結果を共有します。
- 明示的にトランザクションを開始する場合と、そうでない場合 (=各 Update がオートコミットになる場合) のパフォーマンス差
- SQL を直接実行する場合と、アプリケーションで ORM を使用した場合のパフォーマンス差
- bulk update した場合と 1 件ずつ update した場合のパフォーマンス差
また、「明示的なトランザクションあり/なしでのパフォーマンス差が生まれる主な要因のひとつに WAL の fsync が挙げられる」という点についても付随して検証し、結果について考察します。
今回検証に用いたコードは こちらの git リポジトリ に置いています。
README に検証方法も記載しているため、興味があればぜひお試しください。
検証環境
- OS: MacOS Sequoia v15.5
- メモリ: 16GB
- チップ: Apple M1 Pro
- PostgreSQL:
postgres:17コンテナ@ローカル - Go バージョン: 24
ベンチマークに使用した DB
以下のように、 benchmark_accounts というテーブルを作成し、検証に使用しました。
seed_updates.sql を用い、1万レコードをテーブルに追加した状態で各検証を開始しています。
Table "public.benchmark_accounts"
Column | Type | Collation | Nullable | Default
---------------+--------------------------+-----------+----------+------------------------------------------------
id | bigint | | not null | nextval('benchmark_accounts_id_seq'::regclass)
status | text | | not null |
balance | numeric(12,2) | | not null | 0
last_accessed | timestamp with time zone | | not null | now()
version | integer | | not null | 0
note | text | | not null | ''::text
Indexes:
"benchmark_accounts_pkey" PRIMARY KEY, btree (id)
"idx_benchmark_accounts_status" btree (status)
比較したケース (生 SQL の場合)
以下の 3 つのケースを比較しました。
① トランザクションを張らない (=毎回オートコミット) ケース
UPDATE benchmark_accounts SET status = 'active' WHERE id = 1;
UPDATE benchmark_accounts SET status = 'active' WHERE id = 2;
...
UPDATE benchmark_accounts SET status = 'active' WHERE id = 10000;
② トランザクションを張るケース
BEGIN;
UPDATE benchmark_accounts SET status = 'active' WHERE id = 1;
UPDATE benchmark_accounts SET status = 'active' WHERE id = 2;
...
UPDATE benchmark_accounts SET status = 'active' WHERE id = 10000;
COMMIT;
③ bulk update するケース
UPDATE benchmark_accounts SET status = 'active'
WHERE id IN (
1,
2,
...
10000
);
比較したケース (Go + bob の場合)
アプリケーション側で 「bob」という ORM コード生成ツールを使用して、上記と同様の SQL を実行した場合の時間を計測しました。
① トランザクションを張らない (=毎回オートコミット) ケース
①-a: 直接 SQL を Exec するケース
for _, benchmarkAccount := range benchmarkAccounts {
if _, err := db.ExecContext(ctx,
"UPDATE benchmark_accounts SET status = 'active' WHERE id = $1",
benchmarkAccount.ID,
); err != nil {
return err
}
}
①-b: ORM で生成したコードを利用するケース
setter := &models.BenchmarkAccountSetter{
Status: omit.From("active"),
}
for _, benchmarkAccount := range ba {
if err := benchmarkAccount.Update(ctx, db, setter); err != nil {
return err
}
}
② トランザクションを張るケース
②-a: 直接 SQL を Exec するケース
tx, err := db.BeginTx(ctx, nil)
for _, benchmarkAccount := range benchmarkAccounts {
if _, err := tx.ExecContext(ctx,
"UPDATE benchmark_accounts SET status = 'active' WHERE id = $1",
benchmarkAccount.ID,
); err != nil {
return tx.Rollback(ctx)
}
}
return tx.Commit(ctx)
②-b: ORM で生成したコードを利用するケース
tx, err := db.BeginTx(ctx, nil)
setter := &models.BenchmarkAccountSetter{
Status: omit.From("active"),
}
for _, benchmarkAccount := range ba {
if err := benchmarkAccount.Update(ctx, tx, setter); err != nil {
return tx.Rollback(ctx)
}
}
return tx.Commit(ctx)
③ bulk update
setter := models.BenchmarkAccountSetter{
Status: omit.From("active"),
}
if err := ba.UpdateAll(ctx, db, setter); err != nil {
return err
}
結果
各ケースにおける実行時間は以下のとおりでした。
「生 SQL 実行」は 1 行目、「a: Go & 直接 SQL を Exec するケース」は 2 行目、「b: Go & ORM で生成したコードを利用するケース」は 3 行目に結果を記載しています。
(なお全ケースについて、5回分の実行時間の平均をとっています)
| ① トランザクションなし | ② トランザクションあり | ③ bulk update | |
|---|---|---|---|
| 生 SQL | 10.28 s | 9.42 s | 0.15 s |
| Go (SQL 直接) | 17.6 s | 17.2 s | - |
| Go (生成された ORM コード) | 18.4 s | 18.1 s | 0.14 s |
わかること
記事冒頭であげた各疑問点に対して、上記の検証結果から言えることをまとめます。
1 .明示的にトランザクションを開始する場合と、そうでない場合 (=各 Update がオートコミットになる場合) のパフォーマンス差
- 明示的なトランザクションありの方が、無しの場合より少しだけ早かったです。(この理由については、以下で詳しく検証します)
- 予想していたよりはパフォーマンス差が小さかったです
2 . SQL を直接実行する場合と、アプリケーションで ORM を使用した場合のパフォーマンス差
- ORM を使用した場合、生 SQL を実行するよりも 2 倍弱時間がかかっている
- DB との通信回数は変わらないため、Go のランタイムオーバーヘッドや ORM の構造体マッピング等のコストと考えられます
- 具体的にどこがボトルネックになっているかは不明 (pprof など使って計測したい)
3 . bulk update した場合と 1 件ずつ update した場合のパフォーマンス差
- bulk update にした方が圧倒的に高速 (実行時間 1/50 ~ 1/100)
- bulk update 可能な処理については、できる限り bulk update でやるのが良さそうです
トランザクションありの方が、なしの場合よりも時間が短い理由
生 SQL の実行結果において、トランザクションなし(オートコミット)の場合と、トランザクションありの場合で、実行時間に 1 秒弱の差が出ました。
このパフォーマンス差が生まれる主な要因のひとつに WAL の fsync が挙げられます。この影響を特定するため、追加の調査を行いました。
補足: WAL について + WAL fsync について
WAL (ログ先行書き込み) は、データファイルへの変更を直接ディスクに書き込む前に、操作ログを先行して記録する方式です。(ドキュメントで分かりやすく解説されていたので是非)
PostgreSQL は WAL を採用することで、ディスクへの書き込みを遅延させつつ、ログ書き込みだけで永続性を保証しクラッシュ時のデータベースリカバリを可能にしています。
WAL を利用する場合、COMMIT 時に WAL ファイルの fsync (WAL ファイルをディスクへ同期すること) が行われます。この fsync にかかる時間が、COMMIT 時に時間を多く必要とする処理のひとつ、と言われています。 (他にも COMMIT 時の重い処理は色々あるかもしれませんが、把握しきれていません。他にも何か知っている方いましたら、コメントに記載いただけると筆者が非常に喜びます)
調査: WAL 同期時間の計測
PostgreSQL には、WAL (ログ先行書き込み) の活動状況に関する統計情報を表示する pg_stat_wal というビューが存在します。 (詳細は ドキュメント をご覧ください。)
このビューのうち、以下の 2 つを取得することで、WAL の fsync にかかる時間と回数を取得することができます。
-
wal_sync_time: WAL ファイルのディスクに書き込むのに費やされた合計時間 -
wal_sync: WAL ファイルがディスクに同期された回数
COMMIT 回数と fsync の時間の関係を確認するため、この統計情報を用いてトランザクションあり/なしの場合で比較しました。
計測結果は以下の通りでした。
| ケース | 平均実行時間 | 平均 WAL 同期時間 (wal_sync_time) |
平均 WAL 同期回数 (wal_sync) |
|---|---|---|---|
| ① トランザクションなし | 10.28 s | 1.99 s | 10000 回 |
| ② トランザクションあり | 9.42 s | 0.05 s | 47 回 |
考察
トランザクションなしの場合は、update 文それぞれが独立したトランザクションとして扱われるため、実行の度に WAL の sync が実行され、結果として 10,000 回 fsync が実行されています。
一方、トランザクションありの場合は、コミットが最後の 1 回のみで済むようになり、fsync も 47 回に抑えられています。
結果として、トランザクションなしの場合は wal_sync_time が約 1.98 秒かかっているのに対し、トランザクションありの場合は約 0.05 秒で済んでいます。
この結果より、「毎回のコミットに伴う WAL 同期のオーバーヘッド」 が、実行時間の差を生んでいる主要な原因であると結論付けられます。
実行時間の差(約 1.2 秒)と wal_sync_time の差(約 1.9 秒)に多少のズレがある理由は分からなかったため、こちらももしわかる方いましたらコメントにて教えていただけますと大変ありがたいです。
なぜトランザクションありの場合も fsync が複数回実行されているのか
明示的にトランザクションを張った場合は COMMIT が 1 回なのに、fsync が 1 回でないのは、 COMMIT とは別に定期的に WAL fsync が実行される機構が存在するためです。
この周期は wal_writer_delay というパラメータによって制御されています。 (ドキュメント) また、 wal_writer_flush_after というパラメータで、WAL fsync が強制的に行われるトリガーとなる最小量も制御できます。
これらのパラメータを大きくすることで、 wal_sync (fsync の回数) が小さくなります。
アプリケーションの実行時間が長い理由と、Prepare が与える影響の検証
「Prepare したら実行時間が短くなるのではないか」という助言をいただいたため、こちらについても追加で検証しました。
調査: Prepare した場合としない場合の差
以下のように、 プリペアードステートメントを用いて SQL を実行した際のケースを「④」とし、実行時間を計測しました。
④: Prepare → プリペアードステートメントを用いて実行 (生 SQL)
BEGIN;
PREPARE update_active (bigint) AS UPDATE benchmark_accounts SET status = 'active' WHERE id = $1;
EXECUTE update_active(1);
EXECUTE update_active(2);
...
EXECUTE update_active(10000);
COMMIT;
DEALLOCATE update_active;
④: Prepare → プリペアードステートメントを用いて実行 (Go)
tx, err := db.BeginTx(ctx, nil)
stmt, err := tx.PrepareContext(ctx, "UPDATE benchmark_accounts SET status = 'active' WHERE id = $1")
defer stmt.Close()
for _, benchmarkAccount := range ba {
if _, err := stmt.ExecContext(ctx, benchmarkAccount.ID); err != nil {
return tx.Rollback(ctx)
}
}
return tx.Commit(ctx)
}
この計測結果を、「②: トランザクションあり」のケースと比較してみます。
| ケース | 生SQL or Go | 平均実行時間 |
|---|---|---|
| ② : Prepare なし (再掲) | 生SQL | 9.42 s |
| ④ : Prepare あり | 生SQL | 9.03 s |
| ②-a : Prepare なし (再掲) | Go | 17.2 s |
| ④ : Prepare あり | Go | 8.5 s |
生 SQL 実行の場合、Prepare ありにすることで Prepare なしの場合より実行時間が少しだけ短くなりました (-0.4 秒程度)。
この結果より、Prepare によって毎回パースする必要がなくなることによる実行時間の短縮は、0.5秒程度であると分かります。
一方、 Go を用いた場合、Prepare ありの時 Prepare なしに比べほぼ半分の時間で完了しました。
つまり、アプリケーションの場合、DB 側でのパースの負荷軽減とは別の、時間短縮の主要因があるということです。
この理由について、以下で考察します。
アプリケーションの場合のみ時間が半減したことの考察
以下は、②-a のケース (Prepare なし) の ExecContext のさらに内部の execDC というメソッドの重要な処理を抽出したものです。
func (db *DB) execDC(ctx context.Context, dc *driverConn, release func(error), query string, args []any) (res Result, err error) {
execerCtx, ok := dc.ci.(driver.ExecerContext)
var execer driver.Execer
if ok {
withLock(dc, func() {
resi, err = ctxDriverExec(ctx, execerCtx, execer, query, nvdargs)
})
}
var si driver.Stmt
withLock(dc, func() {
si, err = ctxDriverPrepare(ctx, dc.ci, query)
})
ds := &driverStmt{Locker: dc, si: si}
return resultFromStatement(ctx, dc.ci, ds, args...)
}
今回の場合、if ok の中には入らず、その下の ctxDriverPrepare と resultFromStatement が実行されます。
このメソッドの実装より、 ExceContext 時は必ず Prepare した上で作成された stmt を用いて SQL が実行されることが分かります。
(Prepare を前もって実行するのは、未指定の値 ($1 みたいなやつ) を含んだ文を実行できるようにするためだと認識しています、違ったら教えてください)
つまり、現状の仕様だと、 tx.ExecContext や db.ExecContext を一度実行するために、Prepare + 実行 の 2回 DB と通信する必要がある のです。
このことより、少なくとも今回検証に用いたローカル環境においては、 DB との通信が 2 倍になったことが時間倍増の主要因と結論づけられます。
(環境がクラウドなどに変わったら結果が変わる可能性もあるので、あらためて検証したいです)
最後に
今回の検証を通じて、気になっていたパフォーマンスの差について、実際に計測して確認することができてよかったです。トランザクション有無の実行時間の差よりも、アプリケーション実行と生SQL実行の差の方が大きいのは意外でした。
トランザクションの有無によるパフォーマンス差の要因として、WAL 同期のオーバーヘッドを定量的に確認できたことも大きな収穫でした。
実際のプロダクション環境(AWS などのクラウド環境)では、ネットワークレイテンシやディスク I/O の特性が異なるため、今回の結果とはまた違った傾向が見られる可能性があります。
そのため、クラウド環境での検証や、ORM 利用時のボトルネック特定(pprof を用いた解析など)にも取り組んでいきたいです。
本記事が、Update 処理のパフォーマンスチューニングや、PostgreSQL の挙動理解の一助となれば幸いです。