原文算术模型中,两条指标建议汇合后取较大值:CPU 建议七个副本,平均队列积压建议十二个。

Horizontal Pod Autoscaler 依赖明确的指标语义

HorizontalPodAutoscaler 把配置的指标转换为副本建议,因此选择指标与聚合口径本身就是应用设计的一部分。增加副本应该能够降低所测压力,或提升真正有用的吞吐量,否则控制器可能只增加费用,却无法缓解瓶颈。应设置明确的最小与最大副本数,但成本上限不等于性能保障,更不代表到达上限之后仍能处理任何规模的请求。 展示原文算术模型,不是实时观测。历史 Python 模型通过范围不变。模型未包含真实控制器的容差、就绪、稳定窗口与速率策略;不证明集群行为或服务可用。 区分测量与控制 HPA 通常周期性协调,控制器默认同步间隔为十五秒。对于按 Pod 平均的目标,基本计算是当前副本数乘以当前指标与目标指标的比值,再向上取整。CPU 利用率相对于资源 requests 计算,不是相对于 limits,也不是整台节点的总容量。把分母理解错,会让看起来合理的百分比对应完全不同的扩容时机。 跨 Pod 求平均,与对时间序列做移动平均是两回事。Metrics Server 的 CPU 用量来自一个测量窗口,自定义适配器则有自己的查询与取样语义。HPA 并不会统一替每种输入做一个可配置时间窗口的平滑。容差、就绪处理、稳定窗口与速率策略属于控制层,是围绕指标建议的不同机制,不能全部称作“先平滑再决策”。 先确定单位,再讨论目标数字。按 Pod 的自定义指标使用 AverageValue;对象和外部指标可使用 Value 或 AverageValue,扩缩意义不同。整个队列的总积压与每副本平均积压不是可以随意互换的输入。资源利用率还要求提供相应资源请求。即使接口返回正常数字,如果它衡量的不是实际瓶颈,控制器依旧可能做出没有业务价值的动作。 ...

十二月 22, 2027 · 2 分钟 · XB Field Notes
两次日历触发启动相互重叠的七十秒工作实例。

Cron 按日历重复触发任务

Cron 按日历字段匹配时间,并在匹配时启动命令。它不会天然等待同一个命令的上一次执行结束。如果每分钟触发的任务要运行七十秒,下一分钟就可能再启动一份。因此,触发规则、并发规则和成功完成状态必须分别设计,不能因为写好了一个周期表达式,就认为重复工作的所有行为都已经确定。 示意轨道展示七十秒工作结束前,下一次日历触发已到来。共享非阻塞锁使后来实例记录跳过,但不保证业务效果恰好一次。原文此前的计时结果未在本轮重跑。 把字段理解为日历选择 传统用户 crontab 一行依次包含分钟、小时、月内日期、月份、星期,然后是命令。星号表示该字段的全部取值。分钟位置的 */15 是在每个小时内选择相应分钟值,不是从上次任务结束之后再计时十五分钟。步长如果不能均匀划分字段范围,跨过边界时就可能出现不同长度的间隔,不能把它当作连续经过时长。 Debian/Vixie 格式对于两个日期字段有特殊规则:当月内日期和星期都受到限制时,满足其中一个日期条件就可能运行,而分钟、小时和月份限制仍然必须满足。这不是月份与星期之间的“或”关系。安装简短表达式之前,应列出具有代表性的实际日期核对意图,避免以为“每月某日恰好是周五”却写成两个日期条件各自都能触发。 时区、夏令时变化和停机都会影响日历调度,不同实现对跳过或重复时段的处理并不完全相同。Debian 文档为部分小幅时钟调整和固定时间任务定义了特殊行为,从其他调度器复制结论可能不适用。本实验没有修改时钟,也没有覆盖这些切换情况。日历触发同样不等于持久队列,不能自动保证停机期间错过的每次任务都会补跑。 坏掉的样子 重叠任务可能重复产生副作用、争夺资源或相互覆盖文件。如果要求同一时刻只有一个实例,就要明确实现这一政策。非阻塞建议锁可以让后来的实例记录跳过;阻塞锁则可能留下等待进程。两种选择都不自动提供业务效果恰好一次,也不能修复已经执行了一半的操作。是否允许跳过,应来自任务自己的业务要求。 本地文件锁只协调使用同一相关锁机制的参与者,不会自动串行化无关主机上的工作。网络文件系统的锁语义也需要单独确认。锁必须覆盖实际工作生命周期,跳过结果则应能被观察。如果一个实例永久卡住,后来每次触发都静默丢弃,表面上调度器还在工作,但业务可能早已停止推进,因此还要监控最近成功完成距今多久。 Cron 也并非一律没有日志。Debian 默认通过 syslog 记录任务启动,并提供更细的日志选项。命令输出可能在可用邮件链路存在时被投递,但极简容器经常没有这样的链路。应把启动、完成、失败和跳过结果写到明确目的地。启动记录只能证明任务被派发,不能证明真正的应用工作已经成功完成。 快速验证 2026-10-19 修复草案:此版本只进行了静态检查与离线命令/文件状态替身测试,没有执行 cron、crontab、账号或信号操作。技术与实际运行验证仍然受阻;下文测量值属于原始示例,不能视为修订版的执行结果。 未来执行必须另行获得授权,并使用新建的一次性 Debian 12 实验环境,预装 Python 3.11+、cron 3.0pl1-162 和 procps。环境不得挂载宿主目录,不得已有 root 任务表、cron 进程、/etc/crontab、/etc/cron.d 或其他用户任务表中启用的无关计划(包括周期目录的派发),也不得有并发管理员操作。root 只是权限前提,不能证明隔离。保存为 cron-overlap.py,未来命令为 python3 cron-overlap.py --isolated-disposable-lab。此参数仅确认由外部核实的前提,不能建立隔离。应预留约两分钟等待真实日历触发,并在结束后丢弃整个环境。 ...

