<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>幂等 on Zero Day Notes</title>
        <link>https://blog.5772447.xyz/tags/%E5%B9%82%E7%AD%89/</link>
        <description>Recent content in 幂等 on Zero Day Notes</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>zh</language>
        <lastBuildDate>Tue, 10 Nov 2026 09:00:00 +0800</lastBuildDate><atom:link href="https://blog.5772447.xyz/tags/%E5%B9%82%E7%AD%89/index.xml" rel="self" type="application/rss+xml" /><item>
            <title>CronJob 允许并发：日报任务重叠出双份</title>
            <link>https://blog.5772447.xyz/posts/c8a7917a/</link>
            <pubDate>Tue, 10 Nov 2026 09:00:00 +0800</pubDate>
            <guid>https://blog.5772447.xyz/posts/c8a7917a/</guid>
            <description>&lt;h2 id=&#34;问题背景&#34;&gt;&lt;a href=&#34;#%e9%97%ae%e9%a2%98%e8%83%8c%e6%99%af&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;问题背景&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;财务系统有一个每日报表任务，跑在 Kubernetes 的 CronJob 里：每天凌晨 1 点启动，从数据仓库拉取前一天的交易数据，生成 Excel 报表，写到共享存储，再给财务和业务负责人发邮件。正常情况十几分钟跑完，资源占用不大。&lt;/p&gt;&#xA;&lt;p&gt;这个 CronJob 是两年前上线的，YAML 只写了 schedule 和容器镜像，其他字段基本没配。平时运行稳定，大家也就没再动过。到了月底和季末，数据量会涨到平时的十倍，任务耗时明显变长；今年三季度的一次月结，任务跑了六个多小时，之后开始出现“一天收到两份报表”的反馈。&lt;/p&gt;&#xA;&lt;h2 id=&#34;故障现象&#34;&gt;&lt;a href=&#34;#%e6%95%85%e9%9a%9c%e7%8e%b0%e8%b1%a1&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;故障现象&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;财务和业务负责人偶尔在同一天收到两封日报邮件，两封的汇总数字还不一样。&lt;/li&gt;&#xA;&lt;li&gt;共享存储上的 &lt;code&gt;latest.xlsx&lt;/code&gt; 有时打不开，提示文件损坏；重命名成带时间戳的文件后能打开其中一份。&lt;/li&gt;&#xA;&lt;li&gt;出问题的日子，&lt;code&gt;kubectl get jobs&lt;/code&gt; 能看到同一个 CronJob 下有两个活跃 Job，Pod 创建时间相差 24 小时。&lt;/li&gt;&#xA;&lt;li&gt;任务耗时在月结期间从十几分钟涨到 6–8 小时，极端情况超过 24 小时还没结束。&lt;/li&gt;&#xA;&lt;li&gt;数据库里偶尔出现重复的报表记录，下游对账任务因此报主键冲突。&lt;/li&gt;&#xA;&lt;li&gt;平时数据量小的日子完全不出现，只有月结、季结或上游延迟时才复现。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;排查过程&#34;&gt;&lt;a href=&#34;#%e6%8e%92%e6%9f%a5%e8%bf%87%e7%a8%8b&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;排查过程&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;第一步确认重复来源。查 &lt;code&gt;kubectl get jobs -n finance&lt;/code&gt;，按创建时间排序，发现出问题当天有两个同前缀的 Job：前一天的 Job 还处于 Running，新一天的 Job 已经启动。两个 Pod 都在跑同一份报表逻辑，各自生成文件、各自发邮件，收件人自然收到两份。&lt;/p&gt;&#xA;&lt;p&gt;第二步看 CronJob 配置。&lt;code&gt;kubectl get cronjob daily-report -o yaml&lt;/code&gt; 里没有 &lt;code&gt;concurrencyPolicy&lt;/code&gt; 字段。Kubernetes 对 CronJob 的默认值是 &lt;code&gt;Allow&lt;/code&gt;：不管上一次有没有跑完，到点就再起一个。相比之下，&lt;code&gt;Forbid&lt;/code&gt; 会跳过本次调度，&lt;code&gt;Replace&lt;/code&gt; 会杀掉旧的再起新的。这个默认值加上“偶尔跑超过 24 小时”的任务，重叠几乎是必然的。&lt;/p&gt;&#xA;&lt;p&gt;第三步看任务时长和超时配置。Job 模板里没有 &lt;code&gt;activeDeadlineSeconds&lt;/code&gt;，任务再慢也不会被终止；CronJob 也没有 &lt;code&gt;startingDeadlineSeconds&lt;/code&gt;，错过的调度不会被判定为过期。上游数据仓库在月结期间慢，任务就一路挂着，和新任务并行。&lt;/p&gt;&#xA;&lt;p&gt;第四步解释文件损坏和重复记录。两个 Pod 同时写同一个 &lt;code&gt;latest.xlsx&lt;/code&gt;，一个在写、一个在覆盖，Excel 文件结构被破坏；数据库插入没有唯一约束，两个 Job 各插一份，对账任务读到重复数据就报主键冲突。邮件发送也没有幂等键，两个 Pod 各发各的。&lt;/p&gt;&#xA;&lt;p&gt;第五步验证并发策略。在测试环境把 &lt;code&gt;concurrencyPolicy&lt;/code&gt; 改成 &lt;code&gt;Forbid&lt;/code&gt;，用 &lt;code&gt;sleep&lt;/code&gt; 模拟一个跑 30 分钟的任务，把 schedule 临时改成每分钟一次：后续调度全部被跳过，&lt;code&gt;kubectl get jobs&lt;/code&gt; 始终只有一个活跃 Job；改成 &lt;code&gt;Replace&lt;/code&gt; 则会看到旧 Job 被删除、新 Job 启动。行为符合预期。&lt;/p&gt;&#xA;&lt;p&gt;第六步确认修复效果。生产上改成 &lt;code&gt;Forbid&lt;/code&gt;，同时给 Job 加 &lt;code&gt;activeDeadlineSeconds: 14400&lt;/code&gt;（4 小时）和 &lt;code&gt;startingDeadlineSeconds: 3600&lt;/code&gt;，再加唯一输出路径和数据库唯一键。观察一个月，没有再出现双份邮件，月结任务即使超时也会被终止并告警，不会拖到第二天。&lt;/p&gt;&#xA;&lt;h2 id=&#34;解决方案&#34;&gt;&lt;a href=&#34;#%e8%a7%a3%e5%86%b3%e6%96%b9%e6%a1%88&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;解决方案&#xD;&#xA;&lt;/h2&gt;&lt;ol&gt;&#xA;&lt;li&gt;&lt;strong&gt;设置并发策略&lt;/strong&gt;：日报这类“同一份数据只应生成一次”的任务用 &lt;code&gt;concurrencyPolicy: Forbid&lt;/code&gt;；如果业务要求“新数据必须覆盖旧任务”，可以用 &lt;code&gt;Replace&lt;/code&gt;，但要确保旧任务写入是原子操作，避免半成品。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;加执行时限&lt;/strong&gt;：Job 模板加 &lt;code&gt;activeDeadlineSeconds&lt;/code&gt;（例如 4 小时），超过就终止并标记失败；CronJob 加 &lt;code&gt;startingDeadlineSeconds&lt;/code&gt;，错过的调度不再补跑，避免堆积。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;控制重试与历史&lt;/strong&gt;：&lt;code&gt;backoffLimit&lt;/code&gt; 设为 1–2，避免失败后无限重试；&lt;code&gt;successfulJobsHistoryLimit&lt;/code&gt; 和 &lt;code&gt;failedJobsHistoryLimit&lt;/code&gt; 设小一点，防止 Job 和 Pod 越积越多。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;做幂等&lt;/strong&gt;：输出文件带 &lt;code&gt;report_date&lt;/code&gt; 和本次运行 ID，先写临时文件再原子重命名；数据库对 &lt;code&gt;(report_date, report_type)&lt;/code&gt; 建唯一约束，重复插入直接失败或 upsert；邮件发送用幂等键或发件记录表去重。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;补监控&lt;/strong&gt;：采集 Job 运行时长、活跃 Job 数、失败次数；时长超过基线（例如 1 小时）或活跃 Job 大于 1 就告警，不要等收件人发现双份。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;月结容量评估&lt;/strong&gt;：月结前评估数据量和下游延迟，必要时临时调大资源、提前调度或拆分任务；不要把“平时十几分钟”当成容量上限。&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;h2 id=&#34;根因分析&#34;&gt;&lt;a href=&#34;#%e6%a0%b9%e5%9b%a0%e5%88%86%e6%9e%90&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;根因分析&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;直接根因是 &lt;strong&gt;CronJob 没有设置 &lt;code&gt;concurrencyPolicy&lt;/code&gt;，默认的 &lt;code&gt;Allow&lt;/code&gt; 允许同一任务在上一次未结束时再次启动；月结期间任务耗时超过 24 小时，两个 Job 并行写同一份文件、发同一封邮件，造成重复和文件损坏&lt;/strong&gt;。&lt;/p&gt;&#xA;&lt;p&gt;更深一层，是把 CronJob 当成了“会自己排队的 cron”。Linux 的 cron 只负责按时间触发，不关心上一次是否结束；Kubernetes 的 CronJob 默认同样不阻止重叠，而且没有默认执行时限。任务一旦有副作用（写文件、发邮件、插数据库），重叠就会直接变成重复数据。&lt;/p&gt;&#xA;&lt;p&gt;还有一层是缺少运行身份和幂等设计。两个 Job 都认为自己是“今天的日报”，没有唯一的运行标识，也没有唯一约束兜底；文件写入不是原子的，邮件发送没有去重。并发策略是防线，幂等才是最后一道保险。&lt;/p&gt;&#xA;&lt;h2 id=&#34;预防措施&#34;&gt;&lt;a href=&#34;#%e9%a2%84%e9%98%b2%e6%8e%aa%e6%96%bd&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;预防措施&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;CronJob 模板统一配置：&lt;code&gt;concurrencyPolicy&lt;/code&gt;、&lt;code&gt;activeDeadlineSeconds&lt;/code&gt;、&lt;code&gt;startingDeadlineSeconds&lt;/code&gt;、&lt;code&gt;backoffLimit&lt;/code&gt;、历史保留数、显式 &lt;code&gt;timeZone&lt;/code&gt;，不允许只写 schedule 就上线。&lt;/li&gt;&#xA;&lt;li&gt;有副作用的定时任务必须幂等：唯一业务键、原子写、邮件去重、数据库唯一约束，四者至少落地两项。&lt;/li&gt;&#xA;&lt;li&gt;监控 Job 时长、活跃数和失败率，按任务设基线；月结/季结前做容量评估和压测。&lt;/li&gt;&#xA;&lt;li&gt;代码评审把“定时任务并发”列为检查项：任务是否可重入、输出是否唯一、失败重试是否安全。&lt;/li&gt;&#xA;&lt;li&gt;定期清理历史 Job/Pod，避免 etcd 和 kubelet 被大量已完成对象拖慢。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;总结&#34;&gt;&lt;a href=&#34;#%e6%80%bb%e7%bb%93&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;总结&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;CronJob 只保证“到点创建 Job”，不保证“上一次已经结束”；默认的 &lt;code&gt;Allow&lt;/code&gt; 把并发控制交给了使用方。一个跑了六小时的任务，加上一个每天准点启动的调度，重叠不是偶发，而是时间问题。把并发策略、执行时限和幂等一起补上，定时任务才从“能跑”变成“跑一次只产生一次结果”。&lt;/p&gt;&#xA;&lt;h2 id=&#34;延伸阅读&#34;&gt;&lt;a href=&#34;#%e5%bb%b6%e4%bc%b8%e9%98%85%e8%af%bb&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;延伸阅读&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://blog.5772447.xyz/posts/8f3aa74b/&#34; &gt;PDB 配成 100%：集群维护卡在节点排空&lt;/a&gt;&lt;/li&gt;&#xA;&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://blog.5772447.xyz/posts/97876a5b/&#34; &gt;Pod 长期卡在 Terminating 的定位与修复&lt;/a&gt;&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;</description>
        </item></channel>
</rss>
