定时器有四步循环,“每秒一次”就真会打四次

一、要改的第一个直觉:进程是执行单元,不是线程
这类常驻框架的运行方式是主进程按配置派生出若干工作进程,每个工作进程独立跑自己的事件循环,彼此不共享内存。而定时任务的注册位置在 onWorkerStart 这个回调里,这个回调是每个工作进程启动时各执行一遍。所以写在代码里的“注册一个每秒执行一次的定时器”,真实含义是“在每一个工作进程里都注册一个每秒执行的定时器”。进程数配成 4,一秒之内这个任务就被触发 4 次。
接口签名把这件事讲得很清楚,只是我们读的时候没把它和进程模型连起来。Timer::add(float $interval, callable $callback, array $args = [], bool $persistent = true):间隔秒数、回调、传参数组、是否持续执行。它承诺的是“本进程内每经过这个间隔执行一次”,从来没有承诺“全局一次”,因为在多进程模型里它做不到。我们最早遇到这类问题时的现象都很具体:第三方接口的调用量比业务侧统计的任务数多出正好一个整数倍,而这个倍数恰好等于当时的工作进程数。这不是偶发抖动,是确定性的重复。
把这条读透之后,后面所有关于定时任务的判断都换了一套问法:不问“这个任务多久跑一次”,先问“这个任务在几个执行单元里各跑一次”。
也要把边界说清楚:上面这种重复触发是这一个框架在多进程模型下的具体语义,不是所有常驻运行时的公共结论。有的运行时把工作项交给外部调度器统一派发,有的用线程共享同一份注册表,语义与这里完全不同。我们要搬的不是它的具体行为,而是读接口的那四个问题。
二、读一个定时接口要问的四件事
我们把定时接口的阅读动作固化成四问,任何常驻服务在接入前都要填这张表。
| 语义点 | 要问的具体问题 | 这个接口给的回答 | 读错之后的典型后果 |
|---|---|---|---|
| 精度下限与抖动 | 间隔能小到多少还能稳定 | 最小稳定精度约 0.1 秒 | 把亚秒级节拍当可靠时钟,跑一段时间后累计偏移 |
| 返回值语义 | 返回值能否用于后续操作 | 返回一个整型任务标识 | 拿不到标识就只能全量重建,无法定点摘除 |
| 取消与重置 | 有无取消、有无改周期、有无批量删除 | 只有取消;没有重置接口,也没有批量删除 | 以为改一次配置就换了周期,实际只能取消再重新注册 |
| 注册时机 | 是否只能在启动回调里注册 | 只能在 onWorkerStart 里注册 |
在业务分支里随手注册,进程数或启动顺序一变行为就变 |
四问里最容易被跳过的是第三问。没有重置接口意味着“修改周期”这个动作在实现层面根本不存在,凡是把任务周期做成可配置项、指望运行中热更新的设计,都要额外写一段“先取消再重注册”的逻辑,而这段逻辑的原子性又依赖第一列那个整型标识是否被完整保存。洹拓在交付这类系统时会要求把任务标识与业务任务名一起落库,否则运行一段时间后没人知道哪个标识对应哪个业务。
三、重复触发的三种真实后果,以及那个看起来聪明的错修法
第一种后果最贵:对外部系统的调用被打了 N 倍。同步接口、计费接口、风控接口都有配额和频次语义,倍数直接体现在费用、限流拒绝率和对方侧的风控标记上。第二种是任务互相抢占:多个进程同时去改同一批数据或同一把业务锁,出现周期性的抖动与冲突重试,观测上表现为“每隔一秒就有一小波负载”。第三种最隐蔽:下游看到的执行次数和业务次数不一致,任何用执行次数做的统计、对账、结算都会失真,而且失真是整数倍的,很容易被误读成业务真的涨了。
错修法是“按进程数换算”——把间隔乘以进程数,或者干脆认定“反正四个进程平均一下也差不多”。这条路的问题在于进程数是一个可变的部署参数,扩缩容、故障重启、灰度期间新旧进程并存,都会让那个倍数在运行期变化;更要紧的是它把语义问题当成数值问题处理,业务本来要的是“一次”,换算之后仍然是“多次里的一次碰巧赢了”,剩下三次仍然在执行副作用代码。
正确的方向是先决定“这个任务到底要不要单例”,再选实现。
四、单例执行的三条路径:适用条件与失效场景
| 路径 | 做法 | 适用条件 | 失效场景 |
|---|---|---|---|
| 专用进程 | 单独划一个进程把工作进程数设为 1,只让它注册定时器 | 任务重、需要独占资源、希望隔离最彻底 | 多管一个进程,它的存活、重启、监控都得单独配齐 |
| 分布式锁 | 触发时用 SETNX lock EX 10 抢到锁的进程才执行 |
多实例部署,且已有可用的锁存储 | 强依赖锁存储的可用性;锁过期时间与任务时长的关系没定死就会出事故 |
| 本机文件锁 | 用 flock 的独占且非阻塞方式抢一把文件锁 |
单机的多进程互斥,不想引入中间件 | 只在单机内有效,多实例部署等于没做 |
第三条路径的完整写法是独占标志与非阻塞标志的组合,也就是 flock LOCK_EX | LOCK_NB:独占保证同一时刻只有一个进程持有,非阻塞保证抢不到锁的进程立刻返回而不是停在原地等,否则一个进程会挂在锁上把整个事件循环拖住。
中间那行的锁过期时间要单独说一句。像上面那种写法,锁的存活被设成一个固定秒数,那么判据只有一个:这个秒数必须大于任务在最坏情况下的执行时长,并且这个“最坏”要来自实测而不是估计。设短了,任务还没跑完锁就自己过期,第二个进程照样进来,重复执行回来了;设长了,持有锁的进程中途崩溃,锁迟迟不释放,任务在无人察觉的情况下停摆。我们的做法是把锁有效期按任务的最坏执行时长留出明显余量,同时在任务结束时显式释放,并给“这一轮没有任何进程抢到锁”配一条独立报警。
五、把“每秒一次”写成可核对的口径
口头承诺的“每秒一次”不可核对,落成四个字段才可核对:执行次数(本轮真正进入回调的次数)、业务次数(本轮真正产生业务结果的次数)、期望频率(配置里声明的间隔与单位)、触发来源进程标识(每一次执行由哪个进程发起)。前两个的差值就是被抢占、被跳过、被拒绝的部分;后两个能回答“这个偏差是不是进程数造成的”。
任务时长超过间隔时只有三种策略,必须显式选一种并让它可观测:丢弃本轮、排队等上一轮结束、允许并发跑。丢弃本轮的信号是业务次数长期低于执行次数且缺口稳定;排队的信号是队列深度单调上涨,一旦超过间隔就再也追不回来;并发跑的信号是同一业务键上出现交叉更新。这三种信号我们在监控里都按任务名分桶,因为“平均一下还正常”恰恰是这类问题最典型的伪装形态。
精度敏感的任务不该放在进程内定时器上。判据很简单:如果这个任务的正确性依赖“在指定的那一毫秒触发”,或者依赖跨进程的全局节拍,那它需要外部调度器来发令,进程内的定时器只适合“每隔一段时间做一次,早一点晚一点不影响业务”的这一类。
六、重启语义与上线前的最小验证
重启要测两件事。第一件是生命周期:进程重启时,未显式删除的任务自动失效。这条决定了“任务是否需要在每次启动时重新注册完整”,也意味着任何靠内存里遗留定时器维持的状态在重启后都不存在。第二件是中断:一个正在执行的任务被重启打断之后,它留下的半成品由谁收拾,重跑会不会把已经做完的部分再做一遍。这两件事没有一条是靠读代码确认的,都得实际重启一次看结果。
上线前的最小验证清单就四条:把每个工作进程的触发时刻打印出来做差,看是不是同一秒出现多次;把统计窗口内的执行次数除以进程数,看是否与期望频率对得上;调一次取消接口,用任务标识确认它真的不再触发,并验证没有批量删除这件事会带来的清理成本;跑一次重启,确认任务失效与重新注册的行为符合预期。四条里只要有一条没做,“每秒一次”这句话在验收文档里就不该出现。