定时任务有时不跑有时跑两遍:先分清漏跑和重复跑再动手

2026-07-29

数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。

定时任务出问题,最容易归错因:看到跑了两遍就去调度配置里翻,看到没跑就加一条重试。真正决定这类故障形态的,是你的服务有几个副本、单轮执行时长和调度间隔的关系、以及写库那一步有没有唯一约束。 调度配置只是最表层的一层,改它经常是把症状挪个位置,过两周换个形式再犯。

这篇讲排查顺序,不讲工具选型。站内另有两篇跟它挨得很近:重试与幂等怎么配合 讲单次调用失败后重试时如何不写重复数据,是请求级的问题;后台任务的日常运维 讲任务上线之后怎么盯、怎么报警、怎么交接。本篇卡在这两者中间——任务根本没被触发,或者被触发了不止一次,这是调度级的问题,用请求级的幂等能兜底但兜不干净,用运维手段能发现但往往发现得太晚。

一、先把现象归类,漏跑和重复跑是两个病

这两类故障的成因几乎不重叠,混在一起查必然绕远路。

漏跑的典型表述是”今天的日报没出”、“这一小时的数据缺了一段”。它的成因集中在三处:触发端根本没发出,比如进程挂了、容器被重建、时区不对、表达式写错;发出了但被自己拦掉,比如代码里有一句”正在运行就跳过”;发出并执行了但中途死了,比如超时被杀、内存被回收、下游长时间不可用把任务拖爆。

重复跑的典型表述是”同一批通知发了两次”、“对账多了一份流水”。它的成因也集中在三处:多个实例同时被触发,这是最常见也最容易被忽略的一种,因为本地永远复现不了;任务超时后被当作失败重新投递,而上一轮其实还活着;人工补数据和自动任务撞车。

分清这一步不是形式主义。漏跑要往触发链路上查,重复跑要往并发与投递语义上查,两条路的第一个动作就不一样。

二、判别表:从现象倒推成因

按现象对号入座,再照”怎么验证”那一列做一次确认,别跳过验证直接上处置动作。

现象大概率成因怎么验证处置动作
完全没有任何执行记录触发端没发出:进程不在、表达式写错、时区不一致在容器里跑 date 看实际生效的时区(只看 TZ 变量不算数,见第五节);确认调度进程存活;把表达式用同一个库在本地解析一遍算出下次触发时间固定时区,容器统一 TZ=UTC,业务层再转换;把”下次触发时间”打进启动日志
有开始日志没有结束日志执行中途被杀:超时、内存被回收、节点驱逐查容器退出码与重启次数;看任务开始时间与进程重启时间是否吻合拆分批次、加执行超时保护、把长任务改成可断点续跑
偶尔跳过一次,日志里有”已在运行”字样上一轮没结束,触发了自我互斥统计每轮实际耗时分布,跟调度间隔比缩短单轮耗时或拉长间隔;把互斥从”直接跳过”改成”登记待补”
同一时刻多条相同的开始日志多实例同时触发在日志里打主机名或实例名,看是不是不同来源收敛到单点触发,或加分布式锁,顺序见下节
执行两次且间隔接近超时时长超时后被重新投递,前一轮仍在跑对比两次开始时间差与队列的重投递阈值(各家叫法不同,可能是可见性超时、确认超时或单条消息最长处理时间,以你所用队列的文档为准)调大该阈值或缩短任务耗时;写入侧加唯一约束兜底
数据多了但任务只跑了一次不是调度问题,是业务内部循环或写入没去重在写入处打条数日志,跟输入条数对账走请求级幂等,参考前面那篇重试与幂等

最后一行专门留出来,是因为很多人把它误报成重复跑。任务只被触发一次却产生重复数据,那是代码问题,不该往调度层去修。

三、四种手段各治什么,上手顺序别搞反

收敛触发源、分布式锁、补偿扫描、幂等落库,这四样经常被笼统说成”防重复”。它们解决的是完全不同的四件事,顺序也有讲究。

第一步,先收敛触发源。 服务扩到多副本时,如果定时逻辑跟业务代码打在同一个镜像里,每个副本都会各自触发。这是重复跑最大的单一来源。最省事的做法是把定时触发从业务进程里拿出来,交给一个明确只有一份的触发方,业务侧只暴露一个可被调用的入口。这一步不需要引入任何中间件,能解决相当大比例的问题。

第二步,才轮到分布式锁。 锁解决的是”同一时刻只有一个执行者”。用 Redis 的话,标准写法是带过期时间的原子写入:

# 持有者标识要唯一,主机名加进程号是最省事的一种
HOLDER="$(hostname)-$$"

