第 35 章

后台任务与优雅退出

后台任务与优雅退出

2018 年,某电商平台在双十一期间遭遇了一次严重事故:运维团队在流量高峰期执行了一次滚动部署,应用进程收到 SIGTERM 信号后立即退出,有 2000 多笔正在处理的订单瞬间中断。由于支付状态未能写入数据库,这些订单进入了"支付已扣款但订单未确认"的灰色地带,客服团队花了整整三天时间手动核查和补偿。

这是一个关于"退出"的故事,但代价远比听起来沉重。

在 Go 的世界里,后台任务和优雅退出是生产级服务必须认真对待的两个问题。前者决定了你的系统能自动完成哪些工作,后者决定了这些工作在系统重启时是否能安全结束。两者共同构成了一个健壮服务的"生命周期管理"能力。

本章从第一性原理出发,剖析后台任务的调度机制、goroutine 的生命周期管理,以及信号处理和优雅退出的完整实现路径。

Level 1 · 你需要知道的

后台任务问题的本质

在 Web 服务的背后,总有一些工作不能放在 HTTP 请求的处理路径上完成:

时间敏感的异步任务:用户注册后发送欢迎邮件,如果放在 HTTP handler 里同步执行,API 响应时间会被邮件服务的延迟拖累。用户在等你发邮件,这不合理。

定期维护任务:过期 Session 清理、日志归档、缓存预热、统计数据聚合……这些任务需要按时间表运行,不依赖任何用户请求触发。

计算密集型任务:生成报告、处理视频、批量数据导入——这些任务可能耗时数分钟甚至数小时,必须在后台异步执行,HTTP 请求不能等这么久。

这些需求催生了"后台任务系统"(Background Job System)的设计模式。在不同语言和框架的生态中,它有不同的叫法:Cron Jobs、Worker、Daemon Task、Background Service。但核心问题是一样的:一个任务从哪里触发、在哪里运行、出错了怎么办、系统关闭时怎么处理。

优雅退出为什么重要

进程终止有两种方式:

粗暴退出(Crash / Kill -9):进程被强制杀死,所有内存状态丢失,任何正在运行的代码立即停止。这是事故场景,不可控。

信号退出(SIGTERM / SIGINT):操作系统或用户发送终止信号,进程有机会捕获信号并执行清理逻辑后退出。这是正常的进程生命周期管理方式。

Kubernetes 的 Pod 滚动更新、Docker 容器重启、systemctl stopCtrl+C——这些操作默认都是通过 SIGTERM 信号来通知进程退出的。如果进程直接忽略信号(或者没有注册处理逻辑),Go runtime 的默认行为是立即退出。

"优雅退出"(Graceful Shutdown)的含义是:收到退出信号后,让已经在处理中的工作安全完成,拒绝新的工作,然后有序地释放所有资源,最后退出。

这个过程可以分解为四个阶段:

  1. 停止接收新请求:HTTP 服务器停止 Accept 新连接;任务调度器停止触发新任务
  2. 等待进行中的工作完成:HTTP 请求处理完毕;正在运行的后台任务执行到安全检查点
  3. 关闭资源连接:数据库连接池、Redis 客户端、消息队列消费者
  4. 退出进程:所有 goroutine 已退出,main 函数返回

Go 的信号处理基础

Go 程序通过 os/signal 包接收 Unix 信号:

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)

sig := <-sigCh
fmt.Printf("received signal: %v\n", sig)
// 开始优雅退出流程

signal.Notify 将指定的信号路由到提供的 channel。缓冲大小建议至少为 1,避免信号到达时没有 goroutine 在读取而导致信号丢失(Go 的信号机制是"尽力而为"的,不保证可靠传递)。

SIGTERM(信号 15)是进程终止的标准信号,kill 命令默认发送的就是它。SIGINT(信号 2)是用户在终端按下 Ctrl+C 时发送的中断信号。生产环境需要同时处理这两个。

Level 2 · 原理与机制

time.Ticker vs time.AfterFunc:两种定时触发模式

Go 标准库提供了两种主要的定时触发机制,它们在语义上有本质区别:

time.Ticker:固定间隔触发

ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()

for {
    select {
    case <-ticker.C:
        // 每分钟触发一次
        doWork()
    case <-ctx.Done():
        return
    }
}

