用 Go 写一个定时任务服务:解决 Next.js 后台任务的痛点

January, 1st 2026 10 min read Markdown
用 Go 写一个定时任务服务:解决 Next.js 后台任务的痛点

最近在做一个 Next.js 项目时遇到了一个痛点:怎么跑定时任务?

Next.js 是请求驱动的框架,没法运行常驻后台进程。之前我用 GitHub Actions 的定时触发来凑合,但灵活性太差了——改个执行时间还得改 workflow 文件,重新部署。

后来看到一个用 Go 写独立定时任务服务的方案,觉得思路很清晰,就花时间学习了一下。这篇文章记录下我的理解。

为什么选 Go?

一开始我也想过用 Node.js 写个脚本跑 node-cron,但仔细想想有几个问题:

  1. 部署麻烦:要装 Node.js 运行时,管理依赖
  2. 资源占用:Node 进程内存起步就几十 MB
  3. 稳定性:长时间运行可能有内存泄漏问题

对比之下,Go 的优势很明显:

特性说明
部署简单编译成单个可执行文件,无需安装运行时
资源占用低内存通常 10-30MB,CPU 几乎为 0
稳定性极高Docker、Kubernetes 都用 Go 写的
并发友好Goroutine 天生适合处理多任务
跨平台编译一行命令编译出 Linux/Mac/Windows 版本

说实话,就算不考虑性能,光是「编译成单文件直接跑」这一点就够吸引我了。

整体架构

设计思路其实很简单:Go Worker 轮询数据库,找到到期的任务,然后调用 Next.js 的 API 来执行。

