はじめに
皆さまは、Railsで User.create! を1回実行したとき、
SQLが何回行われていると思いますか?
私はINSERTしか行われないと考えていました。
でも実機で観察してみたら、答えは違っていました。
Railsで当たり前のようにActiveRecordを使っていますが、
その裏側の中身を私は全然知りませんでした。
そこで今回ActiveRecordの「よしなに」の中身を SQLログで覗いてみました。
この記事で見えること
ActiveRecord を使っていると、User.create! のような 1 行で SQL が自動的に発行されます。
この記事では、その 1 行の裏側で実際にどんな SQL が流れているかを、
sql.active_record イベントを購読して観察します。
読み終えると、create! が自動でトランザクションを張っていること、
そして transaction で囲むとそれが 1 つにまとまることが、実際のログとともに分かります。
特別なツールは使わず、数行のコードを足すだけで覗ける範囲の話です。
観察の道具
ActiveSupport::Notifications は、Rails 標準装備の「イベント通知の仕組み」です。
特定のイベントが起きたら、登録した処理を呼んでくれます。
雑誌の出版社が読者を意識せずに発行し、購読登録した読者だけが届くのを待つ ── あれと同じ仕組みです。
Rails は「SQLを実行したよ」と発火するだけで、
購読登録した皆さまのコードがそれを受け取れます。これを Pub/Sub(発行者と購読者)と呼びます。
今回の記事では sql.active_record というイベントを購読します。
これで ActiveRecord が実行する全 SQL が手元に流れてきます。
これを実際に書いてみるとこうなります。
# Rails が内部で発火する SQL のイベントを購読する
ActiveSupport::Notifications.subscribe('sql.active_record') do |name, start, finish, id, payload|
# payload[:sql] に実行された SQL 文字列が入っている
puts "[SQL] #{payload[:sql]}"
end
subscribe で購読を登録すると、
以降 Rails が instrument('sql.active_record') を呼ぶたびに、
ブロックの中身が実行されます。
皆さまが書くのは subscribe だけで、instrument は Rails 内部が自動で呼んでくれます。
ブロックの第5引数 payload には、そのイベントの情報が入っています。sql.active_record の場合、payload[:sql] で実行された SQL を取得できます。
では実際に動かしてみます。まずは一番シンプルな User.count を実行してみます。
User.count
すると、こんなログが流れてきます。
[SQL] (テーブル情報の事前取得 SQL、省略)
[SQL] (テーブル情報の事前取得 SQL、省略)
[SQL] SELECT COUNT(*) FROM "users"
User.count という 1 行から、
最後の SELECT COUNT(*) が流れているのが見えます。
その手前に並んでいるのは、
ActiveRecord がテーブルの構造を把握するために裏で実行している事前の SQL です。
今回の本筋ではないので省略しました。
道具が揃ったところで、ここから本題に入ります。
User.create! を 1 回呼ぶと、SQL は何本流れるのでしょうか?
発見1: create! の1行は3つのSQLだった
では User.create! を実行してみます。
User.create!(name: "Alice")
流れてきたログがこちらです。
[SQL] (テーブル情報の事前取得 × 3、省略)
[SQL] begin transaction
[SQL] INSERT INTO "users" ("name") VALUES (?) RETURNING "id"
[SQL] commit transaction
[TX] outcome=commit
INSERT しか流れないと思っていた私の予想は、ここで外れました。
事前の SQL を省いても、本体は 3 本あります。
begin transaction、INSERT、commit transaction の 3 つです。
最初と最後の begin transaction / commit transaction は、
トランザクションの開始と終了の合図です。
トランザクションとは「ここからここまでをひとかたまりの処理として扱う」という宣言で、
途中で失敗したら全部なかったことにできる仕組みです。
銀行の振込で「Aさんの口座から引いて、Bさんの口座に足す」という 2 つの操作を、
片方だけ成功した状態で止めないための仕組み ── あれと同じものです。
では、なぜ INSERT を 1 回するだけなのに、わざわざトランザクションで囲むのでしょうか?
理由は、ActiveRecord のコールバックにあります。
コールバックとは、保存の前後など特定のタイミングで自動的に呼ばれる処理のことです。
たとえば after_create(作成直後に呼ばれる) で
「関連レコードも一緒に作る」といった処理を書くことがあります。
もし INSERT だけして、その直後のコールバックで失敗したらどうなるでしょうか?
INSERT は成功したのにコールバックは失敗した、という中途半端な状態が残ってしまいます。
これを防ぐために、ActiveRecord は INSERT とコールバックをまとめてトランザクションで囲み、途中で失敗したら INSERT ごと巻き戻せるようにしているのです。
ここから言えるのは、こういうことです。
データを変更する処理(INSERT / UPDATE / DELETE)は、たとえ 1 回でも、
必ずトランザクションで囲まれる
User.create! の 1 行が「よしなに」やっていたのは、この自動トランザクションでした。
発見2: transaction で囲むと1セットにまとまる
発見1で、User.create! 1 回ごとに begin transaction と
commit transaction が付くことが分かりました。では、
複数のレコードを続けて作るとどうなるでしょうか?
まずは何も考えずに create! を 2 回呼んでみます。
User.create!(name: "Bob")
User.create!(name: "Carol")
これだと、発見1で見たトランザクションが 2 セット流れます。
begin → INSERT → commit が、Bob と Carol でそれぞれ 1 回ずつです。
次に、2 回の create! を User.transaction do ... end で囲んでみます。
User.transaction do
User.create!(name: "Bob")
User.create!(name: "Carol")
end
すると、ログはこう変わります。
[SQL] begin transaction
[SQL] INSERT INTO "users" ... (Bob)
[SQL] INSERT INTO "users" ... (Carol)
[SQL] commit transaction
[TX] outcome=commit
begin transaction と commit transaction が、
2 つの INSERT をまとめて 1 セットだけになりました。
囲まなかったときは 2 セットだった begin / commit が、
囲むことで 1 セットに減っています。
これには 2 つのうれしいことがあります。
1 つ目は整合性です。
Bob と Carol が「どちらも成功」か「どちらも失敗」のどちらかになり、
Bob だけ作られて Carol は作られなかった、という中途半端な状態が起きなくなります。
2 つ目は性能です。begin / commit 自体にもコストがかかります。
create! を 1 件ずつ実行すると、
その回数だけ begin / commit が繰り返されますが、
transaction でまとめると 1 回で済みます。
注意
たとえば 1000 件を 1 件ずつ create! するループは、transaction で囲むと速くなると一般に言われています。まとめて囲むことで begin / commit の往復が減るぶん、オーバーヘッドが小さくなるからです。ただし今回の記事ではベンチマークまでは取っていないので、ここは仕組みからの推測です。
User.transaction は、整合性のための仕組みだと思われがちですが、
実は性能の効く場面でもあるというのが、ここでの発見でした。
まとめ
User.create! の 1 行の裏側を、SQL ログで覗いてきました。
分かったのは、次の 2 つです。
1 つ目は、create! の 1 行が begin transaction → INSERT
→ commit transaction の 3 本に展開されること。
データを変更する処理は、たとえ 1 回でも自動的にトランザクションで囲まれていました。
2 つ目は、複数の create! を User.transaction で囲むと、
begin / commit が 1 セットにまとまること。整合性のためだと思っていた仕組みが、
begin / commit の往復を減らすという別の顔も持っていました。
どちらも、ふだんは ActiveRecord が「よしなに」隠してくれている部分です。sql.active_record を購読する数行を足すだけで、その裏側が見えるようになります。
気になったとき自分の手元で覗いてみると、いつもの 1 行の解像度が少し上がるはずです。
それではここまでお読み下さりありがとうございました。
誰かの知見になれば幸いです。
参考文献
この記事は以下の記事を参考にして執筆させて頂きました。