Ticker 是基于 channel 的,ticker.C 是一个只读 channel,runtime 按照指定间隔向其发送时间戳。关键细节:如果 doWork() 耗时超过了 Ticker 的间隔,Ticker 不会等待——它会继续尝试向 channel 发送,但如果 channel 缓冲满了(默认缓冲为 1),这次 tick 会被丢弃。 这意味着 Ticker 不保证每次触发的间隔时间严格相等。

time.AfterFunc:延时单次触发

time.AfterFunc(5*time.Second, func() {
    doWork()
})

AfterFunc 在一个新的 goroutine 中执行回调函数,不阻塞调用方。它是一次性的,不会自动重复。要实现循环执行,需要在回调中再次调用 AfterFunc,这种模式可以实现"任务完成后等待 N 秒再执行"的语义,不同于 Ticker 的"每隔 N 秒触发一次"。

核心区别:Ticker 是"固定频率"(wall-clock interval),AfterFunc 递归调用是"固定间隔"(execution interval)。对于耗时任务,前者可能导致并发执行,后者不会。

robfig/cron:Cron 表达式调度

标准库的 Ticker 只支持固定时间间隔。生产环境经常需要更复杂的调度规则,比如"每天凌晨 3 点"或"每月第一个周一"。这时候需要 cron 表达式调度器。

github.com/robfig/cron/v3 是 Go 生态中最常用的实现:

c := cron.New(cron.WithSeconds()) // 支持秒级精度
c.AddFunc("0 0 3 * * *", func() { // 每天凌晨3点
    cleanupExpiredSessions()
})
c.AddFunc("0 0 * * 1", func() { // 每周一凌晨
    generateWeeklyReport()
})
c.Start()
defer c.Stop()

cron.Stop() 会停止调度器接受新任务,但不会等待正在运行的任务完成。如果需要等待,需要结合 WaitGroup 自行实现。这是在设计后台任务系统时需要特别处理的细节。

robfig/cron 的内部实现:维护一个按下次触发时间排序的任务列表(最小堆),一个 goroutine 不断循环计算最近的 wakeup 时间并 sleep,被唤醒后触发到期的任务(每个任务在独立 goroutine 中执行),然后重新计算下次 wakeup 时间。

Goroutine 生命周期管理

Go 的 goroutine 没有父子关系,没有隐式的取消传播。这与操作系统线程不同——操作系统进程树有明确的层级,父进程退出可以通过信号广播给子进程。Go 中,goroutine 的退出需要开发者显式地通过 channel 或 context 来协调。

context.WithCancel 的传播模型:

ctx, cancel := context.WithCancel(context.Background())

// 启动多个后台 goroutine,都接受同一个 ctx
go workerA(ctx)
go workerB(ctx)
go workerC(ctx)

// 取消时,所有 goroutine 都会收到通知
cancel()

context.WithCancel 返回的 cancel 函数关闭了 ctx.Done() channel(实际上是向 done channel 发送信号,让所有监听者解除阻塞)。所有通过 ctx.Done() 等待的 goroutine 都会被唤醒。

这是一个典型的"扇出通知"(fan-out notification)模式:一个 cancel 调用,多个 goroutine 同时被通知。这正是 Go channel 广播的实现方式——不是向 channel 发数据,而是关闭 channel,所有在读取已关闭 channel 的 goroutine 都会立即收到零值。

context 树的传播:

rootCtx, rootCancel := context.WithCancel(context.Background())

// HTTP 服务器使用 rootCtx 的子 context,带超时
httpCtx, httpCancel := context.WithTimeout(rootCtx, 30*time.Second)
defer httpCancel()

// 后台任务使用另一个子 context
jobCtx, jobCancel := context.WithCancel(rootCtx)
defer jobCancel()

// 当 rootCancel() 被调用,httpCtx 和 jobCtx 都会被取消

这种树形结构让优雅退出的实现变得清晰:根 context 的取消像水流一样向下传播,每个组件只需要监听自己的 ctx.Done() 并做好收尾工作。

os/signal:信号处理的完整机制

signal.Notify 内部维护了一个信号-channel 的注册表。当 Go runtime 捕获到信号时,它遍历注册表,将信号发送到所有注册了该信号的 channel。