# 只在 key 不存在时写入,同时设置 30 秒过期,返回 OK 才算拿到锁
redis-cli SET job:daily-report:lock "$HOLDER" NX PX 30000

释放时别直接 DEL,否则可能删掉别人刚拿到的锁——你以为还持有,其实锁早就过期并被下一个执行者抢走了。也别写成”先 GET 比对再 DEL”的两条命令,那和后面要批的”先查后写”是同一个毛病:比对通过之后、删除执行之前仍有窗口,锁可能正好在这一瞬过期易主。正确写法是把比对和删除塞进一段 Lua 脚本,由 Redis 单线程原子执行:

# KEYS[1]=锁的 key,ARGV[1]=自己写入时的持有者标识,只有还是自己才删
redis-cli --eval release.lua job:daily-report:lock , "$HOLDER"

# release.lua 内容:
# if redis.call("GET", KEYS[1]) == ARGV[1] then
#   return redis.call("DEL", KEYS[1])
# else
#   return 0
# end

注意 --eval 的参数里,key 和 argv 之间要用一个前后带空格的逗号分开,少了空格会被当成 key 名的一部分。

锁的过期时间必须大于任务正常耗时,但也不能无限长——持锁进程一旦崩了,锁要能自己过期,否则就从重复跑变成永久漏跑。这是这类事故里最经典的翻车方式:为了防重复把锁设成不过期,结果一次崩溃之后任务再也不跑,还长期没人发现。

锁只保证互斥,不保证任务一定被执行。所以它治重复跑,不治漏跑。

第三步,补偿扫描治漏跑。 补偿的思路是不再假设每次触发都成功,而是让任务自己能查出”哪些该做的还没做”。做法是给每个应产出的单元建一条状态记录,按日期或者按分片键都行,任务启动后先扫最近若干个周期里状态不是已完成的,逐个补。这样即使某天进程挂了、某天时钟不对,下一次正常执行就能把窟窿填上,不需要人工介入。

补偿扫描的代价是你必须先有”应产出清单”。没有清单就没法判断缺了什么,这也是为什么很多团队的漏跑只能靠用户投诉发现。清单不复杂,一张记录业务键、周期、状态、更新时间的表就够用。

第四步,幂等落库是兜底,不是主力。 幂等的意思是同一件事做两次和做一次结果一样。落到实处最可靠的形式是数据库唯一约束加冲突忽略,而不是先查再写——先查再写在并发下必然有窗口。

-- 前提:这两列上必须先有唯一索引,否则冲突子句无处可依
ALTER TABLE daily_report ADD CONSTRAINT uk_report UNIQUE (biz_date, shard);

-- PostgreSQL / SQLite 写法:重复写入直接被数据库挡掉
INSERT INTO daily_report (biz_date, shard, payload)
VALUES ('2026-07-29', 'a', '...')
ON CONFLICT (biz_date, shard) DO NOTHING;

这段是 PostgreSQL 和较新版本 SQLite 的方言,换数据库要换写法:MySQL 用 INSERT ... ON DUPLICATE KEY UPDATE(想纯忽略就更新一个无关紧要的列,比如把某列赋值成它自己),Oracle 和 SQL Server 用 MERGE。具体子句以你所用数据库的官方文档为准,但不管哪种方言,前置条件都一样:唯一索引必须真的建在业务唯一键上。很多人写了冲突忽略却发现还是插进去两条,原因是索引建在自增主键上,而自增主键每次都不一样,永远不会冲突。

把幂等放在最后,不是说它不重要,而是它只在已经重复触发之后起作用。如果只做幂等不做前三步,重复触发带来的资源消耗、对下游的重复调用、日志里的噪音都还在,只是数据看起来干净了。反过来,前三步做得再好也要留幂等这一层,因为分布式环境里没有哪个互斥手段是百分之百的。

一句话记法:收敛触发源减少并发,锁保证同一时刻只有一个,补偿保证漏了能补回来,幂等保证真的重了也不脏数据。

四、什么情况下别再折腾

排查也要有止损点。下面几种情况继续在调度层加东西是净亏。

加了两轮锁还在重复,回头查触发源。 已经有锁但重复还在发生,多半是锁的粒度跟业务键对不上,比如锁的是任务名而任务内部按用户分片并发跑;也可能干脆是两套部署都在跑同一个任务。这时候继续调锁的超时时长是浪费时间,把所有可能的触发入口列一遍更快。

单轮耗时已经接近调度间隔,别再加互斥,改结构。 每小时一次的任务跑了五十分钟,你无论加什么锁都在危险边缘,任何一次抖动都会导致跳过或者堆积。正确的动作是拆分片并行、把全量改增量、或者把周期拉长。继续在调度层修补属于治标。

