一个页面重复调用相同接口,如何从源头减少请求
梳理项目的背景、目标、核心问题和阶段性交付物。
一个页面打开后,相同的用户信息接口调用了三次;一个聚合接口内部,又有多个线程同时查询同一份配置。单次看都不慢,但并发一上来,网关请求量、服务线程数和数据库 QPS 会一起被放大。
很多人的第一反应是加缓存。缓存当然有用,但它解决的是“以前算过的结果能不能复用”。如果缓存刚失效,100 个相同请求同时进来,100 个线程仍然可能一起回源。
这篇文章只解决一个问题:同一时刻出现的相同请求,如何让系统只执行一次。
一、先确认重复请求到底从哪里来的
不要一上来就写拦截器。先沿着链路看三组数据:
- 浏览器 Network 中,同一个接口是否被多个组件重复触发。
- 网关和应用日志中,相同用户、参数和时间窗口内是否出现相同请求。
- 链路追踪中,一次页面访问是否让后端重复调用同一个下游。
重复请求通常来自三个位置:
| 位置 | 常见原因 | 优先处理方式 |
|---|---|---|
| 前端页面 | 多个组件各自加载、状态没有共享、重复渲染触发请求 | 状态提升、查询库复用、进行中 Promise 复用 |
| 服务内部 | 多个业务分支查询同一数据、并行任务重复调用 | 请求上下文缓存或进程内 SingleFlight |
| 多实例之间 | 多个实例同时回源热点数据 | 分布式互斥、逻辑过期或后台刷新 |
这里有一个重点:不要把所有问题都推给 Redis。请求如果在浏览器里就可以消掉,没必要让它经过网关、鉴权、线程池,最后再到缓存层结束。
二、调用端先复用“正在执行的请求”
以 React Query、SWR 这类查询库为例,它们通常会根据查询 Key 复用进行中的请求。即使不用查询库,也可以自己保存 Promise:
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:
1const user = await requestOnce(
2 `user:${userId}`,
3 () => fetch(`/api/users/${userId}`).then(res => res.json())
4);这段代码没有缓存历史结果。请求完成后,Map 会立即清理。它只合并同一时刻正在进行的相同请求。
这样设计有两个好处:
- 不需要决定缓存多久,不会长期返回旧数据。
- 多个组件同时加载相同资源时,只有第一个请求真正发出。
但调用端治理只能减少当前客户端产生的重复请求。多个用户、多个实例同时访问热点数据时,服务端仍然需要自己的保护。
三、服务端用 SingleFlight 合并执行中的请求
SingleFlight 可以翻译成一句白话:
相同业务 Key 的任务正在执行时,后来的请求不再重复执行,而是等待并复用第一次执行的结果。
在 Java 中,可以用 ConcurrentHashMap 和 CompletableFuture 实现一个进程内版本:
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,而应使用稳定、明确的业务维度:
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 |
|---|---|---|
| 复用已完成结果 | 是 | 否 |
| 合并正在执行的任务 | 不一定 | 是 |
| 控制数据新鲜度 | 需要过期策略 | 不保存历史结果 |
| 典型用途 | 减少长期重复计算 | 防止同一时刻并发回源 |
工程上,两者经常组合使用:
1读取缓存
2 ↓ 未命中
3SingleFlight 合并同 Key 请求
4 ↓
5再次检查缓存
6 ↓ 仍未命中
7查询数据库并回填缓存第二次检查缓存不能省。一个请求进入 SingleFlight 等待队列时,前一个请求可能已经完成回填。如果不再检查一次,它仍可能重复访问数据库。
四、实现时最容易踩的四个坑
1. Key 设计得过粗
如果只用 productId,但接口结果还受租户、语言、权限或地区影响,不同请求会错误复用结果。
Key 必须覆盖所有会改变结果的业务维度:
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。
六、最后总结
重复请求治理应该从链路最前面开始:
- 能在调用端复用,就不要把请求发出来。
- 能在服务内合并,就不要让相同任务重复执行。
- 需要复用历史结果时使用缓存。
- 多实例同时回源仍然超出下游容量时,再考虑分布式保护。
请求减量的本质,不是把请求藏进缓存,而是让同一份业务工作只做一次。
这次改造完成后,你应该得到两个可以复用的产物:一份重复请求排查清单,以及一个带 Key 边界、异常清理和超时约束的 SingleFlight 组件。下一篇再继续处理另一类浪费:调用方循环请求 100 次时,如何把网络往返和数据库操作改造成一个受控的批量接口。