异步编程实战:基于IP地址的全局速率限制和RateLimiter的使用

在开发中,我们经常需要处理批量任务,例如,为一个App的所有用户推送通知,或从API同步所有店铺的数据。为了提升效率,异步并发执行是一个常见的选择。然而,并发编程也常常伴随着一些难以捉摸的“幽灵Bug”。本文将复盘一个典型的并发问题:“单独执行一切正常,批量执行时总是在某个点神秘失败”,并展示如何一步步定位并解决其根本原因。

我们有一个需求:为一个商铺列表(List<TtsShopPO>),并发地调用 pullShopVideoList 方法,从外部API拉取每个店铺的数据。

最初的代码使用了Java的 CompletableFuture 来实现:

Java

// 初始问题代码
public void pullVideoList() {
    // ... 准备工作 ...
    ExecutorService ioExecutor = Executors.newFixedThreadPool(5);
    try {
        ttsShopPOS.forEach(ttsShopPO -> {
            CompletableFuture.runAsync(() -> {
                pullShopVideoList(ttsShopPO.getShopId(), ...);
            }, ioExecutor);
        });
    } finally {
        ioExecutor.shutdown();
    }
}

遇到的现象是: 程序运行后,处理了几个店铺,然后就悄无声息地停止了,日志里没有任何错误。排在后面的店铺数据都没有被更新。

初版代码最大的问题是“发射后不管”(Fire-and-Forget)。主线程提交了所有异步任务后,没有等待它们完成就直接退出了。更糟糕的是,如果任何一个后台任务抛出异常,这个异常会被“吞噬”掉,主线程完全感知不到。

解决方案: 收集所有CompletableFuture,并在主流程中等待它们全部完成。

// 第二版代码:增加了等待和基础异常处理
// ...
try {
    List<CompletableFuture<Void>> futures = new ArrayList<>();
    ttsShopPOS.forEach(ttsShopPO -> {
        futures.add(
            CompletableFuture.runAsync(() -> {
                pullShopVideoList(ttsShopPO.getShopId(), ...);
            }, ioExecutor)
        );
    });
    // 等待所有任务完成
    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
} finally {
    ioExecutor.shutdown();
}

修改后,我们发现程序在运行到.join()时直接抛出了一个CompletionException并中断,但日志里依然看不到根本的异常原因。这是因为异常被主框架在高层捕获了。

解决方案: 为每个异步任务添加独立的异常处理器,确保能“看到”后台的错误。

// 第三版代码:增加了独立的异常处理器
// ...
futures.add(
    CompletableFuture.runAsync(...)
    .exceptionally(ex -> {
        log.error("任务失败 for shopId: {}", ttsShopPO.getShopId(), ex);
        return null; // 返回null表示异常已处理
    })
);
// ...

至此,我们终于能在日志中清晰地看到哪个任务失败了。但一个新的谜题出现了。

新的线索: 当我们把那个失败的店铺ID拿出来,单独调用 pullShopVideoList 方法时,发现它每次都能成功,没有任何异常。

这个现象——“单独调用可以,并行调用失败”——几乎可以100%地确定,问题并非出在代码的业务逻辑本身,而是出在**API接口的速率限制 (Rate Limiting)**上。

最终诊断:基于IP地址的全局速率限制

即使我们为每个店铺都使用了独立的认证Token,但所有的请求都来自同一个服务器IP地址。大型API平台为了防止滥用,除了有基于单个用户的限制,通常还有一个更严格的、基于源IP地址的全局限制。

我们的程序在短时间内从同一个IP并发了大量请求,触发了这个全局限制,导致API服务器开始拒绝后续的请求,从而抛出异常,中断了整个批量任务。

我们之前的限流思路(在pullVideoList的循环外限流)是错误的,因为它只能限制“任务启动”的频率,而无法限制任务内部可能存在的多层循环发出的API请求。

正确的做法是,将速率控制器传递到真正发起API请求的最深处。

  1. 引入Google Guava的RateLimiter,并创建一个全局实例。

  2. 修改方法签名(依赖注入),让内层方法可以接收这个RateLimiter实例。

    Java

    // 外层调用方法
    public void pullVideoList() {
        final RateLimiter rateLimiter = RateLimiter.create(2.0); // 例如,每秒最多2个请求
        // ...
        CompletableFuture.runAsync(() -> {
            pullShopVideoList(rateLimiter, shop.getShopId(), ...); // 将实例传入
        }, ioExecutor)
        //...
    }
    
    // 内层工作方法
    private void pullShopVideoList(RateLimiter rateLimiter, Integer shopId, ...) {
        // ...
        while(hasMorePages) {
            // 在每一次真实API请求前,获取“许可”
            rateLimiter.acquire(); 
            // 真正发起API请求...
            JSONObject response = httpClient.post(url, params);
            // ...
        }
    }
    

通过这种方式,RateLimiter就像一个全局的令牌桶,被所有并行的任务和任务内部的循环所共享。无论哪个线程想发起请求,都必须先从这个唯一的令牌桶里获取许可。这确保了从服务器IP发出的总请求数被精确地控制在阈值之下,完美地解决了问题。

关键知识点温习

  1. 处理并发任务,必须追踪和等待:绝不能“发射后不管”,使用 CompletableFuture.allOf().join() 或类似机制等待所有任务完成。

  2. 为异步任务添加独立异常处理:使用 .exceptionally() 避免单个任务的失败导致整个批处理的中断。

  3. 警惕API速率限制:当遇到“单独执行成功,并发执行失败”时,应首先怀疑速率限制,特别是基于IP的全局限制

  4. 将限流器注入核心:速率控制必须应用在真正发起网络请求的地方,而不是外层的业务逻辑调用处。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值