上个月我们的线上应用出了一次严重的故障,从发现到完全修复花了将近六个小时。整个过程惊心动魄,现在想起来还心有余悸。

这篇文章完整复盘一下这次故障,从发现到定位到修复,以及从中总结的经验教训。希望能给大家一些启发。

故障发生

那天是周三下午,我们刚发布了一个新版本,主要是把React从17升级到了18,同时升级了相关的依赖。发布过程很顺利,灰度了10%的流量,看起来一切正常。

大概过了半个小时,客服开始收到用户反馈,说页面打开是空白的,什么都看不到。我们赶紧看监控,发现错误率飙升到了25%,而且还在上升。

最诡异的是,这个错误只在特定的浏览器上出现。Chrome和Edge正常,Firefox和Safari有问题,而且是旧版本的Firefox和Safari。新版本的浏览器都正常。

我们第一反应是回滚。回滚到上一个版本之后,错误率降下来了,但没有完全消失,还有5%左右的错误率。这说明不只是新版本的问题,老版本也有潜在问题,只是新版本触发了。

初步排查

回滚只能临时缓解,根因还没找到。我们开始深入排查。

先看错误日志。错误信息是"TypeError: Cannot read properties of undefined (reading 'then')",堆栈指向React的内部代码。这个错误看起来像是React内部出了问题,但React本身应该不会有这么低级的bug。

然后我们在本地复现。用旧版本的Firefox打开页面,确实能复现,页面空白,控制台报同样的错误。但用Chrome打开就正常。

我们开始怀疑是浏览器兼容性问题。React 18对旧浏览器的支持怎么样?查了一下文档,React 18支持IE11以上的浏览器,Firefox和Safari应该都支持。但我们用的Firefox版本比较旧,是2020年的版本,可能有什么API不支持。

我们加了一些日志,发现错误出现在Suspense组件的渲染过程中。React 18的Suspense在服务端渲染和客户端注水的时候有一些新的逻辑,可能在旧浏览器上有问题。

深入排查

我们把问题缩小到了Suspense和注水的交互上。

我们的应用是SSR的,服务端渲染HTML,客户端注水。React 18的注水逻辑和React 17不一样,React 18支持选择性注水,Suspense的处理也更复杂。

我们在旧版Firefox上调试,发现注水的时候,React在读取一个Promise对象,但这个对象是undefined,所以调用.then的时候报错了。

为什么Promise会是undefined?我们开始追踪数据流向。我们用的是React Query做数据获取,服务端预取数据,然后把数据 dehydrate 到HTML里,客户端hydrate之后React Query从HTML里读取预取的数据。

问题出在dehydrate和hydrate的过程中。React Query的dehydrate会把查询数据序列化到HTML的script标签里,客户端hydrate的时候反序列化。但在旧版Firefox上,反序列化出了问题,数据没有正确恢复,导致Suspense等待的Promise是undefined。

找到根因

继续深挖,终于找到了根因。

React Query的dehydrate用的是JSON序列化,把数据放到script标签里。但我们的数据里包含了一些特殊字符,比如Unicode的零宽字符和某些控制字符。这些字符在JSON序列化的时候会被转义,但在旧版Firefox上,script标签的解析和现代浏览器不一样,导致转义字符没有被正确还原。

具体来说,我们的数据里有一个字段包含了U+2028(行分隔符)和U+2029(段落分隔符)。这两个字符在JSON里是合法的,但在JavaScript的字符串里,U+2028和U+2029会被当作行终止符,导致script标签里的JSON解析出错。

现代浏览器(Chrome、Edge、新版Firefox和Safari)已经修复了这个问题,会正确处理这些字符。但旧版Firefox和Safari没有修复,所以解析出错,数据没有正确恢复。

为什么React 17的时候没有问题?因为React 17的注水逻辑和React 18不一样。React 17在注水的时候,如果数据没准备好,会直接重新渲染,不会去读那个Promise。React 18的Suspense注水逻辑会去读Promise,所以就报错了。

这就解释了为什么回滚之后还有5%的错误率:老版本在某些边界条件下也会触发,但概率低;新版本因为注水逻辑变了,触发概率大大增加。

修复方案

