背景:高德API调用QPS限制引发的问题
最近在项目中使用高德API进行地址转坐标时,频繁遇到CUQPS_HAS_EXCEEDED_THE_LIMIT
错误。这是因为API对每秒请求量(QPS)有严格限制(如高德地图API默认QPS为3)。当客户端请求速度超过限制时,服务端会直接拒绝请求。为解决这一问题,我们需要在客户端实现QPS控制,确保请求速率符合服务端要求。
一、当前实现方案
ScheduledExecutorService + Semaphore
我们采用信号量(Semaphore)与定时任务(ScheduledExecutorService)结合的方式控制QPS:
-
信号量:初始化为允许的最大并发数(如100)。
-
定时任务:每秒重置信号量许可数量,确保QPS不超过限制。
简单实现:
public class QpsTaskCtrl {
private final Semaphore semaphore;
private final ScheduledExecutorService scheduler;
public QpsTaskScheduler(int qps) {
this.semaphore = new Semaphore(qps);
this.scheduler = Executors.newScheduledThreadPool(1);
this.scheduler.scheduleAtFixedRate(() -> {
if( semaphore.availablePermits() >= qps ){
semaphore.drainPermits();
}
semaphore.release();
}, 0, 1000/qps, TimeUnit.MILLISECONDS);
}
public <T> T execute(Callable<T> callable){
try {
semaphore.acquire();
} catch (InterruptedException e) {
LOGGER.error("Failed to acquire semaphore", e);
}
T result = null;
try {
result = callable.call();
} catch (Exception e) {
LOGGER.error("Failed to execute task", e);
semaphore.release();
} finally {
}
return result;
}
}
测试
public static void main(String[] args) throws Exception {
QpsTaskScheduler scheduler = new QpsTaskScheduler(3);
for (int i = 0; i < 20; i++) {
String testUrl = "https://jsonplaceholder.typicode.com/todos/"+ DateFormatUtils.format(new Date(), "yyyy-MM-dd HH:mm:ss.SSS");
Request req = new Request.Builder().url(testUrl).build();
Response res = scheduler.execute(()->{
return new OkHttpClient().newCall(req).execute();
});
System.out.println( res );
}
}
优点:
-
实现简单,无需引入外部依赖。
-
适用于单机场景。
缺点:
-
定时任务可能因延迟导致信号量重置不及时。
-
无法应对突发流量(如瞬时高并发)。
-
分布式场景下需额外同步机制。
二、其他QPS控制方案及对比
除上述方案外,常见的QPS控制方法还包括:
1. 漏桶算法(Leaky Bucket)
-
原理:请求进入“漏桶”,以固定速率流出。若桶满则拒绝请求。
-
实现:使用队列存储请求,定时任务按QPS处理队列。
-
优点:严格控制请求速率,平滑流量。
-
缺点:无法利用突发流量(如短时间内允许更多请求)。
2. 令牌桶算法(Token Bucket)
-
原理:每秒生成固定数量令牌,请求需消耗令牌。若令牌不足则等待或拒绝。
-
实现:使用
Guava RateLimiter
(基于令牌桶)。 -
优点:允许一定突发流量,灵活性高。
-
缺点:实现复杂度较高。
代码示例:
import com.google.common.util.concurrent.RateLimiter;
RateLimiter rateLimiter = RateLimiter.create(100); // QPS=100
rateLimiter.acquire(); // 阻塞直到有令牌可用
3. 滑动窗口计数器(Sliding Window Counter)
-
原理:将时间窗口划分为多个子窗口,统计每个子窗口内的请求数。
-
实现:使用环形数组记录每个子窗口的请求量。
-
优点:精确控制QPS,避免固定窗口计数器的“突刺问题”。
-
缺点:内存占用较高。
三、服务端接口限流
针对服务端的限流,我们需要通过控制客户端请求频率来控制,避免过于频繁的接口调用出错或封号等。
服务端限流一般有哪些方案呢?
1. Guava RateLimiter(单机)
-
原理:基于令牌桶算法,线程安全。
-
优点:简单易用,适用于单机服务。
-
缺点:无法跨节点同步,分布式场景需配合Redis。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimiter {
double qps(); // 每秒允许的请求数
long warmupPeriod() default 0; // 预热期(毫秒)
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
}
@Aspect
@Component
public class RateLimiterAspect {
// 使用ConcurrentHashMap存储不同接口的RateLimiter实例
private final Map<String, RateLimiter> rateLimiterMap = new ConcurrentHashMap<>();
@Around("@annotation(rateLimiter)")
public Object around(ProceedingJoinPoint joinPoint, RateLimiter rateLimiter) throws Throwable {
String methodKey = joinPoint.getSignature().getName(); // 以方法名作为限流标识
RateLimiter limiter = rateLimiterMap.computeIfAbsent(
methodKey,
k -> RateLimiter.create(
rateLimiter.qps(),
rateLimiter.warmupPeriod(),
rateLimiter.timeUnit()
)
);
if (!limiter.tryAcquire()) { // 尝试获取令牌,立即返回
throw new TooManyRequestsException("接口请求过于频繁,请稍后再试");
}
return joinPoint.proceed();
}
}
2. Redis计数器(分布式)
-
原理:利用Redis的
INCR
命令统计请求数,结合EXPIRE
实现时间窗口。 - 实现:
-- lua脚本实现原子计数 local key = KEYS[1] local limit = tonumber(ARGV[1]) local current = tonumber(redis.call('GET', key) or "0") if current + 1 > limit then return 0 else redis.call("INCR", key) redis.call("EXPIRE", key, 1) return 1 end
-
优点:支持分布式场景,精度高。
-
缺点:依赖Redis性能,需考虑网络延迟。
3. Nginx限流(网关层)
- 配置:
limit_req_zone $binary_remote_addr zone=one:10m rate=100r/s; server { location /api { limit_req zone=one burst=200; proxy_pass http://backend; } }
-
优点:高效(内核级处理),不影响业务逻辑。
-
缺点:配置较复杂,无法感知业务状态。
4. Sentinel(阿里开源框架)
-
功能:基于滑动窗口限流,支持熔断、降级、负载保护。
-
优点:功能全面,适用于微服务架构。
-
缺点:引入额外依赖,学习成本较高。
结语
QPS控制是保障系统稳定性的重要环节。选择方案时需结合业务场景、技术成本和扩展性。通过客户端与服务端的双重限流,配合监控与报警,可有效避免因QPS超限导致的服务不可用。