上周三晚上大概十一点多我正准备睡觉突然手机收到了一条告警短信说我们线上的前端错误上报量突然暴增了十倍,而且错误类型显示的都是同一个错误"Maximum call stack size exceeded"我当时心里一紧知道出事了赶紧打开电脑登录错误追踪平台一看果然错误量还在疯狂上涨,而且影响的用户数也在快速增加。

我意识到这不是一个普通的线上bug而是我们自己的前端错误追踪SDK出了问题导致了雪崩效应,于是我开始了一整夜的排查和修复过程中间踩了不少坑也走了不少弯路终于在凌晨五点多找到了根本原因修复了问题今天想把这次经历记录下来分享给大家希望能帮大家避免类似的坑。

一、问题发现:错误上报量暴增十倍

先说说背景我们公司的前端项目用的是基于开源方案二次开发的错误追踪SDK自己搭的上报服务和展示平台已经稳定运行了一年多了平时错误上报量比较稳定每天大概几万条告警也比较少,所以那天晚上突然收到错误量暴增十倍的告警我第一反应就是出事了。

我打开错误追踪平台的仪表盘一看果然错误量的曲线在晚上十点半左右开始陡峭上升从平时的每分钟几十条一下子涨到了每分钟几千条,而且还在涨我点进去看错误详情发现90%以上的错误都是同一个类型"Maximum call stack size exceeded"也就是栈溢出错误,而且这些错误的堆栈信息都很像都指向我们SDK内部的几个函数调用循环这就很明显了不是业务代码的问题而是我们自己的错误追踪SDK出了bug导致了无限递归,或者循环调用栈溢出,然后这个错误又被SDK自己捕获上报形成了死循环越上报越错越错越上报错误量就雪崩了。

我当时第一反应是先止血不能让错误量继续涨了不然上报服务可能会被打挂,而且大量的错误上报也会影响用户页面的性能,于是我先做了紧急处理把SDK的上报开关临时关了停止错误上报先把血止住,然后再慢慢排查原因这是处理线上故障的基本原则先止损再排查,不然问题会越来越严重造成更大的影响。

止住血之后,错误量很快就降下来了上报服务的压力也缓解了我松了一口气,但是知道这只是临时方案必须找到根本原因修复bug不然下次还会出问题,而且总不能一直关着错误上报吧那样线上真出了业务bug我们就发现不了了,于是我开始了漫长的排查过程。

二、排查过程:一步步缩小范围

排查的第一步是看错误的堆栈信息看看到底是哪些函数在循环调用导致栈溢出我从错误追踪平台导出了几条典型的错误堆栈仔细看发现堆栈里反复出现的是这几个函数调用:

at formatError (sdk.js:123)
at stringify (sdk.js:89)
at formatError (sdk.js:125)
at stringify (sdk.js:89)
at formatError (sdk.js:125)
...

可以看到formatErrorstringify这两个函数在互相调用形成了无限递归导致栈溢出formatError是我们SDK里用来格式化错误信息的函数stringify是我们自己实现的一个JSON序列化函数(因为原生的JSON.stringify不能处理循环引用的对象会报错,所以我们自己实现了一个能处理循环引用的stringify函数)。

那问题就来了为什么这两个函数会互相调用形成无限递归呢我去看我们SDK的代码formatError函数的逻辑大概是这样的:

function formatError(err) {
    return {
        message: err.message,
        name: err.name,
        stack: err.stack,
        // 把错误对象的其他属性也序列化进去
        extra: stringify(err)
    };
}

stringify函数的逻辑大概是这样的(简化版):

function stringify(obj) {
    // ... 处理循环引用的逻辑 ...
    try {
        return JSON.stringify(obj, replacer);
    } catch (e) {
        // 如果序列化失败递归调用formatError格式化错误信息
        return formatError(e);
    }
}

看到这里问题就很清楚了formatError调用stringify来序列化错误对象而stringify如果序列化失败(比如遇到了某种特殊的对象JSON.stringify处理不了我们的replacer也没处理好)就会catch到错误,然后调用formatError来格式化这个错误而formatError又会调用stringify来序列化这个新的错误对象,如果这个新的错误对象序列化也失败就又会调用formatError这样就形成了无限递归formatErrorstringifyformatErrorstringify...无限循环直到栈溢出报"Maximum call stack size exceeded"错误。

那为什么之前,一年多都没问题突然这天晚上就出问题了呢这说明不是所有错误对象序列化都会失败而是某种特定的错误对象,或者特定的场景下序列化会失败触发这个无限递归那到底是什么错误对象序列化会失败呢我需要找到触发这个问题的那个"元凶"错误对象。

