事务泄漏 Bug 总结
原代码问题
QyWechatSync.php QyWechatSync.php 中 syncUsers() 方法处理每个成员的逻辑:
php
// 原代码(Bug)
Db::startTrans(); // ① 开启事务,计数器 +1
try {
if (isset($userMap[$qyUserid])) {
// ... 更新已有用户
} else {
if (empty($mobile)) {
$skipped++;
continue; // ② ⚠️ 直接跳过!跳过了下面的 commit()
}
// ... 新增用户
}
Db::commit(); // ③ 永远不会被执行
} catch (\Exception $e) {
Db::rollback();
}
问题本质
ThinkPHP 事务是计数器机制 :startTrans() +1,commit() -1,只有计数器归零才真正执行 SQL COMMIT。
当 continue 触发时(遇到无手机号的成员,如朱敬),commit() 被跳过,计数器永远 ≥1。之后整个请求生命周期内所有数据库操作都被包裹在这个未提交的事务里,脚本结束时全部回滚。
连锁影响
kotlin
第1次执行: 同步5人 → 朱敬触发continue → 事务泄漏 → 全部回滚
第2次执行: 同步5人 → 朱敬触发continue → 事务泄漏 → 全部回滚
第N次执行: 同步5人 → 同上
- 日志显示"执行成功"(因为业务逻辑代码本身跑完了)
- 数据库无数据(事务回滚了)
- 自增 ID 持续增长(MySQL 自增不随事务回滚)
markAsCompleted、logTask等后续操作也在同一事务里,全部丢失
修复方案
将 empty($mobile) 检查移到 startTrans() 之前:
php
// 修复后
$isNewUser = !isset($userMap[$qyUserid]);
if ($isNewUser && empty($mobile)) {
$skipped++;
continue; // 事务未开启,直接跳过无副作用
}
Db::startTrans(); // 到这里说明一定能走到 commit
try {
// ... 业务逻辑
Db::commit(); // ✅ 一定执行
} catch (\Exception $e) {
Db::rollback();
}
核心原则:startTrans() 之后,try 代码块内不能有任何 continue/return 跳过 commit()。
ThinkPHP 事务机制详解
一、核心设计:计数器模式
ThinkPHP 的事务不是 直接映射到 SQL 的 BEGIN / COMMIT,而是用一个嵌套计数器管理:
scss
startTrans() → 计数器 +1
commit() → 计数器 -1(归零时真正执行 SQL COMMIT)
rollback() → 计数器归零(真正执行 SQL ROLLBACK)
这是为了支持多层嵌套事务而设计的。
二、执行流程图解
场景 1:单层事务
php
Db::startTrans(); // 计数器: 0 → 1
INSERT INTO ...; // 在事务中
Db::commit(); // 计数器: 1 → 0,真正执行 COMMIT
场景 2:嵌套事务
php
Db::startTrans(); // 计数器: 0 → 1
Db::startTrans(); // 计数器: 1 → 2
INSERT INTO ...;
Db::commit(); // 计数器: 2 → 1,不提交!
INSERT INTO ...;
Db::commit(); // 计数器: 1 → 0,真正执行 COMMIT
关键点 :只有计数器归零才真正提交。内层 commit() 只是减计数器,并不提交数据。
场景 3:嵌套回滚
php
Db::startTrans(); // 计数器: 0 → 1
Db::startTrans(); // 计数器: 1 → 2
INSERT INTO ...;
Db::rollback(); // 计数器: 2 → 0,立刻 ROLLBACK!
INSERT INTO ...; // ❌ 不在事务中
Db::commit(); // 计数器已是 0,执行 COMMIT(无意义)
关键点 :任何一层 rollback() 都会让所有层回滚,计数器直接归零。
三、常见陷阱
❌ 陷阱 1:startTrans() 后用 continue/return 跳过 commit()
php
foreach ($users as $user) {
Db::startTrans(); // 计数器 +1
try {
if (empty($user['mobile'])) {
continue; // ⚠️ 跳过 commit!
}
// ... 业务逻辑
Db::commit(); // 永远不执行
} catch (\Exception $e) {
Db::rollback();
}
}
// 循环结束,计数器仍为 N,所有操作都未提交!
正确做法 :在 startTrans() 之前做检查
php
foreach ($users as $user) {
if (empty($user['mobile'])) {
continue; // 事务未开启,安全跳过
}
Db::startTrans();
try {
// ... 业务逻辑
Db::commit();
} catch (\Exception $e) {
Db::rollback();
}
}
❌ 陷阱 2:try 块内 return 跳过 commit()
php
Db::startTrans();
try {
if (!$condition) {
return false; // ⚠️ 跳过 commit
}
// ...
Db::commit();
} catch (\Exception $e) {
Db::rollback();
}
正确做法 :在 try 之前检查条件,或在 return 前手动 rollback
❌ 陷阱 3:异常被 catch 但忘了 rollback
php
Db::startTrans();
try {
// ...
throw new \Exception('错误');
Db::commit(); // 不会执行
} catch (\Exception $e) {
Log::error($e->getMessage());
// ⚠️ 忘了 Db::rollback()!
}
// 计数器仍为 1,后续操作都在未提交事务中
四、最佳实践
原则 1:startTrans() 之前不做事务外的条件判断
php
// ✅ 正确
if (!$eligible) {
continue;
}
Db::startTrans();
try {
// ...
Db::commit();
} catch (\Exception $e) {
Db::rollback();
}
原则 2:try 块内绝不使用 continue/return
如果必须提前退出,先 rollback:
php
Db::startTrans();
try {
if (!$eligible) {
Db::rollback(); // 先回滚
continue; // 再跳过
}
// ...
Db::commit();
} catch (\Exception $e) {
Db::rollback();
}
原则 3:catch 中必须有 rollback
php
try {
Db::startTrans();
// ...
Db::commit();
} catch (\Exception $e) {
Db::rollback(); // ✅ 不可省略
}
原则 4:使用模型的 safe 方法或手动管理
php
// 方式 1:手动管理(灵活,推荐循环中使用)
Db::startTrans();
try {
// ...
Db::commit();
} catch (\Exception $e) {
Db::rollback();
}
// 方式 2:闭包式(自动管理,不会忘记 commit/rollback)
Db::transaction(function () {
// ... 业务逻辑
});
Db::transaction() 是 ThinkPHP 5.0+ 提供的闭包式事务,自动处理 commit/rollback,是更安全的写法。
五、Db::transaction() 闭包式事务
php
// 自动管理,闭包正常返回则 commit,抛异常则 rollback
Db::transaction(function () use ($data) {
Db::name('user')->insert($data);
Db::name('ticket_user')->insert($ticketData);
});
内部实现原理:
php
public static function transaction(\Closure $callback)
{
self::startTrans();
try {
$callback();
self::commit();
} catch (\Exception $e) {
self::rollback();
throw $e; // 重新抛出,由外层处理
}
}