设计原则

核心设计原则

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

解耦带来的三个直接收益:

  1. 存储方案可以演进。 从数据库轮询换成 Redis ZSet,业务代码一行不改。这正是《关键决策》里”分层方案”能逐步落地的前提——如果业务代码里写死了 redis.opsForZSet(),任何一次架构调整都要重写所有调用方。
  2. 执行逻辑和调度逻辑分离。 调度器只负责”什么时候把任务取出来”,取出来之后交给一个 TaskHandler 接口去执行。新增一类任务 = 新增一个 Handler,不改调度器。
  3. 故障可以隔离。 存储层(Redis)挂掉时,业务侧仍然能提交任务(降级到写数据库),而不是直接返回 500。

但解耦不是免费的。 需要付出三样东西:

  • 多一层抽象和一次转换。 业务对象要序列化成通用的 TaskPayload,多一次 JSON 转换(微秒级,可以接受)。
  • 抽象泄漏的风险。 任务表里有些字段是 Redis 实现特有的(比如 score 里的序列号),业务侧一旦用上,抽象就破了。规则是:业务侧只能看到通用字段,实现特有的字段由实现自己管。
  • 调试链路变长。 一个任务没执行,要分别看业务日志、队列日志、调度日志。必须靠一个贯穿全链路的 taskId 串起来。

判断有没有解耦过度的简单标准: 把存储从 Redis 换成 MySQL,如果需要改动的只有队列实现类和它的配置,那么解耦是到位的;如果改动散落在十几个业务类里都出现了队列相关的 import,说明抽象已经漏了。

发现内容有问题?

告诉我们哪里需要改进。