于是我开始第二步排查找触发问题的原始错误是什么,因为无限递归的错误堆栈里都是formatErrorstringify看不到最开始的那个原始错误是什么我需要从错误数据里找到最开始触发这个问题的那条错误看看它的错误对象是什么有什么特殊之处。

我去上报服务的日志里找晚上十点半左右最开始出现"Maximum call stack size exceeded"错误的那几条记录看看它们的原始错误信息是什么找了半天终于找到了最开始的几条错误发现它们的原始错误都是同一个类型——DOMException而且具体是,SecurityError错误信息是"Blocked a frame with origin from accessing a cross-origin frame"也就是跨域iframe访问被浏览器安全策略阻止的错误。

哦原来如此是跨域iframe的SecurityError触发了这个问题那为什么DOMException对象序列化会失败呢我去研究了一下DOMException对象的结构发现DOMException对象有一些特殊的属性,比如codenamemessage等这些属性本身没问题,但是DOMException对象的原型链上有一些特殊的属性,或者说它是一个宿主对象(Host Object)不是普通的JavaScript对象JSON.stringify在处理某些宿主对象的时候,可能会有问题特别是当对象有getter属性,或者不可枚举属性的时候,我们的stringify函数的replacer可能没处理好这种情况导致JSON.stringify抛出错误,然后就触发了后面的无限递归。

为了验证这个猜想我在本地写了一段测试代码模拟跨域iframe的SecurityError然后用我们的stringify函数去序列化它看看会不会触发无限递归结果一测果然复现了问题stringify序列化DOMException对象的时候JSON.stringify抛出了错误,然后catch到错误调用formatErrorformatError又调用stringify序列化这个新的错误对象而这个新的错误对象也是DOMException(或者包含DOMException的引用)序列化又失败又调用formatError无限递归栈溢出完美复现了线上的问题。

那为什么之前,一年多都没遇到这个问题呢我想了一下应该是,因为之前,我们的业务代码里很少有跨域iframe访问的场景,所以很少产生DOMException类型的错误自然就触发不了这个bug而那天我们运营上了一个新的活动页面里面嵌入了一个第三方的跨域iframe而且业务代码里有尝试访问这个跨域iframe的内容的逻辑(虽然加了try-catch但是错误还是被我们的全局错误监听捕获到了)所以就产生了大量的DOMException错误触发了SDK的这个隐藏bug导致了雪崩效应这就是为什么之前,没问题这天突然出问题的原因。

找到根本原因之后,我松了一口气,但是此时已经是凌晨三点多了中间走了不少弯路,比如一开始我以为是上报服务的问题查了半天服务端的日志和代码没发现问题后来又以为是业务代码的问题查了半天最近上线的业务代码也没发现问题最后才想到可能是SDK自己的问题去看SDK的代码和错误堆栈才找到原因这一折腾就是几个小时,所以排查问题的时候,思路一定要清晰先看错误堆栈和错误类型从最直接的线索入手不要瞎猜瞎查,不然很容易走弯路浪费时间。

三、修复方案:从根本上解决问题

找到根本原因之后,就开始想修复方案这个问题的根本原因是formatErrorstringify之间,可能形成无限递归,而且stringify函数在处理某些特殊对象(比如DOMException宿主对象)的时候,会序列化失败,所以修复方案也要从这几个方面入手从根本上解决问题而不是只做表面的修补。

我想了几个修复点综合起来彻底解决这个问题:

修复点1:打破formatError和stringify之间,的递归调用

这是最直接的修复stringify函数在catch到序列化错误的时候,不应该再调用formatError因为formatError又会调用stringify形成递归正确的做法是在stringify序列化失败的时候,直接返回一个简单的错误信息字符串,比如"[Serialization failed: " + e.message + "]"不要再调用任何可能触发序列化的函数这样就打破了递归调用不会再形成无限循环了。

修改后的stringify函数大概是这样:

function stringify(obj) {
    // ... 处理循环引用的逻辑 ...
    try {
        return JSON.stringify(obj, replacer);
    } catch (e) {
        // 序列化失败时直接返回简单的错误信息不要调用formatError避免递归
        return "[Serialization failed: " + (e && e.message ? e.message : "unknown error") + "]";
    }
}

这样,即使序列化失败也只是返回一个字符串不会再调用formatError自然就不会形成递归了这是最关键的修复点。