九月 30, 2027 · 4 分钟 · XB Field Notes
必需作业的结果汇总到检查中。独立的仓库规则必须针对正确事件与版本要求对应检查通过;只有工作流 YAML 不会强制阻止合并。

GitHub Actions 工作流给构建把门槛

工作流能够为合并决定提供证据,但只有 YAML 并不会让这些证据成为强制要求。可靠的构建门禁需要连接正确的事件、实际测试的准确版本、有意义的测试结果,以及要求对应检查通过的仓库规则。审阅时应把这些环节一起核对,不能看到早先某次运行有绿色标志,就认为这次拟合并的内容已经满足当前要求。 汇总判断只是合并门禁的一部分:仓库规则必须针对正确事件与版本要求对应检查。保留的通过记录只覆盖本地 success 判断。本次绘图未重跑示例。 区分执行与强制要求 .github/workflows/ 下的文件描述触发器和作业。on 选择事件,分支保护或规则集则另行决定合并之前必须通过哪些检查。应记录稳定的检查名称,并在改名时同时审阅相应规则。不同工作流也应避免产生含糊的同名作业;如果规则指定检查来源,还需确认结果确实由预期的可信应用提供。 还要查看合并请求检查框显示的版本。pull_request 运行通常检查的是合成的合并提交,而不只是贡献者分支末端。合并队列为候选组合生成检查时,需要独立的 merge_group 事件。本地构建成功,或者手动触发过某份工作流,都不能自动替代规则要求的事件与版本;检查对象不一致时,即使输出相同,也不构成同一次验证。 明确依赖结果 没有依赖的作业可以并行运行,单个作业里的步骤通常顺序执行。needs 建立前置关系,条件控制失败或跳过之后哪些工作仍会执行。只有一个必需测试作业的工作流,并不需要人为增加依赖作业,也不必额外添加事件条件。因此,把缺少这些字段一律判成错误的检查器,无法准确判断门禁是否有效。 如果使用汇总门禁,应等待每个必需作业,并显式检查其结果。通过 if: ${{ always() }} 避免前置失败后只把门禁跳过,再要求结果确实为成功。如果某项可选工作允许跳过,应明确编码这个例外,而不是把所有缺失结果都视为通过。必需测试也不能随意开启忽略错误,否则一条普通的成功提示就可能掩盖没有完成的验证。 needs 上下文只包含直接依赖,不会包含依赖图中所有更早的作业。如果测试依赖构建,而门禁必须分别检查构建和测试的结果,就应在门禁的 needs 中直接声明这两个作业。仅仅因为构建是间接前置条件,并不意味着门禁可以从该上下文取得构建结果。 权限声明约束令牌,不约束所有凭据 permissions 控制的是 GITHUB_TOKEN,不会限制另行传给命令的其他秘密凭据。严格的工作流级声明可以由作业继承,不需要每个作业机械重复同样内容。只有真正需要的地方才增加权限。下面的离线实验没有检出私有源码,也不调用 GitHub 接口,因此不使用令牌;真实项目接入源码时,应重新检查实际最低权限需求。 普通的复刻仓库合并请求通常收到只读令牌,而不会收到仓库秘密,具体仍受仓库设置影响。“秘密只读”不是准确说法,令牌权限与秘密可用性是两个问题。不能为了取得秘密,就改用具有更高权限的 pull_request_target 并执行不可信的请求代码。日志遮盖只是辅助保护,不是打印凭据的许可,也不保证经过转换后的值绝不会泄漏。 坏掉的样子 整个工作流因路径或分支过滤没有运行时,对应必需检查可能一直等待;作业因条件而跳过,却可能在没有执行测试的情况下满足检查规则。这是两种不同结果。修改检查名称时应小心,并在托管服务上验证失败、跳过和取消路径,不能只用一次成功运行证明规则配置正确。检查能否出现、是否真正运行和能否阻止合并,需要分别确认。 缓存需要兼容的键,也需要按依赖声明执行可靠安装。普通合并请求产生的缓存属于该请求的合并引用,仅仅键相同,并不意味着基础分支可以恢复它。不能笼统声称任何复刻请求缓存都能污染主分支;应结合事件、引用范围和信任关系判断风险。另外,测试取消与部署恢复也不同:取消正在运行的作业,不会自动撤销它已经产生的外部变化,重试必须了解实际完成到了哪里。 快速验证 下面实验单独检查一项汇总门禁决定:只有前置作业的结果严格等于 success 才接受。在真实工作流中,这个值应来自 needs.test.result,并通过 needs 声明前置依赖。这里的输入则刻意采用合成状态,是本地判断练习,不是工作流校验器,也不能替代项目真实测试。它不会安装工作流或启动托管运行,只让这一项判定逻辑能够独立复现。 ...

