HTTP客戶端實現請求QPS的控制,你學會了嗎?
背景:高德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超限導致的服務不可用。