几个重要的边界情况:

信号丢失问题:如果 channel 已满(缓冲用尽),信号会被丢弃,不会阻塞发送方(避免死锁)。这是为什么建议缓冲大小至少为 1。

signal.Reset:可以恢复信号的默认处理行为(对于 SIGTERM,默认行为是直接退出)。在某些测试场景中有用。

signal.Stop:停止向 channel 投递信号,用于清理注册。

Goroutine 安全signal.Notify 是并发安全的,可以在任意 goroutine 中调用。

Level 3 · 代码实践

构建完整的后台任务系统

我们来一步步构建一个生产级的后台任务系统,支持任务注册、定时调度、panic 恢复和指标统计。

1. 任务定义和 JobRunner

package jobrunner

import (
    "context"
    "log/slog"
    "sync"
    "time"

    "github.com/robfig/cron/v3"
)

// Job 定义了一个后台任务的接口
type Job interface {
    Name() string
    Run(ctx context.Context) error
}

// JobRunner 管理所有后台任务的调度和执行
type JobRunner struct {
    cron    *cron.Cron
    mu      sync.RWMutex
    jobs    map[string]*jobEntry
    wg      sync.WaitGroup // 等待所有正在运行的任务
    logger  *slog.Logger
    metrics *JobMetrics
}

type jobEntry struct {
    job      Job
    cronExpr string
    lastRun  time.Time
    lastErr  error
    runCount int64
    errCount int64
}

type JobMetrics struct {
    mu       sync.RWMutex
    counters map[string]int64
}

func NewJobRunner(logger *slog.Logger) *JobRunner {
    return &JobRunner{
        cron:    cron.New(cron.WithSeconds(), cron.WithChain(
            cron.Recover(cron.DefaultLogger), // robfig 内置 panic 恢复
        )),
        jobs:    make(map[string]*jobEntry),
        logger:  logger,
        metrics: &JobMetrics{counters: make(map[string]int64)},
    }
}

// Register 注册一个定时任务
func (r *JobRunner) Register(cronExpr string, job Job) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    entry := &jobEntry{
        job:      job,
        cronExpr: cronExpr,
    }

    _, err := r.cron.AddFunc(cronExpr, func() {
        r.executeJob(context.Background(), entry)
    })
    if err != nil {
        return fmt.Errorf("invalid cron expression %q: %w", cronExpr, err)
    }

    r.jobs[job.Name()] = entry
    r.logger.Info("job registered", "name", job.Name(), "schedule", cronExpr)
    return nil
}

// executeJob 执行单个任务,包含 panic 恢复、计时和错误记录
func (r *JobRunner) executeJob(ctx context.Context, entry *jobEntry) {
    r.wg.Add(1)
    defer r.wg.Done()

    name := entry.job.Name()
    start := time.Now()

    r.logger.Info("job starting", "job", name)

    // Panic 恢复:防止单个任务 panic 崩溃整个进程
    defer func() {
        if rec := recover(); rec != nil {
            r.mu.Lock()
            entry.errCount++
            entry.lastErr = fmt.Errorf("panic: %v", rec)
            r.mu.Unlock()

            r.logger.Error("job panicked",
                "job", name,
                "panic", rec,
                "duration", time.Since(start),
            )
        }
    }()

    err := entry.job.Run(ctx)
    duration := time.Since(start)

    r.mu.Lock()
    entry.lastRun = start
    entry.runCount++
    if err != nil {
        entry.errCount++
        entry.lastErr = err
    } else {
        entry.lastErr = nil
    }
    r.mu.Unlock()

    if err != nil {
        r.logger.Error("job failed",
            "job", name,
            "error", err,
            "duration", duration,
        )
    } else {
        r.logger.Info("job completed",
            "job", name,
            "duration", duration,
        )
    }
}

// Start 启动调度器
func (r *JobRunner) Start() {
    r.cron.Start()
    r.logger.Info("job runner started")
}

