`insert_all` でシリアライズ済み値のコーダー往復処理をスキップする最適化
PR #48139 で導入されたリグレッションにより、シリアライズ列を持つテーブルへの insert_all が生SQLの約2倍遅くなっていた問題を修正します。insert_all 内部にホットパスを追加し、値がすでにシリアライズ済みの場合は cast をスキップすることで、余分な serialize/deserialize ラウンドトリップを回避します。
背景
今回の修正は、#48139 が導入した cast 呼び出しの副作用として生じたパフォーマンスリグレッションへの対応です。
#48139 は Book.create!(name: ["Array"]) と Book.insert!({ name: ["Array"] }) でデータベースへの保存値が異なるという一貫性の問題を修正しました。insert_all が cast ステップを省略していたため、配列値の正規化が行われず、create とは異なるシリアライズ結果になっていたのが原因です。修正として insert_all の値処理パスに type.cast(value) を追加しましたが、これがシリアライズ型に対して深刻なパフォーマンス問題を引き起こしました。
ActiveModel::Type::Helpers::Mutable が定義する cast(value) は deserialize(serialize(value)) として実装されており、ダーティトラッキングのためにメモリ上の値を正規化する目的で使用されます。Serialized 型はこの Mutable をインクルードしているため、cast を呼び出すたびに YAML.dump → YAML.load(またはJSONに相当する処理)のフルラウンドトリップが発生します。insert_all では渡される値がすでに Hash や Array であることが多く、このコーダー往復は完全に無駄なオーバーヘッドとなっていました。
PR本文のベンチマーク結果では、insert_all!(cast あり)が生SQLに対して約2倍遅いことが示されています。一方、cast をスキップした場合や後述のホットパスを適用した場合は生SQLとほぼ同等の速度になっています。
技術的な変更
activerecord/lib/active_record/insert_all.rb の Builder#values_list メソッドに1行の条件分岐を追加し、シリアライズ型の値に対する cast を選択的にスキップします。
変更前:
ActiveModel::Type::SerializeCastValue.serialize(type = types[key], type.cast(value))
変更後:
type = types[key]
value = type.cast(value) unless type.serialized?
ActiveModel::Type::SerializeCastValue.serialize(type, value)
type.serialized? が true の場合(YAML・JSONなどのコーダーを持つシリアライズ型)、cast を呼び出さずに直接 ActiveModel::Type::SerializeCastValue.serialize に値を渡します。serialized? が false の通常の型(String・Integer など)は従来通り cast を経由します。シリアライズ型はコーダーを持っており、SerializeCastValue.serialize がそのコーダーを使って値をデータベース向けの形式に変換するため、事前の cast による正規化は不要です。
テスト面では activerecord/test/cases/serialized_attribute_test.rb に test_serialized_json_column_direct_attribute_assignment が追加されました。このテストは、insert_all をスキップした場合に懸念されたリグレッション(属性への直接代入後に { a: :b } がDBから { "a" => "b" } として戻ってくる問題)が通常の属性代入では発生しないことを確認しています。今回の変更が insert_all 内部に局所化されており、属性代入のパスに影響しないことをテストで保証しています。
設計判断
insert_all のパス内に 局所化されたホットパス を追加する方式が採用されました。
PR本文では代替案として Serialized 型の cast メソッドをオーバーライドして Hash または Array の場合に短絡する方法が検討されました。しかしこの案では属性代入(Book.content = { a: :b })でも cast がスキップされてしまい、DBへの保存前後で値の表現が変化するという別の問題が生じたため却下されています。
採用された解決策は変更を insert_all の値処理パスのみに限定することで、属性代入や create など他の操作への影響を完全に排除しています。serialized? という既存のAPIを活用した判定により、コーダーの有無を直接確認できるため、型の種類による場合分けを追加する必要もありません。
まとめ
わずか3行の変更でありながら、シリアライズ列を持つテーブルへの insert_all の性能を cast あり比で約2倍改善し、生SQLとほぼ同等の速度に回復させます。問題の修正範囲を insert_all の値処理パスに厳密に限定することで、#48139 が解決した型一貫性の保証を損なわずに、パフォーマンスリグレッションを解消する設計となっています。