plaintext
123456789101112131415161718192021
┌─────────────────────────────────────────────────────────────┐
│                      Next.js 应用                            │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │ Web 前端    │  │ API Routes  │  │ /api/cron/* 端点    │◄─┼──┐
│  └─────────────┘  └──────┬──────┘  └─────────────────────┘  │  │
│                          │                                   │  │
└──────────────────────────┼───────────────────────────────────┘  │
                           │                                      │
                           ▼                                      │
                  ┌─────────────────┐                             │
                  │   Turso 数据库   │◄────────────────┐           │
                  └─────────────────┘                 │           │
                           ▲                          │           │
                           │ 1. 轮询查询待执行任务     │           │
                           │ 4. 更新任务状态           │           │
                  ┌────────┴────────┐                 │           │
                  │  Go 定时任务服务  │─────────────────┘           │
                  │                  │                             │
                  │  2. 找到到期任务   │                             │
                  │  3. 调用 API ─────┼─────────────────────────────┘
                  └──────────────────┘

这样设计的好处是:

  • 职责分离:Go 只负责调度,业务逻辑还是在 Next.js 里
  • 复用现有代码:不用把业务逻辑移植到 Go
  • 方便调试:API 可以独立测试

数据库设计

任务信息存在数据库里,我用的是 Turso(SQLite 兼容)。表结构大概这样:

sql
12345678910111213141516171819202122232425262728293031323334
CREATE TABLE scheduled_tasks (
  -- 基本信息
  id TEXT PRIMARY KEY,
  task_type TEXT NOT NULL,              -- 任务类型
  payload TEXT,                         -- JSON 格式的任务参数

  -- 调度信息
  scheduled_at INTEGER NOT NULL,        -- 计划执行时间(Unix 时间戳)
  cron_expression TEXT,                 -- 可选:重复任务的 cron 表达式

  -- 状态管理
  status TEXT DEFAULT 'pending',        -- pending | running | completed | failed
  priority INTEGER DEFAULT 0,           -- 优先级

  -- 执行追踪
  retry_count INTEGER DEFAULT 0,        -- 已重试次数
  max_retries INTEGER DEFAULT 3,        -- 最大重试次数
  locked_at INTEGER,                    -- 锁定时间(防止重复执行)
  locked_by TEXT,                       -- 锁定者(Worker 实例 ID)

  -- 结果记录
  started_at INTEGER,
  completed_at INTEGER,
  result TEXT,
  error TEXT,

  -- 元数据
  created_at INTEGER DEFAULT (unixepoch()),
  updated_at INTEGER
);

-- 索引:快速查询待执行任务
CREATE INDEX idx_pending_tasks
ON scheduled_tasks(status, scheduled_at, priority DESC);

几个关键字段:

  • locked_atlocked_by:防止多个 Worker 重复执行同一任务
  • retry_count:失败后自动重试
  • cron_expression:支持 cron 表达式的重复任务(Go 端需要用 robfig/cron 库来解析)

核心调度逻辑

Go 的调度器核心逻辑其实不复杂,就是个循环:

go
123456789101112131415161718192021222324252627282930
func (s *Scheduler) Run(ctx context.Context) {
    ticker := time.NewTicker(10 * time.Second) // 每 10 秒轮询一次
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            s.processNextBatch()
        }
    }
}

func (s *Scheduler) processNextBatch() {
    // 1. 先释放超时的锁(防止 Worker 崩溃后任务卡住)
    s.releaseExpiredLocks()

    // 2. 获取待执行任务(带锁)
    tasks, err := s.fetchAndLockTasks(10)
    if err != nil {
        log.Printf("获取任务失败: %v", err)
        return
    }

    // 3. 并发执行任务
    for _, task := range tasks {
        go s.executeTask(task)
    }
}

这里有个细节:获取任务的时候要「加锁」,用数据库事务保证原子性:

go
1234567891011121314151617181920212223242526272829
func (s *Scheduler) fetchAndLockTasks(limit int) ([]Task, error) {
    now := time.Now().Unix()

    // 使用事务确保原子性
    tx, _ := s.db.Begin()
    defer tx.Rollback()

    // 查询并锁定任务
    rows, err := tx.Query(`
        UPDATE scheduled_tasks
        SET status = 'running',
            locked_at = ?,
            locked_by = ?,
            started_at = ?
        WHERE id IN (
            SELECT id FROM scheduled_tasks
            WHERE status = 'pending'
              AND scheduled_at <= ?
            ORDER BY priority DESC, scheduled_at ASC
            LIMIT ?
        )
        RETURNING id, task_type, payload
    `, now, s.workerID, now, now, limit)

    // ... 解析结果

    tx.Commit()
    return tasks, nil
}

UPDATE ... RETURNING 一条语句搞定查询和锁定,SQLite/Turso 支持这个语法。

失败重试机制

任务失败后不能直接标记为 failed,要有重试机制。我学到一个技巧叫「指数退避」:

go
123456789101112131415161718192021222324252627282930
func (s *Scheduler) handleTaskFailure(task Task, err error) {
    task.RetryCount++

    if task.RetryCount >= task.MaxRetries {
        // 彻底失败
        s.db.Exec(`
            UPDATE scheduled_tasks
            SET status = 'failed', error = ?, completed_at = ?
            WHERE id = ?
        `, err.Error(), time.Now().Unix(), task.ID)

        log.Printf("任务 %s 失败(已达最大重试次数)", task.ID)
    } else {
        // 延迟重试:1分钟 → 2分钟 → 4分钟 → ...
        retryDelay := time.Duration(1<<task.RetryCount) * time.Minute
        nextRun := time.Now().Add(retryDelay).Unix()

        s.db.Exec(`
            UPDATE scheduled_tasks
            SET status = 'pending',
                scheduled_at = ?,
                retry_count = ?,
                error = ?
            WHERE id = ?
        `, nextRun, task.RetryCount, err.Error(), task.ID)

        log.Printf("任务 %s 将在 %v 后重试(第 %d 次)",
                   task.ID, retryDelay, task.RetryCount)
    }
}

1 << task.RetryCount 就是 2 的 n 次方。因为在计算前 RetryCount 已经 +1 了,所以实际的重试间隔是 2、4、8… 分钟,避免失败任务频繁重试。

调用 Next.js API

执行器负责调用 Next.js 的 API:

go
1234567891011121314151617181920212223242526272829303132
type Executor struct {
    apiBaseURL string
    authSecret string
    httpClient *http.Client
}

func (e *Executor) Execute(task Task) (string, error) {
    url := fmt.Sprintf("%s/api/cron/%s", e.apiBaseURL, task.TaskType)

    body, _ := json.Marshal(task.Payload)
    req, _ := http.NewRequest("POST", url, bytes.NewBuffer(body))

    // 添加认证头
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+e.authSecret)

    resp, err := e.httpClient.Do(req)
    if err != nil {
        return "", fmt.Errorf("HTTP 请求失败: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode >= 400 {
        return "", fmt.Errorf("API 返回错误: %d", resp.StatusCode)
    }

    var result map[string]interface{}
    json.NewDecoder(resp.Body).Decode(&result)

    resultJSON, _ := json.Marshal(result)
    return string(resultJSON), nil
}

这样 Next.js 那边只要实现 /api/cron/check_abnormal/api/cron/send_notification 这些端点就行。

部署方式

编译很简单,一行命令:

bash
12
# 在开发机上编译 Linux 版本
GOOS=linux GOARCH=amd64 go build -o worker .

产出一个 10-15MB 的可执行文件,上传到服务器就能跑。

用 systemd 管理服务:

ini
1234567891011121314151617
# /etc/systemd/system/task-worker.service
[Unit]
Description=Task Worker
After=network.target

[Service]
Type=simple
ExecStart=/opt/task-worker/worker
Restart=always
RestartSec=5

Environment=DATABASE_URL=libsql://xxx.turso.io?authToken=xxx
Environment=API_BASE_URL=https://your-app.example.com
Environment=AUTH_SECRET=xxx

[Install]
WantedBy=multi-user.target

Restart=always 保证进程挂了会自动重启,配合锁超时机制,基本不用担心任务卡死。

健康检查

加个简单的 HTTP 端点用于监控:

go
12345678
func (h *HealthServer) healthHandler(w http.ResponseWriter, r *http.Request) {
    status := map[string]interface{}{
        "status":    "healthy",
        "uptime":    time.Since(h.startTime).String(),
        "timestamp": time.Now().Format(time.RFC3339),
    }
    json.NewEncoder(w).Encode(status)
}

可以用 cron 定时检查:

bash
12
# 每 5 分钟检查一次,失败就重启
*/5 * * * * curl -sf http://localhost:8080/health || systemctl restart task-worker

