JSONL(每行一个 JSON)是做日志的省事选择:追加写、流式读、不用把整个文件读进内存。但它有三个坑,平时不疼,出事时正好是你最需要日志的时候。
坑一:BOM 和半行,让"读日志"这件事先崩掉
用带 BOM 的 UTF-8 写文件,第一行会多出 \ufeff,json.loads 直接抛异常。Python 里读日志统一用 encoding="utf-8-sig",写出时确认用 utf-8。
更常见的是半行:进程被强杀时,最后一行往往只写了一半。如果读取代码写成"整体读入再逐行解析,出错就 return []",那么一次半行就能让你看到零条日志------排查时比没有日志更误导。正确做法是逐行解析,坏行计数并跳过:
python
ok = bad = 0
with open(path, encoding="utf-8-sig", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
bad += 1
continue
ok += 1
关键不是 try,而是把 bad 打出来。静默跳过坏行,等于把"日志被截断"这个事实藏起来。
坑二:多进程同时追加
在 Linux 上,小于 PIPE_BUF(通常 4096 字节)的单次 write 到以 O_APPEND 打开的文件是原子的。超过这个大小就可能交错,两行互相穿插,得到的 JSON 全部报废。Windows 对追加写没有同样的保证。
三种实用做法,按成本排序:单写者 (所有日志打到队列,由唯一进程落盘,最省心)、按进程分片 (app_{pid}.jsonl,读的时候合并,零锁)、文件锁 (不要自己发明,用 filelock 之类的库)。
坑三:轮转时按字节切
日志轮转最常见的写法是"文件超过 10MB 就切"。按字节切会把某一行劈成两半:前半留在旧文件末尾,后半落到新文件开头,两侧都变成坏行。
按行轮转即可:读时累计字节数,超过阈值就在行边界切换输出文件。这样每个文件都是完整的 JSONL。
一张检查清单
- 读:
utf-8-sig+ 逐行 try + 坏行计数上报 - 写:单写者,或按 pid 分片,不要把锁逻辑写进业务代码
- 转:按行轮转,不按字节
- 删:明确保留策略,别让日志把磁盘吃满
- 验:新加一条日志后,回头真的读一遍,确认能解析
最后一句经验:日志系统的正确性,从来不体现在写的时候,而体现在出故障那天你能不能把最后几行读出来。