【Python量化系统工程化 #09】日志越写越大出错就大海捞针?日志轮转+结构化日志让排错秒级定位

系列定位:让"写一个能稳定跑半年的量化脚本"这件事,从散落的经验贴变成可复制的流水线。


痛点:脚本稳定跑了,但日志成了新的噩梦

上一篇我们用 systemd 给脚本加了守护,现在它能 7×24 不停跑。但新问题来了:

  • 日志文件越来越大:跑了一个月,app.log 从几 KB 长到几百 MB,打开就卡死
  • 出错时大海捞针:三天前某只股票拉取超时,你只知道"数据断了",但在几十万行文本日志里翻不到是哪只、哪一刻、什么原因
  • 日志不可搜索:print() 和默认 Formatter 输出的是人类可读文本,机器不可读——想按股票代码过滤?想按错误类型统计?做不到

本质问题:日志是排错的生命线,但你用了错误的方式记它。


本文你将得到什么

  1. Python logging 模块的核心三件套:Logger / Handler / Formatter
  2. 日志轮转:TimedRotatingFileHandler 按天切分,自动保留 N 天
  3. JSON 结构化日志:每条日志一行 JSON,字段可搜索、可统计、可告警
  4. 结构化日志搜索:按级别 / 模块 / 关键词秒级过滤
  5. 真实 API 调用全链路日志演示

一、Python logging 核心三件套

Python 标准库 logging 模块的设计是三层管道:

你的代码 → Logger(记录器)→ Handler(输出器)→ Formatter(格式器)→ 最终日志

组件 职责 常用选择
Logger 入口,决定要不要记 logging.getLogger("quant")
Handler 决定写到哪里 FileHandler(文件)/ StreamHandler(控制台)
Formatter 决定长什么样 默认文本 / 自定义 JSON

一个 Logger 可以挂多个 Handler——比如同时写文件和控制台,两边格式不同。


二、日志轮转:TimedRotatingFileHandler

为什么需要轮转

不轮转的日志文件会一直增长。跑半年,一个 app.log 几百 MB 是常态。排查问题时 grep 一次要等十几秒,IDE 打开直接卡死。

轮转的核心思想:日志按时间(或大小)切成多个文件,旧文件自动改名归档,超期的自动删除。

TimedRotatingFileHandler 用法

import logging
import logging.handlers
import os

def setup_logger(log_dir, name="quant", backup_days=7):
log_path = os.path.join(log_dir, f"{name}.log")
os.makedirs(log_dir, exist_ok=True)

logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
logger.handlers.clear() # 避免重复添加

按天轮转

file_handler = logging.handlers.TimedRotatingFileHandler(
log_path,
when="midnight", # 每天午夜切分
interval=1,
backupCount=backup_days, # 保留 7 天
encoding="utf-8",
)
file_handler.setLevel(logging.DEBUG)

控制台也输出(开发调试用)

console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(
logging.Formatter("%(asctime)s [%(levelname)s] %(module)s:%(lineno)d %(message)s")
)

logger.addHandler(file_handler)
logger.addHandler(console_handler)
return logger

when 参数选型

值 含义 适用场景
"midnight" 每天午夜 最常用,量化脚本每日跑批
"h" 每小时 高频采集(分钟 K 线),日志量大
"D" 每天但不限午夜 灵活,但不如 midnight 精确
"S" 每秒 仅用于测试

轮转后的文件长什么样

logs/
├── quant.log ← 当天日志
├── quant.log.2026-08-20
├── quant.log.2026-08-19
├── quant.log.2026-08-18
├── quant.log.2026-08-17
├── quant.log.2026-08-16
├── quant.log.2026-08-15
└── quant.log.2026-08-14 ← 第 7 天后自动删除

backupCount=7 意味着最多保留 7 个旧文件 + 1 个当前文件。超出后最老的自动删除,磁盘不会爆。


三、JSON 结构化日志:让日志从"可读"变"可搜"

默认 Formatter 的问题

默认文本日志长这样:

2026-08-21 10:09:32,471 [INFO] main:139 开始拉取股票日K线
2026-08-21 10:09:37,072 [INFO] main:149 拉取完成: 600519 获得6061条K线

