Upgrade to Pro — share decks privately, control downloads, hide ads and more …

見えないメモリを観測する: PHP 8.4 `pg_result_memory_size()`...

見えないメモリを観測する: PHP 8.4 `pg_result_memory_size()` とSQL結果のメモリ管理

PHP Conference Japan 2024
#phpcon #track3

2024-12-22 13:15 -
トラック3 / 4F コンベンションホール 梅 / レギュラートーク(25分)

fortee: https://fortee.jp/phpcon-2024/proposal/6dadacb3-51b7-4a71-910a-f5f71f75e1e3

武田 憲太郎

December 21, 2024
Tweet

More Decks by 武田 憲太郎

Other Decks in Programming

Transcript

  1. ⾃⼰紹介 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する @KentarouTakeda / 武⽥ 憲太郎 /

    Webアプリケーションエンジニア • PostgreSQL: 2001年 - 現在 • 最初の仕事がPostgreSQLだった • 他のDBの勉強中もPostgreSQLのマニュアルを読んでいた • PHP: 2004年 - 現在 • 最初は、テンプレートエンジンとして • その後しばらく、オレオレフレームワークと共に • Laravel: 2019年 - 現在 • 「PostgreSQLを直で使えれば◦◦できるのに…」 • 「オレオレフレームワークなら△△するのに…」 • php-src: 2023年 - 現在 • PHPからPostgreSQLをより便利に使って欲しい(義務感) 2
  2. アジェンダ 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する • PDOからのSQL実⾏を紐解く • PHPが利⽤する2種類のメモリ •

    巨⼤な結果セットの取り扱い • アプリケーション実装での注意点 • 補⾜と参考 3
  3. 突然ですがクイズです データベースと対話している のはどこ?(複数回答可) 1. SQL⽂を準備: prepare() 2. SQL⽂を実⾏: execute() 3.

    結果を取得: fetchAll() $pdo = PDO::connect('pgsql:'); // 1. SQL文を準備 $statement = $pdo->prepare( 'select * from users' ); // 2. SQL文を実行 $statement->execute(); // 3. 結果を取得 $records = $statement->fetchAll(); 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 5
  4. 正解: 2. SQL⽂を実⾏ 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 1. SQL⽂を準備: prepare()

    • $statement を⽣成するのみ • SQLの PREPARE とは意味が異なる 2. SQL⽂を実⾏: execute() • $statement をデータベースへ送信 • PREPARE と EXECUTE が同時に⾏われる 3. 結果を取得: fetchAll() • ここではデータベースと対話しない • どこから結果を取得? $pdo = PDO::connect('pgsql:'); // 1. SQL文を準備 $statement = $pdo->prepare( 'select * from users' ); // 2. SQL文を実行 $statement->execute(); // 3. 結果を取得 $records = $statement->fetchAll(); 6
  5. どこから結果を取得している? 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する // データベースへ接続 $pdo = PDO::connect('pgsql:');

    error_log(memory_get_usage()); // 出力: 395144 // 1. SQL文を準備: 10万件のuuidを返却 $statement = $pdo->prepare( 'select generate_series(1, 100000), uuid_generate_v4()' ); error_log(memory_get_usage()); // 出力: 395880 // 2. SQL実行: 実行結果を内部に保存 $statement->execute(); error_log(memory_get_usage()); // 出力: 396008 // 3. 結果を取得 $records = $statement->fetchAll(); error_log(memory_get_usage()); // 出力: 46497336 処理 消費メモリ データベースへ接続 - 1. SQL⽂を準備 + 736 Bytes 2. SQL実⾏(結果を内部に保存) + 128 Bytes 3. 結果を(内部から)取得 + 46,101,328 Bytes 7
  6. PHPが管理するメモリ PHP: memory_get_usage - Manual PHP Internals Book - Learning

    the PHP lifecycle - Request initialization: RINIT() (意訳) emalloc() で確保したメモリは、Zend Memory Manager で管理され、リクエ スト終了時に⾃動的に解放されます。 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 9
  7. PHPが管理するメモリ Zend Memory Manager • C⾔語のレイヤー(ユーザーは普段は意識しない) • 確保されたメモリを追跡、終了時に開放 ユーザーランドからの観測 •

    memory_get_usage(): • Zend Memory Managerが追跡するメモリの合計 • memory_limit: • Zend Memory Managerが管理するメモリの上限 PHP Internals Book - Zend Memory Manager (意訳) Zend Memory Managerは、リクエス ト毎の動的なメモリの確保と解放を⾏ うC⾔語のレイヤーです。 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 10
  8. PHPが管理できないメモリ 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する • Zend Memory Manager追跡外 •

    memory_get_usage() で観測でき ず memory_limit の制限外 • 通常は⾮推奨 $statement->execute() の結果 はこの領域に保存される: 何故? PHP Internals Book - Learning the PHP lifecycle - Request initialization: RINIT() (意訳) libc の malloc() や Zend の pemalloc() などの永続的な動的メモリ を使うべきではありません。 11
  9. PHPのデータベースアクセスの実装 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 12 ベンダーが提供するライブラリを通じてデータを取得 • libpq: PostgreSQL

    • libmysqlclient: MySQL ü現在は純粋なPHP実装のmysqlndに置き換えられている • unixodbc: Microsoft SQL Server • sqlite3: SQLite
  10. ライブラリによる詳細の隠蔽 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 13 PHPユーザーにとっての「ライブラリ」 • guzzle: HTTPプロトコルを知らなくてもHTTPサーバと対話できる

    • PHPユーザーは、内部で何が起きているか関知できない PHP開発者(C⾔語ユーザー)にとっての「ライブラリ」 • libpq: PostgreSQLプロトコルを知らなくてもPostgreSQLと対話で きる • PHP開発者は、内部で何が起きているか関知できない • メモリ使⽤量も追跡できない
  11. クエリ実⾏と結果の取得 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 15 $statement->execute() • ❌ クエリを実⾏する関数

    • ✅ クエリ実⾏結果を内部に保存する関数 $statement->fetchAll() • ❌ データベースから結果を取得する関数 • ✅ 保存済の結果をPHP変数に変換する関数 ※正確にはPDOの動作モードにより変わります。この動作を変更する⽅法を後半のスライドで説明します。
  12. 「⾒えないメモリ」の危険 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 16 •Allowed memory size of

    ... ではなく PDOException • memory_limit では捕捉不可 •libpq内でメモリ確保に失敗 OSのメモリ制限に抵触 // PHPが利用するメモリの上限を2Mに制限 ini_set('memory_limit', '2M'); // 100万件のuuid生成(結果は取得せず) PDO::connect('pgsql:')->prepare(' select generate_series(1, 1000000), uuid_generate_v4() ')->execute(); ## 実行環境全体のメモリ上限を150Mに制限 $ ulimit -S -v 150000 $ php /tmp/huge-query-results.php # Fatal error: Uncaught PDOException: # SQLSTATE[HY000]: General error: # 7 out of memory for query result
  13. エクササイズ 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 18 $records = $statement->fetchAll() とライブラリ内部形式とで、メモリ消費量にどの程度の差

    があるでしょう? 参考: $records と PGresult 構造体の違い array $resocds PGresult res ⾔語 PHP C⾔語 型 動的 静的 メモリ管理 何もしなくて良い 開発者⾃⾝が管理 ⼤きさ変更 ⾃動 realloc() 等で明⽰的に変更 解放 ⾃動 free() 等で明⽰的に解放 コピー Copy on Write 即時コピー
  14. ⾒えないメモリを観測する 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 19 •C⾔語の変数として9M •PHP変数として追加で5倍 $pdo =

    PDO::connect('pgsql:'); $statement = $pdo->prepare(' select generate_series(1, 100000), uuid_generate_v4() '); $statement->execute(); error_log($statement->getAttribute( Pdo¥Pgsql::ATTR_RESULT_MEMORY_SIZE )); $records = $statement->fetchAll(); error_log($statement->getAttribute( Pdo¥Pgsql::ATTR_RESULT_MEMORY_SIZE )); PHP C(libpq) $pdo ->prepare() + 736 Bytes - $statement ->execute() + 128 Bytes 9,081,048 Bytes $statement ->fetchAll() + 46,101,328 Bytes 9,081,048 Bytes
  15. 巨⼤な結果セットの取り扱い: fetchAll() 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 20 結論: 現実的には問題になりにくい •

    ライブラリの消費メモリはPHPと⽐較し⼩さい • 多くの場合 fetchAll() の時点で問題に気づく • 2重にメモリが消費される点だけ注意 • ⼼配な時は ATTR_RESULT_MEMORY_SIZE で観測
  16. 巨⼤な結果セットの取り扱い: fetch() ループ 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 21 結論: 本当にメモリ削減できているか要確認

    • 「ライブラリ内部 全⾏」「PHP: 1⾏」のメモリ消費 • 2重に消費されることはない • 結果セットがあまりにも巨⼤なケースに注意 • 「fetch() ループでメモリ節約」 • ⾒えない場所で全件管理されてます • 「memory_limit を厳し⽬に設定」 • 捕捉されません
  17. 「⾒えないメモリ」はいつ開放されるのか? 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 23 即座に開放されるケース • $statement =

    null, unset($statement) • ローカル変数 $statement のスコープ外に出る 解放されないかもしれないケース • $statement がインスタンス変数として利⽤されており、 • 他のインスタンスとの間で循環参照を持っている • インスタンスを解放しても循環参照コレクタが起動さ れるまで解放されない • 「PDOをラップした独⾃のDB管理クラス」などに注意 static int pgsql_stmt_dtor(pdo_stmt_t *stmt) { // 省略 if (S->result) { /* free the resource */ PQclear(S->result); S->result = NULL; } // 省略 } 答え: PDOStatementオブジェクトへの参照がなくなった時 php/php-src: PDOStatement デストラクタ
  18. ベストプラクティス 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 24 • $statement はローカル変数として扱う •

    $statement->execute() は結果取得の直前 • fetchAll() の場合: • $statement は速やかに unset() • fetch() の場合: • $record はループ内に閉じる • ループ終了後 $statement は速やかに unset()
  19. 実装例: Laravel select() 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 25 • execute()

    ののち速やかに fetchAll() • 結果はどこにも代⼊せずそのまま返却 • ローカル変数 $statement はスコープを外れる public function select($query, $bindings = [], $useReadPdo = true) { return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) { if ($this->pretending()) { return []; } $statement = $this->prepared( $this->getPdoForSelect($useReadPdo)->prepare($query) ); $this->bindValues($statement, $this->prepareBindings($bindings)); $statement->execute(); return $statement->fetchAll(); }); }
  20. 実装例: Laravel cursor() 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 26 • execute()

    ののち速やかに fetch() ループ • 結果はどこにも代⼊せずそのまま引き渡し • ループ終了と同時にローカル変数 $statement はスコープを外れる public function cursor($query, $bindings = [], $useReadPdo = true) { $statement = $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) { if ($this->pretending()) { return []; } $statement = $this->prepared($this->getPdoForSelect($useReadPdo) ->prepare($query)); $this->bindValues( $statement, $this->prepareBindings($bindings) ); $statement->execute(); return $statement; }); while ($record = $statement->fetch()) { yield $record; } } このクロージャは 同期で即時実⾏
  21. 典型的な処理の流れ 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 27 PHPが管理できないメモリ • ⽣成を出来るだけ遅らせる •

    できるだけ早く破棄 • アプリケーション層に決して持 ち込まない PHPが管理するメモリ • 「管理できない〜」との共存時 間を最⼩に
  22. $statement->execute() での全⾏取得を許容できないケース 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 30 $pdo = PDO::connect('pgsql:');

    // 「5⾏」の結果セットを持つSQL $statement = $pdo->prepare( 'select generate_series(1, 5)', [ PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL ] ); // DECLARE: カーソルだけ作成し結果は取得しない $statement->execute(); // FETCH: カーソルから1⾏ずつ取得 while($record = $statement->fetch()) { // } # postgresql.log ## `$pdo->prepare()` LOG: statement: DECLARE pdo_crsr_00000001 SCROLL CURSOR WITH HOLD FOR select generate_series(1, 5) LOG: statement: FETCH FORWARD 0 FROM pdo_crsr_00000001 ## `$statement->fetch()` - 次の行が存在(5行分) LOG: statement: FETCH NEXT FROM pdo_crsr_00000001 LOG: statement: FETCH NEXT FROM pdo_crsr_00000001 LOG: statement: FETCH NEXT FROM pdo_crsr_00000001 LOG: statement: FETCH NEXT FROM pdo_crsr_00000001 LOG: statement: FETCH NEXT FROM pdo_crsr_00000001 ## `$statement->fetch()` - 6行目 LOG: statement: FETCH NEXT FROM pdo_crsr_00000001 ## 次の行が無いためカーソルを閉じて処理終了 LOG: statement: CLOSE pdo_crsr_00000001 fetch() ループ カーソル変換 ループ終了
  23. $statement->execute() での全⾏取得を許容できないケース 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 31 • ✅ 結果セットの⾏数に関わらずメモリは常に1⾏分

    • ❌ 結果セットの⾏数だけデータベースと通信が発⽣ • FETCH = 1⾏だけ結果を返す SELECT と考えると解りや すい • ❌ CLOSE でカーソルを閉じるまでの間データベース サーバ側のメモリを消費 • ❌ Amazon RDS Proxyではピン留めが発⽣ • カーソルにより接続が状態(クエリ結果)を持つため
  24. mysqlnd + pdo-mysql での動作検証 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 33 ベンダー提供のCライブラリよりメモリ効率が良い

    // PostgreSQLでのデモ同様10万件のuuid $statement = $pdo->prepare('select * from uuids'); error_log(memory_get_usage()); // 出力: 1029256 $statement->execute(); error_log(memory_get_usage()); // 出力: 7578808 $results = $statement->fetchAll(); error_log(memory_get_usage()); // 出力: 53692456 pdo-pgsql mysqlnd + pdo-mysql $statement ->execute() 9,081,048 + 128 Bytes 6,549,552 Bytes $statement ->fetchAll() 46,101,328 Bytes 46,113,648 Bytes
  25. MySQL ⾮バッファクエリ 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 34 ⾮バッファクエリの利⽤ PHP: MySQL:

    バッファクエリと⾮バッファクエリ $pdo->setAttribute( PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false );
  26. PHP-8.5: PostgreSQLでの⾮バッファクエリ 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 35 • Pdo¥pgSql needs

    an efficient fetch() mode https://github.com/php/php-src/issues/15287 • Fix / implement GH-15287: add a lazy fetch to Pdo¥PgSql https://github.com/php/php-src/pull/15750 概要 • MySQL ⾮バッファクエリと同じ動作 • サーバに対し FETCH ⽂を実⾏しないため性能が向上
  27. まとめ • PHPのデータベースアクセス • memory_get_usage() や memory_limit は、データベースライブラリが管理 する「⾒えないメモリ」を捕捉できない •

    「⾒えないメモリ」によるクラッシュやパフォーマンス低下のリスク • ⾒えないメモリの観測 • ⼤量データを扱う場合 $statement->execute() のメモリ消費量も確認 • ベストプラクティス • $statement は短命に保ち、インフラストラクチャ層の外にリークさせない • 解決できない場合はPDOの動作モードを変更するやり⽅も • 負荷を他の場所に移転しているに過ぎない点に注意 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 36
  28. データベース機能以外での「⾒えないメモリ」 • ここで紹介された「⾒えないメモリ」はデータベース機能以外でも使われるのか? • 多くの機能においてYes。PHPマニュアル「インストール」の章に「PHP はグルー(糊)で す」と書かれている通り、PHPの機能の多くは外部ライブラリのラッパー。それらの機能 に依存した時点でZend Memory Manager管理外のメモリは必要になる。

    • それは危険ではないのか? • 多くの場合No。マルチバイト⽂字列、XML操作、画像処理、PHPは様々な外部ライブラリ を使うが、これらの多くはPHP開発者またはPHPユーザーがメモリ消費量をある程度コン トロールできる。正規表現などその限りでない機能もあるが、それらはセキュリティ上の ベストプラクティスとして情報が出回っている。 • SQLだけはどうにもならない。select * from users が消費するメモリ量を予測する⽅法 は無い。同じような性質を持つcurl(HTTPリクエストやダウンロード)は、ダウンロード サイズの制限(変更可能)が初期設定されている。
  29. フレークワークでの注意点 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 39 • フレームワークを使っているが同じような注意点はないか? • 基本的には無いと考えて良い。「$statement

    を短命に」などのプ ラクティスは概ね守られている。 • 本セッションは「C⾔語のレイヤーでのメモリ管理」を扱ったが、 その先のフレームワーク実装にも複数のレイヤーが存在する。カー ソルフェッチや遅延ハイドレーションなどの機能を使う場合「それ は具体的にどのレイヤーのメモリを削減しているのか?」を考え、 ⾏オブジェクトやモデルを適切に短命化すると良い。 • フレームワークを⾃作する場合の注意点は? • 本セッション「典型的な処理の流れ」の内容を実践すれば問題ない。
  30. php-srcへの貢献 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 40 • php-srcへコントリビューションを始めたきっかけ は? •

    PHP 8.4のリリースマネージャーがLaravelのバグを修正 したのがきっかけ。C⾔語の部分での機能追加を当⾯の ⽬標とした。 • あまり機能追加が活発でない拡張も、そのような⽅針と いうわけではなく、単にコントリビューターが少ないだ けということに気付いた。PostgreSQLのマニュアルは libpq含め全て⽬を通している。ここなら貢献できると感 じPHPに⾜りない機能を探した。