技术挑战
在开始深入技术方案之前,让我先回顾一下我们系统遇到的所有问题。这些问题后来都成了我们设计延时队列时的核心挑战。
问题的发现
2024 年 4 月的一天,客服收到了 200 多条投诉:
“我下单后支付了,为什么第二天订单被取消了?!”
我慌了,赶紧查日志。发现:
- 用户 14:00 下单
- 用户 14:05 支付成功
- 服务器 14:30 重启
- 重启后,Redis 中的延时任务数据丢失(我们没有开启 AOF)
- 14:35,系统重新启动,数据库查询时没找到这个任务的记录
- 任务永远丢失了
后果:用户支付成功,但订单还是被取消了,愤怒要求退款。
根本原因分析
任务丢失的原因
1. 内存存储,无持久化
├─ Redis RDB 默认策略:可能丢失最近 5-15 分钟的数据
├─ 服务器重启:内存数据全部清空
└─ 恢复机制缺失:没有从数据库恢复任务的机制
2. 任务创建和执行分离
├─ 创建时:只写入内存(Redis)
├─ 支付时:取消任务(删除内存记录)
├─ 服务器重启:内存清空,任务记录消失
└─ 执行时:无从知晓任务曾经存在
3. 缺少补偿机制
├─ 任务丢失后无法发现
├─ 没有定期校验任务状态
└─ 没有死信队列记录失败任务
解决方案思路
关键要点:
- ✅ 双层存储:Redis + 数据库,性能和可靠性兼顾
- ✅ 先持久化后内存:保证数据不丢
- ✅ 补偿机制:定期检查数据一致性
- ✅ 死信队列:记录异常任务,便于排查
问题的发现
某天运营发现:库存扣减有问题。
正常情况下:
- 用户下单:库存 -1
- 订单取消:库存 +1
但实际数据显示:
- 某个商品的总订单量:1000
- 实际库存扣减:1500
- 多扣了 500 件!
我查日志,发现了问题:同一个”取消订单”任务被执行了多次。
根本原因分析
任务重复执行的原因:
重复执行的场景
1. 消费者重启
├─ 任务刚从队列取出,还没执行完
├─ 消费者挂了,任务没有标记为完成
└─ 消费者重启,又把任务取出来执行
2. 网络问题
├─ 任务执行成功,但确认 ACK 丢失
├─ 队列认为任务还没完成
└─ 又把任务投递给其他消费者
3. 并发竞争
├─ 两个消费者同时弹出同一个任务
├─ 由于锁机制失效
└─ 两个消费者同时执行
4. 重试机制
├─ 任务执行抛异常
├─ 进入重试队列
└─ 但实际上任务已经执行了一部分
解决方案:幂等性设计
幂等性设计的三个层次:
问题的发现
产品经理跑来找我:
“用户投诉了,他们下单后 29 分钟订单就被取消了,不是说 30 分钟吗?”
我查了一下日志:
- 用户 14:00:30 下单
- 定时任务 14:30:05 执行(应该 14:30:30 执行,但 cron 是每分钟的第 0 秒执行)
- 订单超时判定:14:30:05 - 30 分钟 = 14:00:05
- 因为 14:00:30 > 14:00:05,所以被判定为超时
误差接近 1 分钟!
用户觉得被骗了——说好的 30 分钟,怎么 29 分钟就取消了?
精度问题分析
不同方案的精度对比:
| 方案 | 精度 | 误差来源 | 适用场景 |
|---|---|---|---|
| 数据库轮询 | 分钟级 | 轮询间隔(如 1 分钟) | 低精度要求 |
| Redis ZSet | 毫秒级 | 消费者轮询间隔 | 高精度要求 |
| 时间轮 | 毫秒级 | 时间轮粒度 | 固定间隔延时 |
| 消息队列 | 毫秒级 | 网络延迟、消费延迟 | 高精度要求 |
误差来源分解:
延时执行的实际延时 = 业务指定的延时 + 系统误差
系统误差 = Δ1 + Δ2 + Δ3 + Δ4
Δ1: 调度延迟
└─ 调度器检查队列的间隔(如 1 秒)
Δ2: 消费延迟
└─ 消费器处理任务的耗时
Δ3: 网络延迟
└─ 任务从队列传输到消费者的时间
Δ4: 时钟偏差
└─ 不同服务器的时钟不一致
提高精度的方法
方法 1:时间补偿
思路:既然误差主要来自时钟偏差,那就先量出偏差,再把偏差补偿掉。
最朴素的做法是让所有节点都做时间同步(NTP / Chrony),但 NTP 是”尽力而为”的:网络抖动时偏移可以到几十甚至几百毫秒,而且偏差是持续漂移的,不是一次性校准就固定了。
主动测量偏移。 客户端定期 ping 一台权威时间源,用”往返时间的一半”当作单程延迟的估计:
long rtt0 = System.nanoTime();
long t0 = timeSource.now();
long t1 = timeSource.now(); // 立即再取一次,估算服务端处理耗时
long rtt1 = System.nanoTime();
long rtt = rtt1 - rtt0;
long offset = t0 + rtt / 2 - (rtt0 + rtt1) / 2; // 本机时钟 - 时间源时钟
这个 offset 就是本机时钟的偏差。把它应用到 score 上:
// 本机时钟快了 offset 毫秒,真实到期时间就要往后推 offset
long correctedDeadline = task.executeAt - clockOffset;
zset.add(correctedDeadline, task.id());
更彻底的做法是统一改用 Redis 服务端的时间来算 score,让”谁写的”不影响”排在哪个位置”:
// 在 Redis 端用 TIME 命令取服务器时间,而不是用客户端的 System.currentTimeMillis()
抖动也要补偿。 单次测量出来的 offset 本身有抖动,直接用会忽快忽慢。做法是保留一个滑动窗口(例如最近 10 次测量),取中位数而不是平均值——中位数对偶发的大延迟不敏感:
offset = median(recentOffsets); // 抗抖动
| 做法 | 精度 | 成本 |
|---|---|---|
| 只用 NTP 同步 | ±100ms | 最低,但漂移不可控 |
| 主动测量 + 中位数补偿 | ±10ms | 需要一个时间源和一个探测线程 |
| 统一用 Redis 服务端时间 | ±5ms | 需要多一次往返 |
方法 1 的前提是所有节点都愿意被补偿。如果集群里混着几台时钟不准的机器,或者补偿逻辑本身有 bug,误差就会被放大——所以它必须配合监控:把每次测出来的 offset 记录下来,看它的分布,而不是只看一个平均值。分布里偶尔出现的尖峰,正是”某台机器时钟跳变”的信号。
方法 2:优先级队列
思路:不把所有任务放在一条 FIFO 队列里,而是按”真正的截止时间”排序,谁快到期谁先出队。
这解决的是 FIFO 队列的一个经典问题:队头阻塞。如果队列头部堆着 1000 个 24 小时后才到期的任务,而后面有一个 5 秒后就到期的任务,FIFO 会让后者一直等着——哪怕队列里明明有”现在就能执行”的任务。
优先级队列按真实截止时间排序,出队时挑最小值:
PriorityQueue<DelayTask> queue =
new PriorityQueue<>(Comparator.comparingLong(DelayTask::executeAt));
Redis 里的对应物就是 ZSet 本身——score 存执行时间,Redis 内部天然就是按 score 排序的,取到期任务用 ZRANGEBYSCORE 而不是 LRANGE:
ZRANGEBYSCORE delay:queue 0 1710505800000 LIMIT 0 200
这个”取最小值”的能力就是优先级队列的全部价值所在:
| 队列形态 | 出队顺序 | 队头阻塞 |
|---|---|---|
| FIFO 链表 | 入队顺序 | ❌ 严重 |
| 最小堆 | 按 executeAt 排序 | ✅ 无 |
| Redis ZSet | 按 score 排序 | ✅ 无 |
代价是排序开销。链表插入是 O(1),堆插入是 O(log n),ZSet 插入是 O(log n) 加一次网络往返。对”每天一千万任务”的量级,这个开销必须实测,不能想当然。
还有一个隐含收益:优先级队列天然支持”提前到期的任务插队”。 业务上临时要求某个任务提前执行时,只要把它的 score 改小(ZADD 同一个 member,新 score 覆盖旧 score),它就会自动排到前面,不需要任何额外的队列操作。FIFO 做不到这一点——任务已经在队列中间了,想让它插队只能重建整个队列。
方法 3:动态调整轮询间隔
精度优化效果:
| 优化方法 | 精度提升 | 适用场景 | 成本 |
|---|---|---|---|
| 时间补偿 | ±100ms → ±10ms | 时钟偏差大的环境 | 需要时间同步服务 |
| 优先级队列 | 平均延迟降低 30% | 高并发场景 | 需要排序开销 |
| 动态轮询 | 响应速度提高 50% | 波动明显的场景 | 复杂度增加 |
问题的发现
2024 年 6 月 18 日,我们做了一次”618 大促”活动。
活动开始前,我们做了充分的准备:
- 数据库扩容到 32 核 128GB
- Redis 集群扩容到 3 主 3 从
- 消费者增加到 20 个节点
但活动开始 10 分钟后,系统还是挂了。
监控显示:
- 延时任务创建 QPS:8000
- 消费者处理能力:5000
- 任务积压:每秒增加 3000
- Redis 内存占用:90%
- 消费者 CPU:100%
结论:生产速度 > 消费速度,系统雪崩。
性能瓶颈分析
性能瓶颈的三个层面
1. 存储层瓶颈
├─ 数据库:连接池耗尽
├─ Redis:带宽和内存不足
└─ 消息队列:分区不足
2. 网络层瓶颈
├─ 消费者和存储之间的高频交互
├─ 序列化/反序列化开销
└─ 网络带宽限制
3. 计算层瓶颈
├─ 单个消费者处理能力有限
├─ 锁竞争导致性能下降
└─ GC 或内存问题
性能优化方案
优化 1:批量处理
性能提升:
- 查询次数:1 次 / 100 个任务(减少 100 倍)
- 删除次数:1 次 / 100 个任务(减少 100 倍)
- 网络往返:1 次批量 vs 100 次单次(减少 100 倍)
优化 2:多队列分片
性能提升:
- 10 个分片,理论吞吐量提升 10 倍
- 分片之间无竞争,充分利用多核 CPU
- 每个分片独立扩容,灵活调整
优化 3:缓存热点数据
性能提升:
- 热点任务查询减少 99% 的网络 I/O
- 本地缓存命中时,延迟从毫秒级降到微秒级
- 适用于延时时间集中的场景
问题的发现
2024 年 8 月,我们的系统突然出现大量订单超时未取消。
但监控大盘显示:
- 延时队列服务状态:✅ 正常
- 任务创建成功率:✅ 99.9%
- 任务执行成功率:✅ 99.9%
一切都看起来正常!
但实际情况:
- 消费者进程虽然活着,但死锁了
- 任务虽然创建成功,但被错误地路由到错误的队列
- 任务虽然执行成功,但数据库更新失败了
结论:监控指标太粗粒度,无法发现问题。
监控指标设计
我们需要更细粒度的监控:
核心监控指标:
| 指标 | 含义 | 告警阈值 | 严重程度 |
|---|---|---|---|
| pending_count | 待处理任务数 | > 10000 | Warning |
| ready_count | 到期待执行任务数 | > 1000 | Critical |
| backlog_ratio | 积压率 | > 50% | Warning |
| avg_delay | 平均执行延迟 | > 60s | Warning |
| p99_delay | P99 执行延迟 | > 300s | Critical |
| process_rate | 任务处理速率 | < 创建速率的 80% | Warning |
| failure_rate | 任务失败率 | > 1% | Warning |
| consumer_health | 消费者健康度 | 0(不健康) | Critical |
思考 1
如果你的系统要求任务绝对不能丢,你会采用什么方案?请从持久化、补偿、重试三个维度设计。
解题思路:
“绝对不能丢”必须拆成四个具体失败点才能逐个兜底:服务器重启、数据库故障、网络故障、消费者崩溃。任何一点没有闭合任务就会丢,所以要分层加固,并留一个不依赖链路的校验兜底。
详细步骤:
需求分析:
“绝对不能丢”意味着:
- 服务器重启时,任务不能丢
- 数据库故障时,任务不能丢
- 网络故障时,任务不能丢
- 消费者崩溃时,任务不能丢
方案设计:三层可靠性保障
可靠性保障总结:
| 保障层次 | 技术 | 可靠性 | 性能 |
|---|---|---|---|
| L1: 数据库 | 事务 + 日志 | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| L2: Redis | ZSet + 持久化 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| L3: 备份 MQ | 持久化队列 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 补偿机制 | 定期校验 | ⭐⭐⭐⭐ | ⭐⭐ |
| 重试机制 | 指数退避 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
最佳实践:
- 数据永远先写数据库,再写缓存
- 所有操作都要有日志,便于故障排查
- 定期进行数据校验,发现不一致及时修复
- 设置合理的重试策略,避免无限重试
- 监控所有关键指标,及时发现问题
最终答案:
用三层可靠性保障:L1 数据库事务加日志作为事实来源,L2 Redis ZSet 加持久化做调度索引,L3 备份消息队列做最终防线,再叠加补偿机制(定期校验数据库与 Redis 的一致性)和重试机制(指数退避)。落地时数据永远先写数据库再写缓存、所有操作留日志、定期校验一致性、设置有限重试策略避免无限重试,并监控所有关键指标。
发现内容有问题?
告诉我们哪里需要改进。
