1. 先把日志这件事想清楚
做 Python 开发久了你会慢慢发现,代码写得好不好是一回事,线上出问题能不能快速定位又是另一回事。而日志(Logging)恰恰是连接这两件事的桥梁。很多项目跑着跑着突然报错,第一反应是翻日志,结果打开日志文件发现要么啥都没记,要么被一堆 print 刷屏,关键信息早就淹没掉了。这背后不是日志库不好用,而是没想清楚日志到底该怎么组织。
这篇文章围绕 Python 标准库的 logging 模块展开,讲的不是 API 抄一遍,而是从设计思路、关键概念、落地配置到踩坑实录,把一套可以实际用到项目里的最佳实践拆给你看。适合刚接触 Python 想养成好习惯的新人,也适合已经写了一段时间、每次排查问题都想骂日志的同学。
先说一个基本判断:日志这个事,看起来简单,实际上属于典型的“不遇到生产事故就意识不到重要性”的基础设施。代码里的 print 能应付学习阶段的小脚本,但一旦涉及多模块协作、服务部署、多人联调,print 的弊端立马暴露:没有级别、没有时间戳、没有来源文件、不能按模块开关,还会拖慢业务代码。logging 模块就是来解决这些问题的。
1.1 日志不只是 print 的升级版
print 和 logging 的本质区别在哪里?print 是“把字符串扔到标准输出”,它的生命周期只存在于程序员写代码的那一刻。logging 是“按规则记录事件”,它把结构化程度、保存位置、记录级别、过滤策略这些东西全都抽象了出来。
你可以把 logging 理解成一个分拣系统:事件产生之后,先判断它的严重程度(DEBUG、INFO、WARNING、ERROR、CRITICAL),再决定要交给谁处理(控制台、文件、远程采集),最后以什么格式呈现(纯文本、JSON、带时间戳的格式)。整个过程是分层的,每一层都能单独控制。
实际项目中我见过太多反面例子:有人在每个函数里加 print,结果日志里全是无头无尾的片断;有人把日志级别设成 DEBUG 然后直接上生产,日志文件一天几个 GB;还有人把密码、手机号直接打进了日志,事故复盘时恨不得把日志系统整个删掉。这些问题不是靠“多打几行日志”能解决的,而是要在设计阶段就把日志当成正式的代码资产来管理。
1.2 为什么标准库 logging 值得作为默认选择
Python 生态里日志方案不少,除了标准库 logging,还有 loguru、structlog 这些第三方库。loguru 上手确实愉快,一个 add 函数就能搞定输出;structlog 在结构化日志上很顺手。但我个人的建议是:常规项目优先用标准库 logging,不要一上来就引入第三方依赖。
原因很实际。第一,标准库零依赖,任何一个 Python 环境都自带,不会出现换台机器就缺包的情况。第二,logging 的设计足够完整,Logger、Handler、Formatter、Filter 四个核心组件能覆盖从简单脚本到微服务的大部分场景。第三,很多第三方库(比如 urllib3、aiohttp、requests)内部走的就是 logging 体系,你用标准库可以统一接管它们的日志输出;但如果项目里自己用 loguru,外部库的日志还是走 logging,两边各打各的,排查时反而割裂。
当然,如果你的团队已经深度使用结构化日志,或者就是喜欢 loguru 的简洁 API,那也完全可以。但无论如何,理解标准库 logging 的工作原理都有好处,因为很多高级日志库的底层思路和它是一脉相承的。这篇文章我以标准库为主线,但你学到的东西可以迁移到任何方案上。
2. 理解 logging 的四个关键角色
很多初学者看不懂 logging,是因为把它当成一个“函数集合”:info 是记信息,error 是记错误,debug 是调试。这种理解太浅了。logging 真正强大之处在于它由四个彼此独立又能协作的角色组成:Logger、Handler、Formatter、Filter。你只有理解了这四个角色各自负责什么,才能把日志配置玩明白。
2.1 Logger:日志的入口与命名空间
Logger 是你调用 logging.getLogger(name) 拿到的那个对象,它是日志事件的入口。关键点是这个 name。最佳实践里几乎所有人都推荐用name,也就是当前模块的完整路径名。
比如你的包结构是 myapp/order/service.py,那在 service.py 里 logging.getLogger(name) 返回的 logger 名字就是 myapp.order.service。这个名字不是随便起的,它天然形成了层级关系:myapp 是父 logger,myapp.order 是子 logger,myapp.order.service 是孙子 logger。
有了层级之后,你可以按命名空间统一控制日志级别。比如只对 myapp.order 模块开启 DEBUG 调试,其他模块保持 INFO :
logging.getLogger("myapp.order").setLevel(logging.DEBUG)这种能力在生产环境非常有用:某个模块行为异常,不需要全局提高日志级别,只需要定向打开那一块的 DEBUG 日志,能少打很多无用的日志。
另一个重要概念是有效级别。子 logger 如果没有显式 setLevel,会自动继承最近的祖先 logger 的级别。所以在最顶层的应用入口设置 root logger 的级别,所有子 logger 默认都会跟随。
这里有个特别容易踩的坑:不要在模块里直接调用 logging.basicConfig,也不要给某个业务模块的 logger 单独挂 handler。日志配置应该由应用入口统一负责,模块里只负责通过 getLogger(name) 拿 logger 并记录事件。否则别人用你的模块时,日志输出会被你的配置劫持,双方都难受。
2.2 Handler:日志的出口
Logger 负责接收日志事件,但真正决定“日志去哪儿”的是 Handler。一个 logger 可以挂多个 handler,各自输出到不同目的地。最常见的几个 Handler 如下:
| Handler | 输出目标 | 适用场景 |
|---|---|---|
| StreamHandler | 控制台、任意流 | 本地开发、容器部署时看 stdout |
| FileHandler | 单个文件 | 简单的本地落盘 |
| RotatingFileHandler | 按大小自动轮转的文件 | 生产环境最常用 |
| TimedRotatingFileHandler | 按时间自动轮转的文件 | 需要按天/小时归档的场景 |
| SocketHandler | 网络 socket | 自建日志收集通道 |
| QueueHandler | 内存队列 | 配合异步日志写入,解耦 IO |
Handler 也有独立的级别,和 logger 级别是两个独立关卡。一条日志要真正输出,必须同时通过 logger 级别的检查和 handler 级别的检查。比如 logger 设了 DEBUG,handler 设了 INFO,那 DEBUG 日志会被 handler 拦掉,不会输出。
这说明一个生产环境的常见套路:应用入口的 logger 级别可以放开到 DEBUG,但具体 handler 的级别可以卡在 INFO。这样既能在需要时用 logger.setLevel(DEBUG) 动态调试,又不会让 DEBUG 日志轻易刷爆文件。
2.3 Formatter 与 Filter:日志的颜值和闸门
Formatter 决定日志打印出来的样子,Filter 决定哪些日志能通过。这两者看着不起眼,其实是日志好不好用的关键。
一个标准的生产日志行,至少应该包含时间、级别、logger 名、具体消息。在此基础上我建议加上文件名、行号、函数名,这样线上出了错能快速定位到代码位置。常用的格式字段有:
LOG_FORMAT = ( "%(asctime)s | %(levelname)-8s | %(name)s | " "%(filename)s:%(lineno)d | %(funcName)s | %(message)s" )其中 %(asctime)s 是本地时间,%(levelname)-8s 是左对齐的级别名,%(message)s 是实际消息。如果还想要进程和线程信息,可以追加 %(process)d、%(threadName)s。
Formatter 的好处是可替换。本地开发想看易读的纯文本,线上采集想收 JSON 结构化日志,只需要换 Formatter 就行,日志源码完全不用动。
Filter 则是更灵活的控制手段。Handler 的 level 只能按严重程度过滤,但 Filter 可以按任意条件过滤,比如模块名、消息内容、是否携带某个上下文标记。后面讲敏感信息脱敏和日志采样时,Filter 都是关键工具。
2.4 日志级别与传播机制
logging 有五个标准级别,从低到高分别是 DEBUG(10)、INFO(20)、WARNING(30)、ERROR(40)、CRITICAL(50)。级别数字越小越详细,越大越严重。
很多人的误区是觉得 ERROR 日志就是“代码抛异常时输出”。实际上,ERROR 是一种事件严重程度的声明,不一定要和异常绑定。比如调用外部接口超时,业务可以接受重试,但如果你想让人关注,完全可以直接 logger.error("外部接口超时,响应时间 3000ms")。反之,如果只是记录一条业务状态流转,用 logger.info 更合适。
日志还有一个传播机制:默认情况下,logger 处理完事件后会把它继续向上传给祖先 logger,直到 root。这本来是为了让多个层级都有机会处理日志,但也直接导致了最常见的“日志重复输出”问题。一个模块的 logger 挂了 handler,同时它又把事件传播给了挂了同款 handler 的 root logger,于是同一条日志打了两遍。
解决方式很简单:要么模块 logger 保持默认传播,只在 root 上挂 handler,靠层级命名天然汇聚;要么显式设置 logger.propagate = False,让它自产自销。具体怎么选,我下面在踩坑部分详细说。
3. 落地一套可复用的日志配置
理解了四个角色,就该动手写配置了。这一节我会从一个最小可用的配置开始,逐步升级到适合生产环境的版本。这里的所有代码都是可以直接复制到项目里改改就用的。
3.1 从一段最小可用配置开始
如果你是写一个小脚本,或者跑一个简单的定时任务,最省事的配置是 basicConfig:
import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S", )这行代码放到程序的入口处,整个进程的 root logger 就有了基本能力。之后再写业务模块时只要按惯例取 logger:
logger = logging.getLogger(__name__) def process_order(order_id: int) -> None: logger.info("开始处理订单: %s", order_id)这里有个细节:logger.info 的第二个参数是格式化参数,不推荐用 f-string 拼好再传。原因很简单,logging 的消息是惰性格式化的,如果日志级别连 INFO 都没达到,参数不会真的被格式化。用 f-string 的话,不管日志要不要输出,字符串都已经拼出来了,白白浪费 CPU。在高频日志场景下,这个差异是能测出来的。
3.2 生产级配置:文件轮转、JSON 和 dictConfig 一次搞定
当项目开始部署到服务器上,只输出到控制台就不够了。你需要把日志落盘,还要控制文件大小,不能让它无限涨。logging 自带的 RotatingFileHandler 和 TimedRotatingFileHandler 就是干这个的。
用 dictConfig 管理配置是最推荐的方式。相比手动 addHandler,dictConfig 把整个配置集中到一个 Python 字典里,结构一目了然,还能通过 "()" 指定自定义类,比 fileConfig 灵活得多。
import logging.config LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, "formatters": { "standard": { "format": "%(asctime)s | %(levelname)-8s | %(name)s | " "%(filename)s:%(lineno)d | %(message)s" } }, "handlers": { "console": { "class": "logging.StreamHandler", "level": "INFO", "formatter": "standard", "stream": "ext://sys.stdout" }, "file": { "class": "logging.handlers.RotatingFileHandler", "level": "INFO", "formatter": "standard", "filename": "app.log", "maxBytes": 10 * 1024 * 1024, "backupCount": 5, "encoding": "utf-8" } }, "root": { "level": "INFO", "handlers": ["console", "file"] } } logging.config.dictConfig(LOGGING_CONFIG)这里做了三件事:第一,控制台和文件各挂一个 handler,开发时能看到输出,生产时有文件留存;第二,文件按 10MB 大小轮转,保留 5 个备份,也就是最多 6 个文件,磁盘占用控制在 60MB 左右,可预测;第三,root logger 统一接管,所有模块的 logger 只要不手动断传播,都会走到这里。
关于 maxBytes 和 backupCount 怎么定,我的经验是:单文件大小不要定太大,10MB 到 50MB 比较合理。太大了一旦压缩、上传、truncate 都会很慢;太小了轮转太频繁,日志事件容易丢。备份数根据你的存储周期来,保留最近 5~30 份基本够用。
如果日志不仅要人能看,还要喂给日志收集系统分析,纯文本就不够了。这时候建议上 JSON 结构化日志。logging 自带的 Formatter 不支持直接输出 JSON,但自己写一个很简单:
import json import logging class JsonFormatter(logging.Formatter): def format(self, record): data = { "time": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"), "level": record.levelname, "logger": record.name, "module": record.module, "line": record.lineno, "message": record.getMessage(), } if record.exc_info: data["exc_info"] = self.formatException(record.exc_info) if hasattr(record, "request_id"): data["request_id"] = record.request_id return json.dumps(data, ensure_ascii=False)然后在 dictConfig 里通过 "()" 指定这个类:
"formatters": { "json": { "()": "your_module.JsonFormatter" } }用 JSON 格式的好处是采集端不用正则去抠字段,level、时间、logger 名、异常堆栈都能作为独立字段查询。在你接 ELK、Loki 或自建的日志平台时,结构化日志能省掉大量清洗工作。
3.3 第三方库日志的接入与去噪
你的代码只是整个服务的一部分,项目中引用的第三方库也会打日志。默认情况下,很多库用的 logger 级别比较低,可能 DEBUG 信息也往外面打。比如 urllib3 在连接池里会打大量 debug 日志,如果不控制,日志文件会被“Starting new HTTP connection”这类消息刷屏。
解决方案就是利用 logger 命名空间,把第三方库的日志级别提上去:
logging.getLogger("urllib3").setLevel(logging.WARNING) logging.getLogger("aiohttp").setLevel(logging.WARNING) logging.getLogger("requests").setLevel(logging.WARNING)这段配置放在应用入口,和 dictConfig 一起执行。注意,这里用的也是命名空间,urllib3 的所有子 logger 都会继承这个级别。第三方库日志的噪声问题基本都能靠这一招解决。
反过来,如果你希望保留某个库的调试信息来排查问题,也可以单独把它调到 DEBUG,而不用全局改动。这就是 logging 命名空间设计的威力。
3.4 敏感信息脱敏
日志里最容易出问题的不是性能,而是把不该记录的东西记下来。密码、token、手机号、身份证号、银行卡号,只要出现在日志里,一旦日志被同步到第三方平台或者泄露出去,就是安全事故。
最好的办法是“不采集”:业务层在构造日志消息时就不要拼接敏感字段。但现实是,第三方库可能会把 URL 参数带上,异常堆栈里也可能出现环境变量里的密码。所以最后一道防线是在 Formatter 或 Filter 做统一脱敏。
我习惯用 Formatter 做脱敏,因为它能拿到最终要输出的字符串,操作最直接:
import re import logging class MaskFormatter(logging.Formatter): def __init__(self, fmt=None, datefmt=None): super().__init__(fmt, datefmt) self.patterns = [ (re.compile(r'(?<=password=)[^&\s]+'), "***"), (re.compile(r'(?<=token=)[^&\s]+'), "***"), (re.compile(r'\b\d{11}\b'), "138****5678"), ] def format(self, record): msg = record.getMessage() for pattern, repl in self.patterns: msg = pattern.sub(repl, msg) record.msg = msg record.args = () return super().format(record)注意,这里为了不让 super().format 再用 record.args 重新格式化,必须把 record.args 清空。这个写法有一些边界情况要测试,但它能在不改动业务代码的情况下,对日志做一个统一的兜底脱敏。
提示:脱敏规则宁可保守一点。不确定某个字段是不是敏感时,建议一律用正则覆盖常见的手机号、身份证、密钥格式。日志里少输出一个字段,比泄露了再补救强得多。
第 4 章 踩坑实录:日志最常见的六大问题
再好的配置,实际跑起来也会遇到各种诡异问题。我从自己项目里踩过的坑里挑了一些最常见的,按问题现象、原因、解决方案整理出来,希望你不用重走这些弯路。
4.1 日志重复输出:明明配了一次,却打了两遍
现象是一条日志在控制台出现两次,甚至多次。最常见的原因是模块 logger 自己挂了 handler,同时 propagate 又是默认 True,事件既被模块自己的 handler 处理了一次,又向上传播给了 root 的 handler,于是重复。
我见过有人为了让日志“更可靠”,在每个模块里都手动 addHandler,结果日志输出直接翻倍。正确的做法是在模块里只做 getLogger(name) 和记录事件,handler 统一交给入口的 dictConfig 管理。如果实在要在一个独立业务模块里单独处理日志,那就显式设 logger.propagate = False,堵住向上传播的通道。
还有一种是配置加载了多次,比如在测试框架里反复执行 dictConfig,导致 handler 被叠加。这时可以先调用 logging.config.dictConfig({...}) 或者 logging.shutdown() 清理,再重新配置。
4.2 日志丢失或不同步
日志丢失往往比重复更隐蔽。最常见的原因是 dictConfig 的 disable_existing_loggers 参数。它默认是 True,意思是配置加载之后,之前已经创建的 logger 会被禁用。如果你的模块在配置之前就执行了 getLogger,那么配置之后它可能不再处理任何日志,看起来就是“有些文件没写日志”。
解决办法很简单:dictConfig 里不要偷懒,把这个参数显式设为 False。这是我见过最高频的一个坑,尤其出现在应用启动顺序不固定的项目里。
另一个丢失场景和 QueueHandler 有关。为了性能,很多人会把日志事件先丢进内存队列,后台线程再消耗。但如果进程退出前没有停止队列监听线程,队列里还没来得及写盘的日志事件就直接丢了。解决方式是注册一个退出钩子:
import atexit listener = logging.handlers.QueueListener( queue_handler, file_handler, respect_handler_level=True, ) listener.start() atexit.register(listener.stop)多进程写同一个日志文件也会导致日志互相覆盖、轮转出错。RotatingFileHandler 不是进程安全的,两个进程同时写一个文件会把内容搅在一起。轻量方案是每个进程写自己的日志文件,或者在 Linux 上用 WatchedFileHandler,由外部 logrotate 负责轮转。
4.3 时区与编码问题
日志时间是个容易被忽略的坑。默认情况下 asctime 是本地时间,如果你的服务器时区是 UTC,而办公电脑是北京时间,排查问题时看到日志时间还要默默换算一下,非常累。
统一策略很重要。我建议服务端日志统一用 UTC 时间记录,或者至少在 Formatter 里带上时区偏移。把时间的 converter 改成 gmtime 可以让 asctime 输出 UTC 时间:
import time import logging class UTCFormatter(logging.Formatter): converter = time.gmtime然后在 format 里加 %z 就能带上时区信息。前端展示时可以转成用户本地时区,但落盘日志的时间基准要统一。
编码问题常见于 Windows 环境。FileHandler 默认编码是系统编码,Windows 上可能是 GBK,一旦日志里有 emoji 或特殊字符就可能报 UnicodeEncodeError,甚至导致进程崩溃。所有文件类 handler 都建议显式指定 encoding="utf-8"。
4.4 性能问题:同步 IO 拖慢业务
日志本身也是 IO 操作。如果每条日志都同步写文件,在高频业务下会明显拖慢接口响应。我测过一个简单场景:每秒打几千条 INFO 日志,同步写文件能让接口耗时上升一个数量级。
处理方案有三个层次。第一,级别控制,生产环境不要放开 DEBUG,INFO 也要有节制;第二,尽量少打大对象,尤其是不要直接把整个请求体、响应体打进去;第三,使用 QueueHandler + QueueListener 把写日志变成异步操作:
import queue import logging import logging.handlers console = logging.StreamHandler() file_handler = logging.handlers.RotatingFileHandler( "app.log", maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" ) queue_handler = logging.handlers.QueueHandler(queue.Queue(-1)) listener = logging.handlers.QueueListener( queue_handler, console, file_handler, respect_handler_level=True ) root = logging.getLogger() root.addHandler(queue_handler) root.setLevel(logging.INFO) listener.start()QueueHandler 会把日志事件放进内存队列,业务线程立刻返回;后台的 QueueListener 线程统一消费队列,再交给真正的 handler 写盘。这样把日志 IO 和业务线程隔离开,性能影响很小。
注意:队列的消费速度如果跟不上生产速度,内存队列会无限增长。写盘 IO 慢时要考虑日志级别和采样策略,而不是一味依赖队列。
4.5 上下文信息缺失
日志打了很多,但每条日志之间像孤岛:看不到是哪个请求触发的,也看不出线程上下文。尤其在并发场景下,多个请求交织,日志里的“用户 A 下单成功”“用户 B 下单成功”根本无法关联。
解决办法是给每条日志注入一个请求 ID。简单粗暴的版本是在线程本地存一个 ID,然后让 Formatter 输出它。更现代的做法是用 contextvars,它在线程、异步任务之间传播都比 threading.local 可靠:
import contextvars import logging request_id_var = contextvars.ContextVar("request_id", default="-") class RequestIdFilter(logging.Filter): def filter(self, record): record.request_id = request_id_var.get() return True然后在请求入口处设置这个变量:
request_id = request_id_var.set(uuid4().hex) try: # 处理请求 pass finally: request_id_var.reset(request_id)把这个 Filter 挂到 handler 上,再在 Formatter 里加上 %(request_id)s,之后每条日志都会带上当前请求 ID。排查问题时按 request_id 一查,整个请求链路的所有日志都能捞出来。
4.6 常见问题排查速查表
| 现象 | 可能原因 | 快速定位手段 | 解决方案 |
|---|---|---|---|
| 日志重复输出 | 多个 handler 同时处理同一事件 | 查看 logger 的 handlers 和 propagate 值 | 统一入口配置,或设 propagate=False |
| 部分模块没日志 | disable_existing_loggers 为 True | 看初始化顺序 | dictConfig 中设为 False |
| 日志丢失 | 队列监听线程未正常退出 | 检查进程退出日志 | atexit 注册 listener.stop |
| 时间差 8 小时 | 服务器 UTC,客户端本地时间 | 对比系统时区 | Formatter 用 UTC+%z 统一基准 |
| 中文乱码或编码报错 | 文件 handler 使用系统编码 | 查看文件编码 | 指定 encoding="utf-8" |
| 接口被拖慢 | 日志同步写盘 | profile 查看写日志耗时 | 级别控制、QueueHandler 异步化 |
| 日志无法关联请求 | 缺少请求 ID | 查看日志是否有统一字段 | contextvars + Filter 注入 request_id |
5. 进阶:让日志真正服务于排查
配置跑通只是起点。当项目体量上来了,日志不再只是给自己看的“心理安慰”,而是需要成为可查询、可统计、可告警的数据源。最后这一节聊几个我自己在实际中受益匪浅的进阶玩法。
5.1 结构化日志与 trace_id
结构化日志的最大价值是把“给人看的文本”变成“给系统查的数据”。JSON 格式只是第一步,更关键的是让日志带上足够多的关联字段。
在微服务或者分布式系统里,一个请求会经过多个服务,单纯的 request_id 只能关联单个服务内的日志。如果网关在入口生成 trace_id,并通过 HTTP 头向下游传递,那所有服务日志里都会有同一个 trace_id,查问题就能跨服务拉通。
Python 生态里实现链路追踪一般会用 opentelemetry、sentry-sdk 这类库,它们会注入自己的 span_id、trace_id 到日志上下文。如果你还不想上整套链路追踪,至少要做到 request_id 贯穿一次请求的所有中间件和业务代码。这个投入产出比非常高,从“日志能看”到“日志能查”的跨越,往往就差这一个字段。
5.2 日志采样与成本控制
日志采集是有成本的,文件存储、带宽、检索都要花钱。如果你把系统里每一条 DEBUG 都收上去,存储成本会涨得很难看。但反过来,ERROR 日志绝对不能丢,这是底线。
在高频日志和成本之间,常用手段是采样。对 WARNING 以下的日志按一定概率记录,ERROR 和 CRITICAL 全部保留。实现不复杂,一个自定义 Filter 就能做:
import random import logging class SamplingFilter(logging.Filter): def __init__(self, rate: float, level_no: int = logging.WARNING): super().__init__() self.rate = rate self.level_no = level_no def filter(self, record): if record.levelno >= self.level_no: return True return random.random() < self.rate挂到 handler 上时指定保留比例,比如 INFO 日志只记 10%,WARNING 以上全记。这个做法在流量大、日志量大时能显著降低成本,而且不会影响对真正错误的感知。
5.3 一些我个人的习惯
说多了方法论,最后分享几个我自己这些年坚持的习惯。
第一个,拒绝在模块里到处做日志配置。模块只负责拿 logger、记事件,配置一律在应用入口集中管理。这样做的好处是代码复用时不污染宿主环境,换一个项目部署也不会因为日志配置而连坐。
第二个,异常必然带着 traceback 记录。捕捉到异常时用 logger.exception 而不是 logger.error("xxx")。exception 会自动携带当前异常的堆栈信息,排查问题能少走很多弯路。
第三个,生产环境留足 ERROR 日志的容量。很多时候 DEBUG、INFO 都可以砍,但在系统异常高发窗口,ERROR 日志可能短时间暴增。给 ERROR 日志单独开一个文件,按大小轮转留足够备份,能避免关键错误被普通日志顶掉。
第四个,定期演练日志排查。我见过不少项目日志配得很好,但真出问题时没人会用,只知道打开日志文件按 Ctrl+F。尝试把日志接入一套查询界面,然后用一条真实故障日志练习“按 request_id 拉全链路”“按时间段聚合错误数”。这些能力真到了凌晨三点线上报警时,能救命。
日志这个事不复杂,但因为它不产生业务价值,经常被糊弄过去。等到线上故障复盘时,你才会发现,当初认真设计的每一行日志格式、每一个轮转参数、每一个脱敏规则,都在帮未来的自己省时间。