在 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 异常,避免重复下单。
四、面试高频追问与避坑指南
- 事务中调用外部 HTTP 接口会怎样? 事务长时间不提交会持有锁,导致其他请求阻塞,甚至锁等待超时。正确做法是将外部调用移出事务。
- ThinkPHP 的
setInc/setDec是原子操作吗? 是,底层生成UPDATE ... SET num = num + 1,但前提是 where 条件本身能保证并发安全。 - 事务隔离级别如何设置? ThinkPHP 默认使用数据库的隔离级别(MySQL InnoDB 默认为 RR)。可通过原生 SQL
SET TRANSACTION ISOLATION LEVEL调整,但通常不建议在应用层随意修改。 lock(true)和shared lock的区别? ThinkPHP 未直接提供共享锁方法,需用原生lock('LOCK IN SHARE MODE')实现,共享锁允许其他事务读但禁止写。
五、总结
ThinkPHP 的事务与锁机制并不复杂,但面试考察的是候选人能否在真实并发场景中做出正确决策。核心要点可归纳为:
- 事务必须成对出现,推荐使用
Db::transaction闭包; - 悲观锁
lock(true)必须配合事务和索引使用; - 乐观锁通过 version 字段实现,适合读多写少;
- 库存扣减优先使用原子 UPDATE,必要时叠加悲观锁;
- 事务内避免外部 IO,防止锁持有时间过长。
掌握这些要点,不仅能从容应对面试,更能在实际项目中写出经得起并发考验的代码。
未经允许不得转载:任鹏个人博客 » ThinkPHP 面试精讲:数据库事务、锁与并发场景处理

