この記事はリサーチ・アンド・イノベーション 開発者ブログ のアドベントカレンダーに掲載した記事を一部手直ししたものです。
三浦半島の付け根に住んでいる私ですが、今年の2月から三浦半島.rbという地域Rubyコミュニティが立ち上がり、そちらによくお邪魔しています。 だいたい二ヶ月に一度くらいの頻度で集まっているので、お近くにお住まいの方は是非顔を出してみて下さい。
ここから本題です。 表題の障害が発生したサービスは、WebアプリケーションのバックエンドAPIで、他社さんと協業で運営しています。 今回はこのサービスに起きた障害を紹介し、原因究明までお話ししたいと思います。
9月からの期間限定キャンペーンが始まった瞬間、レスポンスが急激に悪化。 タイムアウトエラーが頻発し始めました。 メトリクスを見るとDB(Aurora Serverless)のCPUが100%に張り付いています。
とりあえずACU(CPUユニット)の上限を上げましたが、すぐ再び100%に張り付いてしまいます。
AWSのperformance insightで確かめると、なんかすごいクエリがCPU資源を大量に消費していました。
SELECT DISTINCTROW `aaaa` . `id` FROM `aaaa`
LEFT OUTER JOIN `bbbb` ON `bbbb` . `aaaa_id` = `aaaa` . `id`
LEFT OUTER JOIN `cccc` ON `cccc` . `id` = `bbbb` . `cccc_id`
LEFT OUTER JOIN `dddd` ON `dddd` . `aaaa_id` = `aaaa` . `id`
LEFT OUTER JOIN `eeee` ON `eeee` . `id` = `ffff` . `eeee_id`
WHERE ( `start_at` < ? AND `end_at` > ? ) LIMIT ? OFFSET ?
????
とりあえずexplainしてみます。
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: aaaa
partitions: NULL
type: range
possible_keys: PRIMARY,index_aaaa_on_col,index_aaaa_on_end_at,index_aaaa_on_start_at
key: index_aaaa_on_end_at
key_len: 8
ref: NULL
rows: 1
filtered: 100.00
Extra: Using index condition; Using where; Using temporary
*************************** 2. row ***************************
以下略
Using temporaryで一時テーブルを作成しているのが問題? ただし、datadogの指標では残メモリは十分にあるように見えます。
原因不明のまま、いったんキャンペーンを止めたところ治まりました。 -> 根本原因は不明だが、問題はキャンペーンにありそう。
このへんでもう店頭にPOP出しちゃってます等の情報が入って胃がキリキリ痛む
とりあえず社内で一番データベースに詳しい人を呼んで意見を聞いたところ、「Left JoinしているのにJoinしたカラムでWhereしていないのはおかしい」と言われました。 た、確かに。流石です。
原因をかみ砕くと、LEFT JOINしているのにそのテーブルの条件でフィルタリングしていないため、全件を含んだtemporary tableが作られ、それをDISTINCTするために大量にCPUを使っていると考えられます。 ActiveRecordのようなO-Rマッパーを使っているのでなければ起こりえない事象です。
まずは該当する部分のRailsのコードを見てみます。
class ModelA < ApplicationRecord
def self.search(param_d: nil, param_b: nil, ongoing: false, extra_search: false)
results = eager_load(:model_b, model_c: :model_d)
results = results.merge(ModelC.merge(ModelD.where(param_d:))) if param_d.present?
results = ongoing ? results.ongoing : results.in_period
return results unless extra_search
return results.where.missing(:model_b) if param_b.blank?
results = results.where(another_conditions)
# snip
end
end
Builderパターンですね。Builder好き。 ちなみに書いた人は自分です。(誰のせいにもできない)
(良い本だけど絶版・・・英語では第二版が出ている)
(-<: has_many)
ActiveRecordのjoin系メソッドは3種類あります。
テーブルごとにクエリを作り、外部キー(xxx_id)によってid IN条件で検索します。
例: UserモデルからPropertyモデルがhas_manyされているとして
User.preload(:properties).where(login_at: 1.day.ago..8.days.ago)
SELECT * FROM users WHERE login_at BETWEEN '2025-09-11 00:00:00 +09:00' AND '2025-09-18 23:59:59 +09:00'
SELECT * FROM properties WHERE id in (xxxx, xxx, xxx, ...)
最も単純ですが以下のデメリットがあります。
予めLEFT OUTER JOINでテーブルを結合し、1つのクエリで検索する
例: UserモデルからPropertyモデルがhas_manyされているとして
User.preload(:eager_load).where(login_at: 1.day.ago..8.days.ago)
SELECT properties.id AS t0_r0, users.id AS t1_r0 FROM users LEFT OUTER JOIN properties ON properties.user_id = users.id WHERE users.login_at BETWEEN '2025-09-11 00:00:00 +09:00' AND '2025-09-18 23:59:59 +09:00'
クエリが一度で済むので高速。 デメリットはさっき見た通り、検索条件があろうとあるまいとテーブルがLEFT OUTER JOINされます。
デフォルトだとpreloadと同じ挙動。 関連先テーブルの条件で絞り込むとeager_loadになる。 なお今回のように「関連先の関連先」の条件で絞り込む場合、予めjoinsで結合しておかないとうまく動作しない。
検索条件のある時だけeager_loadするように変更しました。
class ModelA < ApplicationRecord
def self.search(param_d: nil, param_b: nil, ongoing: false, extra_search: false)
raise 'must be specify param_d when extra_search is true.' if param_d.blank? && extra_search
results = if param_d.present? && extra_search
extra_search(param_d:, param_b:)
elsif param_d.present?
eager_load(model_b, model_c: model_d).merge(ModelC.merge(ModelD.where(param_d:)))
else
includes(:model_c)
end
ongoing ? results.ongoing : results.in_grace_period
end
end
修正をリリースしたところ、キャンペーンを再開してもCPUは正常のままになりました。
個人的には今年経験したうちで最も学びの多いトラブルでした。
なお、障害発生から数ヶ月後に参加したKaigi on Rails 2025で近い事象の発表がありました。 (preloadでメモリを爆発させてしまったという話)
録画も公開されているので、関心があればぜひ見て下さい。