Guide / Reinhardt 0.3.15
ORMが隠しているネットワーク往復
この記事の内容
100件の投稿と、それぞれの著者名を表示する一覧を考えます。著者は10人です。 著者名をそろえる方法は、投稿ごとに著者を取得する方法と、必要な著者をまとめて取得する方法があります。 このサンプルでは前者が101回、後者が2回の問い合わせで、カードの内容も順序も同じです。 参照する著者の条件を変えると、11回問い合わせていてもN+1検出器の報告が0件になるケースもありました。
いずれも、ダウンロードできるRustサンプルで確認した結果です。 Reinhardt 0.3.15とメモリ内SQLiteを使っています。今回のSQLiteはアプリと同じプロセスで動きます。 ネットワークの図は、DBが別のマシンにある場合のコストを説明する模式図で、ネットワークの実測値ではありません。
Reinhardt Pressは、Reinhardtプロジェクト自身が運営する開発者向け広報媒体です。 アプリへ採用する前に、実装を読み、条件を変えて試せるサンプルを用意しました。
関係の定義と、名前を取得するタイミング
投稿は著者に属します。両方のモデルを#[model]で定義し、Postのauthorフィールドに #[rel]を付け、ForeignKeyField<Author>で関連を表します。
use reinhardt::db::associations::ForeignKeyField;
use reinhardt::model;
use serde::{Deserialize, Serialize};
#[model(app_label = "orm_network", table_name = "posts")]
#[derive(Serialize, Deserialize)]
pub struct Post {
#[field(primary_key = true)]
pub id: i64,
#[field(max_length = 200)]
pub title: String,
#[rel(foreign_key, on_delete = Cascade)]
pub author: ForeignKeyField<Author>,
}0.3.15では、modelマクロが関連の裏側にあるauthor_idフィールドを生成します。 別のスカラーフィールドとして手書きはしません。DBの列と後述の問い合わせは、この生成されたIDを使います。 関連の定義だけで著者名まで読み込まれるわけではありません。
AuthorもIDと名前を持つ#[model]です。サンプルでは準備と検証対象の読み込みを分けるため、 テーブル作成と架空のテストデータ投入をSQLで明示しています。マイグレーション生成は検証していません。 完全なソースと依存設定はダウンロードに含まれます。
どちらの実装も、まず同じ投稿をID順で取得します。
QuerySet::<Post>::new()
.order_by(&["id"])
.limit(count)
.all_with_db(db)
.await?ここでタイトルと著者IDがそろいます。最初の実装は、投稿を一つ取り出すたびに著者を取得します。 次の抜粋ではカードの組み立てだけを省略しています。
for post in posts(db, count).await? {
let author = QuerySet::<Author>::new()
.filter(Filter::new(
"id",
FilterOperator::Eq,
FilterValue::Integer(post.author_id),
))
.first_with_db(db)
.await?
.ok_or("missing author")?;
// Assemble the card from post and author.
}この問い合わせはアプリが明示的に要求しています。投稿の数だけ著者のSELECTを実行し、 その完了を待って次の投稿に進みます。ループ間で著者をキャッシュしないので、前に取得した著者も再び読み込みます。 数えると、投稿の取得1回と、著者の取得N回になります。
必要なIDを集めてから問い合わせる
二つ目の実装は、取得した投稿から著者IDを集め、重複を除いて一括で取得します。
let ids = posts
.iter()
.map(|post| post.author_id)
.collect::<BTreeSet<_>>();
let authors = QuerySet::<Author>::new()
.filter(Filter::new(
"id",
FilterOperator::In,
FilterValue::List(ids.into_iter().map(FilterValue::Integer).collect()),
))
.all_with_db(db)
.await?;IN条件の値はバインドします。完全な実装では、著者IDから名前へのマップを作り、 元の投稿を順番にたどってカードを組み立てます。これなら著者クエリの返却順に左右されず、投稿の順序を保てます。 必要な著者が見つからなければ、両方の実装がエラーにします。
投稿が0件なら著者を問い合わせずに終了します。このデータでは、空でないページのSELECTは合計2回です。 これはQuerySetによる明示的な一括取得であり、prefetch_related()の動作を検証したものではありません。
実行して確認したこと
Rust 1.96.0と、公開済みのreinhardt-web・reinhardt-db 0.3.15を使いました。 依存関係は同梱のCargo.lockで固定しています。データは100件の投稿で、著者の主キーにはインデックスがあります。
| 投稿数 | 参照した著者の種類 | 逐次取得のSELECT | 一括取得のSELECT | 逐次取得の検出件数 |
|---|---|---|---|---|
| 0 | 0 | 1 | 1 | 0 |
| 1 | 1 | 2 | 2 | 0 |
| 5 | 5 | 6 | 2 | 0 |
| 10 | 10 | 11 | 2 | 1 |
| 100 | 10 | 101 | 2 | 1 |
| 10 | 1 | 11 | 2 | 0 |

