Log::write()高并发下拖垮接口响应,因ThinkPHP默认同步阻塞写文件,导致磁盘I/O瓶颈、响应延迟、文件描述符耗尽;需异步化并重写Log::save(),配合队列失败重试与监控。

为什么 Log::write() 在高并发下会拖垮接口响应
ThinkPHP 默认日志写入是同步阻塞的,每次调用 Log::write() 都会打开文件、追加内容、刷新缓冲、关闭句柄。QPS 上千时,磁盘 I/O 成为瓶颈,常见现象是接口平均响应时间飙升、偶发超时,甚至出现 file_put_contents(): failed to open stream: Too many open files 错误。
根本原因不是日志量大,而是写操作卡在主线程里 —— 用户请求必须等日志落盘完才返回。哪怕只是记录一条访问日志,也会把整个 HTTP 响应卡住。
- 日志级别设为
debug且开启 SQL 日志时,单次请求可能触发数十次Log::write()调用 - Linux 系统默认单进程文件描述符限制(
ulimit -n)通常为 1024,高频小文件写入极易触达上限 - 使用
File驱动时,ThinkPHP 不做批量合并,每条日志都是独立fopen+fwrite
用 think-queue 把日志写入丢进队列
不改业务逻辑,只替换日志驱动:让 Log::write() 看似照常调用,实际只是往 Redis 或数据库里塞一条任务,由独立的消费者进程异步执行真正写入。
关键不是“加个队列”,而是确保日志上下文(如请求 ID、用户信息、trace_id)能完整传递到消费者中 —— ThinkPHP 的 Log 类本身不携带运行时上下文,直接丢进队列会丢失关键字段。
立即学习“PHP免费学习笔记(深入)”;
- 不要在消费者里重新调用
Log::write(),它仍会走同步流程;应直接用file_put_contents()或error_log()写文件 - 推荐用
Redis作队列驱动,避免 MySQL 表锁影响日志吞吐;配置项queue.default设为redis - 消费者启动命令必须指定
--queue=log,否则所有任务混在一起,日志任务可能被业务任务挤压延迟 - 示例消费者逻辑片段:
public function fire($job, $data) { $content = $data['content'] ?? ''; $level = $data['level'] ?? 'info'; $file = RUNTIME_PATH . 'log/' . date('Ym/d.') . $level . '.log'; file_put_contents($file, $content . "\n", FILE_APPEND | LOCK_EX); $job->delete(); }
Log::record() 和 Log::write() 的调用时机差异很关键
很多人以为只要把 Log::write() 换成队列就万事大吉,却忽略了 ThinkPHP 内部日志收集机制:框架自动记录的 SQL 日志、行为日志、错误捕获日志,走的是 Log::record() 流程,不是你手动写的 Log::write()。
Log::record() 是内存暂存,直到请求结束前由 Log::save() 统一刷出 —— 这个环节默认仍是同步文件写入。所以即使你重写了 Log::write(),SQL 日志照样卡主线程。
- 必须重写
Log::save()方法,将暂存日志打包成队列任务,而不是逐条调用Log::write() - 重写时注意保留原始格式(如
[ info ] [2024-05-12 10:23:45]),否则消费者无法识别日志级别和时间 - 别忘了在
AppService或BaseController构造函数里调用Log::init()重新绑定自定义驱动,否则框架仍用默认File驱动
Redis 队列积压时,日志丢失比慢更危险
异步日志最大的隐性风险不是延迟,而是不可见的丢失:消费者进程挂了、Redis 内存满、任务执行抛异常没被捕获 —— 这些情况都不会反馈给 HTTP 请求端,开发者还以为“日志已发出去”。线上排查问题时才发现关键请求根本没有日志。
不能依赖“队列可靠”这种假设。真实生产环境里,think-queue 的失败任务默认不重试、不告警、不持久化失败详情。
- 务必开启
failed_table配置,把执行失败的任务存进数据库,定期巡检 - 消费者代码里对
file_put_contents()加if (false === $ret)判断,失败时主动$job->release(60)延迟重试 - 监控项要包括:
redis.queue.log.length(队列长度)、queue.failed.count(失败数)、消费者进程存活状态 - 上线前用
ab -n 5000 -c 200 http://test.com/api/log-test压测,观察队列长度是否线性增长、失败数是否归零
日志异步化真正的复杂点不在“怎么发”,而在“怎么确认它真的落盘了”。没做失败兜底的队列日志,比同步日志更难 debug。


















