ThinkPHP 事务机制详解

事务泄漏 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 自增不随事务回滚)
  • markAsCompletedlogTask 等后续操作也在同一事务里,全部丢失

修复方案

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;  // 重新抛出,由外层处理
    }
}
相关推荐
安妮的小熊呢2 个月前
CRMEB BZ v6.0 使用教程:从安装部署到后台基础配置
php·thinkphp·电商系统·crmeb
withoutfear2 个月前
Fastadmin中获取IP和手机号归属地信息
php·thinkphp·fastadmin·ip归属地·手机号归属地
qq3186929963 个月前
ThinkPHP + Supervisor 队列任务丢失:僵尸 Worker 排查全记录
服务器·thinkphp·宝塔
linlinlove23 个月前
前端uniapp、后端thinkphp股票系统开发功能展示、代码披露、HQChart
前端·uni-app·echarts·thinkphp·hqchart·配资·deepseek选股票
SuperherRo3 个月前
服务攻防-开发框架安全&ThinkPHP&Laravel&SpringBoot&Struts2&SpringCloud&复现
spring boot·laravel·thinkphp·struts2·框架安全
quweiie3 个月前
thinkphp8生成海报
thinkphp·海报
云游云记4 个月前
FastAdmin 路由完全开启教程:去掉 index 前缀 + 优雅路由配置
thinkphp
kertag4 个月前
ThinkPHP 8 多应用入口绑定:BIND_MODULE vs $http->name() 全面解析
php·thinkphp
妙码生花5 个月前
全新的 TP8+Workerman+BuildAdmin 整合方案,已有近 2000 次下载使用。
websocket·php·thinkphp