根因在下游不稳定,先给下游加隔离。 如果任务失败的根因是某个外部接口频繁超时,把重试次数从三次加到十次只会让任务耗时更不可控,进而引发上面那个”耗时接近间隔”的问题。先加超时上限和熔断,让任务快速失败并被补偿机制记下来,比死等健康。很多”只在生产重复、本地永远正常”的怪事最后落在环境差异上,可以对照运行环境不一致怎么定位那篇一起排。

回滚点在哪: 如果这次改动是为了修重复跑而引入了新的互斥机制,上线后一旦出现漏跑,优先回滚互斥而不是继续加补丁。宁可重复也别漏,是绝大多数业务场景下的正确取舍——重复有幂等兜底,漏跑往往要人工翻数据。

五、避坑清单

用本地时间写调度表达式。 为什么会踩:开发机、构建机、生产容器的时区常常三个样,本地验证通过不代表线上一致,跨夏令时的地区还会一年错两次。怎么避:容器统一设成 UTC,表达式按 UTC 写,需要”本地早上八点”这种语义时在业务层转换,并把算出的下次触发时间打进启动日志,部署后扫一眼就知道对不对。这里还有个连环坑:不少精简基础镜像不带时区数据库,你在环境变量里设了 TZ=Asia/Shanghai 却没装 tzdata,进程解析不到这个时区名,实际仍按 UTC 走,而且不报错。验证办法是进容器执行一次 date,看输出的时区缩写是不是你想要的,别只 echo $TZ 就下结论——变量设上了不等于生效了。

锁不设过期时间。 为什么会踩:出发点是”绝对不能重复”,但持锁进程一旦异常退出,锁就成了永久的墓碑,任务从此静默不跑。怎么避:一律设过期,过期时长取正常耗时的两到三倍;长任务用续期而不是一开始就设很长;同时给”连续多少个周期没有成功记录”配一条告警。

把互斥写成”正在运行就直接跳过”,还不留痕。 为什么会踩:跳过看起来是安全行为,但它把漏跑变成了无声的漏跑,等到有人对数才发现缺了一周。怎么避:跳过必须记录,并且把这次跳过登记进待补偿清单,让下一轮扫描能捡回来。

用”先查后写”做幂等。 为什么会踩:查和写之间有时间窗,两个实例几乎同时进来会双双查到不存在然后各写一条。怎么避:把唯一性交给数据库约束,冲突时忽略或更新,代码里的查询只作为快速短路,不作为正确性保证。

用时间戳当幂等键。 为什么会踩:补数据重跑时时间戳变了,同一份业务数据会被当成新的写进去;或者两个分片恰好取到同一秒,反而互相挡掉。怎么避:幂等键必须来自业务语义,比如业务日期加分片编号、订单号加动作类型,跟执行时刻无关。

任务里没有分片键,只能整体重跑。 为什么会踩:出问题时想只补一小块,结果只能全量重来,一次补数据把下游打崩。怎么避:从第一版就把任务设计成”给我一个键,我只处理这个键”,定时触发只是遍历键的一层外壳,人工补数据复用同一个入口。

日志里不打执行来源。 为什么会踩:多实例重复触发在日志里长得跟单实例一模一样,你看到两条开始日志也判断不出是两个副本还是一次重投。怎么避:开始日志固定带上实例标识、一个本轮唯一的执行编号、以及本轮处理的键范围,排查时一眼可分。这跟后台执行静默失败那篇讲的可观测性要求是同一套思路。

只在失败时告警。 为什么会踩:进程根本没起来的时候,没有任何东西会失败,所以也没有任何告警,这正是漏跑最隐蔽的形态。怎么避:改成心跳缺失告警——超过预期周期没有收到成功记录就报,而不是等失败信号。

六、收束与自检

这类故障的难点从来不在手段,锁和幂等的写法到处都是;难点在于你得先知道自己得的是哪个病。先分漏跑还是重复跑,再定位触发端、执行端还是写入端,最后才轮到上手段,顺序错了就会出现”改了一堆东西问题还在”的局面。如果任务是由某个自动化流程触发的,判断路径一样,只是触发端的可观测性通常更差,可以配合执行失败后的重试边界一起看。

上线前过一遍这份自检:

  • 生产环境有几个进程会触发它,你能说出确切数字吗
  • 时区在哪一层确定,启动日志里有没有打出下次触发时间
  • 单轮正常耗时是多少,占调度间隔的几分之几
  • 有没有应产出清单,缺了一个周期你多久能发现
  • 幂等键是什么,它跟执行时刻有关吗
  • 锁会不会过期,过期时长和最长耗时的比值是多少
  • 连续几个周期没有成功记录会触发告警,这条告警有人收吗

七条里有三条答不上来,就先别急着改代码,把这几件事量出来再动手。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。