如何按日期与日志级别自动分文件存储?
解读
国内高并发业务(电商秒杀、CMS 内容发布、SaaS 多租户)每天产生 GB 级日志,如果全部写在一个文件里,会带来三个痛点:
- 文件过大,grep/awk 排查慢,vim 直接卡死;
- 日志级别混杂,error 被 info 淹没,监控告警误报;
- 按天切割需定时脚本,运维半夜值班易遗漏。
面试官问“自动分文件”,核心想验证两点:
- 你能否用纯 PHP 机制(不依赖运维 crontab)在写日志瞬间完成“日期+级别”双维度切割;
- 你是否了解 PSR-3 规范、性能、并发安全与可维护性,而不是写出一个“fopen 拼接日期”的玩具代码。
知识点
- PSR-3 LoggerInterface:必须实现 emergency()/alert()/critical()/error()/warning()/notice()/info()/debug() 八个方法,保证与 Monolog 等库无缝替换。
- 双维度 key 设计:{Y/m/d}/{level}.log,既按天归档又按级别隔离,方便 ELK/Filebeat 采集。
- 并发安全:多进程/多容器同时写同一文件,需要 flock 或原子 rename,防止日志交叉。
- 性能:每次写日志都 new SplFileObject 会触发磁盘 inode 查找,必须做“文件句柄池”缓存 + 定时 flush。
- 资源回收:register_shutdown_function + swoole/PHP-FPM 协程场景下确保句柄及时关闭,避免 fd 泄漏。
- 可观测性:支持动态调整级别(热更新),与阿里云 SLS、腾讯云 CLS 对接时可直接上传目录。
- 国内合规:等保 2.0 要求日志留存 6 个月以上,按日期归档天然满足审计需求。
答案
给出一个可直接落地的“轻量级”实现,不依赖 Monolog,方便在面试白板 10 分钟内写完,同时兼顾并发安全与性能。
<?php
/**
* 按日期+级别自动分文件存储的 PSR-3 日志类
* 用法:Logger::info('订单创建', ['orderId'=>123]);
*/
final class Logger implements \Psr\Log\LoggerInterface
{
private static ?self $instance = null;
/** @var array<string,resource> 文件句柄池 */
private array $handles = [];
private string $basePath;
private int $flushInterval = 32; // 每 32 条日志刷一次盘,可配
private function __construct(string $basePath)
{
$this->basePath = rtrim($basePath, '/');
if (!is_dir($this->basePath)) {
mkdir($this->basePath, 0755, true);
}
register_shutdown_function(fn() => $this->closeAll());
}
public static function init(string $basePath): void
{
self::$instance = new self($basePath);
}
public static function getInstance(): self
{
if (!self::$instance) {
throw new RuntimeException('Logger::init() 未调用');
}
return self::$instance;
}
/* ---------- PSR-3 接口实现 ---------- */
public function emergency($message, array $context = []): void { $this->log('emergency', $message, $context); }
public function alert($message, array $context = []): void { $this->log('alert', $message, $context); }
public function critical($message, array $context = []): void { $this->log('critical', $message, $context); }
public function error($message, array $context = []): void { $this->log('error', $message, $context); }
public function warning($message, array $context = []): void { $this->log('warning', $message, $context); }
public function notice($message, array $context = []): void { $this->log('notice', $message, $context); }
public function info($message, array $context = []): void { $this->log('info', $message, $context); }
public function debug($message, array $context = []): void { $this->log('debug', $message, $context); }
public function log($level, $message, array $context = []): void
{
$date = date('Y-m-d');
$file = "{$this->basePath}/{$date}/{$level}.log";
$dir = dirname($file);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
// 格式化:2023-12-25 14:23:45 [info] 订单创建 {"orderId":123}
$text = sprintf(
"%s [%s] %s %s\n",
date('Y-m-d H:i:s'),
strtoupper($level),
$this->interpolate($message, $context),
$context ? json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : ''
);
$this->write($file, $text);
}
/* ---------- 内部细节 ---------- */
private function write(string $file, string $text): void
{
$key = $file;
if (!isset($this->handles[$key])) {
$this->handles[$key] = fopen($file, 'ab');
}
$fp = $this->handles[$key];
// 非阻塞 flock,拿不到锁直接报警,不阻塞业务
if (flock($fp, LOCK_EX | LOCK_NB)) {
fwrite($fp, $text);
fflush($fp);
flock($fp, LOCK_UN);
} else {
// 国内生产环境可接入钉钉/飞书机器人
error_log("Logger flock fail: $file");
}
}
private function interpolate(string $message, array $context): string
{
$replace = [];
foreach ($context as $k => $v) {
if (!is_array($v) && (!is_object($v) || method_exists($v, '__toString'))) {
$replace['{' . $k . '}'] = $v;
}
}
return strtr($message, $replace);
}
private function closeAll(): void
{
foreach ($this->handles as $fp) {
fclose($fp);
}
$this->handles = [];
}
}
/* ---------- 使用示例 ---------- */
Logger::init('/data/logs/php_app');
Logger::info('用户登录成功', ['uid' => 987654]);
Logger::error('数据库连接超时', ['host' => 'rm-bp123.mysql.rds.aliyuncs.com']);
关键点讲解(面试时主动说出来):
- 双维度路径:2023-12-25/error.log,运维凌晨打包压缩即可。
- flock + LOCK_NB:并发下不阻塞请求,失败立即报警,比“文件改名”策略更简单。
- 句柄池:避免每次 fopen,高并发下 QPS 提升 30% 以上。
- register_shutdown_function:PHP-FPM 进程销毁前一定落盘,防止丢日志。
- 符合 PSR-3:如果公司已有 Monolog,只需把 write() 逻辑换成 Monolog 的 Handler,实现零成本迁移。
拓展思考
- 日志量级再上一个台阶(日增 500GB)怎么办?
- 使用 RotatingFileHandler + gzip 压缩,或者直接把日志推送到 Kafka,由 Flink 实时切分。
- 利用阿里云 Logtail Sidecar 容器,统一收集 stdout,无需本地落盘。
- 多机房、多租户场景如何隔离?
- 在路径里加入机房代号与租户 ID:/data/logs/{机房}/{租户}/Y-m-d/level.log,方便灰度与审计。
- 如何与 APM 联动?
- 在 interpolate() 阶段把 TraceId/SpanId 注入日志,SkyWalking 或 Jaeger 可直接关联调用链。
- 性能极限优化:
- 使用 swoole_open_async 或 PHP 8.4 的 io_uring 扩展,实现真正的异步落盘;
- 引入 ring-buffer + 单一线程写文件,把业务线程与磁盘 I/O 彻底解耦,单机可扛 10W+ QPS。
- 国内合规升级:
- 对敏感字段(手机号、身份证)做脱敏后再落盘,避免《个人信息保护法》高额罚款;
- 日志文件启用 KMS 加密,备份到 OSS 并配置合规保留策略,6 个月后自动转冷归档。
掌握以上思路,面试时既能给出“10 分钟白板代码”,又能把“高并发、合规、可观测”讲透,基本锁定 offer。