hyperf 3.1 中数据库事务必须用 db::transaction() 包裹,禁止手动 begin/commit;需参数绑定防注入、读写分离时显式走主库、避免嵌套事务、设置超时防止锁表。

Hyperf 3.1 中数据库事务在协程环境下极易因连接复用、静态状态残留或异常未归还导致数据错乱、连接泄漏甚至 MySQL gone away 错误,必须严格遵循协程安全的事务写法才能保障多请求并发下的数据一致性与连接池健康。
事务必须用 DB::transaction() 包裹,禁止手动 begin/commit
Hyperf 的数据库连接池是协程级复用的,手动调用 DB::beginTransaction() 后若协程中途异常退出,连接不会自动释放,该连接将长期被占用,直到协程销毁——而长生命周期协程(如定时任务、WebSocket 连接)可能存活数小时,直接拖垮连接池。
DB::transaction() 内部通过 try/finally 确保无论成功或抛异常,连接都会归还到池中;手动写法则完全绕过这套保护机制。
错误写法:DB::beginTransaction(); ... DB::commit(); → 【连接泄漏高危操作】
正确写法:直接使用闭包形式,让框架接管生命周期。
执行路径:DB::transaction(function () use ($order_id, $amount) { $user = User::find($order_id); $user->balance -= $amount; $user->save(); });
事务内原始 SQL 必须参数绑定,严禁字符串拼接
很多人以为“用了事务就天然防注入”,其实事务只管原子性,不参与 SQL 构建过程。字符串拼接进原始 SQL,哪怕在 transaction 里,照样触发 SQL 注入。
方法一:问号占位符绑定(顺序严格)
DB::update("UPDATE account SET balance = balance - ? WHERE id = ?", [$amount, $user_id]);
方法二:命名占位符绑定(推荐,可读性强、支持重复变量)
DB::select("SELECT * FROM orders WHERE status = :status AND created_at >= :start", ['status' => 'paid', 'start' => '2026-01-01']);
【Db::raw() 不是绑定接口,不能用于用户输入】 —— Db::raw("'{$name}'") 和直接拼接无异,它只是告诉查询构造器“这段跳过转义”,但外面仍是裸字符串。
读写分离场景下,事务强制走主库
Hyperf 默认读写分离时,where()->first()、all() 等查询走从库,但事务内所有操作必须走主库,否则会出现刚插入记录、事务内查不到的幻读问题。
第一步:确认 config/autoload/database.php 中已启用读写分离配置('read' 和 'write' 分开定义)
第二步:在事务闭包内对模型显式调用 ->useWritePdo()
DB::transaction(function () { $order = Order::where('sn', 'SN20260806001')->useWritePdo()->first(); $order->status = 'shipped'; $order->save(); });
第三步:如果用 DB::table(),需手动指定连接名:DB::connection('mysql-write')->table('orders')->where(...)->update(...);
嵌套事务不生效,协程内禁止多层 transaction()
MySQL 本身不支持真正的嵌套事务,Hyperf 的 DB::transaction() 在已有事务上下文中会静默降级为 savepoint,但 savepoint 在协程异常中断时无法保证回滚完整性,极易引发部分提交、数据残留。
常见踩坑点:Service A 调用 transaction(),内部又调用 Service B 的另一个 transaction() —— 外层回滚时,内层 savepoint 可能未被清理,下次协程复用该连接时残留状态污染新事务。
解决方式:只保留最外层一个事务,内部逻辑全部作为原子操作写入同一闭包;若需拆分逻辑,改用业务层校验 + 单次 commit 控制粒度。
这一步操作起来很简单,直接把所有 DB 操作合并进同一个 DB::transaction() 闭包里就行。
事务超时必须显式设 limit,避免长事务锁表
Hyperf 默认不限制事务执行时间,一个耗时 30 秒的事务会持续持有行锁或表锁,阻塞其他协程的相同资源访问,最终触发 MySQL deadlock 或 Lock wait timeout exceed。
在事务闭包开头插入超时控制:
$startTime = microtime(true);
DB::transaction(function () use ($startTime) {
if (microtime(true) - $startTime > 5.0) {
throw new RuntimeException('Transaction timeout');
}
// 正常业务逻辑
});
更优方案:配合 MySQL 的 innodb_lock_wait_timeout 参数(建议设为 5),并在 PHP 层捕获 PDOException 中的 "Lock wait timeout exceeded" 并重试。











