后台任务失败后要不要重试?答案通常是“要,但不能乱重试”。发送通知、同步数据、生成报表、调用第三方接口都可能遇到临时失败。如果完全不重试,偶发抖动会变成用户可见失败;如果无限重试,可能把一个小问题放大成队列堵塞或外部服务雪崩。
本文用发送通知任务做例子,讲一个入门级重试结构:最大次数、退避等待、错误分类、幂等性和日志。
定义任务
type NotifyJob struct {
ID string
UserID int64
Message string
Attempt int
}
type Notifier interface {
Send(ctx context.Context, userID int64, message string) error
}
Attempt 表示已经尝试次数。真实队列通常会把它保存在消息元数据或数据库里。
错误分类
不是所有错误都应该重试。参数错误、用户不存在、权限错误通常重试也没用。网络超时、503、临时限流可以重试。
var ErrPermanent = errors.New("permanent error")
func permanent(err error) error {
return fmt.Errorf("%w: %v", ErrPermanent, err)
}
func IsPermanent(err error) bool {
return errors.Is(err, ErrPermanent)
}
业务处理:
func HandleNotifyJob(ctx context.Context, notifier Notifier, job NotifyJob) error {
if strings.TrimSpace(job.Message) == "" {
return permanent(errors.New("empty message"))
}
if err := notifier.Send(ctx, job.UserID, job.Message); err != nil {
return fmt.Errorf("send notification: %w", err)
}
return nil
}
空消息重试一百次也不会成功,所以标记成永久错误。
重试循环
func RunWithRetry(ctx context.Context, job NotifyJob, fn func(context.Context, NotifyJob) error) error {
delays := []time.Duration{
200 * time.Millisecond,
1 * time.Second,
3 * time.Second,
}
var err error
for attempt := 0; attempt <= len(delays); attempt++ {
job.Attempt = attempt + 1
err = fn(ctx, job)
if err == nil {
return nil
}
if IsPermanent(err) {
return err
}
if attempt == len(delays) {
break
}
select {
case <-time.After(delays[attempt]):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("job failed after retries: %w", err)
}
这段代码有几个关键点:最大次数有限,永久错误不重试,等待时监听 context。不要用没有上限的 for {} 重试。
幂等性
重试之前先问:重复执行会不会造成副作用?发送通知可能重复打扰用户,创建订单可能重复扣款,同步数据可能覆盖新值。后台任务最好有幂等键,比如 job.ID。
发送通知可以记录发送日志:
type SendLog interface {
AlreadySent(ctx context.Context, jobID string) (bool, error)
MarkSent(ctx context.Context, jobID string) error
}
处理前检查:
sent, err := log.AlreadySent(ctx, job.ID)
if err != nil {
return err
}
if sent {
return nil
}
发送成功后标记。更严格的实现要用事务或唯一约束保证并发安全。幂等性不是重试库能自动提供的,需要业务设计。
记录足够日志
每次失败要记录 job id、attempt 和错误:
slog.Warn("notify job failed",
"job_id", job.ID,
"attempt", job.Attempt,
"err", err,
)
但不要把完整消息内容、token、用户隐私都塞进日志。后台任务日志通常会保存很久,脱敏仍然重要。
测试重试次数
func TestRunWithRetryEventuallySucceeds(t *testing.T) {
var calls int
err := RunWithRetry(context.Background(), NotifyJob{ID: "j1"}, func(ctx context.Context, job NotifyJob) error {
calls++
if calls < 2 {
return errors.New("temporary")
}
return nil
})
if err != nil {
t.Fatal(err)
}
if calls != 2 {
t.Fatalf("calls = %d", calls)
}
}
为了测试速度,可以把 delays 作为参数注入,测试里传很短的延迟或零延迟。不要让单元测试真的等几秒。
死信队列
当任务超过最大重试次数后,不应该悄悄丢掉。常见做法是放入死信队列,或者标记为 failed,等待人工排查:
type FailedJobStore interface {
SaveFailed(ctx context.Context, job NotifyJob, reason string) error
}
失败记录至少包含 job id、任务类型、尝试次数、最后错误和创建时间。这样值班人员可以判断是临时依赖故障、数据错误,还是代码 bug。如果没有失败归档,后台任务会变成黑洞:用户说没收到通知,你只能翻日志猜。
重试间隔要避免同一时间爆发
大量任务同时失败后,如果都在 1 秒后重试,会形成新的峰值。可以给退避加一点 jitter:
func withJitter(base time.Duration) time.Duration {
extra := time.Duration(rand.Int63n(int64(base / 2)))
return base + extra
}
生产代码要注意随机数来源和测试可控性。jitter 的目标不是精确,而是把重试打散,避免所有 worker 同一时间再次冲向下游。
可观察性也要跟上
重试系统至少需要几个指标:任务成功数、失败数、重试次数、死信数量和当前队列长度。没有这些指标,系统可能已经在大量重试,你却只看到下游服务变慢。
type JobMetrics interface {
IncSuccess()
IncFailure()
IncRetry()
SetQueueLength(n int)
}
接口只是示意,具体可以接 expvar、Prometheus 或日志聚合。重点是让重试行为可见。重试越隐蔽,越容易把真实故障拖到更晚才暴露。
小结
后台任务重试要有边界:最大次数、退避、context 取消、错误分类和幂等性。临时错误可以重试,永久错误应该尽快失败。重试不是为了掩盖问题,而是为了吸收短暂抖动。
Go 里实现重试并不难,难的是业务语义。重复执行是否安全?失败日志是否可排查?队列会不会被坏任务堵住?这些问题想清楚,重试才不会添乱。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。