// Shutdown 优雅地停止调度器,等待所有正在运行的任务完成
func (r *JobRunner) Shutdown(timeout time.Duration) error {
    // 1. 停止 cron 调度器,不再触发新任务
    cronCtx := r.cron.Stop()

    // 2. 等待 cron 内部的正在运行的任务完成(robfig/cron 的 Stop 返回的 ctx)
    select {
    case <-cronCtx.Done():
        r.logger.Info("cron scheduler stopped")
    case <-time.After(timeout):
        r.logger.Warn("cron scheduler stop timeout")
    }

    // 3. 等待我们自己 wg 追踪的任务
    done := make(chan struct{})
    go func() {
        r.wg.Wait()
        close(done)
    }()

    select {
    case <-done:
        r.logger.Info("all background jobs completed")
        return nil
    case <-time.After(timeout):
        return fmt.Errorf("background jobs did not finish within %v", timeout)
    }
}

2. 实现一个具体的任务:清理过期 Session

// CleanupSessionJob 清理过期 Session 的后台任务
type CleanupSessionJob struct {
    db     *sql.DB
    logger *slog.Logger
}

func (j *CleanupSessionJob) Name() string { return "cleanup-sessions" }

func (j *CleanupSessionJob) Run(ctx context.Context) error {
    result, err := j.db.ExecContext(ctx,
        "DELETE FROM sessions WHERE expires_at < NOW() LIMIT 10000",
    )
    if err != nil {
        return fmt.Errorf("delete sessions: %w", err)
    }

    affected, _ := result.RowsAffected()
    j.logger.Info("sessions cleaned up", "deleted", affected)
    return nil
}

3. 优雅退出的完整编排

这是整个系统最关键的部分——如何把 HTTP 服务器、后台任务、信号处理组合在一起:

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    // 初始化依赖
    db := mustConnectDB()
    defer db.Close()

    // 创建 HTTP 服务器
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", handleUsers)
    server := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 30 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    // 创建后台任务调度器
    runner := jobrunner.NewJobRunner(logger)
    runner.Register("0 */10 * * * *", &CleanupSessionJob{db: db, logger: logger})
    runner.Register("0 0 4 * * *", &DailyReportJob{db: db})
    runner.Start()

    // 信号处理
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)

    // 启动 HTTP 服务器(非阻塞)
    go func() {
        logger.Info("HTTP server starting", "addr", server.Addr)
        if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            logger.Error("HTTP server error", "err", err)
            os.Exit(1)
        }
    }()

    // 阻塞等待信号
    sig := <-sigCh
    logger.Info("shutdown signal received", "signal", sig)

    // === 优雅退出流程 ===

    shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 60*time.Second)
    defer shutdownCancel()

    // 阶段 1: 停止 HTTP 服务器接受新请求,等待进行中的请求完成
    logger.Info("shutting down HTTP server...")
    if err := server.Shutdown(shutdownCtx); err != nil {
        logger.Error("HTTP server shutdown error", "err", err)
    } else {
        logger.Info("HTTP server shutdown complete")
    }

    // 阶段 2: 停止后台任务调度器,等待正在运行的任务完成
    logger.Info("shutting down background jobs...")
    if err := runner.Shutdown(30 * time.Second); err != nil {
        logger.Error("job runner shutdown error", "err", err)
    } else {
        logger.Info("background jobs shutdown complete")
    }

    // 阶段 3: 数据库连接会在 defer db.Close() 中关闭

    logger.Info("graceful shutdown complete")
}

信号处理的边界情况:二次 Ctrl+C

在开发环境中,有时第一次 Ctrl+C 无法及时退出(比如任务卡死),用户会再按一次。良好的实践是:第一次信号启动优雅退出,第二次信号强制退出:

sigCh := make(chan os.Signal, 2)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)

// 第一个信号:开始优雅退出
sig := <-sigCh
logger.Info("initiating graceful shutdown", "signal", sig)

// 在后台启动优雅退出
go func() {
    doGracefulShutdown()
    os.Exit(0)
}()

// 第二个信号:强制退出
sig2 := <-sigCh
logger.Warn("forcing immediate exit", "signal", sig2)
os.Exit(1)

任务 Panic 的隔离与监控

生产环境中,任务 panic 不应该导致整个服务崩溃。但 panic 也不能被静默吞掉——它必须被记录并通知到监控系统:

