【关于第三方 API 频率上限的业务场景的处理】
关于第三方 API 有频率上限的业务场景的处理-
这里以codeforce(后续简称cf)的api接口为例
背景:为什么 CF 限流是硬约束
cfAPI 文档 明确要求:
Do not send more than one request every two seconds.
违反限流可能返回 FAILED,甚至被临时封禁。与此同时,业务上还有两类调用方:
数据库表
CREATE TABLE task (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
pushed_by BIGINT NOT NULL COMMENT '推送管理员ID(逻辑外键)',
title VARCHAR(200) NOT NULL COMMENT '标题',
description VARCHAR(1000) COMMENT '描述',
start_time DATETIME COMMENT '开始时间',
due_date DATETIME COMMENT '截止时间',
link VARCHAR(500) NOT NULL COMMENT 'Codeforces 题目链接',
excel_exported TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已生成 Excel',
excel_path VARCHAR(500) COMMENT 'Excel 文件路径',
created_at DATETIME NOT NULL COMMENT '创建时间',
updated_at DATETIME NOT NULL COMMENT '更新时间',
PRIMARY KEY (id),
KEY idx_task_pushed_by (pushed_by)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务定义表';
CREATE TABLE task_assignment (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键(API 中的任务分配 ID)',
task_id BIGINT NOT NULL COMMENT '任务定义 ID',
user_id BIGINT NOT NULL COMMENT '接收用户 ID',
completed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否完成',
assigned_at DATETIME NOT NULL COMMENT '分配时间',
created_at DATETIME NOT NULL COMMENT '创建时间',
updated_at DATETIME NOT NULL COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_task_user (task_id, user_id),
KEY idx_task_assignment_task_id (task_id),
KEY idx_task_assignment_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务用户分配表';
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
username VARCHAR(50) NOT NULL COMMENT '用户名',
password VARCHAR(100) NOT NULL COMMENT '密码(建议存储加密后的值)',
nickname VARCHAR(50) COMMENT '昵称',
email VARCHAR(100) COMMENT '邮箱',
cf_handle VARCHAR(24) COMMENT 'Codeforces 用户名(handle)',
role VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT '身份: ADMIN-管理员, USER-普通用户',
created_at DATETIME NOT NULL COMMENT '创建时间',
updated_at DATETIME NOT NULL COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_user_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
业务需求
检查分配的任务对应的user是否完成
| 后台 Async 同步 | 管理员进入页面触发,扫描大量未完成分配,可以慢 |
| Excel 导出 | 用户点击下载,同步阻塞,期望尽快完成 |
因此目标不是「简单加个 sleep」,而是:
业务难点,后台Async同步是为了用户体验,但Excel的导出优先级必须更大,这里用到了双队列的方式优化
- 全局遵守 2 秒间隔
- 同一时刻只发一个 CF 请求(避免并发踩踏)
- 导出优先于后台同步(体验 vs 吞吐的取舍)
- 尽量少打 API(去重、截止后不再检测)
整体架构:三层防护
┌─────────────────────────────────────────────────────────────┐
│ 业务层 TaskCompletionSyncService │
│ · 筛选未完成 / 未截止 / 有 cfHandle / 可解析 link │
│ · 按 (contestId, cfHandle) 去重 │
└───────────────────────────┬─────────────────────────────────┘
│ 每次 CF 调用
▼
┌─────────────────────────────────────────────────────────────┐
│ 调度层 CfSyncWorker(双队列 + 单线程 cf-sync-worker) │
│ · HIGH:Excel 导出 │
│ · LOW:后台 @Async 同步 │
│ · 保证串行 + 导出插队 │
└───────────────────────────┬─────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 传输层 CFUtils.execute() → waitForRateLimit() │
│ · 相邻两次 HTTP 请求至少间隔 2 秒 │
└─────────────────────────────────────────────────────────────┘
串行 ≠ 限频:Worker 保证「同时只有一个请求在飞」;waitForRateLimit 保证「两次请求之间至少 2 秒」。两者缺一不可。
第一层:CFUtils 的 2 秒间隔限流
所有 CF HTTP 请求最终都走 CFUtils.execute(),在发请求前调用 waitForRateLimit():
private static final long MIN_REQUEST_INTERVAL_MS = 2000L;
private void waitForRateLimit() throws InterruptedException {
synchronized (rateLimitLock) {
long now = System.currentTimeMillis();
long waitMs = MIN_REQUEST_INTERVAL_MS - (now - lastRequestAtMs);
if (waitMs > 0) {
Thread.sleep(waitMs);
}
lastRequestAtMs = System.currentTimeMillis();
}
}
逻辑很朴素:记录上次请求时间,不足 2 秒就 sleep 补足。
synchronized (rateLimitLock) 用于保护 lastRequestAtMs 的读改写;在当前架构下,CF 调用经单线程 Worker 串行,这把锁更多是防御性设计——若将来有代码绕过 Worker 直接调 CFUtils,限流状态仍然正确。
第二层:CfSyncWorker —— 双队列 + 单消费者 (双队列是为了让Excel导出时对cfapi请求的优先级更高)
为什么不用一把 ReentrantLock 包整段 sync?
早期可以用「本地公平锁 + 整段 sync 包在锁里」实现串行,但有个问题:
- 后台全量 sync 可能持续几十秒甚至更长
- Excel 导出进来后只能干等整段 sync 结束
所以我们改成 「每个 CF API 调用 = 一个独立任务」 入队,由唯一的 cf-sync-worker 线程依次执行。
核心 API
public <T> T execute(CfSyncPriority priority, Callable<T> action) {
CompletableFuture<T> future = new CompletableFuture<>();
enqueue(priority, action, future); // 入 HIGH 或 LOW 队列
return future.get(); // 调用方阻塞等待本次 CF 结果
}
业务侧写法:
Set<String> indices = cfSyncWorker.execute(priority,
() -> cfUtils.getAcceptedProblemIndices(handle, contestId));
调度规则
Worker 循环:
while (running) {
if (highQueue 非空) → 取 HIGH 队首
else if (lowQueue 非空) → 取 LOW 队首
else → await 等待新任务
执行 Callable → future.complete()
}
HIGH 绝对优先于 LOW,但不会打断正在进行的 HTTP 请求——Export 最多等多「当前这一次 CF 调用」的时间,而不是整段后台 job。
时序示意
LOW: cf1 ──► LOW: cf2(执行中)
│
Export 插入 HIGH: cfX
│
cf2 结束 ──► cfX(HIGH 先跑)──► LOW: cf3 ──► ...
第三层:业务侧「少打 API」
限流解决「别打太快」,去重和截止筛选解决「别打太多」。
1. 按 (contestId, cfHandle) 去重
同一用户在同一 contest 下做过多道题,只需调一次 contest.status:
Set<CfContestLookupKey> lookupKeys = new HashSet<>();
for (PendingCheck check : pendingChecks) {
lookupKeys.add(new CfContestLookupKey(check.contestId(), check.cfHandle()));
}
// 每个 key 只 cfSyncWorker.execute 一次
2. 仅未截止任务才检测
static boolean isCfSyncEligible(Task task) {
LocalDateTime dueDate = task.getDueDate();
if (dueDate == null) return true; // 无截止时间
return dueDate.isAfter(LocalDateTime.now()); // 尚未截止
}
截止后的任务不再查 CF,减少无效请求,也与用户端「有效期内任务」语义一致。
–
与 @Async 后台任务如何配合
管理员进入任务页时:
POST /sync/completions → trigger() 立即返回
→ @Async("cfSyncExecutor") 跑 syncByTaskIds
→ 内部多次 cfSyncWorker.execute(LOW, ...)
这里有两层线程:
| 组件 | 线程 | 作用 |
|---|---|---|
cfSyncExecutor |
1~2 个 Spring 线程池线程 | 跑整段后台 sync job |
cf-sync-worker |
1 个专用 daemon 线程 | 真正发 CF HTTP |
HTTP 线程不阻塞在整段 sync 上;CF 请求则在 Worker 上严格串行 + 2 秒间隔。
activeJob 单飞机制保证短时间内不会重复提交多个全量 sync——这是 Job 层 的防重复,与 CF 层 的 Worker 互补。
优先级设计:Export 为什么要 HIGH?
Excel 导出是同步接口:用户一直 loading 到文件生成完毕。若后台 sync 占满 CF 队列,导出体验会很差。
因此:
TaskExcelExportService→CfSyncPriority.HIGHTaskCompletionSyncJobService→CfSyncPriority.LOW
这是典型的 「交互式任务优先于批处理」 模式,实现成本低、效果直观。
实测与单元测试
项目包含:
CfSyncWorkerTest:验证 HIGH 任务在 pending LOW 之前执行TaskCompletionSyncServiceTest:验证去重、截止筛选
测试 profile 下 Worker 走 inlineMode,不入队、不启 daemon 线程,单测可直接 mock cfSyncWorker.execute。
局限与后续可优化点
诚实地说,当前方案适合 单机、中小规模 部署:
| 局限 | 说明 |
|---|---|
| 多实例不共享队列 | 每台机器各有一个 Worker,全局 CF QPS = 实例数 × (1/2s) |
| 不能取消进行中的 HTTP | Export 仍需等当前 CF 调用结束 |
| 内存 job 不持久化 | 重启后 async 状态丢失(DB 已写入的 completed 不受影响) |
| 固定 2 秒间隔 | 未根据 CF 返回的 FAILED + comment 做指数退避 |
若规模上来,可考虑:
- Redis 分布式锁 / 全局单飞
- CF 响应缓存(
(handle, contestId)→ AC 题号集合,TTL 5~10 分钟) - 多实例共用 Redis 队列做 CF Worker
小结
在 daily_task_demo 里,我们与 CF 限流的共处之道可以概括为:
- CFUtils:硬性 2 秒间隔,贴近官方要求
- CfSyncWorker:单线程 + 双优先级队列,串行且 Export 可插队
- TaskCompletionSyncService:去重 + 截止筛选,从源头减少调用次数
- @Async + activeJob:后台 fire-and-forget,不阻塞管理端页面
串行保证「同时只有一个在飞」,限频保证「飞得不能太快」,去重保证「能少飞就少飞」——三层叠加,才是一个可上线的 CF 集成方案。
–
更多推荐

所有评论(0)