九月 2, 2027 · 1 分钟 · XB Field Notes
活动的定时器向服务发出启动请求,但服务可能失败。

systemd timer 安排服务启动,不保证任务成功

systemd timer 把调度规则与执行工作的服务分开。定时器决定何时请求激活,服务则定义命令、执行身份、环境、超时和结果。因此,维护任务可以使用普通单元工具检查,但一个按时发出的启动请求,并不能证明备份、清理或报表已经正确完成。调度是否运转与工作是否成功,需要不同证据;只有两者都符合要求,周期任务才真正达到目的。 定时器状态不能证明服务成功。图中仅展示原文示例相对完成的事件顺序,不表示实测时间比例,也不保证精确启动时刻。停止后续调度与停止当前工作是两个决定;此前示例未在本轮重跑。 坏掉的样子 活动的定时器可以与失败的服务同时存在。如果监控只检查 timer 是否活动,就会漏掉真正执行工作的失败。同样,最近触发时间只说明调度发生过,不代表业务输出成功。应检查服务结果,以及任务本来要生成的产物或状态变化,并保留足够上下文区分前后两次激活,避免把昨天留下的文件误当成今天任务已经完成。 除非通过 Unit 指向别的目标,定时器通常激活同名的 service。如果目标服务已经活动,就不会再启动另一份独立副本。这阻止的是同一个单元重叠激活,不是分布式锁,也不能阻止另一个服务执行相同命令。oneshot 如果使用 RemainAfterExit=yes 保持活动,后续计划执行就可能一直不再运行命令;保留状态是否合适,取决于任务真正需要表达什么。 停止定时器会停止后续调度,但不会自动停止已经开始的服务调用。事故期间暂停维护工作时,这个区别尤其重要:需要检查目标服务,再有意决定让当前工作结束还是中断它。即使停止命令成功,先前已经发生的文件或数据库修改也不会因此自动回滚,因此不能把“停了 timer”当成系统已经没有维护副作用。 快速验证 在 PID 1 为 systemd 的一次性 Linux 系统中,以管理员身份运行下面的 Python 脚本。恢复文本记载的环境为 Debian 12、systemd 252.39,但原始运行日志不可用。本轮没有执行此样例;以下结果说明其预期断言,不能视为本轮实测。它创建唯一命名的定时器与服务,以及临时工作程序。工作程序记录真实进程进入和完成时刻,成功任务则通过显式释放文件等待,以便直接观察调度行为: from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,time,uuid name='xbcatm-timer-'+uuid.uuid4().hex[:8];service=name+'.service';timer=name+'.timer' paths={unit:Path('/run/systemd/system')/unit for unit in (service,timer)} assert all(not p.exists() for p in paths.values()) def run(*args): result=subprocess.run(args,text=True,capture_output=True,timeout=10) assert result.returncode==0,(args,result.stderr) return result.stdout.strip() def value(unit,key):return run('systemctl','show',unit,'-p',key,'--value') def wait_for(check): deadline=time.monotonic()+5 while not check(): assert time.monotonic()<deadline,'transition timed out' time.sleep(0.02) with TemporaryDirectory(prefix=name+'-',dir='/run') as directory: root=Path(directory);worker=root/'worker.py' worker.write_text(r'''from pathlib import Path import json,sys,time root=Path(__file__).parent;number=len(list(root.glob('start-*.json')))+1 def record(kind): temp=root/'record.tmp';temp.write_text(json.dumps(time.monotonic())) temp.replace(root/(kind+'-'+str(number)+'.json')) record('start') if (root/'fail').exists():sys.exit(7) while not (root/('release-'+str(number))).exists():time.sleep(0.01) record('end') ''') paths[service].write_text(f'[Service]\nType=oneshot\nTimeoutStartSec=10s\nExecStart=/usr/bin/python3 {worker}\n') definition='[Timer]\nOnActiveSec=100ms\nOnUnitInactiveSec=10s\nAccuracySec=1ms\n' paths[timer].write_text(definition);(root/'fail').touch() try: run('systemd-analyze','verify',*[str(p) for p in paths.values()]) run('systemctl','daemon-reload');run('systemctl','start',timer) wait_for(lambda:value(service,'ActiveState')=='failed') assert value(service,'ExecMainStatus')=='7' and value(timer,'ActiveState')=='active' run('systemctl','stop',timer) print('timer active while its first job failed with exit status 7') (root/'fail').unlink();run('systemctl','reset-failed',service) paths[timer].write_text(definition.replace('OnUnitInactiveSec=10s','OnUnitInactiveSec=250ms')) run('systemctl','daemon-reload');run('systemctl','start',timer) wait_for(lambda:(root/'start-2.json').exists()) time.sleep(0.65) assert len(list(root.glob('start-*.json')))==2 (root/'release-2').touch();wait_for(lambda:(root/'start-3.json').exists()) gap=json.loads((root/'start-3.json').read_text())-json.loads((root/'end-2.json').read_text()) assert gap>=0.25,gap print('no overlapping launch; next job starts at least 250ms after prior deactivation') pid=value(service,'MainPID');assert pid!='0' run('systemctl','stop',timer) assert value(service,'MainPID')==pid and value(service,'ActiveState')=='activating' print('stopping timer leaves its already running oneshot alive') (root/'release-3').touch();wait_for(lambda:value(service,'ActiveState')=='inactive') time.sleep(0.4) assert len(list(root.glob('start-*.json')))==3 and value(service,'Result')=='success' print('released job completes; stopped timer schedules no further run') finally: run('systemctl','stop',timer,service) for unit,path in paths.items(): if value(unit,'ActiveState')=='failed':run('systemctl','reset-failed',unit) path.unlink() run('systemctl','daemon-reload') 第一次触发的工作进程故意以状态七退出,服务变为失败,定时器却继续活动。此时相对服务结束的下一次间隔先设置为十秒,让脚本有时间检查这个状态,并在再次尝试前停止定时器。结果证明调度器完全可能正常工作,而它安排的业务任务一直失败;仅凭 timer 状态不能得出任务可靠的结论。 ...

