Tech Racho エンジニアの「?」を「!」に。
  • 開発

RailsのJSON生成をPostgreSQLのJSON関数で高速化(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

参考: JSON関数と演算子 -- 本記事で扱われている関数が掲載されています

RailsのJSON生成をPostgreSQLのJSON関数で高速化(翻訳)

RailsでのJSON生成方法は多種多様です。#to_jsonメソッドはRailsに組み込まれていますし、jbuildergemやactive_model_serializers gemも同じ目的に使えます。

データベースのレコード数が増加するに連れて、Railsのレスポンス生成時間が大きく増大します。このボトルネックを追うと一般にJSON生成部分にたどり着きます。

最近私たちのアプリで、ページ読み込みに異常に時間がかかる問題が発生しました。そのページはサイトで最も訪問数が多いので、この読み込み時間は重大です。そのページは、あるレースとその出場レーサーや詳細なラップタイムを読み込みます。

レーサーが10〜15人でラップが30〜50程度の短距離レースなら問題ありませんでしたが、レーサーが50〜80人でラップが700〜800の耐久レースともなると、読み込み時間のボトルネックが顕在化しました。

JSON生成のベンチマークを取ったところ、バックエンドが容疑者であることが判明しました。

この問題の修正方法を調べているうちに、PostgreSQLのJSON関数を見つけました。

JSON生成はPostgreSQL 9.2以降で組み込みとしてサポートされており、row_to_json関数やarray_to_json関数を用います。この2つをもう少し詳しく見てみましょう。

row_to_json

row_to_jsonは、各行をJSONオブジェクトとして返します。

select row_to_json(laps) from laps;
{"id":1,
 "number":1,
 "position":4,
 "time":"628.744",
 "flag_type":"Green"
}
.
.
.

サブクエリを用いて、必要な属性やカラムだけをフェッチすることもできます。

select row_to_json(lap)
from (
  select id, number, position, time, flag_type from laps
) lap;
{"id":1,"number":1,"position":4,"time":"628.744","flag_type":"Green"}
{"id":2,"number":2,"position":4,"time":"614.424","flag_type":"Green"}
.
.
.

array_to_json

array_to_jsonを理解するには、最初にarray_aggという集約関数を調べなければなりません。集約関数は、入力値のセットから単一の結果を算出するもので、summinmaxも集約関数です。array_aggはすべての入力値を結合してPostgreSQLのarrayにまとめます。

select array_agg(lap)
from (
  select id, number, position, time, flag_type from laps
) lap;
{"(1,1,4,\"628.744\",\"Green\")","(2,2,4,\"614.424\",\"Green\")", ... }

PostgreSQLのarrayをISONに変換するには、array_to_json関数を次のように用います。

select array_to_json(array_agg(lap))
from (
  select id, number, position, time, flag_type from laps
) lap;
[{"id":1,
  "number":1,
  "position":4,
  "time":"628.744",
  "flag_type":"Green"},
  ...]

より複雑な例

上の2つの関数を組み合わせてカスタムJSONレスポンスを生成できます。

select row_to_json(u)
from (
  select first_name, last_name,
    (
      select array_to_json(array_agg(b))
      from (
        select number, position, time, flag_type
        from laps
        inner join racer_laps
        on laps.id = racer_laps.lap_id
        where racer_laps.racer_id = racers.id
      ) b
    ) as laps
  from racers
  where first_name = 'Jack'
) u;
{
  "first_name": "Jack",
  "last_name": "Altenwerth",
  "laps": [
    {
      "number": 1,
      "position": 4,
      "time": "628.744",
      "flag_type": "Green"
    },
    {
      "number": 2,
      "position": 4,
      "time": "614.424",
      "flag_type": "Green"
    },
    ...
  ]
}

関数をRailsで使う

上述の関数をRailsで使うには以下のようにします。

query = <<~EOQ
select row_to_json(u)
from (
  select first_name, last_name,
    (
      select array_to_json(array_agg(b))
      from (
        select number, position, time, flag_type
        from laps
        inner join racer_laps
        on laps.id = racer_laps.lap_id
        where racer_laps.racer_id = racers.id
      ) b
    ) as laps
  from racers
  where first_name = 'Jack'
) u;
EOQ

generated_json = ActiveRecord::Base.connection.execute(query).values;
puts generated_json
{
  "first_name": "Jack",
  "last_name": "Altenwerth",
  "laps": [
    {
      "number": 1,
      "position": 4,
      "time": "628.744",
      "flag_type": "Green"
    },
    {
      "number": 2,
      "position": 4,
      "time": "614.424",
      "flag_type": "Green"
    },
    ...
  ]
}

上の方法でJSONを生成すると、Railsでの通常のJSON方法に比べて冗長で読みづらくなりますが、その代り高速です。

導入の結果

レーサーのページでPostgreSQL関数を用いてJSONを生成したところ、以下の改善が見られました。

短距離レース(レーサーが10〜15人でラップが30〜50程度)の場合、APIの平均レスポンスタイムは40msから15msに短縮されました。

耐久レース(レーサーが50〜80人でラップが700〜800)の場合、APIの平均レスポンスタイムは1200msから20msと大きく短縮されました。

まとめ

問題がなければ、Rails wayでJSONを生成しましょう。パフォーマンスが問題視されるようになったら、データベースで使えるこの機能をためらわずに使いましょう。この場合パフォーマンスと複雑さのトレードオフになりますが、時にはトレードオフの価値があるものです。

関連記事

PostgreSQL 10の使って嬉しい5つの機能(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。