func safeRun(ctx context.Context, job Job, onPanic func(name string, err error)) (err error) {
    defer func() {
        if r := recover(); r != nil {
            // 捕获完整的堆栈信息
            buf := make([]byte, 64<<10) // 64KB
            n := runtime.Stack(buf, false)
            stackTrace := string(buf[:n])

            panicErr := fmt.Errorf("panic in job %s: %v\nstack:\n%s",
                job.Name(), r, stackTrace)
            err = panicErr

            if onPanic != nil {
                onPanic(job.Name(), panicErr)
            }
        }
    }()

    return job.Run(ctx)
}

Level 4 · 进阶与边界

基于 Redis 的持久化任务队列

内存中的任务调度有一个根本缺陷:进程重启后,正在队列中等待的任务会丢失。对于关键业务任务(如发送通知、处理支付回调),这是不可接受的。

解决方案是使用 Redis 作为持久化的任务队列,通常通过 asynq(基于 Redis 的任务队列库)实现:

import "github.com/hibiken/asynq"

// 定义任务类型和 payload
const TypeEmailWelcome = "email:welcome"

type EmailWelcomePayload struct {
    UserID int64
    Email  string
}

// 入队:在 HTTP handler 中将任务放入队列
func EnqueueWelcomeEmail(client *asynq.Client, userID int64, email string) error {
    payload, _ := json.Marshal(EmailWelcomePayload{UserID: userID, Email: email})
    task := asynq.NewTask(TypeEmailWelcome, payload,
        asynq.MaxRetry(3),              // 最多重试 3 次
        asynq.Timeout(30*time.Second),  // 单次执行超时
        asynq.Retention(24*time.Hour),  // 完成后保留记录 24 小时
    )
    _, err := client.Enqueue(task)
    return err
}

// Worker 端:处理任务
func handleEmailWelcome(ctx context.Context, t *asynq.Task) error {
    var payload EmailWelcomePayload
    if err := json.Unmarshal(t.Payload(), &payload); err != nil {
        return fmt.Errorf("unmarshal payload: %w", err)
    }
    return sendWelcomeEmail(ctx, payload.UserID, payload.Email)
}

// 启动 Worker server
func startWorker(redisAddr string) {
    srv := asynq.NewServer(
        asynq.RedisClientOpt{Addr: redisAddr},
        asynq.Config{
            Concurrency: 10,
            Queues: map[string]int{
                "critical": 6, // 高优先级队列,占 60% worker
                "default":  3,
                "low":      1,
            },
        },
    )

    mux := asynq.NewServeMux()
    mux.HandleFunc(TypeEmailWelcome, handleEmailWelcome)

    if err := srv.Run(mux); err != nil {
        log.Fatal(err)
    }
}

asynq 的优雅退出非常简单,srv.Stop() 会等待当前正在处理的任务完成(最多等待 ShutdownTimeout 时间)后停止 Worker。

分布式 Cron:Leader 选举

当服务横向扩展为多实例时,一个问题浮现:所有实例都注册了相同的 cron 任务,到点后每个实例都会执行,造成重复执行。

解决方案是 Leader 选举(Leader Election):只有当前被选为 Leader 的实例才执行 cron 任务。

使用 etcd 实现 Leader 选举:

import (
    clientv3 "go.etcd.io/etcd/client/v3"
    "go.etcd.io/etcd/client/v3/concurrency"
)

func runWithLeaderElection(ctx context.Context, etcdClient *clientv3.Client, campaignKey string, fn func(ctx context.Context)) error {
    session, err := concurrency.NewSession(etcdClient, concurrency.WithTTL(10))
    if err != nil {
        return err
    }
    defer session.Close()

    election := concurrency.NewElection(session, campaignKey)

    for {
        // Campaign 阻塞,直到成为 Leader
        if err := election.Campaign(ctx, "leader"); err != nil {
            if ctx.Err() != nil {
                return nil // ctx 取消,正常退出
            }
            return err
        }

        log.Println("became leader, starting cron jobs")

        // 创建一个子 context,Leader 丢失时自动取消
        leaderCtx, leaderCancel := context.WithCancel(ctx)

        // 在后台监视 Leader 状态
        go func() {
            defer leaderCancel()
            ch := election.Observe(leaderCtx)
            for resp := range ch {
                // 如果 Leader 键的值变了(被其他节点抢占),退出
                if string(resp.Kvs[0].Value) != "leader" {
                    return
                }
            }
        }()

        // 在 leaderCtx 下运行 cron 任务
        fn(leaderCtx)

        // 如果到这里,说明 leaderCtx 被取消了(Leader 丢失)
        // 重新参与选举
        if ctx.Err() != nil {
            return nil
        }
        log.Println("lost leadership, re-campaigning")
    }
}

