PHP と MySQL と サーバサイド プリペアードステートメント

少し前のお話なのだけど、せっかくなので書いておくことにする。

MySQLのクエリログを見ていたら、 prepare に相当するログが一切流れていないことに気づいたので、PDO(mysql)でサーバサイドプリペアードステートメントが利用されていないんじゃないか疑惑が浮上。

php のバージョンは 5.2.11
mysql は 5.0 系

以下、検証してみた結果と、その対応です。

検証

簡単なソースコードを実行してみて、そのクエリログを見てみることにした。

PDO を利用
$p = new PDO('mysql:dbname=dbname;host=localhost', 'user', 'password');
$stmt = $p->prepare("SELECT * FROM table_name WHERE id=?");
$stmt->execute(array(1));
$stmt->execute(array(2));
クエリログ(PDO)
091016 16:59:20     227 Connect     user@localhost on dbname
                    227 Query       SELECT * FROM table_name WHERE id='1'
                    227 Query       SELECT * FROM table_name WHERE id='2'
                    227 Quit
MySQLi を利用
$m = new mysqli('localhost', 'user', 'pass', 'dbname');
$stmt = $m->prepare("SELECT * FROM table_name WHERE id=?");
$stmt->bind_param('d', $id);
$id=1;
$stmt->execute();
$id=2;
$stmt->execute();
クエリログ(MySQLi)
091016 16:58:28     228 Connect     user@localhost on dbname
                    228 Prepare     [1] SELECT * FROM table_name WHERE id=?
                    228 Execute     [1] SELECT * FROM table_name WHERE id=1
                    228 Execute     [1] SELECT * FROM table_name WHERE id=2
                    228 Quit

MySQLiでは利用されているprepareが PDOでは利用されていない事が分かる。

原因

5.2.11のソースコードより

ext/pdo_mysql/mysql_driver.c@158 から始まる ''mysql_handle_preparer'' が、mysql利用時の PDO::parepare の実体

