PHP实现ETag缓存策略:从原理到实战,彻底解决浏览器重复下载

发布时间:2026/9/20 0:46:21
PHP实现ETag缓存策略:从原理到实战,彻底解决浏览器重复下载
上线久了之后你会发现一个特别扎心的现象服务器CPU和带宽看着都没满但页面就是感觉慢尤其是图片多、接口多的站点。后来我认真看了下浏览器开发者工具里的Network面板发现大量请求返回的是200响应体一个没少地传了回来——明明这些资源一点都没变。问题就出在缓存策略上。HTTP协议其实给过我们一套现成的验证机制就是ETag很多PHP项目要么没用要么用错了。这篇文章就围绕PHP方案里的ETag缓存策略把从原理到落地的完整细节拆开讲清楚重点解决“资源没变但浏览器还在重新下载”这个核心痛点适合正在优化PHP站点性能、或者想把HTTP缓存机制真正用起来的后端开发者参考。1. ETag缓存策略的整体设计为什么PHP项目优先考虑ETag1.1 HTTP缓存体系里ETag到底处在什么位置在讲ETag之前得先把HTTP缓存体系这盘棋摆清楚。HTTP缓存说白了就两件事这个东西能不能缓存以及缓存的东西还新不新鲜。“能不能缓存”由Cache-Control和Expires决定。Cache-Control里的max-age指令告诉浏览器这个资源在多少秒内可以直接用本地副本不用问服务器。比如Cache-Control: max-age3600一小时内再有请求直接走浏览器缓存完全不经过网络。“还新不新鲜”则由Last-Modified和ETag来验证。过了max-age之后浏览器不能直接用了它会发一个条件请求去问服务器“我这个缓存版本还新鲜吗”。如果服务器说“还新鲜”就返回304 Not Modified响应体为空只传输Header省流量省时间。如果服务器说“不新鲜了”就正常返回200和新的响应体。ETag全称Entity Tag实体标签是服务器给资源生成的一个唯一标识符。它就像快递包裹上的指纹哪怕内容有一丁点变化指纹都会变。浏览器在条件请求里通过If-None-Match头把上次拿到的ETag发回服务器服务器用当前资源的ETag一比对相等就返回304不相等就返回200新资源。在很多PHP项目里大家只管往Header里加Cache-Control却忽略了ETag这是很可惜的。没有ETag做校验max-age一过期所有请求都会退化成完整的200响应缓存的价值直接打对折。1.2 ETag与Last-Modified、Cache-Control的搭配逻辑这三者不是替代关系而是配合关系。单独用任何一个都不够完整配合起来才能覆盖完整的缓存生命周期。Last-Modified是资源的最后修改时间属于启发式缓存验证。它的问题在于精度只有秒级。如果资源在一秒内被修改了两次或者修改时间变了但内容其实没变它判断起来就不太准。更麻烦的是有些场景下Apache默认会用文件inode信息拼到ETag里导致多台服务器之间ETag不一致这个后面会细说。ETag精度更高内容变才变内容不变就不变。它既可以基于文件内容算哈希也可以基于业务版本号自定义非常灵活。Cache-Control负责“多久不用问服务器”ETag负责“问了之后怎么快速判断”。举个例子一个图片接口响应带上Cache-Control: max-age300和ETag: abc123。第五分钟内浏览器直接用本地缓存不发任何请求第五分钟后浏览器带着If-None-Match: abc123回来问服务器服务器一比对图片没变返回304Response Headers里没有响应体传输量小到可以忽略。所以实际项目里的标准配置应该是Cache-Control控制缓存时效ETag或者Last-Modified最好两个都上控制过期后的校验逻辑。只加Cache-Control的过了max-age又得全量下载只用ETag的每次请求都会发一个条件请求虽然流量省了但请求次数没降下来服务器还是要处理一次PHP请求。两者结合才既有长缓存又有精准校验。1.3 ETag方案相对其他缓存策略的优势相比其他缓存控制手段ETag有几个明显的优势第一省流量的效果直接。304响应没有响应体一个几十KB的JSON接口304后可能只传几百B的Header效果立竿见影。实测一个图片列表接口每天上千万次请求加了ETag之后回源带宽直接降了一个量级。第二动态内容也能缓存。CDN层面的缓存主要面向静态资源PHP层做接口缓存时响应体往往因人而异。ETag可以基于接口的核心数据算指纹比如“用户最后操作时间数据版本号”数据没变就复用缓存不用硬编码很短的max-age。第三实现成本低。PHP原生环境几十行代码就能实现不用引入Redis不用装扩展。后来引入Redis做分布式缓存时ETag也可以直接借用Redis里的版本号来生成完全不冲突。2. PHP中ETag的核心实现生成、校验与响应流程2.1 ETag的生成方式与强弱校验ETag生成方式没有标准答案PHP场景下常见的有三类基于文件内容哈希适合静态资源。用md5_file()或sha1_file()计算整个文件的哈希值内容变则ETag变。大文件计算哈希有一定IO开销建议配合文件修改时间做缓存。基于修改时间文件大小适合内容不太敏感的场景。比如$etag . filemtime($file) . - . filesize($file) . 。这种生成方式成本低但存在理论上的碰撞可能文件被改了但大小和秒级修改时间都没变的情况虽然罕见却不是不可能。基于业务版本号适合动态接口。比如$etag . md5(json_encode($data)) . 或者更高效的做法是给数据表加一个updated_at字段ETag就用最后更新时间复杂度O(1)。关于强弱校验需要特别注意。ETag标准里有个弱校验标头写法W/etag值。弱ETag允许语义等价的资源被视为相同强ETag要求字节级相同。PHP里如果接口返回JSON字段顺序变了、加了多余的空格强ETag都会认为内容不同返回200。有些时候我们不想让这种无意义的变化触发200就用弱ETag。实际项目里默认推荐强ETag除非你很清楚自己在放宽校验条件。2.2 PHP原生代码实现ETag的完整流程我直接贴一段带完整注释的代码这套逻辑在原生PHP环境里跑得很好核心流程就分为“生成ETag、输出时带上ETag、收到条件请求时判断返回304还是200”三件事。?php /** * 基于文件内容生成ETag * param string $filePath 文件路径 * return string */ function generateFileEtag($filePath) { $lastModified filemtime($filePath); $fileSize filesize($filePath); // 使用修改时间大小路径组合MD5后作为ETag避免直接暴露路径信息 return . md5($lastModified . - . $fileSize . - . $filePath) . ; } /** * 处理静态文件的ETag请求 * param string $filePath 文件路径 * return void */ function handleStaticFileWithEtag($filePath) { if (!file_exists($filePath)) { http_response_code(404); exit(Not Found); } $etag generateFileEtag($filePath); $lastModified gmdate(D, d M Y H:i:s, filemtime($filePath)) . GMT; // 输出缓存相关响应头 header(Cache-Control: public, max-age3600); header(ETag: . $etag); header(Last-Modified: . $lastModified); // 浏览器带回来的If-None-Match和If-Modified-Since $ifNoneMatch isset($_SERVER[HTTP_IF_NONE_MATCH]) ? trim($_SERVER[HTTP_IF_NONE_MATCH]) : ; $ifModifiedSince isset($_SERVER[HTTP_IF_MODIFIED_SINCE]) ? trim($_SERVER[HTTP_IF_MODIFIED_SINCE]) : ; // 只要ETag匹配就无条件返回304 if ($ifNoneMatch ! $ifNoneMatch $etag) { header(HTTP/1.1 304 Not Modified); exit; } // 如果HTTP_IF_NONE_MATCH没传退而求其次用Last-Modified做校验 if ($ifNoneMatch $ifModifiedSince ! $ifModifiedSince $lastModified) { header(HTTP/1.1 304 Not Modified); exit; } // 走到这一步说明资源变了正常输出文件 header(Content-Type: . mime_content_type($filePath)); header(Content-Length: . filesize($filePath)); readfile($filePath); } // 使用示例处理 uploads/test.jpg handleStaticFileWithEtag(__DIR__ . /uploads/test.jpg);这段代码里有几个容易踩坑的点需要单独说。exit的位置很关键。返回304之后一定要在设置完状态码后立刻exit否则PHP脚本会继续往下执行把文件内容输出来那样响应体就会带上文件内容304就变成“带body的304”这在部分浏览器里会导致奇怪的问题后面排查章节我会展开说。Header的输出去重要注意。如果项目里设置了SESSIONPHP会自动输出Cache-Control: private, no-store, must-revalidate之类的缓存头跟我们的ETag策略冲突。实测下来遇到Session的页面session_cache_limiter(public)要主动设置甚至直接用session_cache_limiter()关掉默认限制否则Cache-Control会被覆盖掉ETag存在也白搭。mime_content_type函数在部分环境里对某些扩展名识别不准比如.svg会返回text/plain最好自己维护一份MIME映射表。动态接口的ETag也类似生成ETag后判断逻辑可以复用。我习惯把公共逻辑抽成一个函数静态资源和动态接口都能用。/** * 动态接口使用的ETag生成 * param mixed $data 接口核心数据 * return string */ function generateApiEtag($data) { return . md5(json_encode($data)) . ; } // 示例用户信息接口 $userData getUserInfo($userId); $etag generateApiEtag($userData); header(ETag: . $etag); header(Cache-Control: private, max-age60); if (isset($_SERVER[HTTP_IF_NONE_MATCH]) trim($_SERVER[HTTP_IF_NONE_MATCH]) $etag) { header(HTTP/1.1 304 Not Modified); exit; } // 返回正常数据 echo json_encode([code 0, data $userData]);这里需要强调一个设计理念动态接口的ETag不要基于完整的页面数据去计算因为页面里可能有随机token、广告位内容这些每次都在变的东西。ETag要基于“真正的资源状态”计算。比如用户数据接口基于用户表里的updated_at字段计算文章详情接口基于文章的updated_at title content计算。那些随机性的、敏感性的内容不应该参与ETag计算否则ETag每次都不一样缓存策略直接失效。2.3 框架与中间件中的ETag集成思路在Laravel、ThinkPHP这类框架里全局给所有响应加ETag可以写在中间件里。核心逻辑是响应已经生成完毕在Output阶段接手。Laravel里实现方式非常简单?php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Http\Response; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class EtagMiddleware { public function handle(Request $request, Closure $next) { /** var Response $response */ $response $next($request); if (!$response instanceof SymfonyResponse) { return $response; } // 只处理GET请求 if (!$request-isMethod(GET)) { return $response; } $content $response-getContent(); $etag . md5($content) . ; // 客户端发来的If-None-Match $ifNoneMatch $request-header(If-None-Match); if ($ifNoneMatch trim($ifNoneMatch) $etag) { return response(, 304); } $response-headers-set(ETag, $etag); return $response; } }中间件的实现思路跟原生PHP一致只是把header读取变成了框架的Request方法把304返回变成了框架的response方法。注意要判断响应类型避免给所有响应都套上ETag比如流式下载、大文件导出这类场景就不适合。ThinkPHP的中文框架风格大家也很熟悉思路一样的关键是把ETag逻辑放到Response类处理之前这样可以在框架层统一控制避免业务代码里到处写缓存头。3. 实操过程静态资源与动态接口的ETag落地细节3.1 静态资源场景图片、CSS、JS的ETag处理静态资源的缓存优化是整个站点提速的基石。CSS、JS、图片占了页面传输流量的绝大部分把这块的缓存做对了页面加载速度直接起飞。先说我踩过的一个坑。最初项目里加ETag时用的是文件路径的inode属性。在一台服务器上跑得挺好后来做负载均衡上了三台服务器问题来了同一张图片在服务器A上的ETag是12345-678在服务器B上变成了54321-678。用户第一次请求打到A机器第二次条件请求打到了B机器B一比对ETag不一样觉得图片变了就返回了200完整图片。缓存完全失效。Apache默认的ETag配置是FileETag MTime Size不会带inode但某些编译版本或Nginx配置里可能会涉及inode。跨服务器部署时最稳妥的方式是去掉所有跟机器相关的因素直接用内容哈希。所以我后来给静态资源改了方案用文件修改时间文件大小文件路径做源再算MD5。这样三台服务器计算出来的ETag完全一致跨机器条件请求也能命中304。再一个实践细节是静态资源的ETag要考虑代理层。如果站点前面有Nginx做反向代理Nginx自己也有etag配置默认情况下Nginx会在响应头附加自己的ETag跟PHP生成的ETag叠加产生冲突。解决方式是确认Nginx配置里没有额外生成etag或者在Nginx层关了etag on把ETag交给PHP统一控制。还有一种做法是干脆反过来Nginx管静态文件PHP只管动态接口两边各自维护ETag互不干扰这样职责划分最清晰。图片这种二进制文件用md5_file()全文件哈希最安全但大图会占CPU。我的线上做法是给图片设置一个很长的Cache-Control: public, max-age86400配合ETag: 基于最后修改时间极端情况最多就是大家排队回源校验一下整体压力可控。3.2 动态接口场景JSON API的ETag处理动态接口是ETag发挥价值的主战场也是最容易搞坏的地方。先说一个概念动态接口的ETag不能拿响应的最终字符串去做MD5。为什么因为响应里往往带着动态生成的token、时间戳、随机字段。比如接口返回{code:0,time:1699999999}time每次都在变MD5出来的ETag自然每次都不一样缓存等于没有。正确做法是把ETag锚定在“业务数据的状态”上。比如用户信息接口用户表里有个updated_at字段那ETag就可以是md5($user[updated_at])。用户不修改资料updated_at不变ETag不变缓存就能命中。一旦用户改了头衔updated_at变了ETag变返回200新数据自然更新。数据列表接口也一样的逻辑。文章列表页的ETag可以基于最大文章的updated_at和列表总数计算$etag md5($latestUpdatedAt . - . $totalCount);这样新增文章、修改文章时ETag都会变其他情况稳定命中304。动态接口的Cache-Control要更谨慎。用户维度的数据Cache-Control: private, max-age60表示私有个缓存浏览器可以缓存但CDN不能缓存。非用户维度的公共数据比如城市列表、帮助文档Cache-Control: public, max-age300更合适。实际项目里动态接口如果处理不好304和正常响应的Header差异很容易出现“接口有时候返回数据有时候返回空”的诡异现象。我排查过的几个案例里两个最典型的一是返回304前没有exit导致后面还执行了echo二是渲染逻辑里有多余的header()输出把已输出的ETag又覆盖了。3.3 与Nginx/Apache服务器的配合要点PHP跑起来通常离不开Nginx或Apache服务器层的配置会直接影响ETag的最终表现。Nginx的fastcgi_cache或者proxy_cache如果在启用状态需要在cache key里算上$http_if_none_match。默认的cache key不考虑这个变量导致Nginx缓存里只有一组响应客户端发来的If-None-Match根本没参与key计算Nginx永远返回缓存的200。要命的是Nginx返回200后PHP的ETag逻辑根本不会执行一切都是Nginx缓存说了算。调整key的方式是在配置文件的cache_key里加上$http_if_none_matchfastcgi_cache_key $scheme$request_method$host$request_uri$http_if_none_match;Apache环境相对简单确认下FileETag配置。多台机器部署时建议统一配置为FileETag MTime Size去掉inode避免跨机器ETag不一致。Nginx官方向来是默认开启etag on的如果直接用Nginx托管静态文件它自己生成的ETag也够用。但如果你在PHP里做了额外的ETag处理两份ETag会同时存在还是后者覆盖前者取决于配置顺序和expires的情况。我的建议是明确职责边界要么Nginx管静态文件要么PHP管不要两者同时管同一份资源。4. 常见问题与排查技巧实录4.1 明明加了ETag却不生效这是最常见的坑。加了ETag头浏览器开发者工具里也看到了但每次请求都还是200304从来没出现过。排查思路第一步确认浏览器请求里有没有带If-None-Match。如果没带问题大概率出在Cache-Control上——浏览器认为这个响应根本不可缓存压根就没存自然也不会拿来验证。特别是PHP里操作了Session后PHP默认输出Cache-Control: no-store, no-cache这个头优先级很高会把你自己设置的Cache-Control给覆盖掉。解决办法在2.2里提过设置session_cache_limiter(public)或者干脆session_cache_limiter()关掉。第二步检查是不是有代理层把ETag头给剥了。有些CDN或者Nginx配置里会proxy_hide_header ETag或者对过期的缓存主动重新回源表现就是浏览器收到200但响应头里没有ETag。在Nginx里添加proxy_pass_header ETag;第三步检查响应头里是不是有多个ETag。比如Nginx生成了一个PHP又生成了一个浏览器拿到的值跟JS里看到的可能不一致。用curl -v仔细看一下返回的头信息。4.2 304响应了但响应体还在传输这个问题的表现很诡异状态码是304但Response里明显有内容页面渲染也正常。原因几乎都是服务端设置了304状态码后没有终止脚本执行。我遇到过一个Laravel项目接口返回204但下面还有一行echo $json。后来排查发现是框架某处先发了304但业务代码继续跑把数据也打了出来。虽然HTTP协议规定304不应该带body但有些服务器和客户端会容忍表现就是“304却有内容”而某些严格的客户端会直接报错。排查方法是抓包看响应结构。curl -i查看完整响应如果304后面还有Content-Length大于0基本就是没exit或没终止框架执行。在原生PHP里exit够用在框架里针对Response对象要return一个空的304响应让框架停止后续输出。另外注意一个高分险场景返回304前如果已经用header()发送了少量内容再设置304状态码可能已经太晚状态码改不掉了。所以ETag相关逻辑要放在所有输出之前尤其是PHP文件开头不能有任何非?php标签之外的空白字符。一些框架为了兼容性会开启输出缓冲这个问题不容易暴露但原生PHP脚本里必现。4.3 多台服务器ETag不一致多机部署同一URL从不同机器返回的ETag不同缓存命中率断崖式下跌。这个问题在4.1里提过Apache的FileETag配置我再补充两个更隐蔽的触发点。第一文件路径不同导致的ETag不一致。假设你用文件绝对路径参与MD5计算服务器A的项目放在/data/www/site服务器B的放在/app/www/site路径不一样ETag结果再MD5也还是不一样。所以参与哈希的内容要剥离机器相关因素。文件修改时间、大小、相对路径这些在代码库一致的前提下跨机器是稳定的。第二PHP的realpath()在某些环境返回的路径带符号链接的真实地址可能导致两台机器路径不一致。在生产环境尽量用统一的部署目录或者参与计算前对路径做一层规约。如果静态资源迁移到了对象存储或者CDN上那ETag直接由存储服务生成跨机器的问题自然消失。剩下来自不同源站的ETag是否会冲突取决于源站配置此时Nginx层做个统一处理会更稳妥。4.4 问题排查速查表现象可能原因快速排查方法加了ETag但请求始终200浏览器未缓存Cache-Control被Session覆盖curl -I看响应头确认Cache-Control是否含no-store304响应头但是有响应体PHP未在304后终止执行curl -i看Content-Length多机部署ETag变化频繁文件inode或绝对路径参与计算对比两台服务器计算ETag的源值ETag时有时无Nginx代理层剥掉或覆盖了响应头检查proxy_pass_header、etag相关配置部分客户端不识别ETag响应头格式错误ETag没带引号用curl -v确认ETag值是否形如abc123格式动态接口ETag每次变化响应用了当前时间戳或随机token参与计算检查ETag源值是否基于业务数据版本号排查这类问题我强烈建议一开始就养成用curl而不是浏览器的习惯。curl -I和curl -i能直接看到完整的请求响应头不会受浏览器缓存策略干扰。实际工作中95%的缓存问题一条curl命令Response Headers对比就能定位到大方向。写在最后从我自己的项目经验来看ETag这套东西本身不难难的是跟现有项目架构融合时不打架。Session的默认头部、Nginx的缓存层、多机部署的路径差异每个细节都可能让ETag策略全部落空。但只要把这些点捋顺ETag带来的带宽收益是完全可以量化的。我那个天天被图片带宽折磨的客户改造完当天回源带宽就从平均每秒20多MB降到了6MB左右响应体传输量降了七成。最后分享一个很实用的小技巧在做ETag改造时可以先只接一个流量较小的接口试点抓包对比改造前后的大包数量。等效果确认了再全面铺开这样既稳妥也能积累一套适合自己项目的ETag生成规范。缓存这事做得好的项目用户是感觉不到的但服务器的负载曲线会替你说话。