一份计划必须先说清楚“时间”是什么意思。“纽约每天早上九点”指向民用日历,“任务结束十五分钟后”则指向经过时长和一个参考事件。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 十三点,本地钟表时间却始终是九点。这检查的是日历解释,并不是修改主机时区,也不是把系统时间跳到那些日期。对指定地区的每日任务而言,这种偏移变化可能正是正确行为。

持久化 timer 在第一个实验时刻到来前先启动,再停止;首次启动建立它的时间戳状态。停用期间,三个指定时刻全部经过。再次激活时,它恰好运行一次服务,结果文件只有一行。非持久化 timer 同样在这些时刻之后才激活,却没有生成结果文件。因此差别来自已经实际存在的停用区间及对应补触发策略,而不是两份命令做了不同的工作。

这个实验真实经历了定时器停用,没有手工修改时间戳文件来伪装漏跑,也不需要重启机器。三个过去事件合并为一次激活;如果应用必须处理三个独立结算周期,就需要自己发现并记录这些周期。不能让“已经补跑一次”的系统状态替代业务层对遗漏范围和处理结果的判断。

随后再次停止并启动持久化 timer,结果文件没有新增行,因为期间没有新的事件被错过。清理时先停止运行时单元,再通过 systemctl clean --what=state 删除持久化 timer 的状态;非持久化 timer 没有对应状态资源,不执行这项清理。最后删除配置文件和无害结果目录,让生命周期验证包括本次创建资源的实际回收。

实验把 AccuracySec 设为一毫秒,随机延迟设为零,让较短的过程容易观察。但这些设置不保证任意负载下进程都在精确时刻开始。代码会在有限等待窗口内观察实际完成,并断言调用次数,而不是把预测截止时间直接当成命令已经执行。计划时刻、管理器发出激活请求和程序开始运行,本来就是不同事件。

明确哪些漏掉的工作必须补齐

对于备份,停机后生成一份新的快照可能符合恢复策略;对于计费,每个遗漏周期可能都需要独立核对。这种差别应放进应用状态与幂等规则。服务在 timer 到点时已经活动,不会自动复制成另一个实例;保留活动状态的 oneshot,也可能阻止命令再次运行。因此补触发能力必须与目标服务生命周期一起考虑,不能只看 timer 配置。

检查调度时应查看已载入配置和下次、上次触发属性,检查执行时则查看目标服务结果。日历 timer 排在时间相关 target 之后,本身并不证明远端时间源已经完成校时;如果业务需要这个保证,实际时间服务配置必须提供它。只有顺序关系,没有兑现条件的服务,就不能当成可靠的同步屏障。

依赖一份计划之前,应验证有代表性的日期与生命周期变化,包括时区边界、定时器禁用和任务预期耗时。经过时长需求选择合适的单调参考事件,民用时间需求选择明确地区的日历规则。同时把“有用工作已完成”的证据与“管理器发出过触发”的证据分别保存,才能解释计划正确却业务结果缺失的情况。

延伸阅读

来源