ActiveRecord のパフォーマンス ― メモリ圧迫・遅延評価・MySQL データ型
📖 概要
この記事は、Rails ガイドや MySQL 公式ドキュメントなどの一次情報をもとに、ActiveRecord のパフォーマンスに関する要点を個人学習用にまとめ直したものです。
ActiveRecord は手軽にデータベースを扱える一方で、仕組みを理解せずに使うとメモリを圧迫したり、無駄なクエリを発行したりしやすいです。本記事では次の3点を扱います。
- 🗂️ ActiveRecord はオブジェクト化の際にデフォルトで全カラムを取得(
SELECT *)し、メモリを圧迫しやすいこと - ⏳
ActiveRecord::Relationが実際にオブジェクト化され、メモリにデータが展開されるタイミング(遅延評価) - 🧬 MySQL と連携する際に、パフォーマンスの観点から気を付けたいデータ型
1️⃣ ActiveRecord は全カラムを取得してオブジェクト化する
ActiveRecord は、明示的にカラムを絞らない限りデフォルトで SELECT * を発行する。取得した各行はモデルインスタンスへ変換される。
User.all.to_a
# => SELECT `users`.* FROM `users`
不要なカラムまで取得・オブジェクト化すると、その分メモリを消費する。ここで重要なのは、「取得するカラムを絞ること」と「オブジェクトを生成しないこと」は別のコストだという点。
📦 select vs pluck ― カラム絞り込みの2つの方法
- 📦
select(:id, :name): 取得カラムを絞るが、モデルインスタンスは生成する(未選択カラムを参照するとActiveModel::MissingAttributeError) - 🪶
pluck(:id, :name): モデルインスタンスを作らず、生の値(配列)を返す
User.select(:id, :name) # User のインスタンス(カラムは絞られる)
User.pluck(:name) # => ["Alice", "Bob"](ただの配列、オブジェクト生成なし)
一覧表示や集計など「値だけあれば十分」な場面では、pluck のようにオブジェクトを生成しない方法が軽量となる。
📊 大量データを全カラムでロードするとどれくらいメモリを使うか
例として、次のような汎用的な users テーブルを考える。
| カラム | 型 |
|---|---|
id |
bigint |
name |
string |
email |
string |
encrypted_password |
string |
age |
integer |
bio |
text(自己紹介などの長文) |
settings |
json(設定の入れ子データ) |
created_at / updated_at
|
datetime |
📊 DB の行サイズ ≠ Ruby のメモリ消費
User.all 自体は ActiveRecord::Relation を返すだけだが(詳しくはセクション2で後述)、これを列挙すると全カラムを 1 行ごとに読み込み、各行をモデルインスタンスへ変換する。このとき注意したいのは、DB 上の行のバイト数と、Ruby オブジェクトとして消費するメモリは別物だという点。1 行のロードで生成されるのは 1 オブジェクトではなく、行ごとの属性管理に加えて列ごとの属性オブジェクトが伴うため、Ruby 側のメモリは DB の行サイズより大きくなりやすい。
📊 件数ごとのメモリ消費の目安
フルのモデルインスタンス 1 件あたりのメモリを、短い列だけなら約 2KB、長い bio/settings を含むと平均約 12KB と仮定すると、件数に応じておおよそ次の規模になる。
| 件数 | 短い列のみ(≒2KB/件) | 長い text/json 含む(≒12KB/件) |
|---|---|---|
| 1,000 件 | 約 2MB | 約 12MB |
| 10,000 件 | 約 20MB | 約 120MB |
| 100,000 件 | 約 200MB | 約 1.2GB |
長い text/json 列を含むと、10 万件で 1GB を超える規模になり得る。json 列は Rails 側で Hash/Array にデシリアライズされると、元の JSON 文字列よりさらにメモリ上で膨らむこともある。
この数値はあくまで「ロード時に Ruby 側で確保される量のざっくりした桁感」で、memory_profiler の allocated・Ruby のヒープ・実プロセスの RSS 増加量と一致するものではない。実際の量は Ruby/Rails のバージョン、列数、値の長さ、TEXT/JSON の有無、GC(ガベージコレクション。不要になったメモリを自動回収する仕組み)の状態に大きく左右される。正確に知りたい場合は memory_profiler などで計測する。
💡 補足: 「全カラム取得」は ActiveRecord 固有ではない
「全カラムを取得してしまう」のは ActiveRecord 固有の欠点ではなく、行をモデル/結果オブジェクトにマップする ORM(Object-Relational Mapping。DB のレコードとプログラムのオブジェクトを対応づける仕組み)では、デフォルトが全(スカラー)カラムになりがちだ。何を取得するかをコード上どう指定するかが言語・ライブラリで異なる。
// Go の手書き SQL(sqlx。database/sql の薄い拡張)
// SQL を自分で書くので取得カラムは書いた通り。「デフォルト」という概念が無い
var users []User
db.Select(&users, "SELECT id, name FROM users") // id, name のみ
# Django(Python)
User.objects.all() # 全カラム + モデルインスタンス
User.objects.values("id", "name") # id, name のみ(dict の QuerySet)
# only / defer はモデルインスタンスを維持したまま部分取得(未取得フィールド参照で追加クエリ)
2️⃣ オブジェクトがメモリに展開されるタイミング(遅延評価)
ActiveRecord::Relation は遅延評価される。where や order などをつなげただけでは SQL は発行されない。
relation = User.where(active: true).order(:id) # ここではまだ SQL を発行しない
実際に SQL が発行されるタイミングは、おおまかに次の通り。
- 🔵 SQL を発行し、モデルオブジェクトを生成する:
to_a/each/mapなどで列挙したとき、load/first/last/findなど - 🟢 SQL は発行するが、モデルオブジェクトは生成しない:
pluck/ids/count/sumなどの集計、exists?
🔄 大量データにはバッチ処理
大量データを扱うときは、全件を一度にメモリへ展開する all.each ではなく、バッチ処理を使うとメモリ消費を抑えられる。
User.find_each(batch_size: 1000) do |user|
# 1000 件ずつ読み込んで処理する
end
find_each / find_in_batches / in_batches はデフォルトで 1000 件ずつ処理する。
🔁 バッチ処理3メソッドの違い: ブロックに渡されるものが異なる
3つともデフォルトで 1000 件ずつ取得する点は同じだが、ブロックに渡される単位が異なる。
- 1️⃣
find_each: レコードを 1 件ずつ 渡す。1 件ごとの処理を書きたいときに使う。 - 📚
find_in_batches: レコードの 配列(バッチ単位) を渡す。バッチ単位でまとめて処理したいときに使う。 - 🔗
in_batches: バッチをActiveRecord::Relationとして渡す。デフォルトでload: false(モデルインスタンス化しない)のため、update_all/delete_allのようなバッチ単位の一括操作に向く。
バッチサイズを指定するオプション名も異なり、find_each / find_in_batches は batch_size:、in_batches は of: を使う(いずれもデフォルト 1000)。
User.find_each { |user| puts user.name } # user は 1 レコード
User.find_in_batches { |users| puts users.size } # users は配列(最大 1000 件)
User.in_batches(of: 1000) { |relation| relation.update_all(active: true) } # relation は Relation
3️⃣ パフォーマンスで気を付ける MySQL データ型
SELECT * で全カラムを取得する性質上、サイズの大きいカラムはメモリ・転送量の両面で負担になる。特に次の型に注意したい。
- 📄
TEXT/BLOB(MEDIUMTEXT/LONGTEXT含む) - 📏 大きい
VARCHAR - 🧩
JSON
MySQL の標準的な保存方式では、データを一定サイズの固まり(ページ)単位で扱う。TEXT や大きい VARCHAR のように長くなりがちなカラムは、行の本体とは別の場所に分けて保存されることがある。その場合、SELECT * でそれらまで取得すると、本体とは別の読み込みが追加で発生し得る。また JSON は読み込み時に Ruby のオブジェクトへ変換(デシリアライズ)するコストもかかる。
🛡️ 対策
対策としては、まず select / pluck で必要なカラムだけを取得するのが基本。
ignored_columns でモデル単位にカラムを SELECT から除外する方法もあるが、本来はカラム削除前の段階的移行のための機能で、属性アクセサも消えるため、性能目的の常用は避けるのが無難だ。
設計段階では、整数型は収まる範囲で最小の型(TINYINT / SMALLINT / INT / BIGINT)を選ぶ、VARCHAR の桁数を過大にしない(ソートや一時テーブルで宣言長分のメモリを確保しうる)といった点も効いてくる。
✅ まとめ
- 🗂️ ActiveRecord はデフォルトで
SELECT *を発行し全カラムをオブジェクト化するため、必要なカラムだけをselect/pluckで取得するとメモリを抑えられる - ⏳
Relationは遅延評価され、列挙やfindなどのタイミングで SQL 発行とオブジェクト生成が起きる。大量データはバッチ処理で扱う - 🧬
TEXT/BLOB/ 大きいVARCHAR/JSONは読み込みコストが大きい。取得カラムの絞り込みと適切な型選択が効く
📚 参考文献
この記事は以下の情報を参考にして執筆しました。
- Active Record クエリインターフェイス - Rails ガイド
- ActiveRecord::ModelSchema::ClassMethods (ignored_columns) - Rails API
- The BLOB and TEXT Types - MySQL 8.0 Reference Manual
- Data Type Storage Requirements - MySQL 8.0 Reference Manual
- QuerySet API reference (values / only / defer) - Django Documentation
- Illustrated guide to SQLX