修复点2:增强stringify函数的健壮性处理特殊对象

除了打破递归还要增强stringify函数的健壮性让它能正确处理各种特殊对象(比如DOMException宿主对象循环引用对象等)不要轻易序列化失败具体来说,做了以下几个增强:

  • 在replacer函数里判断属性值的类型,如果是函数Symbolundefined等JSON.stringify处理不了的类型直接返回对应的字符串表示(比如"[Function]""[Symbol]"等)不要让JSON.stringify自己处理避免出问题。
  • 对于宿主对象(Host Object)比如DOMExceptionwindowdocument等不要尝试深度序列化它们直接返回它们的toString()结果,或者简单的类型标识(比如"[DOMException]")因为宿主对象的结构很复杂,而且可能有安全限制深度序列化很容易出问题也没必要我们只需要知道它是什么类型的对象就行不需要序列化它的所有属性。
  • 增加循环引用检测的健壮性用WeakSet或者数组记录已经序列化过的对象遇到循环引用的时候,直接返回"[Circular]"不要让JSON.stringify自己处理避免报错。
  • 增加try-catch的范围,不仅JSON.stringify调用包在try-catch里replacer函数里的操作也尽量包在try-catch里避免replacer里的错误没被捕获导致整个序列化失败甚至抛出未捕获的错误。

通过这些增强stringify函数的健壮性大大提升了能处理各种特殊对象而不容易序列化失败,即使失败了也会被catch住返回简单的错误信息不会再触发递归,或者其他问题。

修复点3:formatError函数也增加保护避免重复格式化

除了stringify函数formatError函数也要增加保护避免对同一个错误对象重复格式化,或者格式化已经格式化过的对象导致问题具体来说,做了以下几个保护:

  • formatError函数开头判断传入的对象是不是已经是格式化过的错误对象(比如有,我们自定义的formatted标记)如果是直接返回不要重复格式化。
  • 格式化完成后给返回的对象加一个formatted标记避免被重复格式化。
  • 限制格式化的深度不要无限深度地序列化对象属性超过一定深度就停止返回"[Object]"避免,因为对象嵌套过深导致序列化慢,或者栈溢出。
  • formatError函数里也加try-catch包裹整个格式化过程,如果格式化过程中出了任何错误直接返回一个最简单的错误对象(只有message和name)不要让错误抛出也不要再调用其他可能出问题的函数保证formatError函数本身永远不会抛出错误也不会形成递归。

通过这些保护formatError函数也变得非常健壮不会再出问题也不会和,stringify形成递归了。

修复点4:增加SDK的自我保护机制避免雪崩效应

最后还要增加SDK的自我保护机制避免,因为SDK自己的bug或者异常情况导致雪崩效应影响用户页面性能,或者打挂上报服务具体来说,做了以下几个保护:

  • 增加错误上报的频率限制(Rate Limiting)每个用户每分钟最多上报N条错误超过的就丢弃,或者合并上报避免,因为某个bug导致短时间内大量错误上报打挂服务,或者影响页面性能。
  • 增加相同错误的去重和合并机制同一个用户短时间内产生的相同错误(错误类型和堆栈一样)只上报一次,或者合并成一条上报带上发生次数避免重复上报大量相同的错误。
  • 增加SDK自身错误的隔离机制SDK内部的任何函数调用都包在try-catch里SDK自己的错误不会抛出到业务代码也不会影响页面正常运行,即使SDK自己出了bug也最多是错误上报不正常不会影响业务功能和页面性能。
  • 增加熔断机制当错误上报量短时间内超过阈值,或者上报服务返回错误的比例超过阈值的时候,自动降低采样率,或者暂时停止上报等恢复正常后再逐渐恢复上报避免雪崩效应越来越严重。

通过这些自我保护机制,即使SDK以后再出什么未知的bug或者遇到什么异常情况也不会导致雪崩效应不会影响用户页面性能也不会打挂上报服务最多是部分错误上报不正常影响范围可控这对一个基础SDK来说非常重要,因为SDK是跑在用户页面里的一旦出问题影响面很大,所以一定要有完善的自我保护机制保证SDK的稳定性和可靠性。

四、验证和上线:确保修复有效

修复方案确定之后,我先在本地做了充分的测试验证修复是否有效首先,用之前,复现问题的测试代码再测一遍看看还会不会触发无限递归和栈溢出结果测了很多次都不会了stringify序列化DOMException对象的时候,会正确返回"[DOMException]"或者简单的错误信息不会再抛出错误也不会调用formatError自然就不会形成递归了问题解决。

