陌上人如玉
公子世无双

7.5 缓存、失效与重试

7.5 缓存、失效与重试

一句话:缓存的核心是「这份数据什么时候算新鲜、什么时候该重新拉、没人用的时候什么时候回收」——对应 staleTime(新鲜期)、失效(invalidateQueries)与 gcTime(回收时间);重试则要「只重试值得重试的错误」。

一、缓存的三个状态

一个查询的缓存有三种状态:

① fresh(新鲜)
   · 在 staleTime 内
   · 使用它【不会】触发新的请求
   · 是「最省钱」的状态

② stale(过期但仍保留)
   · 超过 staleTime,但缓存还在
   · 【先用缓存渲染】(用户立刻看到内容)
   · 同时【后台重新请求】(revalidate)→ 拿到新数据再更新界面

③ inactive(无人订阅)
   · 没有组件在用它(都卸载了)
   · 开始计时 gcTime,超时后【从缓存中删除】

⭐ 这是「stale-while-revalidate(SWR)」策略:
   先给用户旧数据(快),再在后台更新(新)。
时间轴示意(staleTime = 60s,gcTime = 5min):

t=0      组件挂载 → 请求 → 拿到数据(fresh 开始)
t=0~60s  组件挂载/切回标签页 → 直接用缓存,不发请求
t=60s    数据变成 stale(但仍在缓存里)
t=70s    组件重新挂载 → 先用旧数据显示 → 后台重新请求 → 更新
t=120s   组件卸载 → 进入 inactive 状态,gcTime 倒计时开始
t=420s   gcTime 到期 → 缓存被删除

二、staleTime 与 gcTime

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,

  staleTime: 60_000,        // ⭐ 新鲜期:1 分钟内不发新请求
  gcTime: 5 * 60_000,       // ⭐ 无订阅后保留 5 分钟(默认值)
});
配置 含义 影响
staleTime 数据「新鲜」的时长 越长 → 请求越少、数据越可能旧
gcTime 无订阅后缓存的保留时长 越长 → 内存占用越多、返回页面越快
✅ 按「数据变化频率」设置 staleTime:

· 几乎不变(配置、枚举、字典)  →  staleTime: Infinity(或很长)
· 偶尔变化(用户资料、详情)    →  staleTime: 5 * 60_000(5 分钟)
· 经常变化(列表、通知)        →  staleTime: 30_000(30 秒)
· 实时性要求高(库存、价格)    →  staleTime: 0 + refetchInterval
· 只需要一次(如「字典」)      →  staleTime: Infinity + gcTime: Infinity
⚠️ 为什么默认 staleTime 是 0?
   TanStack Query 的默认假设是「数据随时可能过期」——
   所以「组件挂载」时会立即重新请求(先用缓存渲染,再后台更新)。
   如果你的接口「变化很少」,应该显式调大 staleTime(否则会请求过多)。

三、失效的三种方式

const queryClient = useQueryClient();

// ① invalidateQueries:标记失效 + 重新获取(最常用)
queryClient.invalidateQueries({ queryKey: ['users'] });
// ⭐ 前缀匹配:['users'] 会失效 ['users'] / ['users', 1] / ['users', { page: 1 }]

// 精确匹配某个查询
queryClient.invalidateQueries({ queryKey: ['users', 1], exact: true });

// 只标记失效但不立即重新获取(下次使用时才拉)
queryClient.invalidateQueries({ queryKey: ['users'], refetchType: 'none' });

// ② setQueryData:直接写入缓存(乐观更新 / 局部更新)
queryClient.setQueryData<User[]>(['users'], (old = []) => [...old, newUser]);
queryClient.setQueryData(['users', id], newUser);

// ③ removeQueries:删除缓存(不重新请求)
queryClient.removeQueries({ queryKey: ['users', id] });
// ⚠️ 与 invalidate 的区别:remove 是「删掉」,下次用时会重新请求(像从没加载过)
方式 缓存 是否重新请求 用途
invalidateQueries 标记 stale + 保留 ✅(默认立即) mutation 后刷新列表(最常用)
setQueryData 直接覆盖 ❌ 乐观更新、已知新数据
removeQueries 删除 下次用时 数据「不应保留」(如登出)
resetQueries 重置为初始态 ✅ 需要「彻底重来」
// ✅ 失效的粒度:用「前缀」批量失效
// 所有用户相关查询(列表 + 详情)
queryClient.invalidateQueries({ queryKey: ['users'] });

