Task结构体必须携带ID、Source、CreatedAt等可追踪字段并独立成包,Dispatcher需支持多后端切换且抽象接口,handler应仅调用service层函数,重试与超时须按任务类型配置。

Task 结构体必须带明确上下文,否则异步后无法追溯来源或重试——这是多数人一开始没意识到的致命点。
用 internal/task 包封装任务定义和分发逻辑
不要把 Task 类型直接扔在 main 或 server 包里。它属于跨模块复用的契约,应独立成包,且强制携带可追踪字段:
-
ID字段必须是全局唯一(推荐用ulid或uuid,不用自增 int) -
CreatedAt和Source字段必填,用于后续排查“哪个服务、什么时间、触发了这个异步任务” - 避免在
Task里存闭包或函数指针——序列化/跨进程时会 panic - 示例结构体:
type Task struct { ID string `json:"id"` Source string `json:"source"` // e.g. "user-service:register" CreatedAt time.Time `json:"created_at"` Payload map[string]string `json:"payload"` }
task.NewDispatcher() 要支持多后端切换
单机开发用 channel,上线就得切到 Redis 或 SQS。硬编码 channel 会导致后期重构成本爆炸。正确做法是抽象出接口:
- 定义
Dispatcher接口,含Dispatch(task *Task) error和RegisterHandler(name string, fn func(*Task)) - 实现
MemDispatcher(用于测试)、RedisDispatcher(生产) - 在
cmd/main.go中根据环境变量选择初始化方式:if os.Getenv("TASK_BACKEND") == "redis" { d = redis.New() } - 别让 handler 函数直接操作数据库——它只负责解包、调用
pkg/service层的具体业务函数
Worker 启动时必须绑定具体 pkg/service 模块
worker 不该知道 HTTP 路由或 CLI 参数,只认 service.SendEmail 这类纯业务函数。常见错误是把 handler 写成:
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
func handleUserRegister(t *task.Task) {
// ❌ 错误:混入了 HTTP context、logger 实例、DB 连接池
http.ResponseWriter...
log.Printf(...)
db.Exec(...)
}
应该写成:
立即学习“go语言免费学习笔记(深入)”;
func handleUserRegister(t *task.Task) {
// ✅ 正确:只做参数转换 + 调用 service 层
email := t.Payload["email"]
err := userSvc.CreateWelcomeEmail(email)
if err != nil {
// 记录失败但不 panic —— 交给 dispatcher 重试机制
log.Warn("welcome email failed", "task_id", t.ID, "err", err)
}
}
- 每个
pkg/service模块提供自己的 handler 注册函数,例如user.RegisterTaskHandlers(d) - worker 启动时统一调用所有模块的注册函数,避免漏掉新模块
- handler 函数签名固定为
func(*task.Task),便于统一调度和监控
重试与超时必须按任务类型配置,不能全局一刀切
发短信失败重试 3 次就够了,而生成报表可能要重试 10 次且每次间隔递增。硬编码重试策略会让某些任务永远卡住或过早放弃。
- 在
Task.Payload中预留"retry_limit"和"retry_backoff_ms"字段(非必需,有则用,无则走默认) -
Dispatcher.Dispatch()应解析这些字段,并传给底层后端(如asynq的asynq.RetryCount或TaskQ的RetryPolicy) - 特别注意:Redis backend 下,如果用
LPUSH + BRPOP自研队列,重试需手动RPUSH回队尾——漏掉这步就等于丢任务 - 超时建议设两层:worker 执行超时(如
context.WithTimeout),和任务整体生命周期超时(如创建 24 小时后自动丢弃)

