然后我又做了各种边界情况的测试,比如序列化循环引用对象序列化包含函数Symbolundefined的对象序列化windowdocument等宿主对象序列化非常深嵌套的对象等等各种特殊情况都测了一遍确保stringifyformatError函数在各种情况下都能正常工作不会抛出错误也不会形成递归测试结果都很好没有再出问题。

接着我又测了SDK的自我保护机制,比如模拟短时间内大量错误看看频率限制和去重合并机制是否生效模拟上报服务返回错误看看熔断机制是否生效等等测试结果也都符合预期自我保护机制都能正常工作能有效避免雪崩效应。

本地测试通过之后,我先把修复后的SDK发布到测试环境在测试环境跑了一天观察有没有问题,同时让测试同学帮忙测了各种场景包括之前,触发问题的跨域iframe场景都测了一遍没有发现问题SDK运行稳定错误上报正常也没有再出现栈溢出的错误。

测试环境验证没问题之后,我才把修复后的SDK发布到线上发布的时候,采用了灰度发布的方式先给小部分用户用观察一段时间没问题再逐步扩大范围最后全量发布发布后我又观察了两天错误上报量稳定没有再出现暴增的情况也没有再出现"Maximum call stack size exceeded"的错误SDK运行稳定问题彻底解决了。

此时已经是问题发生后的第三天了从发现问题到排查原因到修复验证到上线整个过程,虽然辛苦(特别是排查的那一夜基本没睡)但是最终解决了问题也积累了宝贵的经验还是很值得的。

五、复盘和总结:经验和教训

问题解决之后,我做了一次完整的复盘总结了这次故障的经验和教训也整理了一些改进措施避免以后再出类似的问题这里也分享给大家希望能帮大家避免类似的坑。

经验教训1:基础SDK一定要有完善的自我保护机制

这次故障最大的教训就是基础SDK一定要有完善的自我保护机制,因为SDK是跑在用户页面里的一旦出问题影响面很大可能会影响所有接入了SDK的页面和用户,所以SDK的稳定性和可靠性是第一位的一定要有完善的自我保护机制,比如错误隔离(SDK自己的错误不影响业务)频率限制(避免大量上报打挂服务)去重合并(避免重复上报)熔断机制(异常情况下自动降级)等等这些机制平时可能看不出来作用,但是一旦遇到异常情况就能发挥巨大的作用避免小问题变成大故障这次,如果我们的SDK之前,就有频率限制和熔断机制,即使出了无限递归的bug也不会导致错误量暴增十倍打挂服务影响会小很多,所以基础SDK的自我保护机制一定要提前做好不要等出了问题才想起来补。

经验教训2:错误处理函数本身一定要健壮不能再抛出错误

另一个重要的教训是错误处理函数(比如formatErrorstringify等用来处理错误信息的函数)本身一定要非常健壮不能再抛出错误也不能形成递归,因为这些函数是用来处理错误的,如果它们自己又抛出错误,或者出问题就会导致错误处理失败甚至形成死循环让问题更严重,所以写错误处理函数的时候,一定要格外小心做好各种边界情况的处理加好try-catch避免函数自己抛出错误也要避免函数之间,形成递归调用这次的问题就是,因为stringify函数在catch到错误的时候,调用了formatErrorformatError又调用stringify形成了递归才导致了无限循环和栈溢出,如果当时写代码的时候,能注意到这一点stringify序列化失败的时候,直接返回简单字符串不要调用formatError就不会出这个问题了,所以写错误处理相关的代码一定要格外谨慎多想一步考虑各种异常情况。

经验教训3:要充分测试各种边界和异常情况

这次的bug其实是一个隐藏了一年多的bug之前,没暴露是,因为没遇到触发条件(跨域iframe的DOMException错误)这说明我们之前,的测试不够充分没有覆盖到各种边界和异常情况特别是各种特殊类型的错误对象的序列化测试没做够,所以才让这个bug隐藏了这么久直到遇到触发条件才爆发出来造成了线上故障,所以以后写SDK或者基础库的时候,一定要做充分的测试特别是边界情况和异常情况的测试要覆盖各种可能的输入和场景包括各种特殊类型的对象各种异常情况等等不要只测正常情况正常情况一般都不会出问题出问题的往往都是边界和异常情况,所以测试一定要充分覆盖各种情况才能保证SDK的稳定性和可靠性。

经验教训4:排查问题要思路清晰从最直接的线索入手

