尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

前端切 SSR 那周,商品接口 QPS 涨了 6.8 倍:后端该提前算的 4 笔账

前端切 SSR 那周,商品接口 QPS 涨了 6.8 倍:后端该提前算的 4 笔账 title: 前端切 SSR 那周商品接口 QPS 涨了 6.8 倍后端该提前算的 4 笔账tags: SSR,CSR,SSG,BFF,后端架构category: 后端一次没通知后端的前端改造这事的起因是 SEO。我们的商品详情页是 Vue 的纯客户端渲染搜索引擎爬虫抓到的 HTML 只有一个空的div idapp商品标题、描述、价格全靠 JS 加载后填充。市场部门反馈自然搜索流量一直上不去前端团队决定上 Nuxt 的 SSR。改造做了三周前端侧测试很顺利首屏时间从 2.8s 降到 1.1sLighthouse 分数从 61 涨到 89。上线选在周二凌晨灰度 10%。早上 9 点我们后端的商品详情聚合接口开始告警。QPS 从平时的 800 涨到 5400涨了 6.8 倍。灰度只放了 10% 的流量接口 QPS 却涨了近 7 倍。原因是这样的CSR 时代浏览器有缓存。用户点开商品页JS 从 CDN 拿接口数据走 HTTP 缓存我们设了 60 秒的Cache-Control: max-age60同一个用户在 60 秒内反复进出同一个商品页只有第一次真的打到后端。而且很多用户是从 APP 内嵌 webview 进来的webview 的缓存策略更激进。切成 SSR 之后渲染发生在 Node 服务器上。Node 服务器没有浏览器那套 HTTP 缓存机制每次请求进来都老老实实调一遍后端接口。更糟的是Nuxt 的服务端渲染阶段和客户端 hydration 阶段各调了一次——同一份数据请求了两遍。再加上爬虫。SEO 是这次改造的目的改完之后爬虫抓取频率确实上去了百度和 Google 的爬虫每天带来几十万次页面请求每一次都触发一整轮后端调用。这三个因素叠加就是 6.8 倍。三种渲染模式后端要承担的东西完全不同在讲怎么解决之前先把这三个词对后端的含义说清楚。很多后端同学听到 SSR/CSR/SSG 会觉得这是前端的事这个认知是这次事故的根本原因。CSR客户端渲染服务端返回一个空壳 HTML JS bundle浏览器执行 JS调 API 拿数据渲染 DOM。对后端意味着- 请求方是浏览器数量 真实用户数- 浏览器会遵守Cache-Control、ETag有天然的一层缓存- 请求带用户的 Cookie、能拿到真实 IP、能做设备指纹- 接口可以设计得很碎前端按需调用- 流量是分散在时间轴上的用户滚动到哪加载到哪SSR服务端渲染Node 服务器接到请求调后端 API 拿数据在服务端把组件渲染成 HTML 字符串返回浏览器拿到的是完整 HTML然后 hydration 接管交互。对后端意味着- 请求方是Node 服务器来自少数几个 IP- 没有浏览器缓存除非 Node 侧自己实现- 每次页面请求 一整套接口调用且是串行或并行的批量调用- 首屏需要的所有数据必须在渲染前拿到接口的 P99 直接变成页面的 TTFB- 流量集中在页面请求的瞬间尖峰更陡SSG静态站点生成构建时就把页面渲染成静态 HTML 文件部署到 CDN用户请求直接命中静态文件。对后端意味着- 请求方是构建流水线一天可能就几次- 后端接口 QPS 几乎为 0- 但构建时会在几分钟内把所有页面的数据全拉一遍是个短促的大批量请求- 数据更新有延迟需要增量构建或 ISR 机制三者对后端的压力模型对比维度CSRSSRSSG后端 QPS 来源真实用户浏览器渲染服务器构建流水线浏览器缓存生效是否不适用单页面请求接口数按需可分批首屏全量必须同步构建时全量对接口 P99 敏感度中影响局部加载极高直接是 TTFB低构建慢点无所谓真实 IP / UA可获取需要透传容易丢无登录态Cookie 自动带需要 Node 侧手动转发无爬虫放大效应小爬虫拿到空壳就走大爬虫触发完整渲染无流量突增风险低高极低爬虫放大效应这一行是我们当时完全没想到的。CSR 时代爬虫抓到空壳 HTML不执行 JS对后端零压力。切 SSR 之后爬虫每抓一个页面后端就要跑一整轮聚合。而 SEO 优化本身就会导致爬虫抓取频率上升——这是个正反馈。第一天的紧急止血告警之后我们做的第一件事不是优化是把接口的限流阈值调高 临时扩容。商品聚合服务从 30 个 pod 扩到 90 个先扛住。然后当天下午做了两个改动。改动一给 Node 侧加一层 HTTP 缓存。这个其实前端团队自己能做但他们不知道该做。我们一起在 Nuxt 的 server middleware 里加了个基于 LRU 的响应缓存// server/middleware/api-cache.js前端同学写的我参与了 key 设计 const LRU require(lru-cache) const cache new LRU({ max: 5000, ttl: 30 * 1000 }) // 30 秒5000 条 module.exports async function (req, res, next) { // 只缓存商品详情这类公共数据带登录态的一律不缓存 if (!req.url.startsWith(/api/item/detail) || req.headers.cookie) { return next() } const key req.url const hit cache.get(key) if (hit) { res.setHeader(X-Cache, HIT) return res.end(hit) } // ... 调用后端拿到结果后 cache.set(key, body) }这一层上线后后端 QPS 从 5400 降到 2100。改动二把服务端渲染阶段和 hydration 阶段的重复请求砍掉。Nuxt 有个useAsyncData会自动把服务端拿到的数据序列化进 HTML 的__NUXT__变量里客户端直接用不重复请求。但前端团队用的是mounted里手动调接口的老写法导致两边各调一次。改成标准写法后又降了一半QPS 到 1100。两个改动加起来从 5400 降到 1100比 CSR 时代的 800 只高 37%可以接受。pod 从 90 缩回 45。后端侧真正该做的三件事止血之后我们花了一个月做了更系统的改造。这三件事我认为是任何团队要上 SSR 前后端都该做的。一、把碎接口合并成 BFF 聚合接口CSR 时代前端可以慢慢调先调商品基本信息渲染骨架再调价格、再调库存、再调评价。每个接口都很快用户体验是渐进的。SSR 不行。服务端渲染是一次性的所有首屏数据必须在渲染前全部就位。前端如果还是调 7 个接口就变成了 7 次串行/并行的网络往返TTFB 会很难看。我们做了一个专门给 SSR 用的聚合接口RestController RequestMapping(/bff/ssr) public class SsrAggregateController { private final ItemService itemService; private final PriceService priceService; private final StockService stockService; private final ReviewService reviewService; // 专用线程池跟主业务线程池隔离避免 SSR 流量打满共享池 private final ThreadPoolExecutor ssrPool; GetMapping(/item/{itemId}) public ItemPageVO itemPage(PathVariable long itemId, RequestHeader(value X-Real-IP, required false) String realIp) { // 并行发起不要串行。7 个接口串行 P99 会累加并行只取最慢的那个 CompletableFutureItemBaseVO baseF CompletableFuture .supplyAsync(() - itemService.getBase(itemId), ssrPool); CompletableFuturePriceVO priceF CompletableFuture .supplyAsync(() - priceService.getPrice(itemId), ssrPool) // 价格拿不到时降级成询价不能因为一个下游挂了整页 500 .exceptionally(e - PriceVO.unavailable()); CompletableFutureStockVO stockF CompletableFuture .supplyAsync(() - stockService.getStock(itemId), ssrPool) .exceptionally(e - StockVO.unknown()); CompletableFutureListReviewVO reviewF CompletableFuture .supplyAsync(() - reviewService.top(itemId, 5), ssrPool) .exceptionally(e - Collections.emptyList()); try { // 整体超时 300ms。SSR 场景下宁可少渲染一块内容也不能让 TTFB 超过阈值 CompletableFuture.allOf(baseF, priceF, stockF, reviewF) .get(300, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { // 超时不抛错用已经完成的部分组装。getNow 拿不到就用默认值 log.warn(ssr aggregate partial timeout, itemId{}, itemId); } catch (Exception e) { throw new BizException(聚合失败, e); } ItemBaseVO base baseF.getNow(null); // base 是必需的拿不到就必须报错让 SSR 降级成 CSR if (base null) { throw new BizException(商品基础信息不可用); } return ItemPageVO.of(base, priceF.getNow(PriceVO.unavailable()), stockF.getNow(StockVO.unknown()), reviewF.getNow(Collections.emptyList())); } }这段代码里几个设计决策值得说每个非核心下游都挂exceptionally降级。CSR 时代某个接口挂了页面上就那一块转圈用户还能看别的。SSR 时代一个异常没接住整个页面 500用户看到的是白屏。SSR 把局部失败变成了整体失败这是后端必须在代码里对冲的风险。整体超时 300ms 而不是等所有完成。SSR 的 TTFB 直接影响 SEO 评分Google 的 Core Web Vitals 里 TTFB 建议在 800ms 以内。留给后端的预算撑死 300-400ms。getNow而不是get超时后不能再阻塞用已完成的部分组装。独立线程池ssrPool这个是踩坑之后加的。最初用的ForkJoinPool.commonPool()SSR 流量一冲把同一个 JVM 里其他用 parallelStream 的业务全拖慢了。二、缓存层要重新设计 key 和粒度CSR 时代缓存 key 是按接口维度的item:base:12345、item:price:12345分开缓存各有各的 TTL基础信息 10 分钟价格 30 秒。SSR 的聚合接口如果直接缓存整个ItemPageVO会遇到一个矛盾TTL 取多少取 10 分钟价格就不准取 30 秒基础信息白白重复查。我们的做法是两层缓存 组合Service public class SsrCacheService { // 本地缓存放变化慢、体积小的Redis 放变化快、要共享的 private final CacheLong, ItemBaseVO localBase Caffeine.newBuilder() .maximumSize(50_000) .expireAfterWrite(Duration.ofMinutes(10)) // refreshAfterWrite 让过期后第一个请求返回旧值、后台异步刷新 // 避免热点 key 过期瞬间大量请求穿透这个比 expireAfterWrite 单独用重要得多 .refreshAfterWrite(Duration.ofMinutes(8)) .build(this::loadBaseFromRemote); public ItemPageVO buildPage(long itemId) { ItemBaseVO base localBase.get(itemId); // 本地微秒级 PriceVO price priceCache.get(itemId); // Redis30 秒 TTL StockVO stock stockService.getStock(itemId); // 实时不缓存 return ItemPageVO.of(base, price, stock, reviewCache.get(itemId)); } private ItemBaseVO loadBaseFromRemote(Long itemId) { return itemService.getBase(itemId); } }refreshAfterWrite那个注释是血泪教训。我们最初只用expireAfterWrite(10min)结果每 10 分钟整点会有一波尖峰——大量热门商品的缓存同时过期所有请求穿透到下游。加上refreshAfterWrite之后过期不再是断崖而是第一个请求拿旧数据、后台悄悄更新尖峰完全消失了。本地缓存在 SSR 场景下特别划算因为Node 渲染服务器的请求分布很集中——它渲染的就是那些热门商品页命中率比 CSR 时代高得多。我们的本地缓存命中率从 CSR 时代的 34% 涨到 SSR 后的 81%。三、真实 IP、UA、登录态的透传约定这是个很容易被忽略但会引发生产问题的点。CSR 时代接口是浏览器直接调的request.getRemoteAddr()拿到的就是用户 IP经过 LB 的话看X-Forwarded-ForUA 是真实浏览器 UACookie 自动带上。SSR 之后接口是 Node 服务器调的- IP 变成了 Node 服务器的内网 IP就那么几个- UA 变成了 Node 的 HTTP 客户端标识比如axios/1.6.0- Cookie 不会自动带需要 Node 手动从用户请求里取出来转发我们因为这个出了三个问题风控误判。风控系统看到同一个 IP 每秒几千次请求直接把 Node 服务器的 IP 拉黑了。整个站点白屏 8 分钟。AB 实验分流失效。分流是按用户 IP 哈希的所有 SSR 请求来自同几个 IP全部落进同一个实验组数据完全废了。个性化推荐降级。推荐服务拿不到用户标识全站返回默认的热门商品列表。这个问题最隐蔽因为页面正常显示只是内容变差了两周后才被数据分析发现转化率下降。解决方案是定一套透传规范前后端一起遵守Component public class SsrContextFilter implements Filter { private static final String SSR_TOKEN System.getenv(SSR_INTERNAL_TOKEN); Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; // 只信任带内部 token 的请求声明的真实 IP防止外部伪造 X-Real-IP 绕过风控 boolean fromSsr SSR_TOKEN ! null SSR_TOKEN.equals(request.getHeader(X-SSR-Token)); RequestContext ctx new RequestContext(); if (fromSsr) { ctx.setClientIp(request.getHeader(X-Origin-Client-IP)); ctx.setUserAgent(request.getHeader(X-Origin-User-Agent)); ctx.setUserId(parseUserId(request.getHeader(X-Origin-Cookie))); ctx.setSource(Source.SSR); // 打标方便监控区分 SSR 流量和直连流量 } else { ctx.setClientIp(extractRealIp(request)); ctx.setUserAgent(request.getHeader(User-Agent)); ctx.setUserId(parseUserIdFromCookie(request)); ctx.setSource(Source.BROWSER); } RequestContextHolder.set(ctx); try { chain.doFilter(req, res); } finally { RequestContextHolder.clear(); // ThreadLocal 必须清否则线程复用会串数据 } } }X-SSR-Token那个校验是必须的。如果任何人都能通过设置X-Origin-Client-IP头来声明自己的 IP风控就形同虚设了。这个 token 我们放在 K8s Secret 里每季度轮换。SSG 才是被低估的那个整个改造做完之后我做了一个可能有点打脸的判断我们的商品详情页里有 40% 根本不该用 SSR应该用 SSG。原因是数据形态。我们的商品分两类活跃商品大促款、爆款价格库存分钟级变化必须 SSR 或 CSR长尾商品占 SKU 数的 78%但只占访问量的 12%几个月不改一次价格库存也几乎不动长尾商品用 SSR 是纯粹的浪费——每次爬虫来抓都要跑一整轮聚合而返回的内容跟上次一模一样。我们后来引入了 Next.js 的 ISR增量静态再生成思路在 Nuxt 里用类似机制实现长尾商品页生成静态 HTML 存 CDN设置stale-while-revalidate超过 1 小时后第一个访问者拿到旧页面、同时触发后台重新生成。效果指标全 SSRSSR ISR 混合后端聚合接口 QPS1100340Node 服务器 pod 数249长尾页面 TTFB420 ms38 msCDN 直出爬虫带来的后端压力占 43%占 6%数据新鲜度长尾实时最长延迟 1 小时后端 QPS 从 1100 降到 340比 CSR 时代的 800 还低了 57%。Node 服务器成本也降了 62%。代价就是那 1 小时的数据延迟对长尾商品完全可以接受——它们本来就几个月不变。我的取舍判断渲染模式的选择不该由前端单方面决定。这次事故最大的教训不是技术是流程。前端做了三周改造后端一无所知直到告警响起。现在我们的规范是任何涉及渲染模式变更的改造必须在设计阶段拉后端评估 QPS 放大倍数、缓存策略、降级方案。判断标准我总结成三条内容对所有用户一样、更新不频繁 → SSG/ISR。文档站、商品长尾页、营销落地页、帮助中心。这是性价比最高的选择后端压力几乎为零首屏还最快。很多团队直接跳过 SSG 上 SSR我觉得是被SSR 更高级的印象误导了。需要 SEO 且内容因人/因时而异 → SSR。但要认清代价后端 QPS 放大、失去浏览器缓存、局部失败变整体失败、真实 IP 丢失。这些都要提前做工程对冲。不需要 SEO 的后台系统、管理端、APP 内嵌页 → CSR。别跟风。管理后台上 SSR 纯属自找麻烦——它没有 SEO 需求用户量小但你要多维护一套 Node 服务、多一层故障点。我不建议为了首屏快 1 秒就上 SSR。首屏优化有很多更便宜的手段接口合并、资源预加载、骨架屏、代码分割、CDN 预热。这些做完通常能把首屏压到 1.5s 以内而 SSR 带来的是一整套新的运维复杂度——Node 服务的内存泄漏、渲染超时、SSR 与 CSR 的行为不一致hydration mismatch。我们上线三个月里Node 侧出过 4 次故障其中 2 次是内存泄漏导致的 OOM。如果一定要上 SSR后端至少要准备这四样独立的聚合接口带并行 超时 降级、独立的线程池、专门的缓存策略本地 refreshAfterWrite、以及一套上下文透传规范。少任何一样都会在上线后第一周找上门来。留个问题如果你的页面既需要 SEO商品标题、描述、图片要被爬虫抓到又有强实时性的内容库存、秒杀倒计时你会怎么切分我们的做法是静态骨架 SSG 动态部分 CSR 补齐把 SEO 需要的内容做成静态 HTML库存价格这些用一个轻量接口在客户端异步拉。这样爬虫拿到了它要的东西用户拿到了实时数据后端压力也可控。但这意味着页面上有一小段时间显示的是占位符。你会怎么权衡这个体验损失评论区聊聊。
返回列表