多 worker 场景下,Python 日志按天落盘为什么会“串日期”

在一个多 worker 的 Python 服务里,日志按天落盘本来是件很普通的事,但真正跑进 Docker 和多进程环境之后,事情往往没有想象中那么简单。我们这次遇到的,就是一个非常典型、也非常容易被忽视的问题:日志文件日期彻底混乱了。

最离谱的时候,目录里会看到一个名字像这样的文件:

app.log.2026-04-18

但打开之后,里面却混进了 2026-04-23 的日志。

这种现象第一眼看上去很玄学,实际上原因并不玄学。核心就是一句话:

多 worker 进程同时写同一个轮转日志文件,天然就容易出事。

这篇文章把这个问题的成因、误区,以及我们当前先落地的临时方案整理一下。也提前说明:这不是最终版日志架构,只是先把现在线上最痛的跨天串文件问题止住。

背景

项目运行在 Docker 里,服务启动方式是:

uvicorn src.main:app --host 0.0.0.0 --port 8000 --workers 4

也就是说:

  • 一个容器
  • 4 个 Uvicorn worker 进程
  • 所有 worker 都会初始化日志

原来的日志实现使用的是 Python 标准库的:

TimedRotatingFileHandler

配置思路也很常见:

  • 当前写入 logs/app.log
  • 到午夜时轮转
  • 旧文件加日期后缀

如果是单进程,这套做法很多时候能跑。

但一旦切到多 worker,这种方案就开始埋雷了。

我们观察到的异常现象

表面症状主要有两个:

  1. 旧日期文件里混入新日期内容
  2. 同一时间段出现多次重复初始化日志

第二个现象非常重要。日志里会连续看到类似内容重复多次:

  • Initializing application...
  • TaskManager initialized...
  • Application initialized successfully

这其实是在提醒我们:

  • 不是一个进程在初始化
  • 而是 4 个 worker 都各自做了一次初始化

换句话说,日志系统不是“全局唯一实例”,而是每个 worker 进程里各有一套。

真正的问题不是“时区错了”,而是“多进程写同一文件”

最开始大家也会很自然地想到时区问题。

毕竟 Docker 容器默认往往是 UTC,如果按 midnight 轮转:

  • 你以为是北京时间午夜切割
  • 实际上可能是 UTC 午夜切割

这个问题确实存在,但它只会导致:

  • 切割时间不符合本地预期
  • 文件日期和人的直觉不一致

不会导致一个日期文件里混进另一天的日志。

出现“2026-04-18 文件里混入 2026-04-23 内容”这种情况,根因基本只能往下面这个方向找:

多个进程同时操作同一个日志文件及其轮转过程。

TimedRotatingFileHandler 在多 worker 下为什么会出问题

核心原因很简单:

  • 每个 worker 都持有自己的 TimedRotatingFileHandler
  • 每个 handler 都认为自己在独立管理 logs/app.log
  • 到了应该切割的时候,多个进程可能几乎同时判断“该轮转了”
  • 它们会分别去 rename、关闭、重开同一个文件

于是就会出现各种竞争:

  • 某个进程已经把文件切走了
  • 另一个进程还握着旧文件句柄继续写
  • 第三个进程又重新创建了新的 app.log
  • 最终目录里的文件名和内容就开始错位

这不是某一行代码写错的问题,而是这种方案在多进程共享文件场景下,本来就不稳。

我们最后定下来的约束

这次处理日志问题时,有一个前提条件是明确的:

多 worker 不动。

也就是说:

  • 不能为了日志简单,直接把 workers=4 改成 1
  • 服务并发模型要保留
  • 但日志还得继续落盘

在这个前提下,就必须接受一个现实:

不要再让多个 worker 共用一个日志文件。

当前先落地的临时方案

我们这次先采用的是一个足够简单、也足够稳的方案:

每个 worker 按“日期 + 进程号”写自己的日志文件。

文件名长这样:

app-2026-04-23.101.log
app-2026-04-23.102.log
app-2026-04-23.103.log
app-2026-04-23.104.log