人能读,但机器不能高效搜索。想按股票代码过滤?你得写正则从文本里抠。想统计今天有多少次超时?你得全文 grep 一遍。

JSON Formatter 解决方案

自定义一个 Formatter,把每条日志序列化为一行 JSON:

import json
import logging
import datetime

class JsonFormatter(logging.Formatter):
"""把 LogRecord 序列化为一行 JSON。"""

def format(self, record: logging.LogRecord) -> str:
log_entry = {
"ts": datetime.datetime.fromtimestamp(record.created).strftime(
"%Y-%m-%d %H:%M:%S.%f"
)[:-3],
"level": record.levelname,
"module": record.module,
"line": record.lineno,
"msg": record.getMessage(),
}
# 自定义 extra 字段
for key in ("stock_code", "api_name", "duration_ms",
"record_count", "error_type"):
val = getattr(record, key, None)
if val is not None:
log_entry[key] = val
if record.exc_info:
log_entry["exc"] = self.formatException(record.exc_info)
return json.dumps(log_entry, ensure_ascii=False)

给 file_handler 挂上这个 Formatter:

file_handler.setFormatter(JsonFormatter())

现在每条日志长这样(一行一条 JSON):

{"ts": "2026-08-21 10:09:32.471", "level": "INFO", "module": "main", "line": 139, "msg": "开始拉取股票日K线", "api_name": "stock_history", "record_count": 3}
{"ts": "2026-08-21 10:09:37.072", "level": "INFO", "module": "main", "line": 149, "msg": "拉取完成: 600519 获得6061条K线", "stock_code": "600519", "api_name": "stock_history", "duration_ms": 2330.5, "record_count": 6061}

关键优势:

  • stock_code、duration_ms、error_type 等业务字段直接是 JSON key,不需要正则解析
  • jq '.select(.level=="ERROR")' 一行命令过滤所有错误
  • 日志聚合系统(ELK / Loki)可直接索引

四、extra 字段:给日志打业务标签

logging 的 extra 参数让你在记录日志时附加任意字段:

logger.info(
f"拉取完成: {code} 获得{rows}条K线",
extra={
"stock_code": code,
"api_name": "stock_history",
"duration_ms": duration,
"record_count": rows,
},
)

Formatter 里通过 getattr(record, key, None) 取出这些字段,写进 JSON。如果该条日志没有这个 extra,就跳过——不报错。

注意:extra 的 key 不能与 LogRecord 内置属性冲突(如 name、level、msg、module 等),否则报 KeyError。


五、结构化日志搜索:秒级定位

有了 JSON 日志,搜索变得简单:

import json

