Go 后台任务重试入门:失败后怎么重试才不添乱

用发送通知任务讲 Go 后台任务的重试设计,包括最大次数、退避、幂等性、错误分类和日志记录。

后台任务失败后要不要重试?答案通常是“要,但不能乱重试”。发送通知、同步数据、生成报表、调用第三方接口都可能遇到临时失败。如果完全不重试,偶发抖动会变成用户可见失败;如果无限重试,可能把一个小问题放大成队列堵塞或外部服务雪崩。

本文用发送通知任务做例子,讲一个入门级重试结构:最大次数、退避等待、错误分类、幂等性和日志。

定义任务

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 里实现重试并不难,难的是业务语义。重复执行是否安全?失败日志是否可排查?队列会不会被坏任务堵住?这些问题想清楚,重试才不会添乱。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页