这样一来:

  • worker 101 只写自己的文件
  • worker 102 只写自己的文件
  • 不再共享文件句柄
  • 不再共享轮转动作

这个方案最大的优点是朴素:

不优雅,但稳定。

而我们这次的目标也很明确,不是马上把日志体系一步做到最好,而是先把“跨天串文件”这个问题彻底结束掉。

所以这里要明确一句:

当前方案是临时工程解,不是我们心里最理想的长期日志方案。

新方案怎么工作

新的日志逻辑不再依赖 TimedRotatingFileHandler,而是改成:

  1. 当前进程启动时拿到自己的 PID
  2. 每次写日志前,按指定时区计算当天日期
  3. 把日志写入 app-YYYY-MM-DD.<pid>.log
  4. 如果日期变化,就切换到新的文件名
  5. 清理超过保留天数的旧日志

配套上,我们还显式给容器配置了时区,例如:

TZ=Asia/Shanghai
LOG_TIMEZONE=Asia/Shanghai

这样做之后,至少有两件事变得确定了:

  1. 文件日期按本地时区计算
  2. 每个进程只碰自己的文件

也就是说,之前那个最烦人的问题:

日期文件名和内容日期错位

在这个方案下就基本被消掉了。

这个方案解决了什么

它解决的是下面这些具体问题:

  1. 跨天串文件
    多个 worker 不再共享同一个轮转文件,旧文件里混入新日期内容的问题基本可以结束。

  2. 多进程 rename 竞争
    因为不再多人共同操作一个 app.log,也就没有一起 rename 同一个文件的问题。

  3. 日期语义混乱
    日志文件名按明确时区生成,不再默认跟着 UTC 午夜走。

这个方案没有解决什么

这也要诚实写清楚。

这个方案是我们当前阶段的临时但靠谱方案,它不是终极形态,也不是后续不会再动的最终结论。

它没有解决的点主要有:

  1. 同一天会有多个文件
    排查问题时,可能要同时 grep 几个日志文件。

  2. 不是中心化日志方案
    如果以后服务规模继续变大,最终可能还是会走:

  • stdout + Docker 日志驱动
  • ELK / Loki / Datadog
  • 专门的日志采集与聚合
  1. 旧历史日志不会自动变整齐
    新方案生效后,新的日志会按新规则写,但老的 app.log* 历史文件依旧会留在目录里。

为什么我们接受这个方案

因为在当前约束下,它是性价比最高的选择。

我们有三个现实要求:

  1. 多 worker 不动
  2. 日志必须继续落盘
  3. 尽快结束跨天混乱问题

在这个约束组合下,最实际的办法就是:

不要追求一个共享单文件的优雅结构,而是先把进程间文件竞争拆开。

这其实是很多工程问题里都适用的思路:

  • 如果共享资源容易打架
  • 那就优先拆分资源边界

这里的共享资源,就是日志文件本身。

后续更优方案还没有搬上来

这部分也要说清楚,避免读完之后误以为“日志问题已经彻底一次性收工”。

并没有。

我们现在只是先把最危险的共享文件竞争拆掉了,但更完整、更长期的日志方案还没有正式搬上来。

后续如果继续演进,可能会往下面几个方向走:

  1. stdout 为主,平台统一采集
    这是容器场景最自然的方案。

  2. 专门的日志写入进程/队列
    多 worker 不直接落文件,而是统一汇聚后写盘。

  3. 集中式日志系统
    把查询、过滤、检索都交给专门的日志平台。

但这些都比当前需求更重,也意味着更多改造成本,所以这次没有一并推进。

现阶段,我们优先要的是:

日志别再跨天乱套。

至于统一采集、集中检索、单视图聚合这些更“舒服”的能力,后面再逐步补。

最终结论

这次日志问题的核心教训非常清楚:

  • TimedRotatingFileHandler 在单进程下常见,在多 worker 下风险很大
  • 时区问题只是表层因素
  • 真正致命的是多进程共同写同一个轮转文件

我们当前处理方式是:

  • 保留多 worker
  • 保留日志落盘
  • 改成每个 worker 每天单独文件
  • 文件名带日期和 PID
  • 明确使用本地时区生成日期

