bigywater.tech
微服务 · 79

错误追踪:异常堆栈与采样

异常堆栈有多大、多贵?怎么给异常分组才能把同一个 bug 的上万个异常合成一个?同一个 bug 瞬间爆发时该全量捕获还是采样?线程池、裸线程、asyncio 里没被处理的异常会不会丢?这篇用 Python 的 traceback 逐项量出来。

62421 字节
500 层调用(交替递归)的异常堆栈文本大小;limit=-20 之后是 2665 字节
10000 vs 5
1 万个来自 5 个 bug 的异常,按原始消息分组与按 “类型 + 最内层 2 帧” 分组得到的组数
18.43 s vs 0.07 s
同一个 bug 爆发 2 万次时,每次完整捕获与限流加计数器耗费的 CPU 时间

堆栈的代价

实测 · 本机执行输出
  调用深度 | (a)自递归:堆栈文本字节数 | (a)format_exc 耗时(µs) | (b)ping/pong:堆栈文本字节数 | (b)format_exc 耗时(µs) | (b)limit=-20 的字节数 | (b)带异常链(from)的字节数
  -----+----------------+----------------------+----------------------+----------------------+-------------------+------------------
  4    | 864            | 218                  | 917                  | 210                  | 917               | 1253             
  20   | 866            | 643                  | 2901                 | 626                  | 2665              | 3237             
  100  | 866            | 2808                 | 12821                | 2732                 | 2665              | 13157            
  500  | 867            | 13629                | 62421                | 13257                | 2665              | 62757            
  交替调用的堆栈文本随深度线性变长,格式化的耗时也线性增长;自己调自己的递归在文本里被折叠成 “Previous line repeated N more times”(字节数几乎不变),但格式化的耗时仍然随深度增长;limit 能限制放进报告的帧数;异常链会把两层异常的堆栈都带上

堆栈文本随调用深度线性增长,格式化也随深度变慢;用 limit 限制帧数。

指纹分组

实测 · 本机执行输出
  指纹策略            | 得到的组数 | 混了不同 bug 的组数 | 每个 bug 被分成几个组                              
  ----------------+-------+--------------+--------------------------------------------
  (a)原始消息         | 10000 | 0            | B1→2053, B2→2006, B3→1980, B4→2019, B5→1942
  (b)只用异常类型       | 3     | 1            | B1→1, B2→1, B3→1, B4→1, B5→1               
  (c)规整后的消息       | 4     | 1            | B1→1, B2→1, B3→1, B4→1, B5→1               
  (d)类型 + 最内层 2 帧 | 5     | 0            | B1→1, B2→1, B3→1, B4→1, B5→1               
  (e)类型 + 全部帧的函数名 | 6     | 0            | B1→1, B2→2, B3→1, B4→1, B5→1               
  用原始消息分组,每个异常几乎都是一个独立的组(消息里带着变量);只用类型会把不同的 bug 混在一起(B2、B3、B5 都是 ValueError);规整消息之后 B2 和 B5(消息格式一样)被混在一起;按最内层几帧的函数名分组能正确区分 5 个 bug,并把 B2 的两条调用路径合并;用全部帧则把 B2 拆成两组

按消息分组会爆炸,只按类型会混;类型加最内层几帧的函数名最接近“一个 bug 一个组”。

爆发与采样

实测 · 本机执行输出
  处理方式                   | CPU 时间(s) | 完整捕获的次数 | 保留的字节数   | 实际发生的次数 | 能还原出的总次数
  -----------------------+-----------+---------+----------+---------+---------
  (基线)只抛出并捕获、不记录         | 0.05      | 0       | 0        | 20000   | -       
  (a)每次完整捕获              | 18.43     | 20000   | 19.88 MB | 20000   | 20000   
  (b)按指纹限流 + 计数器         | 0.07      | 10      | 0.01 MB  | 20000   | 20000   
  (c)前 5 次 + 每 100 次 1 次 | 0.24      | 205     | 0.20 MB  | 20000   | 20005   
  完整捕获的开销主要在格式化堆栈和序列化,同一个 bug 每次捕获的堆栈几乎一样,信息量很小;限流之后只保留少量样本,再用计数器记录总次数,开销接近基线,信息也没丢多少;只靠采样会让次数变成估计值

同一个 bug 的堆栈几乎一样,按指纹限流并用计数器记录总数,开销几乎等于基线。

漏掉的异常

实测 · 本机执行输出
  配置     | (a)线程池任务(Future 回调捕获到) | (b)裸线程(threading.excepthook 捕获到) | (c)asyncio 任务(异常处理器捕获到) | 被打印到 stderr 的堆栈/提示行数
  -------+------------------------+----------------------------------+-------------------------+---------------------
  没有任何钩子 | 0 / 100                | 0 / 100                          | 0 / 100                 | 300                 
  装了钩子   | 100 / 100              | 100 / 100                        | 100 / 100               | 0                   
  线程池里的任务异常存放在 Future 里,不取结果就悄悄丢了;裸线程和没 await 的 asyncio 任务的异常只会被默认处理打印到 stderr(没有集中的错误追踪就看不到);错误追踪要在这三个位置都装钩子
ERRORS {}

线程池、裸线程、没 await 的任务里的异常要靠钩子捕获,否则悄悄丢或只打到 stderr。

分步演示

错误追踪
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / Go / nginx / Envoy / etcd / Consul / Nacos / OpenTelemetry / Prometheus(按各篇注明用到哪些及版本);所有服务都是本机自己起的进程,端口 9150 以上,只监听 127.0.0.1,不连任何外部服务,不碰本机已有的实例;故障用 kill -9 进程、让连接断开或在代码里注入延迟与错误来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。Python 3.14 的 traceback 模块,纯进程内实验(没有用 Sentry 等产品);堆栈格式化的耗时是 200 次的中位数,爆发那节只跑了一次(CPU 时间用 process_time);分组实验的 5 个 bug 是我构造的,真实应用里的指纹更复杂;限流是按单一指纹的令牌桶,没有做多指纹、多进程汇总
    • 没有验证:Sentry / Rollbar 等产品的分组算法和采样配置;Java、Go 的异常堆栈(Go 的 error 默认没有堆栈);源码映射(source map)与符号化;捕获局部变量的开销和隐私问题;异常与 trace 的关联(见 trace 的篇目);面向用户的错误上报(浏览器、移动端)
    京ICP备2026067502号-1京公网安备11010502063487号