前言
为什么突然想写一篇总结了呢,其实也是被虐的。今年 3 月份初期,我们商城接了一个 XX 银行的一分购活动(说白点就是薅羊毛),那时候是活动第一期,未曾想到活动入口开放时,流量能直接将 cpu 冲至 100%,导致服务短暂的 502 了。期间采取了紧急方案到活动结束,但未曾想到还有活动二期,以及上周刚上线的活动三期。想着最近这段时间也做了一些事情,还有遇到的一些坑点,趁此机会,就不偷懒记录一下吧。
活动一期到三期具体做了些什么
技术背景&瓶颈
项目是基于 Vue+SSR 架构的,且没有做缓存处理,没做缓存的主要原因第一个是原本应用 tps 比较低,改造动力不强,并且页面渲染结果中包含了用户数据以及服务端时间,没法在不经过改造的情况下直接上缓存。所以当一期活动大流量冲击时,高并发情况下很容易将 cpu 打至 100%。
一期在未知情况下,服务直接扛不住了,当时为了活动能正常进行,首要方案就是先加机器扛住部分压力,紧接着就是加缓存,目前有两种缓存方案,缓存页面或缓存组件,但由于我们的需要缓存的商品详情页组件涉及到动态信息,可维护性太差,心智成本高,最终选择了前者。我们整理了一下商详页有关动态变化的信息数据(与时间/用户相关等类型的数据),在活动期间,紧急屏蔽了部分不影响功能的动态内容,然后页面上 CDN。
活动结束后,我们做了下复盘,要像应用要能保障大流量情况下稳定运行,性能优化处理是避免不了的了。为此我们做了以下四大方案:
1.对数据做动静分离: 我们可以将数据分类成动静两类,静态数据即是一段时间内,不随时间/用户改变,动态数据则是相反的,经常变动,与时间有关,有用户相关等类型的数据都可以归类为动态数据。原页面无法上缓存的最大阻碍就是,就是在 node 渲染模板时,会默认获取用户数据,或是在 asyncData 中调用用户相关的接口;此外,还会设置服务端时间等动态数据。所以思路就是将静态数据放在 node 获取,将动态数据放到客户端(浏览器读取 asyncData、mounted 等浏览器生命周期里)获取保证服务端的清洁。
2.页面接入 CDN: 经过动静态分离的改造后,已经可以将其路径加入 cdn。但需要注意路径上 query 等参数是否会影响渲染,如果影响,需要把逻辑搬到客户端,同时需要注意一下过期时间(如 10 分钟)是否会对业务产生影响
3.应用缓存: 如果在比较糟糕的情况下,cdn 失效了导致回源率上升,应用本身还是需要做好准备。这就要根据项目需要去选择内存缓存/redis 缓存。
4.自动降级: 在极端的情况下,前面的缓存都没挡住流量,就需要终极方案:降级渲染。所谓降级渲染,就是不进入路由,直接将空模板返回,完全交给浏览器去做渲染。这样做的最大好处就是完全避免了 node 压力,将一个 SSR 应用变成了静态应用。缺点也是显而易见的,用户看到的是空模板,需要等待初始化。那如何自动降级呢,可以通过定时器时时检测 cpu、负载等压力,来确定当前机器的负载,从而决定是否降级;也可以通过 url 的 query 上是否携带特定标识,显式地决定是否降级。
对项目方案做了以上性能优化接下来就是压测,也算是顺利上线了。🤪
二期活动没过多久又来了,不过如我们预期,项目很稳定地扛住了压力,期间也增加了流量接口,并加友好提示等优化。但其中一个痛点是需要针对几个特殊商品去做个文案处理,这几个文案非接口返回,也是临时性的一些醒目提示,没必要放在详情页接口中返回。由于时间也很紧急,我们不确定后面还有没有这种特定的文案需求(和具体的页面以及特定的区域关联),决定还是暂时写一些 low code:针对特定的活动商品 id,临时添加文案,活动下线之后,把文案去除。这样做的风险性是有的,毕竟代码是临时性的,需要上下线,并且有时间延迟。但好在活动结束时是周末,最后一天流量访问并不大,给了相对应的引导文案以及售后处理,评估下来影响不大,尚可接受。
以下图片商品详情页和商品购买页需要加的特定文案:
薅羊毛活动是真香现场吗~~6 月底产品就和我打了个招呼,说 XX 活动又要有三期了,但整体方案依旧和二期一样不变。我内心:还来???(小声说句打工人太苦了),由于最终时间没定下来,也有了二期的教训之后,和后端同学也一起商量了一下,把活动商品往配置化方向考虑,放在我们配置后台中文案模块且是可行的。针对商品详情页,考虑到不破坏动静分离,先确定下配置化接口返回的数据是静态的,可以放在服务端获取。以下具体三期做的事情:
- 将参与活动商品的文案做成配置化,从配置接口获取,去除 low code
- 整理大流量活动页(例如商详页)的接口,放在客户端的接口需要做限流,接口达到一定的 tps 后,返回 429 状态,前端要做容错处理,页面功能正常访问,屏蔽限流接口错误。
- 针对购买限流接口,需要给 busy 提示(活动太火爆了,请稍后再试)
- // 统一在 getResponseErrorInterceptor 处针对 429 状态做处理
- export const getResponseErrorInterceptor = ({ errorCallback }) => (error) => {
- if (!isClient) {
- ...
- } else {
- // 429 Code 服务报错需要支持不弹出错误提示
- if (+error.response.status === 429) {
- // 针对限流接口,且需要 busy 提示时增加 needBusyMsg 属性
- errorCallback(error.config.needBusyMsg ? '活动太火爆了,请稍后再试' : null);
- } else {
- ...
- );
- }
- }
- return throwError(error);
- };
结束了上周一周忙碌的压测和测试,三期终于上线了。👏👏👏👏
想了解更多 Vue SSR 性能优化方案可以移步到这里: Vue SSR 性能优化实践
实际过程中遇到的一些 Coding Question
1.本地项目(vue-SSR 架构)里,一个动态接口放在服务端获取时,有一段代码很 easy,一个是否是会员的标识去开通会员按钮的显隐,代码如下(代码有简化):
- <redirect
- v-if="!isVip"
- :link="link"
- type="h5"
- >
- 开通会员<ui-icon name="arrow-right" />
- </redirect>
本地中虽然运行正常,但是会有如下警告:vue.esm.js:6428 Mismatching childNodes vs. VNodes: NodeList(2) [div, a.DetailVip-right.Redirect] (2) [VNode, VNode]
[Vue warn]: The client-side rendered virtual DOM tree is not matching server-rendered content. This is likely caused by incorrect HTML markup, for example nesting block-level elements inside
, or missing . Bailing hydration and performing full client-side render.
但更新到测试环境中,页面会失效,点击失效等。会报有如下错误:Failed to execute 'appendChild' on 'Node': This node type does not support this method
分析
Vue SSR 指南在客户端激活里刚好说到了一些需要注意的坑,使用「SSR + 客户端混合」时,需要了解的一件事是,浏览器可能会更改的一些特殊的 HTML 结构。例如 table 中漏写<tbody>,像以下几种情况也会导致导致服务端返回的静态 HTML 和浏览器端渲染的内容不一致:
- 无效的HTML(例如:<p><p>Text</p></p>)
- 服务器与客户端的不同状态
- 例如日期、时间戳和随机化等不确定变量的影响
- 第三方脚本影响到了组件的渲染
- 需要身份验证相关时
当然确定原因之后对症下药,总结有几种办法可以解决此问题:
- 检查相关代码,确保 HTML 有效
- 最简单粗暴的一个方法就是:用v-show去代替v-if,要知道构建时生成的HTML是无状态的,应用程序中与身份验证相关的所有部分应该只在客户端呈现,具体可以 diff 下获取数据以及在服务器/客户端呈现的内容,解决服务器和客户端之间的状态不一致
- 面对第三方脚本这类的,可以通过将组件包装在标签中来避免在服务器端渲染组件
- .....(欢迎补充)
针对此类问题,还可以看看这篇文章:https://blog.lichter.io/posts/vue-hydration-error/
2: 同样的 h5 页面,在浏览器中打开配置生效,而在公众号&小程序中打开却失效了?
三期的时候,我们把活动商品 id 和对应文案做成了配置化处理。配置方式如下:
获取商品配置内容经过 JSON.stringify()之后,毋庸置疑会得到如下字符串:
- 455164527672033280|龙支付 立减 10 元|满 40 立减 10(仅限 XX 卡)#\\n623841656577658880|龙支付 立减 10 元(仅限 XX 卡)|满 40 立减 10(仅限 XX 卡)#\\n350947143063699456|龙支付测试 立减 10 元(仅限 XX 卡)|满 40 立减 10 测试(仅限 XX 卡)#
在详情页获取所有的商品 id 列表信息,我们用的#做区分,写了一个简单的正则如下:
- activityItems() {
- return this.getFieldValue('activity_item')?.split('#\\n');
- },
但在公众号里面打开我们的 h5 链接,会将#自动转义成\,内容会变成:
- 455164527672033280|龙支付 立减 10 元(仅限 XX 卡)|满 40 立减 10(仅限 XX 卡)\\\n623841656577658880|龙支付 立减 10 元(仅限 XX 卡)|满 40 立减 10(仅限 XX 卡)\\\n350947143063699456|龙支付测试 立减 10 元(仅限 XX 卡)|满 40 立减 10 测试(仅限 XX 卡)\
啊,这,,,不是吧??(发现时内心几乎是崩溃的)😩 解决方式立马把#字符换成不被转移的字符;。
另外在小程序中打开失效是因为延用二期的方案,当时做了限制判断,只需要在主站和主 app 中打开有效,小程序设有自己单独的 appid,三期活动有多方入口,把该限制放开即可。
总结
薅羊毛参与了三期,也是积累了一些经验,踩了一些坑吧,想着太久没写了该记录一下了,先总结到这里,还有忘记的再补充~
本文转载自微信公众号「微医大前端技术」,可以通过以下二维码关注。转载本文请联系微医大前端技术公众号。