这是 Beta 探索课程,内容结构、实验步骤和示例可能会继续调整。
技术挑战
那次惨痛的事故回顾
在开始深入技术方案之前,让我先回顾一下我们系统遇到的所有问题。这些问题后来都成了我们设计延时队列时的核心挑战。
挑战 1:任务丢失
问题的发现
2024 年 4 月的一天,客服收到了 200 多条投诉:
“我下单后支付了,为什么第二天订单被取消了?!”
我慌了,赶紧查日志。发现:
- 用户 14:00 下单
- 用户 14:05 支付成功
- 服务器 14:30 重启
- 重启后,Redis 中的延时任务数据丢失(我们没有开启 AOF)
- 14:35,系统重新启动,数据库查询时没找到这个任务的记录
- 任务永远丢失了
后果:用户支付成功,但订单还是被取消了,愤怒要求退款。
根本原因分析
任务丢失的原因
1. 内存存储,无持久化
├─ Redis RDB 默认策略:可能丢失最近 5-15 分钟的数据
├─ 服务器重启:内存数据全部清空
└─ 恢复机制缺失:没有从数据库恢复任务的机制
2. 任务创建和执行分离
├─ 创建时:只写入内存(Redis)
├─ 支付时:取消任务(删除内存记录)
├─ 服务器重启:内存清空,任务记录消失
└─ 执行时:无从知晓任务曾经存在
3. 缺少补偿机制
├─ 任务丢失后无法发现
├─ 没有定期校验任务状态
└─ 没有死信队列记录失败任务解决方案思路
关键要点:
- ✅ 双层存储:Redis + 数据库,性能和可靠性兼顾
- ✅ 先持久化后内存:保证数据不丢
- ✅ 补偿机制:定期检查数据一致性
- ✅ 死信队列:记录异常任务,便于排查
挑战 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 | 待处理任务数 | > 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 | 持久化队列 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 补偿机制 | 定期校验 | ⭐⭐⭐⭐ | ⭐⭐ |
| 重试机制 | 指数退避 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
最佳实践:
- 数据永远先写数据库,再写缓存
- 所有操作都要有日志,便于故障排查
- 定期进行数据校验,发现不一致及时修复
- 设置合理的重试策略,避免无限重试
- 监控所有关键指标,及时发现问题