hyperf生产环境日志禁用同步streamhandler,须改用bufferhandler+rotatingfilehandler异步写入,并按sql、access等业务拆分通道,配合processor脱敏敏感字段。

Hyperf 生产环境日志不能直接用 StreamHandler 同步写文件——高并发下会阻塞协程、拖慢响应,且所有日志混在同一个文件里,排查效率极低。必须改用异步写入 + 多通道分级存储。
为什么不能用默认 StreamHandler 写生产日志
默认配置中 StreamHandler 是同步阻塞的:每次 logger->info() 都会真实调用 fwrite(),哪怕 Swoole 协程化了系统调用,频繁小写入仍会触发内核态切换和磁盘 I/O 竞争。实测 QPS > 500 时,日志写入延迟可飙升至 20ms+,且 hyperf.log 会快速膨胀、无法按业务维度过滤。
关键问题不是“能不能写”,而是“会不会拖垮请求链路”和“出问题时能不能秒级定位”。
- 同步写入在协程密集场景下会隐式让出调度权,放大协程切换开销
-
useLocking => true(默认 false)看似安全,但开启后会引入flock(),反而更卡 - 单文件存储导致
grep -A 5 -B 5 "order_id:12345"效率低下,尤其当 SQL 日志和访问日志混在一起时
用 AsyncHandler 包裹 RotatingFileHandler 实现异步写入
Monolog 自带 Monolog\Handler\WhatFailureGroupHandler 和 Monolog\Handler\BufferHandler,但真正适合 Hyperf 生产的是 Monolog\Handler\BufferHandler + RotatingFileHandler 组合——它把日志先缓存在内存,再批量刷盘,避免高频小写。
修改 config/autoload/logger.php 中某个通道(如 'default')的 handler:
'handler' => [
'class' => Monolog\Handler\BufferHandler::class,
'constructor' => [
'handler' => [
'class' => Monolog\Handler\RotatingFileHandler::class,
'constructor' => [
'filename' => BASE_PATH . '/runtime/logs/hyperf.log',
'maxFiles' => 30,
'filenameFormat' => 'hyperf-{date}.log',
'compress' => true, // 需 Monolog ≥ 2.10
],
],
'bufferSize' => 100, // 每满 100 条才 flush 一次
'flushOnClose' => true,
],
],
-
bufferSize设为 50–200 之间较稳妥;太小失去异步意义,太大可能丢日志(进程异常退出时未 flush) - 务必保留
flushOnClose => true,否则php bin/hyperf.php server:stop后会丢失缓冲区日志 -
RotatingFileHandler的filenameFormat必须含{date},否则轮转逻辑失效(Hyperf 3.x/4.x 已验证)
按业务用途拆分通道并独立配置轮转策略
不要只依赖一个 default 通道。SQL 日志、API 访问日志、错误日志、审计日志应物理隔离——不仅便于运维归档,还能防止某类日志爆炸(如慢 SQL 刷屏)拖垮其他通道。
在 config/autoload/logger.php 的 'channels' 数组里新增:
'sql' => [
'handler' => [
'class' => Monolog\Handler\RotatingFileHandler::class,
'constructor' => [
'filename' => BASE_PATH . '/runtime/logs/sql.log',
'level' => Monolog\Logger::INFO,
'maxFiles' => 7,
'filenameFormat' => 'sql-{date}.log',
],
],
'formatter' => [
'class' => Monolog\Formatter\JsonFormatter::class,
],
],
'access' => [
'handler' => [
'class' => Monolog\Handler\RotatingFileHandler::class,
'constructor' => [
'filename' => BASE_PATH . '/runtime/logs/access.log',
'level' => Monolog\Logger::INFO,
'maxFiles' => 30,
'filenameFormat' => 'access-{date}.log',
],
],
'formatter' => [
'class' => Monolog\Formatter\LineFormatter::class,
'constructor' => [
'format' => "[%datetime%] %level_name%.%channel%: %message% %context% %extra%\n",
],
],
],
- 调用时用
$loggerFactory->get('sql', 'sql')显式获取通道,确保日志不进错文件 - SQL 日志设为
maxFiles => 7+ JSON 格式,方便后续用 Logstash 或阿里云 SLS 做字段提取 - Access 日志用
LineFormatter保持可读性,但format字段必须包含%context%,否则request_id、status_code等上下文会丢失
敏感字段脱敏必须在 Processor 层做,不能靠 Formatter
Formatter 只负责格式化输出字符串,一旦日志进入 Handler 缓冲区,原始 context 已不可逆。真正在生产环境起效的脱敏,必须在日志进入 Handler 前,用 Processor 清洗 context 或 message 中的敏感键。
新建 app/Logger/SensitiveDataProcessor.php:
namespace App\Logger;use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;class SensitiveDataProcessor implements ProcessorInterface
{
public function __invoke(LogRecord $record): LogRecord
{
if (env('APP_ENV') !== 'prod') {
return $record;
}
$this->anonymizeContext($record->context);
if (is_string($record->message)) {
$record->message = $this->anonymizeString($record->message);
}
return $record;
}private function anonymizeContext(array &$context): void
{
foreach (['token', 'password', 'phone', 'id_card'] as $key) {
if (isset($context[$key]) && is_string($context[$key])) {
$context[$key] = '***';
}
}
}
private function anonymizeString(string $str): string
{
return preg_replace('/(token|password|phone|id_card)[^&\s]+/i', '$1=***', $str);
}}
然后在 logger.php 对应通道里加:
'processors' => [ App\Logger\SensitiveDataProcessor::class, ],
注意:processors 是数组,可叠加多个;但别对整个 $context 做 json_encode() 再正则替换——会破坏 trace_id、exception 等结构化字段,导致监控告警失效。