def search_json_logs(log_file, level=None, module=None, keyword=None):
"""搜索 JSON 结构化日志文件。"""
results = []
with open(log_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if level and entry.get("level") != level:
continue
if module and entry.get("module") != module:
continue
if keyword and keyword not in json.dumps(entry, ensure_ascii=False):
continue
results.append(entry)
return results

实战搜索演示

搜索所有 ERROR

errors = search_json_logs("logs/quant.log", level="ERROR")
# → 命中 4 条,秒级返回

搜索特定股票的全部日志

stock_logs = search_json_logs("logs/quant.log", keyword="600519")
# → 命中 4 条:2 条 INFO(拉取成功)+ 2 条 ERROR(网络超时)

搜索特定 API 的调用记录

api_logs = search_json_logs("logs/quant.log", keyword="stock_history")
# → 命中 14 条

对比文本日志:同样搜 600519,你得 grep "600519" app.log 然后人眼过滤——JSON 结构化让你能直接 entry["stock_code"] == "600519" 精确匹配。


六、真实 API 调用 + 结构化日志全链路

下面是把上面所有部件串起来的完整示例。用 mairui SDK 拉取三只股票的日 K 线,全程结构化日志:

import time
import logging

(前面定义的 setup_logger / JsonFormatter 已就绪)

def demo_api_with_logging(logger):
from mairui import Client

演示证书,请换成你自己的正式证书

client = Client("LICENCE-66D8-9F96-0C7F0FBCD073")

codes = ["000001", "600519", "000002"]
logger.info("开始拉取股票日K线",
extra={"api_name": "stock_history", "record_count": len(codes)})

total_records = 0
for code in codes:
t0 = time.time()
try:
df = client.stock_history(code=code, period="d", dividend="n")
duration = round((time.time() - t0) * 1000, 1)
rows = len(df) if df is not None else 0
total_records += rows
logger.info(
f"拉取完成: {code} 获得{rows}条K线",
extra={
"stock_code": code,
"api_name": "stock_history",
"duration_ms": duration,
"record_count": rows,
},
)
except Exception as e:
duration = round((time.time() - t0) * 1000, 1)
logger.error(
f"拉取失败: {code} {type(e).name}: {e}",
extra={
"stock_code": code,
"api_name": "stock_history",
"duration_ms": duration,
"error_type": type(e).name,
},
exc_info=True,
)

logger.info(f"批次完成,共获取{total_records}条K线",
extra={"api_name": "stock_history", "record_count": total_records})

实测输出

[INFO] 开始拉取股票日K线 api_name=stock_history record_count=3
[INFO] 拉取完成: 000001 获得8696条K线 stock_code=000001 duration_ms=1743.2
[INFO] 拉取完成: 600519 获得6061条K线 stock_code=600519 duration_ms=248.1
[INFO] 拉取完成: 000002 获得8696条K线 stock_code=000002 duration_ms=264.0
[INFO] 批次完成,共获取23453条K线 record_count=23453

日志文件检查:

JSON 有效行: 18/18
JSON 格式率: 100.0%


七、Linux 生产环境:logrotate 补充

如果你的脚本跑在 Linux 上,除了 Python 内部的 TimedRotatingFileHandler,还可以用系统级 logrotate 做第二道保险:

/etc/logrotate.d/quant

/home/quant/logs/*.log {
daily
rotate 7
compress
delaycompress
missingok
notifempty
copytruncate
}

选项 作用
daily 每天轮转
rotate 7 保留 7 份
compress 旧日志 gzip 压缩
delaycompress 延迟一轮再压缩(避免写一半时压缩)
copytruncate 复制后清空原文件(不停进程)

两者不冲突:TimedRotatingFileHandler 在 Python 层面轮转,logrotate 在系统层面兜底。但如果同时用,建议关掉一个——通常 Python 层面的轮转就够了。


常见坑

坏 好习惯
用 print() 代替 logger print 没有级别、没有轮转、不能过滤;脚本超过 50 行就该用 logging
Logger 重复添加 Handler 每次调用 setup_logger 先 logger.handlers.clear()
extra key 与内置属性冲突 避开 name/level/msg/module/lineno 等
JSON 中文被转义 \uXXXX json.dumps(..., ensure_ascii=False)
轮转文件太多占满磁盘 backupCount 设合理值(7~30 天够用)
只写文件不写控制台 开发期间同时挂 StreamHandler,部署后可关掉


小结

  • 轮转:TimedRotatingFileHandler(when="midnight", backupCount=7) 一行解决日志膨胀
  • 结构化:自定义 JSON Formatter,每条日志一行 JSON,字段可搜索
  • extra:给日志打业务标签(stock_code/api_name/duration_ms),排错时精确过滤
  • 搜索:json.loads() 逐行解析 + 字段过滤,比 grep 文本高效
  • 生产:TimedRotatingFileHandler + logrotate 双保险

下篇预告:日志管好了,脚本也稳定了,但只有你自己能看到结果——#10 Streamlit 看板,把脚本变成团队 5 分钟能看懂的产品。


代码与文档

本文配套代码已开源在 GitHub:https://github.com/MaiRuiApi

安装 SDK:

pip install mairui

证书获取:访问麦蕊官网申请免费版或付费版 API 证书。


免责声明:本文仅供技术学习交流,不构成任何投资建议。股市有风险,入市需谨慎。

posted @ 2026-09-19 09:38  量化分析码农  阅读(7)  评论(0)    收藏  举报