请求减量

一个页面重复调用相同接口,如何从源头减少请求

2026-07-273 min read高并发系统设计
请求减量Roadmap
摘要

梳理项目的背景、目标、核心问题和阶段性交付物。

一个页面打开后,相同的用户信息接口调用了三次;一个聚合接口内部,又有多个线程同时查询同一份配置。单次看都不慢,但并发一上来,网关请求量、服务线程数和数据库 QPS 会一起被放大。

很多人的第一反应是加缓存。缓存当然有用,但它解决的是“以前算过的结果能不能复用”。如果缓存刚失效,100 个相同请求同时进来,100 个线程仍然可能一起回源。

这篇文章只解决一个问题:同一时刻出现的相同请求,如何让系统只执行一次。

一、先确认重复请求到底从哪里来的

不要一上来就写拦截器。先沿着链路看三组数据:

  1. 浏览器 Network 中,同一个接口是否被多个组件重复触发。
  2. 网关和应用日志中,相同用户、参数和时间窗口内是否出现相同请求。
  3. 链路追踪中,一次页面访问是否让后端重复调用同一个下游。

重复请求通常来自三个位置:

位置常见原因优先处理方式
前端页面多个组件各自加载、状态没有共享、重复渲染触发请求状态提升、查询库复用、进行中 Promise 复用
服务内部多个业务分支查询同一数据、并行任务重复调用请求上下文缓存或进程内 SingleFlight
多实例之间多个实例同时回源热点数据分布式互斥、逻辑过期或后台刷新

这里有一个重点:不要把所有问题都推给 Redis。请求如果在浏览器里就可以消掉,没必要让它经过网关、鉴权、线程池,最后再到缓存层结束。

二、调用端先复用“正在执行的请求”

以 React Query、SWR 这类查询库为例,它们通常会根据查询 Key 复用进行中的请求。即使不用查询库,也可以自己保存 Promise:

ts
1const pending = new Map<string, Promise<unknown>>(); 2 3export function requestOnce<T>( 4 key: string, 5 loader: () => Promise<T> 6): Promise<T> { 7 const existing = pending.get(key) as Promise<T> | undefined; 8 if (existing) { 9 return existing; 10 } 11 12 const current = loader().finally(() => { 13 pending.delete(key); 14 }); 15 16 pending.set(key, current); 17 return current; 18}

调用时使用稳定的业务 Key:

ts
1const user = await requestOnce( 2 `user:${userId}`, 3 () => fetch(`/api/users/${userId}`).then(res => res.json()) 4);

这段代码没有缓存历史结果。请求完成后,Map 会立即清理。它只合并同一时刻正在进行的相同请求。

这样设计有两个好处:

  • 不需要决定缓存多久,不会长期返回旧数据。
  • 多个组件同时加载相同资源时,只有第一个请求真正发出。

但调用端治理只能减少当前客户端产生的重复请求。多个用户、多个实例同时访问热点数据时,服务端仍然需要自己的保护。

三、服务端用 SingleFlight 合并执行中的请求

SingleFlight 可以翻译成一句白话:

相同业务 Key 的任务正在执行时,后来的请求不再重复执行,而是等待并复用第一次执行的结果。

在 Java 中,可以用 ConcurrentHashMapCompletableFuture 实现一个进程内版本:

java
1import java.util.concurrent.CompletableFuture; 2import java.util.concurrent.ConcurrentHashMap; 3import java.util.function.Supplier; 4 5public class SingleFlight<K, V> { 6 7 private final ConcurrentHashMap<K, CompletableFuture<V>> flights = 8 new ConcurrentHashMap<>(); 9 10 public V execute(K key, Supplier<V> loader) { 11 CompletableFuture<V> future = new CompletableFuture<>(); 12 CompletableFuture<V> running = flights.putIfAbsent(key, future); 13 14 if (running != null) { 15 return running.join(); 16 } 17 18 try { 19 V value = loader.get(); 20 future.complete(value); 21 return value; 22 } catch (Throwable ex) { 23 future.completeExceptionally(ex); 24 throw ex; 25 } finally { 26 flights.remove(key, future); 27 } 28 } 29}

业务代码里不要直接使用完整 URL 当 Key,而应使用稳定、明确的业务维度:

java
1@Service 2public class ProductService { 3 4 private final SingleFlight<Long, ProductDetail> singleFlight = 5 new SingleFlight<>(); 6 private final ProductRepository productRepository; 7 8 public ProductService(ProductRepository productRepository) { 9 this.productRepository = productRepository; 10 } 11 12 public ProductDetail queryDetail(Long productId) { 13 return singleFlight.execute( 14 productId, 15 () -> productRepository.queryDetail(productId) 16 ); 17 } 18}

假设缓存刚失效,50 个请求同时查询同一个商品。第一个线程负责回源,其他线程等待同一个 CompletableFuture。数据库实际只执行一次查询。

这就是它和普通缓存的区别:

能力缓存SingleFlight
复用已完成结果
合并正在执行的任务不一定
控制数据新鲜度需要过期策略不保存历史结果
典型用途减少长期重复计算防止同一时刻并发回源

工程上,两者经常组合使用:

text
1读取缓存 2 ↓ 未命中 3SingleFlight 合并同 Key 请求 45再次检查缓存 6 ↓ 仍未命中 7查询数据库并回填缓存

第二次检查缓存不能省。一个请求进入 SingleFlight 等待队列时,前一个请求可能已经完成回填。如果不再检查一次,它仍可能重复访问数据库。

四、实现时最容易踩的四个坑

1. Key 设计得过粗

如果只用 productId,但接口结果还受租户、语言、权限或地区影响,不同请求会错误复用结果。

Key 必须覆盖所有会改变结果的业务维度:

java
1public record ProductQueryKey( 2 Long tenantId, 3 Long productId, 4 String locale 5) {}

Key 也不能无限细。如果把时间戳、TraceId 这类每次都变化的字段放进去,所有请求都会得到不同的 Key,SingleFlight 就失去作用。

2. 首个任务没有超时

后续请求都在等待第一个任务。如果第一个下游调用一直不返回,原本的重复请求问题会变成集体等待问题。

因此,真正执行的 loader 必须受下游超时和整条请求预算约束。SingleFlight 只负责合并,不负责替代超时治理。

3. 异常结果长期残留

任务无论成功还是失败,都必须从 flights 中清理。上面的实现把清理放在 finally,并使用 remove(key, future),避免误删后来启动的新任务。

失败结果是否应该短暂复用,要看业务。如果第三方明确返回“参数非法”,短期复用可以减少无效调用;如果只是网络抖动,则应该让下一次请求有重新执行的机会。不要在通用组件里替业务做决定。

4. 误以为进程内合并可以保护整个集群

上面的 Map 只在当前 JVM 内有效。部署 10 个实例时,同一个热点 Key 最多仍可能产生 10 次回源。

这通常已经能大幅降低压力。如果数据库仍然承受不了,就需要进一步使用分布式互斥、逻辑过期或后台刷新。不要为了理论上的“全局只执行一次”,一开始就把所有请求都放进分布式锁。

五、改造后应该看什么指标

只看接口平均响应时间是不够的。至少对比以下指标:

指标改造前改造后关注点
网关请求数页面真实发出的请求量调用端复用后是否下降
业务方法调用数进入服务后的调用量服务内重复调用是否减少
下游或 SQL 执行数实际资源消耗相同 Key 并发时是否接近 1
SingleFlight 等待数是否存在明显热点 Key
P95/P99 延迟并发回源时容易抖动等待首个任务时是否仍在预算内
异常与超时数原始基线首任务失败是否造成集中失败

压测不能只用随机参数。随机参数几乎不会产生相同 Key,也就测不出请求合并效果。应该单独增加热点场景,例如:

  • 70% 请求访问少量热点商品;
  • 20% 请求访问普通商品;
  • 10% 请求访问完全随机商品。

然后同时记录入口请求数和数据库真实查询数。假设 1 秒内有 1000 个请求访问同一个商品,SingleFlight 的目标不是让入口 QPS 变成 1,而是让当前实例对数据库的实际查询尽可能接近 1。

六、最后总结

重复请求治理应该从链路最前面开始:

  1. 能在调用端复用,就不要把请求发出来。
  2. 能在服务内合并,就不要让相同任务重复执行。
  3. 需要复用历史结果时使用缓存。
  4. 多实例同时回源仍然超出下游容量时,再考虑分布式保护。

请求减量的本质,不是把请求藏进缓存,而是让同一份业务工作只做一次。

这次改造完成后,你应该得到两个可以复用的产物:一份重复请求排查清单,以及一个带 Key 边界、异常清理和超时约束的 SingleFlight 组件。下一篇再继续处理另一类浪费:调用方循环请求 100 次时,如何把网络往返和数据库操作改造成一个受控的批量接口。