找到根因之后,修复方案就简单了。

第一个修复是在dehydrate的时候过滤掉特殊字符。在把数据序列化到HTML之前,把U+2028、U+2029以及其他可能导致问题的控制字符替换掉或者转义。这样旧浏览器也能正确解析。

第二个修复是升级React Query到最新版本。最新版本的React Query已经修复了这个问题,在dehydrate的时候会自动处理这些特殊字符。

第三个修复是加一个兜底。在客户端hydrate的时候,如果数据没有正确恢复,不要让React去读undefined的Promise,而是触发重新渲染。这样即使数据有问题,页面也不会空白,最多是多一次渲染。

修复完成后,我们在旧版Firefox上测试,页面正常显示了,没有再报错。然后灰度发布,观察了一个小时,错误率降到了0。

故障影响

这次故障持续了大约六个小时,其中回滚临时缓解了三个小时,完全修复用了三个小时。

受影响的用户大约占总用户的8%,主要是使用旧版Firefox和Safari的用户。这些用户打开页面是空白的,完全无法使用。

业务影响方面,这六个小时的订单量下降了约15%,估算损失了几十万的GMV。品牌影响也有,一些用户在社交媒体上吐槽了我们的应用打不开。

事后我们做了完整的复盘,总结了几个教训。

经验教训

第一个教训是升级核心依赖要谨慎。React从17升级到18是大版本升级,虽然官方说升级很平滑,但内部的渲染和注水逻辑变化很大。我们在升级的时候只测了主流浏览器的最新版本,没有测旧版本浏览器,导致了这个问题。以后升级核心依赖,必须在所有目标浏览器上做完整测试,包括旧版本。

第二个教训是SSR的数据序列化要小心。把数据放到script标签里的时候,不能直接JSON.stringify,因为有些字符在JSON里合法但在JS里不合法。要用专门的序列化库,或者自己处理特殊字符。这个问题其实是个老问题了,很多人都踩过坑,但我们还是踩了。

第三个教训是监控要更细粒度。我们的错误监控只统计了错误率,没有按浏览器、浏览器版本、操作系统细分。如果一开始就能看到错误集中在旧版Firefox上,排查速度会快很多。后来我们给监控加了浏览器维度,以后排查问题会快很多。

第四个教训是灰度发布要更谨慎。我们灰度了10%就全量了,而且灰度时间只有半个小时。如果灰度时间长一点,或者灰度比例小一点,可能在灰度阶段就能发现这个问题。以后大版本升级,灰度时间至少要两个小时,而且要覆盖不同的用户群体。

第五个教训是要有完善的回滚机制。这次故障能快速缓解,靠的就是快速回滚。如果回滚慢了,影响会更大。我们的CI/CD流程里,回滚只需要点一下按钮,几分钟就能完成。这个机制在关键时刻救了我们。

后续改进

故障之后,我们做了一系列改进。

第一是完善了浏览器测试矩阵。在CI里加了旧版Firefox和Safari的测试,每次发布都要在这些浏览器上跑一遍核心流程。

第二是加了数据序列化的安全检查。在dehydrate的时候自动过滤危险字符,并且加了单元测试覆盖各种特殊字符的情况。

第三是完善了监控告警。错误率告警加了浏览器维度,而且告警阈值更灵敏了,错误率超过5%就告警,不用等到25%才发现。

第四是优化了灰度发布流程。大版本升级必须灰度至少两个小时,而且要按浏览器版本分层灰度,先灰度现代浏览器,再灰度旧浏览器。

第五是做了故障演练。定期模拟各种故障场景,测试团队的响应速度和处理能力。这次故障的处理流程也被整理成了文档,作为以后故障处理的参考。

写在最后

这次故障是一次惊心动魄的经历,但也是一次宝贵的学习机会。每一次线上故障都是对系统的一次压力测试,能帮你发现平时发现不了的问题。

React 18的生态还在快速发展,新特性很多,但坑也不少。升级的时候一定要谨慎,做好充分的测试和准备。

希望这篇复盘能给大家一些启发。线上故障不可怕,可怕的是故障之后没有总结,下次还犯同样的错误。我们已经把这次的教训都记下来了,争取以后不再犯类似的问题。