最佳实践与陷阱
1 任务设计
1.1 Payload 设计原则
| 原则 | 说明 | 示例 |
|---|---|---|
| 💡 保持小巧 | Payload 仅携带标识和参数 | ✅ {"user_id": 42} ❌ {"image_data": "<base64>"} |
| 💡 使用 JSON | 跨语言兼容,人类可读 | ✅ json.Marshal(p) ❌ gob.NewEncoder() |
| 💡 避免 time.Time | 使用 Unix 时间戳或 RFC3339 | ✅ "2026-07-17T09:00:00Z" ❌ time.Now() |
| 💡 避免动态字段 | 如果使用 Unique 去重 | ✅ {"order_id": 123} ❌ {"created_at": <now>} |
| 💡 大数据存对象存储 | Payload 中只放 URL | ✅ {"file_url": "https://s3/..."} |
1.2 任务类型命名规范
// ✅ 推荐:domain:action 格式
const (
TypeEmailSend = "email:send"
TypeEmailSendWelcome = "email:send:welcome" // 子类型
TypeOrderCancel = "order:cancel"
TypeOrderRefund = "order:refund"
TypeReportExport = "report:export"
TypeImageProcess = "image:process"
)
// ✅ 版本化(当需要不兼容变更时)
const (
TypeEmailSendV1 = "email:send:v1"
TypeEmailSendV2 = "email:send:v2"
)
// ❌ 避免:无命名空间,容易冲突
const TypeSend = "send"1.3 幂等性设计
由于 Asynq 保证至少一次执行,Handler 必须支持幂等:
// 策略 1:数据库唯一约束
func HandleOrderCancel(ctx context.Context, t *asynq.Task) error {
var p OrderCancelPayload
json.Unmarshal(t.Payload(), &p)
// INSERT ... ON DUPLICATE KEY 或 UPSERT
err := db.CancelOrder(ctx, p.OrderID)
if errors.Is(err, ErrAlreadyCancelled) {
return nil // 已取消,幂等
}
return err
}
// 策略 2:分布式锁
func HandleUniqueAction(ctx context.Context, t *asynq.Task) error {
lockKey := fmt.Sprintf("lock:task:%s", t.Type())
locked, err := redisClient.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
if err != nil || !locked {
return nil // 正在处理,跳过
}
defer redisClient.Del(ctx, lockKey)
// 执行实际逻辑
return doWork(ctx, t)
}
// 策略 3:状态机
func HandleStateTransition(ctx context.Context, t *asynq.Task) error {
var p Payload
json.Unmarshal(t.Payload(), &p)
// 只有状态为 "pending" 时才执行
rowsAffected, err := db.TransitionState(ctx, p.ID, "pending", "processing")
if rowsAffected == 0 {
return nil // 已被其他 Worker 处理,幂等
}
return err
}2 队列规划
2.1 推荐队列结构
| 队列 | 权重 | 用途 | 并发分配(Concurrency=20) |
|---|---|---|---|
critical |
6 | 支付回调、订单处理等核心业务 | ~12 Workers |
default |
3 | 常规任务(邮件、通知等) | ~6 Workers |
low |
1 | 报表、日志、非紧急任务 | ~2 Workers |
2.2 队列规划原则
✅ 按业务重要性划分队列
✅ 慢任务和快任务分到不同队列
✅ 有外部依赖的任务单独分队列(便于暂停隔离)
❌ 不要所有任务都用同一个队列
❌ 不要创建过多队列(管理复杂,建议 3~5 个)2.3 队列隔离示例
asynq.Config{
Queues: map[string]int{
"critical": 6, // 核心业务
"default": 3, // 常规任务
"external_api": 2, // 调用外部 API(可单独暂停此队列)
"low": 1, // 低优先级
},
}当外部 API 故障时,可以暂停 external_api 队列而不影响其他任务:
asynq pause external_api3 错误处理策略
3.1 决策矩阵
| 错误类型 | 返回 | 原因 |
|---|---|---|
| Payload 格式错误 | SkipRetry |
重试多次结果相同 |
| 业务参数无效(用户不存在、订单已取消) | SkipRetry |
业务逻辑错误,重试无意义 |
| 网络超时 | 普通 error |
可能是临时的,值得重试 |
| 数据库连接失败 | 普通 error |
可能是临时的,值得重试 |
| 下游服务限流(429) | 普通 error |
稍后可能恢复 |
| 磁盘空间不足 | 普通 error |
运维可能正在清理 |
| 任务已被业务撤销 | RevokeTask |
直接丢弃,不归档 |
3.2 错误信息设计
// ✅ 推荐:提供上下文
return fmt.Errorf("send email to %s: smtp error: %w", p.To, err)
// ✅ 推荐:标明是否可重试
return fmt.Errorf("transient db error: %w", err) // 可重试
return fmt.Errorf("invalid user: %w", asynq.SkipRetry) // 不重试
// ❌ 避免:无上下文的错误
return err4 性能优化
4.1 Concurrency 调优
1. 从 CPU 核心数开始
2. 观察 CPU 使用率和任务延迟
3. CPU 使用率 < 50% → 提高 Concurrency
4. 任务延迟增加但仍有余量 → 检查下游瓶颈
5. Redis CPU 接近 100% → 考虑 Redis 扩容或降低 Concurrency4.2 Redis 优化
| 优化项 | 配置 | 说明 |
|---|---|---|
| 连接池 | PoolSize >= Concurrency |
避免连接等待 |
| 独立 DB | DB: 0(与其他应用隔离) |
避免 key 冲突 |
| 内存管理 | maxmemory-policy noeviction |
防止任务数据被意外淘汰 |
| 持久化 | RDB + AOF | 防止重启后任务丢失 |
4.3 避免常见性能反模式
// ❌ 在 Handler 中做大量同步 IO
func BadHandler(ctx context.Context, t *asynq.Task) error {
// 每个任务都创建新的 HTTP Client
resp, _ := http.Get("https://api.example.com")
return nil
}
// ✅ 复用 HTTP Client
var httpClient = &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
},
}
func GoodHandler(ctx context.Context, t *asynq.Task) error {
resp, _ := httpClient.Get("https://api.example.com")
return nil
}
// ❌ 在 Handler 中连接数据库(频繁创建连接)
func BadHandler(ctx context.Context, t *asynq.Task) error {
db, _ := sql.Open("postgres", dsn)
defer db.Close()
// ...
}
// ✅ 注入连接池
type Handler struct {
db *sql.DB // 全局连接池
}5 部署最佳实践
5.1 进程分离
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ API Server │────▶│ Redis │◀────│ Worker Server │
│ (Client) │ │ │ │ (Server) │
│ 处理 HTTP 请求 │ │ 任务队列 │ │ 处理异步任务 │
└────────────────┘ └────────────────┘ └────────────────┘
▲
┌────────────────┐
│ Scheduler │
│ 定时任务触发 │
└────────────────┘💡 推荐架构:
- API Server:处理 HTTP 请求,作为 Client 入队任务
- Worker Server:独立进程,仅运行 Asynq Server 处理任务
- Scheduler:独立进程,仅运行定时任务调度
- Asynqmon:独立部署,用于监控
5.2 容器化部署
# Worker Dockerfile
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o /worker ./cmd/worker
FROM alpine:3.20
COPY --from=builder /worker /usr/local/bin/worker
ENTRYPOINT ["/usr/local/bin/worker"]5.3 优雅关闭配置
srv := asynq.NewServer(redisOpt, asynq.Config{
Concurrency: 20,
ShutdownTimeout: 30 * time.Second, // 关键:给活跃任务足够时间
})
// K8s: 确保 terminationGracePeriodSeconds > ShutdownTimeout# Kubernetes Pod 配置
spec:
terminationGracePeriodSeconds: 60 # 应大于 ShutdownTimeout
containers:
- name: worker
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"] # 等待 Endpoint 更新6 常见陷阱汇总
6.1 Redis 相关
| 🚨 陷阱 | 说明 | 解决方案 |
|---|---|---|
| Redis 版本过低 | < 4.0 不支持部分 Lua 命令 | 升级 Redis ≥ 5.0 |
| 内存爆满 | Retention 过长 + 高频任务 | 缩短 Retention,监控内存 |
| Cluster 兼容性 | 部分 Lua 脚本跨 slot 失败 | 优先使用 Sentinel |
| 多应用共享同 DB | 不同应用的任务 key 冲突 | 使用独立 DB 编号 |
| maxmemory 淘汰 | allkeys-lru 可能淘汰任务数据 | 使用 noeviction |
6.2 任务设计相关
| 🚨 陷阱 | 说明 | 解决方案 |
|---|---|---|
| Payload 过大 | 传输慢,Redis 内存压力 | 大数据存对象存储,Payload 放 URL |
| 唯一性失效 | Payload 包含时间戳/随机数 | 提取稳定字段,或使用自定义 TaskID |
| 非幂等 Handler | 至少一次执行导致重复 | 数据库唯一约束、状态机、分布式锁 |
| time.Time 序列化 | 不同时区、反序列化失败 | 用 Unix 时间戳或 RFC3339 字符串 |
| 用 gob 序列化 | Go 版本升级不兼容 | 统一用 JSON |
| 长时间运行的 Handler | 阻塞 Worker,无法响应关闭信号 | 设置 Timeout,定期检查 ctx.Done() |
6.3 并发与重试相关
| 🚨 陷阱 | 说明 | 解决方案 |
|---|---|---|
| 无限重试 | IsFailure 返回 false 但错误永不恢复 | 确保可恢复,配合超时或手动终止 |
| ShutdownTimeout 太短 | 频繁重启,大量孤儿任务 | 设为最长任务执行时间的 2 倍 |
| 重试雪崩 | 大量任务同时重试打垮下游 | RetryDelayFunc 添加随机抖动 |
| Concurrency 太高 | Redis 连接不够,上下文切换开销 | 通过压测确定最优值 |
| 忘记 defer client.Close() | 连接泄漏 | 创建后立即 defer Close() |
| 忘记 defer sema.Release() | x/rate 信号量 token 泄漏 | 紧跟 Acquire 写 defer Release |
6.4 运维相关
| 🚨 陷阱 | 说明 | 解决方案 |
|---|---|---|
| 死信堆积 | 归档任务无人清理 | 定期清理脚本 + 告警 |
| 缺少监控 | 任务堆积不知情 | Prometheus + Grafana + 告警规则 |
| 队列暂停忘记恢复 | 维护后队列一直暂停 | 运维 SOP 记录恢复步骤 |
| Scheduler 多实例未加锁 | v0.24 以前版本可能重复调度 | 使用 PeriodicTaskManager(自带锁) |
| 日志级别太低 | Debug 日志撑爆磁盘 | 生产环境用 Warn 或 Info |
6.5 序列化相关
| 🚨 陷阱 | 说明 | 解决方案 |
|---|---|---|
| 用 map[string]interface{} | 类型断言易出错 | 定义明确的 Payload 结构体 |
| Payload 向后不兼容 | 添加必填字段,旧任务反序列化失败 | 新字段用指针或设默认值 |
| 二进制数据无编码 | JSON 不支持二进制 | Base64 编码或存对象存储 |
7 上线前自检清单
任务设计
- 所有 Handler 支持幂等执行
- Payload 使用 JSON,结构小巧稳定
- 任务类型命名使用
domain:action格式 - 永久性错误正确返回
SkipRetry - 临时性错误保留重试语义
配置
-
Concurrency根据任务特征合理设置 - 队列按优先级划分(建议 3~5 个)
-
ShutdownTimeout大于最长任务执行时间 -
Retention合理设置,避免内存爆满 -
RetryDelayFunc根据任务类型定制
部署
- Redis 配置高可用(Sentinel 或主从)
- Scheduler 独立部署
- Worker 可水平扩展
- 优雅关闭已配置并验证工作正常
监控
- Prometheus 指标已采集
- 队列积压告警已配置
- 死信数量告警已配置
- Web UI(asynqmon)已部署可访问
- 关键日志已接入日志平台
运维
- 死信定期清理脚本已就绪
- 运维手册包含:暂停/恢复队列、手动重试、清理任务
- 故障恢复流程已文档化