先确认存储引擎,再写事务代码
在ThinkPHP6中使用事务前,先确认当前数据库引擎支持事务,例如MySQL的InnoDB支持而MyISAM不支持。可以通过查询表结构或建表语句判断,若引擎为MyISAM,事务操作会静默失效,导致数据不一致。建议在模型或数据库配置中统一指定引擎为InnoDB,并在关键写操作前用Db::startTrans()显式开启事务,而不是依赖框架自动事务。
这里说的“静默失效”很容易被忽略:MyISAM 本身不返回错误,但事务的提交、回滚都不生效,数据写到一半可能就停在那里。确认引擎可以用 SHOW TABLE STATUS LIKE 'user' 查看 Engine 列,或者在建表 SQL 里找 ENGINE=InnoDB。如果项目已经用了一套表,建议在迁移或初始化脚本中统一调整,不要等线上出问题再查。
开启事务的常用写法也不是单纯 startTrans。通常要配合 try/catch:
Db::startTrans();
try {
// 你的写操作
Db::commit();
} catch (\Exception $e) {
Db::rollback();
throw $e;
}这个结构保证了异常时能够回滚。注意 startTrans 和 commit 必须配对,少一个都可能导致连接状态不对。
回滚判断不能只看返回结果
事务中所有SQL操作必须检查返回值。ThinkPHP6的Db类执行写操作时返回影响行数或true/false,但注意更新数据值与原值相同可能返回0,此时不能仅凭返回0判定失败。应结合业务逻辑判断是否需要回滚,例如在修改库存场景中,若更新结果为0但数据实际无误,仍需手动确认操作是否成功,避免误回滚。可使用Db::getLastSql()打印SQL排查。
具体来说,UPDATE 语句如果更新后的值和原值一样,MySQL 返回的影响行数是 0,这是正常的。你不能因为这个 0 就触发回滚,否则库存可能没扣成,却把前面的操作撤销了。正确做法是先查一次原值,或者把“影响行数 0”当作“条件未命中”和“值未变化”两种情形分开处理。打印 SQL 是定位问题的第一手段,Db::getLastSql() 在 TP6 里直接可用,前提是当前连接没被重置。
另外,事务里如果抛了异常,回滚后别忘了重新抛出,不然被 catch 吞掉,外层就不知道失败。
乐观锁和悲观锁,按冲突频率选
ThinkPHP6中锁的使用要区分乐观锁和悲观锁。悲观锁在查询时加FOR UPDATE,例如Db::name('user')->where('id',1)->lock(true)->find(),此锁会持续到事务结束,必须配合事务使用,否则锁自动释放,失去作用。乐观锁通常通过版本号或更新时间字段实现,更新时检查版本号,若不一致则拒绝操作。选择时根据并发冲突频率决定,低冲突用乐观锁,高冲突用悲观锁。
这里的关键是“冲突频率”这个判断维度。如果你做的是下单扣库存,同一行记录可能被多个请求同时改,用悲观锁直接在事务内锁住这一行,简单直接。如果业务上大部分时间只有一个写者,偶尔撞一下,那就用乐观锁,更新时带上版本条件即可。
TP6 中悲观锁长这样:
$user = Db::name('user')->where('id', 1)->lock(true)->find();这个 lock(true) 最终会在 SQL 末尾加上 FOR UPDATE。锁的释放点是事务的 commit 或 rollback,所以如果你不加事务,这条语句执行完锁就没了,等于白锁。
悲观锁的边界:连接、事务和排查
很多线上问题出在锁的持有边界。一个常见错误是先在事务外查询了一次,然后在事务内再查一次,以为第二次查询也会加锁。实际上只有事务内那次查询会带 FOR UPDATE,事务外那次查到的数据可能已经过期。所以要在事务开始之后,再去查询要锁定的行,拿到结果后做判断,再执行后续更新。
锁被持有后,如果事务迟迟不提交,其他请求会进入锁等待。轻则接口超时,重则堆积成雪崩。排查锁状态可以执行 SHOW PROCESSLIST,看 State 列是否有 Lock wait,也可以看 Info 列对应的 SQL。MySQL 还提供 SHOW ENGINE INNODB STATUS 看更详细的锁信息,但内容很长,适合有针对性时再看。
另外注意:ThinkPHP6 的事务默认是单连接,如果在一个事务里切换了模型的数据源或连接,锁可能就不是同一个连接上持有的,这种情况要额外小心。建议在事务方法里避免动态切换连接,保持数据库连接统一。
加了锁的条件必须走索引
悲观锁不是只锁你查询的那一行。InnoDB 在 SQL 执行时,会先定位到要扫描的索引范围,然后对这些行加锁。如果查询条件没有命中索引,数据库会做全表扫描,那就相当于把整张表的行都锁了。这样即使并发量不高,也会互相阻塞。
所以在写带 lock(true) 的查询之前,先跑一遍 EXPLAIN,看 type 和 key 字段。如果 key 为 NULL,说明没有使用索引,需要调整查询条件或加索引。对于复合索引,要记得把等值条件的字段放在前面,范围条件放后面,尽量缩小锁的范围。你也可以看 rows 估算扫了多少行,行数越少越好。
这里有一个容易忽略的点:有时索引确实存在,但因为查询写法不对,比如在索引列上用了函数,索引就失效了。所以检查 EXPLAIN 是每次加锁前都要做的动作,不是写完就算。