make(chan t, n) 是 go 中最轻量、天然 fifo 且并发安全的内存消息队列,但仅适用于原型验证或单机低负载场景;生产环境必须引入 wal 日志、消费者 ack 机制和独立存储层,否则消息丢失是常态。

直接上结论:用 goroutine + channel 实现的“内存队列”只适合原型验证或单机低负载场景;真要落地,必须引入 WAL 日志、消费者确认机制和独立存储层,否则消息丢失是常态,不是例外。
为什么 channel 不能直接当生产队列用
很多人一上来就写 chan *Message,以为加个 buffer 就能扛住流量。实际踩坑后才发现:
- 进程崩溃或重启时,
channel里所有未消费消息彻底消失,无任何恢复路径 -
channel容量固定,写满后send会阻塞或 panic(取决于是否带select+default),无法优雅降级 - 没有消费者状态跟踪,无法实现重试、死信、ACK 等关键语义
- 多个进程或机器间无法共享
channel,天然不支持分布式扩展
它本质是 Go 的并发原语,不是消息中间件接口。当成队列用,等于把内存当磁盘使。
WAL 日志必须在 write() 后 fsync()
很多轻量实现写了 os.WriteFile 或 file.Write 就以为持久化完成了,但操作系统缓存会让数据滞留在 page cache 里,断电即丢。真正可靠的 WAL 必须:
- 每次追加消息后调用
file.Sync(),强制刷盘 - 日志格式建议每行一条 JSON 或自定义二进制结构,避免解析失败导致整个文件不可读
- 不要用
os.O_APPEND单独开文件写——高并发下 POSIX append 不是原子的,可能产生错位或覆盖 - 考虑加 CRC 校验字段,防止磁盘静默错误破坏日志完整性
性能确实会下降,但这是“至少一次投递”的底线。你可以用批量写 + 定时 Sync 折中,但绝不能完全省略。
消费者 ACK 机制必须带超时重发
单纯靠 channel 推送不算交付完成。真实场景中,消费者可能处理一半 panic、网络中断、或卡死。正确做法是:
- 推送消息时生成唯一
delivery_id,并记录到本地存储(如 BoltDB 或 SQLite) - 消费者处理完必须显式调用
Ack(delivery_id),服务端才从待确认表中删除 - 后台启动 goroutine 定期扫描待确认表,对超时(比如 30s)未 ACK 的消息重新入队或进死信
- 重发前检查该
delivery_id是否已被 ACK(防重复)——这需要存储层支持事务或原子 compare-and-swap
这个逻辑看着琐碎,但跳过它,你的“可靠队列”在真实故障下和内存 channel 没区别。
SQLite 做存储比纯文件更可控
有人坚持用文件系统存消息,觉得“够简单”。但很快会遇到问题:
- 消息查找慢:按 topic 或 status 查询得遍历所有日志文件
- 并发写冲突:多个 goroutine 同时
open/append同一个文件,POSIX 行为不一致 - 无法原子更新状态:比如“标记某条消息为已处理”,文件系统没提供 atomic update 原语
而嵌入式 SQLite(如 github.com/mattn/go-sqlite3)自带 WAL 模式、行级锁、ACID 事务,且单文件部署。用它建三张表:messages、deliveries、consumers,就能支撑 ACK、重试、延迟调度等基础能力,代码反而更清晰。
真正的复杂点不在代码量,而在状态一致性边界——你得明确说出“哪一步失败会导致什么后果”,而不是依赖“应该不会出问题”。轻量,不等于可以绕过分布式系统的基本约束。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











