最近在做一个 Next.js 项目时遇到了一个痛点:怎么跑定时任务?
Next.js 是请求驱动的框架,没法运行常驻后台进程。之前我用 GitHub Actions 的定时触发来凑合,但灵活性太差了——改个执行时间还得改 workflow 文件,重新部署。
后来看到一个用 Go 写独立定时任务服务的方案,觉得思路很清晰,就花时间学习了一下。这篇文章记录下我的理解。
为什么选 Go?
一开始我也想过用 Node.js 写个脚本跑 node-cron,但仔细想想有几个问题:
- 部署麻烦:要装 Node.js 运行时,管理依赖
- 资源占用:Node 进程内存起步就几十 MB
- 稳定性:长时间运行可能有内存泄漏问题
对比之下,Go 的优势很明显:
| 特性 | 说明 |
|---|---|
| 部署简单 | 编译成单个可执行文件,无需安装运行时 |
| 资源占用低 | 内存通常 10-30MB,CPU 几乎为 0 |
| 稳定性极高 | Docker、Kubernetes 都用 Go 写的 |
| 并发友好 | Goroutine 天生适合处理多任务 |
| 跨平台编译 | 一行命令编译出 Linux/Mac/Windows 版本 |
说实话,就算不考虑性能,光是「编译成单文件直接跑」这一点就够吸引我了。
整体架构
设计思路其实很简单:Go Worker 轮询数据库,找到到期的任务,然后调用 Next.js 的 API 来执行。
┌─────────────────────────────────────────────────────────────┐
│ Next.js 应用 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Web 前端 │ │ API Routes │ │ /api/cron/* 端点 │◄─┼──┐
│ └─────────────┘ └──────┬──────┘ └─────────────────────┘ │ │
│ │ │ │
└──────────────────────────┼───────────────────────────────────┘ │
│ │
▼ │
┌─────────────────┐ │
│ Turso 数据库 │◄────────────────┐ │
└─────────────────┘ │ │
▲ │ │
│ 1. 轮询查询待执行任务 │ │
│ 4. 更新任务状态 │ │
┌────────┴────────┐ │ │
│ Go 定时任务服务 │─────────────────┘ │
│ │ │
│ 2. 找到到期任务 │ │
│ 3. 调用 API ─────┼─────────────────────────────┘
└──────────────────┘这样设计的好处是:
- 职责分离:Go 只负责调度,业务逻辑还是在 Next.js 里
- 复用现有代码:不用把业务逻辑移植到 Go
- 方便调试:API 可以独立测试
数据库设计
任务信息存在数据库里,我用的是 Turso(SQLite 兼容)。表结构大概这样:
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_at和locked_by:防止多个 Worker 重复执行同一任务 -
retry_count:失败后自动重试 -
cron_expression:支持 cron 表达式的重复任务(Go 端需要用 robfig/cron 库来解析)
核心调度逻辑
Go 的调度器核心逻辑其实不复杂,就是个循环:
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)
}
}这里有个细节:获取任务的时候要「加锁」,用数据库事务保证原子性:
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,要有重试机制。我学到一个技巧叫「指数退避」:
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:
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 这些端点就行。
部署方式
编译很简单,一行命令:
# 在开发机上编译 Linux 版本
GOOS=linux GOARCH=amd64 go build -o worker .产出一个 10-15MB 的可执行文件,上传到服务器就能跑。
用 systemd 管理服务:
# /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.targetRestart=always 保证进程挂了会自动重启,配合锁超时机制,基本不用担心任务卡死。
健康检查
加个简单的 HTTP 端点用于监控:
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 定时检查:
# 每 5 分钟检查一次,失败就重启
*/5 * * * * curl -sf http://localhost:8080/health || systemctl restart task-worker在 Next.js 里创建任务
最后,在 Next.js 应用里创建任务也很简单:
// 创建一次性任务: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',
});学习心得
这个方案让我对后台任务调度有了更深的理解:
- 数据库锁是保证任务不重复执行的关键
- 指数退避重试能避免失败任务频繁重试
- Go 确实适合写这种「跑着就行」的后台服务
- 职责分离很重要——调度归调度,业务归业务
目前我还没实际部署过这套方案,但代码逻辑已经理清了。等有合适的项目再实践一下。
如果你也有类似需求,希望这篇笔记对你有帮助。