// 只有某个用户的详情
queryClient.invalidateQueries({ queryKey: ['users', id] });
// ✅ mutation 里的标准用法(见 7.4)
useMutation({
  mutationFn: updateUser,
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['users'] });   // ⭐ 无论成败都刷新
  },
});

四、自动重新验证的触发点

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnMount: true,          // 组件挂载时(数据 stale 才请求)
      refetchOnWindowFocus: true,    // 切回标签页时
      refetchOnReconnect: true,      // 网络恢复时
      refetchInterval: false,        // 定时轮询(默认关闭)
      refetchIntervalInBackground: false,  // 后台(标签页不可见)是否继续轮询
    },
  },
});
触发点 默认 说明
组件挂载 ✅ 数据 stale 才会发请求(fresh 时用缓存)
窗口聚焦 ✅ 用户切回标签页 → 数据可能已变
网络重连 ✅ 断网期间的数据可能已过期
定时轮询 ❌ 用 refetchInterval 显式开启
手动 — refetch()
// ✅ 实时数据的轮询写法
useQuery({
  queryKey: ['notifications'],
  queryFn: fetchNotifications,
  refetchInterval: 30_000,           // 每 30 秒
  refetchIntervalInBackground: false, // 标签页不可见时暂停(省资源)
  staleTime: 0,                       // 每次都要最新
});

// ✅ 只想要「手动刷新」的场景(如后台管理列表)
useQuery({
  queryKey: ['orders'],
  queryFn: fetchOrders,
  staleTime: Infinity,                // ⭐ 永不自动刷新
  refetchOnWindowFocus: false,
  refetchOnMount: false,
  // 只在 mutation 后 invalidate,或用户手动 refetch
});

五、重试策略

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,

  retry: 3,                           // 失败重试 3 次(默认 3)
  retryDelay: (attemptIndex) =>       // ⭐ 指数退避
    Math.min(1000 * 2 ** attemptIndex, 30_000),
  // 0s → 1s → 2s → 4s(上限 30s)
});
// ✅ 「只重试值得重试的错误」——这是最重要的配置
useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  retry: (failureCount, error) => {
    const status = (error as { status?: number }).status;

    // ❌ 4xx:参数错误、无权限、不存在 → 重试也不会成功
    if (status && status >= 400 && status < 500) return false;

    // ✅ 网络错误(无 status)与 5xx → 可以重试
    return failureCount < 3;
  },
  retryDelay: (i) => Math.min(1000 * 2 ** i, 30_000),
});
错误类型 是否重试 理由
网络错误(断网/DNS) ✅ 可能恢复
超时 ✅ 可能恢复
429 限流 ✅(但要尊重 Retry-After) 过一会儿能成功
5xx 服务端错误 ✅ 可能是瞬时故障
400 参数错误 ❌ 重试结果一样
401 未认证 ❌ 需要重新登录(由拦截器处理)
403 无权限 ❌ 重试也没权限
404 不存在 ❌ 资源本身没有了
⚠️ 三个重试相关的注意点:

① 幂等性:只有「GET 类查询」适合自动重试;
   mutation(写操作)默认【不重试】(可能重复下单/重复创建)
   → 如果接口设计为幂等(带幂等键),才可以开启 mutation 的 retry

② 退避:重试间隔要「指数增长 + 加抖动」,避免雪崩
   (大量客户端同时重试会打垮服务端)

③ 上限:要有次数上限与总时长上限,否则可能一直重试

六、实践配置建议

