ThinkPHP 面试精讲:数据库事务、锁与并发场景处理

在 PHP 面试中,ThinkPHP 框架的数据库操作几乎是必考内容,而事务、锁与并发处理更是区分初级与中高级开发者的关键分水岭。很多候选人对 Db::startTrans() 的认知仅停留在“开启事务、提交、回滚”三步走,却无法回答“事务中锁怎么加”“并发下如何保证库存不超卖”“乐观锁和悲观锁在 ThinkPHP 中如何落地”等进阶问题。本文将从面试实战角度出发,系统梳理 ThinkPHP 中事务、锁与并发场景的核心考点。

一、ThinkPHP 事务机制:不只是 startTrans

ThinkPHP 5.x 和 6.x/8.x 在事务 API 上基本一致,核心方法包括:

  • Db::startTrans():开启事务
  • Db::commit():提交事务
  • Db::rollback():回滚事务
  • Db::transaction(function(){}):自动事务闭包

面试中常被追问的细节是:事务是否支持嵌套? 答案是 ThinkPHP 支持嵌套事务,底层通过 transTimes 计数器和 savepoint 实现。当嵌套层级大于 1 时,会创建保存点(savepoint),回滚时只回滚到对应保存点,而非整个事务。

另一个高频问题是:模型和 Db 类的事务是否互通? 在 ThinkPHP 中,Db::startTrans() 与模型操作共享同一个数据库连接实例,因此模型内的写操作同样受事务控制。但要注意,如果模型中使用了 connection 属性指定了不同数据库连接,则事务不会跨库生效。

Db::transaction(function () {
    Db::name('order')->insert($orderData);
    Db::name('stock')->where('id', 1)->setDec('num', 1);
});

闭包内任何异常都会自动触发回滚,这是面试中推荐的标准写法。

二、ThinkPHP 中的锁:悲观锁与乐观锁

1. 悲观锁:lock(true) 的正确使用

ThinkPHP 提供了 lock(true) 方法,用于在查询时添加 FOR UPDATE 排他锁:

Db::name('stock')->where('id', 1)->lock(true)->find();

面试陷阱题:lock(true) 必须在事务内使用吗? 是的。如果不在事务中执行,FOR UPDATE 会立即释放锁,失去意义。此外,lock(true) 依赖 InnoDB 引擎,MyISAM 不支持行锁,只会锁表。

还需要注意:lock(true) 查询的字段如果未走索引,InnoDB 会退化为表锁,这是并发场景下性能骤降的常见原因。面试官往往借此考察候选人对索引与锁粒度关系的理解。

2. 乐观锁:版本号字段的经典实现

ThinkPHP 没有内置乐观锁方法,需要开发者手动实现。典型做法是在表中增加 version 字段:

$affected = Db::name('stock')
    ->where('id', 1)
    ->where('version', $version)
    ->update([
        'num'     => $num - 1,
        'version' => $version + 1,
    ]);

if (!$affected) {
    // 更新失败,说明数据已被其他请求修改,需重试或抛出异常
}

乐观锁适合读多写少的场景,面试中常与悲观锁对比:悲观锁吞吐量低但一致性强,乐观锁并发高但需要处理失败重试。

三、并发场景实战:库存超卖与订单重复

场景一:库存超卖

这是最经典的并发面试题。假设库存表 stock 有字段 num,多个请求同时下单。

错误写法:

$stock = Db::name('stock')->where('id', 1)->find();
if ($stock['num'] > 0) {
    Db::name('stock')->where('id', 1)->setDec('num', 1);
}

先查后减,在并发下必然超卖。

正确写法一:原子操作

$affected = Db::name('stock')
    ->where('id', 1)
    ->where('num', '>', 0)
    ->setDec('num', 1);

if (!$affected) {
    throw new Exception('库存不足');
}

利用 UPDATE ... WHERE num > 0 的原子性,配合 setDec 直接完成判断与扣减。

正确写法二:悲观锁

Db::startTrans();
try {
    $stock = Db::name('stock')->where('id', 1)->lock(true)->find();
    if ($stock['num'] <= 0) {
        throw new Exception('库存不足');
    }
    Db::name('stock')->where('id', 1)->setDec('num', 1);
    Db::commit();
} catch (Exception $e) {
    Db::rollback();
}

场景二:订单重复提交

除了前端防抖,后端常用唯一索引 + 事务兜底。例如对 user_id + goods_id + 时间窗口 建立唯一索引,插入时捕获 Duplicate entry 异常,避免重复下单。

四、面试高频追问与避坑指南

  1. 事务中调用外部 HTTP 接口会怎样? 事务长时间不提交会持有锁,导致其他请求阻塞,甚至锁等待超时。正确做法是将外部调用移出事务。
  2. ThinkPHP 的 setInc/setDec 是原子操作吗? 是,底层生成 UPDATE ... SET num = num + 1,但前提是 where 条件本身能保证并发安全。
  3. 事务隔离级别如何设置? ThinkPHP 默认使用数据库的隔离级别(MySQL InnoDB 默认为 RR)。可通过原生 SQL SET TRANSACTION ISOLATION LEVEL 调整,但通常不建议在应用层随意修改。
  4. lock(true) 和 shared lock 的区别? ThinkPHP 未直接提供共享锁方法,需用原生 lock('LOCK IN SHARE MODE') 实现,共享锁允许其他事务读但禁止写。

五、总结

ThinkPHP 的事务与锁机制并不复杂,但面试考察的是候选人能否在真实并发场景中做出正确决策。核心要点可归纳为:

  • 事务必须成对出现,推荐使用 Db::transaction 闭包;
  • 悲观锁 lock(true) 必须配合事务和索引使用;
  • 乐观锁通过 version 字段实现,适合读多写少;
  • 库存扣减优先使用原子 UPDATE,必要时叠加悲观锁;
  • 事务内避免外部 IO,防止锁持有时间过长。

掌握这些要点,不仅能从容应对面试,更能在实际项目中写出经得起并发考验的代码。

未经允许不得转载:任鹏个人博客 » ThinkPHP 面试精讲:数据库事务、锁与并发场景处理

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