SSR、CSR、SSG 不是前端的事:一次首屏 4s 把 BFF 打挂,后端被误解的 3 个锅
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 变成 4sBFFBackend For Frontend层的线程池被打满DB 连接池耗尽报警响了一整晚。复盘时发现问题不在前端渲染本身而在于SSR 把原本在用户浏览器里分散发生的请求全部集中挪到了服务端同一时刻发出。后端接口完全没变但请求的时间分布和并发模型彻底变了。这篇文章从后端视角把三种渲染模式对服务端的真实影响讲清楚。三种模式到底在哪里、什么时候发请求先统一认知免得后端同学看前端术语发懵CSRClient-Side Rendering客户端渲染HTML 是个空壳浏览器下载 JS 后由 JS 在客户端发起 API 请求拿数据、再渲染。请求发生在用户浏览器里且是异步、分散的先渲染骨架再逐个拉数据。SSRServer-Side Rendering服务端渲染用户在服务端就把页面 HTML 渲染好含数据首屏直出。意味着服务端要先把数据取齐才能返回 HTML数据请求发生在服务端、且是同步阻塞在首屏响应里的。SSGStatic 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 MonoString 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把三个下游调用串行串起来总耗时是 12080150 ≈ 350ms而且每个都在首屏响应路径上同步等待。问题是SSR 下每一个用户请求首屏都会触发这一串。假如大促来了 5000 QPS这串调用就会在后端形成 5000 × 3 1.5 万 QPS 的下游压力且首屏 P99 直接被最慢的那个下游绑架。我们那晚就是推荐服务抖了一下从 150ms 变 1.2s首屏 P99 立刻 4sBFF 线程池耗尽。注意第 4 行的renderHtml才是渲染但它依赖前面三个全部完成这正是 SSR 把请求瀑布压到服务端的表现。改法把串行瀑布改成并发聚合并加缓存CSR 模式下这些调用在浏览器里本来就是并发的到了 SSR 服务端你更该并发而不是串行// 修正版用 zip 并发拉取并用缓存吸收重复请求 GetMapping(/ssr/home) public MonoString homeFixed(RequestParam long userId) { MonoProductFeed feed productClient.getFeed(userId).cache(Duration.ofSeconds(30)); // 1. 结果缓存 30s MonoUserProfile profile userClient.getProfile(userId).cache(Duration.ofSeconds(30)); // 2. 并发而非串行 MonoRecommend 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 回到 600msBFF 线程占用降了 60%。但这里有个后端要警惕的点cache在 SSR 高并发下如果粒度太粗比如按全局缓存所有用户内存会爆按 userId 缓存又要防缓存击穿热点用户瞬间上万请求同时穿透。我们最后用了Caffeine 本地缓存 单 flight 合并同一 key 只放一个请求去下游才算彻底稳。SSG 对后端简直是减负神器SSG 模式下页面在构建时渲染成静态 HTML运行时后端几乎不承受数据请求压力。但有个后端容易忽略的细节SSG 的构建时拉数据会把原本分散的请求集中成构建期的一次性突发。我们曾有个文档站用 SSG每次 CI 构建会瞬间对后端配置服务发起上万次全量拉取因为每篇文档构建时各拉一次配置。后端配置服务被构建流水线打挂过两次。解决办法是给 SSG 构建配一个构建专用的只读缓存代理且错峰构建别和线上流量抢。我们被误解的 3 个锅和真相锅一首屏慢是后端接口慢。真相CSR 时首屏慢可能是因为前端懒加载或串行请求SSR 时首屏慢的元凶常常是 BFF 把这些请求串行瀑布化。先确认是接口本身慢还是请求编排方式慢。锅二后端只需提供数据渲染快慢不关我事。真相SSR 把渲染压力转移到服务端后端接口从被浏览器异步调用变成被首屏同步阻塞调用超时设置和并发模型都得重新评估。我们后来给所有 SSR 依赖的下游都设了比首屏预算更紧的超时比如首屏预算 800ms单个下游超时就设 300ms 快速失败避免一个下游拖死整屏。锅三SSG 上线后端可以躺平。真相SSG 把压力移到构建时如果构建和线上共用一套后端服务构建突发会把线上带崩。务必隔离构建流量。三种模式对后端的 ChecklistCSR后端接口做好分页、懒加载支持监控客户端并发请求峰值大列表可能瞬间打几百个请求必要时合并接口GraphQL 或 BFF 聚合。SSRBFF 必须把下游调用并发聚合而非串行给热点数据加短 TTL 缓存下游超时比首屏预算更紧准备好首屏超时兜底返回骨架页而非卡死。SSG构建期数据拉取要隔离、错峰、走缓存代理运行时后端基本无压但要防重新构建触发的突发。我的取舍我现在会把渲染模式写进后端接口的 SLA 设计里而不是等前端改版了再被动救火。凡是依赖 SSR 的首屏BFF 层一律按高并发聚合 缓存 紧超时三件套来设计CSR 的接口则重点防客户端突发小请求风暴SSG 重点防构建期批量拉取。渲染模式从来不只是前端的选型它直接决定了后端要承受的请求形态——这一点后端工程师越早想清楚越省事。顺手给 SSR 的下游加紧超时和降级前面说了SSR 首屏被最慢下游绑架。后端该做的是给每个 SSR 依赖的下游设比首屏预算更紧的超时且超时就返回降级数据而非卡死// 给 SSR 依赖的下游设独立超时并准备降级兜底 public MonoString homeWithTimeout(long userId) { MonoProductFeed feed productClient.getFeed(userId) .timeout(Duration.ofMillis(250)) // 1. 单个下游超时就断不等首屏预算耗尽 .onErrorResume(e - Mono.just(ProductFeed.EMPTY)); // 2. 降级成空数据首屏仍可渲染 MonoUserProfile profile userClient.getProfile(userId) .timeout(Duration.ofMillis(200)) .onErrorResume(e - Mono.just(UserProfile.EMPTY)); MonoRecommend 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 构建是不是在和线上抢同一套后端服务