跳到正文

日志与可定位性设计:级别、刷盘与真实成本

环境:Windows 11 x64,GCC 13.1.0(MinGW-w64),gcc -std=c11 -O2 -Wall -Wextra,计时用 QueryPerformanceCounter(本机频率 10,000,000 Hz,分辨率 100ns)。字节数、行数、落盘量是确定性的、逐次可复现的;纳秒级耗时是 3–5 次重复的中位数(区间),同期机器上还有其他任务在跑,比值比绝对值可靠。日志目的地都是本机 NTFS 文件(进系统页缓存),未计磁盘实际落盘时间。

一、级别必须挡在格式化之前:0.2ns 与 181ns

同一份代码,5 万行,只改”在哪一层丢弃”:

写法ns/行 中位(区间)
级别判断在 snprintf 之前,整条跳过0.2(0.2–0.2,5 次一致)
snprintf 已经做完,级别判断只挡住输出186.3(171.7–242.0)
snprintf 做完、也没挡住,写进默认 4KB 缓冲769.3(648.8–2071.1)

171.7–242.0 这一档的实测输出是 fmt_bytes=4588890 file_bytes=0——一行没写出去,但 50,000 次格式化全做完了,合计 9.1ms 白烧(同一程序单独用运行期级别过滤再测一次是 180.5ns/行,10 万行规模,两者同一量级)。这就是”用 if (level) 包住输出、而不是包住整条日志语句”的典型写法。约 900 倍差距不需要讨论,只需要把宏写成:

#define LOG(lvl, ...) do { if ((lvl) <= BUILD_LEVEL) { snprintf __VA_ARGS__; sink(...); } } while (0)

BUILD_LEVEL 是编译期常量时更彻底:mode=10 的 0.2ns 就是循环本身的下限。级别系统的价值全在它站在 snprintf 的哪一边。

二、上下文行该放什么:取本地时间和取 UTC 差 19 倍

每行日志都要的时间戳/线程号,单次调用成本(每项 2–10 万次累计,共 6 轮):

APIns/次 中位(区间)备注
GetTickCount641.3(1.0–1.9)16ms 粒度,只够算”多久之前”
GetCurrentThreadId1.6(1.2–1.9)读 TEB,无系统调用,想加就加
GetSystemTimeAsFileTime2.4(1.7–2.5)100ns 精度的 UTC,首选
time(NULL)8.2(7.6–8.9)1 秒粒度
QueryPerformanceCounter12.8(12.1–14.3)单调,适合量耗时而不是打时间戳
GetSystemTimePreciseAsFileTime16.2(15.5–22.8)比 AsFileTime 贵近 7 倍,只在真要亚毫秒对齐时用
GetLocalTime45.2(42.7–47.7)填 SYSTEMTIME + 时区换算,最贵的一个

把 GetLocalTime 换成 GetSystemTimeAsFileTime,每行省约 43ns;一天 1 亿行 = 省 4.3 秒纯 CPU,而且时区换算本来就该在展示端做。“日志里放什么上下文”的成本排序:线程号近乎免费(1.6) < UTC(2.4) < 单调计数(12.8) < 本地时间(45.2)。

顺带:本机 QPC 频率 10 MHz ⇒ 计数器粒度 100ns,所以上面这些”单次 1–2ns”的数字只能靠几万次数累计摊出来,单次计时毫无意义。

三、结构化不是免费的,但也不贵

同一条记录(id/code/bytes/msg)五种布局,20 万次格式化 × 9 轮:

布局样例行字节/行(普通 msg / msg 含引号)ns/snprintf 中位(区间)
csv1234567890,5,1087,advance slot31 / 3478.4(75.6–110.2)
plainid=… code=5 bytes=1087 msg=advance slot49 / 52102.8(100.8–117.4)
kv(msg 外加引号)… msg="advance slot"51 / 54104.7(98.6–114.7)
json(手拼,不转义){"id":…,"msg":"advance slot"}61 / 64119.0(112.4–131.1)
json + 手工转义{"id":…,"msg":"he said \"hi\" ok"}61 / 66125.1(118.9–135.2)

三点要从表里读出来:

  1. JSON 比 csv 贵 97% 的体积(61 vs 31 字节)、只贵 52% 的时间(119.0 vs 78.4ns)。相对第五节 260–8,195ns/行的输出成本,格式化那 40.6ns 是零头。选布局的理由是可查询性和体积,不是 CPU。
  2. 手拼 JSON 会产出坏数据。msg 里带两个引号时,不转义的输出实测是:
{"id":1234567890,"code":5,"bytes":1087,"msg":"he said "hi" ok"}