任务去重与幂等性

即使使用单 Leader 执行,某些场景仍然可能造成任务重复执行:Leader 在任务执行过程中崩溃,新 Leader 接管后再次执行同一任务。

正确的设计应该保证任务的幂等性(Idempotency):执行一次和执行多次的效果相同。

// 使用 Redis 的 SETNX 实现分布式锁来去重
func (r *JobRunner) executeWithDedup(ctx context.Context, job Job, ttl time.Duration) error {
    lockKey := fmt.Sprintf("job:lock:%s:%d",
        job.Name(),
        time.Now().Truncate(ttl).Unix(), // 按时间窗口去重
    )

    // SET key value NX EX seconds
    ok, err := r.redis.SetNX(ctx, lockKey, "1", ttl).Result()
    if err != nil {
        return fmt.Errorf("acquire lock: %w", err)
    }
    if !ok {
        // 另一个实例已经在执行或已执行完毕这个时间窗口的任务
        log.Printf("job %s skipped (already executed in this window)", job.Name())
        return nil
    }

    return job.Run(ctx)
}

死信队列与任务心跳监控

对于长时间运行的任务,需要心跳机制来检测任务是否卡死:

// 任务心跳:每隔一段时间更新 Redis 中的心跳时间戳
func runWithHeartbeat(ctx context.Context, rdb *redis.Client, taskID string, interval time.Duration, fn func(ctx context.Context) error) error {
    heartbeatKey := fmt.Sprintf("job:heartbeat:%s", taskID)

    // 启动心跳 goroutine
    go func() {
        ticker := time.NewTicker(interval)
        defer ticker.Stop()
        for {
            select {
            case <-ticker.C:
                rdb.Set(ctx, heartbeatKey, time.Now().Unix(), interval*3)
            case <-ctx.Done():
                return
            }
        }
    }()

    return fn(ctx)
}

// 监控 goroutine:定期检查所有任务的心跳
func monitorHeartbeats(ctx context.Context, rdb *redis.Client, alertFn func(taskID string)) {
    ticker := time.NewTicker(30 * time.Second)
    defer ticker.Stop()
    for {
        select {
        case <-ticker.C:
            keys, _ := rdb.Keys(ctx, "job:heartbeat:*").Result()
            for _, key := range keys {
                exists, _ := rdb.Exists(ctx, key).Result()
                if exists == 0 {
                    // 心跳键已过期,任务可能卡死或崩溃
                    taskID := strings.TrimPrefix(key, "job:heartbeat:")
                    alertFn(taskID)
                }
            }
        case <-ctx.Done():
            return
        }
    }
}

死信队列(Dead Letter Queue)用于收集多次重试后仍然失败的任务,供人工或自动化系统审查和处理。asynq 内置了死信队列支持——超过最大重试次数的任务会自动移入 asynq:dead 队列,可以通过 Web UI 查看和手动重新入队。

设计总结:生产级后台任务系统的七项原则

  1. 显式的生命周期:每个 goroutine 都应该有明确的启动和退出路径,通过 context 或 channel 来控制。
  2. Panic 隔离:任务的 panic 不应该崩溃整个进程,用 recover 捕获并记录。
  3. 优雅退出是必须:收到 SIGTERM 后,给正在运行的任务足够的时间完成(30秒通常是合理的上限)。
  4. 持久化关键任务:对于业务关键任务,使用持久化队列(Redis/数据库),而不是内存队列。
  5. 幂等性设计:任务应该能安全地执行多次,或者通过锁机制防止重复执行。
  6. 可观测性:任务的执行次数、失败次数、最后执行时间、执行耗时都应该暴露为指标。
  7. 分布式去重:多实例部署时,使用 Leader 选举或分布式锁确保 cron 任务不被重复触发。

这七项原则不是"最佳实践",而是生产环境的血泪总结。每一条背后都有真实的事故。

本章评分
4.8  / 5  (3 评分)

💬 留言讨论