你的定时任务跑了三遍,还把数据库打挂了:分布式调度里六个没人愿意告诉你的真相

2026-11-12 9 0

你的定时任务跑了三遍,还把数据库打挂了:分布式调度里六个没人愿意告诉你的真相

凌晨两点,值班手机响了。告警说某个对账任务把数据库连接池打满了。监控里那个"每天凌晨跑一次"的任务实例数是 3:三个副本三份全量扫描,数据库当场跪给你看。

你的第一反应大概是:"加个分布式锁不就行了?"

能解决,但只解决了一半。真正的坑从来不在"锁"上,而在"什么时候该跑、该不该跑、跑了算不算数、跑了怎么证明跑了"这四件事上。锁最好搜,也最容易被自欺欺人。


真相一:cron 表达式本身就是个谎言

先说最反直觉的一点:0 0 2 * * ?,你以为它表示"每天凌晨两点跑一次",但它从来不是这个意思。

crontab 表达式描述的是"时间点的集合",不是"任务执行的承诺"。真正要回答的问题是:时间点到了,任务却没跑成(进程挂了、机器重启了、Pod 还没 Ready),该怎么办?

这问题业界叫 misfire(错过的触发)。三种常见策略:

  • 立即补跑(FIRE_NOW):错过的触发点全部执行。听起来勤奋,实际是灾难制造机:服务挂两小时恢复,一口气补跑几十个全量任务。
  • 跳过(SKIP):错过的就当没发生。简单,但像"每天同步外部账单"这种任务,跳过就意味着数据永远缺一天。
  • 只补最近一次(MISFIRE_DO_NOTHING 之外的折中):把 N 个错过的触发压缩成一次执行。多数业务场景这是最合理的默认值。

关键在于:这个策略必须显式声明。很多调度器有默认值,但没人读过文档,出事时没人知道默认是什么。

// 伪代码:调度循环里最容易被忽略的一行
if (lastFireTime < now - allowedDelay) {
    // 不是"现在执行一次",而是要决定:补跑几次?
    if (misfirePolicy == SKIP) {
        return; // 什么都不做,但要记账
    }
    if (misfirePolicy == FIRE_NOW) {
        while (nextFireTime < now) { enqueue(); nextFireTime = advance(nextFireTime); }
    }
    if (misfirePolicy == FIRE_ONCE) {
        enqueue(); // 把所有错过的合并成一次
    }
}

顺带一提,时区是另一个大坑。容器默认 UTC,配置写本地时区,同一份 cron 表达式在两个环境跑出不同结果。夏令时那种"凌晨两点不存在"或"出现两次"的奇葩时刻,如果调度器自己解析表达式而不是交给系统 crontab,就得自己处理。


真相二:多副本部署 + 定时任务 = 幂等性灾难

容器化部署之后,一个最朴素的直觉是:定时任务也跑在 Pod 里,副本数 3 就意味着任务跑 3 次。于是加锁。

但锁本身也有坑:

// 分布式锁的经典写法
if (tryLock(key, 30)) {
    defer unlock(key)
    doHeavyWork()   // ← 问题在这
}

这里的 defer unlock 是错的。如果进程在 doHeavyWork() 中途被 OOM kill、断电或被 K8s 驱逐,锁不会释放,你会拿着一个"活着的锁"和一个"死掉的任务"僵持到超时:要么任务真的没跑,要么下次调度以为该跑。

锁只能保证"同一时刻只有一个在跑",不能保证"跑过一次且只跑过一次"。这是两件不同的事,把它们混为一谈,是定时任务事故的头号来源。

真正需要的是幂等。而幂等的正确做法不是"加锁防重",而是让重复执行在数据层面无害:

  • 用业务唯一键做 upsert,而不是先查再插(check-then-act 并发下必然出事);
  • 用数据库的行锁或 INSERT ... ON CONFLICT DO NOTHING 抢占执行权,抢占成功的那个实例才真正执行;
  • 写执行记录表:(task_name, biz_date, status) 唯一索引,状态机从 PENDING 推到 SUCCESS,重跑跳过已 SUCCESS 的。

有了执行记录表,你顺带得到天然幂等和可观测性:"这任务到底跑没跑过"终于能回答了。


真相三:选主模型和分片模型,别混着用

集群化定时任务大致两条路线,很多系统里被混着用,然后互相打架。

选主(Leader / Active-Standby)

同一时刻只有一个节点执行全部任务,其他待命。任务量小、需要严格顺序或"全局扫一遍"时用这个,通常靠 Redis/etcd 租约实现:

// 租约式选主,带心跳续约
lease, err := coord.Acquire("job:leader", ttl=15*time.Second)
if err != nil { return err } // 不是主,直接退出
for {
    if !lease.Renew(10*time.Second) {
        log.Warn("失去主身份,立即停止执行")
        cancel()   // 关键:主动中止正在跑的任务
        return
    }
    time.Sleep(3 * time.Second)
}

坑在于失去主身份和停止执行之间有缝隙。网络抖一下租约失效,老主却还在跑,新主也起来了,两边同时干活。所以必须有"失去身份立刻 cancel",否则只是把三倍执行变两倍。

分片(Sharding)

所有节点都干活,按规则分片,节点 A 负责 user_id % N == 0 的部分。适合"每个租户一份任务"这类能自然切分的场景。分片键选错是经典坑:按时间分片会漂,按 ID 取模扩容时会让大量数据重新归属,通常用虚拟节点 + 一致性哈希 + 二次因子打散热点。

