Files
ulthon_admin/.agents/skills/ulthon-timer/SKILL.md
augushong 1b0be9a29b docs(timer): 定时器动态管控技能文档补充四章节
新增并发数动态调整、手动触发定向、多节点协调、clamp 有效域钳制四章节,含 12 个示例对照与 4 个坑位说明。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-25 21:58:48 +08:00

29 KiB
Raw Blame History

name, description
name description
ulthon-timer 内置秒级定时器php think timer的使用与扩展规范用于新增/调整定时任务site/call、并发分片、TimerController 防刷)。

timer内置秒级定时器

核心机制(你要记住的 4 件事)

  1. 任务配置统一从 app_file_path('common/command/timer/config.php') 读取
  2. 每个配置会按 concurrency 自动展开成多份任务实例,并自动注入 concurrency_id / concurrency_countconcurrency 支持后台动态覆盖DB 字段为 NULL 时继承代码默认,详见「动态控制 > 动态并发机制」)
  3. "是否该执行"主要由定时器侧的 Cache 节流控制(可选叠加控制器侧的防刷保护)
  4. 进程运行期间支持热加载:后台改并发、手动触发某节点跑一次,都无需重启 php think timer(详见「动态控制」章节)

相关实现入口:

  • 系统入口(优先从 app 层理解):
    • 定时器命令入口:app/common/command/Timer.php(实现继承自 extend/base/common/command/TimerBase.php
    • 任务实例服务入口:app/common/service/TimerService.php(实现继承自 extend/base/common/service/TimerServiceBase.php
    • site 任务控制器基类入口:app/common/controller/TimerController.php(实现继承自 extend/base/common/controller/TimerControllerBase.php
  • 实现文件(需要深入机制时再看 Base

新增定时任务(默认规则)

无特殊情况下,新增定时任务应当通过本机制实现:在 timer/config.php 注册任务 + 以 sitecall 的方式实现目标逻辑。

1) 选择任务类型

  • site:通过 HTTP 访问一个控制器地址(默认优先使用)。即使任务需要“长时间运行”,也尽量设计成 site 模式(分片/分批/可重入),以复用框架的页面/接口同体、鉴权、日志、事务、限流等能力。
  • call:直接执行一个 PHP callable不推荐除非万不得已。仅在确实不适合走 HTTP 上下文、且不希望暴露为控制器入口时使用。

长时间运行任务的推荐写法(仍用 site

  • 设计为"可重入"的短任务:每次 do() 只处理一小批数据,处理进度写入缓存/表,下次继续
  • 结合 concurrency 做分片:按 $this->concurrencyId 划分数据范围,多个实例并行推进
  • 结合 frequency 控制节奏:用调度频率限制整体吞吐,避免单次占用过久

按时间窗口循环处理(队列消费推荐)

适用于:需要持续消费队列/轮询数据的场景(如消息处理、订单状态同步、通知推送等)。

核心思路:在 do() 方法内设置一个最大执行时间窗口,循环处理单条数据,超时后自动退出,由定时器下次调度继续。

class MyQueueTask extends TimerController
{
    // 每次执行的最大运行时间(秒),根据业务和定时器 frequency 合理设置
    // 建议不超过 frequency 的一半,留出调度间隔
    protected $maxRunTime = 5;

    // 防刷间隔(秒),与定时器侧 frequency 配合
    protected $frequency = 10;

    public function do()
    {
        $maxTime = time() + $this->maxRunTime;

        do {
            $this->doItem();

            // 超时检查:时间窗口用完则退出,下次调度继续
            if (time() >= $maxTime) {
                break;
            }
        } while (true);
    }

    protected function doItem()
    {
        // 取一条待处理的数据
        $item = SomeModel::where('status', 0)->order('id', 'asc')->find();

        if (empty($item)) {
            // 队列为空时短暂休眠,避免空转消耗 CPU
            sleep(1);
            return;
        }

        // ... 处理单条数据的业务逻辑 ...

        $item->status = 1;
        $item->save();
    }
}

要点:

配置项 建议
maxRunTime 不超过 frequency 的一半;例如 frequency=10 时设 3~5 秒
doItem() 中的 sleep() 队列空时必须休眠,防止 CPU 空转;有数据时不要 sleep
doItem() 的粒度 每次只处理一条/一批数据,保证可重入、可中断
frequency maxRunTime 配合,frequency >= maxRunTime * 2 为宜

与普通定时任务的区别:

  • 普通任务:do() 执行一次就返回,靠定时器周期性调度
  • 时间窗口模式:do() 在时间窗口内持续循环消费,处理完积压数据后自动退出
  • 优势:面对突发积压数据时,单次调度可处理多条,提高吞吐;超时安全退出,不会阻塞定时器

2) 编写任务目标target

A. site 类型HTTP 任务)

实现方式建议:

  • 框架使用者(做业务):仅在 app/tools/controller/timer/ 新增控制器 *.php 即可;不需要、也不应该在 extend/base/ 增加 *Base.php
  • 框架作者(维护内核):在 extend/base/tools/controller/timer/ 新增 *Base.php 作为默认实现,同时在 app/tools/controller/timer/ 增加同名入口类 *.php 继承 Base系统唯一调用入口
  • 控制器建议继承 app\common\controller\TimerController(以获得并发参数校验与可选的 $frequency 防刷);执行入口一般提供 do()

示例(已有实现可参考):ClearLog.phpClearLogBase.php

并发与防刷建议:

  • 如需并发分片处理,在控制器中设置:
    • protected $concurrency = N;
    • 使用 $this->concurrencyId 做分片编号0 ~ N-1
  • 如希望防止外部重复请求(不仅是定时器自身节流),在控制器中设置:
    • protected $frequency = 秒数;
    • 这会启用 TimerControllerBase::protectVisit() 基于 URL 的防刷限制

B. call 类型(函数任务)

目标形态:

  • targetcall_user_func 可执行的 callable例如[SomeService::class, 'method']、闭包等
  • 由于 Base/App 双层机制的入口要求,类引用应指向 app/ 下的入口类(由入口类继承 Base 实现)
  • 长时间/重任务不建议用 call:它缺少 HTTP 上下文与控制器层通用能力,排错与复用成本更高

3) 注册到任务配置timer/config.php

默认配置文件位置(支持分层覆盖):

  • 优先覆盖:app/common/command/timer/config.php(一旦存在,app_file_path(...) 将优先读取此文件)
  • 框架默认:extend/base/common/command/timer/config.php(当 app 未提供时回落到该文件)

该覆盖行为是框架"文件级覆盖机制"的实例之一视图模板、include 标签、app_file_path 共三类)。完整说明与不支持覆盖的反例清单详见规则:文件级覆盖机制

