Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

モチベーション ⾒えないメモリを観測する ⻑年の夢が叶いました •「本当は怖いデータベースアクセス」を説明する機 会に恵まれました 1年越しの夢が叶いました • PHP 8.4に新たな関数を追加できました 昔からの問題意識を、⾃分⾃⾝が追加した機能を使って、 このセッションで説明します。 2024-12-22 #phpcon #track3 1

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

アジェンダ 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する • PDOからのSQL実⾏を紐解く • PHPが利⽤する2種類のメモリ • 巨⼤な結果セットの取り扱い • アプリケーション実装での注意点 • 補⾜と参考 3

Slide 5

Slide 5 text

PDOからのSQL実⾏を紐解く

Slide 6

Slide 6 text

突然ですがクイズです データベースと対話している のはどこ?(複数回答可) 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

Slide 7

Slide 7 text

正解: 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

Slide 8

Slide 8 text

どこから結果を取得している? 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

Slide 9

Slide 9 text

PHPが利⽤する2種類のメモリ • PHPが管理するメモリ • PHPが管理できないメモリ

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

PHPのデータベースアクセスの実装 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 12 ベンダーが提供するライブラリを通じてデータを取得 • libpq: PostgreSQL • libmysqlclient: MySQL ü現在は純粋なPHP実装のmysqlndに置き換えられている • unixodbc: Microsoft SQL Server • sqlite3: SQLite

Slide 14

Slide 14 text

ライブラリによる詳細の隠蔽 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 13 PHPユーザーにとっての「ライブラリ」 • guzzle: HTTPプロトコルを知らなくてもHTTPサーバと対話できる • PHPユーザーは、内部で何が起きているか関知できない PHP開発者(C⾔語ユーザー)にとっての「ライブラリ」 • libpq: PostgreSQLプロトコルを知らなくてもPostgreSQLと対話で きる • PHP開発者は、内部で何が起きているか関知できない • メモリ使⽤量も追跡できない

Slide 15

Slide 15 text

巨⼤な結果セットの取り扱い

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

「⾒えないメモリ」の危険 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

Slide 18

Slide 18 text

SQL結果のメモリ管理(PostgreSQL) 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 17 •PHP 8.4から利⽤可能 •PostgreSQLでの「⾒えないメモリ」を観測 PHP: pg_result_memory_size - Manual PHP: Pdo\Pgsql - Manual ‒ 定義済み定数

Slide 19

Slide 19 text

エクササイズ 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 18 $records = $statement->fetchAll() とライブラリ内部形式とで、メモリ消費量にどの程度の差 があるでしょう? 参考: $records と PGresult 構造体の違い array $resocds PGresult res ⾔語 PHP C⾔語 型 動的 静的 メモリ管理 何もしなくて良い 開発者⾃⾝が管理 ⼤きさ変更 ⾃動 realloc() 等で明⽰的に変更 解放 ⾃動 free() 等で明⽰的に解放 コピー Copy on Write 即時コピー

Slide 20

Slide 20 text

⾒えないメモリを観測する 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

Slide 21

Slide 21 text

巨⼤な結果セットの取り扱い: fetchAll() 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 20 結論: 現実的には問題になりにくい • ライブラリの消費メモリはPHPと⽐較し⼩さい • 多くの場合 fetchAll() の時点で問題に気づく • 2重にメモリが消費される点だけ注意 • ⼼配な時は ATTR_RESULT_MEMORY_SIZE で観測

Slide 22

Slide 22 text

巨⼤な結果セットの取り扱い: fetch() ループ 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 21 結論: 本当にメモリ削減できているか要確認 • 「ライブラリ内部 全⾏」「PHP: 1⾏」のメモリ消費 • 2重に消費されることはない • 結果セットがあまりにも巨⼤なケースに注意 • 「fetch() ループでメモリ節約」 • ⾒えない場所で全件管理されてます • 「memory_limit を厳し⽬に設定」 • 捕捉されません

Slide 23

Slide 23 text

アプリケーション実装での注意点

Slide 24

Slide 24 text

「⾒えないメモリ」はいつ開放されるのか? 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 デストラクタ

