设计原则
1. 简单性原则
“Simple is better than complex.”
原则说明:
- 从简单方案开始
- 避免过度设计
- 逐步演进
实践:
演进路径:
第 1 阶段:数据库轮询(最简单)
├─ 适合:< 10 万任务/天
├─ 优点:实现简单、可靠
└─ 缺点:性能有限
第 2 阶段:Redis ZSet(性能优化)
├─ 适合:10 万 ~ 1000 万任务/天
├─ 优点:高性能、易扩展
└─ 缺点:成本较高
第 3 阶段:混合架构(终极方案)
├─ 适合:> 1000 万任务/天
├─ 优点:性能和成本的平衡
└─ 缺点:复杂度高
2. 可靠性优先原则
“Data loss is unacceptable.”
原则说明:
- 任务不能丢
- 任务不能重复
- 任务不能遗漏
实践:
可靠性的三条防线(按实现成本从低到高):
第 1 道:任务不丢
├─ 写库在前、写缓存在后 → 库里有记录就一定不丢
├─ 双写 + 定期对账 → 定期发现两边的差异
└─ 重试 + 死信队列 → 失败不是消失,而是被记录下来
第 2 道:任务不重复
├─ 任务状态机(PENDING → PROCESSING → DONE)
├─ 执行前用状态机做条件更新 → UPDATE ... WHERE status = 'PENDING'
└─ 业务侧幂等(唯一索引) → 最后一道防线
第 3 道:任务不遗漏
├─ 定期全量校验:DB 里的 PENDING 任务是否都还在队列里
├─ 补偿任务:把漏掉的任务补回队列
└─ 对账报表:每天输出创建数 / 执行数 / 失败数
可靠性的成本要算在业务账上。 一个”不丢”的延时队列,存储成本通常是简单实现的 2~3 倍(双写、额外索引、死信表)。但订单超时不取消的代价,往往是这几倍存储成本的好几倍——这个取舍在《关键决策》里有过完整推导。
3. 性能可控原则
“Performance is a feature, but not the only feature.”
原则说明:
- 性能要满足业务需求
- 不追求极致性能
- 权衡性能和成本
实践:
性能目标要写清楚边界,而不是追求极限:
✅ 好的目标
"订单超时取消,99% 的任务在超时后 5 秒内执行"
❌ 不好的目标
"延时队列要尽可能快"
先量后调:
1. 定义 SLO → p99 延迟 ≤ 5s
2. 埋点 → 记录创建延迟、执行延迟、队列深度
3. 压测找瓶颈 → 逐项排除:存储 / 网络 / 计算
4. 只优化瓶颈 → 不要凭直觉优化
5. 回归验证 → 优化后重跑基准
先问”要不要这么快”。 秒杀倒计时要求毫秒级,但”7 天后自动确认收货”允许误差几小时。同一个系统里,不同类型的任务该用不同精度的方案——这也是《多队列》按延时范围分档的思路。
性能优化有明确的优先级,从上往下逐层做,收益递减:
| 顺序 | 手段 | 收益 | 代价 |
|---|---|---|---|
| 1 | 减少不必要的工作 | 最高 | 最低 |
| 2 | 批量化(减少往返次数) | 高 | 低 |
| 3 | 换更合适的存储 | 高 | 中 |
| 4 | 加缓存 | 中 | 中 |
| 5 | 分布式 / 分片 | 中 | 高 |
| 6 | 调优参数 | 低 | 低 |
4. 可扩展性原则
“Prepare for growth.”
原则说明:
- 设计要考虑未来增长
- 支持水平扩展
- 避免单点瓶颈
实践:
扩展性体现在两个方向:垂直扩展和水平扩展。
垂直扩展(先做这个):
├─ 换更大的机器 → 成本线性上升,有天花板
├─ 优化单条链路性能 → 见"性能可控原则"
└─ 瓶颈通常是 IO 而非 CPU → 先看慢查询、锁等待
水平扩展(量到了再做):
├─ 无状态化:调度器之间不共享内存
│ └─ 任务状态放共享存储,槽位归属由存储层保证
├─ 分片:按业务键哈希到 N 个队列
│ └─ 每个分片独立扩容,扩容粒度更细
├─ 无锁并发:用 SKIP LOCKED / 原子命令代替分布式锁
└─ 水平拆分的前提:任务能按 key 独立处理
扩容的信号:
├─ 队列深度持续增长,且消费速率追不上生产速率
├─ 单分片 CPU 饱和但整集群还有余量 → 该分片了
└─ 延迟 P99 随任务量线性上升 → 扫描成本过高
顺序很重要:先垂直,后水平。 水平扩展的复杂度是垂直扩展的好几倍——分片键选错、扩容时的数据迁移、跨分片的顺序保证,每一项都是长期的运维负担。很多系统在任务量还没到必须分片的时候,就提前上了分片架构,然后被分片键的选择折磨了两年。
《多队列》里”按业务线分队列”的切法,本质上就是为水平扩展预留的接口:等某个业务线的流量真的撑不住了,把它单独拆到一组节点上,其他业务线毫发无损。
5. 可观测性原则
“You can’t improve what you can’t measure.”
原则说明:
- 监控关键指标
- 记录详细日志
- 及时告警
实践:
可观测性的三支柱,按排障时的实际用途排序:
① 日志(回答"发生了什么")
- 每次任务流转都打一条,带 taskId
- 级别分明:正常流转用 INFO,异常用 WARN / ERROR
- 不要在热路径上打海量 INFO 日志
② 指标(回答"整体健康吗")
┌────────────────┬───────────────────────────┐
│ 创建速率 │ 创建数 / 秒 │
│ 执行速率 │ 执行数 / 秒 │
│ 积压深度 │ 各队列 PENDING 数 │
│ 执行延迟 │ execute_at → 实际执行的差值 │
│ 失败率 │ 失败数 / 总数 │
│ 重试次数 │ 分布直方图 │
└────────────────┴───────────────────────────┘
③ 链路(回答"卡在哪一步")
- 同一个 taskId 贯穿提交 → 入队 → 出队 → 执行
- 每一步单独打耗时,而不是只有总量
最关键的一条:延迟指标要按 P50 / P99 / P999 分开看。 平均值会把长尾抹平——“平均延迟 200ms” 听起来很好,但如果是 P99 = 30 秒,用户体验已经是坏的。《技术挑战》里”挑战 5:监控与运维”讲的”消费者进程活着但死锁了”,就是一个只有平均值就完全看不见的问题。
告警要能直接指向动作。 一条没有处置建议的告警,最后都会变成噪音:
❌ "Redis 延迟异常"
✅ "Redis 延迟 P99 > 500ms(持续 5 分钟)"
→ 可能原因:慢查询、大 key、连接池打满、宿主机 IO 争抢
→ 处置:先看 slowlog,再看 bigkeys
最后补一个容易被忽略的视角:把”任务最终有没有执行”做成一张对账表。 每天输出”创建数 / 执行数 / 失败数 / 未执行数”,未执行数持续大于 0 就说明有泄漏。这一张表能兜住所有监控指标都发现不了的静默失败。
分层设计
分层架构:
┌─────────────────────────────────────┐
│ 应用层(Application) │
│ ├─ 任务创建 API │
│ ├─ 任务取消 API │
│ └─ 任务查询 API │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ 业务层(Business) │
│ ├─ 任务路由逻辑 │
│ ├─ 任务编排逻辑 │
│ └─ 任务重试逻辑 │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ 服务层(Service) │
│ ├─ 任务调度服务 │
│ ├─ 任务执行服务 │
│ └─ 任务监控服务 │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ 存储层(Storage) │
│ ├─ Redis(热数据) │
│ ├─ MySQL(温数据) │
│ └─ Kafka(消息队列) │
└─────────────────────────────────────┘
解耦设计
分层架构解决的是”谁调用谁”,解耦设计解决的是”谁可以不知道谁”。延时队列最容易踩的坑,是把业务逻辑和调度逻辑搅在一起——结果就是换个存储方案要重写一遍业务代码。
耦合的写法:
业务代码 ──直接调用──► 延时队列实现
▲ │
│ ▼
└──── 依赖具体 API ── Redis ZSet / 数据库表结构
解耦的写法:
业务代码 ──► 接口(DelayQueue) ◄── 实现(RedisZSetDelayQueue / DbPollingDelayQueue)
│
▼
任务载荷(TaskPayload,JSON)
三条解耦线:
| 解耦维度 | 业务侧看到的 | 实现侧可以自由替换的 |
|---|---|---|
| 提交 | delayQueue.submit(task) | 存 Redis 还是写数据库 |
| 取消 | delayQueue.cancel(taskId) | ZREM 还是 UPDATE |
| 查询 | delayQueue.query(taskId) | 内存索引还是 SQL |
解耦带来的三个直接收益:
- 存储方案可以演进。 从数据库轮询换成 Redis ZSet,业务代码一行不改。这正是《关键决策》里”分层方案”能逐步落地的前提——如果业务代码里写死了
redis.opsForZSet(),任何一次架构调整都要重写所有调用方。 - 执行逻辑和调度逻辑分离。 调度器只负责”什么时候把任务取出来”,取出来之后交给一个
TaskHandler接口去执行。新增一类任务 = 新增一个 Handler,不改调度器。 - 故障可以隔离。 存储层(Redis)挂掉时,业务侧仍然能提交任务(降级到写数据库),而不是直接返回 500。
但解耦不是免费的。 需要付出三样东西:
- 多一层抽象和一次转换。 业务对象要序列化成通用的
TaskPayload,多一次 JSON 转换(微秒级,可以接受)。 - 抽象泄漏的风险。 任务表里有些字段是 Redis 实现特有的(比如 score 里的序列号),业务侧一旦用上,抽象就破了。规则是:业务侧只能看到通用字段,实现特有的字段由实现自己管。
- 调试链路变长。 一个任务没执行,要分别看业务日志、队列日志、调度日志。必须靠一个贯穿全链路的
taskId串起来。
判断有没有解耦过度的简单标准: 把存储从 Redis 换成 MySQL,如果需要改动的只有队列实现类和它的配置,那么解耦是到位的;如果改动散落在十几个业务类里都出现了队列相关的 import,说明抽象已经漏了。
发现内容有问题?
告诉我们哪里需要改进。
