1. 为什么你的应用需要一套正式的日志规范
1.1 从print到Logging:我踩过的第一个坑
有一次线上业务报错,我打开生产环境的日志文件,5分钟刷了2万行第三方库的DEBUG输出,真正的异常却被淹没在一个没带堆栈的ERROR里。那是我第一次意识到:Python日志记录(Logging)看起来简单,但从“能打印”到“能用、能救火”之间,还隔着一整套工程规范。
早期写项目时我也习惯用print,毕竟本地调试足够直观。可一旦部署到服务器,print的缺点就全暴露了:没有时间戳,没有日志等级,不知道是哪一行代码打的,更别提按文件名和行号去定位了。更要命的是,print会写到标准输出,跟业务打印混杂在一起,如果服务还有定时任务,日志基本就是一锅粥。
后来我换成了logging模块,但一开始只是把print改成logger.info,这算迈过了第一道门槛,却远远不够。网上很多教程只教了“怎么用”,没教“怎么用好”。同样的logging,在小型脚本和生产服务里的定位完全不同。如果只是跑一个本地小工具,print完全够用;但只要你的代码需要被其他人复用、需要在后台常驻运行、需要出问题后按时间线回放现场,就必须引入正式日志规范。
这也是我写这篇内容的核心目的:把多次项目迭代中沉淀下来的日志记录(Logging)最佳实践完整梳理一遍,从最基本的三件套到工程化配置、文件轮转、结构化日志、多进程陷阱,一次讲透。无论你是刚接触Python,还是已经在生产环境被日志问题折磨过,都应该能从里面找到可以直接照搬的方案。
1.2 日志系统到底在回答什么问题
很多团队把日志当成“程序运行轨迹记录”,这个想法没错,但太模糊。我认为日志系统本质上只回答三个问题:什么时候发生了什么(When)、在代码里的哪个位置发生的(Where)、具体内容是什么(What)。
三个问题缺一个,排障效率就会断崖式下降。比如只记录“用户登录失败”而没有时间,你无法判断失败是否集中在一个时间段;没有logger名称和代码行号,你还要在几千行业务代码里人肉搜索;没有足够的上下文参数,你根本不知道这次失败和前一次失败有什么区别。
所以日志的设计原则应该是“给未来排障的自己看”,而不是“给我现在看”。这意味着需要做到:可分级,能够按严重程度过滤;可配置,在不同环境里能选择不同输出位置;可聚合,同一请求的日志能通过一个ID串起来;可筛选,运行时能快速查找关键词。
这些需求,恰恰是Python标准库logging模块被设计出来的原因。它从1994年首次推出,经过多年迭代,至今仍是大多数项目日志体系的地基。与其重复造轮子,不如先把这套标准机制的边界摸清楚。
1.3 什么项目才需要日志规范
我给不出“必须超过多少行代码就要用logging”的武断结论,但可以根据项目阶段判断:
- 脚本型工具:一次性运行、结果简单,用print问题不大。
- 常驻服务:Web服务、Worker、定时任务,必须用logging。
- 库项目:只负责提供功能,内部不要配置日志,只创建logger并记录,具体输出交给调用方。
- 跨团队项目:日志是共同语言,必须提前约定统一格式,否则排障时大家各自为政。
很多避坑经验都来自这个判断:库代码里配置了handler,结果被依赖方用了之后,重复日志满天飞;脚本里强上logging,反而增加理解成本。所以最佳实践的第一步,是先想清楚你的代码处在哪个生态位。
2. 基础配置:Logger、Handler、Formatter三件套怎么搭
2.1 logging模块的核心流程
我把logging模块的运行机制总结成一句话:业务代码把日志事件交给Logger,Logger根据级别判断放行还是丢弃,放行的事件经过Handler路由到目的地,再由Formatter决定最终外观。
用生活类比来理解:Logger是写日记的人,Handler是决定把日记交给日记本、邮件还是碎纸机的人,Formatter是排版模板。三者各司其职,可以任意组合。
很多人刚接触时会困惑:为什么在Logger上要设置一次setLevel,在Handler上又要设置一次setLevel?这是两套独立的闸门。Logger.setLevel决定这条日志事件“要不要接进管道”,Handler.setLevel决定“管道里的日志在出口处要不要丢弃”。为了达到某个等级才输出,通常把Logger设为DEBUG,把Handler设为业务需要的INFO,这样调试时只需把Logger调成DEBUG,就能看到完整过程。
另一个核心概念是传播链。当你执行logger.info("hello")时,模块会生成一条LogRecord,先送给logger自身挂载的handlers,然后逐级向上传递给祖先logger的handlers。默认情况下,最终会传到root logger。子logger不挂handler时,日志会使用root的配置;子logger挂了handler且没有关闭propagate时,输出内容就可能出现两份。这条传播机制引出了后面要重点讲的重复日志问题。
2.2 最小可用配置与关键参数
先从最朴素的一份配置开始:
import logging logger = logging.getLogger("myapp") logger.setLevel(logging.DEBUG) handler = logging.StreamHandler() handler.setLevel(logging.DEBUG) formatter = logging.Formatter( "%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S" ) handler.setFormatter(formatter) logger.addHandler(handler) logger.info("system started")这段代码看着简单,实际藏着几个关键点。getLogger("myapp")创建了一个命名的logger,名字会成为日志内容的一部分,方便从模块名反查代码位置。setLevel(logging.DEBUG)是源头开关,如果你忘了这一步,logger会继承root默认的WARNING级别,info日志就会无声消失。这是新手最常见的问题:明明调用了logger.info,控制台却什么都没打印。
StreamHandler默认会输出到stderr。如果希望输出到stdout,要显式指定:
handler = logging.StreamHandler(sys.stdout)生产环境里,stderr和stdout可能会被运维系统分开收集,统一成stdout反而更容易处理。这一点经常被忽略,等到对接日志采集平台时才暴露出来。
2.3 模块内logger的命名习惯
我看到很多人不管在哪个文件里都写logging.info,这种写法本质上用的是root logger。小项目没问题,但模块一多,日志就分不清来源。最佳实践是每个模块都使用自己的命名logger:
# service.py import logging logger = logging.getLogger(__name__) logger.info("service started")__name__会根据模块路径生成层级名称,比如services.user_service。这个名称天然反映了代码结构,排查问题的时候,只要看logger名就能知道是哪个模块打的。层级命名也方便统一控制:你可以只调整services这个logger的级别,就能影响它下面所有子模块。
有两个原则必须刻进脑子里。第一,应用入口负责配置root logger;模块内部的logger只管记录,不要再addHandler。第二,库代码永远不要配置日志,调用方怎么配置是他们的事。遵循这两条,日志配置就是单向依赖,不会互相踩脚。
3. 格式与级别:让日志真的能帮你排查问题
3.1 日志级别是语义设计,不是随口标记
日志级别看似简单,很多人却用得很混乱。我给团队定规矩时,通常用下面这套语义:
| 级别 | 语义 | 典型场景 |
|---|---|---|
| DEBUG | 开发期细节 | 变量值、函数入参、中间状态 |
| INFO | 正常业务节点 | 任务开始、请求处理完成、数据库连接建立 |
| WARNING | 可恢复的异常 | 重试第2次、缓存失效、配置降级 |
| ERROR | 当前操作失败 | 某次API调用500、任务执行失败 |
| CRITICAL | 整体不可用 | 启动失败、数据损坏、进程退出 |
实际项目里最常见的错误是:把可恢复的异常打成INFO,理由是“反正重试就成功了”;把单个请求失败打成CRITICAAL,理由是“用户很生气”。级别一旦失去语义,告警规则就无法落地。我看到过一套相对合理的做法:ERROR必须伴随告警,且必须有人响应;WARNING是白天会看、晚上不惊醒人的;INFO是能够连成业务链路的;DEBUG是为将来临时排查预留的。
关于异常日志,有一个黄金写法:在except块内用logger.exception。它会自动附带当前异常堆栈,并按照ERROR级别输出,省去手动exc_info=True的麻烦。
try: result = 10 / int(user_input) except ZeroDivisionError: logger.exception("计算失败,输入值: %r", user_input)%r会把值用repr展示,字符串会带引号,便于区分变量类型。如果你在except外调用logger.exception,得到的只有一行没有任何堆栈的ERROR,等于白记。这条坑我几乎每个项目都见人踩过。
3.2 日志格式里该放哪些字段
Formatter的格式串决定你日后排查时能拿到多少信息。一个只带message的日志,基本没有排查价值。我常用的生产格式是这样的:
FORMAT = ( "%(asctime)s | %(levelname)-8s | %(name)s | " "%(process)d:%(thread)d | %(filename)s:%(lineno)d | %(message)s" )逐项解释:asctime是时间戳,levelname是对齐显示级别,name是logger名,process和thread是多进程与多线程里定位现场用的,filename和lineno能直接跳转到代码行。日志格式里带行号,在Python这种动态语言里特别重要,因为框架调用链很深,仅靠函数名很难定位实际触发点。
时间戳还要注意时区问题。默认asctime使用本地时间,生产服务器如果分布在多个时区,或者与日志采集平台时区不一致,时间轴就会乱。我通常统一用UTC记录,格式化时把converter改成UTC:
formatter = logging.Formatter( "%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%dT%H:%M:%S%z" ) formatter.converter = time.gmtime%z会显示偏移量,配合time.gmtime,日志时间就具备了全球可读性。当然,如果你们团队统一用北京时间,保持一致即可,但一定不要“每个服务器用自己本地时间”。
消息内容同样有讲究。尽量使用参数化格式,而不是提前拼接字符串:
# 推荐 logger.info("user %s login from %s", user.id, ip) # 不推荐 logger.info(f"user {user.id} login from {ip}")原因很简单:logging是懒格式化。只有当这条记录真的要被输出时,%s才会被替换成实际值。如果当前级别过滤掉了INFO,参数化写法可以避免构造字符串的开销。对于高频链路,这个性能差异还是能感受到的。
3.3 敏感信息必须脱敏
日志里最容易出问题的是敏感数据。密码、令牌、身份证、支付账号,任何一条出现在日志文件里,都可能成为安全事故。最佳实践是:能不打就不打;必须打就去掉中间字段再打。
最简单的做法是写一个Filter,把消息里的关键模式替换掉:
class MaskSecretFilter(logging.Filter): def filter(self, record): if isinstance(record.msg, str): record.msg = record.msg.replace("password=", "password=***") return True这只能作兜底,更稳妥的是在业务代码里少传敏感参数。等到日志已经落盘才发现脱敏问题,再去清洗历史文件,成本高得多。
4. 工程化配置:dictConfig的正确打开方式
4.1 为什么我不用fileConfig
项目一旦多了,手写logger.addHandler这种样板代码就开始失控。尤其是多个模块都想往不同地方输出、各自还要不同级别时,手工维护Handler实例的代码会非常丑陋。标准库提供了两种工程化配置方案:fileConfig和dictConfig。
fileConfig基于INI文件配置,历史包袱重,对Python对象的表达能力弱。比如你的Formatter需要传入自定义过滤器,或者Handler需要调用自定义函数,INI格式就很难优雅表达。dictConfig直接使用Python字典,描述清晰、类型安全、支持自定义工厂函数,几乎可以覆盖所有场景。它也更适合放进settings.py这类配置模块里,跟项目其他配置共享一套加载逻辑。
唯一需要提醒的是disable_existing_loggers这个参数。dictConfig的默认值是True,意味着执行配置时会禁用所有已存在的logger。如果你的模块在配置之前就调用过getLogger(),配置完成后这些logger可能集体沉默。所以工程代码里一定要显式设置:
"disable_existing_loggers": False,4.2 一套可直接复用的dictConfig模板
import logging.config LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, "formatters": { "standard": { "format": "%(asctime)s | %(levelname)-8s | %(name)s | " "%(process)d:%(thread)d | %(filename)s:%(lineno)d | %(message)s", "datefmt": "%Y-%m-%d %H:%M:%S" } }, "handlers": { "console": { "class": "logging.StreamHandler", "level": "INFO", "formatter": "standard", "stream": "ext://sys.stdout" }, "app_file": { "class": "logging.handlers.RotatingFileHandler", "filename": "logs/app.log", "maxBytes": 10485760, "backupCount": 10, "encoding": "utf-8", "formatter": "standard" } }, "root": { "handlers": ["console", "app_file"], "level": "INFO" }, "loggers": { "uvicorn": {"level": "WARNING"}, "requests": {"level": "WARNING"} } } logging.config.dictConfig(LOGGING_CONFIG)这套配置做了四件事:格式化统一、输出到控制台和文件、root级别为INFO、把两个嘈杂的第三方库调成WARNING。其中的RotatingFileHandler、encoding等细节后面会展开。
如果你习惯用logging.basicConfig,也可以把它看作一个只支持root配置的快捷方式。但当项目需要“control loggers”“权限控制”“全局过滤器”时,basicConfig很快就不够用了,还不如一开始就上dictConfig。
4.3 屏蔽第三方库的噪音日志
Python的日志世界有一个重要事实:第三方库如果遵守规范,它就不应该自己配置输出,而是创建一个命名logger,把决定权交给使用方。所以屏蔽噪音最优雅的方式不是去改库代码,而是通过配置调整它的logger级别。
上面的配置里,"requests": {"level": "WARNING"}这一行关键点在于:没有给它配handler,也没有设propagate,所以它仍然把日志传播给root,由root的console和file统一输出,只是这次级别过滤掉了INFO和DEBUG。如果某个库日志实在太多,也可以单独把它设为ERROR。
需要注意propagate的误用。有些人为了屏蔽某个logger,直接写"propagate": False,结果这个logger内部自己挂了handler,日志照样输出,而它的子logger却再也到不了root,其他日志反而丢了。屏蔽噪音优先用setLevel,不要动不动就关传播。
5. 文件轮转与清理:别让日志把磁盘撑爆
5.1 主流轮转Handler的选型
如果日志只写到一个文件里,磁盘迟早会被撑爆。我见过一台生产机器因为日志文件占满磁盘而服务挂掉的案例,原因就是日志量比预想的大了几倍,没配置轮转。标准库里有两个主流选择:RotatingFileHandler按大小轮转,TimedRotatingFileHandler按时间轮转。
RotatingFileHandler的逻辑是:当前文件写到maxBytes大小后,把现有文件依次改名,最新备份叫app.log.1,旧的叫app.log.2,最多保留backupCount份。配置示例:
from logging.handlers import RotatingFileHandler handler = RotatingFileHandler( filename="app.log", maxBytes=10 * 1024 * 1024, # 10MB backupCount=10, encoding="utf-8" )TimedRotatingFileHandler则按时间切分,最常用的是午夜切一天一个文件:
from logging.handlers import TimedRotatingFileHandler handler = TimedRotatingFileHandler( filename="app.log", when="midnight", backupCount=30, encoding="utf-8" )选型建议:磁盘敏感、日志量稳定,优先按大小;需要按天回看业务曲线,优先按时间。我通常两个都用:普通应用用大小轮转,保留最近10个文件,整体磁盘上限大约100MB;业务需要对账、按天归档时,用时间轮转并保留30天。
5.2 多进程日志的安全姿势
单个应用起多个进程时,RotatingFileHandler并不安全。轮转过程涉及文件重命名和重新打开,多个进程同时操作同一个文件,轻则日志写入错乱,重则把备份文件覆盖掉。生产环境里常见于gunicorn多worker、celery多worker跑同一套日志配置。
处理方式有三条路。第一,每个进程写不同的文件,比如文件名里带上进程号;第二,写stdout,由外部日志采集程序负责轮转归档;第三,进程内通过QueueHandler收集日志,再由一个专门的listener进程写入文件。
第三种方案用标准库就可以落地:
import logging.handlers import queue log_queue = queue.Queue(-1) queue_handler = logging.handlers.QueueHandler(log_queue) file_handler = logging.handlers.RotatingFileHandler(...) queue_logger = logging.getLogger("app") queue_logger.addHandler(queue_handler) listener = logging.handlers.QueueListener(log_queue, file_handler) listener.start()这种方式把写文件的操作收敛到单点上,多进程可以共用listener?严格来说QueueListener要挂在每个进程内还是全局,要结合进程模型设计。更常见的部署是容器环境:应用不做文件写日志,而是全部打到stdout,由容器日志采集器读取。这样轮转、压缩、清理都不需要业务代码关心。
5.3 日志保留与压缩
轮转不等于无限保留,backupCount只决定保留多少份备份,不决定保留多少天。要控制保留天数,最直接的是用外部脚本定期清理:
find logs -name "*.log.*" -mtime +30 -delete也可以在代码层面对旧日志做压缩,避免历史文件占用太多空间。给RotatingFileHandler挂上自定义rotator:
import gzip import os def gzip_rotator(source, dest): with open(source, "rb") as f_in, gzip.open(dest + ".gz", "wb") as f_out: f_out.write(f_in.read()) os.remove(source) handler.rotator = gzip_rotator这是一个小细节,但对长时间运行的服务来说,能显著减少磁盘占用。压缩是异步的更好,但它至少能保证日志不会失控式膨胀。
6. 结构化日志与Trace ID:从可读走向可观测
6.1 文本日志为什么难排查
文本日志适合人读,不适合机器分析。按行拼接的日志,格式稍微不一致,检索和聚合就非常痛苦。你没法直接问“过去5分钟有多少个登录失败”,因为失败信息藏在几万行文本中间。而结构化日志就是让日志变成可被程序消费的数据,最常用的是JSON格式。
在现代后端体系里,日志不再只是给人看的文件,而是可观测性系统的一部分。运维平台会根据日志关键字做查询、告警、趋势统计。如果每个字段没有明确语义,这些统统做不了。所以我建议新项目从第一天开始就采用结构化日志,而不是等日志量大了再迁移。
6.2 自己写一个JSON Formatter
不引入第三方库也能实现。标准库Formatter子类,把LogRecord的属性装进字典,序列化成JSON:
import json import logging class JsonFormatter(logging.Formatter): def format(self, record): data = { "time": self.formatTime(record, self.datefmt), "level": record.levelname, "logger": record.name, "message": record.getMessage(), "module": record.module, "line": record.lineno, "process": record.process, "thread": record.thread, } for key, value in record.__dict__.items(): if key not in ( "name", "levelname", "msg", "args", "exc_info", "exc_text", "stack_info", "created", "msecs", "relativeCreated", "module", "line", "process", "thread" ): data[key] = value if record.exc_info: data["exc_info"] = self.formatException(record.exc_info) return json.dumps(data, ensure_ascii=False, default=str)这段代码有个关键:把record.__dict__里不是内置字段的自定义属性也带进JSON。这样后面我们加的request_id就会自动出现在每条日志里,非常方便。
当然也可以直接用python-json-logger、structlog这类库。我的建议是:团队已有固定习惯就用顺手的;新项目从标准库自己封装一个Formatter,依赖少,也更容易理解机制。
6.3 给日志加上Request ID / Trace ID
单次请求会经过中间件、业务函数、数据库操作、外部调用,如果这些日志之间没有共同标识,排查一次完整链路就像在拼图。最常见的做法是:在请求入口生成一个request_id,并让它自动贯穿这条链路上的所有日志。
同步场景用threading.local就够,异步场景要使用contextvars,否则协程之间会串号。下面是一个基于ContextVar的实现:
import contextvars import uuid request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar( "request_id", default="-" ) class RequestIdFilter(logging.Filter): def filter(self, record): record.request_id = request_id_var.get() return True在FastAPI的中间件里,每次请求开始时设置:
request_id_var.set(uuid.uuid4().hex)然后在日志格式或JSON字段中带上request_id,就能把所有相关日志串起来。我曾经在一个分布式任务里用类似方案追踪一个失败的订单处理,原来需要翻半小时日志,后来一条request_id直接定位到8个节点的全部轨迹。
7. 这些坑我几乎每个项目都会遇到
7.1 重复日志与handler堆积
重复日志是logging最出名的问题。症状就是:某条日志在控制台出现了两次、三次,排查半天发现是子logger既挂了自己的handler,又没有关闭propagate,于是root handler又输出了一遍。
我怎么排查这类问题的:先检查root上的handlers。
root = logging.getLogger() print(root.handlers)再看具体logger:
logger = logging.getLogger("myapp.service") print(logger.handlers) print(logger.propagate)解决方向很简单,二选一:要么子logger不挂handler,完全靠root输出;要么子logger挂handler,同时设置propagate=False。如果你用的是dictConfig,某个logger配置里出现了"handlers": [...]且没有"propagate": False,就要警惕重复。
另一个常见源头是配置函数被多次调用。比如在模块import时执行了一次setup_logging(),函数内部又无脑addHandler,导致每import一次就多一个handler。最佳实践是配置函数幂等化:
def setup_logging(): if logging.getLogger().handlers: return logging.config.dictConfig(LOGGING_CONFIG)7.2 basicConfig在入口之后的“失效”
很多人调试时会这样写:
import logging logging.basicConfig(level=logging.INFO) ... logging.basicConfig(level=logging.DEBUG) # 想切到DEBUG,却发现没生效原因是basicConfig只有在root还没有任何handler的时候才会执行,如果已经配置过,后续调用会静默忽略。这不是你写错了,而是设计如此。想切换级别,应该直接操作root:
logging.getLogger().setLevel(logging.DEBUG)或者重新加载完整的dictConfig。生产环境里,建议把日志配置收敛到入口模块的一个函数,避免散落在各处。我在项目里定了一条约定:只有main.py或app.py能调用配置函数,其他模块一律只创建logger。
7.3 异常日志却看不到堆栈
没有堆栈的ERROR只能告诉你“出错了”,不能告诉你“哪里错了、为什么错”。我看到过不少代码这样写:
except Exception as e: logger.error(f"something went wrong: {e}")这条日志记录了错误对象,但没有traceback。要拿到完整堆栈,正确的姿势是:
except Exception: logger.exception("something went wrong")logger.exception的本质是ERROR级别加上exc_info=True,但它必须在except块内使用,因为只有那里才能拿到当前异常上下文。如果你已经捕获异常并处理完,之后再用logger.exception,就只能得到一条空堆栈。所以规则是:捕获异常后立刻记录,不要等到最后统一记。
7.4 多进程和多线程并发的日志竞争
日志模块本身是线程安全的,多个线程写同一个handler不会导致内容交错崩溃。但多进程场景下,文件handler会碰到更复杂的问题。gunicorn默认就起多个worker,如果每个worker都在RotatingFileHandler里轮转同一个文件,文件重命名的竞态几乎一定会出现。日志偶尔会丢,轮转备份会莫名少几个。
我目前的方案顺序是:优先写stdout,让容器或进程管理工具收集;必须写文件时,要么按进程PID分文件,要么通过QueueHandler把日志汇聚到一个writer进程里。
import multiprocessing as mp import logging.handlers queue = mp.Queue(-1) def worker_main(q): h = logging.handlers.QueueHandler(q) logger = logging.getLogger("worker") logger.addHandler(h) logger.info("worker running") listener = logging.handlers.QueueListener( queue, logging.handlers.RotatingFileHandler("app.log", maxBytes=10485760, backupCount=10) ) listener.start()这个方案的代价是多了一个专门消费日志的线程,但换来的是多进程写安全。如果并发量不高,也可以简单点:每个worker进程内部独立创建一份文件,文件名带PID。
8. 最后分享几个我在生产环境坚持的小习惯
这些经验没有写在官方文档里,但确实帮我扛过了很多次线上事故。
第一,所有文件Handler都显式声明encoding="utf-8"。Windows服务器或者特殊环境里,默认编码可能是GBK,一旦日志里出现中文且编码不一致,日志文件可能直接乱码,排查起来非常闹心。
第二,容器应用只写stdout,不写文件。Kubernetes这类环境里,标准输出会被采集组件统一收集,业务代码再写一份日志文件既浪费磁盘,又会造成重复采集。日志的持久化、轮转、归档交给基础设施去处理,应用只负责输出语义化事件。
第三,日志配置只加载一次,并且放在入口模块的最前面。如果配置迟到,早先的logger可能已经以默认级别创建,后续调整会留下“部分生效”的隐患。
第四,预留运行时动态调整级别的通道。我现在很多项目里会暴露一个内部接口,只要带上日志级别参数,就能在线修改root logger的level,排查问题时不用重启服务:
logging.getLogger().setLevel(logging.DEBUG)第五,不记录核心敏感数据,必须记录时先脱敏。密码、Token、Cookie值这些字段,不属于日志该操心的事。
最后再说一个细节:如果你的日志量很大,类似logger.debug("result=%r", result)这种写法确实比f-string更省性能,但如果格式化对象本身构造很昂贵,还是需要先判断logger.isEnabledFor(logging.DEBUG)再执行。日志设计没有银弹,理解机制之后,每个选择都应该是可解释的。