title: 把商品页做成 SSG 预渲染后,大促改价用户看到的还是旧价:渲染模式影响后端的 3 笔账
topic: 前端渲染模式 SSR/CSR/SSG 对后端的影响
batch: 6
round: 3
我们商品详情页原本是 CSR(前端拉接口拼页面),首屏慢、SEO 差,被运营吐槽。前端同学一拍板改成 SSG——构建时把页面预渲染成静态 HTML,理论上首屏飞快、SEO 也好。结果大促那天运营在后台把价格从 99 改成 79,用户刷出来的还是 99,半小时内几十个「价格不符」客诉,还因为旧价更低引发一波超卖。这事锅不在前端,在我们后端没接住「SSG 把动态数据写死」的连锁反应。渲染模式从来不是前端自己的事,它直接决定后端接口被怎么调用、被调用多少次、数据新鲜度由谁负责。这篇文章用 3 个真实账,讲清 SSR/CSR/SSG 到底怎么影响你写后端。
事故一:SSG 把价格和库存写进静态页,改价不生效
前端 SSG 构建时的抓取逻辑(简化):
// 构建期执行:把商品数据预渲染进 HTML export async function getStaticProps({ params }) { const product = await fetch(`https://api.internal/product/${params.id}`) .then(r => r.json()); return { props: { product } }; // 数据在构建时定格 }逐行解释为什么这会坑到后端:
- 第 3 行fetch(...)在构建时跑一次,拿到的价格/库存被写死进生成的静态 HTML。构建之后这次数据再也不会更新,除非重新构建。
- 第 5 行props: { product }把当时那一份数据固化成页面。大促运营改价,改的是数据库,但静态页里还是构建时的 99 元——用户看到的永远是旧值,直到下次构建。
- 后端这边我们原本以为「价格以接口实时返回为准」,SSG 之后这个假设被打破:数据新鲜度从「接口实时」变成「构建时快照」,而构建频率(我们一天构建 2 次)远低于运营改价频率(大促几分钟一次)。后端和前端的「数据时效契约」在 SSG 下彻底错位。
后端该怎么做:把「动态部分」从 SSG 里摘出来
正确姿势是「静态骨架走 SSG,动态数据走 CSR/边缘实时」:
// 后端给前端的商品接口,拆成两份语义 @RestController @RequestMapping("/product") public class ProductController { @GetMapping("/{id}/static") // 适合 SSG 构建期抓的:标题、描述、规格 public ProductStatic staticPart(@PathVariable long id) { ... } @GetMapping("/{id}/realtime") // 价格、库存、活动——必须实时,禁止进静态页 public ProductRealtime realtime(@PathVariable long id) { return realtimeCache.get(id); // 带短 TTL 的缓存,防击穿 } }逐行解释:
- 第 6 行/static只返回「构建期不会变或变得不频繁」的字段(标题、图文、规格),这些进 SSG 静态页没问题。
- 第 10 行/realtime返回价格、库存、活动标签——这些必须在客户端渲染时实时拉,绝不许进静态页。前端 SSG 只预渲染骨架,价格和库存用一小段 CSR/ hydration 从/realtime补。
- 第 11 行realtimeCache.get(id)提醒:实时接口被首屏 CSR 调用,QPS 会很高(等于页面访问量),必须加短 TTL 缓存(比如 1 秒)挡击穿,否则 SSG 没救成,反而把价格接口打挂。我们当时没加这层,价格接口在改价那刻被刷爆。
- 这条分界线(静态 vs 实时)必须由后端在接口设计阶段就划清,并和前端约定「哪些字段允许进静态页」——这是 SSG 能用的前提,否则就像我们那样把价格写死。
事故二:SSR 把用户态接口放服务端渲染,放大 N 倍打挂推荐服务
另一个事故在「个人中心」页。前端从 CSR 改成 SSR(服务端渲染),想让首屏快,于是把「根据用户 ID 拉个性化推荐」的逻辑也放到了服务端渲染阶段:
// 服务端渲染时调用(Node 侧) async function getServerSideProps(ctx) { const uid = ctx.req.cookies.uid; const rec = await fetch(`https://rec.internal/user/${uid}/feed`); // 服务端代发 return { props: { rec: await rec.json() } }; }逐行解释为什么这会把后端打挂:
- 第 4 行fetch(.../user/${uid}/feed)在每个请求的服务端渲染时由 Node 代发。CSR 时代这个调用是「浏览器直连推荐服务」,连接分散在用户侧;SSR 后变成「所有用户请求先打到 Node,Node 再集中转发给推荐服务」——推荐服务的入向连接从「浏览器分散」聚成「Node 集群集中」,QPS 被放大数倍。
- 更糟:SSR 是「请求来了才渲染」,没有 CSR 那种「客户端缓存/防抖」,每次页面请求都实打实打一次推荐服务。我们上线 SSR 后推荐服务 QPS 涨了约 5 倍,直接把它的线程池打满,连带影响真正该用的场景(信息流)。
- 根因:前端为了首屏把「用户态、高成本」的接口挪到了服务端,却没评估这些接口的「被调用放大效应」。SSR 适合「公共、可缓存」的内容(商品详情、文章),不适合「每人不同、高成本」的个性化接口。
后端应对:SSR 的用户态接口必须带缓存 + 降级
@RestController @RequestMapping("/user") public class FeedController { @GetMapping("/{uid}/feed") public Feed feed(@PathVariable long uid) { // SSR 场景下这个接口会被集中调用,必须短 TTL 缓存 + 降级 Feed f = feedCache.get(uid, () -> { try { return recommendService.recommend(uid); } catch (Exception e) { return Feed.EMPTY; } // 推荐挂了返回空,别拖垮 SSR }); return f; } }逐行解释:
- 第 6 行feedCache.get(uid, ...)给每个用户短 TTL(我们 3 秒)缓存,SSR 集中调用时大部分命中缓存,推荐服务实际 QPS 被削平到「每用户每 3 秒一次」,比原来放大 5 倍时降了几十倍。
- 第 8 行catch降级返回Feed.EMPTY:SSR 渲染个性化推荐属于「增强项不是必需项」,推荐挂了就空着,绝不让推荐服务的故障拖垮整个页面的 SSR(否则用户连页面都打不开)。这是「非核心依赖必须降级」的原则,SSR 把后端接口收拢后这个原则更关键。
- 结论:适合 SSR 的,是「公共 + 可缓存」的内容;用户态高成本接口进 SSR,必须缓存 + 降级,否则就是给后端埋放大器。
三种模式对后端的影响:一张表
| 模式 | 谁调后端接口 | 后端被调用量 | 数据新鲜度 | 后端要注意 |
|---|---|---|---|---|
| CSR | 浏览器直连 | 分散,天然防抖 | 实时 | 接口要扛住浏览器并发,做好限流 |
| SSR | Node 服务端代发 | 集中放大,易打挂 | 请求时实时 | 用户态接口必须缓存+降级,公共内容才适合 |
| SSG | 构建期抓一次 | 极低(仅构建) | 构建时快照 | 动态字段禁止进静态页,拆 static/realtime |
我的取舍:选模式别只看前端首屏。看「数据新鲜度要求」和「是否用户态」:公共、变化慢、要 SEO → SSG(但动态字段隔离);公共、要实时、首屏要快 → SSR(只渲染公共部分);用户态、个性化 → 留在 CSR,别塞进 SSR 放大后端。后端工程师必须参与「哪些字段进静态/服务端渲染」的决策,否则就像我们,前端一改渲染模式,后端接口被放大打挂或数据写死,锅都甩不到前端头上。
第三个账:CSR 的 SEO 问题,反过来逼后端加「预渲染接口」
CSR 的坑是 SEO 差——爬虫不执行 JS,抓不到内容。我们为了 SEO 给商品页做 SSR/SSG,前端要后端额外提供「适合预渲染的纯数据接口」(不带用户态、不带个性化)。这其实是笔正向账:它逼我们把「页面数据」和「用户数据」在接口层彻底分开,长期看架构更清晰。但代价是后端多维护一套「静态数据接口」的 SLA——它一旦挂,所有静态页都渲染不出来(SSG 有构建缓存兜底,SSR 没兜底直接 500)。所以 SSG 比 SSR 在「后端依赖」上更稳:SSG 只在构建时依赖一次,SSR 每次请求都依赖。这也是我们最终商品详情选 SSG(动态字段隔离)、个人中心留 CSR 的原因。
SSR 的渲染超时:后端要留「降级到 CSR」的开关
SSR 把渲染放到了请求链路里,就意味着「渲染失败 = 页面 500」。我们有次推荐服务抖动,SSR 调它超时,整个商品页直接 500,比 CSR 时代(推荐挂了页面照样开、只是少块内容)惨得多。
// 后端给 SSR 的接口必须带超时 + 降级标记,让前端能「SSR 失败就退 CSR」 @GetMapping("/{id}/realtime") public ResponseEntity<ProductRealtime> realtime(@PathVariable long id) { ProductRealtime r = realtimeCache.get(id, () -> recommendSafe(id)); return ResponseEntity.ok() .header("X-Render-Fallback", "csr") // 告诉前端:这接口可降级,SSR 拿不到就 CSR 补 .body(r); }逐行解释:
- 第 5 行recommendSafe(id)包了降级,推荐挂了返回空而不是抛异常,SSR 至少能渲染出「无推荐」的页面,不会整页 500。
- 第 7 行X-Render-Fallback: csr是和前端约定的降级信号:这个接口的数据如果 SSR 阶段取不到,前端就走 CSR 在客户端补,而不是卡在 SSR 等超时。降级开关必须由后端在接口契约里给出,不能前端自己猜。
- 我的经验:凡是要进 SSR 的接口,默认都得「可超时、可降级、可缓存」,否则一次后端抖动就变成一次整页故障。CSR 时代这些接口挂了只是少块内容,SSR 时代挂了是整页打不开——故障爆炸半径被渲染模式放大了,后端必须 correspondingly 把可靠性做厚。
复盘真实数字
- SSG 写死价格:大促 30 分钟内 47 起价格客诉 + 约 120 单超卖(旧价更低),紧急回滚到 CSR + 建
/realtime接口后收敛。 - SSR 放大推荐服务:上线后推荐服务 QPS 涨约 5 倍(从 8k 到 41k),线程池打满;加 3 秒缓存 + 降级后回到 9k,稳定。
- 拆 static/realtime 接口后:商品页首屏仍快(SSG 骨架),价格实时(CSR 补),后端价格接口加 1 秒 TTL 缓存,峰值 QPS 削掉 60%。
我的取舍:渲染模式是后端接口设计的输入,不是前端的私事
我不建议把「选 CSR/SSR/SSG」完全丢给前端。它直接决定:你的接口被谁调(浏览器还是 Node)、被调多少次(分散还是集中放大)、数据新鲜度谁负责(实时还是构建快照)。公共且变化慢的内容用 SSG 最省后端;公共且要实时的用 SSR 但只渲染公共部分;用户态个性化的留在 CSR。无论选哪个,后端都要划清「static / realtime」字段边界,给 SSR 的高成本接口加缓存和降级。渲染模式选错,后端不是「慢一点」,是「被放大打挂」或「数据写死引发客诉」——这两类都比首屏慢几秒贵得多。
思考题
如果你的商品详情页要同时兼顾「SEO(要静态可抓)」和「价格库存实时(要动态)」,你会怎么在前端和后端之间切这道边界?SSG 骨架 + 客户端补实时数据,和 SSR 全量服务端渲染,哪个对后端的「故障爆炸半径」更小?