别用分片模型跑"全局唯一"的任务,也别用选主模型扛"每个租户一次"的任务量——后者会让单节点成为瓶颈。


真相四:租约必须续,但续租也会抖动

分布式任务常要跑几十秒甚至几小时(对账、迁移、批量报表)。锁的 TTL 一旦小于任务耗时,任务没做完锁就过期,另一个实例进来接着跑,两个任务同时写同一批数据。

所以任务执行期间要有独立心跳线程持续续租,且续租失败必须能中止业务逻辑。续租失败只打个 warn 日志、任务跑到底的写法不叫分布式锁,叫分布式许愿。

另一个坑:租约续期和业务执行不是一回事。带 fencing token 的租约每次拿锁返回一个单调递增版本号,写数据时带上它,服务端拒绝旧版本写入。旧主"诈尸"也写不进去,这是防脑裂的最后一道保险,比"重试三次"靠谱。


真相五:任务雪崩比单个任务超时更致命

你有一百个任务,每小时第 0 分钟触发。整点一到,100 个任务同时醒来,同时打数据库、调下游、写日志,连接池瞬间见底。

这在单机时代也有,集群只是把它放大成了 N 倍的"同时"。

很多事故的直接原因写着"数据库连接数超限",根因却是一群本该错开的时间点撞在了一起。

解决办法不难,但要主动做:

  • 错峰(jitter):给每个任务加固定偏移,比如 base + hash(task_id) % 60 秒。别用纯随机,那会让"错峰"变成无法预期的乱序。
  • 并发限流:全局(或分片)信号量,限制同时执行的任务数,超出的排队而不是并发。
  • 分级队列:关键任务(对账、结算)走高优先级,普通统计任务走低优先级,资源紧张时先砍后者。
// 一个简单的全局并发闸门
sem := make(chan struct{}, maxConcurrent)
func runTask(t Task) error {
    sem <- struct{}{}
    defer func() { <-sem }()
    return t.Execute()
}

注意这个闸门要跨副本共享。进程内的 channel 只对本机有效:5 台机器各限 10 并发,实际还是 50。全局限流得靠 Redis 信号量或数据库行锁这类跨进程手段。


真相六:没人知道它到底跑没跑过

最讽刺的失败模式:任务静默失败,很久以后业务方来问"这个月数据怎么少了一半"。查日志,日志里写着"任务执行成功"。

但"成功"是谁写的?代码自己写的。写到一半 OOM、分支写反、异常被吞——它照样能写下"成功"。

所以定时任务的可观测性至少要有四层:

  1. 心跳:任务没在预期窗口内开始执行,监控要报警,而不是等到数据对不上才发现。
  2. 产出校验:不看日志,看结果。处理了 100 万条,成功 100 万条,成功率 99.9% 也要报警——那 1000 条去哪了?
  3. 耗时分布:不只看平均值,看 P99 和波动。耗时突然翻倍往往在失败之前。
  4. 执行记录可查:记录表里要有 task_name、执行实例、触发时间、起止时间、处理条数、失败原因。否则事后排查只能靠猜。

顺带说个反模式:用日志代替执行记录。日志会轮转、采样,甚至因磁盘满直接丢。执行记录是业务数据,它不该和日志一个待遇。


顺便说说选型:别把长任务当定时任务

最后说个我踩过的坑:我曾把一个"跑三个小时的迁移任务"直接塞进 cron。触发器只负责发起,真正干活的是常驻 worker,cron 只是往队列里塞个任务 ID。

心智模型应该是:

  • 调度器:只管"什么时候该做一次",逻辑极简、可重入、出错就是出错;
  • 执行器(worker):真正干活,可水平扩展、断点续跑、重试与死信。

混在一个进程里,就会出现"任务执行到一半,调度器被重启了"这种尴尬状态。任务若真需要跑很久、要重试、要人工重跑,它本质上是工作流,不是 cron。

选型:单机就用 crontab 或 systemd timer 配一个幂等脚本,简单可靠;K8s 的 CronJob 要注意 concurrencyPolicy(Forbid / Allow / Replace)和 startingDeadlineSeconds(这就是 misfire 的超时阈值);需要 DAG、回溯、人工审批,就老老实实上工作流引擎,别拿定时任务硬凑。


写在最后

定时任务的问题从来不是"怎么让代码准时跑起来",那是最简单的一步。

真正难的是:它没跑的时候你知不知道?它跑了两遍你能不能接受?它跑了一半你能不能继续?它跑得太快把下游打挂了你有没有闸门?

把这五个问题在设计阶段答清楚,你的任务才不会在某个凌晨用三倍流量把数据库送走。

至于加锁,它是最后一步,不是第一步。

相关文章

你的 Go 服务正在偷偷漏 goroutine:context 取消、channel 关闭与那些没人告诉你的并发真相
为什么你的 Rust 团队,三个月后又写回了 Go?—— 所有权、生命周期与「编译期错误」的谎言
你的 SQL 为什么这么慢:一次把「索引自杀现场」挖了个底朝天
Pod 被 OOMKilled,代码里一行 OOM 都没有:Kubernetes 内存账本的四笔糊涂账
扩容一次,数据搬家一半:一致性哈希、虚拟节点,以及我踩过的那个三倍流量坑
「数据过千万就得分表」——这句话害了多少人?分库分表的四个坑与正解

发布评论