最初の5ケースは著者が10人いるデータを使います。最後のケースは著者が1人だけのDBを作り直して実行しています。 各ケースでカードのID、タイトル、著者名、順序のすべてを、別に組み立てた期待値と比較します。 返る行数だけを比べると、名前や順序の間違いを見落とすためです。
NPlusOneScope::run_with_reportが実際のORMクエリを記録します。 テーブル作成とデータ投入はスコープの外で行い、記録回数とサンプル欠落が0件であることも検証しました。 すべてのアサーションが通っています。回数は指定した範囲内で成功したORMのSELECTであり、 接続の内部処理まで含めた全SQLの回数を表すものではありません。
警告がなくても問い合わせは繰り返される
検証対象の処理を次のように囲みます。
let (result, report) =
NPlusOneScope::warn("posts.naive", NPlusOneConfig::default())
.run_with_report(naive(db, count))
.await;
let cards = result?;0.3.15の標準設定では、同じクエリの形を10回以上実行し、パラメーターが3種類以上ある場合が検出対象です。 一つのスコープが保持するクエリのサンプル数は最大1,024件です。 これらは警告文から推測した値ではなく、設定型に定義された値です。 0.3.15のソースコミットにあるNPlusOneConfig
10投稿が10人の著者を参照すると、次の問い合わせの繰り返しが1件報告されます。
SELECT * FROM "authors" WHERE "id" = ?一方、10投稿がすべて同じ著者を参照すると、報告は0件です。 問い合わせ自体は10回繰り返していますが、パラメーターが1種類しかないためです。 5投稿のケースも、実行回数が10回に届かないので検出されません。一括取得の検出件数は全6ケースで0件でした。
検出器はしきい値を持つ診断です。警告がないことを、効率的な実行方法の証明にはできません。 変更を評価するときは、結果の正しさと問い合わせ回数も一緒に確認します。
ネットワークの図が表す追加コスト
説明のため、リモートDBへの逐次問い合わせ1回につき、往復の待ち時間が20ミリ秒加わると仮定します。 ほかのコストを除くと、101回は101 × 20 ms = 2,020 ms、2回は2 × 20 ms = 40 msの待ちになります。
この時間は仮定です。 今回はネットワーク遅延も、処理時間の短縮率も測定していません。 実際の応答時間にはDB内の処理、転送量、キャッシュ、接続の状態、その他のリクエスト処理も影響します。 待ち時間の一要素を減らしたことから、アプリ全体の速度向上率は断定できません。

図にすると、ループが一つの回答を待ってから次の質問をする、という逐次処理の関係が見えてきます。
次の実験に残した範囲
一括取得は著者マップをメモリに保持します。IDの数はDBのパラメーター上限にも収める必要があります。 今回は最大10種類なので、大きなリストの分割は検証していません。JOINも比較候補ですが、今回の実測対象には含めません。
同時更新も行っていないため、本番で必要になるスナップショットの一貫性やトランザクションの性質は確認していません。 DB間の比較、負荷試験、自動的な関連データ取得APIの検証も別の実験になります。
今回確認したのは、定義した出力を保ちながら、問い合わせ回数がどう変わったかです。
実行して、一つだけ条件を変える
サンプルとロックファイルをダウンロードして展開します。 Rust 1.96.0以降と、ネイティブのRustプログラムをビルドできる環境で実行してください。
cd orm-network
cargo run --locked初回ビルドでは依存パッケージをダウンロードします。DBサーバーやDockerは不要です。 アサーションやDB操作が失敗すれば異常終了し、すべての検証が通るとJSONを出力します。
投稿数を固定し、著者の種類だけを変えてみてください。問い合わせ回数と検出結果をそれぞれ予想してから実行します。 一緒に変更する値はREADMEに記載しています。
期待値と違うケースがあれば、小さな再現例、実行コマンド、Rustのバージョン、ロックファイル、出力を残します。 フレームワークの変更を提案する前に、現在の貢献ガイドを確認してください。 検出器はcrates/reinhardt-db/src/orm/n_plus_one.rsにあり、QuerySet::all_with_dbがORMの実行を記録処理につなぎます。 期待する結果を明確にした一つのケースから、貢献を始められます。