技术挑战

那次惨痛的事故回顾

在开始深入技术方案之前,让我先回顾一下我们系统遇到的所有问题。这些问题后来都成了我们设计延时队列时的核心挑战。


挑战 1:任务丢失

问题的发现

2024 年 4 月的一天,客服收到了 200 多条投诉:

“我下单后支付了,为什么第二天订单被取消了?!”

我慌了,赶紧查日志。发现:

  • 用户 14:00 下单
  • 用户 14:05 支付成功
  • 服务器 14:30 重启
  • 重启后,Redis 中的延时任务数据丢失(我们没有开启 AOF)
  • 14:35,系统重新启动,数据库查询时没找到这个任务的记录
  • 任务永远丢失了

后果:用户支付成功,但订单还是被取消了,愤怒要求退款。

根本原因分析

任务丢失的原因

1. 内存存储,无持久化
   ├─ Redis RDB 默认策略:可能丢失最近 5-15 分钟的数据
   ├─ 服务器重启:内存数据全部清空
   └─ 恢复机制缺失:没有从数据库恢复任务的机制

2. 任务创建和执行分离
   ├─ 创建时:只写入内存(Redis)
   ├─ 支付时:取消任务(删除内存记录)
   ├─ 服务器重启:内存清空,任务记录消失
   └─ 执行时:无从知晓任务曾经存在

3. 缺少补偿机制
   ├─ 任务丢失后无法发现
   ├─ 没有定期校验任务状态
   └─ 没有死信队列记录失败任务

解决方案思路

关键要点:

  1. 双层存储:Redis + 数据库,性能和可靠性兼顾
  2. 先持久化后内存:保证数据不丢
  3. 补偿机制:定期检查数据一致性
  4. 死信队列:记录异常任务,便于排查

挑战 2:任务重复执行

问题的发现

某天运营发现:库存扣减有问题。

正常情况下:

  • 用户下单:库存 -1
  • 订单取消:库存 +1

但实际数据显示:

  • 某个商品的总订单量:1000
  • 实际库存扣减:1500
  • 多扣了 500 件!

我查日志,发现了问题:同一个”取消订单”任务被执行了多次。

根本原因分析

任务重复执行的原因:

重复执行的场景

1. 消费者重启
   ├─ 任务刚从队列取出,还没执行完
   ├─ 消费者挂了,任务没有标记为完成
   └─ 消费者重启,又把任务取出来执行

2. 网络问题
   ├─ 任务执行成功,但确认 ACK 丢失
   ├─ 队列认为任务还没完成
   └─ 又把任务投递给其他消费者

3. 并发竞争
   ├─ 两个消费者同时弹出同一个任务
   ├─ 由于锁机制失效
   └─ 两个消费者同时执行

4. 重试机制
   ├─ 任务执行抛异常
   ├─ 进入重试队列
   └─ 但实际上任务已经执行了一部分

解决方案:幂等性设计

幂等性设计的三个层次:


挑战 3:执行精度

问题的发现

产品经理跑来找我:

“用户投诉了,他们下单后 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:时间补偿

方法 2:优先级队列

方法 3:动态调整轮询间隔

精度优化效果:

优化方法精度提升适用场景成本
时间补偿±100ms → ±10ms时钟偏差大的环境需要时间同步服务
优先级队列平均延迟降低 30%高并发场景需要排序开销
动态轮询响应速度提高 50%波动明显的场景复杂度增加

挑战 4:性能瓶颈

问题的发现

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
  • 本地缓存命中时,延迟从毫秒级降到微秒级
  • 适用于延时时间集中的场景

挑战 5:监控与运维

问题的发现

2024 年 8 月,我们的系统突然出现大量订单超时未取消。

但监控大盘显示:

  • 延时队列服务状态:✅ 正常
  • 任务创建成功率:✅ 99.9%
  • 任务执行成功率:✅ 99.9%

一切都看起来正常!

但实际情况:

  • 消费者进程虽然活着,但死锁了
  • 任务虽然创建成功,但被错误地路由到错误的队列
  • 任务虽然执行成功,但数据库更新失败了

结论:监控指标太粗粒度,无法发现问题。

监控指标设计

我们需要更细粒度的监控:

核心监控指标:

指标含义告警阈值严重程度
pending_count待处理任务数> 10000Warning
ready_count到期待执行任务数> 1000Critical
backlog_ratio积压率> 50%Warning
avg_delay平均执行延迟> 60sWarning
p99_delayP99 执行延迟> 300sCritical
process_rate任务处理速率< 创建速率的 80%Warning
failure_rate任务失败率> 1%Warning
consumer_health消费者健康度0(不健康)Critical

想一想

思考 1

如果你的系统要求任务绝对不能丢,你会采用什么方案?请从持久化、补偿、重试三个维度设计。

参考答案

需求分析:

“绝对不能丢”意味着:

  • 服务器重启时,任务不能丢
  • 数据库故障时,任务不能丢
  • 网络故障时,任务不能丢
  • 消费者崩溃时,任务不能丢

方案设计:三层可靠性保障

可靠性保障总结:

保障层次技术可靠性性能
L1: 数据库事务 + 日志⭐⭐⭐⭐⭐⭐⭐
L2: RedisZSet + 持久化⭐⭐⭐⭐⭐⭐⭐⭐⭐
L3: 备份 MQ持久化队列⭐⭐⭐⭐⭐⭐⭐⭐
补偿机制定期校验⭐⭐⭐⭐⭐⭐
重试机制指数退避⭐⭐⭐⭐⭐⭐⭐⭐

最佳实践:

  1. 数据永远先写数据库,再写缓存
  2. 所有操作都要有日志,便于故障排查
  3. 定期进行数据校验,发现不一致及时修复
  4. 设置合理的重试策略,避免无限重试
  5. 监控所有关键指标,及时发现问题