做后端开发的基本都躲不开生产者消费者模式:面试题里年年出现的 wait/notify,工作里天天接触的 Kafka、RabbitMQ,本质上都在解决同一件事——把生产消息和消费消息两个动作拆开,让它们不用互相等。这次我用 Go 从零手写了一个内存版消息队列系统,目标很直接:用 goroutine 加 channel 把生产者消费者模式做扎实,同时把高并发环境下队列关闭、优雅退出、背压、panic 隔离这些容易翻车的点一个一个踩平。
这个项目核心代码只有几百行,不需要依赖任何第三方库,跑起来之后能清晰看到生产端和消费端的协作关系,也能自己动手改参数观察队列行为。适合正在学 Go 并发、面试前想补一补高并发知识点、或者想在项目里引入轻量级异步处理但不想上重型中间件的人。如果你最后想玩转 Kafka 这类分布式消息队列,先把这个单机版本吃透,底层思路都是相通的。
1. 项目从哪来:先明白消息队列解决的到底是什么问题
1.1 需求拆解:日志异步落盘这个场景
我最初做这个项目是因为手头有个服务需要写操作日志,量不大但很频繁。如果每次请求都同步写磁盘,接口延迟会被 IO 拖住;如果把日志全部攒在内存里又怕堆积太多把内存撑爆。这种场景就是典型的生产者消费者问题:请求线程是生产者,日志写入线程是消费者,中间需要一段缓冲区,让请求不用等磁盘,同时缓冲区有上限,防止内存被打满。
围绕这个场景,我给项目定了几个需求。第一,支持多个生产者并发往队列里丢消息;第二,支持多个消费者从队列里取消息处理,处理逻辑需要可替换;第三,队列满的时候不能让生产者无脑一直等,要支持超时或者直接返回错误;第四,程序关闭时要优雅退出,先把队列里的存量消息处理完,再释放资源;第五,要有消息数量统计,方便压测时看吞吐量。
这些需求对应到工程上就是:并发安全、缓冲容量控制、超时控制、生命周期管理、原子计数。等我把这些点全部用 Go 实现一遍之后,再看 Kafka 官方文档里说的“partition、buffer、consumer group”,会发现很多概念其实都能对应上,只是分布式场景下多了一套网络和副本机制。
1.2 为什么拿 Go 写而不是 Java 或 Python
单说结果的话,Java 的 BlockingQueue、Python 的 queue.Queue 其实都能实现类似功能,但我最终选了 Go,原因有两条。
第一,Go 的 goroutine 非常轻量,起十万个也不至于把内存吃穿。消息队列天生需要大量并发消费者,如果用 Java 线程做,一个消费者一个线程,量大了之后线程上下文切换成本很高;用 Go 的话,起几十个 goroutine 做 consumer worker 池,开销极低,代码写起来也直接,不用纠结线程池参数。
第二,channel 是 Go 语言内置的并发原语,它本身就是一个线程安全的 FIFO 队列。做消息队列的核心就是传递消息,而 channel 恰好把这个语义做进了语法层面。生产者往 channel 里发,消费者从 channel 里收,天然带同步和阻塞语义,不需要再引入任何中间件。对比一下,Java 里需要从 BlockingQueue 接口里选实现类,Python 里还要注意 GIL 对性能的影响,Go 写起来是最省心的。
| 维度 | Go chan | Java BlockingQueue | Python queue.Queue |
|---|---|---|---|
| 并发模型 | goroutine + channel | 线程 + 锁 | 线程 + 锁 |
| 代码简洁度 | 高 | 中,需要选实现类 | 中 |
| 性能上限 | 高,调度成本低 | 中高,线程切换有成本 | 中,受GIL影响 |
| 内置超时控制 | select + context 直接支持 | 需要 poll 或加锁实现 | get(timeout) 有限支持 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心实现:用 channel 搭一个最小可跑的队列系统
2.1 第一个设计决策:用无缓冲还是带缓冲 channel
channel 分两种:无缓冲和有缓冲。无缓冲 channel 的特点是发送方必须等接收方取走数据才返回,否则阻塞。这其实就是一个同步点,生产者和消费者必须同时在线才能完成一次交接。有缓冲 channel 则允许生产者在缓冲区未满时先放进去,不用等消费者,真正实现了生产消费的异步解耦。
消息队列当然要选有缓冲 channel,因为异步的本质就是允许消息在中间暂存。缓冲容量就是队列长度,决定了系统能容忍多大程度的“产消速度差”。我刚开始实现时用了一个固定容量的 channel,缓冲设为 1024,这个值不是拍脑袋定的,而是根据日志场景估算出来的:高峰期每秒大概产生 5000 条日志,消费者批量落盘可以做到每秒 10000 次,理论上缓冲区不会堆积,但为了保证 IO 抖动时不丢请求,留出 1024 的缓冲足够覆盖几秒钟的消费延迟。
真正动手的时候要注意一个点:channel 的容量是固定不变的,不能在运行期扩容。如果预估值不准,只能重启服务调整。所以设计时要留出余量,或者用动态队列(slice + 锁)自己实现可扩容缓冲,但这会牺牲 channel 自带的阻塞和唤醒机制,除非必要,不建议一开始就自己做。
2.2 生产者端实现:Publish 方法
生产者端最核心的是 Publish 方法。一开始我写得很简单,直接 queue <- msg,这在小流量下完全没问题。但是一旦消费者处理速度跟不上,channel 满了之后,这个发送操作就会永久阻塞,整个生产者 goroutine 卡死,请求处理线程全部堵在队列上,系统雪崩。
解决思路是给发送操作加超时和中断机制。Go 提供了 select 语法,可以同时监听 channel 的发送和 context 的取消信号。如果发送成功,立即返回 nil;如果超时或者收到退出信号,就返回错误。这一步虽然只有几行代码,却是整个队列系统从“能跑”到“扛得住”的关键。
go复制func (b *Broker) Publish(ctx context.Context, msg *Message) error {
select {
case b.queue <- msg:
b.produced.Add(1)
return nil
case <-ctx.Done():
return ctx.Err()
case <-b.done:
return ErrBrokerClosed
}
}
这里有三个分支,缺一个都不行。第一个分支是正常发送;第二个分支处理调用方主动取消或者超时;第三个分支处理系统正在关闭。如果不监听 b.done 的话,消费者已经全部退出了,生产者还在往队列里发,消息永远没人处理,就成了事实上的内存泄漏。这个细节我在第一版实现里就漏了,结果测试优雅退出时总是有一批消息卡在队列里。
2.3 消费者端实现:多 worker 消费
消费者端我选择了 worker pool 模式,也就是一次启动 N 个 goroutine,每个 goroutine 都从一个共享 channel 里取消息。这样既保证了并发消费,又能通过控制 worker 数量来限制并发度,避免下游服务被突然打满。
每个 worker 内部是一个 for 循环,用 select 同时监听队列消息和退出信号。从 channel 接收消息时要注意 ok 这个布尔值:当 channel 被关闭且队列里没有剩余消息时,接收操作会返回零值和 false。如果只看消息不看 ok,就会拿到一个 nil 指针,再往后处理必然 panic。
go复制func (b *Broker) worker(ctx context.Context, handler func(*Message) error) {
defer b.wg.Done()
for {
select {
case <-ctx.Done():
return
case <-b.done:
return
case msg, ok := <-b.queue:
if !ok {
return
}
b.protect(msg, handler)
}
}
}
worker 数量的选择也需要结合实际情况。如果消费者处理的是纯内存计算,worker 数量可以接近 CPU 核数;如果是磁盘 IO 或者网络请求,worker 数量可以稍微多一点,因为大部分时间在等待。我一般先设置成运行时 NumCPU 的两倍,再通过压测观察吞吐曲线,找到一个平台期就是最优值。不要盲目追求大 worker 数,因为每个 worker 都在抢同一个 channel,过多的切换反而会降低性能。
2.4 优雅退出:为什么不能随手 close(queue)
优雅退出是消息队列最容易踩坑的地方,也是我这次项目里花了最多时间调的地方。很多人写完生产者和消费者之后,自然想到:结束的时候 close(queue),消费者 range 循环就会自动退出,多简洁。但这里有个致命问题:如果 close(queue) 的时候还有生产者 goroutine 在往队列里发消息,就会触发 send on closed channel panic,而且这种 panic 一旦发生,整个程序直接崩溃。
正确的关闭顺序是先停止生产者,再关闭队列,最后等待消费者处理完存量。我在实现里拆成了两步:第一步 close(b.done) 广播退出信号,所有 Publish 和 worker 收到信号后停止收发;第二步等待所有消费者 goroutine 通过 WaitGroup 退出;第三步才关闭 channel。如果调用方在关闭时还希望把队列里的消息排空,可以再加一个 drain 阶段,消费者退出前先把队列读干净。
go复制func (b *Broker) Close(ctx context.Context) error {
var err error
b.once.Do(func() {
close(b.done)
// 等待所有消费者退出
done := make(chan struct{})
go func() {
b.wg.Wait()
close(done)
}()
select {
case <-done:
// 这里才能安全关闭,因为生产者已经收到退出信号不再发送
case <-ctx.Done():
err = ctx.Err()
}
})
return err
}
这里的 sync.Once 也很关键。Close 方法可能被系统信号、运维脚本、或者其他协程同时触发,如果没有 Once 保护,两个 goroutine 同时执行 close(b.done) 会让第二个 close 直接 panic。用 Once 之后,无论调用多少次,关闭动作只执行一次,这是工程化的基本要求。
3. 高并发优化:从能跑到跑稳
3.1 背压机制:消费者跟不上时怎么办
一个队列如果消费者处理不过来,最直接的后果就是缓冲区被填满。缓冲区满了之后,Publish 方法会阻塞或者超时返回错误,这种把压力反向传递给生产者的机制叫背压。
背压是保护系统稳定性的熔断机制。很多初学并发编程的人会觉得,消费者慢了就把缓冲区开大一点,让它慢慢消费不就行了。听起来合理,但你真的把缓冲区设成十万甚至百万,当消费者卡死的时候,十万条消息堆积在内存里,每条消息按 1KB 算就是 100MB 内存,很快就把服务打爆。所以缓冲区的本质是给生产者提供一个短期容错空间,不是用来无限缓存数据的。
在实现中,背压体现在 Publish 的 select 分支上。当 queue 满了,select 会一直阻塞,直到有消费者取走消息腾出空间。如果此时我们通过 context 设置了 500ms 超时,生产者就只会阻塞 500ms,之后返回 timeout 错误。调用方拿到错误后可以自己决定是重试、丢弃还是把消息写到本地临时文件。这种设计让系统在面对突发流量时不是无声无息地卡死,而是给出明确的反馈。
另外还有一个细节:消费者处理完一条消息后,如果此时队列积压严重,可以主动 sleep 一小段时间(比如 10ms 到 50ms),给生产者留出缓冲空间,避免消费者一直抢 channel 导致生产者长期无法入队。这个叫“限速消费”,在对接外部慢服务时非常有用。
3.2 panic 隔离:别让一个烂 handler 干掉整个进程
消费者处理消息时调用的 handler 是外部传入的,里面可能做数据库写入、调第三方接口、处理业务逻辑,任何一处 panic 都可能让 worker goroutine 崩溃。Go 里 goroutine 一旦 panic 且没有被 recover,整个进程都会退出,这在生产环境是灾难级的事故。
解决办法是在 worker 里给 handler 包一层 recover。我单独抽出来一个 protect 方法,handler 执行前 defer 一个 recover,把 panic 记录成 error 日志,然后继续消费下一条消息。这看起来很简单,但实际很多团队的项目里都没有这一步,因为单测一般触发不到 panic 路径。如果你的消息队列对接了不稳定的下游系统,一定要加上。
go复制func (b *Broker) protect(msg *Message, handler func(*Message) error) {
defer func() {
if r := recover(); r != nil {
b.failures.Add(1)
// 这里上报到日志系统,或者走死信队列
}
}()
if err := handler(msg); err != nil {
b.failures.Add(1)
}
}
至于 panic 的那条消息怎么处理,我项目里简单粗暴地累加了一个失败计数器,方便压测时观察。更完整的方案是准备一个死信队列,把处理失败的消息单独存放,之后统一重放。这个可以根据业务需要扩展,但 panic 不能中断消费者循环这一点是底线。
3.3 并发安全与统计:什么时候需要锁
channel 本身是并发安全的,多个 goroutine 同时读写不会产生数据竞争。但队列系统除了 channel 之外,还需要一些统计字段,比如生产总数、消费总数、失败总数。这些字段如果直接用 int 类型,多个 goroutine 同时累加会产生数据竞争,Go 的 race detector 会直接报警。
统计计数用 sync/atomic 包是最优解,累加操作是 CPU 指令级别原子性,不需要加锁。我代码里用 atomic.Int64 类型,直接调用 Add 方法即可。这里有一个经验:能用 atomic 解决的就不要上 sync.Mutex,atomic 的开销比锁小一个数量级,在高频统计场景下非常明显。
如果还要维护更复杂的状态,比如队列里各类型消息的数量统计,那就需要上 Mutex 保护一个 map。但要注意锁的粒度,不要在锁内做任何耗时操作,比如 IO、序列化,否则锁竞争会直接拉低整个队列的吞吐。我见过有人把日志格式化放在锁里面,结果并发一高整个系统吞吐掉了一半。
3.4 从单机内存队列到 Kafka:概念对照
写完后我发现,虽然这套实现只有几百行,但和 Kafka 的核心模型已经能对上了。Kafka 里的 topic 对应某种消息类型,partition 是有序分片,每个 partition 的追加写入和顺序消费机制,本质上就是多个有序队列的组合。消费者组里的每个消费者负责一个或多个 partition,和我这个 worker pool 里每个 worker 从同一个 channel 拉消息,语义是类似的。
区别主要在三点。第一,Kafka 的 channel 变成了磁盘上的日志段,消息不丢是因为持久化;第二,Kafka 的缓冲变成了 OS page cache,具备远大于内存的缓存能力;第三,Kafka 支持消费者 offset 提交,消费到哪一条是可以记录的,而我这个内存版一旦进程重启,队列里的消息就全部丢失。
这种对照的意义在于:当你理解了单机版本如何用 channel 实现生产、缓冲、消费、退出的完整链路,再看 Kafka 的文档和源码,会发现自己不是从零开始,而是已经有了一个基本的心智模型。分布式只是把单机里的 channel 换成了网络协议和存储引擎,核心的生产者消费者思维没有变。
4. 实测与排查:压测脚本、问题复盘、调优方向
4.1 怎么对一段并发代码做可靠的压测
我压测的思路比较简单直接:启动固定数量的生产者 goroutine,每个 goroutine 循环发布 N 条消息;消费者统计实际收到的消息数。所有生产者结束后,记录总耗时和每秒吞吐。这种办法不考虑复杂的流量模型,但足够用来对比参数调整前后的差异。
go复制func main() {
broker := NewBroker(1024)
ctx, cancel := context.WithCancel(context.Background())
var handled atomic.Int64
broker.Consume(ctx, runtime.NumCPU()*2, func(*Message) error {
handled.Add(1)
// 模拟一点处理耗时
time.Sleep(100 * time.Microsecond)
return nil
})
const producers = 8
const perProducer = 100000
var wg sync.WaitGroup
start := time.Now()
for p := 0; p < producers; p++ {
wg.Add(1)
go func() {
defer wg.Done()
publishCtx, cancelPublish := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelPublish()
for i := 0; i < perProducer; i++ {
if err := broker.Publish(publishCtx, &Message{ID: int64(i), Data: []byte("hello")}); err != nil {
return
}
}
}()
}
wg.Wait()
cancel()
broker.Close(context.Background())
fmt.Printf("handled=%d elapsed=%v rate=%.1f msg/s\n",
handled.Load(), time.Since(start),
float64(handled.Load())/time.Since(start).Seconds())
}
压测时我踩过一个坑,就是统计丢消息。生产端显示已经成功发布了 80 万条,消费者只收到 60 万,差值去哪了?排查后发现,consumer 退出信号 cancel 之后,队列里还剩一部分消息没有处理完成,但我在统计时已经记录为“结束”了。这其实不是数据丢失,是统计方式不对。正确的做法是先关闭生产者,再让消费者把队列排空,最后才统计总数。所以压测脚本里 cancel 之后要有一个 drain 等待过程,否则数据对不上,容易误判性能。
4.2 一组参考数据:buffer 大小和 worker 数量的影响
在我本机(8 核 M 系列芯片,16GB 内存)上,跑上面这个压测脚本,两个参数的调整会带来明显差异。以下数据是在 handler 每条耗时约 0.1ms 的条件下得到的,不同配置结果会有出入,但趋势可以参考。
| 缓冲区大小 | worker数量 | 总吞吐(msg/s) | 现象 |
|---|---|---|---|
| 256 | 16 | 约 18 万 | 生产者经常超时,吞吐受限 |
| 1024 | 16 | 约 26 万 | 平衡 |
| 4096 | 16 | 约 27 万 | 吞吐接近上限 |
| 1024 | 4 | 约 12 万 | worker 数不足,下游处理慢 |
| 1024 | 32 | 约 28 万 | 轻微提升,但开销变大 |
缓冲区太小,生产者会频繁被背压阻塞,如果请求量大,大量 Publish 超时返回,单靠增大并发并不能解决问题。缓冲区增大到一定程度后吞吐进入平台期,继续增大只会浪费内存。worker 数量和下游处理能力强相关,不是越多越好,超过某个临界点会因调度开销而出现吞吐下降。
4.3 常见问题速查表
经历过这几轮开发和压测,我把最容易翻车的几个问题整理成了一张速查表,方便以后直接查。
| 现象 | 根因 | 解法 |
|---|---|---|
send on closed channel panic |
生产者未退出就关闭了队列 | 关闭顺序改为先 close(done),等生产者停止后再关闭 channel;或用 Once 保护 |
| 消费端收到 nil 消息 | 忽略 channel 关闭标志 | 接收时用 msg, ok := <-ch 判断 ok |
| 程序退出时消息丢失 | 队列未排空就直接结束 | 关闭时增加 drain 阶段,等消费者处理完存量 |
| 生产者全部卡死 | channel 满且无超时控制 | Publish 使用 select 加 context 超时 |
| 一个 handler panic 干掉整个进程 | worker 没有 recover | 包裹 recover,失败计数并继续循环 |
| 压测数据对不上 | 生产消费计数统计时机不对 | 先停生产者,排空队列后再统计 |
| race detector 报警 | 统计字段用了普通 int | 改为 atomic.Int64 |
4.4 生产环境还需要补什么
写完这个项目,我在本地模拟了一些极端场景:消费者处理速度极慢、生产者突发大量消息、程序中途被 kill。整体表现是稳定的,但离生产可用还有距离。如果要把它用在实际业务里,我认为至少还要补四块内容。
第一是持久化。内存队列重启即失,重要消息需要落盘或者补一个主备机制。第二是死信队列。消费失败的消息不能无限重试,应该进入一个单独的队列人工处理。第三是监控指标。不仅要有生产总数、消费总数,还要有队列积压量和平均延迟,用 Prometheus 暴露出来,方便告警。第四是动态调整消费者数量。Kafka 有 consumer rebalance 机制,我这里只能启动时定死,如果你想做动态扩容,可以考虑把消费者数量做成可配置项,通过监听配置变更来启停 worker。
最后一个小方向:继续往哪走
我在这个项目里最大的收获不是学会了 channel 语法,而是理解了一个观点:简单消息队列的本质是管理好一个缓冲区,难的不是“往里发”和“往外取”,而是缓冲区满了怎么处理、系统关闭时怎么排空、单个消息处理出错时怎么不拖垮全局。这三个问题解决好了,再去看 Redis 的 list 做队列、RabbitMQ 的 ack 机制、Kafka 的 offset 提交,会觉得处处熟悉。
如果你也想动手复现,我建议从无缓冲 channel 版本开始写,先把同步的语义跑通,再加上缓冲、超时、退出这些机制。每加一层,就跑一遍压测对比数据,你会非常直观地看到每个设计决策对性能和稳定性的影响。这比拿一个现成框架改改配置能学到的东西多得多。
