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