字段说明(兜底默认值由 Base 层的 initConfigItem() 提供):

  • name:任务唯一名称(用于 Cache key不可重复
  • typesitecall
  • target
    • site:以 / 开头的相对路径(建议指向 tools/timer.* 控制器的 do 方法)
    • callcallable
  • frequency:执行频率(秒),小于 0 会被修正为 0
  • concurrency:并发数量(默认 1site 类型会自动把并发参数写入 query
  • run_type:调度策略(仅对 site 类型生效,call 类型不受影响)。可选值见「多节点协调 > run_type 调度」

示例site

return [
    [
        'name' => 'clear_log',
        'type' => 'site',
        'target' => '/tools/timer.ClearLog/do',
        'frequency' => 600,
        'concurrency' => 1,
    ],
];

示例call

return [
    [
        'name' => 'system_host_register',
        'type' => 'call',
        'target' => [\app\common\service\HostService::class, 'heartbeat'],
        'frequency' => 30,
    ],
];

运行与验证

运行命令

  • 常规运行:php think timer
  • 只跑一轮(便于验证):php think timer --temp
  • 无任务时不输出“no request”php think timer --quiet

本地调试(指定请求 Host

site 任务会按站点域名发起请求,默认从 sysconfig('site','site_domain') 读取。

  • 本地调试:php think timer --local --local-host=http://localhost --local-port=8000

运行模式

配置在 timer.php。定时器使用 Guzzle CurlMultiHandler 实现非阻塞异步事件循环:

  • site 类型任务通过 curl multi 并行发送 HTTP 请求,真正非阻塞
  • call 类型任务在主循环中同步执行
  • pending 数组追踪进行中的请求,handler->tick() 非阻塞推进
  • 自适应 sleep 策略50ms/200ms避免 CPU 空转

配置项说明

配置键 默认值 说明
connect_timeout 30 连接超时时间(秒)
timeout 86400 请求响应超时时间(秒)
max_handles 100 curl multi 最大并发句柄数
select_timeout 0.001 curl_multi_select 超时(秒)
force_reload_interval 15 热加载兜底 reload 间隔(秒),防 cache 丢失导致脏标记漏检;.envTIMER_FORCE_RELOAD_INTERVAL 覆盖
trigger_ttl 300 手动触发 TTL超时未消费自动复位 manual_trigger.envTIMER_TRIGGER_TTL 覆盖
drain_max_lifetime 86400 drain 实例最长存活(秒),超时强制移除防永不 drain.envTIMER_DRAIN_MAX_LIFETIME 覆盖
clear_log_days 3 ClearLog 任务清理 debug_log 表的保留天数,支持从 .envTIMER_CLEAR_LOG_DAYS 覆盖

常见坑位(快速自检)

  • name 重复:会导致 Cache key 冲突,表现为任务"莫名其妙不跑/跑得不对"
  • concurrency 与控制器侧 $concurrency 不一致:会触发 concurrency id/count error
  • 只依赖控制器侧 $frequency:它只是防刷,不是调度;调度频率以定时器侧 Cache 节流为准
  • 本地开发忘记 --localsite 任务默认请求 sysconfig('site','site_domain') 指向的生产域名,不带 --local 会直接打到线上
  • concurrency 设成 0 或负数:会被有效域钳制为 1 并打 [timer] clamp 告警日志(防静默吞任务)。后台 UI 也会拒绝 0想"继承默认"应该留空(写 NULL而不是填 0
  • 手动触发依赖 timer 进程在跑:手动触发走的是定时器调度链路(写 manual_trigger=1 等 timer 消费),不是后台直接同步调控制器。如果 timer 进程没启动,触发请求会一直挂着,直到 trigger_ttl(默认 300 秒)超时自动复位。所以触发前先确认 php think timer 在运行
  • 缩容 drain 有延迟:缩容时多余实例不会立刻消失,要等在飞请求完成(pending 里没有该 key才移除。极端慢的任务 drain 会很慢,drain_max_lifetime(默认 86400 秒)是兜底上限,超时强制移除。需要立刻见效只能重启 timer 进程(但重启会放弃所有 drain 连续性)
  • 新增 system_timer_config 字段后必须清 runtime/cache/ThinkPHP ORM 有字段 schema 缓存,即使 fields_cache=false,某些路径仍可能缓存旧字段列表。新增字段(如 concurrency / trigger_node_id)后不清缓存,Db::name('system_timer_config')->update(...) 会报 fields not existsphp think clear --type=cache 或手动删 runtime/cache/ 即可

多节点协调

定时器支持多节点部署,以数据库为协调中心。多个节点连接同一个数据库即自动组成集群,无需额外服务发现。

设计决策

  • 以数据库为协调中心:多节点连接同一个数据库即自动组成集群
  • 主节点自动选举:第一个注册的节点自动成为主节点;管理员可在主机列表页面手动切换
  • 节点身份持久化:节点 ID{hostname}-{8位md5})写入 runtime/node_id.lock,重启保持不变
  • 节点心跳:通过 system_host_register call 任务每 30 秒自动注册一次
  • 配置通过 UI 管理run_typestatus、手动触发等均由管理后台维护

run_type 调度模式

run_type 仅对 site 类型任务生效;call 类型任务始终在所有节点执行。

run_type 行为 适用场景
auto(默认) 两阶段 DB 行锁竞争BEGIN / SELECT FOR UPDATE / UPDATE / COMMIT 抢占,抢占成功后释放锁再执行。每个频率窗口内只有一个节点执行 通用场景
main 仅主节点执行 需要集中处理的任务
all 所有节点各自独立执行 节点本地清理等
manual 该类型的任务永不自动执行,执行全靠后台手动触发(manual_trigger=1 时被消费)。注意:手动触发现在跨 run_type 通用,任何 run_type 的任务都能在后台手动触发一次,manual 类型只是"只接受手动触发"的极端形态(详见「动态控制 > 手动触发升级」) 仅手动触发的任务

节点注册与主节点管理

  • 节点启动后自动通过 system_host_register call 任务注册心跳(每 30 秒一次)
  • 节点 ID 持久化在 runtime/node_id.lock(重启保持不变)
  • 主节点切换 APIHostService::setMasterNode() / HostService::getMasterNode()

自动机制(开发者无需手动调用)

  • 配置同步:定时器启动时 TimerService::syncConfigToDatabase() 将 PHP 配置同步到 system_timer_config 表,不覆盖 run_typestatusconcurrency 等管理字段(以 UI/DB 为准)。新任务进表时 concurrency 留 NULL 表示继承代码默认
  • 热加载:后台改 concurrency 或手动触发时,TimerConfig 控制器在 DB commit 成功后递增脏标记 cache key timer_config_versionrunLoop 每轮检测该标记变化即触发 reload按 task 粒度合并 diff不全量重建。另有每 15 秒的兜底 reload 防 cache 丢失。详见「动态控制」章节
  • 执行日志TimerControllerBase::execute() 自动包裹 do() 并调用 logStart() / logEnd()。配置中写 /do,运行时 TimerServiceBase 自动重写为 /execute 以触发日志包裹。host_id 会自动注入到 site 任务的 URL 参数中

相关数据表

  • ul_system_timer_config:任务协调配置。核心字段:run_typestatusmanual_triggerconcurrencynullableNULL=继承代码默认,正整数=覆盖)、trigger_node_id(手动触发定向节点)、last_trigger_time(触发时间戳,配合 TTL 自动复位)、last_execute_node / last_execute_timeauto 模式竞争用)
  • ul_system_timer_log:执行日志记录
  • ul_system_host:节点注册信息(含 is_master 字段标识主节点)

管理后台

页面 路径 功能
定时器配置 /admin/system.timer_config/index 管理 run_type、status、手动触发、动态并发含节点叠加视角详见「动态控制 > 节点中心 UI」
定时器日志 /admin/system.timer_log/index 只读执行日志查看
主机列表 /admin/system.host/index 节点状态、主节点管理

动态控制(动态并发 / 手动触发升级 / 节点中心 UI

这一章覆盖进程运行期间的动态能力:后台改并发实时生效、对任意任务手动触发一次来调试、按节点视角管理多节点执行情况。核心入口都在 extend/base/common/command/TimerBase.phpapp/admin/controller/system/TimerConfig.php

动态并发机制(后台可调 + 热加载)

1. concurrency 优先级链

concurrency 的生效值按下面的链路解析,框架升级改默认值能自动惠及没定制过的任务:

代码基线timer/config.php 的 concurrency
        ↓ 被覆盖
DB system_timer_config.concurrencynullable
        ↓ NULL 表示"继承代码默认"
        ↓ 正整数表示"覆盖代码默认"
生效值(再经下面的有效域钳制)
  • DB 字段为 NULL:继承代码默认(存量任务、新任务进表时都留 NULL向后兼容
  • DB 字段为正整数:覆盖代码默认
  • DB 字段为 0 或负数:会被钳制为 1 并告警(防静默吞任务,见常见坑位)

2. 热加载机制

后台改 concurrency 或触发手动执行时,TimerConfig 控制器在 DB commit 成功后递增脏标记:

// app/admin/controller/system/TimerConfig.php 的 trigger() / edit()
$row->save([...]);
Cache::inc('timer_config_version');  // 必须在 commit 成功之后D18防脏读

runLoop 每轮检测这个标记变化:

// extend/base/common/command/TimerBase.php 的 runLoop()
$current_version = Cache::get('timer_config_version', 0);  // 故意不打 tag防被 Cache::tag()->clear() 连带清掉
$force_interval = (int) Config::get('timer.force_reload_interval', 15);
if ($current_version !== $this->lastVersion || (time() - $this->lastForceReload) >= $force_interval) {
    $effective_map = $this->computeEffectiveConcurrency();  // 钳制有效域
    $this->reloadRequestList($effective_map);               // 合并式 reload不全量重建
    $this->checkTriggerTtl();                               // TTL 复位检查
    $this->lastVersion = $current_version;
    $this->lastForceReload = time();
}

两层探测:

  • 脏标记(实时):后台一改,下个 tick 就 reload
  • 兜底 reload每 15 秒):防 cache 丢失导致脏标记漏检,最坏 15 秒内也能追上

多节点前提:脏标记跨节点传播依赖共享 cacheRedis。文件 cache 下节点间互不可见,只能靠每 15 秒兜底 reload 兜底。生产多节点务必用 Redis cache。

3. 有效域钳制

reload 时对每个任务的 concurrency 三重钳制:

effective = max(1, min(DB 值 ?? 代码值, 控制器 $concurrency cap, 剩余 max_handles 预算))
  • 控制器 capTimerControllerBase::$concurrency 现在是"最大并发上限"语义(放行 concurrency_id < cap 的所有分片DB 实际并发不会超过它
  • max_handles 预算:全局 curl multi 句柄上限(默认 100减去其他任务已占的 active 实例数
  • 超限(effective != raw)会写 [timer] clamp task={name} db={v} effective={n} 告警日志

4. 优雅增缩容

reload 用合并而非重建(保留其他任务的 draining 实例,不影响其 drain 连续性):

  • 扩容append 新实例,concurrency_id 从该任务现有 max(id) + 1 续编(保证 id 单调递增不重用)
  • 缩容:把超出的实例从高 id 开始置 state=draining,停止发新请求,保留在结构中等在飞请求完成
  • 缩容时清掉被 drain 实例的节流 cache keytimer_request_{name}_{id}),防下次扩容回原值被旧频率窗口误判跳过
  • drain 最长存活上限(drain_max_lifetime,默认 86400 秒draining 实例超过该时长强制移除,防任务卡死导致永不 drain

drain 完成的判定与移除发生在 runLoop 遍历时:

// draining 实例pending 无此 key 或超过 drain_max_lifetime 则移除
if ($state === 'draining') {
    $drain_max = (int) Config::get('timer.drain_max_lifetime', Config::get('timer.timeout', 86400));
    $timed_out = ($drain_started > 0 && (time() - $drain_started) > $drain_max);
    if (!isset($pending[$key]) || $timed_out) {
        unset($this->requestList[$arr_key]);
        Log::info('[timer] drain-done task=' . $name . ' id=' . $request_item['concurrency_id'] . ($timed_out ? ' (timeout)' : ''));
    }
    continue;
}

5. 崩溃恢复契约(重要边界)

drain 状态是纯内存的。进程重启后以 DB concurrency 为准全重建(generateAllRequestList 产出 0..N-1 完整分片),放弃 drain 连续性。重启前正在 draining 的实例不会恢复 draining直接按新 DB 值重建为 active。concurrency_id 的稳定性也只在单进程生命周期内保证,重启后重置为 0..N-1。

6. 遍历不变量

runLoop 直接遍历 $this->requestList 属性,不再用局部副本(旧实现 $request_list = $this->requestList 是 COW 拷贝reload 改属性但 foreach 仍遍历旧副本会导致 reload 永不生效。PHP foreach 进入时拷贝数组值while 每轮重新 foreachreload 修改属性后下一轮可见mid-iteration append 也安全。

7. 统一日志格式(验收 grep 基准)

事件 日志格式
reload [timer] reload task={name} concurrency {old}->{new} effective={n}
drain 开始 [timer] drain task={name} id={id}
drain 完成 [timer] drain-done task={name} id={id}(超时则尾部加 (timeout)
手动触发消费 [timer] trigger task={name} node={id} consumed
触发 TTL 复位 [timer] trigger-expire task={name} node={id} reset
并发被钳制 [timer] clamp task={name} db={v} effective={n}

手动触发升级

1. 跨 run_type 通用

手动触发不再限定 manual 类型。任何 run_typeauto / main / all / manual的任务都能在后台手动触发一次。shouldExecuteTask 的判定顺序:

  1. call 类型直接放行
  2. 手动触发优先检查(穿透 status读 configmanual_trigger=1,原子条件 UPDATE 抢占
  3. status=0 拦截自动调度(手动已在步骤 2 穿透,所以已停用的任务也能手动触发一次)
  4. run_type 调度manual 类型在此 return false即永不自动执行全靠步骤 2

2. 穿透 status 的调试工作流

常见调试场景:把任务 status 临时关掉(停自动调度),反复手动触发验证,验证完再打开。手动触发穿透 status 让这个工作流顺畅:

status=0关自动 → 后台手动触发 → 定向节点跑一次 → 看日志 → 再触发 → ... → status=1开自动

3. 必带 trigger_node_id定向单节点

手动触发必须定向到选中节点(后台 UI 强制选节点才能触发,空值会被拒绝)。这是机制约束也是调试场景的正确语义:

  • manual_trigger 是全局单标志位,单赢家原子 UPDATE 只能定向单节点,无法实现"全节点各跑一次"
  • 手动触发本质=调试某节点跑一次all 模式的"全节点"需求靠正常调度(status=1 + all run_type 自动每节点跑),不靠手动触发

4. 原子条件 UPDATE防多节点竞态

抢占用原子条件 UPDATE避免旧实现 find → update 的竞态:

// extend/base/common/command/TimerBase.php 的 shouldExecuteTask()
$affected = Db::name('system_timer_config')
    ->where('task_name', $task_name)
    ->where('manual_trigger', 1)
    ->where('trigger_node_id', $current_node_id)  // 定向本节点
    ->update([
        'manual_trigger'    => 0,
        'trigger_node_id'   => null,
        'last_trigger_time' => null,
        'update_time'       => time(),
    ]);
if ($affected === 1) {
    Log::info('[timer] trigger task=' . $task_name . ' node=' . $current_node_id . ' consumed');
    return true;
}
// affected=0本节点不是目标fall through 走正常调度

多节点同时触发同一任务同一节点时,只有一个节点能 affected=1 消费成功。

5. bypass 频率节流(但仍写 cache

runLoop 把手动触发预检提到 cache 节流检查之前,让手动触发立即消费、不受 frequency 窗口阻塞:

// runLoop() 内
$bypass_throttle = !empty($task_cfg['manual_trigger'])
    && !empty($task_cfg['trigger_node_id'])
    && $task_cfg['trigger_node_id'] === HostService::getNodeId();

if (!$bypass_throttle) {
    $last_exec_time = Cache::get($cache_key, 0);
    if ($last_exec_time >= time() - $request_item['frequency']) {
        continue;
    }
}
// 无论 bypass 与否都写节流 cache防 all/main 无 DB 频率兜底的任务跨 tick 双发)
Cache::tag($cache_tag)->set($cache_key, time());

关键:bypass 节流检查,但不 bypass 节流写入。只 bypass 检查会导致 all/main无 DB 频率兜底)的任务在同一个 tick 内被多个实例双发。

6. TTL 自动复位

触发指向离线节点时(节点宕机/网络异常),manual_trigger=1 会卡住。runLoop 的 reload 兜底分支检查 TTL

// checkTriggerTtl()
$ttl = (int) Config::get('timer.trigger_ttl', 300);
// manual_trigger=1 且 last_trigger_time 超过 300s 未消费 → 复位
Db::name('system_timer_config')
    ->where('id', $r['id'])
    ->where('manual_trigger', 1)  // 防并发消费后误复位
    ->update(['manual_trigger' => 0, 'trigger_node_id' => null, 'last_trigger_time' => null]);
Log::info('[timer] trigger-expire task=' . $r['task_name'] . ' node=' . $r['trigger_node_id'] . ' reset');

7. consume-then-fail 取舍

触发被消费后(affected=1),如果随后 getAsync 请求失败(网络/控制器异常),触发不会自动重试。这是原子单赢家机制的固有取舍:标志位已清,无法回滚。运维看错误日志后手动重新触发即可。

节点中心 UI

定时器配置页(/admin/system.timer_config/index)重构为两层叠加结构。

1. 配置基础层(默认,永远可见)

全局任务配置表,呈现所有任务的 task_name / run_type / status / concurrency / trigger_node_id 等字段。不依赖节点选中,无节点在线时也能管理全局配置、改并发。

2. 节点叠加层(选节点后显示)

表格上方有节点选择器(调 hostList 接口拉在线节点)。选中节点后:

  • 多出归属列:基于 run_type + 当前选中节点是否主节点判断:

    run_type 当前节点是主节点 当前节点非主节点
    all 当前自动 当前自动
    main 当前(主)自动 仅主节点
    auto 竞争 竞争
    manual 仅手动 仅手动
    status=0 已停用 已停用
  • 手动触发按钮:定向到选中节点,无节点选中时按钮禁用。触发走 trigger 接口POST body 必带 trigger_node_id

3. concurrency 双通道编辑

  • 行内编辑layui table edit直接改成正整数校验必须 >= 1;想恢复"继承默认"请走 edit 表单传空值
  • edit 表单:默认/自定义切换,提交空值写 NULL继承代码默认提交正整数写覆盖值

后端归一化逻辑(TimerConfig::normalizeConcurrency):空字符串/null 写 NULL正整数写 int0 或负数或非数字直接拒绝。

4. all 模式预估提示

run_type=all 的任务改 concurrency 时UI 提示"全网预估 = 在线节点数 × 此值",防每节点并发乘节点数爆量。

多节点约束

动态并发是多节点环境下的能力,有几个硬约束必须记住。

1. 并发语义:每节点并发

concurrency 管的是每个节点的并发实例数,不是全网总并发。run_type哪些节点跑concurrency每节点跑几个实例,两者正交。

  • run_type=all + concurrency=3 + 5 个在线节点 = 全网 15 个并发实例(每节点 3 个)
  • run_type=main + concurrency=3 + 5 个节点1 主 4 从) = 全网 3 个并发实例(只主节点跑 3 个)

2. 脏标记跨节点传播依赖共享 cache

timer_config_version 脏标记存在 cache 里。多节点要实时感知彼此的配置变更,必须用共享 cacheRedis。文件 cache 下节点间互不可见,后台改并发只能靠每 15 秒的兜底 reload 追上,最坏有 15 秒延迟。

3. drain 不跨进程重启

drain 状态(state=drainingdrain_started_at)是纯内存的。进程重启后以 DB concurrency 为准全重建 0..N-1 完整分片,放弃 drain 连续性。重启前正在 draining 的实例直接变 active。

4. concurrency_id 稳定性仅单进程生命周期

concurrency_id 的"缩容 drain 高 id / 扩容 max+1 续编"规则只在单进程生命周期内保证。进程重启后 id 重置为 0..N-1分片编号会变。如果你的业务逻辑强依赖固定分片号比如按 concurrency_id 哈希分桶),要意识到重启会重新分配。

5. 仅覆盖 site 类型

动态并发、热加载、drain 这些能力只覆盖 site 类型任务call 类型任务始终在所有节点执行,不受 concurrency 热加载影响(技能文档本来也不推荐 call优先用 site

日志清理

php think admin:timer:log:clean --days=30