六月 21, 2027 · 2 分钟 · XB Field Notes
具有状态感知能力的 Ansible 模块比较现状与声明的目标。已经匹配时报告 ok,不匹配时可向目标收敛并报告 changed。这取决于模块及参数,不是对所有任务的统一保证。

Ansible 任务可以声明可重复收敛的目标状态

Ansible 的 playbook 按顺序编排任务。具有状态感知能力的模块可以记录机器应该拥有的状态,在部署中途失败后比较真实现状并补齐差异。收敛来自所选模块与参数,并不是执行引擎保证任何任务重跑都安全。它是一份可以执行的契约:inventory 决定对象,模块实现比对,操作者审阅即将发生的变化。 只写终点,不写每一步 命令式 shell 脚本会说“按顺序执行这些命令”;面向状态的任务则说“这个包应该已安装、这个文件应该是这些字节、这个服务应该启用并运行”。模块在动作前比较目标主机与目标状态,已经满足的任务报告 ok,只有不一致才报告 changed。同一份声明可以用于全新的机器、只配置了一半的机器,也可以用于被人手工改过的机器。 这里的目标不能只是模糊意图。state: present、模板的精确内容、所有者和权限、服务的 enabled/running 都是契约的一部分。把多台机器共用的值放到变量或 role 没问题,但最终值应该对审阅者可见。隐藏的默认值,或者绕过模块直接改变状态的命令,都会让这份契约难以推理。每一个 task 都应回答“运行完成后应该看到什么”,而不是“我习惯先敲哪一条命令”。 模块让重跑逐渐收敛 多数内置模块都围绕幂等性设计。ansible.builtin.copy 会先比较文件内容和元数据,只有不同才替换;ansible.builtin.package 向包管理器询问目标状态;ansible.builtin.service 使用 state: started 时只在运行状态不符时调整;state: restarted 则要求每次运行都重启。第二次运行应该是一份安静的确认,而不是再次重启、追加重复行或再写一条记录。幂等不是“什么都不做”,而是“已经达到目标,所以没有必要再做”。 并非所有动作天然幂等。直接用 command 创建用户、轮换密钥或调用外部 API,若没有条件限制就会重复副作用。优先找专用模块;没有时先 register 查询结果,再用 when 判断是否执行,或者使用精确的 creates/removes 文件条件。changed_when 只决定变更报告和 handler 通知,不控制任务是否执行;设成 false 不能阻止重复变更,还可能隐瞒真实副作用。对外部接口仍要定义超时、重试与部分成功后的恢复方法,不能把网络调用假装成普通文件复制。 ...

