作成経緯と対象読者
実務でprismaによるメモリリークの原因調査を行なっていて、glibcについて詳しく調べたので、勉強の記録として書いておこうと思いました
論文の書き方本も最近読んだので、それのアウトプットの練習も踏まえてます
本記事はglibcのメモリ管理方法について理解した上でなぜメモリが高止まるのかについてメインに言及しています
なのでkyselyの導入方法について知りたい方はhttps://zenn.dev/randd_inc/articles/b8e009b74863abを参照した方が早いです
kyselyを導入する意思決定の論拠を得たい方、glibcのメモリ管理のロジックについて知りたい方向けに書いてます
アブストラクト
Docker環境でPrisma ORMを使用したNode.jsアプリケーションにおいて、同時接続数30・ペイロード約2MBのクエリを実行した際、コンテナの物理メモリ使用量(RSS)が130MBから2.0GBまで上昇し、その後1.5GBで高止まりする問題が発生した。
V8ヒープダンプおよびprocess.memoryUsage()の分析により、この問題はV8管理のJavaScriptヒープではなく、PrismaのRust製queryEngineが使用するglibc mallocに起因することを特定した。
glibc mallocは、マルチスレッド環境でのロック競合を回避するためスレッドごとに独立したアリーナ(メモリプール)を割り当てる設計を採用している。Prismaのtokio非同期ランタイムが複数のワーカースレッドを生成することで、多数のアリーナが作成され、各アリーナ内でfreeされたメモリチャンクがbins(フリーリスト)にキャッシュされ続けることで断片化が発生し、OSへのメモリ返却が阻害されていた。
本問題の解決策として、重いクエリ箇所のみをprisma-kyselyを用いてKyselyに差し替えた。KyselyはPure TypeScript実装であり、Node.jsのイベントループ上でシングルスレッド(ほぼメインスレッドのみ)で動作するため、glibc mallocのmain_arenaのみを使用し、マルチアリーナによる断片化を回避できる。局所的な差し替えにより、メモリ高止まり問題の解消を実現した。
目次
- はじめに - 問題の背景
- 問題の切り分け - V8 vs queryEngine
- glibc malloc設計思想
- Prismaアーキテクチャとの相互作用
- 検証 - malloc_infoによる断片化確認
- 解決策 - Kyselyへの局所的移行
- 効果検証
- 結論
- 参考文献
1. はじめに - 問題の背景
1.1 発生環境
本問題は以下の環境で発生した:
| ライブラリ | バージョン | 備考 |
|---|---|---|
| Prisma | 6.19.0 | Rust製queryEngine使用 |
| Node.js | 20.17.0 | Debian bookworm |
| Kysely | 0.28.8 | Pure TypeScript SQL Builder |
| prisma-kysely | 2.2.1 | Prismaスキーマから型生成 |
| pg | 8.16.3 | PostgreSQLドライバ |
1.2 現象の詳細
大量データを取得するAPIエンドポイントにおいて、以下の現象が観察された:
- 同時接続数: 30
- 取得ペイロード: 約2MB
- メモリ推移: 130MB → 2.0GB → 1.5GBで高止まり
- 特徴: リクエスト完了後、データをクライアントに返却してもRSSが下がらない
実測データ: プロセスメモリ状況
/proc/[pid]/statusから取得した実測値:
| 項目 | Before (起動時) | After (クエリ後) | 増加量 |
|---|---|---|---|
| VmRSS | 137 MB | 978 MB | +841 MB |
| RssAnon | 80 MB | 912 MB | +832 MB |
| VmHWM (ピーク) | 154 MB | 3,913 MB | - |
| Threads | 22 | 23 | +1 |
process.memoryUsage()の出力例 (メモリ高止まり時):
{
"memoryUsage": {
"rss": 37343232, // ~35MB - process.memoryUsage()は不正確
"heapTotal": 4227072, // ~4MB - V8ヒープは正常
"heapUsed": 3031136, // ~3MB - 正常
"external": 1153642, // ~1MB - 正常
"arrayBuffers": 10515 // 微小
},
"heapStats": {
"total_heap_size": 4489216,
"used_heap_size": 3822800,
"heap_size_limit": 4345298944
}
}
重要な発見: V8のheapUsed(約3MB)は正常値であるが、/proc/[pid]/statusのRssAnon(912MB)は異常に高い。この832MBの乖離がNative Memory(Prisma queryEngine)に起因することを示している。
2. 問題の切り分け - V8 vs queryEngine
2.1 Node.jsプロセスのメモリ構造
Node.jsプロセスのメモリは、大きく2つの領域に分かれる:
| 領域 | 管理者 | 計測方法 | 本件での状態 |
|---|---|---|---|
| V8 Managed Heap | V8 GC | process.memoryUsage().heapUsed |
正常 |
| Native Memory | glibc malloc | rss - heapTotal |
高止まり |
根拠: Node.js公式ドキュメント - process.memoryUsage()
https://nodejs.org/api/process.html#processmemoryusage
2.2 検証方法
V8ヒープダンプを取得し、メモリリークの原因がV8側にないことを確認した。
実測データ: V8 heapSpaces分析
"heapSpaces": [
{ "space_name": "new_space", "space_used_size": 486240 }, // ~475KB
{ "space_name": "old_space", "space_used_size": 2851976 }, // ~2.7MB
{ "space_name": "code_space", "space_used_size": 245744 }, // ~240KB
{ "space_name": "large_object_space", "space_used_size": 262160 } // ~256KB
]
// 合計: 約3.8MB → V8ヒープに異常なし
結論: V8が管理するJavaScriptヒープ(約4MB)には問題がなく、メモリ高止まりの原因はNative Memory領域にある。
2.3 仮説の設定
上記の検証結果から、以下の仮説を設定した:
- 問題の所在: Prisma queryEngine(Rust/tokio)が使用するNative Memory
- 原因の推定: glibc mallocのマルチスレッド対応設計によるアリーナ断片化
- 検証方法: malloc_info()によるアリーナ状態の確認
3. glibc malloc設計思想
本セクションでは、問題の根本原因を理解するために、glibc mallocの設計思想を詳細に解説する。
3.1 設計目標 - 時間効率の優先
glibc mallocは、Doug Leaによるdlmallocをベースに、Wolfram Glogerがマルチスレッド対応を追加したptmalloc2を採用している。
根拠URL:
- glibc malloc.c ソースコード: https://sourceware.org/git/?p=glibc.git;a=blob;f=malloc/malloc.c
- Understanding glibc malloc: https://sploitfun.wordpress.com/2015/02/10/understanding-glibc-malloc/
引用: malloc.cソースコードコメント
"Bins are approximately proportionally (log) spaced. There are a lot of these bins (128). This may look excessive, but works very well in practice."
訳: 「Binsはおおよそ対数的に間隔が空けられている。多くのbin(128個)がある。これは過剰に見えるかもしれないが、実際には非常にうまく機能する。」
設計思想の3つの柱
| 設計原則 | 説明 | トレードオフ |
|---|---|---|
| 再利用優先 | freeされたメモリは即座にOSに返却せず、binsにキャッシュして再利用 | メモリ使用量 vs 割り当て速度 |
| O(1)操作 | best-fitではなくapproximate best-fitでO(1)に近い速度を実現 | メモリ効率 vs 時間効率 |
| ロック競合回避 | スレッドごとにアリーナを分離 | メモリ使用量 vs スケーラビリティ |
3.2 Bins構造 - なぜfreeしたメモリをキャッシュするのか
glibc mallocは、free()されたメモリチャンクを即座にOSに返却せず、サイズ別の「bins」と呼ばれるフリーリストにキャッシュする。これは時間効率を優先した設計思想に基づく。
根拠URL:
- Azeria Labs Heap Exploitation Part 2: https://azeria-labs.com/heap-exploitation-part-2-glibc-heap-free-bins/
- openEuler glibc解析: https://www.openeuler.org/en/blog/wangshuo/Glibc_Malloc_Source_Code_Analysis_(1).html
- glibc公式ソースコード: https://codebrowser.dev/glibc/glibc/malloc/malloc.c.html
なぜキャッシュするのか?
free()のたびにOSにメモリを返却すると、以下のコストが発生する:
-
システムコールのオーバーヘッド:
munmap()やmadvise()はカーネルコンテキストスイッチを伴う - 次回mallocの遅延: 再度OSからメモリを取得する必要がある
設計上の選択: 「OSへの返却コスト」よりも「再利用の高速性」を優先し、freeされたチャンクをbinsにキャッシュして次回のmallocで即座に再利用する。
各Binの特性
| Bin種別 | サイズ範囲 | 結合 | OSへの返却 | 特徴 |
|---|---|---|---|---|
| tcache | 24-1032B | なし | なし | スレッドローカル、ロック不要、最高速 |
| fastbins | 32-160B | なし | なし | 単方向LIFO、inuseビット維持 |
| smallbins | 32-1008B | あり | なし | 双方向FIFO、ページ境界未満 |
| largebins | 1024B以上 | あり | 部分的 | サイズ順ソート、ページ境界部分のみ |
| unsorted bin | 全サイズ | あり | なし | 一時保管所 |
重要: すべてのbinはOSへのメモリ返却を行わない(largebinsのページ境界部分を除く)。結合(consolidation)があっても、チャンクはbinに残り続ける。
3.3 結合(Consolidation)の有無と断片化
tcache / fastbins - 結合しない
glibc公式ソースコード(malloc.c)より引用:
"Chunks in fastbins keep their inuse bit set, so they cannot be consolidated with other free chunks."
訳: 「fastbin内のチャンクはinuseビットがセットされたままなので、他の解放済みチャンクと結合できない。」
図解: fastbinsが結合しない理由(AI出力)
【通常のfree(fastbin以外)】
A, B, C が連続して存在
┌──────┐┌──────┐┌──────┐
│ A ││ B ││ C │
│inuse ││inuse ││inuse │ ← 全てinuse=1
└──────┘└──────┘└──────┘
Bを解放 → inuse=0に変更
┌──────┐┌──────┐┌──────┐
│ A ││ B ││ C │
│inuse ││ free ││inuse │ ← Bはinuse=0
└──────┘└──────┘└──────┘
Aを解放 → Bと結合して大きなチャンクに
┌─────────────┐┌──────┐
│ A + B ││ C │
│ free ││inuse │ ← 結合された
└─────────────┘└──────┘
【fastbinのfree - 結合しない】
A, B, C が連続して存在(全て小さいサイズ)
┌──────┐┌──────┐┌──────┐
│ A ││ B ││ C │
│inuse ││inuse ││inuse │
└──────┘└──────┘└──────┘
Bを解放 → fastbinへ(inuseは変わらない!)
┌──────┐┌──────┐┌──────┐
│ A ││ B ││ C │
│inuse ││inuse ││inuse │ ← inuse=1のまま
└──────┘└──────┘└──────┘
↓
fastbin[idx]に登録
Aを解放 → 結合されない!
┌──────┐┌──────┐┌──────┐
│ A ││ B ││ C │
│inuse ││inuse ││inuse │ ← 両方ともinuse=1
└──────┘└──────┘└──────┘
↓ ↓
fastbin[idx]に別々に登録(断片化)
断片化の原因: tcacheとfastbinsは結合しないため、小さなチャンクが大量にfreeされても、連続した大きな空き領域にならない。
smallbins / largebins - 結合するがOSには返却しない
Azeria Labsより引用:
"The downside of fastbins, of course, is that fastbin chunks are not 'truly' freed or merged, and this would eventually cause the memory of the process to fragment and balloon over time."
訳: 「fastbinの欠点は、チャンクが『本当に』解放・結合されないため、プロセスのメモリが時間とともに断片化し膨張すること。」
smallbins/largebinsは隣接チャンクとの結合(consolidation)が行われるが、結合してもbinに残り続け、OSには返却されない。
- smallbins(32-1008B): 結合してもチャンクサイズがページサイズ(4KB)未満であり、OSに返却されない
- largebins(1024B以上): 結合してもチャンクはbinに残り、再利用待ちとなる
結論: すべてのbinsはfreeされたチャンクをキャッシュし、OSに返却しない。これがメモリ高止まりの原因となる。
3.4 freeがOSにメモリを返さない理由 - top chunkのみが返却可能
glibc mallocにおいて、free()が物理メモリをOSに返却できるのはtop chunk経由のみである。
根拠URL:
- shellphish/how2heap: https://github.com/shellphish/how2heap
- Azeria Labs Heap Exploitation: https://azeria-labs.com/heap-exploitation-part-2-glibc-heap-free-bins/
top chunkとは
アリーナ内で最も高いアドレスに位置する未使用領域。新規mallocはここから切り出され、freeされたチャンクがここに隣接すると吸収される。
アリーナのメモリレイアウト:
┌─────────────────────────────────────────────────────────┐
│ チャンク1 │ チャンク2 │ チャンク3 │ top chunk │
│ (使用中) │ (free) │ (使用中) │ (未割当領域) │
└─────────────────────────────────────────────────────────┘
↑
ここだけがOSに返却可能
free()の処理フロー
なぜbins内のチャンクはOSに返却されないのか
bins内のチャンクは以下の理由でOSに返却されない:
- tcache/fastbins: 結合されないため、小さなチャンクのまま滞留
- smallbins/largebins: 結合してもチャンクはbinに残り、再利用待ち
- top chunkに到達できない: 使用中チャンクが間にあると、top chunkに吸収されない
重要: OSへのメモリ返却はtop chunkが十分大きくなった場合のみ発生する。binsに滞留したチャンクは再利用されるまでメモリを占有し続ける。
断片化がメモリ高止まりを引き起こすメカニズム
【正常なケース】全てのチャンクがtop chunkに隣接してfreeされる
┌─────┐┌─────┐┌─────┐┌───────────────┐
│ A ││ B ││ C ││ top chunk │
│free ││free ││free ││ │
└─────┘└─────┘└─────┘└───────────────┘
↓ A, B, C がtop chunkに吸収される
┌─────────────────────────────────────┐
│ top chunk │
│ A + B + C + 元top が全て吸収 │
└─────────────────────────────────────┘
↓ heap_trim() で OSに返却可能
【断片化したケース】使用中チャンクが散在
┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐┌───────────────┐
│ A ││ X ││ B ││ Y ││ C ││ top chunk │
│free ││使用中││free ││使用中││free ││ │
└─────┘└─────┘└─────┘└─────┘└─────┘└───────────────┘
↓ A, B, C は結合できない(X, Y が邪魔)
↓ top chunkに隣接するのはCのみ
┌─────┐┌─────┐┌─────┐┌─────┐┌───────────────────────┐
│ A ││ X ││ B ││ Y ││ C + top chunk │
│bins ││使用中││bins ││使用中││ (Cが吸収された) │
└─────┘└─────┘└─────┘└─────┘└───────────────────────┘
↓ A, B はbinsに残留 → OSに返却されない
↓ メモリ高止まり発生
結論: アリーナ内で使用中チャンクが散在すると、freeされたチャンクがbinsに滞留し、top chunkに到達できない。この状態がメモリ高止まりの直接原因である。
3.5 マルチスレッドとアリーナ分離 - 断片化が増幅される理由
前節で説明した断片化は、マルチスレッド環境でさらに増幅される。glibc mallocは、ロック競合を回避するためにスレッドごとに独立したアリーナを割り当てる設計を採用しているためである。
根拠URL:
- Heroku glibc Tuning: https://devcenter.heroku.com/articles/tuning-glibc-memory-behavior
- Code Arcana Arena Leak: https://codearcana.com/posts/2016/07/11/arena-leak-in-glibc.html
引用: Code Arcana
"The glibc malloc implementation attempted to improve performance by allowing all threads to use their own arena (up to a default cap of 8 arenas per CPU core)."
訳: 「glibcのmalloc実装は、すべてのスレッドが独自のアリーナを使用できるようにすることでパフォーマンス向上を図っている(デフォルト上限はCPUコアあたり8アリーナ)。」
アリーナ作成のトリガー
kosaki55tea (glibc開発者) のスライドより:
スレッドがmalloc()を呼び出す際、以下の流れでアリーナが選択/作成される:
- 既存アリーナのロック取得を試行
- ロック取得に失敗した場合、新しいアリーナを作成
- 新アリーナはスレッドに紐付けられる
問題: ロック競合が頻発すると、アリーナ数が増加し、それぞれのアリーナで独立して断片化が進行する。
アリーナ数の上限
| アーキテクチャ | 計算式 | 8コアの場合 |
|---|---|---|
| 32bit | 2 × CPUコア数 | 16アリーナ |
| 64bit | 8 × CPUコア数 | 64アリーナ |
Docker環境での問題
Heroku Dev Centerより:
コンテナ内でも__get_nprocs()はホストのCPUコア数を返す。そのため:
- ホストが16コアの場合 → 最大128アリーナが作成可能
- 各アリーナが64MBまで拡大可能(HEAP_MAX_SIZE)
- 理論上の最大メモリ: 128 × 64MB = 8GB
結論: マルチスレッド環境では、各スレッドが独立したアリーナを持ち、それぞれで断片化が進行する。これにより、シングルスレッドの場合よりも断片化の影響が増幅される。
4. Prismaアーキテクチャとの相互作用
4.1 Prisma queryEngineの構造
Prismaは、Rust製のqueryEngineをNode-APIネイティブアドオンとしてNode.jsプロセスにロードする。
根拠URL:
- Prisma Engine Documentation: https://www.prisma.io/docs/orm/more/under-the-hood/engines
- Prisma GitHub Issue #16912: https://github.com/prisma/prisma/issues/16912
- Prisma GitHub Issue #25371: https://github.com/prisma/prisma/issues/25371
引用: Prisma GitHub Issue #16912
"The JS heap usage actually looks pretty reasonable in both cases, but the process total memory is significantly higher... This suggests the memory is likely being consumed by the Prisma Engine."
訳: 「JSヒープ使用量はどちらの場合もかなり妥当に見えるが、プロセスの総メモリは大幅に高い...これはメモリがPrismaエンジンによって消費されていることを示唆している。」
なぜPrismaで複数アリーナが生成されるか
要点: Prisma queryEngineはRust製で、内部でtokioマルチスレッドランタイムを使用する。各ワーカースレッドがmallocを呼び出すと、glibc mallocはスレッドごとに独立したアリーナを割り当てる。
結果: CPUコア数に応じて最大 8 × コア数 のアリーナが作成され、各アリーナが独立してメモリを保持するため、プロセス全体のメモリ使用量が肥大化する。
4.2 なぜPrismaで顕著に発生するのか
根拠URL:
- Svix Blog Heap Fragmentation: https://www.svix.com/blog/heap-fragmentation-in-rust-applications/
- Tokio GitHub Issue #6083: https://github.com/tokio-rs/tokio/issues/6083
Prismaで断片化が顕著になる理由は、tokioマルチスレッドランタイム + 短命オブジェクトの大量生成の組み合わせにある。
引用: Tokio GitHub Issue #6083
"Each runtime seems to exclusively occupy the memory it has allocated (and deallocated), preventing other runtimes from using that memory."
翻訳: 「各ランタイムは割り当てた(そして解放した)メモリを排他的に占有しているようで、他のランタイムがそのメモリを使用できない。」
引用: Svix Blog
"The specific cause for the fragmentation could be any number of things: JSON parsing with serde, something at the framework-level in axum, something deeper in tokio, or even just a quirk of the specific allocator implementation."
翻訳: 「断片化の具体的な原因はいくつも考えられる:serdeでのJSONパース、axumフレームワークレベルの何か、tokioの深い部分、あるいは単にアロケータ実装の癖。」
Prismaで顕著になる条件
- マルチスレッド: tokioワーカーごとに独立したアリーナが作成される
- 短命オブジェクト: クエリ結果のJSONパース(serde)で一時バッファが大量に割り当て・解放
- 長命オブジェクト: コネクションプール等がアリーナ内に残留し、top chunkへの結合を阻害
この組み合わせにより、セクション3で説明した「bins残留 → 断片化 → メモリ高止まり」が各アリーナで並行して発生し、問題が増幅される。
5. 検証 - malloc_infoによる断片化確認
5.1 検証の目的
証明したいこと: クエリ実行後、glibc mallocがOSから確保したメモリの大部分がbins(フリーリスト)に残留し、OSに返却されていないこと。
根拠URL: malloc_info man page: https://www.man7.org/linux/man-pages/man3/malloc_info.3.html
5.2 Before / After 比較
/proc/[pid]/status(プロセスメモリ)
| 項目 | Before(起動時) | After(クエリ後) | 増加量 |
|---|---|---|---|
| VmRSS | 134 MB | 955 MB | +821 MB |
| RssAnon | 78 MB | 891 MB | +813 MB |
| VmHWM(ピーク) | 151 MB | 3,821 MB | - |
malloc_info() サマリー
| 項目 | Before(起動時) | After(クエリ後) | 増加量 |
|---|---|---|---|
| アリーナ数 | 21 | 39 | +18 |
| system(確保量) | 23.4 MB | 845 MB | +822 MB |
| system max(ピーク) | 23.4 MB | 1,960 MB | - |
| bins残留 | 12.9 MB | 828 MB | +815 MB |
| bins残留率 | 55% | 97.8% | - |
5.3 実データ: malloc_info() 各アリーナの詳細
Before(起動時): 21アリーナ
Arena 0: system= 10.61MB, rest= 0.40MB ← main_arena
Arena 15: system= 2.39MB, rest= 2.31MB
Arena 16: system= 2.72MB, rest= 2.65MB
Arena 17: system= 3.44MB, rest= 3.40MB
Arena 18: system= 2.22MB, rest= 2.12MB
(その他のアリーナは各0.13MB程度)
─────────────────────────────────────────
合計: system= 23.44MB, rest= 12.87MB
After(クエリ後): 39アリーナ
Arena 0: system= 157.73MB, rest= 144.31MB ← main_arena (15倍に膨張)
Arena 8: system= 25.51MB, rest= 25.50MB ← 新規に肥大化
Arena 9: system= 25.51MB, rest= 25.50MB ← 新規に肥大化
Arena 19: system= 60.89MB, rest= 60.80MB ← 60MB級アリーナ
Arena 20: system= 63.87MB, rest= 62.44MB ← 60MB級アリーナ
Arena 21: system= 62.43MB, rest= 62.29MB ← 60MB級アリーナ
Arena 22: system= 56.96MB, rest= 56.81MB ← 60MB級アリーナ
Arena 23: system= 62.73MB, rest= 62.63MB ← 60MB級アリーナ
Arena 24: system= 59.95MB, rest= 59.82MB ← 60MB級アリーナ
Arena 25: system= 63.63MB, rest= 63.52MB ← 60MB級アリーナ
Arena 26: system= 60.25MB, rest= 60.08MB ← 60MB級アリーナ
Arena 27: system= 63.98MB, rest= 63.80MB ← 60MB級アリーナ
Arena 28: system= 63.30MB, rest= 63.15MB ← 60MB級アリーナ
(その他のアリーナは各0.13〜3MB程度)
─────────────────────────────────────────
合計: system= 845.33MB, rest= 828.50MB
注目点: Arena 19〜28の10個のアリーナが各60MB級に膨張し、合計約620MBを占有している。
5.4 実データ: malloc_info() XML抜粋
<!-- Before: 全体サマリー -->
<total type="fast" count="194" size="9696"/>
<total type="rest" count="736" size="13491707"/>
<system type="current" size="24580096"/>
<system type="max" size="24580096"/>
<!-- After: 全体サマリー -->
<total type="fast" count="1173" size="57056"/>
<total type="rest" count="5377" size="868783882"/> <!-- 828MB がbinsに残留 -->
<system type="current" size="886288384"/> <!-- 845MB を確保中 -->
<system type="max" size="2054721536"/> <!-- ピーク時 1.96GB -->
5.5 結果の解釈
Before: system 23.4MB のうち bins残留 12.9MB (55%)
↓ クエリ実行
After: system 845MB のうち bins残留 828MB (97.8%)
発見:
- アリーナ数が21→39に増加: tokioワーカースレッドの増加に対応
- 60MB級アリーナが10個新規生成: Arena 19〜28が各60MB前後を占有
- main_arena(#0)が10MB→157MBに膨張: 15倍以上に肥大化
- bins残留率97.8%: system 845MBのうち828MBがbinsに残留
-
ピーク時1.96GB:
system type="max"より、一時的に約2GBまで膨張
結論:
クエリ実行後、glibc mallocが確保した845MBのうち828MB(97.8%)がbinsに残留しており、これが「メモリ高止まり」の直接的原因である。特に60MB級の大型アリーナが10個生成されたことが、メモリ肥大化の主要因である。
6. 解決策 - Kyselyへの局所的移行
6.1 Kyselyはなぜメモリ高止まりしにくいのか
言いたいこと: Kyselyは「メインスレッド+main_arena」だけで動くため、複数アリーナが肥大化するPrismaと違い、断片化リスクが小さい。
根拠URL:
- Kysely GitHub: https://github.com/kysely-org/kysely
- node-postgres: https://github.com/brianc/node-postgres
アーキテクチャ(シンプルな流れ)
Prisma と Kysely の違い
| 観点 | Prisma (問題側) | Kysely (解決側) | なぜ差が出るか |
|---|---|---|---|
| 実装 | Rust + tokio + ネイティブアドオン | Pure TypeScript (ネイティブなし) | KyselyはV8上のJSだけで完結 |
| スレッド | マルチスレッド(CPUコア数分ワーカー) | メインスレッド中心(libuvプールはI/Oのみ) | malloc呼び出しが分散しない |
| アリーナ数 | 39個に増殖(60MB級が10個) | 基本1個 (main_arena) | スレッド単位でアリーナが増えない |
| 断片化 | bins残留率 97.8% (828MB) | 小さい(main_arena内で再利用されやすい) | キャッシュが1アリーナに集約 |
補足: libuvのスレッドプール(デフォルト4スレッド)はI/O用途で追加アリーナを作る可能性はあるが、クエリ実行のmallocはメインスレッドで発生するため、Prismaほど増殖しない。
6.2 prisma-kyselyによる型安全な移行
根拠URL:
- prisma-kysely GitHub: https://github.com/valtyr/prisma-kysely
引用: prisma-kysely README
"Generate Kysely type definitions from your Prisma schema"
訳: 「PrismaスキーマからKyselyの型定義を生成する」
prisma-kyselyのメリット
- 既存のPrismaスキーマを活用: schema.prismaから型定義を自動生成
- 段階的移行が可能: 全体を一度に変更する必要がない
- 型安全性を維持: Prismaと同等の型チェックを継続
6.3 局所的な差し替え実装
本ケースでは、全面的なORM移行ではなく、問題の原因となっている重いクエリ箇所のみをKyselyに差し替えた。
差し替え対象の選定基準
以下の条件を満たすクエリを優先的にKyselyへ差し替える:
- ネストしたincludeを多用するクエリ
- Prisma内部でのマージ処理が複数スレッドで走り、アリーナが増殖しやすい
- 大量レコード(数千件以上)を取得するクエリ
- 一度のクエリで大きなメモリ割り当てが発生し、断片化の影響を強く受ける
- 同時接続で実行されるクエリ
- 複数のtokioワーカーが同時にmallocを呼び出し、アリーナが増え断片化が顕著になる
差し替え前後のクエリコード比較
Before: Prisma (ネストinclude使用)
const users = await prisma.user.findMany({
include: {
posts: {
include: {
comments: {
select: {
id: true,
text: true
}
}
}
}
}
});
After: Kysely (明示的JOIN)
const users = await db
.selectFrom('User')
.selectAll('User')
.select((eb) => [
jsonArrayFrom(
eb.selectFrom('Post')
.selectAll('Post')
.select((ebPost) => [
jsonArrayFrom(
ebPost
.selectFrom('Comment')
.select(['Comment.id', 'Comment.text'])
.whereRef('Comment.postId', '=', 'Post.id')
.orderBy('Comment.id')
).as('comments')
])
.whereRef('Post.authorId', '=', 'User.id')
.orderBy('Post.id')
).as('posts')
])
.execute();
重要な違い:
- Prisma: queryEngineがRust内部でネストクエリをマージ処理(複数のtokioワーカーがmallocを呼び出し)
- Kysely: Node.jsメインスレッドでSQLを構築し、pgドライバーが単一接続で実行(main_arenaのみ使用)
7. 効果検証
7.1 検証シナリオ
差し替え前後で同一条件の負荷テストを実施した。
| 項目 | 値 |
|---|---|
| 同時接続数 | 30 |
| ペイロードサイズ | 約2MB |
| 測定項目 | RSS, malloc_info, レスポンスタイム |
7.2 検証の問い
Kyselyに切り替えることで、メモリ高止まりは本当に解消されるのか
この問いに答えるため、3種類のデータソースで前後比較を行った。
| データソース | 測定対象 | 確認できること |
|---|---|---|
/proc/[pid]/status |
プロセス全体 | RSS(物理メモリ使用量)の変化 |
malloc_info() |
glibcアリーナ内部 | アリーナ数・bins残留量(断片化の直接証拠) |
/proc/[pid]/smaps |
メモリマッピング詳細 | 匿名領域の物理占有(アリーナの実体) |
7.3 検証結果①:プロセスメモリ(/proc/[pid]/status)
| 項目 | Prisma After | Kysely After | 改善幅 |
|---|---|---|---|
| VmRSS | 955.3 MB | 198.3 MB | ▼757 MB (79%減) |
| RssAnon | 891.3 MB | 134.7 MB | ▼757 MB (85%減) |
| VmHWM | 3,821.7 MB | 198.3 MB | ▼3,623 MB (95%減) |
このデータから言えること:
- RSSが955MB→198MBに低下 → メモリ高止まりが解消された
- RssAnon(匿名メモリ)が891MB→135MB → 問題の本体である「動的確保されたまま返却されないメモリ」が激減
- VmHWM(ピーク)が3.8GB→0.2GB → Prismaは処理中に3.8GBまで膨張したが、Kyselyは0.2GBで安定
7.4 検証結果②:glibcアリーナ内部(malloc_info)
| 項目 | Prisma After | Kysely After | 改善幅 |
|---|---|---|---|
| アリーナ数 | 39 | 23 | ▼16 (41%減) |
| system total | 845.23 MB | 36.40 MB | ▼809 MB (96%減) |
| bins残留 | 828.54 MB | 18.89 MB | ▼810 MB (98%減) |
| bins残留率 | 97.8% | 52% | ▼46ポイント |
| 60MB級アリーナ | 10個 | 0個 | 完全消失 |
このデータから言えること:
- bins残留が828MB→19MBに激減 → 断片化の直接原因(freeされてもOSに返却されないメモリ)がほぼ消失
- 60MB級アリーナが10個→0個 → 問題の根本である「スレッドごとの巨大アリーナ」が発生しなくなった
- 残留率97.8%→52% → Prismaは確保した845MBのうち828MBがbinsに滞留、Kyselyは36MBのうち19MBのみ
malloc_info XML抜粋
Prisma After - main_arenaだけで157MB、他に60MB級アリーナが10個
<heap nr="0">
<total type="rest" count="1294" size="151321101"/> <!-- 151MB がbinsに残留 -->
<system type="current" size="165388288"/> <!-- 157MB 確保中 -->
</heap>
<heap nr="19">
<total type="rest" count="165" size="63753652"/> <!-- 60MB がbinsに残留 -->
<system type="current" size="63848448"/> <!-- 60MB 確保中 -->
</heap>
<!-- Arena #19〜#28 がすべて60MB級 -->
Kysely After - main_arenaが15MB、他は0.1MB程度
<heap nr="0">
<total type="rest" count="14" size="157565"/> <!-- 0.15MB がbinsに残留 -->
<system type="current" size="16515072"/> <!-- 15.7MB 確保中 -->
</heap>
<heap nr="1">
<total type="rest" count="6" size="127157"/> <!-- 0.12MB がbinsに残留 -->
<system type="current" size="135168"/> <!-- 0.13MB 確保中 -->
</heap>
<!-- 巨大アリーナが存在しない -->
7.5 検証結果③:物理メモリマッピング(smaps Anonymous)
| 項目 | Prisma After | Kysely After | 改善幅 |
|---|---|---|---|
| 総Anonymous | 890.71 MB | 134.69 MB | ▼756 MB (85%減) |
| heap (main_arena) | 157.44 MB | 9.52 MB | ▼148 MB (94%減) |
| 60MB級アリーナ数 | 10個 | 0個 | 完全消失 |
Prisma After - 60MB級アリーナが10個並ぶ
| アドレス範囲 | Anonymous (MB) | 種別 |
|---|---|---|
0ceca000-16c84000 |
157.44 | heap (main_arena) |
fffb58000000-fffb5bffb000 |
63.95 | arena |
fffc64000000-fffc67f4d000 |
63.30 | arena |
fffb44000000-fffb47fde000 |
63.14 | arena |
fffb50000000-fffb53fa1000 |
62.89 | arena |
| … 他6個省略 | ~60 MB × 6 | arena |
| 合計 | 890.71 MB | - |
Kysely After - 大きなアリーナが存在しない
| アドレス範囲 | Anonymous (MB) | 種別 |
|---|---|---|
| heap (main_arena) | 9.52 | heap |
238e2000000-23922000000 |
15.69 | arena(VM 1GB確保だが実使用15MB) |
| その他 | 数MB未満 | - |
| 合計 | 134.69 MB | - |
このデータから言えること:
- smapsでもmalloc_infoと同じ結論:Prismaは60MB級アリーナが10個、Kyselyは0個
- 仮想メモリ確保 ≠ 物理メモリ占有:KyselyのVM 1GB領域は、実際のRSSは15MBのみ(ページフォルトで必要な分だけ確保)
- 匿名メモリ総量890MB→135MB:プロセスが物理的に保持するメモリが85%減少
7.6 結論:3つのデータが示す改善
| 問い | 根拠データ | 結論 |
|---|---|---|
| メモリ高止まりは解消されたか? |
/proc/status: RSS 955→198MB |
解消された(79%減) |
| 断片化は解消されたか? |
malloc_info: bins残留 828→19MB |
解消された(98%減) |
| 巨大アリーナは消えたか? |
malloc_info + smaps: 60MB級 10→0個 |
完全に消失した |
| ピーク使用量は改善したか? |
/proc/status: VmHWM 3.8GB→0.2GB |
95%改善 |
つまり、Kyselyへの切り替えにより:
- マルチスレッドによる複数アリーナ生成が抑制された
- 各アリーナでの断片化(bins残留)が発生しなくなった
- その結果、プロセス全体のメモリ使用量が約80%削減された
8. 結論
8.1 原因のまとめ
本問題は、以下の3つの要因が組み合わさることで発生した:
- glibc mallocの設計思想: 再利用優先・ロック競合回避のためのマルチアリーナ設計
- Prismaのアーキテクチャ: Rust製queryEngineがtokioマルチスレッドランタイムを使用
- 断片化のメカニズム: 各アリーナ内でtcache/fastbinsがfreeチャンクをキャッシュし、OSへの返却を阻害
8.2 解決策のまとめ
解決策はkyselyで問題の重いクエリ部分だけを置き換えることに集約される。
理由:
- KyselyはNodeメインスレッド主体で動作し、glibcのmain_arenaを中心に使うため、Prismaのようにワーカーごとにアリーナを乱立させず断片化が抑制される
- prisma-kyselyを用いれば既存のPrismaスキーマから型を生成でき、型安全性を維持したまま局所的に差し替えられる
- 問題が集中する重いクエリのみを移行対象にすることで、影響範囲を最小化しつつ即効性のあるメモリ改善を得られる
8.3 適用の指針
本解決策は、以下の条件を満たすケースで特に有効である:
- メモリ高止まりの原因がglibc mallocのアリーナ断片化であると特定されている
- 問題を引き起こしているクエリが局所的である
- Prismaスキーマが既に定義されており、型安全性を維持したい
9. 参考文献
glibc malloc関連
| タイトル | URL |
|---|---|
| glibc malloc.c ソースコード | https://sourceware.org/git/?p=glibc.git;a=blob;f=malloc/malloc.c |
| Understanding glibc malloc | https://sploitfun.wordpress.com/2015/02/10/understanding-glibc-malloc/ |
| Azeria Labs Heap Exploitation Part 2 | https://azeria-labs.com/heap-exploitation-part-2-glibc-heap-free-bins/ |
| openEuler glibc解析 | https://www.openeuler.org/en/blog/wangshuo/Glibc_Malloc_Source_Code_Analysis_(1).html |
| Heroku glibc Tuning | https://devcenter.heroku.com/articles/tuning-glibc-memory-behavior |
| Code Arcana Arena Leak | https://codearcana.com/posts/2016/07/11/arena-leak-in-glibc.html |
| malloc_trim man page | https://www.man7.org/linux/man-pages/man3/malloc_trim.3.html |
| malloc_info man page | https://www.man7.org/linux/man-pages/man3/malloc_info.3.html |
Node.js / V8関連
| タイトル | URL |
|---|---|
| Node.js process.memoryUsage() | https://nodejs.org/api/process.html#processmemoryusage |
Prisma関連
| タイトル | URL |
|---|---|
| Prisma Engine Documentation | https://www.prisma.io/docs/orm/more/under-the-hood/engines |
| Prisma GitHub Issue #16912 | https://github.com/prisma/prisma/issues/16912 |
| Prisma GitHub Issue #25371 | https://github.com/prisma/prisma/issues/25371 |
| Svix Heap Fragmentation | https://www.svix.com/blog/heap-fragmentation-in-rust-applications/ |
| Tokio GitHub Issue #6083 | https://github.com/tokio-rs/tokio/issues/6083 |
Kysely関連
| タイトル | URL |
|---|---|
| Kysely GitHub | https://github.com/kysely-org/kysely |
| prisma-kysely GitHub | https://github.com/valtyr/prisma-kysely |
| node-postgres | https://github.com/brianc/node-postgres |