在 Next.js 里创建任务

最后,在 Next.js 应用里创建任务也很简单:

typescript
12345678910111213141516171819
// 创建一次性任务:1 小时后发送提醒
// 注意:如果用 Drizzle ORM,字段名会自动从 snake_case 转换
await db.insert(scheduledTasks).values({
  id: nanoid(),
  taskType: 'send_notification',  // 对应数据库的 task_type
  payload: JSON.stringify({ userId: 'xxx', message: '模具即将到期' }),
  scheduledAt: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
  status: 'pending',
});

// 创建重复任务:每天早上 8 点检查
await db.insert(scheduledTasks).values({
  id: nanoid(),
  taskType: 'check_abnormal',
  payload: JSON.stringify({}),
  scheduledAt: Math.floor(Date.now() / 1000),
  cronExpression: '0 8 * * *',  // Go 端需要用 robfig/cron 库解析
  status: 'pending',
});

学习心得

这个方案让我对后台任务调度有了更深的理解:

  1. 数据库锁是保证任务不重复执行的关键
  2. 指数退避重试能避免失败任务频繁重试
  3. Go 确实适合写这种「跑着就行」的后台服务
  4. 职责分离很重要——调度归调度,业务归业务

目前我还没实际部署过这套方案,但代码逻辑已经理清了。等有合适的项目再实践一下。

如果你也有类似需求,希望这篇笔记对你有帮助。