// ✅ 一个较合理的默认配置
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,                 // 默认 1 分钟(按业务调整)
      gcTime: 10 * 60_000,               // 保留 10 分钟
      refetchOnWindowFocus: true,        // 合理(列表数据会变)
      refetchOnReconnect: true,
      retry: (count, error) => {
        const status = (error as { status?: number }).status;
        if (status && status >= 400 && status < 500) return false;
        return count < 2;
      },
      retryDelay: (i) => Math.min(1000 * 2 ** i, 30_000),
      // ⭐ 全局错误处理(可选;也可以在每个 query 里处理)
    },
    mutations: {
      retry: 0,                          // 写操作默认不重试
    },
  },
});
// ✅ 按「数据特征」分类配置
// src/api/queries.ts
export const queryKeys = {
  users: {
    all: ['users'] as const,
    list: (params: ListParams) => ['users', 'list', params] as const,
    detail: (id: string) => ['users', 'detail', id] as const,
  },
  dict: {
    all: ['dict'] as const,
    item: (type: string) => ['dict', type] as const,
  },
} as const;

// 字典类型:永不自动过期
export function useDict(type: string) {
  return useQuery({
    queryKey: queryKeys.dict.item(type),
    queryFn: () => fetchDict(type),
    staleTime: Infinity,
    gcTime: Infinity,
  });
}

// 列表:30 秒新鲜期 + 聚焦重验证
export function useUserList(params: ListParams) {
  return useQuery({
    queryKey: queryKeys.users.list(params),
    queryFn: () => fetchUsers(params),
    staleTime: 30_000,
  });
}

七、常见坑

// ① 把 staleTime 当「缓存时间」(其实是「新鲜期」)
staleTime: 60_000;                    // ⚠️ 不是「缓存 60 秒就删除」

// ② staleTime 设得太大导致数据「很旧还显示」
// ③ 忘了 invalidate → mutation 后列表不更新
// ④ invalidate 的 key 粒度太粗(一次刷新所有列表)
queryClient.invalidateQueries({ queryKey: ['users'] });   // ⚠️ 所有 users 查询都刷
// ✅ 用更具体的 key

// ⑤ 对 mutation 开启自动重试(可能重复创建)
useMutation({ retry: 3 });            // ⚠️ 除非接口幂等

// ⑥ 对所有错误都重试(4xx 永远失败)
// ⑦ 重试间隔没有退避(雪崩风险)
// ⑧ 用 removeQueries 代替 invalidate(语义不同)
// ⑨ 用 setQueryData「手写」本应 invalidate 的逻辑(容易漏字段)
// ⑩ 忘记「登出要清缓存」(下个用户看到上个用户的数据)
authStore.clear();
// ✅ 还要 queryClient.clear();
// ⑪ 把 staleTime 设成 Infinity 但忘了「数据会变」(用户看到永久旧数据)
// ⑫ 在「轮询」时没关 refetchIntervalInBackground(后台标签页白耗资源)
// ⑬ 认为「invalidate 会立刻删除缓存」(其实是「标记过期 + 后台刷新」)
// ⑭ 用「时间」而不是「事件」来失效(应该 mutation 后 invalidate)
// ⑮ 在 SSR 里忘了「把预取的数据脱水给客户端」(导致客户端再请求一次)
// ⑩ 的完整处理
function useLogout() {
  const queryClient = useQueryClient();

  return useCallback(() => {
    authStore.getState().clear();
    queryClient.clear();               // ⭐ 清掉所有缓存(避免数据串用户)
    navigate('/login', { replace: true });
  }, [queryClient, navigate]);
}

