GraphQL凭借灵活的数据查询能力,已经成为很多前后端分离项目的标配方案。不过它的灵活性也给缓存带来了麻烦:传统HTTP缓存机制主要面向GET请求和静态URL,而GraphQL查询大多通过POST提交,每次请求体不同,URL却完全一样,导致Apache、Nginx这类反向代理以及各类CDN默认情况下都无法缓存GraphQL响应。这篇文章就来聊聊如何基于Apache的mod_cache模块,搭建一套可行的GraphQL查询缓存方案,包括配置细节、缓存键设计和失效策略。

为什么GraphQL接口默认缓存不生效
要理解缓存问题,得先看HTTP缓存的工作前提。Apache的mod_cache模块判断是否缓存一个响应,主要依据请求方法和响应头中的缓存指令。对于POST请求,HTTP/1.1规范本身就规定中间代理不能默认缓存,除非响应头明确带上Cache-Control并允许缓存,这在RFC 7234中有相应说明。而绝大多数GraphQL客户端(如Apollo Client)默认都用POST发送查询,这就直接把缓存的路堵死了一半。
另一半问题出在URL上。即使你把查询改成GET请求,GraphQL的标准做法是把整个查询语句、变量拼进URL参数,比如/graphql?query={user(id:1){name}}。这种URL每次都可能不同,虽然对缓存键来说是好事(不同查询天然区分),但也意味着URL长度容易超限,而且包含花括号等特殊字符需要严格编码。更隐蔽的是,很多网关或Apache配置会对查询字符串做归一化或截断处理,导致缓存键不稳定。
还有一个容易忽视的点:GraphQL服务端通常返回Cache-Control: no-store或者干脆不返回缓存头,尤其是查询里涉及用户身份时。如果后端不加区分地把所有响应都标记为不可缓存,代理层自然无能为力。所以整个缓存方案的核心思路是:让“可缓存的公共查询”和“不可缓存的私有查询”在代理层能够被区分开来。
基于Apache mod_cache的代理缓存配置实战
Apache提供了两个核心缓存模块:mod_cache负责缓存决策框架,mod_cache_disk(磁盘缓存)或mod_cache_socache(共享内存缓存)负责实际存储。配合mod_proxy做反向代理,就能把请求缓存下来。下面是一个典型配置,假设GraphQL后端服务运行在8000端口:
# 启用需要的模块
LoadModule cache_module modules/mod_cache.so
LoadModule cache_disk_module modules/mod_cache_disk.so
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http_module modules/mod_proxy_http.so
<VirtualHost *:80>
ServerName api.ippipp.com
# 开启磁盘缓存,指定缓存目录
CacheEnable disk "/graphql"
CacheRoot "/var/cache/apache2/mod_cache_disk"
CacheDirLevels 2
CacheDirLength 1
# 强制缓存规则(覆盖后端的no-store时需谨慎)
CacheHeader on
CacheDefaultExpire 3600
CacheMaxExpire 86400
CacheStorePrivate On
# 把请求代理到后端GraphQL服务
ProxyPass "/graphql" "http://127.0.0.1:8000/graphql"
ProxyPassReverse "/graphql" "http://127.0.0.1:8000/graphql"
# 对GET请求允许缓存查询字符串作为缓存键的一部分
CacheKeyBaseURL "http://api.ippipp.com/"
</VirtualHost>
这段配置里有几个参数值得展开说明。CacheEnable disk "/graphql"限定了只缓存/graphql路径下的响应,避免影响其他接口。CacheStorePrivate On允许缓存带有Cache-Control: private头的响应,这在GraphQL场景下很常用,但前提是你必须确保该路径不包含真正的用户私有数据,否则会造成严重的越权缓存泄露。
关于缓存键,mod_cache默认会使用完整的URL(包括查询字符串)作为键的一部分。对于GET形式的GraphQL查询,这意味着不同的查询参数会命中不同的缓存条目,正是我们想要的效果。但要注意CacheKeyBaseURL的设置:如果你的Apache前面还有负载均衡,不同后端Apache生成的缓存键可能不一致,统一设置这个指令可以保证键的稳定性。另外,如果后端返回了Vary头(比如Vary: Accept-Encoding),缓存会按Vary维度再分桶,这个行为是符合预期的,gzip和非gzip版本会各自缓存一份。
缓存键设计与失效策略的进阶方案
单纯依赖mod_cache的默认行为,只解决了“能不能缓存”的问题,实际生产中还需要更精细的缓存键设计。推荐的做法是:客户端或网关把查询语句做哈希,生成一个短的查询标识(query ID),URL变成/graphql?queryId=abc123&variables=...的形式。这样URL长度可控,缓存键也更加规整。持久化查询(Persisted Queries)方案天然就是这个思路:查询语句预先注册到服务端,客户端只发送哈希ID,既减小了请求体积,又让缓存命中率大幅提升。
缓存失效是另一个必须面对的问题。GraphQL的数据往往来自多个数据源,某个底层数据更新后,如何让代理层缓存的旧响应失效?有几种常见做法:第一种是设置较短的TTL,配合CacheDefaultExpire控制,适合对实时性要求一般的内容;第二种是主动清除,Apache的磁盘缓存可以通过htcacheclean工具配合自定义脚本删除指定条目,但按URL精确清除比较麻烦;第三种是版本化URL,数据更新时让后端返回新的查询ID或在响应头中递增版本号,旧缓存自然不再被命中。
最后提醒几个坑点。第一,POST请求即使配置了CacheStorePrivate,mod_cache在较新版本的Apache(2.4.x后期版本)之前默认也不缓存,如果确实需要缓存POST响应,务必确认Apache版本并显式配置,同时让后端返回明确的Cache-Control: max-age头。第二,注意不要把携带Authorization头的请求缓存进去,可以在配置中用<If>条件判断头信息,动态决定是否启用缓存。第三,务必监控缓存命中率,通过mod_cache的CacheDetailHeader on可以在响应头中观察命中情况,便于调优。综合来看,只要区分好公共查询和私有查询,Apache完全可以用较低的成本为GraphQL提供一层高效的代理缓存。