这不是最终形态,但它足够直接,也足够有效。

更好的后续方案我们已经想到了,只是这次还没有搬上来。

对于当前这个项目来说,这个方案已经能满足最重要的目标:

结束日志跨天串文件问题。

Read more

三台机器部署 ClickHouse 高可用集群实战记录

本文是一份可发布版部署记录。真实 IP、域名、账号、密码、下载链接、业务目录名、机器唯一标识等敏感信息已经替换为占位符。命令中的 <...> 需要按自己的环境替换。 目标与拓扑 这次目标是用三台数据节点部署一套 ClickHouse 高可用集群,拓扑采用: 1 shard x 3 replicas 含义是:集群只有一个逻辑分片,三台机器都保存同一份数据的完整副本。任意一台数据节点宕机时,只要 ClickHouse Keeper 仍然有多数派,剩余节点仍可继续提供读写服务。 规划节点如下: 主机名示例地址角色ch-01<ch-01-ip>ClickHouse Server + ClickHouse Keeperch-02<ch-02-ip>ClickHouse Server + ClickHouse Keeperch-03<ch-03-ip&

By ladydd

折腾记(二):接入火山引擎实时语音 API,家庭语音助手体验直接拉满

接上篇 上一篇用全开源组件(Whisper + Hermes + Edge-TTS)搭了个语音助手,能跑,但体验就是"能用"二字: * 中文识别只有 70 分,方言基本歇菜 * 英文唤醒词"Alexa"喊着别扭 * 说完到回复要等 4-8 秒 * 它说话的时候你插不了嘴 这些问题靠堆开源组件很难根治。于是我去试了火山引擎(字节跳动)的语音服务,结果直接换了条路。 这篇分两段:先讲怎么用火山引擎的 ASR/TTS 替换掉开源组件(小改),再讲怎么上端到端实时语音模型(大改)。 第一段:先把 ASR 和 TTS 换成火山引擎 为什么换 我用豆包输入法的时候发现它语音识别准得离谱。一查,豆包用的就是字节自家的火山引擎 Seed-ASR。开通后有免费额度(

By ladydd

折腾记(一):用全开源组件给家里搭一个语音助手,对接自己的 Hermes Agent

起因 事情是从一块 ESP32-S3 开发板开始的。 我手上有一块 Seeed Studio XIAO ESP32-S3 Sense,带摄像头和麦克风。最初的想法很美好:用这块板子做一个无线语音终端,对着它说话,连到我服务器上跑的 Hermes Agent(一个自托管的 AI agent),让它回答我。 但折腾到一半我突然意识到一件事:我的麦克风、音响、服务器全在家里,为什么要绕一圈用 ESP32?直接把麦克风和音响插到服务器上不就行了? ESP32 那条路(做无线拾音终端)当然也有价值,但那是"为了学嵌入式而学",不是解决问题的最短路径。于是这个项目就从"嵌入式项目"变成了"在服务器上拼一个语音助手"。这篇就记录后者。 教训零:先想清楚你要解决的是什么问题。很多时候最优解比你最初设想的简单得多。 目标

By ladydd

Kiro 的三种代理设置方法:本地、服务端、Remote

作为kiro的骨灰级用户,这篇是我自己折腾 Kiro / Kiro Remote / Ubuntu Server 代理问题后的复盘。 核心不是“怎么配一个代理”,而是先判断:到底是谁在访问外网? 谁访问外网,代理就要配给谁。 0. 先说结论 Kiro 相关代理大概分三类: 场景真正访问外网的进程在哪里代理应该配在哪里本地 KiroWindows / Mac 本机本机 Clash / Proxifier / 系统代理服务端 Kiro / CLIUbuntu Server 上的 shell、CLI、node、kiro 进程Ubuntu 的环境变量,比如 HTTP_PROXY / HTTPS_PROXYKiro Remote远程 Ubuntu 上的 ~/.kiro-server 和 extensionHost远程 Ubuntu 的 Kiro Server

By ladydd
陕公网安备61011302002223号 | 陕ICP备2025083092号