1. 项目概述:为什么在 Ubuntu 18.04 上用 PDO 做 MySQL 事务不是“选修课”,而是必修基本功
PHP 开发者在 Ubuntu 18.04 环境下操作 MySQL,如果还停留在
mysql_query()
或
mysqli_query()
单条执行、靠
if/else
手动判断成败的阶段,相当于开车只踩油门不踩刹车——短期能跑,但一遇到转账、库存扣减、订单创建这类多步强一致场景,数据错乱就是分分钟的事。PDO(PHP Data Objects)不是什么高大上的新玩具,它是 PHP 官方自 5.1 起就内置、至今仍是生产环境事实标准的数据访问抽象层。而事务(transacciones),正是它最不可替代的核心能力:把一组 SQL 操作打包成一个原子单元——要么全部成功,要么全部回滚,中间任何一步失败,数据库状态自动退回到事务开始前的干净快照。我在给一家本地电商做库存同步模块时就吃过亏:没加事务,用户下单成功但库存没扣减,结果同一商品被超卖三次;后来改用 PDO 事务,配合
try/catch
和
rollback()
,上线三个月零库存异常。Ubuntu 18.04 虽然已进入 EOL(2023 年 4 月终止支持),但大量遗留系统、教学环境、Docker 容器镜像仍在使用它,它的 LAMP 栈(Linux + Apache + MySQL + PHP)组合稳定、文档成熟、调试工具链完整,恰恰是练手事务机制的最佳沙盒。你不需要懂底层 MVCC 或 WAL 日志,只需要明白三件事:第一,PDO 是统一接口,换 PostgreSQL 只需改 DSN 字符串;第二,
beginTransaction()
、
commit()
、
rollback()
这三个方法就是事务的开关、确认键和紧急制动阀;第三,Ubuntu 18.04 的 PHP 7.2 默认已启用 PDO 扩展,但 MySQL 驱动(pdo_mysql)需要单独确认是否加载——这恰恰是很多人卡住的第一步。接下来我会从环境验证、代码结构、错误处理到真实业务场景,带你把这套机制焊进肌肉记忆里。
2. 环境准备与扩展验证:别急着写代码,先让系统“开口说话”
2.1 确认 PHP 版本与 PDO 基础状态
Ubuntu 18.04 默认安装的是 PHP 7.2,这是关键前提。高版本 PHP(如 8.x)语法更简洁,但 7.2 的兼容性对老项目更友好,且事务逻辑完全一致。先打开终端,敲两行命令:
php -v
php -m | grep pdo
第一行输出应类似
PHP 7.2.24-0ubuntu0.18.04.12
,第二行应看到
pdo
(表示 PDO 核心扩展已加载)。如果
pdo
没出现,说明 PHP 编译时没启用——但 Ubuntu 官方包默认都启了,大概率是你装了多个 PHP 版本,当前 CLI 使用的是另一个。用
which php
查路径,再用
php --ini
看配置文件位置,重点检查
Loaded Configuration File
指向的
php.ini
文件里有没有
extension=pdo.so
这一行(通常在
/etc/php/7.2/cli/php.ini
)。没有就手动加上,保存后重启终端或运行
php -v
验证。
提示:别用
sudo service apache2 restart来测试 CLI 环境!CLI 和 Web 服务器(Apache/NGINX)的 PHP 配置是分开的。CLI 用php -m,Web 端用<?php phpinfo(); ?>输出页面查,两者要分别确认。
2.2 关键一步:验证 pdo_mysql 驱动是否就位
PDO 是个壳,真正干活的是驱动(driver)。
pdo_mysql
就是连接 MySQL 的那根“电线”。光有
pdo
不行,必须有
pdo_mysql
。继续在终端执行:
php -m | grep mysql
理想输出是
pdo_mysql
(注意不是
mysql
或
mysqli
)。如果没看到,说明驱动没加载。去
php.ini
文件里找扩展目录(
extension_dir
),通常是
/usr/lib/php/20170718/
(PHP 7.2 的模块目录名带时间戳),然后检查该目录下是否存在
pdo_mysql.so
文件:
ls -l /usr/lib/php/20170718/pdo_mysql.so
存在就直接在
php.ini
里加一行
extension=pdo_mysql.so
;如果不存在,说明
php-mysql
包没装。Ubuntu 下执行:
sudo apt update && sudo apt install php-mysql
这个包会自动安装
pdo_mysql.so
并在
php.ini
中添加对应行(通常在
/etc/php/7.2/mods-available/pdo_mysql.ini
,并通过符号链接启用)。装完后,
必须重启 Apache 或 PHP-FPM
(如果是 NGINX)才能生效:
sudo systemctl restart apache2
# 或者
sudo systemctl restart php7.2-fpm
注意:
php-mysql包名在 Ubuntu 18.04 是准确的,不要用php7.2-mysql(那是旧版命名)。我曾在一个客户服务器上因包名写错导致折腾两小时,最后发现apt list --installed | grep mysql显示的是php-mysql,立刻纠正。
2.3 MySQL 服务与用户权限检查:事务不是“免检产品”
PDO 事务依赖 MySQL 的存储引擎支持。MyISAM 引擎不支持事务,InnoDB 才是唯一选择。登录 MySQL 控制台:
mysql -u root -p
执行:
SHOW ENGINES;
确保
InnoDB
行的
Support
列是
DEFAULT
或
YES
。接着检查你要操作的表:
SHOW CREATE TABLE tu_tabla;
看
ENGINE=InnoDB
是否存在。如果不是,用
ALTER TABLE tu_tabla ENGINE=InnoDB;
转换。更重要的是用户权限:普通用户必须有
INSERT
,
UPDATE
,
DELETE
权限,但事务本身(
START TRANSACTION
,
COMMIT
,
ROLLBACK
)不需要额外权限,只要能连上库就行。不过,我建议创建专用测试用户,避免用
root
:
CREATE USER 'testuser'@'localhost' IDENTIFIED BY 'strongpassword';
GRANT SELECT, INSERT, UPDATE, DELETE ON testdb.* TO 'testuser'@'localhost';
FLUSH PRIVILEGES;
这样既安全,又能在出错时快速定位是权限问题还是逻辑问题。
3. PDO 事务核心代码结构:从“Hello World”到银行级严谨
3.1 最简事务骨架:三行代码讲清原子性本质
别一上来就写复杂业务。先用一个极简例子,证明事务真的能“撤销”:
<?php
$dsn = 'mysql:host=localhost;dbname=testdb;charset=utf8mb4';
$user = 'testuser';
$pass = 'strongpassword';
try {
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4"
]);
// 1. 开启事务
$pdo->beginTransaction();
// 2. 执行两条 SQL:插入一条记录,再故意让第二条失败(字段不存在)
$pdo->exec("INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com')");
$pdo->exec("INSERT INTO users (name, email, non_existent_field) VALUES ('Bob', 'bob@example.com')"); // 这行会报错
// 3. 如果走到这里,说明上面都成功了,提交事务
$pdo->commit();
echo "¡Transacción completada con éxito!";
} catch (PDOException $e) {
// 4. 一旦 catch 到异常,立刻回滚
$pdo->rollback();
echo "¡Error en la transacción! Se ha revertido: " . $e->getMessage();
}
?>
运行这段代码,你会看到错误提示,且
users
表里
不会出现 Alice 的记录
。这就是事务的原子性:第二条 SQL 失败,第一条的插入也被撤销。关键点在于
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
—— 这是整个事务机制的基石。如果不设这个属性,PDO 默认是静默模式(
PDO::ERRMODE_SILENT
),
exec()
失败只返回
false
,你得手动
if (!$pdo->exec(...)) { $pdo->rollback(); }
,极易遗漏。用异常模式,
catch
一兜底,逻辑清晰无比。
3.2 真实业务场景:模拟电商下单的四步原子操作
现在升级到真实需求。一个订单创建涉及:1) 扣减商品库存;2) 创建订单主表;3) 创建订单明细表;4) 更新用户积分。任何一步失败,前面的操作都得撤销。代码如下:
<?php
// 假设已建立 $pdo 连接(同上)
function crearOrden($pdo, $usuario_id, $producto_id, $cantidad, $precio_unitario) {
try {
// 开启事务
$pdo->beginTransaction();
// 步骤1:检查并扣减库存(使用 SELECT ... FOR UPDATE 锁定行,防止并发超卖)
$stmt = $pdo->prepare("SELECT stock FROM productos WHERE id = ? FOR UPDATE");
$stmt->execute([$producto_id]);
$producto = $stmt->fetch();
if (!$producto || $producto['stock'] < $cantidad) {
throw new Exception("Stock insuficiente para el producto ID {$producto_id}");
}
$nuevo_stock = $producto['stock'] - $cantidad;
$pdo->prepare("UPDATE productos SET stock = ? WHERE id = ?")->execute([$nuevo_stock, $producto_id]);
// 步骤2:创建订单主表
$stmt = $pdo->prepare("INSERT INTO pedidos (usuario_id, total, estado) VALUES (?, ?, 'pendiente')");
$stmt->execute([$usuario_id, $cantidad * $precio_unitario]);
$pedido_id = $pdo->lastInsertId();
// 步骤3:创建订单明细
$stmt = $pdo->prepare("INSERT INTO pedidos_detalles (pedido_id, producto_id, cantidad, precio_unitario) VALUES (?, ?, ?, ?)");
$stmt->execute([$pedido_id, $producto_id, $cantidad, $precio_unitario]);
// 步骤4:更新用户积分(假设每消费 1 元加 1 积分)
$pdo->prepare("UPDATE usuarios SET puntos = puntos + ? WHERE id = ?")->execute([$cantidad * $precio_unitario, $usuario_id]);
// 所有步骤成功,提交
$pdo->commit();
return ['success' => true, 'pedido_id' => $pedido_id];
} catch (Exception $e) {
// 回滚所有变更
$pdo->rollback();
error_log("Error al crear orden: " . $e->getMessage());
return ['success' => false, 'error' => $e->getMessage()];
}
}
// 调用示例
$resultado = crearOrden($pdo, 123, 456, 2, 99.99);
if ($resultado['success']) {
echo "¡Orden #{$resultado['pedido_id']} creada exitosamente!";
} else {
echo "Fallo: {$resultado['error']}";
}
?>
这段代码体现了事务的实战要点:
SELECT ... FOR UPDATE
是关键,它在读取库存时就加了行锁,确保并发请求不会同时读到旧库存值;
lastInsertId()
获取刚插入订单的 ID,用于关联明细表;
error_log()
记录错误而非直接输出,符合生产环境日志规范。整个函数返回结构化数组,调用方可以轻松判断结果。
3.3 事务隔离级别与并发控制:当两个用户同时抢最后一瓶可乐
MySQL 默认隔离级别是
REPEATABLE READ
,对大多数应用足够。但极端高并发下,可能遇到“幻读”(Phantom Read):事务 A 查询
stock > 0
得到 1,事务 B 同时也查到 1 并扣减为 0,A 再执行
UPDATE
时发现
stock
已是 0,但 A 的
SELECT
结果还是旧的。解决方案有两个:
-
升级隔离级别 :在
beginTransaction()后立即设置:$pdo->exec("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE");这是最强级别,但性能开销大,会锁更多资源。
-
更推荐:应用层重试 + 乐观锁 :在
UPDATE语句中加入条件检查:$stmt = $pdo->prepare("UPDATE productos SET stock = stock - ? WHERE id = ? AND stock >= ?"); $filas_afectadas = $stmt->execute([$cantidad, $producto_id, $cantidad]); if ($filas_afectadas == 0) { throw new Exception("Stock insuficiente o ya modificado por otro proceso"); }这样,即使并发,
UPDATE也会因WHERE条件不满足而返回 0 行影响,throw出错,事务回滚。我在线上秒杀系统中就用这个方案,比SERIALIZABLE更轻量,且错误更明确。
4. 错误处理与调试技巧:当事务“静悄悄”失败时怎么办
4.1 常见陷阱与排查清单
事务失败往往不报错,而是逻辑错误。以下是我在 Ubuntu 18.04 环境下踩过的坑,按发生频率排序:
| 问题现象 | 根本原因 | 快速诊断方法 | 解决方案 |
|---|---|---|---|
INSERT
成功但
UPDATE
失败,数据却没回滚
|
PDO::ATTR_ERRMODE
没设为
EXCEPTION
,
UPDATE
返回
false
但没被捕获
|
在
exec()
后加
var_dump($pdo->errorCode());
,非
00000
表示错误
|
严格设置
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
|
事务内执行
SELECT
后,
UPDATE
报
Lock wait timeout exceeded
|
SELECT ... FOR UPDATE
锁未释放,其他事务在等锁
|
SHOW PROCESSLIST;
查看
State
为
Locked
的线程;
SELECT * FROM information_schema.INNODB_TRX;
查活跃事务
|
缩短事务范围,
SELECT
后尽快
UPDATE
,避免在事务中做耗时操作(如 API 调用)
|
rollback()
执行后,
SELECT
还能看到刚插入的数据
| 用了 MyISAM 表,或表引擎转换没生效 |
SHOW CREATE TABLE nombre_tabla;
确认
ENGINE=InnoDB
|
ALTER TABLE nombre_tabla ENGINE=InnoDB;
并检查
innodb_file_per_table=ON
(Ubuntu 18.04 默认开启)
|
beginTransaction()
报
SQLSTATE[HY000]: General error: 2014 Cannot execute queries while other unbuffered queries are active
|
前一个
SELECT
语句没
fetch()
完,结果集未释放
|
在
SELECT
后强制
fetch()
或
fetchAll()
,或用
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true
|
在 PDO 构造参数中添加
'PDO::MYSQL_ATTR_USE_BUFFERED_QUERY' => true
|
实操心得:Ubuntu 18.04 的 MySQL 5.7 默认
wait_timeout=28800(8小时),但innodb_lock_wait_timeout=50(50秒)。如果你的事务里有sleep(60)这种调试代码,50秒后就会被强制 kill,报Lock wait timeout。线上绝对禁用sleep,调试用usleep(10000)(10毫秒)代替。
4.2 日志与监控:让事务行为“看得见”
光靠
echo
和
var_dump
调试事务是原始的。Ubuntu 18.04 提供了强大的日志工具:
-
MySQL 通用查询日志 (临时开启,查事务流):
SET GLOBAL general_log = 'ON'; SET GLOBAL log_output = 'TABLE'; -- 日志写入 mysql.general_log 表,方便查然后执行你的 PHP 脚本,再查:
SELECT * FROM mysql.general_log WHERE argument LIKE '%BEGIN%' OR argument LIKE '%COMMIT%' OR argument LIKE '%ROLLBACK%' ORDER BY event_time DESC LIMIT 20;你能清晰看到
BEGIN、INSERT、UPDATE、COMMIT或ROLLBACK的完整序列。 -
PHP 错误日志 :Ubuntu 18.04 的 Apache 日志在
/var/log/apache2/error.log。确保php.ini中log_errors = On且error_log = /var/log/php_errors.log(自定义路径更清晰)。在catch块里用error_log("Transaccion fallida: " . $e->getMessage(), 3, "/var/log/php_errors.log");。 -
慢查询日志 (定位长事务):
SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1; -- 超过1秒记为慢查询日志文件默认在
/var/log/mysql/mysql-slow.log,用mysqldumpslow -s t /var/log/mysql/mysql-slow.log可分析。
4.3 性能优化:事务不是越长越好
事务持有锁的时间越长,阻塞越多。我的经验是: 事务内只做数据库操作,绝不做 I/O、网络、计算密集型任务 。例如:
❌ 错误示范(事务内调用外部 API):
$pdo->beginTransaction();
$pdo->exec("UPDATE orders SET status='processing' WHERE id=123");
// 调用支付网关 API(可能耗时数秒)
$result = file_get_contents('https://api.payment.com/pay?order=123');
$pdo->exec("UPDATE orders SET status='paid' WHERE id=123");
$pdo->commit();
✅ 正确做法(拆分事务):
// 第一阶段:本地事务,标记为待支付
$pdo->beginTransaction();
$pdo->exec("UPDATE orders SET status='pending_payment' WHERE id=123");
$pdo->commit();
// 第二阶段:独立流程,调用 API
$result = callPaymentAPI(123);
// 第三阶段:根据 API 结果,再启事务更新状态
if ($result['success']) {
$pdo->beginTransaction();
$pdo->exec("UPDATE orders SET status='paid', payment_id=? WHERE id=123", [$result['payment_id']]);
$pdo->commit();
} else {
$pdo->beginTransaction();
$pdo->exec("UPDATE orders SET status='failed' WHERE id=123");
$pdo->commit();
}
这样,数据库锁只持有一瞬间,系统吞吐量提升数倍。我在一个日均 50 万订单的系统里,就是靠这种“微事务”设计扛住了流量高峰。
5. 进阶实践与避坑指南:从能用到好用的跨越
5.1 自动化事务管理:封装成可复用的 Trait
每次写
beginTransaction()
/
commit()
/
rollback()
很重复。PHP 5.4+ 的 Trait 可以优雅解决。创建
TransactionTrait.php
:
<?php
trait TransactionTrait
{
protected $pdo;
/**
* Ejecuta una función dentro de una transacción
* @param callable $callback Función que recibe $this->pdo como parámetro
* @return mixed Resultado de la función, o lanza excepción si falla
*/
public function transaction(callable $callback)
{
$this->pdo->beginTransaction();
try {
$result = $callback($this->pdo);
$this->pdo->commit();
return $result;
} catch (Exception $e) {
$this->pdo->rollback();
throw $e;
}
}
}
在你的 DAO 类中使用:
class OrderDAO
{
use TransactionTrait;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
public function createOrder($data)
{
return $this->transaction(function ($pdo) use ($data) {
// Aquí va todo el código de creación de orden, sin begin/commit/rollback
$pdo->exec("INSERT INTO orders ...");
$pdo->exec("UPDATE inventory ...");
return ['order_id' => $pdo->lastInsertId()];
});
}
}
调用时
new OrderDAO($pdo)->createOrder($data);
,事务逻辑完全隐藏,业务代码极度干净。Trait 比继承更灵活,一个类可同时用多个 Trait。
5.2 与现代框架集成:Laravel 的 DB::transaction() 底层就是它
虽然标题是原生 PHP,但理解底层对用框架至关重要。Laravel 的
DB::transaction()
方法,其核心源码(简化)就是:
public static function transaction(Closure $callback, $attempts = 1)
{
for ($i = 0; $i < $attempts; $i++) {
try {
static::connection()->beginTransaction();
$result = $callback(static::connection());
static::connection()->commit();
return $result;
} catch (Throwable $e) {
static::connection()->rollback();
if ($i >= $attempts - 1) throw $e;
}
}
}
完全就是我们手写的逻辑,只是加了重试机制。所以,当你在 Laravel 里写:
DB::transaction(function () {
User::create([...]);
Account::decrement('balance', 100);
});
背后就是 PDO 的
beginTransaction()
→
INSERT
→
UPDATE
→
commit()
。知道这个,你就能在框架出问题时,直奔 PDO 层调试,而不是在框架迷宫里打转。
5.3 安全加固:防止 SQL 注入与事务滥用
事务本身不防注入,但 PDO 的预处理语句是黄金搭档。永远不要拼接 SQL:
❌ 危险:
$nombre = $_POST['nombre'];
$pdo->exec("INSERT INTO users (name) VALUES ('$nombre')");
✅ 安全(事务内也一样):
$nombre = $_POST['nombre'];
$stmt = $pdo->prepare("INSERT INTO users (name) VALUES (?)");
$stmt->execute([$nombre]);
另外,警惕“事务滥用”:不是所有操作都要事务。比如单纯
SELECT
统计报表、
INSERT
日志记录(允许丢失),加事务反而降低性能。我的原则是:
只有涉及“资金、库存、状态变更”且要求强一致性的操作,才上事务
。一个
INSERT
用户注册日志,用
autocommit=true
直接执行即可。
最后一个小技巧:Ubuntu 18.04 的 MySQL 5.7 默认
autocommit=1,即每个 SQL 是独立事务。beginTransaction()会自动关闭autocommit,commit()/rollback()后会自动恢复autocommit=1。所以你不必手动SET autocommit=0,PDO 已帮你管好了。这点很多教程没说清,导致新手以为要手动开关。
我在实际项目中发现,把事务逻辑从“能跑通”做到“稳如磐石”,关键不在代码多炫酷,而在对
PDO::ERRMODE_EXCEPTION
的死守、对
InnoDB
引擎的确认、对
SELECT ... FOR UPDATE
的合理运用,以及把耗时操作坚决踢出事务外。Ubuntu 18.04 虽老,但它的稳定性和成熟的调试工具链,恰恰是磨炼这些基本功的最佳环境。当你能在终端里
tail -f /var/log/mysql/error.log
实时看到事务的
COMMIT
和
ROLLBACK
,你就真正掌握了这门手艺。

143

被折叠的 条评论
为什么被折叠?



