并发数据错误需用请求ID日志+Redis锁+行锁验证:入口生成req_id并强制实时写盘;锁必须在事务外获取、包裹整个事务;MySQL须用SELECT...FOR UPDATE配合WHERE条件更新,避免MVCC快照导致超卖。

并发场景下日志里看不到重复写入,但数据出错了怎么办
ThinkPHP 默认的日志(think\facade\Log)是同步写入的,多个请求同时触发 Log::info() 时,日志内容可能被截断、覆盖或乱序,根本看不出谁先谁后、谁卡在了哪一步。这不是日志不准,而是它没设计用来做并发时序分析。
真正要定位数据竞争,得让日志带上下文标识,并强制串行落盘:
- 在请求入口(如中间件)生成唯一请求 ID,存到
think\Container或Request对象中,后续所有日志都拼上[req_id:xxx] - 用
Log::write()替代Log::info(),传入['type' => 'text', 'realtime' => true]强制立即刷盘 - 避免在事务内写日志——MySQL 事务未提交时,日志写入可能早于 DB 实际变更,造成“日志有、DB 没”的假象
用 Lock::block() 做临界区保护,但锁不生效的常见原因
think\facade\Lock 的 block() 方法默认基于文件驱动,而文件锁在 NFS、Docker 卷、某些云存储挂载路径下会失效——不是 ThinkPHP 的 bug,是底层 flock() 不支持分布式文件系统。
验证锁是否起作用,最直接的方法是加日志+ sleep:
立即学习“PHP免费学习笔记(深入)”;
$lock = Lock::block('order_create_' . $user_id, 10);
Log::info("[req_id:{$reqId}] got lock");
sleep(3); // 故意卡住
// ... 执行扣减库存等操作
$lock->release();
再开两个并发请求,如果第二个请求的日志里出现 got lock 时间比第一个晚 3 秒以上,说明锁生效;如果几乎同时打出来,基本可以确定锁驱动不可靠。
- 生产环境必须切到 Redis 驱动:
'default' => 'redis'配置在config/cache.php的stores.redis下,并确保cache.default是redis - Redis 锁 key 命名要带业务维度,比如
stock_lock:goods_123,别只用stock这种全局名,否则不同商品互相阻塞 -
block()第二个参数是超时秒数,不是等待时间——它指“最多尝试获取锁多久”,不是“拿到锁后能占多久”
事务里嵌套 Lock::block() 导致死锁或超时
MySQL 事务 + 外部锁组合时,顺序错了就会出问题。典型错误是:先开启事务,再调 Lock::block(),结果锁住了,但事务卡在那,其他请求既拿不到锁、也进不了事务。
正确顺序只能是:锁 → 事务 → 操作 → 提交 → 释放锁。且锁的生命周期必须严格包裹事务:
- 不要在事务回调(
Db::transaction()匿名函数里)才去申请锁——锁应该在回调外就拿到 - 如果锁获取失败(
block()返回false),必须立刻 return 或 throw,不能继续进事务 - 用
try...catch包裹整个流程,确保异常时锁能被$lock->release()释放,否则锁可能永久残留(Redis 驱动下有 TTL 可兜底,文件驱动则真就卡死)
用 MySQL 行锁验证是否真解决了竞争,而不是靠 PHP 层“假装串行”
光靠 PHP 锁只能保证同一时刻只有一个请求执行逻辑,但如果这个逻辑里没用好数据库的 SELECT ... FOR UPDATE 或 UPDATE ... WHERE 条件更新,还是可能丢更新。
最简单的验证方式:在关键 SQL 前加 SHOW ENGINE INNODB STATUS\G(开发环境),看 TRANSACTIONS 部分有没有 lock struct(s) 和等待线程;更实际的做法是,在更新语句里故意写错条件,比如:
Db::name('stock')->where('id', 1)->where('num', ['>', 0])->dec('num', 1)->failException(true)->execute();
如果并发请求都返回影响行数 0,说明 WHERE 条件拦住了超卖;如果某个请求成功减了 1,另一个也成功减了 1(即影响行数都是 1),那说明没锁住行,WHERE 条件没起到保护作用——这时候得检查是不是用了缓存值、是不是读到了旧快照(RR 隔离级别下 SELECT 不加锁会读 MVCC 快照)。
真正保险的做法是:读库存时就加 FOR UPDATE,且该 SELECT 必须在事务内、且和后续 UPDATE 在同一个事务里。



