这不是合法 JSON(引号提前闭合)。加了转义循环后是 66 字节且合法:"msg":"he said \"hi\" ok",代价 +5 字节、+6.1ns。结论:结构化日志必须走带转义的格式化函数,不能只写一个 printf 模板。

  1. 还有一条静默的坑:行缓冲按”典型行长”分配,长行被 snprintf 截掉尾部,而且返回值骗人。 同一份日志(每条本该 91.8 字节)把行缓冲从 128 依次改小,5 万条实测:
slot=128:应写 4,588,890  实际 4,588,890  截断行数 0      文件 4,588,890 字节 / 50,000 行
slot= 64:应写 4,588,890  返回值 4,588,890 截断行数 50,000 文件 3,150,000 字节 / 按 \n 只能切出 1 段
slot= 32:应写 4,588,890  返回值 4,588,890 截断行数 50,000 文件 1,550,000 字节 / 按 \n 只能切出 1 段

snprintf 返回的是”如果缓冲够大本该写多少”,所以截断时它照样报 91——看返回值判断截断是错的,必须 if (len >= slot) 报警。更糟的是截断点正好吃掉行尾的 \n:slot=64 时每行实写 63 字节,整个 3.15MB 文件按换行切只有 1 段,所有按行解析的工具当场失效。行长上限要作为日志模块的自检项(和第七节”入队数==落盘数”同一类断言)。

四、刷盘策略决定崩溃时你还剩几行

这是本节的核心实验,也是纯确定性的。写入 3,001 行(113,728 字节)后立刻制造访问违例,然后数文件里还剩多少:

策略落盘字节落盘行数丢失
默认 4KB 全缓冲,无 handler110,5922,91883 行 / 3,136 字节
同上,但 signal(SIGSEGV, h) 里 fflush113,8103,0030(多出的 2 行是 handler 自己写的)
setvbuf(_IONBF) 无缓冲113,7283,0010
setvbuf(_IOFBF, 1MB) 大缓冲00全部 3,001 行
每行 fflush113,7283,0010

默认 4KB 那一行的落盘量 110,592 = 27 × 4,096,1MB 那一行是 0 × 1MB——规律一句话讲完:

落盘字节 = ⌊已写入字节 ÷ 缓冲区大小⌋ × 缓冲区大小。

同一场景换成”硬终止”(OpenProcess(PROCESS_TERMINATE) + TerminateProcess,等价于任务管理器结束进程 / 被控制器杀掉),并且这次把 handler 装上:

策略(都是硬终止)落盘字节落盘行数(应为 3,001)
默认 4KB 缓冲,未装 handler110,5922,918
默认 4KB 缓冲,装了 SIGSEGV handler110,5922,918(一模一样,文件里也没有 handler 那行)
每行 fflush113,7283,001
对照:同一份程序改成访问违例(handler 能跑)113,7473,002

110,592 / 2,918 这一格连跑 3 次完全一致。

四条工程结论:

  1. handler 里的 fflush 只救”自杀”,不救”他杀”:TerminateProcess、断电、看门狗复位下它一行都不多留(上表第二行与第一行逐字节相同)。
  2. “崩溃时一行不丢”只有每行 fflush 这一条路,实测 8,195ns/行,是 1MB 缓冲的 31 倍(第五节)。高频路径上全量同步刷盘不成立。
  3. 可行的折中是**“小缓冲 + 关键锚点”:缓冲宁可用默认 4KB(至多丢 83 行)也别为了快开到 1MB(丢光全部),并在状态机跃迁、外部调用、释放资源之前**显式 fflush——锚点行一定在,丢的只是最后一次跃迁之后的行。
  4. 丢多少是可预测的(只取决于崩溃时刻缓冲里剩多少),所以”缓冲区多大 = 最多丢多少行”可以直接写进设计文档。

五、生产环境的日志成本:同一行落在不同 sink 上差 40 倍

同一份代码(snprintf 91.9 字节/行),5 万行,只换输出方式,交错跑 5 轮:

方式ns/行 中位(区间)说明
级别短路,不格式化0.2(0.2–0.2)第一节
snprintf 之后什么都不做181.2(171.7–242.0)格式化税
fwrite + 1MB 全缓冲260.1(245.2–311.0)每 ~11,400 行一次 WriteFile
fputs 到 stderr(重定向到文件)523.1(496.2–827.8)见下方字节注记
fwrite + 默认 4KB 缓冲769.3(648.8–2071.1)每 ~44 行一次 WriteFile
fwrite + _IOLBF 行缓冲1232.6(717.4–2801.4)比全缓冲贵 1.6 倍,且区间很大
每行一次 WriteFile6766.0(6334.0–9904.0)
fwrite + 每行 fflush8195.2(6510.5–15104.1)第四节里”崩溃不丢”的代价
_IONBF 无缓冲 fwrite8754.7(7461.0–13357.3)
  • 缓冲区大小本身就是性能参数:4KB → 1MB 是 769 → 260ns(3.0 倍),因为 WriteFile 次数从 1,121 次降到 5 次(4,588,890 ÷ 4,096 vs ÷ 1MB)。但它和第四节直接冲突:缓冲越大越快、崩得越干净率越低。这就是为什么”缓冲 1MB”必须配”独立崩溃恢复手段”,不能只靠 handler。
  • 为什么 _IONBF 和”每行 fflush”都比”每行 WriteFile”慢、而 stderr 反而快 3 倍——本机没有系统调用追踪工具(无 ETW/xperf/wtfdebug),没实测到 WriteFile 的真实次数,这条不解释,只记住排序。
  • stderr 那条的确定性收获:同一批 50,000 行,slog7.err 是 4,638,890 字节,而二进制模式文件是 4,588,890 字节——正好多 50,000 字节:文本模式把 \n 换成 \r\n,每行多 1 字节(+1.09%)。日志量按 TB 算的公司,这一条就够写进规范。

六、多线程:全局锁 + 共享 FILE 是 8.5 倍税

4 个线程各写 10 万行(共 40 万行),三种形态:

形态ns/行 中位(区间)相对
只格式化 + memcpy 到各自缓冲(无 IO 无锁下限)59.9(50.5–62.7)1.0×
每线程独立 FILE* + 256KB 缓冲,无锁95.6(94.6–103.1)1.6×
全局 CRITICAL_SECTION + 共享 FILE*(默认缓冲)811.6(514.8–1296.7)13.5×
全局锁 + 共享 FILE* + 每行 fflush13,623.4(11,756.2–15,087.5)227×

同一台机器同样的行,“共享一个 FILE 并加锁”比”每线程一个 FILE”贵 8.5 倍(811.6 / 95.6),而且区间宽 2.5 倍——锁的排队在他人手里,你自己的基准测不出来。每行 fflush 再叠 17 倍。多线程日志的正确默认形状是:每线程私有的缓冲(文件或内存环),由退出时或后台线程合并,而不是把 printf 包在一把全局锁里。

限流/采样用的计数器也一样:4 线程 × 200 万次 atomic_fetch_add:

布局ns/次(3 轮)计数结果
4 线程共享一个原子变量12.4 / 12.5 / 12.08,000,000(正确)
同一变量但 __attribute__((aligned(64)))12.3 / 11.5 / 11.98,000,000
每线程一个、各自填满缓存行2.2 / 2.1 / 2.08,000,000

贵的是共享不是未对齐:把共享变量对齐到 64 字节毫无改善(12.3 vs 12.4),改成每线程独占才降到 2.1ns(5.9 倍)。三种写法结果都对——原子性从来没坏,坏的是吞吐。

七、异步队列:省的是阻塞,不是吞吐

每线程一个 SPSC 环(160 字节槽、65,536 槽)+ 每线程一个搬运线程,4 生产者 × 20 万行 = 80 万行:

形态ns/行(3 轮)丢失产物
异步:搬运线程写共享 FILE*(1MB 缓冲)214.5 / 218.9 / 222.00ring_out.txt 44,800,000 字节
异步:搬运线程各写独立文件(1MB 缓冲)90.2 / 90.8 / 91.504 × 11,200,000 字节
同步:单线程直接 fwrite + 1MB 缓冲(对照)229.6 / 231.2 / 233.5—9,188,890 字节/10 万行

异步 + 每线程独立文件(90.8ns)和同步 + 每线程独立文件(95.6ns)基本打平——异步并没有白捡的收益,因为总工作量不变,只是从业务线程转移到了搬运线程。所以”要不要异步”的判据不是快慢,而是”业务线程绝不能在这里阻塞”:日志目标在网络、控制台、慢盘上时才必须异步;本机的实测就是”共享 FILE 的异步仍要 219ns,因为串行点还在”。