一月 17, 2027 · 2 分钟 · XB Field Notes
持久化定时器恢复活动后,错过的三个日历时刻合并为一次补触发。

为 systemd 定时器选对时钟和补跑规则

一份计划必须先说清楚“时间”是什么意思。“纽约每天早上九点”指向民用日历,“任务结束十五分钟后”则指向经过时长和一个参考事件。systemd timer 同时支持这两类表达,但它们使用的时钟与起算点不同,在时区变化、系统休眠和定时器停用期间会表现出不同结果。只把它们统称为周期调度,会隐藏本来应该由需求决定的差别。 日历持久化请求一次补触发,不会逐个重放所有遗漏业务周期。日期与三个事件的例子来自原文;本图不增加定时器运行验证证据。 坏掉的样子 OnCalendar 按墙钟日历表达式触发,省略时区通常使用系统本地时区,因此同一份配置在不同机器上可能代表不同实际时刻。夏令时规则变化时,每天相同的本地时间也可能相对 UTC 移动。应使用固定参考日期预览预期表达式,而不是把今天的 UTC 偏移当成全年不变的事实。调度语法能解析,只证明表达式有效,不证明它符合业务要求的地区时间。 单调计时选项明确命名参考事件。OnActiveSec 从 timer 本身激活起算,OnStartupSec 从服务管理器启动起算,OnUnitInactiveSec 从目标单元结束活动起算。这些并不是“隔几分钟运行”的可互换写法。普通单调计时在系统休眠期间暂停,WakeSystem 会改变时钟选择,并依赖平台支持。因此是否计入休眠时间,也应该属于调度需求,而不是部署后的偶然发现。 Persistent=yes 对日历计时器生效。它记录触发时刻,在定时器恢复活动且至少错过一个事件时请求一次补触发;不会逐次重放所有错过的日历时刻,也不能证明上次业务执行成功。如果配置了随机延迟,补触发仍受其影响。触发历史不是一条持久业务队列,不能据此推定停机期间应做的所有工作都已完整保存。 快速验证 在 PID 1 为 systemd、安装了时区数据库的一次性 Linux 系统中,以管理员身份运行下面的 Python 脚本。恢复文本记载的环境为 Debian 12、systemd 252.39,但原始运行日志不可用。本轮没有执行此样例;以下结果说明其预期断言,不能视为本轮实测。脚本先预览日历表达式,再安装两个唯一命名的定时器与服务组合,双方使用同样三个即将到来的日历时刻,全程不会调整机器时钟: from pathlib import Path from tempfile import TemporaryDirectory from datetime import datetime,timezone import subprocess,time,uuid name='xbcatm-calendar-'+uuid.uuid4().hex[:8] names=[name,name+'-plain'];paths=[] def run(*args): result=subprocess.run(args,text=True,capture_output=True,timeout=10) assert result.returncode==0,(args,result.stderr) return result.stdout.strip() preview=run('systemd-analyze','calendar','--base-time=2026-03-07 00:00:00 UTC', '--iterations=3','*-*-* 09:00:00 America/New_York') for expected in ('2026-03-07 14:00:00 UTC','2026-03-08 13:00:00 UTC','2026-03-09 13:00:00 UTC'): assert expected in preview,preview print('09:00 New York: 14:00 UTC before spring DST, 13:00 UTC after it') with TemporaryDirectory(prefix=name+'-',dir='/run') as directory: root=Path(directory);worker=root/'worker.py' worker.write_text("from pathlib import Path\nimport sys\np=Path(sys.argv[1])\nwith p.open('a') as output:output.write('run\\n')\n") now=int(time.time());events=[now+3,now+4,now+5] calendar=''.join('OnCalendar='+datetime.fromtimestamp(t,timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')+'\n' for t in events) try: for index,prefix in enumerate(names): service=Path('/run/systemd/system')/(prefix+'.service');timer=service.with_suffix('.timer') assert not service.exists() and not timer.exists() service.write_text(f'[Service]\nType=oneshot\nExecStart=/usr/bin/python3 {worker} {root}/{index}.log\n');paths.append(service) timer.write_text('[Timer]\n'+calendar+'AccuracySec=1ms\nRandomizedDelaySec=0\nPersistent='+('yes' if index==0 else 'no')+'\n');paths.append(timer) run('systemd-analyze','verify',*[str(p) for p in paths]);run('systemctl','daemon-reload') run('systemctl','start',name+'.timer');run('systemctl','stop',name+'.timer') assert not (root/'0.log').exists() and time.time()<events[0] while time.time()<events[-1]+0.15:time.sleep(0.05) run('systemctl','start',name+'.timer',name+'-plain.timer') deadline=time.monotonic()+3 while not (root/'0.log').exists(): assert time.monotonic()<deadline,'catch-up did not run';time.sleep(0.02) time.sleep(0.15) assert (root/'0.log').read_text()=='run\n' and not (root/'1.log').exists() print('three elapsed calendar events: persistent timer catches up once; plain timer does not') run('systemctl','stop',name+'.timer');run('systemctl','start',name+'.timer') time.sleep(0.2);assert (root/'0.log').read_text()=='run\n' print('reactivating with no newly missed event does not replay the job') print('timer inactivity was tested without changing the system clock or rebooting') finally: if paths: run('systemctl','stop',*[p.name for p in paths]) run('systemctl','clean','--what=state',name+'.timer') for path in paths:path.unlink(missing_ok=True) run('systemctl','daemon-reload') 预览从二〇二六年三月夏令时切换前的固定日期开始,询问纽约早上九点的后续发生时刻。第一次对应 UTC 十四点,后两次对应 UTC 十三点,本地钟表时间却始终是九点。这检查的是日历解释,并不是修改主机时区,也不是把系统时间跳到那些日期。对指定地区的每日任务而言,这种偏移变化可能正是正确行为。 ...

十一月 20, 2026 · 2 分钟 · XB Field Notes
对于同一逻辑键,生效的尝试把完成键与固定十单位额度一起提交。每次尝试各自使用事务;重复尝试也会提交,但不再增加额度。边界只在一个 SQLite 数据库内,不覆盖外部效果。

定时任务需要能够安全重跑

调度器启动的是一次尝试,业务关心的通常是某个逻辑任务是否完成。一笔每日额度可能因为报错、重启或人工补跑而尝试多次。如果每次尝试都生成新的身份,重复执行就无法与新工作区分。应使用能够代表预期任务的键,例如任务名称与名义计划时间,并在修改业务状态之前定义重复键的含义。尝试日志中的唯一编号可以保留,但不能代替业务层面的去重身份。 调度触发与业务执行是两件事 不能假设所有调度器都会补跑遗漏工作。普通 cron 的具体行为随实现变化,没有统一补跑承诺。启用 Persistent=true 的 systemd 日历定时器,在停用期间至少错过一次触发时,可以在重新启用后补触发;这不等于把所有错过的时间点逐个重放。Kubernetes 文档也明确说明,某些情况下 CronJob 可能重复创建 Job,或者没有创建对应任务。 因此要先确定业务规则:过时任务直接跳过、每个缺失周期都处理,还是对当前状态做一次校正。去重键应使用名义上的逻辑周期,并明确时区。实际启动时间会随重试变化,不能用于识别同一次计划工作。诊断需要的尝试编号与业务身份应分开记录,这样才能同时看见执行了几次尝试,以及到底完成了几个逻辑周期。 坏掉的样子 先查询有没有处理过,再单独修改数据,存在竞态。两个工作进程可能都看到键不存在,然后都应用业务效果。进程锁也许能阻止一台机器上的重叠运行,却不能让业务更新与完成标记文件自动形成原子操作。如果更新成功后、写标记之前失败,重试就会重复生效;先写标记再执行业务,则可能把尚未发生的工作误记为完成。调整两个步骤的先后顺序不能消除中间窗口。 当业务状态与完成记录位于同一个事务数据库时,应在唯一约束下共同提交。提交之前失败,两者都不应留下;提交之后重试,应识别已经完成的逻辑键。SQLite 的 BEGIN IMMEDIATE 会提前开始写事务,其他写入者可能等待,也可能因为繁忙而失败。超时限制等待长度,并不保证任何并发量下都不会冲突;调用方仍需正确区分重试、失败和重复完成。 这个原子性边界不覆盖外部邮件、支付或 HTTP 调用。本地回滚无法撤销远端已经接受的效果。此类流程需要远端提供合适的幂等契约,或采用持久投递设计并在接收方去重。同一个键如果可能携带不同负载,还需要明确拒绝这种冲突的规则。下面示例始终增加固定额度,因此只验证同一逻辑操作的重复尝试,没有把变更参数的重用键视为新请求。 固定额度与唯一完成键在一个 SQLite 事务中提交。历史八线程实验仅在此本地边界内通过;调度送达、强制终止恢复和外部恰好一次效果仍未验证。本次绘图未重跑示例。 快速验证 原文记录的 Python 3.12.14 与 SQLite 3.53.1 仍作为历史证据保留;本轮复跑记录另见下文。Python 需要包含 sqlite3,所链接的 SQLite 至少为 3.24.0 才支持 UPSERT,并须支持多线程分别使用独立连接。 ...

八月 15, 2026 · 2 分钟 · XB Field Notes