Slide 25

Slide 25 text

ベストプラクティス 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 24 • $statement はローカル変数として扱う • $statement->execute() は結果取得の直前 • fetchAll() の場合: • $statement は速やかに unset() • fetch() の場合: • $record はループ内に閉じる • ループ終了後 $statement は速やかに unset()

Slide 26

Slide 26 text

実装例: 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(); }); }

Slide 27

Slide 27 text

実装例: 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; } } このクロージャは 同期で即時実⾏

Slide 28

Slide 28 text

典型的な処理の流れ 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 27 PHPが管理できないメモリ • ⽣成を出来るだけ遅らせる • できるだけ早く破棄 • アプリケーション層に決して持 ち込まない PHPが管理するメモリ • 「管理できない〜」との共存時 間を最⼩に

Slide 29

Slide 29 text

補⾜と参考

Slide 30

Slide 30 text

$statement->execute() での全⾏取得を許容できないケース 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 29 •pdo-pgsqlはSELECTのカーソル変換をサポート 取り出す⾏数の 上限を指定可能 任意の⾏数の 結果セット

Slide 31

Slide 31 text

$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() ループ カーソル変換 ループ終了

Slide 32

Slide 32 text

$statement->execute() での全⾏取得を許容できないケース 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 31 • ✅ 結果セットの⾏数に関わらずメモリは常に1⾏分 • ❌ 結果セットの⾏数だけデータベースと通信が発⽣ • FETCH = 1⾏だけ結果を返す SELECT と考えると解りや すい • ❌ CLOSE でカーソルを閉じるまでの間データベース サーバ側のメモリを消費 • ❌ Amazon RDS Proxyではピン留めが発⽣ • カーソルにより接続が状態(クエリ結果)を持つため

Slide 33

Slide 33 text

Mysqlndでの動作(pdo-mysql, mysqli) 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 32 $statement->execute() でのメモリ消費は memory_get_usage() で観測可能 PHP: Mysqlnd - はじめに PHP: Mysqlnd - 概要

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

MySQL ⾮バッファクエリ 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 34 ⾮バッファクエリの利⽤ PHP: MySQL: バッファクエリと⾮バッファクエリ $pdo->setAttribute( PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false );

Slide 36

Slide 36 text

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 ⽂を実⾏しないため性能が向上

Slide 37

Slide 37 text

まとめ • PHPのデータベースアクセス • memory_get_usage() や memory_limit は、データベースライブラリが管理 する「⾒えないメモリ」を捕捉できない • 「⾒えないメモリ」によるクラッシュやパフォーマンス低下のリスク • ⾒えないメモリの観測 • ⼤量データを扱う場合 $statement->execute() のメモリ消費量も確認 • ベストプラクティス • $statement は短命に保ち、インフラストラクチャ層の外にリークさせない • 解決できない場合はPDOの動作モードを変更するやり⽅も • 負荷を他の場所に移転しているに過ぎない点に注意 2024-12-22 #phpcon #track3 ⾒えないメモリを観測する 36

Slide 38

Slide 38 text

想定質問

Slide 39

Slide 39 text

データベース機能以外での「⾒えないメモリ」 • ここで紹介された「⾒えないメモリ」はデータベース機能以外でも使われるのか? • 多くの機能においてYes。PHPマニュアル「インストール」の章に「PHP はグルー(糊)で す」と書かれている通り、PHPの機能の多くは外部ライブラリのラッパー。それらの機能 に依存した時点でZend Memory Manager管理外のメモリは必要になる。 • それは危険ではないのか? • 多くの場合No。マルチバイト⽂字列、XML操作、画像処理、PHPは様々な外部ライブラリ を使うが、これらの多くはPHP開発者またはPHPユーザーがメモリ消費量をある程度コン トロールできる。正規表現などその限りでない機能もあるが、それらはセキュリティ上の ベストプラクティスとして情報が出回っている。 • SQLだけはどうにもならない。select * from users が消費するメモリ量を予測する⽅法 は無い。同じような性質を持つcurl(HTTPリクエストやダウンロード)は、ダウンロード サイズの制限(変更可能)が初期設定されている。

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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