あかね
N+1とOOMの間にある「???」を埋めてみた
2026年05月30日
見出しはありません
要約を生成中...
先日、勉強会でCSの話がありました。
「システム設計の基礎だから完全理解して欲しい」とのこと。
基礎って言いつつ同時に「これがわかればシニアレベル」って言われて「基礎ってエンジニアならジュニアでもわかれって意味やないん?」ってまた引っかかりつつ(何も言ってない)w
線形代数 基礎(👹)的な基礎かなって解釈しましたw
最近細かいことを突っ込みすぎて「茜さんのお小言」って言われるようになったw
しかし勉強会してくれるのありがたすぎる。
OOM(Out Of Memory)が発生する原因の例として、
N+1
が挙げられました。私はその瞬間いつもの「え???」となりました。
私の中でN+1って、
SELECTなどのクエリが大量に発行される
DBとの通信回数が増える
レスポンスが遅くなる
という認識だったからです。
だから、N+1でOOMになると言われても、正直ピンと来ませんでした。
クエリ回数が増えて遅くなるのは分かるけど結果取得できるデータは同じですよね?なんで突然メモリの話になるんだろう、、、?時間がかかるのはわかるけど、、、なんで、、と。
そこから今回の調査が始まりました。
即突っ込んだんですが説明を聞いていると、
SELECTが何回も発行されて〜
という話でした。それを聞いて私は、「うん、それは知ってる」と思いました。
だってその説明って、
SELECT増える
↓
DBとの通信回数増える
↓
遅くなるの説明ですよね。ってことはこれはメモリの話やないやん、、、私が知りたかったのは、
SELECT増える
↓
???
↓
メモリ増える
↓
OOMの部分でした。なので途中で、
クエリ回数が増えるから問題なのは分かるんですけど、最終的に取得できるデータがメモリに乗るしそれってN+1でもN+1やなくても同じじやないですか?
と聞きました。(確かにって言ってた🥹)
例えば、N+1の
users = User.all
users.each do |user|
puts user.logs.count
endも、N+1ではない
users = User.includes(:logs)
users.each do |user|
puts user.logs.count
endこれも欲しい結果自体は同じです。だから私の頭の中では
N+1
↓
クエリ増加
↓
遅いまでは繋がる。でも
N+1
↓
OOMには直接繋がらなかったんです。
調べていくうちに見えてきたのは、「N+1だからOOMになる」のではなく、「N+1の実装によってはOOMになる」ということでした。
users = User.all
users.each do |user|
puts user.logs.count
endこれは典型的なN+1です。
User取得 1回
logs.count がユーザー数分
つまりクエリは大量発行されます。
ただし返ってくるのは数値です。
もちろん、
User.allの時点でUserオブジェクトは全件メモリ上に保持されています。
そのためメモリ使用量がゼロというわけではありません。
ただ、このケースではログのActive Recordオブジェクトを大量生成しているわけではなく、取得しているのはカウント結果だけです。
そのため、
DBは苦しい
レスポンスは遅い
一方で、ログレコードを大量にオブジェクト化するケースと比較すると、追加のメモリ消費は比較的小さくなります。
users = User.all
users.each do |user|
user.logs.to_a.each do |log|
puts log.action
end
endこれもN+1です。ただし今度は、
user.logs.to_aによってログレコードがActive Recordオブジェクトとして大量生成されます。
例えば、
User 10万件
1ユーザーあたり平均100件のLog
だった場合、最大で1000万件のLogオブジェクトが生成される可能性があります。
.to_a を書かなくても、単に user.logs.each とループを回すだけで同じように全件オブジェクト化されてしまうので要注意。つまり、Relationではなくなったタイミングです。
実際にはGCによって不要なオブジェクトは回収されます。
ただし、処理対象の件数が非常に多い場合や、オブジェクト生成速度に対してGCが追いつかない場合は、一時的に大量のメモリを消費しOOMの原因になることがあります。
生成する ≠ 保持している
users = User.includes(:logs)これで全件取得した場合。N+1は発生していません。
それでも、
Userが数十万件
Logが数千万件
みたいな状況ならメモリの大量にデータが載るので普通にOOMします。
つまり、OOMの原因はN+1そのものではありません。
例として全件includesしているけど、普通はwhereとか使って全件ロードはしないように絞り込むかと。
今回調べていて一番腹落ちしたのはここでした。
クエリ回数増加
↓
DB負荷増加
↓
レスポンス悪化大量オブジェクト生成
↓
大量オブジェクト保持
↓
メモリ不足
↓
OOMN+1がOOMの原因になることはあります。
でもそれはN+1だからではなく、N+1な実装の中で大量オブジェクトを生成しているからです。
最初はActive Record特有、あるいはRails特有の話だと思っていました。
でも考えてみると、TypeScriptのORMであるPrismaでも同じです。
const users = await prisma.user.findMany()
for (const user of users) {
const logs = await prisma.log.findMany({
where: { userId: user.id }
})
process(logs)
}大量データを取得し、大量のオブジェクトを生成するわけです。
これをやればRailsに限らずOOMになります。
つまり本質は、Active RecordなのかPrismaなのかではなく最終的に欲しいデータを取得するまでの間に「どれだけのオブジェクトを生成するか」でした。
返り値しか見てなかったけど、メモリは途中で生成されるオブジェクト量の影響も受ける
私は最初、CS基礎の話というのを前提とした頭だったので、N+1はクエリ回数の問題(IOの方の問題)として覚えていました。
一方で説明していた側は、実運用で起きるメモリ問題まで含めて話していたのだと思います。
だから噛み合わなかったというわけですね〜
今回みたいに掘っていくと、実は言葉の裏にある前提が共有されていなかっただけということが結構あります。
そして今回の結論は、
N+1がOOMを起こすのではない。
N+1の実装によってはOOMを起こす。
でした。「なんか違和感あるな」と思ったら流さずに掘ってみるのはやっぱ大事ですね〜
今回も、その違和感のおかげで理解が一段深まりました。
結構話してて噛み合わないって感じる時って対象のレイヤーが違うこと多々あるんですよね。
噛み合わないし伝わらなくて結構ストレスなんですが、途中で話してるレイヤーが変わってる(あるいは行ったり来たりしている)ことは少なくないので、今なんの話してるか??は結構考えなきゃアンジャッシュのコントしてるみたいやわって思います。
コメント
まだコメントはありません。