八、面试延伸

  1. 「staleTime 和 gcTime 有什么区别?」
    staleTime 是「新鲜期」——数据在新鲜期内不会因为「组件挂载/窗口聚焦/重新连接」而重新请求(直接用缓存渲染);超过它数据就变成 stale(会「先用旧数据渲染、同时后台重新请求」)。gcTime 是「回收时间」——当查询没有任何组件订阅时,缓存还能保留多久(超时被删除)。关键词:staleTime 决定「要不要重新请求」,gcTime 决定「什么时候丢掉缓存」。

  2. 「默认的 staleTime 是多少?为什么?」
    默认是 0——也就是「数据一拿到就过期」。TanStack Query 的假设是「服务端数据随时可能变化」,所以「组件挂载」时会立即重新请求(先用缓存渲染,再在后台更新,即 stale-while-revalidate)。如果你的接口「变化很少」(字典、枚举、配置),应该显式把 staleTime 调大(甚至 Infinity),否则会产生大量无意义的请求。

  3. 「invalidateQueries 做了什么?」
    两件事:① 把匹配 queryKey 的所有查询标记为 stale(过期);② 默认立即重新获取(refetchType: 'active' 只重新获取「有组件在用的」)。它支持前缀匹配(['users'] 会命中 ['users', 1]、['users', { page: 1 }]),也可以用 exact: true 精确匹配。它是 mutation 之后刷新数据的标准做法(配合 onSettled),比手写 setQueryData 更简单可靠。

  4. 「什么时候会『自动重新请求』?」
    四个默认触发点:① 组件挂载(且数据是 stale);② 窗口重新聚焦(用户切回标签页);③ 网络重新连接;④ 依赖的 queryKey 变化(参数变了)。另外还可以显式开启 refetchInterval(轮询),或调用 refetch()。实践上「聚焦重验证」和「重连重验证」很有价值(用户回到页面时数据自动是最新的),但对「不需要实时的列表」可以关掉以减少请求。

  5. 「重试策略应该怎么设计?」
    三个原则:① 只重试值得重试的错误——网络错误、超时、429、5xx 可以重试;4xx(参数、权限、不存在)不该重试(结果不会变)。② 指数退避 + 抖动——retryDelay: i => Math.min(1000 * 2 ** i, 30000),避免「大量客户端同时重试打垮服务端」。③ 有上限——次数与总时长都要限制(默认 3 次)。此外 mutation 默认不重试(避免重复写),除非接口幂等。

  6. 「为什么不重试 mutation?」
    因为 mutation 是「写操作」——重试可能导致重复创建/重复下单/重复扣款。HTTP 语义上 GET 是幂等的(重试安全),POST 不是。只有当你确认「接口是幂等的」(如带幂等键 idempotency-key、或业务上是 PUT 全量替换)时,才可以开启 mutation 的重试。所以 TanStack Query 的默认是「queries.retry: 3、mutations.retry: 0」。

  7. 「invalidateQueries 和 setQueryData 怎么选?」
    判断标准是「你是否已经知道新数据」:① 不知道(只改了服务端,本地没有完整的新数据)→ 用 invalidateQueries(让库去重新拉取,简单可靠、不会漏字段);② 知道(比如乐观更新时你已经构造了「提交后的样子」)→ 用 setQueryData(直接写入缓存,没有网络往返,体验更快)。实践中常见的是「乐观更新时用 setQueryData,onSettled 里再用 invalidateQueries 兜底」。

  8. 「登出时为什么要清缓存?」
    因为缓存里可能存着上一个用户的私有数据(列表、详情、消息)。如果只清「鉴权状态」(token/user)而不清缓存,那么下一个用户登录后,某些查询会在「缓存新鲜期」内直接返回上一个用户的数据——这是很严重的隐私与安全问题。所以登出时必须:① 清鉴权状态;② queryClient.clear()(清所有查询缓存);③ 清 localStorage 里的用户相关数据;④ 清理其它用户相关的全局状态(如购物车)。

一句话速记

缓存有三个状态「fresh(新鲜期内不发请求)→ stale(先用旧数据渲染 + 后台刷新)→ inactive(无订阅后按 gcTime 回收)」,这就是 stale-while-revalidate;staleTime 决定「要不要重新请求」(默认 **0,所以「字典/枚举」要显式调大甚至 Infinity)、gcTime 决定「什么时候丢缓存」(默认 5 分钟);失效三方式「invalidateQueries(标记过期 + 后台刷新,最常用、支持前缀匹配)、setQueryData(直接写缓存,用于乐观更新)、removeQueries(删除)」;自动重验证触发点是「挂载 / 窗口聚焦 / 网络重连 / key 变化」(轮询要显式开 refetchInterval);重试只针对「网络错误 / 超时 / 429 / 5xx」,4xx 不重试,并用指数退避 + 上限;mutation 默认不重试(除非幂等);登出必须 queryClient.clear()**(否则串用户数据)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 7.5 缓存、失效与重试

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