158 static int mysql_handle_preparer(pdo_dbh_t *dbh, const char *sql, long sql_len, pdo_stmt_t *stmt, zval *driver_options TSRMLS_DC)
159 {
160         pdo_mysql_db_handle *H = (pdo_mysql_db_handle *)dbh->driver_data;
    (中略)
173         if (H->emulate_prepare) {
174                 goto end;
175         }
176 
177 #if HAVE_MYSQL_STMT_PREPARE
178         server_version = mysql_get_server_version(H->server);
179         if (server_version < 40100) {
180                 goto fallback;
181         }
182         stmt->supports_placeholders = PDO_PLACEHOLDER_POSITIONAL;
    (中略)
236 fallback:
237 #endif
238 end:
239         stmt->supports_placeholders = PDO_PLACEHOLDER_NONE;
240         
241         return 1;

ここで、''stmt->supports_placeholders'' に指定可能な定数は以下の通り

PDO_PLACEHOLDER_NONE (0)
DBはプレースホルダをサポートしない
=> PDOによるフルエミュレーション
PDO_PLACEHOLDER_NAMED (1)
DBは名前付きプレースホルダのみサポートする
=> 「?」を利用する場合はエミュレーション
PDO_PLACEHOLDER_POSITIONAL (2)
DBは序数プレースホルダのみサポートする
=> 「:xxx」を利用する場合はエミュレーション
PDO_PLACEHOLDER_NAMED | PDO_PLACEHOLDER_POSITIONAL (3)
DBは、両方のプレースホルダをサポートする
=> エミュレーションは一切行われない


(参照: https://www.codeblog.org/blog/moriyoshi/20061221.html )


つまり、以下の全ての条件に一致することで、始めてサーバサイドプリペアードステートメント(序数プレースホルダのみ)が利用できる。

  1. 173行目より、dbh->driver_data->emulate_prepare が偽
  2. 177行目より、HAVE_MYSQL_STMT_PREPARE が真
  3. 179行目より、接続先のMySQLバージョンが4.01以上

逆に、上記条件のうち、一つでも満たさないとPDOによるフルエミュレーションとなる。


HAVE_MYSQL_STMT_PREPARE に関しては、RPMのログを見る限りでは、定義されていた。

checking for MySQL support for PDO... yes, shared
checking for mysql_config... /usr/bin/mysql_config
checking for mysql_query in -lmysqlclient... yes
checking for mysql_commit... yes
<strong>checking for mysql_stmt_prepare... yes</strong>  # ← これ!
checking for mysql_next_result... yes
checking for mysql_sqlstate... yes

また、利用しているMySQLサーバはバージョン5.0系なので、当然 4.01以上。

ということで、 dbh->driver_data->emulate_prepare の存在が焦点となってくる。


ext/pdo_mysql/mysql_driver.c@434 から始まる ''pdo_mysql_handle_factory'' 関数が、mysql 利用のPDOオブジェクト構築の際に呼ばれるっぽい(参照: http://www.php.net/manual/ja/internals2.pdo.php )のだが、
その中で、

434 static int pdo_mysql_handle_factory(pdo_dbh_t *dbh, zval *driver_options TSRMLS_DC) /* {{{ */
435 {
436         pdo_mysql_db_handle *H;
    (中略)
459         H = pecalloc(1, sizeof(pdo_mysql_db_handle), dbh->is_persistent);
    (中略)
472         dbh->driver_data = H;
473         H->max_buffer_size = 1024*1024;
474         H->buffered = H->emulate_prepare = 1;

という、初期化をしている

重要なのはここ

474         H->buffered = H->emulate_prepare = 1;

ということで、デフォルトではサーバサイドプリペアードステートメントを利用しないことが分かる。

対策

ソースコードより、

ext/pdo_mysql/mysql_driver.c の ''pdo_mysql_handle_factory'' 内で以下のように''driver_options'' が渡された場合に ''emulate_prepare'' を上書きする処理があるので、

PDOの構築時に、第4引数 ($driver_options)で、

  • array(PDO::ATTR_EMULATE_PREPARES => false)

または

  • array(PDO::MYSQL_ATTR_DIRECT_QUERY => false)

を渡してやればよい

477         if (driver_options) {
    (中略)
484                 H->emulate_prepare = pdo_attr_lval(driver_options,
485                         PDO_MYSQL_ATTR_DIRECT_QUERY, H->emulate_prepare TSRMLS_CC);
486                 H->emulate_prepare = pdo_attr_lval(driver_options, 
487                         PDO_ATTR_EMULATE_PREPARES, H->emulate_prepare TSRMLS_CC);

ちなみに、pdo_attr_lval は、ext/pdo/php_pdo_driver.h@190 で定義されているインライン関数。

array型である options の option_name 要素があればその値を返し、無ければ TSRMLS_DC を返す

190 static inline long pdo_attr_lval(zval *options, enum pdo_attribute_type option_name, long defval TSRMLS_DC)


あるいは


ext/pdo_mysql/mysql_driver.c@304 の ''pdo_mysql_set_attribute'' (msql利用PDOにおける PDO::setAttribute の実体) において、

304 static int pdo_mysql_set_attribute(pdo_dbh_t *dbh, long attr, zval *val TSRMLS_DC)
305 {
306         switch (attr) {
    (中略)
321         case PDO_MYSQL_ATTR_DIRECT_QUERY:
322         case PDO_ATTR_EMULATE_PREPARES:
323                 ((pdo_mysql_db_handle *)dbh->driver_data)->emulate_prepare = Z_BVAL_P(val);

とあるので、

PDO オブジェクト $pdo に対して、

  • $pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

あるいは

  • $pdo->setAttribute(PDO::MYSQL_ATTR_DIRECT_QUERY, false);

を実行すればよい

まとめ

  • PDO(mysql)をそのまま利用するだけだと、サーバサイドプリペアードステートメントは利用されない
  • 利用したいときは PDO::ATTR_EMULATE_PREPARES か PDO::MYSQL_ATTR_DIRECT_QUERY を false にしてあげる必要がある

以上