轮询设计

最简单的方案

还记得我一开始是怎么实现订单超时取消的吗?

然后用 Linux 的 cron 定时任务,每分钟执行一次:

验证要点

  • 命令只用于验证系统状态,读者不需要记具体参数。

这个方案简单粗暴,但真的能用吗?


数据库轮询的工作原理

核心思想

轮询方案的核心:

1. 定期(如每分钟)查询数据库
2. 找出应该执行的任务(执行时间 <= 当前时间)
3. 执行任务
4. 标记任务为已完成

基本实现

轮询器本身就是一个定时循环:每隔一段时间查一次数据库,把到期的任务捞出来执行。

public class DatabasePoller {

    private final int batchSize = 200;

    /** 一轮轮询:捞出到期任务 → 执行 → 标记完成 */
    public void pollOnce() {
        List<DelayTask> tasks = taskDao.findDueTasks(batchSize);
        if (tasks.isEmpty()) {
            return;                                  // 空轮询,注意这里的成本
        }
        for (DelayTask task : tasks) {
            try {
                handlerRegistry.get(task.taskType()).handle(task);
                taskDao.markDone(task.id());
            } catch (Exception e) {
                taskDao.markFailed(task.id(), e.getMessage());
            }
        }
    }
}

配套的 SQL 依赖 execute_at 上的索引:

SELECT id, task_type, task_data, execute_at
FROM delay_tasks
WHERE status = 'PENDING'
  AND execute_at <= NOW()
ORDER BY execute_at
LIMIT 200;

调度既可以用 cron,也可以用代码里的定时器:

ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(this::pollOnce, 1, 1, TimeUnit.SECONDS);

这段代码能跑,但它是”教科书版本”,有三个地方在生产上必须改:

  1. 捞取和标记之间没有原子性。 两个轮询线程会捞到同一批任务,导致重复执行。生产版本要用 FOR UPDATE SKIP LOCKED 抢任务,或者用《锁机制》里讲的分布式锁。
  2. 每条任务单独提交。 数据库往返次数等于任务数,批量收益为零。生产版本要用《批量处理》里的批量更新和批量占用。
  3. 固定间隔轮询。 任务稀疏的时候,绝大多数轮询都是空查询——execute_at 上虽然有索引,但索引只能让”找到”变快,不能让”没东西可查”这件事消失。解决办法见本章最后「想一想」里的方案 1 和方案 2。

数据库表设计

基本表结构

数据设计要点

  • 核心是在 delay_tasks 里保存业务事实,而不是把规则散落在应用逻辑里。
  • 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
  • 关键字段包括 id、task_id、task_type、task_data、execute_at、retry_count、max_retries、error_message,它们决定后续查询和管理能力。

字段说明

字段类型说明
task_idVARCHAR(100)任务唯一标识,用于取消、查询
task_typeVARCHAR(50)任务类型,用于路由到不同的处理器
task_dataTEXT任务数据(JSON 格式)
execute_atTIMESTAMP任务执行时间
statusENUM任务状态
retry_countINT重试次数
max_retriesINT最大重试次数
error_messageTEXT错误信息
consumer_idVARCHAR(100)消费者 ID(用于故障恢复)

关键索引

数据设计要点

  • 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。

为什么这个索引很重要?

数据设计要点

  • 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
  • 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。

轮询间隔的选择

间隔对精度的影响

间隔精度CPU 消耗数据库压力适用场景
1 秒±0.5 秒🔴 极高🔴 极高秒杀倒计时
10 秒±5 秒🟡 高🟡 高高精度场景
1 分钟±30 秒🟢 中🟢 中订单超时
5 分钟±2.5 分钟🟢 低🟢 低定时任务
1 小时±30 分钟🟢 极低🟢 低长周期任务

自适应轮询

根据任务量动态调整轮询间隔:

固定间隔的问题在于它只能满足一个目标:间隔调小,精度高但数据库压力和 CPU 消耗高;间隔调大,省资源但任务可能晚几十秒才执行。而任务量本身是波动的——白天零星几单,深夜可能一个都没有,固定 1 秒的轮询在深夜纯属浪费。

自适应轮询的核心思路是:根据上一次拉到多少任务,动态决定下一次等多久。

MIN_INTERVAL = 0.5   # 秒,最多 1 秒查一次
MAX_INTERVAL = 30.0  # 秒,最多 30 秒查一次

async def poll_loop():
    interval = MIN_INTERVAL
    while True:
        tasks = await fetch_due_tasks(limit=1000)

        if tasks:
            # 捞到任务说明积压多,立刻用最短间隔再查一次
            interval = MIN_INTERVAL
            for task in tasks:
                await handle(task)
        else:
            # 没捞到说明现在是空闲,逐步拉长间隔
            interval = min(interval * 2, MAX_INTERVAL)

        await asyncio.sleep(interval)

注意这里用了指数退避而不是线性加长:空闲时间越长,间隔增长越快,这样深夜能真正降到 30 秒一次;而一旦来了任务立刻回到 0.5 秒,不会因为退避而错过突发流量。

配套的还有两点:

  1. 按状态分开轮询。 待执行任务和已超时的异常任务是两种完全不同的数据,用不同的索引和不同的间隔轮询,异常任务可以被更快地发现。
  2. 多实例轮询要错峰。 多个消费者实例同时查同一张表会产生惊群效应——它们会同时查到同一批任务然后互相争抢。用实例 ID 取模错开起始时间,或者用分布式锁保证同一时刻只有一个实例在查,都可以解决。