一条踩坑实录值得留:第一版把消费者的 atomic_store(&ring->tail, t + 1) 写成了 atomic_fetch_add(&ring->tail, t + 1),程序不崩、不报错、跑得很欢,产出文件是 4,956 行、行长 55–388 字节不等,而应有 800,000 行——无锁队列的错在日志系统里的表现是静默把日志搅乱并丢掉大半,不是崩溃。改对之后连续三轮 lost=0、行长恒定 56 字节(44,800,000 ÷ 800,000)。所以异步日志必须自带”入队计数 == 落盘计数”的自检,光看文件在不在长是没有用的。

八、体积账:压缩比采样更划算

实测字节(确定性):

长日志(91.9 字节/行)  100,000 行 = 9,188,890 字节
短日志(57.0 字节/行)  400,000 行 = 4,560,000 字节(每线程独立文件 100,000 行 = 5,700,000 字节)
1/1024 采样                100,000 行只落 9,003 字节(98 行)
zlib level 6 压缩后:
  9,188,890  → 485,983 字节(5.3%)
  5,700,000  → 279,374 字节(4.9%)
  113,728    →  10,773 字节(9.5%)
  • 采样把体积削到 1/1020,但每行成本只从 769ns 降到 212.7ns(3.6 倍),因为 snprintf 照做(fmt_bytes=9188890 与不采样时一模一样)。想靠采样省钱,判断必须在格式化之前——和第一节同一个道理。
  • 文本日志压缩后只剩 5%–9%(本机 zlib,吞吐 100–434 MB/s,区间受同期任务干扰,只当量级看)。也就是说:“少打日志”换来的磁盘,靠”落盘后压缩”几乎能全拿回来;真正省不回来的是格式化与 WriteFile 的 CPU 和”该有却没有的那一行”。所以压缩该默认开,级别该按锚点设计,而不是为了省磁盘把 DEBUG 行删掉。

九、小结

  • 级别要挡在格式化前面:0.2ns vs 181.2ns,900 倍;file_bytes=0 但 fmt_bytes=4588890 就是”日志已关、成本照付”的铁证。
  • 上下文字段的单价:线程号 1.6ns、UTC 2.4ns、QPC 12.8ns、GetLocalTime 45.2ns(19 倍差距);QPC 粒度 100ns,所以微基准必须累计。
  • 布局:csv 31 / plain 49 / json 61 / json+转义 66 字节每行,格式化 78–125ns。手拼 JSON 遇到消息里的引号会产出非法 JSON(实测样本),必须走转义;行缓冲小于行长时 snprintf 静默截断且返回值照样报”本该写的字节数”,slot=64 时 5 万行连 \n 都被吃掉,整个文件切不出第二段。
  • 崩溃可回溯的唯一硬规律:落盘字节 = ⌊已写 ÷ 缓冲大小⌋ × 缓冲大小。4KB 崩了剩 2,918/3,001 行,1MB 崩了剩 0 行;handler+fflush 只救自杀不救他杀(TerminateProcess 下同样剩 2,918 行);“一行不丢”的代价是每行 fflush(8,195ns,31 倍)。
  • 成本排序:短路 0.2 < 只格式化 181 < 1MB 缓冲 260 < 默认 4KB 769 < 每行 WriteFile 6,766 < 每行 fflush 8,195 < 无缓冲 8,754(ns/行);文本模式重定向每行多 1 字节(+1.09%)。
  • 并发:每线程私有缓冲 95.6ns vs 全局锁共享 FILE 811.6ns(8.5 倍);共享原子计数器 12.4ns vs 每线程独占缓存行 2.1ns(5.9 倍),对齐共享变量没用,独占才有用。
  • 异步:入队 + 搬运端到端 90.8ns/行,与同步打平;它的价值是”业务线程不阻塞”,代价是必须自带”入队数 == 落盘数”自检——本机实测一次 tail 协议写错的 bug 表现成”80 万行只剩 4,956 行、行长 55–388 不等”的静默损坏。
  • 体积:1/1024 采样只把每行成本降 3.6 倍(格式化没省);文本日志 zlib 后 4.9%–9.5%,压缩比删日志更划算。
  • 本机未实测:真实磁盘写入延迟(未用 FILE_FLAG_WRITE_THROUGH/FlushFileBuffers 分离测试)、WriteFile 系统调用次数(无 ETW/xperf)、网络 syslog 采集管道成本、perf/火焰图/valgrind/任何 sanitizer(MinGW GCC 13.1 三个 sanitizer 都链不上,见上一节原文)。