【Python量化系统工程化 #09】日志越写越大出错就大海捞针?日志轮转+结构化日志让排错秒级定位
系列定位:让"写一个能稳定跑半年的量化脚本"这件事,从散落的经验贴变成可复制的流水线。
痛点:脚本稳定跑了,但日志成了新的噩梦
上一篇我们用 systemd 给脚本加了守护,现在它能 7×24 不停跑。但新问题来了:
- 日志文件越来越大:跑了一个月,app.log 从几 KB 长到几百 MB,打开就卡死
- 出错时大海捞针:三天前某只股票拉取超时,你只知道"数据断了",但在几十万行文本日志里翻不到是哪只、哪一刻、什么原因
- 日志不可搜索:print() 和默认 Formatter 输出的是人类可读文本,机器不可读——想按股票代码过滤?想按错误类型统计?做不到
本质问题:日志是排错的生命线,但你用了错误的方式记它。
本文你将得到什么
- Python logging 模块的核心三件套:Logger / Handler / Formatter
- 日志轮转:TimedRotatingFileHandler 按天切分,自动保留 N 天
- JSON 结构化日志:每条日志一行 JSON,字段可搜索、可统计、可告警
- 结构化日志搜索:按级别 / 模块 / 关键词秒级过滤
- 真实 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 证书。
免责声明:本文仅供技术学习交流,不构成任何投资建议。股市有风险,入市需谨慎。

浙公网安备 33010602011771号