请求去重 — 是一种将相同并行请求合并为一个的机制,使得数据源只接收一次调用而不是几十次。在移动应用中,去重尤为重要:多个屏幕可能同时请求相同的用户配置文件或产品列表。根据 Square Engineering (2024),去重的实施将其API负载降低了30%,而无需更改服务器逻辑。
要点
Request Deduplication — 是一种防止在一个时间窗口内向同一数据源执行多个相同请求的技术。系统不会发送10个相同的HTTP请求,而是只发送一个,其余9个等待其结果。
重复请求的问题在采用基于状态的架构(MVVM、MVI、Redux)的移动应用中尤为突出。当多个观察者在短时间内订阅相同数据时,每个都会启动自己的请求,产生过载。根据Uber Engineering (2024)的数据,Uber移动客户端中高达18%的请求是重复的,而客户端去重将其数量减少了4倍。
去重与缓存不同。缓存在请求执行后存储其结果。去重则在执行之前和期间防止多余请求。请求完成后,缓存开始作用并存储结果。
class DeduplicatorT(
private val source: suspend () -> T
) {
private val inFlight = ConcurrentHashMap<String, Deferred<T>>()
suspend fun get(key: String): T = inFlight.getOrPut(key) {
async {
source().also { inFlight.remove(key) }
}
}.await()
}
这个Kotlin类保证每个键只执行一个协程。所有具有相同键的并行调用都等待一个Deferred。完成后,键被删除,下一个请求正常执行。
降低服务器负载 — 第一个也是最明显的原因。每个重复请求都会消耗服务器资源:CPU、内存、数据库连接。在数百万设备的规模下,即使10-15%的重复请求也会产生需要额外服务器的显著负载。
节省电池和流量 — 移动设备上的每个HTTP请求都会消耗无线电模块的能量。根据Google I/O (2025),一个失败或重复的请求可能消耗一个网络会话高达15%的能量。去重减少了无线电模块的激活次数,延长了设备的电池工作时间。
避免数据冲突 — 如果两个重复请求将数据写入本地存储,可能会发生竞争条件:第二个请求可能用过时数据覆盖第一个的结果。去重保证本地存储的写入只执行一次,消除了竞争条件。
改善用户体验 — 用户不会看到相同数据的多个加载指示器。UI状态(加载/成功/错误)由单一真实源头管理,而不是由多个竞争请求管理。
Memoization(记忆化) — 是在函数执行期间缓存其结果。如果函数已经在用相同参数执行,新调用不会启动第二个进程,而是接收第一个的结果。这是进程内场景中最简单形式的去重。
移动应用中的典型实现 — Deferred或Promise中的键的HashMap。键通常是请求的URL字符串或参数的连接。记录的生存期 — 从第一个请求到响应完成。根据Dropbox Engineering (2024),Dropbox移动客户端中的记忆化将API的重复请求数量减少了40%。
有缺陷的去重 — 一个危险的错误:如果不在错误后删除键,所有后续请求将永远返回相同的错误。正确的实现必须处理错误和故障,清除缓存并允许重试。
class MemoizedLoaderT(
private val loader: suspend () -> T
) {
private var cachedResult: Result<T>? = null
suspend fun get(): T = cachedResult ?.getOrThrow() ?: run {
loader().let {
Result.success(it)
}.also { cachedResult = it }
}.await()
}
MemoizedLoader使用Result<T>进行正确的错误处理:成功时缓存,错误时允许重试。这种方法保证了临时网络故障不会阻止后续请求。
Request Merging(请求合并) — 是一种将多个对同一源的不同请求收集到一组并作为一个批处理请求发送的技术。与去重不同,请求不相同 — 它们在参数上不同,但指向同一资源。
典型场景:应用程序的5个屏幕请求不同用户的配置文件。系统不会向/api/users/1、/api/users/2等发送5个单独请求,而是等待20毫秒,收集所有ID,然后发送一个请求/api/users?ids=1,2,3,4,5。窗口超时 — 关键参数:窗口太长会降低用户体验,太短则无法收集足够的请求。
根据Netflix Engineering (2023)的数据,在GraphQL BFF(Backend for Frontend)聚合器中,请求合并将层间的HTTP调用数量减少了65%,平均响应时间减少了120毫秒,消除了多余的RTT。异步窗口(防抖) — 通过协程或RxJava的标准实现。
class BatchMergerT {
private val pending = ConcurrentLinkedQueue<Pair<String, CompletableDeferred<T>>>()
suspend fun get(id: String): T = suspendCoroutine { cont ->
pending.add(Pair(id, cont))
scheduleFlush()
}
}
这个混合使用suspendCoroutine来暂停每个请求,并使用30毫秒窗口来收集组。定时器到期后,所有收集的ID通过一个批处理请求发送,每个协程接收自己的结果。
DataLoader — 是一个库(最初用于JavaScript/GraphQL),在服务器端实现批处理和记忆化。它在一个事件循环滴答内将同一数据源的所有请求分组,并通过一次调用执行它们。DataLoader广泛用于GraphQL,但也可应用于任何REST应用程序。
工作原理:在一个微任务内的所有loader.load(id)调用被收集到ID数组中,并传递给批处理函数。收到结果后,每个ID接收自己的数组元素。DataLoader中的缓存仅在单个HTTP请求内有效 — 下一次请求时缓存重置,保证了数据的时效性。
根据Meta Engineering (2024)的数据,在Facebook的GraphQL层中引入DataLoader消除了N+1问题,将典型页面的数据库查询数量从200减少到10。批处理调度 — DataLoader的关键创新 — 使用process.nextTick(Node.js)或DispatchQueue.main(iOS)来优化分组。
Memoization — 适用于单个进程(移动应用、微服务)。易于实现,对相同的并行调用高效。缺点 — 不能在进程或设备之间工作。
Request Merging — 适用于BFF层或聚合服务。需要服务器上批处理端点的支持。当前端对相同类型的多个不同数据发出许多小请求时,这是最佳选择。
DataLoader — 用于GraphQL服务器的去重标准。自动解决N+1问题,不需要手动配置缓存。推荐给任何具有GraphQL层的服务器。
带去重的HTTP缓存 — 在OkHttp(Android)或URLSession(iOS)级别可以通过Interceptor或delegate配置去重。OkHttp CacheInterceptor — 一个自定义拦截器,检查具有相同URL的请求是否已在执行并合并它们。这种方法在业务逻辑以下的级别工作,覆盖应用程序的所有请求而无需更改功能代码。
常见问题
去重在第一个请求仍在执行时阻止重复请求的执行。缓存在执行后存储结果。它们相互补充:去重在加载时防止重复请求,缓存则防止加载后的重复请求。
如果去重键选择不正确。例如,如果所有用户使用一个键,第一个请求将阻止所有其他请求。键必须具体:包含URL、参数、用户ID。此外,去重可能掩盖服务器问题,在指标中隐藏请求的实际频率。
最优窗口 — 用户场景为20-50毫秒。这足以收集一组请求,但不足以让用户注意到延迟。对于后台操作(日志、分析),窗口可以增加到200-500毫秒。经验法则:窗口不应超过单个请求执行时间的10%。
是的,原理相同:如果应用程序的多个部分订阅了相同的WebSocket频道,去重器会打开一个连接并将消息发送给所有订阅者。RxJava Share或Kotlin SharedFlow — 非常适合在客户端去重WebSocket消息的工具。
使用Android的MockWebServer (OkHttp)或iOS的OHHTTPStubs。启动10个具有相同参数的并行请求,并检查服务器是否恰好接收到一个调用。CountDownLatch或coroutineScope将帮助在测试中同步并行调用。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。