BoltDB+内存最小堆方案适合单实例定时通知调度:启动时加载过期任务立即执行、未来任务建堆;新任务先持久化再入堆;调度循环单goroutine休眠唤醒批量处理,兼顾可靠性、低开销与重启恢复。

高并发下队列数据不能等消费完再落盘,必须在入队时就完成可靠持久化,否则进程崩溃就会丢任务。
用 BoltDB + 内存最小堆做单实例持久化调度
适合定时通知、轻量后台任务等单机部署场景。BoltDB 轻量、ACID 安全、无依赖,但写入是同步阻塞的,直接每条任务都 BoltDB.Update 会严重拖慢吞吐。
- 启动时:从 BoltDB 加载所有
isSent = false且delayUntil ≤ now的任务,立即触发;再加载未来任务构建container/heap最小堆 - 新任务写入:先
db.Update持久化到 BoltDB,成功后再heap.Push到内存堆(需sync.Mutex保护) - 调度循环:只用一个 goroutine,
time.Sleep到堆顶时间点,唤醒后批量处理所有到期任务(标记isSent=true并从 DB 和堆中清理)
这种模式避免了轮询开销,也规避了为每个任务启 goroutine 导致的资源爆炸,重启后自动恢复未执行任务。
Redis ZSET 做分布式延时队列时的持久化陷阱
很多人直接用 ZPOPMIN 拿任务,但这是危险操作:它原子性删除却无法标记“正在处理”,多消费者会重复消费,失败后任务直接丢失。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
立即学习“go语言免费学习笔记(深入)”;
- 正确做法:用
ZRANGEBYSCORE delay_queue -inf <now_ms></now_ms>扫描,再通过 Lua 脚本原子性ZREM+HSET delay_processing <id><payload></payload></id> -
score必须用毫秒时间戳:time.Now().UnixMilli(),秒级会导致同秒内任务顺序不可控、扫描误触发 - 消费成功后,不能只发
HDEL delay_processing <id></id>—— 网络超时或 panic 会让 key 残留,需加守护协程定期扫描超时未完成的delay_processing记录并回滚
高频入队场景下如何避免 BoltDB / Redis 成为瓶颈
当 QPS 超过 500,单次 db.Update 或 ZADD 就会成为延迟热点。不能靠堆机器硬扛,得做写入缓冲和批量提交。
- 对 BoltDB:用
sync.Pool复用bytes.Buffer和序列化结构体,避免 GC 压力;把多个任务攒成 batch,用单次db.Update写入(注意事务大小别超 10MB) - 对 Redis:用 pipeline 批量
ZADD,而不是逐条调用;若使用 TaskQ 等框架,确认其redisq.Queue.Publish是否已内置 pipeline 优化 - 无论哪种存储,入队函数必须有明确超时(如
context.WithTimeout(ctx, 100*time.Millisecond)),超时直接返回ErrQueueFull,由上游决定降级或重试
为什么不用 MySQL 自增 ID 做队列主键
不是不能用,而是它在高并发队列场景下会暴露三个硬伤:写放大、锁竞争、分库后全局不唯一。
- 每条任务都要一次 INSERT,InnoDB 的自增锁(
auto-inc lock)在高并发插入时会串行化,吞吐卡死 - 如果做了分库分表,不同库的自增 ID 必然重复,无法作为全局任务 ID 追踪
- 更隐蔽的问题:MySQL 的
INSERT ... ON DUPLICATE KEY UPDATE在主从延迟时可能造成状态不一致,而队列要求强 exactly-once 语义
真正要兼顾性能与可靠性,应让 ID 生成与队列写入解耦:用 time.Now().UnixMilli() + 随机后缀生成任务 ID,写入时用该 ID 作为 BoltDB key 或 Redis member,而非依赖数据库自增。

















