Kodokon kodokon.com

PDO:预处理语句与事务

像生产环境那样配置 PDO,从结构上消除 SQL 注入,并用事务让你的写入操作变得可靠。

10 分钟 · 3 题

在 Kodokon 中打开本课

PDO 是统一的数据库访问接口:MySQL、PostgreSQL 或 SQLite 都用同一套 API 来驱动。三项设置把一个幼稚的连接和一个生产级的连接区分开来:ERRMODE_EXCEPTION 让每一个 SQL 错误都抛出异常,而不是悄无声息地失败;默认使用 FETCH_ASSOC 以得到干净的结果;以及把 EMULATE_PREPARES 设为 false,让预处理语句真正由服务器执行。直接在 DSN 中加上 charset=utf8mb4 - 这是唯一可靠的声明它的地方。

PHP
<?php

declare(strict_types=1);

$dsn = 'mysql:host=localhost;dbname=shop'
    . ';charset=utf8mb4';

$options = [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES => false,
];

$pdo = new PDO($dsn, 'app_user', 'secret', $options);
生产级连接:异常、关联数组取值、原生预处理。

预处理语句是你抵御 SQL 注入唯一从结构上有效的防线。原理如下:查询的结构,连同它的 :email? 占位符,会先被发送到服务器;数据则随后单独跟上,永远不会被当作 SQL 来解释。把一个变量拼接进查询里,哪怕它是经过"转义"的,仍然是一个专业级别的错误。命名占位符让代码更易读;对于简短的查询,位置占位符就足够了。

PHP
<?php

declare(strict_types=1);

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
    PDO::ATTR_ERRMODE,
    PDO::ERRMODE_EXCEPTION,
);

$pdo->exec(
    'CREATE TABLE users (
        id INTEGER PRIMARY KEY,
        email TEXT NOT NULL
    )'
);

$insert = $pdo->prepare(
    'INSERT INTO users (email) VALUES (:email)'
);
$insert->execute(['email' => 'lea@example.com']);

$query = $pdo->prepare(
    'SELECT id, email FROM users
     WHERE email = :email'
);
$query->execute(['email' => 'lea@example.com']);

var_dump($query->fetch(PDO::FETCH_ASSOC));
一个自包含的 SQLite 例子:预处理、绑定、执行。

事务保证原子性:要么所有写入都成功,要么一个都不生效。经典的写法:beginTransaction(),把工作放在 try 里,在代码块末尾 commit(),在 catchrollBack() - 然后你要重新抛出这个异常,因为隐藏失败比失败本身更糟糕。让事务保持简短:在一次网络调用或一段漫长处理过程中持有的每一把锁,都会拖累整个应用的并发能力。

PHP
<?php

declare(strict_types=1);

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
    PDO::ATTR_ERRMODE,
    PDO::ERRMODE_EXCEPTION,
);

$pdo->exec(
    'CREATE TABLE accounts (
        id INTEGER PRIMARY KEY,
        balance INTEGER NOT NULL
    )'
);
$pdo->exec(
    'INSERT INTO accounts (balance)
     VALUES (100), (20)'
);

$pdo->beginTransaction();

try {
    $debit = $pdo->prepare(
        'UPDATE accounts
         SET balance = balance - :n
         WHERE id = :id'
    );
    $debit->execute(['n' => 50, 'id' => 1]);

    $credit = $pdo->prepare(
        'UPDATE accounts
         SET balance = balance + :n
         WHERE id = :id'
    );
    $credit->execute(['n' => 50, 'id' => 2]);

    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

echo 'Transfer done';
原子转账:要么全部成功,要么全部回滚。

知识检测

确认你已牢记本课的重点内容。

  1. 为什么预处理语句能阻止 SQL 注入?
    • 它会自动转义字符串中的单引号
    • SQL 结构和数据分开传输:数据永远不会被当作 SQL 代码来解释
    • 它会在 PHP 和服务器之间加密查询
  2. 在预处理语句中,你可以通过 :param 占位符传入什么?
    • 一个值:字符串、数字或 null
    • 一个表名或列名
    • 一个完整的 ORDER BY 子句
  3. 在经典的事务写法中,你在 catch 块里要做什么?
    • commit() 保存已经成功的部分
    • rollBack(),然后重新抛出异常
    • 什么都不做:事务会自行失效