GORM の Preload は、Go でリレーショナルデータを扱ううえで最も強力な機能のひとつです。しかし、その内部を理解していないと、サービスのパフォーマンスを静かに蝕む N+1 クエリの罠にはまりがちです。Preload の便利さにはコストがあり、そのコストは CPU ではなくラウンドトリップで支払われます。
この記事は、私が使い始めた頃にあればよかったと思うメモです。Preload が実際にどんな SQL を生成するのか、どんなときに Joins を選ぶべきか、クエリ数をどう一定の範囲に抑えるか、そして本番の Go サービスで私がよく使うパターンを順に見ていきます。
Preload が解決しようとしている N+1 問題
最もシンプルなケースから始めましょう。複数の Order を持つ User があるとします:
type User struct {
ID uint
Name string
Orders []Order
}
type Order struct {
ID uint
UserID uint
Total int64
}Eager Loading を使わずに、100 人のユーザーとその注文を取得すると次のようになります:
var users []User
db.Find(&users) // 1 回のクエリ
for i := range users {
db.Where("user_id = ?", users[i].ID).Find(&users[i].Orders) // N 回のクエリ
}100 人のユーザーに対して 101 回のクエリ――典型的な N+1 です。ネットワーク RTT が 1ms の MySQL 環境なら、アプリケーションロジックが 1 バイトも動く前に、100ms が丸ごと余計に上乗せされることになります。
Preload はどのように SQL を生成するか
Preload は、N 回のアソシエーションクエリを1 回にまとめることで N+1 を解消します。db.Preload("Orders").Find(&users) を呼び出したときに実際に起きていることは次のとおりです:
- GORM がベースとなる
SELECT * FROM usersを実行します。 - 結果セットから主キーを集めます:
[1, 2, 3, ..., 100]。 SELECT * FROM orders WHERE user_id IN (1, 2, ..., 100)を 1 回だけ実行します。- Go の側で、
user_idを突き合わせて各ユーザーに注文を割り当てます。
つまりクエリ数は 101 ではなく 2 です。GORM は、N 回の SQL ラウンドトリップを、より大きな IN リストとメモリ上での 1 回の追加走査と引き換えにしているわけです。
GORM のロガーで確認できます:
db := db.Debug().Preload("Orders").Find(&users)
// [0.321ms] SELECT * FROM `users`
// [0.854ms] SELECT * FROM `orders` WHERE `user_id` IN (1,2,3,...)ネストした Preload:再帰的に動く
Preload("Orders.Items") も同じ仕組みで、再帰的に動きます。GORM は users、orders(user_id で)、items(order_id で)の 3 つのクエリを実行します。各階層で 1 回のラウンドトリップであり、N 回ではありません。
ひとつ見落としがちな点があります。3 階層の深さで親が 1000 件ある場合、末端の階層の IN リストは巨大になりえます。MySQL の max_allowed_packet(デフォルト 64MB)でたいていは吸収できますが、ある時点でパーサー自体がボトルネックになります。要素数が非常に多い IN リストは、パースや実行計画の作成にも時間がかかるようになります。大きな結果セットをページングするなら、積極的にチャンク分割しましょう。
Preload と Joins:別物の 2 つのツール
Preload と Joins は GORM の DSL 上では置き換え可能に見えますが、生成される SQL はまったく異なります。
Preload は別々のクエリを発行し、Go 側でつなぎ合わせます:
SELECT * FROM users;
SELECT * FROM orders WHERE user_id IN (...);Joins は JOIN を含む 1 つのクエリを生成します:
SELECT users.*, orders.*
FROM users LEFT JOIN orders ON orders.user_id = users.id;トレードオフは次のとおりです:
- 一対一 / 多対一:
Joinsを選びます。クエリは 1 回で、重複もなく、つなぎ合わせのオーバーヘッドもありません。 - 一対多:
Preloadを選びます。Joinsは子ごとに親の行を複製するため、ネットワーク上を流れるバイト数が膨らみ、アプリケーションコード側での重複排除を強いられます。 - 多対多:常に
Preloadです。Joinsを使った GORM の中間テーブルの扱いは冗長で、間違いを起こしやすいからです。 - 関連テーブルで絞り込みたいとき:WHERE 句には
Joinsを使い、アソシエーション全体が必要なら別途Preloadします。両者を混ぜて使っても問題ありません。
行の重複という罠
一対多のアソシエーションで Preload から Joins に切り替えるのは最適化に見えますが、逆効果になることがあります。各ユーザーが約 200 件の注文を持っていれば、JOIN によってユーザー 1 人あたり 200 行が生成されます。500 人なら 10 万行で、その大半は重複したユーザーデータです。クエリ自体は高速かもしれません――足を引っ張るのは、転送コストと Go 側での重複排除です。
経験則:子テーブルのカーディナリティに上限がないなら Preload。必ずです。
カスタム Preload スコープ
私にとって Preload を「便利」から「なくてはならない」ものに変えた機能が、関数を引数に取る形式です:
db.Preload("Orders", func(tx *gorm.DB) *gorm.DB {
return tx.Where("status = ?", "paid").
Order("created_at DESC").
Limit(10)
}).Find(&users)これにより、プリロードされる行を絞り込めます。私が本番でよく使うパターンは次のとおりです:
ステータスでの絞り込み ―― アクティブな子だけをロードします:
db.Preload("Subscriptions", "status = ?", "active").Find(&users)Limit + 並び順 ―― 親ごとの上位 N 件は厄介です。素の Limit は親ごとではなく全体に適用されます。親ごとに件数を制限するには、ウィンドウ関数が必要です:
db.Preload("Posts", func(tx *gorm.DB) *gorm.DB {
return tx.Where("id IN (?)",
tx.Model(&Post{}).Select(
"FIRST_VALUE(id) OVER (PARTITION BY user_id ORDER BY created_at DESC)",
),
)
})見た目は悪いですが、クエリは 1 回です。代わりに件数制限付きのクエリを N 回発行するやり方は、まさに最初に見た N+1 そのものです。
必要なカラムだけを選択 ―― Preload はデフォルトで SELECT * で取得します。幅の広いテーブルでは、これは無駄です:
db.Preload("Orders", func(tx *gorm.DB) *gorm.DB {
return tx.Select("id, user_id, total, created_at")
}).Find(&users)これが最も効いてくるのは、エンドポイントが一切読まない大きなカラム(JSON スナップショット、TEXT、BLOB)を子テーブルが持っている場合です。必要なカラムだけを選択すれば、データベースが読み取り、ソートし、送信しなければならないすべての行が小さくなります。
本番運用のヒント
本番で Preload が足を引っ張らないようにするためのパターンをいくつか紹介します:
INリストには必ず上限を設ける。1 万件の親に対してプリロードする可能性があるなら、DB にアクセスする前に 1000 件ずつのバッチに分割しましょう。MySQL は大きなIN句も処理できますが、実行計画の作成時間は非線形に増えていきます。- テストでクエリ数を数える。
*gorm.DBをラップして実行回数を数えるヘルパーを書きます。「このエンドポイントのクエリは ≤ 5 回」とアサートしておけば、リグレッションは見えないままではなく、はっきり目立つようになります。 - ポリモーフィック関連に注意する。ポリモーフィックなフィールドに対する
Preload("Attachments")は、全体で 1 回ではなく型ごとに 1 回クエリを発行します。直感に反するので、ユニットテストを書いておく価値があります。 PrepareStmtを有効にする。リクエストをまたいでプリペアドステートメントをキャッシュしてくれます。同じ 4 つのクエリを順番に実行するエンドポイントなら、クエリごとのオーバーヘッドが驚くほど削れます。- エンドポイントごとに一度は Debug 出力を読む。GORM のフルーエント API は多くのことを隠しています。実際にどんな SQL を送っているのかを知る唯一の方法は、自分の目で見ることです。
おわりに
Preload はレバーです。正しく引けば、数百回のラウンドトリップを 2〜3 回にまとめてくれます。雑に引いても、やはりまとめてはくれます――ただし同時に、使わないカラム、望んでいない JOIN、まったく絞り込まない子までロードしてしまいます。私が頭に置いているメンタルモデルはこうです:すべての Preload は別々のクエリであり、すべての Joins はより大きな行である。これが身につけば、それぞれの分岐点での正しい選択は自明になります。
最速のクエリは、今でも実行しないクエリです。エッジでキャッシュし、ホットパスで 3 階層の JOIN が要らないようにスキーマを設計し、Preload はリレーショナルモデルが本当にリクエストの形に合うときのためにとっておきましょう。