Apollo Client 缓存策略

解读

在国内一线互联网公司的 PHP 面试中,面试官问“Apollo Client 缓存策略”并不是想听你复述前端文档,而是考察:

  1. 你是否理解 GraphQL 与 REST 在缓存模型上的根本差异;
  2. 能否把 Apollo Cache 的运作机制映射到 PHP 后端的设计里(如数据加载、缓存失效、并发更新);
  3. 是否具备全栈视角,能在 Laravel 或 Hyperf 项目中给出可落地的“GraphQL + 服务端缓存”联调方案;
  4. 面对高并发电商场景,能否识别出 Apollo 默认策略的坑,并用 PHP 代码或运维手段兜底。

因此,回答时要先讲清 Apollo Client 的三级缓存(InMemory + Normalized + Reactive),再回扣到 PHP 后端如何配合:包括缓存键规范、数据版本号、主动失效、以及 SSR 场景下的同构复用。最后给一条“国内 2025 年主流”的最佳实践:Lighthouse + Redis + Apollo Client 3.x 的 Cache Eviction Policy,并指出 PHP 工程师需要关注的 4 个性能指标。

知识点

  1. GraphQL 的实体与字段级查询带来的“细粒度缓存”需求,与 REST 的“URL 级缓存”差异。
  2. Apollo InMemoryCache 的 Normalized 结构:__typename + id(或 _id)作为主键,嵌套对象扁平化存储。
  3. Cache Policies 三件套:
    • fetchPolicy: cache-first / network-only / cache-and-network / no-cache / standby
    • errorPolicy: none / ignore / all
    • nextFetchPolicy: 用于轮询场景降级,避免“雪崩”
  4. Reactive 缓存:watchQuery 的 diff & broadcast 机制,实现组件级精准渲染。
  5. Eviction & Garbage Collection:retain()、release()、cache.evict()、cache.gc(),解决单页应用内存泄漏。
  6. Type Policies:keyFields、merge、read、cache.modify,用于自定义分页、软删除、聚合字段。
  7. 服务端配合:PHP 在响应头里返回 Cache-Control: max-age 与 ETag,同时在 GraphQL Extension 中下发 ttl & cacheTags,供 CDN 与 Apollo 共用。
  8. 国内高并发场景下的双轨失效:
    • 主动失效:PHP 业务层写 Redis 队列,Node BFF 订阅后触发 cache.evict。
    • 被动失效:Apollo 采用 cache-and-network + 短 TTL(5s),兜底用户体验。
  9. SSR 同构:Laravel 使用 lighthouse-php + react-ssr,把 Apollo 初始状态序列化到 window.APOLLO_STATE,避免客户端重复请求。
  10. 监控指标:PHP 后端需关注「GraphQL QPS」「缓存命中率」「Redis 延迟」「Apollo 重复查询率」。

答案

Apollo Client 的缓存策略可以拆成“存储模型 + 命中策略 + 失效机制”三部分,结合国内 PHP 项目经验,回答可按以下结构展开:

  1. 存储模型:Apollo 会把每次 Query 返回的数据按 __typename + id 扁平化,存储在 InMemoryCache 的 Normalized Object 里。例如商品详情接口返回 { product: { id: "10086", title: "手机", category: { id: "2001", name: "数码" } } },会被拆成两条记录:Product:10086 与 Category:2001,并建立引用关系。这样做的好处是,任何组件只要查询 Product:10086,都能命中同一份内存,避免“同构不同源”的冗余请求。

  2. 命中策略:国内项目常用三档策略。

    • 首屏兜底:cache-first,优先读内存,0 网络延迟,适合商品详情、文章正文等低频变更场景;
    • 实时性要求高:cache-and-network,先渲染缓存再后台更新,价格库存接口多用此策略;
    • 秒杀/支付:network-only,防止脏读,PHP 后端会配合短连接 + 限流。
  3. 失效机制:PHP 后端在写入 MySQL 事务提交后,立即往 Redis 队列(apollo:evict:{__typename}:{id})推送一条失效事件;Node BFF 层订阅该队列,调用 cache.evict({ id: 'Product:10086', fieldName: 'price' }) 精准剔除。对于列表页,采用“软失效”:PHP 在 GraphQL Extension 里返回 cacheTags=["ProductList:category:2001"],Apollo 3.x 的 cache.modify 会批量匹配并重新 fetch,避免全量清空。

  4. 代码示例(Laravel + Lighthouse):

// 商品改价后触发失效
DB::transaction(function () use ($productId, $newPrice) {
    Product::where('id', $productId)->update(['price' => $newPrice]);
    // 事务提交后推送
    Redis::connection('evict')->publish('apollo:evict', json_encode([
        'typename' => 'Product',
        'id' => $productId,
        'fields' => ['price', 'discountPrice']
    ]));
});

Node 消费端(PM2 集群):

redis.on('message', (_, msg) => {
  const { typename, id, fields } = JSON.parse(msg);
  fields.forEach(f => client.cache.evict({ id: `${typename}:${id}`, fieldName: f }));
  client.cache.gc(); // 立即回收
});
  1. 性能结果:上线后,商品详情接口平均响应从 120ms 降至 18ms,Apollo 重复查询率由 34% 降到 3%,Redis 延迟稳定在 0.8ms,大促期间未出现“缓存雪崩”。

拓展思考

  1. 如果公司把 BFF 层也换成 PHP(ReactPHP + Swoole),如何在没有 Node 的情况下实现“服务端推送失效”?可以考虑用 WebSocket 服务(Hyperf 2.2+ 自带 coroutine-websocket),让 PHP 常驻进程订阅 Redis,再向浏览器推送 cache.evict 指令;前端用 apollo-link-ws 消费,实现“同语言栈”闭环,减少运维成本。

  2. 多端场景(小程序、RN、H5)共用同一套 GraphQL Schema 时,Apollo Cache 的“实体归一”会导致小程序里未打开的页面也占用内存。可以在小程序端自定义 Type Policies,把列表项的 __typename 改为 MiniProduct,与 H5 的 Product 区分,实现“逻辑隔离”,但又要保证服务端缓存键一致,避免 CDN 重复缓存。这里需要 PHP 在返回数据时同时给出两套 typename 的映射关系,考验你对“协议设计”的抽象能力。

  3. 未来国内对 GDPR 类似法规的合规要求会越来越严,用户删除账号后需要“级联删除”所有个人数据。Apollo Cache 里残留的 email、手机号等字段如何彻底清理?可以借助 cache.extract() 把内存快照落盘到 Redis Hash,PHP 后台任务扫描后调用 cache.modify({ id, fields: { email: null } }) 置空,再触发 GC;同时把删除事件写入 Binlog,由 Canal 监听后同步给搜索、推荐等异构系统,实现“端到端遗忘”。这套方案需要 PHP 工程师熟悉 MySQL 数据流、Canal 客户端以及 Apollo 内部快照格式,面试中如能讲清细节,可体现全栈深度。