缓存、重试与请求去重

缓存、重试与请求去重

一个页面里有两个组件读取同一份用户信息,网络抖动时又各自重试,很容易看到几次内容相同的请求。直觉上可以加一个“请求优化层”,但缓存、去重和重试解决的其实是三个不同问题。

缓存复用过去已经完成的结果,请求去重合并当前正在进行的相同请求,重试则在一次请求失败后重新尝试。把三者混成一个开关,通常会同时得到过期数据、无法取消的共享请求和不断放大的故障流量。

查询键决定什么算同一份数据

缓存和去重都需要先回答“这两个请求是否相同”。只比较 URL 往往不够,查询参数、分页游标、语言、租户和当前用户都可能改变结果。查询键漏掉任何一个维度,都会让不该共享的数据共用一份缓存。

查询键也要稳定。对象字段顺序不同但含义相同,不应该产生两条缓存;无法稳定序列化的函数和临时对象则不适合作为身份。更实用的做法是由业务 API 明确构造键,而不是让缓存层猜测整个请求配置中哪些字段重要。

认证信息尤其不能忽略。同一个资源地址在不同用户下可能返回不同内容,服务端渲染时如果复用跨请求缓存,还可能把一个用户的数据交给另一个用户。

缓存需要同时考虑新鲜度和保留时间

缓存命中不等于数据仍然新鲜。很多客户端缓存会把“多久以内可以直接使用”和“无人使用后保留多久”分开:前者决定是否后台刷新,后者决定何时从内存移除。

对变化较慢的配置,几分钟内直接复用可能没有问题;支付状态和库存则可能需要更短的新鲜窗口。这个时间应该来自业务允许的陈旧程度,而不是所有请求共用一个看起来合理的默认值。

一种常见体验是先显示已有数据,再在后台重新验证。用户不用每次看到空白加载状态,页面也能逐渐更新。不过旧数据仍然可能短暂出现,界面应能区分第一次加载和后台刷新,不能把两者都渲染成整页 Loading。

缓存失效也不只是到期删除。完成更新操作后,可以让相关查询变为过期并重新获取。直接手工修改多份缓存副本看起来更快,却很容易漏掉列表、详情和统计数据之间的关系。

请求去重只共享正在进行的工作

两个调用在同一时间读取同一个查询键时,可以共享同一个进行中的任务。请求结束后,这条去重记录就应移除;之后是否复用结果属于缓存策略,不是去重本身。

共享请求会带来取消问题。一个组件不再需要结果,不代表另一个订阅者也不需要。只有所有订阅者都离开后,底层请求才适合取消。反过来,如果每个调用者都能直接中止共享任务,一个局部组件就可能让整页的数据获取失败。

写请求通常不适合按参数自动合并。两次内容相同的提交可能代表用户确实执行了两次操作,缓存层无法仅凭请求体判断业务意图。防止重复提交更适合依靠交互状态和服务端幂等键。

重试先判断这次操作能否重复

网络错误、限流和部分 5xx 可能只是暂时故障,重试有机会恢复。参数错误、权限不足和资源不存在通常不会因为多等一会儿而改变,重复请求只会增加负载。

读取请求通常更容易重试,写请求则要确认幂等性。HTTP 把 PUT、DELETE 等方法定义为幂等,不代表具体服务实现一定没有额外副作用;POST 也可以借助服务端支持的幂等键安全重放。前端不能只看方法名就做决定。

重试间隔需要逐步拉长,并加入少量随机抖动,避免大量客户端在服务恢复前同时发起下一轮请求。服务端返回 Retry-After 时也应尊重它。尝试次数要有上限,并把等待状态暴露给调用方,让用户能够取消或主动重试。

用户取消的请求不应自动恢复。取消表达的是结果已经不再需要,如果重试层把它当成网络故障,页面离开后请求仍会在后台继续。

旧响应可能覆盖新结果

搜索框快速变化时,旧关键词的请求可能比新请求更晚返回。即使没有使用缓存,也会出现旧结果覆盖当前页面的竞态。

可以在发起新请求时取消旧请求,也可以给每次查询记录版本,只接受当前版本的结果。这里判断的是结果是否仍与当前输入匹配,不能简单依赖请求发出的先后时间。

具体的取消信号和请求层职责在 HTTP Client 与拦截器的边界中说明。缓存如何成为页面中的服务端状态,则放在服务端状态管理继续展开。

指标要能看出策略是否在帮忙

命中率高并不一定代表缓存有效,也可能只是数据保留得过久。除了缓存命中,还应观察结果年龄、后台刷新失败、去重合并次数、重试成功率和最终失败率。

如果重试后成功的比例很低,却明显增加了请求量,策略可能只是延迟错误暴露。缓存、去重和重试都应该减少用户遇到的问题,而不是单纯让网络面板里的请求数量变少。