页面缓存与片段缓存:服务端渲染性能优化的关键实战

发布时间:2026/10/12 9:28:54
页面缓存与片段缓存:服务端渲染性能优化的关键实战
页面打开要800毫秒数据库压力还不小索引加了、SQL也优化过效果却很有限。这种情况我见过太多次。其实很多页面慢并不是某一条查询慢而是每个请求都在重复做同一件事查同样的数据、渲染同样的模板、拼出几乎一模一样的HTML。页面缓存和片段缓存就是冲着这部分重复劳动去的。这组方案我前后在好几个项目里落地过今天把设计思路、实操要点和踩过的坑一次性说清楚适合正在做Web服务端渲染、被页面响应时间折磨的开发者也适合想给项目做一次性能体检、不知道从哪里下手的团队参考。1. 页面缓存与片段缓存先弄清各自治什么病1.1 页面缓存把整个成品直接交付页面缓存的思路最直白一个请求过来页面经过路由匹配、数据库查询、模板渲染、组件拼装最终吐出一段完整的HTML。这个HTML是“成品”下次再有同样的请求来时直接把这盘成品端上去不再重新做菜。我习惯把渲染过程类比成做一桌菜。如果你每天中午都做同一桌菜不会每顿都从洗菜切菜开始而是提前做好、客人来了直接上桌。页面缓存就是这个逻辑。适合它的页面往往具备两个特征一是内容相对固定二是访问量大。典型的例子是文章详情页、商品详情页、公告页、首页的大部分区域。这些页面的数据可能一天才变几次但请求可能一分钟就来几百次每次都重新查库重渲染纯粹是浪费。页面缓存的好处是极致的简单。缓存键设计好、TTL设好、命中直接返回性能提升几乎是立竿见影。但它的短板也很明显只要页面里有一小块个性化或者实时变化的内容整页缓存基本就废了。比如电商详情页右上角挂着“购物车3”三个用户看到的数字完全不同你没法把同一份HTML交给所有人。浏览器的缓存也有类似问题带登录态的页面如果被浏览器缓存串号的隐患非常大。这就是片段缓存登场的原因。1.2 片段缓存把动态区挖出来单练片段缓存换个思路不缓存整页只缓存页面里相对静态的那几个区块。把页面想象成拼图有“文章正文”“侧边栏推荐”“底部导航”这些块也有“用户头像”“购物车角标”“实时价格”这些必须每次现算的块。片段缓存做的就是把静态块刻成模板存起来动态块保留每次渲染最后把两部分的输出拼在一起。这个方案解决的是一个非常实际的矛盾大部分页面不是全静态也不是全动态而是“大部分内容静态小部分内容动态”。如果因为那一个小角标放弃整页缓存等于因噎废食。把动态区域挖出来单独处理静态区域照常缓存性能收益和业务灵活性可以同时拿到。举一个我在某内容平台类项目里见过的典型结构一篇文章详情页文章标题、正文、作者信息、相关推荐这些占了页面95%的面积短时间内几乎不变但页面顶部有个登录状态条代码里要判断当前用户是否登录、显示什么昵称。表面上看这个页面“动态化程度高”实际上真正动态的只有顶部那一条。把正文等大区块做片段缓存顶部做实时渲染整页耗时能下来一大截用户侧又感知不到任何差异。1.3 什么时候该用哪一种很多人上来就问我页面缓存和片段缓存到底选哪个。我的答案永远是先看页面内容的变化频率再看改造成本两者其实是递进关系不是替代关系。如果你刚接手一个慢项目页面里几乎没有什么用户个性化内容所有人看到的HTML基本一致那我建议先做页面缓存这是性价比最高的一步一个统一入口改几百行代码就能搞定。如果页面里有一部分实时数据、但主体内容稳定那就得上片段缓存把那些可变的小区域隔离出去。两者的适用场景放一起对比更直观维度页面缓存片段缓存缓存粒度整页HTML页面内某个区块适用场景内容固定、高并发、无个性化主体静态 局部动态实现成本低统一入口即可中需要改造模板、设计占位机制失效粒度粗整页一起失效细按区块独立失效典型风险动态内容被错误缓存片段边界切错、动态回填遗漏还有一个容易被忽略的维度团队对系统的掌控力。页面缓存基本不挑业务形态只要入口统一就能做。片段缓存则要求你对模板结构非常熟知道哪块是静态的、哪个接口是动态的、数据更新的触发点在哪。如果这些不清楚建议先用慢日志和模板耗时统计找出关键路径再做决定。盲目上缓存等于给一套你不了解的系统加了一层黑箱出了问题反而更难排查。2. 页面缓存实操三层部署顺序与缓存键设计2.1 浏览器缓存层写清楚HTTP缓存头页面缓存的第一层其实在浏览器端不需要动服务器代码只需要把HTTP响应头写对。浏览器自带缓存机制用户第二次访问同一张页面时可以直接用本地副本连网络请求都省了。核心是在响应里带上Cache-Control和ETag或Last-Modified。一个典型的写法Cache-Control: public, max-age300 ETag: article-48201-v3 Last-Modified: Tue, 15 Apr 2025 10:00:00 GMTCache-Control: public, max-age300表示页面在5分钟内可以直接复用不需要向服务器确认ETag是一个内容指纹当max-age过期后浏览器会用条件请求向服务器确认内容是否有变服务器返回304就可以继续用本地缓存。Last-Modified是备选方案两者选一个体系用就好不必同时依赖。这里有一个非常容易踩的坑只要响应里带了Set-Cookie头浏览器基本不会缓存这个响应哪怕你写了Cache-Control: public也没用。也就是说登录态页面的HTML天然不适合靠浏览器缓存用户A的Cookie和HTML一旦被缓存下一个人可能直接读到上一人的内容。所以我的经验是浏览器缓存只放在完全公开、人人可见的页面上凡是带个性化内容的页面哪怕只在首屏顶部有一个用户名都别碰这层缓存。页面有登录态、有用户标识、有CSRF Token、有动态验证码的全部排除在浏览器缓存之外。2.2 服务端页面缓存层缓存键与过期时间怎么定浏览器缓存覆盖不了的页面再考虑服务端缓存。思路是渲染完成后把整段HTML字符串存进本地内存或集中式缓存下一次请求直接读出来返回。服务端页面缓存最核心的设计是缓存键。如果只拿URL当键很快会出问题。同一个URL可能因为不同设备返回不同布局、不同语言返回不同文案你不区分就会有人看到错乱内容。我实际项目里用的键通常包含四类信息规范化后的URL、语言区域、设备类型和发布版本。function buildPageKey(string $uri, array $extra []): string { // 归一化 URL去掉无关的 utm 参数固定参数顺序 $parts parse_url($uri); parse_str($parts[query] ?? , $query); unset($query[utm_source], $query[utm_medium], $query[from]); ksort($query); $normUri $parts[path] . ? . http_build_query($query); $locale $extra[locale] ?? get_current_locale(); $device $extra[device] ?? detect_device_type(); // mobile / desktop $version $extra[version] ?? get_release_version(); // 发版版本用于全量失效 return page: . md5($normUri . | . $locale . | . $device . | . $version); }把版本号放进缓存键是我特别推荐的做法。每次发布新版本时页面结构可能已经变化旧缓存里的HTML很可能带着过期的交互逻辑或者样式引用。如果不区分版本发版后用户会一直看到旧页面排查起来还以为是缓存服务出了问题。把版本号拼进键里发版即自动全量失效这是成本最低、最不容易出错的全量刷新方式。读缓存的流程也很直接$html cache_get($cacheKey); if ($html ! false) { header(X-Cache: HIT); echo $html; return; } $html renderPage(); cache_set($cacheKey, $html, 60); header(X-Cache: MISS); echo $html;这段代码里我特意加了X-Cache响应头它在联调和排查问题时非常有用。上线后先抓几个请求看HIT和MISS的分布立刻能判断缓存有没有真正接上、命中率大致是多少。建议实际项目里把这个头保留到稳定运行之后再摘掉别一开始就省掉这个观察手段。TTL设置上我的默认起点是60秒到300秒。太短了基本没效果太长了内容更新不及时。更重要的是TTL只应该是最后一道防线业务数据变化时要主动删除缓存键而不是干等着它过期。这一步放到第四章详细说。2.3 CDN缓存层什么时候值得上如果服务端缓存已经落地但峰值流量还是把源站压得喘不过气那就要考虑CDN缓存。CDN把HTML分发到边缘节点用户就近读取源站压力能减少一大部分。不是所有页面都适合上CDN。我的判断标准很简单页面是否完全公开、是否不携带用户身份。一旦响应里有登录态、个性化内容、用户标识这个页面就不能进CDN。我见过一个真实的教训某公司把带用户订单信息的页面也配了CDN缓存结果出现了用户A看到用户B订单的事故最后排查到根因就是CDN节点缓存了未设置Cache-Control: no-store的HTML。这个事故之后我把所有可能涉及用户隐私的页面统一加了Cache-Control: private, no-storeCDN层面对这类响应一律不缓存。CDN缓存需要源站配合暴露合适的缓存头。公开页面的服务端响应建议这样设置Cache-Control: public, s-maxage300s-maxage专门控制共享缓存也就是CDN节点的缓存时间它优先于max-age生效。源站修改后还需要主动通知CDN失效某个URL一般通过控制台或API操作否则TTL时间内边缘节点会一直用旧版本。CDN这层适合“锦上添花”不建议把缓存策略的核心逻辑寄托在CDN上源站该做的服务端缓存还是得做扎实。3. 片段缓存模板占位、动态注入与性能平衡3.1 划分片段的边界分析片段缓存的第一步也是最关键的一步是划边界。把页面拆成若干区块哪些区域该缓存、哪些区域必须实时渲染这个判断直接决定后面的效果和稳定性。我的划分标准有三条。第一内容更新频率低比如文章正文、帮助文档、导航菜单、页脚这种按天甚至按周变化的区域天然适合缓存。第二没有用户个体差异同一个区块对所有人展示的内容一致不存在张三是这个列表、李四是另一个列表的情况。第三渲染成本高这个区域后面跟着若干次数据库查询或远程调用缓存它能省下一大块耗时。三条同时满足基本可以确定是优质的缓存片段。反过来看到这几类区域要绕着走购物车数量、用户昵称、消息未读数、实时价格、随机推荐、动态生成的Token、验证码。这些每次请求都不同强行缓存会出大问题。尤其是Token和验证码如果被缓存进片段用户拿到的旧Token提交时必然失败表现就是“页面开了十分钟就报错”非常难排查。还有一个容易被忽略的点先量化再切分。我建议在模板渲染入口做一下耗时打点统计每个区块的渲染耗时占比。不用搞多复杂给每个区块包裹一层计时函数收集一天的数据就能清楚看到哪些区块又慢又稳是片段缓存的最佳候选。不要凭感觉划分数据会帮你避开很多自作聪明的选择。3.2 占位模板与片段回填的实现片段缓存的核心机制用一句话概括静态区块缓存起来动态区块正常渲染最后拼接成完整页面。工程上我习惯封装一个统一的片段渲染函数避免在每个模板里重复写缓存逻辑。function renderFragment(string $key, int $ttl, callable $builder): string { $cacheKey frag: . $key; $html cache_get($cacheKey); if ($html ! false) { return $html; } $html $builder(); cache_set($cacheKey, $html, $ttl); return $html; }模板里调用的时候把本来要渲染的那个区块包进这个函数里即可div classsidebar ?php echo renderFragment(sidebar:nav: . $locale, 600, function () use ($navData) { return renderTemplate(partials/nav, $navData); }); ? /div这里每次渲染侧边栏导航区块时会先尝试读取frag:sidebar:nav:zh-CN这个缓存命中就直接用没命中就正常渲染并把结果存进去。这个封装的好处是业务模板里看不出一堆if else缓存逻辑收敛在一个函数里后续想调整TTL、换存储、加日志统计都只需要改一处。动态区域的注入没有太多花活模板里仍然使用普通变量渲染即可。需要注意拼接顺序先渲染动态区域再渲染静态片段还是反过来取决于你的模板引擎。但从性能角度应该让“可能不命中的渲染”尽量晚执行。页面渲染时如果前半段先跑了好几个动态查询后半段才发现大部分片段都能命中那性能收益会被动态查询的耗时抵消掉不少。我在实践中更倾向于先拼静态片段、最后统一注入动态区域这样动态部分只在最后一步执行查询次数最小化。3.3 缓存粒度与命中率之间的权衡片段缓存切多细是个需要拿捏的问题。切得太粗比如把整篇文章加评论全包成一个片段那么任何一条新评论都会导致整个片段失效缓存命中率上不去。切得太细比如把段落级、句子级的东西都拆成片段模板会变成一堆零碎的缓存调用管理和排查成本剧增而且存储占用也会上升。我一般建议以“独立变更单位”作为片段边界。评论列表是一个独立片段文章正文是另一个独立片段这俩的变更频率完全不同拆开之后彼此不影响。侧边栏导航和页脚也是独立片段它们跟着版本走和文章内容完全无关合在一起反而互相拖累。一个实操经验是先按页面里的DOM区块划分再根据数据变更入口做合并变更入口指的是哪个操作会导致这个区域内容变化一个区域对应一套变更入口是最合适的粒度。缓存嵌套也要谨慎。有人会在页面缓存里再套片段缓存也就是整页HTML缓存住但里面某个片段又单独维护缓存。这个做法不是不行但它会让失效链路变得非常隐蔽。内层片段先失效了外层页面缓存还没失效用户看到的还是旧页面你明明更新了数据、也清理了内层缓存页面却没变化排查起来极其痛苦。如果决定要做嵌套必须建立一套统一的“底层数据变更 - 清理所有相关缓存键”的联动机制这个机制没做好之前建议先保持扁平结构一层缓存一层命出了问题至少能一眼看穿。4. 缓存一致性、失效策略与问题排查实录4.1 写后失效缓存刷新的触发链路缓存系统最怕的不是“慢”而是“脏”。TTL过期只能保证最终一致更新频繁的业务等不了那么久所以一定要建立主动失效链路业务数据一变立即删掉相关的缓存键让下一次请求拿到新数据。以文章系统为例更新文章的接口里至少要做两件事更新数据库、删除文章详情页缓存。function updateArticle(int $id, array $data): void { db_update(articles, $id, $data); cache_delete(page:article: . $id); cache_delete(frag:article_body: . $id); cache_delete(frag:latest_list); }这里有个要点更新文章时不仅要删详情页本身的缓存还可能要删列表页、首页聚合区的缓存。很多人只删了详情缓存隔天发现首页头条还是旧文章一脸茫然。凡是“可能展示这篇文章”的聚合区域都要在变更入口一起失效。顺序上我强烈建议先更新数据库再删缓存。如果先删缓存、后写库失败缓存里就没有数据了请求会全部打到数据库相当于缓存白做了。反过来先写库成功、再删缓存即使删缓存暂时失败顶多命中一次旧缓存还有重试和TTL兜底。真正的脏数据风险在于删缓存成功、写库失败这时候旧数据还在库里新请求却重新渲染了旧数据再缓存问题不大逻辑上最稳的还是写库成功后再删缓存删失败就做补偿重试。4.2 缓存击穿与雪崩的应对页面缓存上线后真正会咬人的是三个经典场景击穿、雪崩、穿透。它们表现不同但都是在极端情况下把数据库打垮的关键路径。击穿是指某个热点key在过期瞬间大量并发请求同时发现缓存里没有一起冲进数据库重建数据。最常见的是某个爆款商品页、某篇全网热文的详情页。解决办法有两个思路。一是热点key不设置过期时间改为后台任务定期刷新或者每次读取时检查是否临近过期、由单线程异步重建。二是加互斥锁保证只有一个请求真正执行重建其余请求等待这个结果$html cache_get($cacheKey); if ($html false) { if (cache_add(lock: . $cacheKey, 1, 10)) { // 拿到锁真正执行重建 $html buildExpensivePage($id); cache_set($cacheKey, $html, 60); cache_delete(lock: . $cacheKey); } else { // 没拿到锁说明有别的请求在重建短暂等待后重读 usleep(200000); $html cache_get($cacheKey); } }这里cache_add和cache_set的区别要理解cache_add只在键不存在时才能设置成功天然适合做分布式锁。这个方案实现简单、效果明显但也别滥用只有确认是热点key才值得这么处理。雪崩是大量key在同一时间集体过期数据库瞬间被压垮。比如你给所有缓存都设了相同的600秒TTL那么每分钟都会有一波过期高峰。解决办法是在TTL里加一个随机偏移让过期时间分散开比如600 rand(0, 120)。还可以部署多级缓存本地进程内缓存作为第一层分布式缓存作为第二层本地命中时连网络请求都省了即使第二层出了问题第一层也能扛住一部分压力。穿透是查询一个必然不存在的数据比如一个被删除的文章ID。因为缓存里没有这个key每次都会落到数据库。解决手段是缓存空值把不存在的ID也缓存一个特殊的空标记TTL设置得短一些比如30秒到60秒。这样至少不会让同一批恶意请求在短时间反复打穿数据库。4.3 常见问题速查与经验教训把这几年碰到的缓存问题整理成一个速查表基本覆盖了大部分排查场景现象可能原因处理建议缓存看似存在但请求仍然打到数据库缓存键不一致参数顺序、大小写、末尾斜杠未归一化统一键生成函数日志打印缓存键对比线上实际值数据明明更新了页面还是旧内容只删了详情缓存遗漏了列表/聚合缓存或者在做页面缓存嵌套梳理数据变更入口建立“变更触发全链路清缓存”的统一方法用户A看到用户B的信息带登录态/个性化内容被页面缓存或CDN缓存这类页面禁止整页缓存响应头加Cache-Control: private, no-store缓存节点内存持续上涨缓存键维度太多比如把时间戳、随机数拼进键里检查键生成逻辑去掉发散维度规范化前缀明明设置了TTL但过期后数据还是旧主动失效链路的删除操作失败没有重试机制删除缓存失败要记录日志并补偿重试比如放入延迟队列缓存命中率很高但页面还是慢慢的不是主体HTML而是某个动态片段里的远程调用对动态区域做进一步拆分或者给远程调用加短TTL缓存补充一个容易误判的场景页面里嵌了一个广告位或者一个随机推荐模块它会让整段HTML每次都不同。即使主体内容完全一样比较HTML字符串会发现每帧都不一样导致缓存命中率低得离谱。这种情况我的处理方式是在渲染时把随机部分单独隔离出来用一个局部占位符代替等整页拼装完成后再用JavaScript异步填充这样主体HTML就能稳定命中缓存。4.4 独家避坑心得最后分享几个常规文档里不会写的细节。第一个存进缓存的HTML建议直接存压缩后的gzip版本。页面文本压缩率非常高通常能省70%以上空间网络传输也更快。读取缓存时如果客户端支持gzip直接把压缩内容返回中间省掉一次压缩计算。前提是你要在缓存写入和读取侧保持一致别出现存的时候是压缩的、读的时候当成明文解了。第二个务必给缓存加监控不然你永远不知道它真实的命中率和延迟是多少。在缓存读写函数里打日志统计命中率、缓存读取耗时、重建耗时。我见过一个项目整体缓存命中率只有30%团队一直没发现因为页面看起来“还挺快”。等加上监控才发现大部分请求都在重建缓存所谓的性能优化形同虚设。第三个缓存键的命名一定要支持手工排查。键里包含业务含义比如page:article:123、frag:latest_list:zh-CN而不是一段无意义的7f3a1c9d8e2b4f5a6c7d8e9f0a1b2c3d。出了问题时能直接根据键名判断它属于哪个页面、哪个片段直接删掉验证。我一度为了省那几个字符用全哈希排查问题时要靠查日志反向定位痛苦不堪。关于这套方案我个人在实际操作中的体会是先别急着两套一起上老老实实从页面缓存开始把命中率调到90%以上再根据业务情况决定要不要引入片段缓存。页面缓存改动集中、收益直观、风险可控适合作为切入的第一刀片段缓存则是对页面缓存覆盖不到的动态区域做补充。最后再建议你一定把X-Cache头留在响应里观察一周它是判断缓存是否真正生效、键设计是否合理最快的手段。等系统稳定了、数据佐证了再考虑去掉还是保留那时候你的判断才是有依据的。