2. 日志配置的工程化实践
2.1 Python logging 的核心组件
在实际项目中,我强烈建议你花半小时把 Python 标准库 logging 的四大组件搞清楚,因为 FastAPI 本身并没有一套独立的日志系统,它复用的就是 Python 的 logging 体系。这四个组件分别是 Logger、Handler、Formatter 和 Filter。
- Logger:日志的入口,也就是你代码里
logger.info("xxx")中的 logger,负责接收日志消息。 - Handler:日志的目的地,决定消息输出到哪里,比如控制台、文件、网络。
- Formatter:日志的排版格式,包括时间、级别、错误堆栈等信息。
- Filter:对日志消息进行过滤,决定哪些消息能通过。
Logger 可以设置级别,比如logger.setLevel("DEBUG"),表示 DEBUG 级别以上的日志才会被处理。Handler 也可以设置级别,两者取交集生效。举个生活中的例子,Logger 就像小区门口的保安,Handler 就是通往不同区域的通道,Formatter 是通道口的门牌——保安会筛选哪些人能进小区(Logger 级的过滤),通道口再判断这个人能不能去对应区域(Handler 级过滤)。
理解了这层结构,你就知道为什么很多新手只调一个logging.basicConfig()却不生效,因为实际代码里的 logger 可能是根 logger 的子 logger,继承关系没对齐,导致消息根本没传给正确的 Handler。
2.2 一套可直接复制的日志配置模板
下面我给你一套我常用的配置模板,直接复制到项目里就能用,它同时输出到控制台和文件,并且按日期滚动。这套配置的完整代码我放在下面。
import logging import os from logging.handlers import TimedRotatingFileHandler LOG_DIR = "logs" os.makedirs(LOG_DIR, exist_ok=True) def setup_logging(): # 创建根 logger logger = logging.getLogger() logger.setLevel(logging.INFO) # 控制台 Handler console_handler = logging.StreamHandler() console_handler.setLevel(logging.INFO) # 文件 Handler,按天滚动 file_handler = TimedRotatingFileHandler( os.path.join(LOG_DIR, "app.log"), when="midnight", backupCount=30, encoding="utf-8" ) file_handler.setLevel(logging.INFO) # 统一的格式 fmt = logging.Formatter( "%(asctime)s | %(levelname)s | %(name)s | %(message)s" ) console_handler.setFormatter(fmt) file_handler.setFormatter(fmt) # 清除已有的 handler,避免重复添加 logger.handlers.clear() logger.addHandler(console_handler) logger.addHandler(file_handler) return logger logger = setup_logging()这里有几点我要特别提醒你:
第一,logger.handlers.clear()这行很重要。如果你在开发模式下用uvicorn --reload,模块会被重新加载多次,每加载一次就 add 一个 handler,不清理的话日志会重复打印好几遍,排查起来很痛苦。
第二,TimedRotatingFileHandler是日志界的老实人,它负责按时间滚动文件,backupCount=30表示保留最近 30 天的日志。为什么是 30 天而不是 7 天?我个人的经验是:线上问题往往不是当天发现的,有时候用户反馈的问题要回溯两周前的日志才能定位,如果你只留一周,关键时刻翻不到历史记录,那就很被动了。
第三,encoding 参数务必设置为utf-8,否则在生产环境里打印中文日志时,会直接报UnicodeEncodeError,而日志系统一旦自己崩了,你就完全失去了排障的眼睛。这个问题我踩过一次,印象深刻。
2.3 请求日志中间件的完整实现
日志不只是给函数打印的,对于 Web 系统来说,每个请求的上下文信息才是最贵的资产。所谓“请求上下文”,就是这一次请求进来了什么、处理了多久、花了多少时间、最终返回什么状态码。把这些记录下来,等出问题的时候,你就能从日志时间线上反推整个链路。
下面是实现方案,核心思路是借 FastAPI 的中间件机制,在请求进入路由之前记录开始时间,在响应返回之后计算耗时并写日志。
import time from fastapi import Request @app.middleware("http") async def request_logger(request: Request, call_next): start_time = time.perf_counter() response = await call_next(request) duration_ms = (time.perf_counter() - start_time) * 1000 logger.info( f"{request.method} {request.url.path} " f"status={response.status_code} cost={duration_ms:.2f}ms " f"client={request.client.host if request.client else 'unknown'}" ) return response注意这里用time.perf_counter()而不是time.time(),原因是 perf_counter 专门用于测量短时间间隔,不受系统时钟调整的影响,精度更高。在高并发下,这个毫秒级数据的准确性就是你定位慢查询的底气。
这套日志的一个小坑是:如果请求在路由内部抛异常,中间件的call_next会直接抛出异常,导致日志无法正常记录。解决办法是把call_next放在 try/except 里,异常发生时额外记录一条 ERROR 日志,这正好引出了下一部分:异常处理和日志的联动。
3. 把异常与日志联动起来——给排障装上“行车记录仪”
3.1 从异常处理器中收集第一手现场信息
默认情况下,FastAPI 的 HTTPException 会把错误信息直接返回给客户端,框架自身并不会记录日志。这意味着,如果用户请求了一个不存在的商品 ID,你会发现日志里什么都没有,线上报错了你连原因都猜不出来。所以我们要做的第一件事,就是在异常处理器里主动写日志。
@app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException): logger.error(f"HTTP exception on {request.method} {request.url.path}: {exc.detail}") return JSONResponse( status_code=exc.status_code, content={"code": exc.status_code, "message": str(exc.detail)} )这个处理器的逻辑很简单:收到 HTTPException 就立即记录一条 error 级别的日志。为什么用 error 而不是 info?因为异常意味着系统没能正常完成请求处理,这已经偏离了预期路径,无论对用户体验还是对系统健康来说,都值得被标记出来。
还有一点,日志里一定要带上的不只是异常本身,而是异常发生时的请求上下文:请求方法、路径、参数等。为什么?因为同一个错误信息“商品不存在”,可能有一百个用户触发,你是想看到一百条孤零零的报错,还是能看到“用户 A 查了 ID 为 8888 的商品但库存不存在”这样的完整画面?显然是后者,上下文才是日志的灵魂。
3.2 记录完整的异常堆栈
对于普通业务异常,一条 error 日志可能够了;但对于未知异常,你光知道报错信息是不够的,你还需要知道它是在哪一行代码崩的。这时候就要用到 traceback——也就是你看到的那种红字堆栈信息。
在 logging 中,记录异常堆栈的标准写法是使用logger.exception(),它会自动附带当前堆栈信息,并将其记录为 ERROR 级别。
@app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): # 记录完整堆栈,避免留下线索盲区 logger.exception(f"Unhandled exception on {request.method} {request.url.path}") return JSONResponse( status_code=500, content={"code": 500, "message": "服务器开小差了,请稍后重试"} )这里有个细节:logger.exception()必须在 except 块内调用,而且它会自动往日志里附加堆栈信息,等价于logger.error(..., exc_info=True)。这两者是等价的,我建议新手直接用logger.exception(),省得在参数上报错。
另外请注意,全局 ExceptionHandler 返回给用户的消息我写的是“服务器开小差了”,而不是把真实的堆栈信息直接返回给客户端。这样做是为了安全——堆栈信息里经常包含文件路径、数据库语句、内部变量等敏感信息。一旦返回给客户端,等于把系统的内部结构免费暴露给任何人,这可比让用户看到“500”可怕多了。正确的姿势是:把真实细节留在日志,给用户的只留一句安抚提示。
3.3 日志字段里的“五要素”
日志信息本身不是随便写的,它需要满足一个最基本的“五要素”标准:何时、何地、何人、何事、结果如何。我整理了一个实操对照表。
| 字段 | 含义 | 典型取值 | 示例 |
|---|---|---|---|
| timestamp | 何时发生 | 精确到毫秒 | 2024-03-02 14:23:45, 123 |
| endpoint | 请求路径 | URL路径 | /api/v1/products/8888 |
| method | 请求方法 | GET/POST/PUT/DELETE | GET |
| status_code | 响应状态码 | 200/400/404/500 | 404 |
| traceback | 堆栈信息 | 异常堆栈 | 详见日志文件 |
我把这个五要素给了很多团队作为日志规范,事实证明效果很好。一旦所有人写日志都按这个格式来,排障的时候就能形成肌肉记忆:打开日志文件,先按时间排序,再按 endpoint 和生产线的报错关联起来,问题几乎都能在一个小时内定位。
4. 日志的进阶玩法——把日志从“写了”变成“好用”
4.1 日志分级:别让 ERROR 噪音淹没重要信息
在生产环境中,最痛苦的不是没有日志,而是日志太多、太吵,关键信息被淹没在汪洋大海里。这时候你必须建立一条铁律:不同级别对应不同场景。我把这套分级策略画成了表格,你直接拿着用:
| 级别 | 适用场景 | 告别“所有问题一个级别” |
|---|---|---|
| DEBUG | 开发调试、逐行追踪 | 正常业务流程的细节输出 |
| INFO | 系统关键节点状态 | 请求完成、任务执行、用户操作 |
| WARNING | 潜在风险但未阻断 | 重试、弃用端口、性能下降 |
| ERROR | 系统无法完成单次操作 | 数据库故障、外部接口失败 |
| CRITICAL | 系统级崩溃、即将不可用 | 启动失败、数据丢失 |
实际使用中最常见的误区有两个。第一个是 INFO 泛滥:有人把每一条数据库查询都打成 INFO,日志量一天几个 GB,查问题跟大海捞针一样。第二个是 ERROR 滥用:有人把正常业务上的一些小失败(比如用户输错密码)也打成 ERROR,导致报警系统整天乱叫,值班的人都麻了,真正严重的问题反而没人管。我的经验是:ERRor 只留给真正需要人来处理的问题,例如外部接口连续报错,说明系统环境可能异常了,必须人工介入。用户输错密码这种属于正常业务分支,用 WARNING 或 INFO 记录就行。
4.2 敏感信息全屏蔽
日志系统里有个容易被忽略但极其重要的原则:敏感信息必须脱敏。不要把用户密码、手机号、身份证号、Token、接口密钥等直接写进日志。因为日志文件往往权限设置不严,一旦泄露,可比代码泄露严重得多——密码改一改就完了,但身份证号泄露了,用户受到的伤害是长期的。
实操上你可以这样做:在中间件里对请求参数做脱敏,把密码字段统一替换为"******"。例如:
def sanitize_log_data(data: dict): sensitive_keys = {"password", "token", "secret"} return {k: ("******" if k in sensitive_keys else v) for k, v in data.items()}在打印请求参数时,先用这个函数过滤一遍再写入日志。同样重要的还有一条纪律:不打印 SQL 语句中拼接的完整查询参数,以防 SQL 注入的痕迹信息变成新的隐患。
4.3 用日志定位慢接口
日志排障场景里,最实用的技巧之一就是用耗时数据定位慢接口。如果你在中间件里记录了每条请求的耗时,你就很容易从一堆日志里筛出那些处理时间异常的请求。
以我上面的中间件为例,我们打印的日志格式里是包含cost={duration_ms:.2f}ms字段的。那么你可以用一行 shell 命令把超过 1 秒的请求全部捞出来:
grep "cost=" /path/to/app.log | awk -F'cost=' '{print $2}' | awk '{if ($1 > 1000) print $1}' | sort -rn | head -20更好的做法是直接升级中间件,在日志里加一个slow标记,比如当耗时超过 1 秒时,额外打一条WARNING日志,方便监控告警系统直接抓取。这种细节看起来很小,但在排障的时候能帮你节约大量时间。
5. 常见问题与排查技巧实录
5.1 日志只在控制台显示,不写入文件
这个问题有两种常见原因。第一种是文件 Handler 的路径不存在,TimedRotatingFileHandler在默认情况下不会自动创建不存在的目录,所以日志文件写入失败。解决办法就是我在配置模板里写的:先os.makedirs(LOG_DIR, exist_ok=True)。第二种原因更隐蔽:代码中定义 logger 时用了多个 Logger 实例,那这个 logger 的 Handler 没有正确设置。简单起见,排查时先把你设置 logger 的代码在整个应用入口处打印一遍,看看它到底绑定到了哪个 Logger 上,基本上就能定位到问题。
5.2 自定义异常处理器没有生效
我见过好几个同学出现这个问题:明明写了@app.exception_handler,但请求一进来还是返回默认的 JSON 错误格式。原因通常有下面几种。
- 处理器注册晚了。异常处理器要在应用定义之后、首次使用之前注册,如果你把
@app.exception_handler写在一个延迟加载的模块里,而那个模块在路由处理请求时还没执行 import,那处理器自然没生效。 - 处理器作用域搞混了。FastAPI 有两种写法:在
app = FastAPI实例上直接加装饰器,或在 APIRouter 上用相同方式加装饰器。两者作用域不一样——APIRouter 的处理器只对该路由下的请求生效。如果你代码里注册的是 router 级别的,但测试请求走的是另一个 router,那自然不会命中。 - 使用了错误的异常类型。比如你写的是
@app.exception_handler(ValueError),但实际代码抛出了TypeError,自然不匹配。排查时先去看异常的类型,用临时大法把异常类型打印出来再匹配。
5.3 uvicorn 的访问日志和自定义日志混在一起怎么办
还有一个非常让人头疼的场景:你启动服务时用的是uvicorn main:app,它默认会打印一层自己的访问日志。这层日志和你的业务日志是分开的。很多时候,这会导致你的日志文件里有两套格式化风格,排障时看起来特别乱。
解决办法也简单,启动时加一个参数--no-access-log,关闭 uvicorn 自带的访问日志,把所有请求日志统一收到你自己写的中间件里。
如果不想全局关闭,你还可以通过调整 uvicorn 默认 logger 的级别来控制。比如:
import logging logging.getLogger("uvicorn.access").setLevel(logging.WARNING)这样 uvicorn 的访问日志就不会被打出来,但错误日志仍然保留。这两种方式我都用过,我推荐新手直接用--no-access-log,简单直接、不容易出错。
5.4 日志轮转文件权限问题
TimedRotatingFileHandler在轮转旧日志时,会调用一个namer逻辑生成新文件名,同时触发os.rename()操作。如果你用 Docker 运行服务,而日志目录是挂载的宿主机目录,轮转时经常会出现权限问题:日志文件生成了,但旧日志无法归档。
这种情况的排查思路是检查挂载目录的文件权限是否足够写入,以及容器内的用户是否有权限执行 rename。我踩过的坑就是 Docker 容器默认以 root 用户运行,但挂载目录的属主是宿主机普通用户,最终导致日志轮转失败。最后我用chown调整了宿主机目录的权限归属,或者让容器改用非 root 用户运行,问题才彻底解决。
6. 从“能跑”到“好维护”——异常处理与日志的落地清单
写到这里,我特别想送你一份落地清单,它能帮你把学到的知识点真正部署到项目里。
- 定义统一响应结构,把 code/message/data 固定下来,前端和客户端对接文档也好写。
- 注册全局 HTTPException 处理器,并加入 logger.error。
- 注册 RequestValidationError 处理器,把参数校验错误转换成友好提示。
- 注册全局 Exception 处理器,兜住未知异常,记录堆栈但不把堆栈返回给用户。
- 配置 logging:控制台 Handler 必须保留,生产环境日志写入文件、按天轮转、保留 30 天。
- 写一个请求日志中间件:记录 method、path、status、耗时。
- 给日志字段做脱敏,不打印密码、Token、身份证号等敏感信息。
- uvicorn 启动参数加
--no-access-log,统一访问日志格式。 - 定期演练排障:打开日志,模拟一次 500 错误,确认能从堆栈快速定位到故障代码行。
这套清单是我搭建过好几个服务后沉淀出来的标准动作。刚开始搭建时,我也经历过那种没人看日志、出了事只能重新部署的老状态;后来慢慢把异常处理和日志从“能跑”打磨到“好用”,才发现线上维护的体验完全不一样了。现在团队排查问题时,只要打开日志,就能在分钟级别内大致锁定问题方向,这是最让我欣慰改进的地方。
最后再分享一个小技巧:别把日志水平停留在“打印一下”的程度。每次遇到线上问题,解决完之后都回头想一想——刚才如果日志里再多一个字段、少一个字段,是不是能更快定位问题?把这些优化回填到日志配置里,你的系统会随着时间推移越变越好维护。这比单纯背一堆 API 文档要实在得多。