我在构建一个不信任 Redis 的作业调度器时的收获
Source: Dev.to

介绍
它是什么?
Tickr 是一个使用 Go、Redis 和 MySQL 构建的后台任务调度器。
它由一组等待分配任务的工作者组成。任务通过使用 Redis 实现的等待队列和就绪队列流转,并由调度器分配给工作者。所有操作并发执行,且基于事件驱动。
我为什么要构建它?
我已经厌倦了在 Node.js 和 Go 中构建 CRUD 后端 API。我想做点不同的、陌生的东西,这会迫使我思考并发、故障处理和系统行为,而不是仅仅关注端点和模式。
什么在 v1 中坏了?
轮询
在 v1 中,调度器每秒轮询一次以检查是否有作业准备执行。它能工作,但效率低下。我用无缓冲的 Go channel 和阻塞操作取代了轮询——channel 成为项目中最重要的概念之一。
假设
我假设很多东西会自行工作。当这些假设失效时,调试和恢复变得困难。
示例: 如果服务器在作业仍在等待队列中时终止,重启后恢复这些作业会很混乱且不可靠。
Redis 作为唯一真相来源
最初我只依赖 Redis 作为唯一的真相来源。边缘案例测试提出了令人不安的问题:
- 如果 Redis 在作业仍在排队时崩溃会怎样?
- 如果 Redis 在崩溃后丢失了状态会怎样?
- 如果作业中途失败会怎样?
- 如果我需要已完成作业的日志或历史记录怎么办?
对这些问题的回答需要重新设计,导致了 Tickr v2 的诞生。
Source: …
v2 架构
MySQL 作为唯一可信来源
在 v2 中,MySQL 存储完整的作业数据。只有 JobID 和调度信息会被推送到 Redis,Redis 仅用于调度和协作,而不承担持久化职责。

这种做法:
- 防止在崩溃时丢失作业
- 避免不必要的 MySQL 轮询
- 允许作业使用轻量级 ID 在队列之间移动
- 让工作节点仅在需要执行时从 MySQL 获取完整作业数据
调度器与工作节点
调度器运行一个单独的 goroutine(PopReadyQueue),在 Redis 上阻塞等待作业可用。
如果 Redis 断开连接:
- 调度器会等待 Redis 再次可达
- 检查 Redis 是否丢失了状态
- 若状态丢失,则从 MySQL 重建队列
从就绪队列弹出的作业会被发送到一个无缓冲的作业通道,所有工作节点都在监听该通道。当通道中出现作业时,恰好有一个工作节点会接收并执行它。
如果作业失败:
- 会在延迟后重试
- 重试次数受限(最多 3 次)
- 每次尝试的延迟线性递增
处理的边缘情况
Redis 崩溃
当 Redis 挂掉时,调度器会暂停并等待 Redis 恢复可用。Redis 恢复后,调度器会检查状态是否丢失,并在需要时从 MySQL 触发恢复;否则继续正常运行。
逾期任务
如果在任务已排期时 Redis 挂掉,任务的执行时间可能会过去却未被触发。之前这些任务会一直等到有新任务进入队列才会运行。我通过在等待队列重新填充时向调度器发送信号(使用 channel),强制立即重新评估逾期任务,从而解决了该问题。
执行期间关闭
如果服务器在任务执行过程中关闭:
- 工作线程停止接受新任务
- 正在执行的任务允许完成
- 程序在退出前等待工作线程结束
曾出现已完成任务无法在 MySQL 中更新状态的情况,因为全局 context 已被取消。我通过为最终状态更新专门使用一个后台 context 来解决此问题,确保即使在关闭过程中也能正确更新。
我学到的
这个项目与我以前构建的任何东西都非常不同,我非常享受其中的过程。我学到了:
- 为什么状态的所有权很重要
- 假设有多么脆弱
- 如何对失败进行推理,而不是回避它
- 如何设计能够恢复的系统,而不仅仅是重新启动
- 如何编写在边缘情况下面不易出错的后端代码
Tickr v2 教会我的后端系统知识,比任何教程都要多。
GitHub 仓库可在此处获取:Tickr