title: SSR、CSR、SSG 不是前端的事:一次首屏 4s 把 BFF 打挂,后端被误解的 3 个锅
tags: SSR, CSR, SSG, BFF, 前端渲染, 后端架构
description: 从一次 SSR 首屏优化把 BFF 层打挂的事故,讲清 SSR/CSR/SSG 三种渲染模式对后端请求模型、并发和超时的真实影响,以及 Java BFF 该如何应对。
很多后端工程师觉得"SSR、CSR、SSG 是前端的事,跟我写的接口没关系"。我以前也这么想,直到我们商城改版上 SSR,首屏从 1.2s 变成 4s,BFF(Backend For Frontend)层的线程池被打满,DB 连接池耗尽,报警响了一整晚。
复盘时发现:问题不在前端渲染本身,而在于SSR 把"原本在用户浏览器里分散发生的请求,全部集中挪到了服务端同一时刻发出"。后端接口完全没变,但请求的时间分布和并发模型彻底变了。这篇文章从后端视角,把三种渲染模式对服务端的真实影响讲清楚。
三种模式到底在"哪里、什么时候"发请求
先统一认知,免得后端同学看前端术语发懵:
- CSR(Client-Side Rendering,客户端渲染):HTML 是个空壳,浏览器下载 JS 后,由 JS 在客户端发起 API 请求拿数据、再渲染。请求发生在用户浏览器里,且是异步、分散的(先渲染骨架,再逐个拉数据)。
- SSR(Server-Side Rendering,服务端渲染):用户在服务端就把页面 HTML 渲染好(含数据),首屏直出。意味着服务端要先把数据取齐才能返回 HTML,数据请求发生在服务端、且是同步阻塞在首屏响应里的。
- SSG(Static Site Generation,静态生成):页面在构建时就渲染成静态 HTML,运行时基本不发数据请求(或只发少量增量)。对运行时后端压力最小。
关键差异一句话:CSR 的请求在客户端分散异步;SSR 的请求在服务端集中同步;SSG 的请求在构建时一次性发生。对后端来说,这就是"流量形态"的差别。
后端视角最容易踩的雷:SSR 的"请求瀑布"
下面是一个典型的 SSR BFF 接口,它要在返回 HTML 前把首屏需要的多个数据聚合好:
// SSR 场景下的 BFF:必须等所有数据齐了才能渲染首屏 @RestController public class HomeBffController { @Autowired private ProductClient productClient; @Autowired private UserClient userClient; @Autowired private RecommendClient recommendClient; @GetMapping("/ssr/home") public Mono<String> home(@RequestParam long userId) { // 1. 顺序串行调用:商品 + 用户 + 推荐,逐个 await,耗时叠加 return productClient.getFeed(userId) // 2. 约 120ms .flatMap(feed -> userClient.getProfile(userId) // 3. 再 80ms .flatMap(profile -> recommendClient.get(userId) // 4. 再 150ms .map(rec -> renderHtml(feed, profile, rec)))); // 5. 全齐了才渲染 } }逐行解释:第 2-4 行用了flatMap把三个下游调用串行串起来,总耗时是 120+80+150 ≈ 350ms,而且每个都在首屏响应路径上同步等待。问题是,SSR 下每一个用户请求首屏都会触发这一串。假如大促来了 5000 QPS,这串调用就会在后端形成 5000 × 3 = 1.5 万 QPS 的下游压力,且首屏 P99 直接被最慢的那个下游绑架。我们那晚就是推荐服务抖了一下(从 150ms 变 1.2s),首屏 P99 立刻 4s,BFF 线程池耗尽。
注意第 4 行的renderHtml才是渲染,但它依赖前面三个全部完成,这正是 SSR 把"请求瀑布"压到服务端的表现。
改法:把串行瀑布改成并发聚合,并加缓存
CSR 模式下这些调用在浏览器里本来就是并发的,到了 SSR 服务端你更该并发,而不是串行:
// 修正版:用 zip 并发拉取,并用缓存吸收重复请求 @GetMapping("/ssr/home") public Mono<String> homeFixed(@RequestParam long userId) { Mono<ProductFeed> feed = productClient.getFeed(userId).cache(Duration.ofSeconds(30)); // 1. 结果缓存 30s Mono<UserProfile> profile = userClient.getProfile(userId).cache(Duration.ofSeconds(30)); // 2. 并发而非串行 Mono<Recommend> rec = recommendClient.get(userId).cache(Duration.ofSeconds(30)); // 3. zip 让三者同时发起,总耗时取最慢那个(~150ms),而非三者之和 return Mono.zip(feed, profile, rec) .map(tuple -> renderHtml(tuple.getT1(), tuple.getT2(), tuple.getT3())); }逐行解释:第 1-2 行给每个下游调用加.cache(30s),意味着 30 秒内同一个 userId 的重复首屏请求会复用结果,直接把下游 QPS 削掉一大截(热点用户尤其明显)。第 3 行Mono.zip是关键——它同时发起三个调用,总耗时等于最慢的那个(约 150ms),而不是串行叠加的 350ms。我们改完这一处,首屏 P99 从 4s 回到 600ms,BFF 线程占用降了 60%。
但这里有个后端要警惕的点:cache在 SSR 高并发下如果粒度太粗(比如按全局缓存所有用户),内存会爆;按 userId 缓存又要防缓存击穿(热点用户瞬间上万请求同时穿透)。我们最后用了"Caffeine 本地缓存 + 单 flight 合并"(同一 key 只放一个请求去下游),才算彻底稳。
SSG 对后端简直是"减负神器"
SSG 模式下,页面在构建时渲染成静态 HTML,运行时后端几乎不承受数据请求压力。但有个后端容易忽略的细节:SSG 的"构建时拉数据"会把原本分散的请求,集中成构建期的一次性突发。
我们曾有个文档站用 SSG,每次 CI 构建会瞬间对后端配置服务发起上万次全量拉取(因为每篇文档构建时各拉一次配置)。后端配置服务被构建流水线打挂过两次。解决办法是给 SSG 构建配一个构建专用的只读缓存代理,且错峰构建,别和线上流量抢。
我们被误解的 3 个锅,和真相
锅一:"首屏慢是后端接口慢。"真相:CSR 时首屏慢可能是因为前端懒加载或串行请求;SSR 时首屏慢的元凶常常是 BFF 把这些请求串行瀑布化。先确认是"接口本身慢"还是"请求编排方式慢"。
锅二:"后端只需提供数据,渲染快慢不关我事。"真相:SSR 把渲染压力转移到服务端,后端接口从"被浏览器异步调用"变成"被首屏同步阻塞调用",超时设置和并发模型都得重新评估。我们后来给所有 SSR 依赖的下游都设了比首屏预算更紧的超时(比如首屏预算 800ms,单个下游超时就设 300ms 快速失败),避免一个下游拖死整屏。
锅三:"SSG 上线后端可以躺平。"真相:SSG 把压力移到构建时,如果构建和线上共用一套后端服务,构建突发会把线上带崩。务必隔离构建流量。
三种模式对后端的 Checklist
- CSR:后端接口做好分页、懒加载支持;监控客户端并发请求峰值(大列表可能瞬间打几百个请求),必要时合并接口(GraphQL 或 BFF 聚合)。
- SSR:BFF 必须把下游调用并发聚合而非串行;给热点数据加短 TTL 缓存;下游超时比首屏预算更紧;准备好首屏超时兜底(返回骨架页而非卡死)。
- SSG:构建期数据拉取要隔离、错峰、走缓存代理;运行时后端基本无压,但要防"重新构建"触发的突发。
我的取舍
我现在会把"渲染模式"写进后端接口的 SLA 设计里,而不是等前端改版了再被动救火。凡是依赖 SSR 的首屏,BFF 层一律按"高并发聚合 + 缓存 + 紧超时"三件套来设计;CSR 的接口则重点防"客户端突发小请求风暴";SSG 重点防"构建期批量拉取"。渲染模式从来不只是前端的选型,它直接决定了后端要承受的请求形态——这一点,后端工程师越早想清楚越省事。
顺手给 SSR 的下游加"紧超时"和降级
前面说了,SSR 首屏被最慢下游绑架。后端该做的是给每个 SSR 依赖的下游设比首屏预算更紧的超时,且超时就返回降级数据而非卡死:
// 给 SSR 依赖的下游设独立超时,并准备降级兜底 public Mono<String> homeWithTimeout(long userId) { Mono<ProductFeed> feed = productClient.getFeed(userId) .timeout(Duration.ofMillis(250)) // 1. 单个下游超时就断,不等首屏预算耗尽 .onErrorResume(e -> Mono.just(ProductFeed.EMPTY)); // 2. 降级成空数据,首屏仍可渲染 Mono<UserProfile> profile = userClient.getProfile(userId) .timeout(Duration.ofMillis(200)) .onErrorResume(e -> Mono.just(UserProfile.EMPTY)); Mono<Recommend> rec = recommendClient.get(userId) .timeout(Duration.ofMillis(300)) .onErrorResume(e -> Mono.just(Recommend.EMPTY)); return Mono.zip(feed, profile, rec) .map(t -> renderHtml(t.getT1(), t.getT2(), t.getT3())); }逐行解释:第 2 行timeout(250ms)是关键——首屏整体预算如果是 800ms,单个下游绝不该撑满它,否则一个慢下游直接拖死整屏。第 3 行onErrorResume做降级:推荐挂了就返回空推荐,首屏照常出(只是少块内容),而不是让用户看到 4 秒白屏。我们上线这套超时 + 降级后,即使推荐服务再抖,首屏 P99 也稳定在 800ms 内,再没发生 BFF 被拖挂。教训是:SSR 把渲染压力挪到服务端,后端就必须用"超时隔离 + 降级"把这种集中压力兜住,否则首屏就是全站最脆弱的单点。
CSR 也不是后端高枕无忧:当心首屏的"小请求风暴"
很多人以为只有 SSR 才给后端加压,CSR 反而轻松。其实不然。CSR 下首屏虽不依赖服务端聚合,但页面挂载后会迸发一堆小请求(图标、配置、首屏列表、用户信息各自拉)。这些请求在浏览器里并发发出,瞬时 QPS 可能比 SSR 还高,且因为分散在不同接口,更容易绕过后端的单接口限流。我们曾在一次 CSR 改版后,发现某个/api/config接口在首屏期间被同一用户并行打 12 次——因为十几个组件各自独立拉配置,谁也不共享。后端后来改成"首屏配置走一次聚合 + 浏览器端短缓存(Cache-Control / 内存缓存)",把这个接口的无效流量削掉了 80%。所以无论哪种渲染模式,后端都得盯着"请求是怎么发的",而不是"页面在哪渲染"。
监控该看什么,才能提前发现渲染模式的问题
我们吃过亏之后,在 BFF 层加了三道监控:一是首屏依赖的下游聚合 P99(而不只看单接口),因为 SSR 下多个下游叠加才是首屏体验;二是首屏路径上的并发调用数,一旦某个下游被串成串行瀑布,聚合耗时曲线会立刻变形;三是 SSG 构建期的后端请求突增告警,把"构建流水线"也当成一类调用方纳入监控。这三道监控上线后,再出现渲染模式相关的性能回归,基本能在雪崩前几分钟就被捕获,而不是等用户投诉才反应。
思考题
你负责的接口里,有没有被 SSR 首屏同步依赖的?如果有,去查一下它的下游调用是串行还是并发,有没有缓存,超时设了多少。我赌你会找到至少一个"串行瀑布"——把它改成 zip 并发 + 短缓存,首屏数字通常会给你一个惊喜。也顺手确认下:你们的 SSG 构建,是不是在和线上抢同一套后端服务?