用带缓冲的channel实现任务队列够用但有边界:适用于单进程内存内短生命周期场景;需防goroutine泄漏、消费者panic退出、生产者阻塞;缓冲大小须显式指定,消费者应for range配合close。

用 channel 实现基础任务队列,够用但有边界
Go 里最轻量的任务队列就是带缓冲的 chan。它适合单进程、内存内、短生命周期的调度场景,比如批量处理日志、触发通知、预热缓存等。
关键不是“能不能跑”,而是“会不会漏”和“卡不卡住”。常见错误是:goroutine 泄漏(没关闭 chan)、消费者 panic 后退出、生产者阻塞在满的缓冲区上。
- 缓冲大小必须显式指定,
make(chan Task, 100)比make(chan Task)更可控 - 消费者要用
for range配合close(),不能只写for { - 生产者写入前建议加超时:
select { case ch <- task: case <-time.After(500 * time.Millisecond): log.Println("task dropped: queue full") }
sync.WaitGroup 和 context.Context 必须一起用
只靠 WaitGroup 等 goroutine 结束,会忽略中断、超时、panic 等退出路径;只靠 Context 又没法知道所有 worker 是否真退出了。两者缺一不可。
典型结构是:启动 N 个 worker,每个都监听 ctx.Done() 并在退出前调用 wg.Done();主 goroutine 调用 wg.Wait() 后再清理资源。
立即学习“go语言免费学习笔记(深入)”;
- worker 内部必须检查
ctx.Err() != nil,不能只依赖 channel 关闭 -
ctx.WithTimeout的 timeout 应该比单个任务预期耗时长 2–3 倍,避免误杀 - 如果 worker 在处理中收到 cancel,要主动 return,不要继续执行或重试
别直接拿 redis 当队列用,先封装一层
直接用 redis.Client.LPush + BRPop 容易出问题:消息丢失(pop 成功但处理失败)、重复消费(worker crash 后未 ack)、无重试机制。
至少得包一层简单抽象,比如:
type RedisQueue struct {
client *redis.Client
key string
}
func (q *RedisQueue) Push(ctx context.Context, payload []byte) error {
return q.client.RPush(ctx, q.key, payload).Err()
}
func (q RedisQueue) Pop(ctx context.Context) ([]byte, error) {
result, err := q.client.BRPop(ctx, 30time.Second, q.key).Result()
if err != nil {
return nil, err
}
if len(result) < 2 {
return nil, io.EOF
}
return []byte(result[1]), nil
}
注意:BRPop 返回的是 []string,第二项才是实际数据;超时时间设为 30 秒,避免无限等待;返回 io.EOF 是为了兼容标准 reader 接口习惯。
任务失败时,别只打日志,要区分可重试和不可重试错误
网络超时、DB 连接断开这类错误可以重试;JSON 解析失败、字段缺失、权限不足这类错误重试也没用,还可能污染下游。
推荐做法是:定义两个 error 类型,用 errors.Is() 判断:
var ErrPermanent = errors.New("permanent failure")var ErrTransient = errors.New("transient failure")- 重试逻辑只对
ErrTransient生效,最多 3 次,每次 delay 指数增长 - 永久错误直接进 dead-letter 队列(哪怕只是本地文件或另一个 redis key)
真正难的是判断——很多第三方 SDK 不暴露错误类型,只能靠 strings.Contains(err.Error(), "timeout") 这种方式兜底,得在注释里写清楚为什么这么 hack。










