关于第三方 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的导出优先级必须更大,这里用到了双队列的方式优化

  1. 全局遵守 2 秒间隔
  2. 同一时刻只发一个 CF 请求(避免并发踩踏)
  3. 导出优先于后台同步(体验 vs 吞吐的取舍)
  4. 尽量少打 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 队列,导出体验会很差。

因此:

  • TaskExcelExportServiceCfSyncPriority.HIGH
  • TaskCompletionSyncJobServiceCfSyncPriority.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 限流的共处之道可以概括为:

  1. CFUtils:硬性 2 秒间隔,贴近官方要求
  2. CfSyncWorker:单线程 + 双优先级队列,串行且 Export 可插队
  3. TaskCompletionSyncService:去重 + 截止筛选,从源头减少调用次数
  4. @Async + activeJob:后台 fire-and-forget,不阻塞管理端页面

串行保证「同时只有一个在飞」,限频保证「飞得不能太快」,去重保证「能少飞就少飞」——三层叠加,才是一个可上线的 CF 集成方案。

更多推荐