GraphQL 查询结果缓存粒度控制

解读

国内一线互联网公司在面试 PHP 后端时,常把“GraphQL 缓存”作为区分初中高级的分水岭。
面试官真正想确认的是:

  1. 你是否理解 GraphQL 的“单端点 + 细字段”带来的缓存失效复杂性;
  2. 能否用 PHP 生态(Nginx、Redis、OPcache、Laravel/Symfony)把缓存粒度拆到“字段级”而不是“接口级”;
  3. 是否具备在千万级 QPS 场景下做“多级缓存+增量失效”的工程经验。
    回答时切忌只谈“Redis 缓存查询结果”,必须给出“按字段哈希、按实体版本、按用户维度”三层粒度的具体策略,并说明在 PHP-FPM 下如何避免惊群、穿透、雪崩。

知识点

  1. GraphQL 查询结构 → 解析为 Field AST → 生成字段路径签名(queryHash + fieldPath)。
  2. 缓存维度:
    a. 整个 Query 级:最快,但失效粒度最粗;
    b. 实体级:按 __typename:id 做版本号,字段级依赖收集;
    c. 字段级:对计算昂贵字段单独缓存,支持 TTL 与标签双策略。
  3. PHP 实现要点:
    • 使用 Symfony Cache Component / Laravel Cache::tags() 实现“标签化失效”;
    • 在 DataLoader 层做“N+1”批量合并,同一进程内走 ArrayCache,跨进程走 Redis pipeline;
    • 利用 Redis Lua 脚本实现“校验式写入”(CAS),防止并发回源;
    • 对列表查询采用“游标+分段缓存”,避免大 Key;
    • 在 Nginx 层使用 proxy_cache_key $graphql_query_hash$field_hash,配合 Cache-Control: s-maxage=60, stale-while-revalidate=300 降低回源。
  4. 失效策略:
    • 订阅 MySQL binlog → 解析变更主键 → 失效 Redis 标签;
    • 使用 Hyperf/Swoole 的 Coroutine Redis 客户端,在 4 核 4G 容器内可支撑 3w+ 失效/秒;
    • 对热点 Key 加随机 TTL(±10%)防止雪崩。
  5. 安全与一致:
    • 对私有字段加入 userId 盐值,防止越权缓存;
    • 使用 GraphQL @cacheControl(scope: PRIVATE, maxAge: 0) 强制跳过共享缓存。

答案

“在 PHP 侧做 GraphQL 缓存,我分三层粒度:

  1. Query 级:对完全相同的请求用 SHA256(query+variables) 做 Key,Nginx 共享内存缓存 60 s,回源时由 PHP-FPM 生成。
  2. 实体级:在 Resolver 里把每个 __typename:id 注册到当前请求的依赖栈,PHP 代码示例:
    $tag = "product:123";
    $version = Redis::hget('entity_version', $tag) ?: 0;
    $cacheKey = "gql:field:product.name:v$version";
    $value = Cache::tags(['product:123'])->remember($cacheKey, 300, fn() => $expensiveValue);
    
    当商品 123 价格变更,消费 binlog 的异步任务执行 Cache::tags(['product:123'])->flush(),粒度精确到实体,不影响其他商品。
  3. 字段级:对秒杀库存这类高频可变字段,使用「短 TTL + 异步回源」策略,TTL 设为 1 s,并在 Redis 值中追加 lock:ttl=500ms 的游标,防止大量 PHP-FPM 进程同时回源。
    通过这三层,线上 98% 请求命中 Nginx,1.5% 命中 Redis 实体缓存,仅 0.5% 落到 MySQL,大促期间接口 P99 从 380 ms 降到 28 ms,且无雪崩。”

拓展思考

  1. 如果业务接入了 Federation,子服务之间如何传递“缓存依赖标签”?可在 Gateway 层把依赖列表放进 HTTP response header Cache-Tags,由边缘 CDN 统一失效。
  2. 在 Swoole 常驻进程模式下,如何避免 DataLoader 的“跨协程污染”?需给每个协程创建独立 DataLoader 实例,并用 Coroutine::getCid() 做池化 key。
  3. 当字段出现“千人千面”推荐时,传统标签失效失效成本过高,可改用「用户分群+布隆过滤器」做近似失效:先按用户标签哈希到 1024 个桶,再对桶级别做批量失效,牺牲少量一致性换取 10 倍性能提升。