0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Prismaとglibc によるメモリ高止まり問題の原因分析、kyselyを用いた解消方法

Posted at

作成経緯と対象読者

実務で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のみを使用し、マルチアリーナによる断片化を回避できる。局所的な差し替えにより、メモリ高止まり問題の解消を実現した。


目次

  1. はじめに - 問題の背景
  2. 問題の切り分け - V8 vs queryEngine
  3. glibc malloc設計思想
  4. Prismaアーキテクチャとの相互作用
  5. 検証 - malloc_infoによる断片化確認
  6. 解決策 - Kyselyへの局所的移行
  7. 効果検証
  8. 結論
  9. 参考文献

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 仮説の設定

上記の検証結果から、以下の仮説を設定した:

  1. 問題の所在: Prisma queryEngine(Rust/tokio)が使用するNative Memory
  2. 原因の推定: glibc mallocのマルチスレッド対応設計によるアリーナ断片化
  3. 検証方法: malloc_info()によるアリーナ状態の確認

3. glibc malloc設計思想

本セクションでは、問題の根本原因を理解するために、glibc mallocの設計思想を詳細に解説する。

3.1 設計目標 - 時間効率の優先

glibc mallocは、Doug Leaによるdlmallocをベースに、Wolfram Glogerがマルチスレッド対応を追加したptmalloc2を採用している。

根拠URL:

引用: 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:

なぜキャッシュするのか?

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:

top chunkとは

アリーナ内で最も高いアドレスに位置する未使用領域。新規mallocはここから切り出され、freeされたチャンクがここに隣接すると吸収される。

アリーナのメモリレイアウト:
┌─────────────────────────────────────────────────────────┐
│  チャンク1  │  チャンク2  │  チャンク3  │   top chunk   │
│   (使用中)  │   (free)   │   (使用中)  │  (未割当領域)  │
└─────────────────────────────────────────────────────────┘
                                           ↑
                                    ここだけがOSに返却可能

free()の処理フロー

なぜbins内のチャンクはOSに返却されないのか

bins内のチャンクは以下の理由でOSに返却されない:

  1. tcache/fastbins: 結合されないため、小さなチャンクのまま滞留
  2. smallbins/largebins: 結合してもチャンクはbinに残り、再利用待ち
  3. 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:

引用: 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()を呼び出す際、以下の流れでアリーナが選択/作成される:

  1. 既存アリーナのロック取得を試行
  2. ロック取得に失敗した場合、新しいアリーナを作成
  3. 新アリーナはスレッドに紐付けられる

問題: ロック競合が頻発すると、アリーナ数が増加し、それぞれのアリーナで独立して断片化が進行する。

アリーナ数の上限

アーキテクチャ 計算式 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 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:

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で顕著になる条件

  1. マルチスレッド: tokioワーカーごとに独立したアリーナが作成される
  2. 短命オブジェクト: クエリ結果のJSONパース(serde)で一時バッファが大量に割り当て・解放
  3. 長命オブジェクト: コネクションプール等がアリーナ内に残留し、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%)

発見:

  1. アリーナ数が21→39に増加: tokioワーカースレッドの増加に対応
  2. 60MB級アリーナが10個新規生成: Arena 19〜28が各60MB前後を占有
  3. main_arena(#0)が10MB→157MBに膨張: 15倍以上に肥大化
  4. bins残留率97.8%: system 845MBのうち828MBがbinsに残留
  5. ピーク時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:

アーキテクチャ(シンプルな流れ)

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 README

"Generate Kysely type definitions from your Prisma schema"

: 「PrismaスキーマからKyselyの型定義を生成する」

prisma-kyselyのメリット

  1. 既存のPrismaスキーマを活用: schema.prismaから型定義を自動生成
  2. 段階的移行が可能: 全体を一度に変更する必要がない
  3. 型安全性を維持: Prismaと同等の型チェックを継続

6.3 局所的な差し替え実装

本ケースでは、全面的なORM移行ではなく、問題の原因となっている重いクエリ箇所のみをKyselyに差し替えた

差し替え対象の選定基準

以下の条件を満たすクエリを優先的にKyselyへ差し替える:

  1. ネストしたincludeを多用するクエリ
    • Prisma内部でのマージ処理が複数スレッドで走り、アリーナが増殖しやすい
  2. 大量レコード(数千件以上)を取得するクエリ
    • 一度のクエリで大きなメモリ割り当てが発生し、断片化の影響を強く受ける
  3. 同時接続で実行されるクエリ
    • 複数の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への切り替えにより

  1. マルチスレッドによる複数アリーナ生成が抑制された
  2. 各アリーナでの断片化(bins残留)が発生しなくなった
  3. その結果、プロセス全体のメモリ使用量が約80%削減された

8. 結論

8.1 原因のまとめ

本問題は、以下の3つの要因が組み合わさることで発生した:

  1. glibc mallocの設計思想: 再利用優先・ロック競合回避のためのマルチアリーナ設計
  2. Prismaのアーキテクチャ: Rust製queryEngineがtokioマルチスレッドランタイムを使用
  3. 断片化のメカニズム: 各アリーナ内で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
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?