这次排查过程中我走了不少弯路一开始瞎猜是服务端的问题,或者业务代码的问题查了半天没结果浪费了很多时间后来才想到去看错误堆栈从最直接的线索入手很快就找到了原因这说明排查问题的时候,思路一定要清晰不要瞎猜瞎查要从最直接的线索入手,比如错误类型错误堆栈错误发生的时间点和什么操作相关等等先分析这些直接的线索缩小排查范围再针对性地去查代码和日志这样效率会高很多也不容易走弯路,另外排查问题的时候,也要有条理一步一步来先做什么再做什么心里要有数不要东一榔头西一棒子那样效率很低也容易遗漏关键线索。

经验教训5:处理线上故障要先止损再排查

这次处理故障的过程中我做的比较好的一点是先止损再排查发现错误量暴增之后,第一时间就把SDK的上报开关关了先把血止住避免问题继续扩大,然后再慢慢排查原因这是处理线上故障的基本原则一定要先止损再排查,不然在你排查的过程中问题可能会越来越严重造成更大的影响和损失止损的方式有很多,比如关开关回滚降级限流等等根据具体情况选择合适的止损方式先把影响控制住再慢慢排查和修复这样才是正确的处理线上故障的方式。

六、后续改进措施

除了总结经验教训我还整理了一些后续的改进措施避免以后再出类似的问题也提升SDK的整体质量和稳定性:

  1. 完善SDK的单元测试和集成测试:补充各种边界情况和异常情况的测试用例特别是各种特殊类型错误对象的序列化测试保证测试覆盖率达到90%以上每次修改代码都要跑全量测试通过才能上线。
  2. 建立SDK的灰度发布和监控机制:SDK发布上线必须经过灰度发布先小流量验证没问题再逐步扩大范围,同时建立SDK自身的监控指标(上报成功率错误率性能数据等)及时发现SDK的异常情况。
  3. 定期做SDK的故障演练和压力测试:定期模拟各种异常情况(大量错误上报服务不可用特殊错误对象等)做故障演练和,压力测试验证SDK的自我保护机制是否有效能不能在异常情况下稳定运行不出问题。
  4. 建立SDK的Code Review机制:SDK的代码修改必须经过至少一个人的Code Review才能合并和,发布特别是错误处理和自我保护相关的代码要重点Review避免引入新的bug。
  5. 整理SDK的开发规范和最佳实践:把这次的经验教训整理成SDK的开发规范和最佳实践,比如错误处理函数怎么写自我保护机制怎么加测试怎么写等等让团队所有人都遵守避免再犯类似的错误。

通过这些改进措施我们SDK的质量和稳定性会大大提升以后再出类似问题的概率会大大降低,即使出了问题也能快速发现和处理不会造成大的影响。

七、写在最后

以上就是这次线上前端错误追踪SDKbug的完整排查和修复经历从发现问题到排查原因到修复验证到上线以及复盘总结和改进整个过程,虽然辛苦(排查了一整夜基本没睡)但是收获很大,不仅解决了问题也积累了宝贵的故障排查和SDK开发的经验也让我们的SDK变得更健壮更稳定了。

做基础SDK或者底层工具的开发者应该都有类似的体会基础组件看起来简单,但是对稳定性和可靠性的要求非常高,因为它们跑在所有业务的底层一旦出问题影响面非常大,所以做基础组件一定要有敬畏心要格外谨慎做好各种边界情况的处理加好自我保护机制做充分的测试不能有丝毫马虎,不然一个小小的bug就可能造成很大的线上故障这次的经历就是一个很好的例子一个stringify函数里的递归调用问题隐藏了一年多遇到触发条件就造成了错误量暴增十倍的故障影响了很多用户,所以基础组件的开发一定要慎之又慎。

另外故障排查也是开发者必备的能力遇到线上故障不要慌要思路清晰先止损再排查从最直接的线索入手一步一步缩小范围找到根本原因,然后针对性地修复修复后要充分测试验证确保修复有效不会引入新的问题最后还要做复盘总结经验教训整理改进措施避免以后再出类似的问题这样每次故障都能变成一次成长的机会让自己和团队都变得更强。

最后用一句话结束这篇文章也是我这次经历最大的心得"基础组件无小事稳定性是生命线敬畏每一行代码做好每一个细节才能构建稳定可靠的系统故障不可怕可怕的是不总结不改进每次故障都是一次成长的机会让我们变得更强。"

愿大家都能写出稳定可靠的代码也能在遇到故障的时候,从容应对快速解决不断成长进步加油!