不能直接用 go func() 启动长时间任务,因其会导致 goroutine 泄漏、无法取消、无监控、重启时资源泄漏;应改用消息队列(如 rabbitmq/kafka)解耦,配合 redis 状态管理、幂等设计与手动确认机制。

为什么不能直接用 go func() 启动长时间任务
微服务里一来请求就 go doHeavyWork(),看着简单,实际会快速耗尽 goroutine 资源,尤其当并发请求多、任务执行时间长时。HTTP handler 本身有超时(比如 30 秒),但后台 goroutine 不受控制,既没法取消,也没法监控,失败了无声无息,日志里连 traceID 都串不上。
更麻烦的是:服务重启时这些 goroutine 会被粗暴杀死,没机会清理资源(比如关闭数据库连接、回滚事务、发补偿消息)。
- 别把
go func()当“异步”万能解——它只是并发,不是可靠异步 - HTTP 请求上下文(
ctx)生命周期和 goroutine 生命周期完全脱钩,ctx.Done()对它无效 - 没有重试、死信、进度追踪能力,运维排查时只能翻日志猜
用消息队列做任务分发的最小可行路径
真正落地时,不推荐自己造轮子维护任务状态机。用现成消息队列(如 RabbitMQ、NATS、Kafka)加一层轻量封装,是平衡可控性与复杂度的常见选择。
核心思路:HTTP 接口只做「入队」,由独立 worker 进程消费并执行,worker 自带重试、限流、错误隔离能力。
立即学习“go语言免费学习笔记(深入)”;
- 入队时必须带上完整上下文信息:traceID、用户ID、原始参数(建议 JSON 序列化进
body,别存数据库再查) - worker 启动时注册唯一
worker_id,方便定位日志;消费前先log.Info("start processing", "task_id", task.ID) - RabbitMQ 用
delivery.Ack(false)手动确认,失败时不要Nack(requeue=true)无限重试,改用requeue=false+ 发送到死信交换器(DLX) - Kafka 注意
enable.auto.commit设为false,处理成功后再手动CommitOffsets
示例入队片段:
err := ch.Publish(
"", // exchange
"task_queue", // routing key
false, // mandatory
false, // immediate
amqp.Publishing{
ContentType: "application/json",
Body: payloadBytes,
Headers: amqp.Table{
"trace_id": ctx.Value("trace_id"),
"source": "user_api",
},
},
)
如何让任务支持取消和状态查询
用户点了“取消导出”,后端不能假装没看见。关键不是实现取消逻辑本身,而是让取消指令能被正在运行的任务感知到,并且状态变更对前端可查。
最简方案:用 Redis 存任务元数据,字段包括 status(pending/running/success/failed/cancelled)、updated_at、result_url(可选)。worker 每次循环开头检查 GET task:123:status,如果是 cancelled 就提前 return。
- 取消接口只需
SET task:123:status cancelled EX 3600,不用管 worker 是否在线 - worker 执行中每 5–10 秒轮询一次状态(避免太密打爆 Redis),发现 cancelled 就调
cleanup()然后退出 - 状态查询接口返回的
status必须是 Redis 里的值,不是内存变量或本地 map —— 多实例部署时状态必须共享 - 别用数据库主键 ID 当任务 ID:UUID 更安全,避免泄露业务量或被恶意枚举
Worker 进程怎么避免启动时重复消费
服务滚动更新或意外崩溃重启后,新 worker 可能和旧 worker 同时消费同一批消息,导致任务重复执行(比如扣款两次、发两封邮件)。
根本解法不是靠“加锁”,而是靠消息队列自身的语义 + 幂等设计。RabbitMQ 的 at-least-once 交付要求你必须在业务层保证幂等;Kafka 的 offset 提交时机决定了重复范围。
- 每个任务消息带唯一
task_id(全局 UUID),worker 处理前先SETNX task:123:executed 1 EX 86400,失败直接 return - 不要在消息体里放“第几次重试”这种状态,它不可靠;所有重试决策交给队列(比如 RabbitMQ 的 TTL + DLX)
- worker 启动时不做“扫描未完成任务”这种操作——Redis 或 DB 里残留的 pending 状态,应由定时 job 清理,而非启动时抢着处理
- 如果用了 Kubernetes,别依赖
preStop杀掉 worker 前做优雅退出——网络断开可能比信号更快,该做的幂等一点不能少
事情说清了就结束:异步不是加个 go 就完事,真正的难点永远在边界上——服务启停、网络分区、消息重复、状态不一致。这些地方没兜住,再漂亮的架构也扛不住真实流量。