这套策略的代价是多了一层状态机:间隔要能被观测和调整,空闲时长和任务延迟的关系要有监控。收益是数据库在低峰期几乎不产生压力,而高峰期又能保持秒级精度。


优缺点分析

优点

优点说明
✅ 简单可靠实现简单,容易理解和维护
✅ 持久化保证数据库天然持久化,任务不会丢
✅ 事务支持可以利用数据库事务保证一致性
✅ 易扩展可以增加索引、分区、分表
✅ 查询灵活支持复杂查询和统计

缺点

缺点说明影响
❌ 精度有限轮询间隔决定精度不适合高精度场景
❌ 数据库压力频繁查询数据库高并发时压力大
❌ 空轮询没有任务时也会查询浪费资源
❌ 性能瓶颈数据库 I/O 限制无法支撑超高并发

想一想

思考 1

如何减少数据库轮询的空查询(没有任务时的查询)?

延时队列

解题思路:

空查询的浪费要先量化:每天 100 万任务、每分钟轮询一次就是 1440 次查询,90% 空转就是 1296 次无效查询。解法分三类——让轮询睡到有任务再醒(预估下次到期时间)、让热路径根本不查库(缓存)、干脆由写入方来唤醒(触发通知)。三者不互斥,实际常常组合使用。

详细步骤:

问题分析:

空查询是指轮询时没有找到任何到期任务,但仍然执行了数据库查询。

空查询的代价:

- 数据库连接占用
- 网络往返延迟
- 数据库 CPU 和 I/O 开销
- 日志记录

假设每天 100 万任务,每分钟轮询一次:
- 总轮询次数:1440 次/天
- 如果大部分时间没有任务,90% 是空查询
- 浪费:1296 次无效查询

解决方案:

方案 1:预估下次查询时间

思路:不按固定间隔轮询,而是算出”下一个任务什么时候到期”,睡到那一刻再查。

实现方式是在表上取”最早到期时间”,或者干脆从一次查询里直接拿到:

SELECT MIN(execute_at) FROM delay_tasks WHERE status = 'PENDING';

拿到之后,下一次轮询时间就定了:

long nextPoll = Math.max(
        earliestExecuteAt.getTime(),
        System.currentTimeMillis() + 200);   // 兜底最短间隔

空闲时用 schedule 挂起而不是 scheduleAtFixedRate,醒来时重新算一次。

优点说明
空查询几乎归零任务稀疏时一天可能只查几次
精度反而更高下一个任务到期时正好被查出来,不用等下一个固定 tick
数据库压力小空转期的 CPU 和 IO 占用接近于零
缺点说明
依赖任务分布任务密集时仍然要频繁查,改善有限
依赖索引MIN(execute_at) 要走 (status, execute_at) 索引,否则就是全表扫
对新任务不敏感轮询器”睡着”时感知不到新插入的、更早到期的任务

最后一条是这个方案最大的软肋:新任务是”写入”动作,轮询器睡着时根本不知道。要补上这个缺口,通常再加一个”有新任务时唤醒轮询器”的信号(本地唤醒队列 / 消息通知 / 一个兜底的低频轮询),而这本质上就滑向了下面的方案 3。


方案 2:使用缓存

思路:把”当前有哪些待执行任务”缓存在内存里,数据库只做兜底。

缓存里存的是任务摘要(task_id、execute_at、task_type),轮询时先看内存,到期了再批量回数据库取完整数据:

// 写入时同时更新缓存
public void submit(DelayTask task) {
    taskDao.insert(task);
    cache.put(task.id(), task.summary());   // 缓存是"待执行任务"的索引
}

// 轮询时
List<TaskSummary> due = cache.dueTasks(System.currentTimeMillis());
if (!due.isEmpty()) {
    List<DelayTask> full = taskDao.findByIds(
            due.stream().map(TaskSummary::id).toList());
    ...
}

缓存放在哪儿决定了它的形态:

缓存位置形态优点缺点
本地内存DelayQueue / 定时堆零网络开销多实例不同步,进程重启即丢
Redis ZSetscore = execute_at多实例共享,可持久化多一次网络往返
本地缓存 + 通知Caffeine + Pub/Sub读路径最快失效逻辑复杂

方案 2 的收益是把数据库的”查询”变成了”只在冷启动和补偿时发生”,热路径完全在内存里。代价是缓存和数据库可能不一致:缓存里的任务已经被别的实例执行掉了、数据库里的新任务还没进缓存。所以缓存方案必须配两个兜底:定期从数据库全量校准,以及执行前二次确认任务状态。

这两个方案并不互斥,实际系统里常常一起用:缓存负责把热路径的查询降到 0,预估时间负责在没有缓存命中时也不做无意义的空查询。

方案 3:触发通知

方案对比:

方案减少空查询复杂度适用场景
预估时间⭐⭐⭐⭐⭐⭐⭐任务稀疏
使用缓存⭐⭐⭐⭐⭐频繁查询
触发通知⭐⭐⭐⭐⭐⭐⭐⭐单消费者

最终答案:

三种方案可组合使用。预估下次查询时间:用 MIN(execute_at) 算出下一个到期点再睡过去,空查询几乎归零且精度更高,但依赖任务分布和索引,且对轮询器睡着时新插入的更早任务不敏感,需要补一个唤醒信号;使用缓存:把热路径的查询降为 0,但缓存与数据库可能不一致,必须配定期全量校准和执行前二次确认;触发通知:由写入方唤醒消费者,空查询和延迟都最优,但只适合单消费者且复杂度最高。

发现内容有问题?

告诉我们哪里需要改进。