轮询设计
还记得我一开始是怎么实现订单超时取消的吗?
然后用 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);
这段代码能跑,但它是”教科书版本”,有三个地方在生产上必须改:
- 捞取和标记之间没有原子性。 两个轮询线程会捞到同一批任务,导致重复执行。生产版本要用
FOR UPDATE SKIP LOCKED抢任务,或者用《锁机制》里讲的分布式锁。 - 每条任务单独提交。 数据库往返次数等于任务数,批量收益为零。生产版本要用《批量处理》里的批量更新和批量占用。
- 固定间隔轮询。 任务稀疏的时候,绝大多数轮询都是空查询——
execute_at上虽然有索引,但索引只能让”找到”变快,不能让”没东西可查”这件事消失。解决办法见本章最后「想一想」里的方案 1 和方案 2。
基本表结构
数据设计要点
- 核心是在
delay_tasks里保存业务事实,而不是把规则散落在应用逻辑里。- 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
- 关键字段包括
id、task_id、task_type、task_data、execute_at、retry_count、max_retries、error_message,它们决定后续查询和管理能力。
字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
task_id | VARCHAR(100) | 任务唯一标识,用于取消、查询 |
task_type | VARCHAR(50) | 任务类型,用于路由到不同的处理器 |
task_data | TEXT | 任务数据(JSON 格式) |
execute_at | TIMESTAMP | 任务执行时间 |
status | ENUM | 任务状态 |
retry_count | INT | 重试次数 |
max_retries | INT | 最大重试次数 |
error_message | TEXT | 错误信息 |
consumer_id | VARCHAR(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 秒,不会因为退避而错过突发流量。
配套的还有两点:
- 按状态分开轮询。 待执行任务和已超时的异常任务是两种完全不同的数据,用不同的索引和不同的间隔轮询,异常任务可以被更快地发现。
- 多实例轮询要错峰。 多个消费者实例同时查同一张表会产生惊群效应——它们会同时查到同一批任务然后互相争抢。用实例 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 ZSet | score = execute_at | 多实例共享,可持久化 | 多一次网络往返 |
| 本地缓存 + 通知 | Caffeine + Pub/Sub | 读路径最快 | 失效逻辑复杂 |
方案 2 的收益是把数据库的”查询”变成了”只在冷启动和补偿时发生”,热路径完全在内存里。代价是缓存和数据库可能不一致:缓存里的任务已经被别的实例执行掉了、数据库里的新任务还没进缓存。所以缓存方案必须配两个兜底:定期从数据库全量校准,以及执行前二次确认任务状态。
这两个方案并不互斥,实际系统里常常一起用:缓存负责把热路径的查询降到 0,预估时间负责在没有缓存命中时也不做无意义的空查询。
方案 3:触发通知
方案对比:
| 方案 | 减少空查询 | 复杂度 | 适用场景 |
|---|---|---|---|
| 预估时间 | ⭐⭐⭐⭐⭐ | ⭐⭐ | 任务稀疏 |
| 使用缓存 | ⭐⭐⭐⭐ | ⭐ | 频繁查询 |
| 触发通知 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | 单消费者 |
最终答案:
三种方案可组合使用。预估下次查询时间:用 MIN(execute_at) 算出下一个到期点再睡过去,空查询几乎归零且精度更高,但依赖任务分布和索引,且对轮询器睡着时新插入的更早任务不敏感,需要补一个唤醒信号;使用缓存:把热路径的查询降为 0,但缓存与数据库可能不一致,必须配定期全量校准和执行前二次确认;触发通知:由写入方唤醒消费者,空查询和延迟都最优,但只适合单消费者且复杂度最高。
发现内容有问题?
告诉我们哪里需要改进。
