--- name: "ulthon-timer" description: "内置秒级定时器(php think timer)的使用与扩展规范;用于新增/调整定时任务(site/call、并发分片、TimerController 防刷)。" --- # timer(内置秒级定时器) ## 核心机制(你要记住的 4 件事) 1) 任务配置统一从 `app_file_path('common/command/timer/config.php')` 读取 2) 每个配置会按 `concurrency` 自动展开成多份任务实例,并自动注入 `concurrency_id` / `concurrency_count`;`concurrency` 支持后台动态覆盖(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.php](../../../app/common/command/Timer.php) / [TimerBase.php](../../../extend/base/common/command/TimerBase.php) - [TimerService.php](../../../app/common/service/TimerService.php) / [TimerServiceBase.php](../../../extend/base/common/service/TimerServiceBase.php) - [TimerController.php](../../../app/common/controller/TimerController.php) / [TimerControllerBase.php](../../../extend/base/common/controller/TimerControllerBase.php) - 运行配置:[timer.php](../../../config/timer.php) ## 新增定时任务(默认规则) 无特殊情况下,新增定时任务应当通过本机制实现:在 `timer/config.php` 注册任务 + 以 `site` 或 `call` 的方式实现目标逻辑。 ### 1) 选择任务类型 - `site`:通过 HTTP 访问一个控制器地址(默认优先使用)。即使任务需要“长时间运行”,也尽量设计成 `site` 模式(分片/分批/可重入),以复用框架的页面/接口同体、鉴权、日志、事务、限流等能力。 - `call`:直接执行一个 PHP callable(不推荐;除非万不得已)。仅在确实不适合走 HTTP 上下文、且不希望暴露为控制器入口时使用。 长时间运行任务的推荐写法(仍用 `site`): - 设计为"可重入"的短任务:每次 `do()` 只处理一小批数据,处理进度写入缓存/表,下次继续 - 结合 `concurrency` 做分片:按 `$this->concurrencyId` 划分数据范围,多个实例并行推进 - 结合 `frequency` 控制节奏:用调度频率限制整体吞吐,避免单次占用过久 #### 按时间窗口循环处理(队列消费推荐) 适用于:需要持续消费队列/轮询数据的场景(如消息处理、订单状态同步、通知推送等)。 核心思路:在 `do()` 方法内设置一个**最大执行时间窗口**,循环处理单条数据,超时后自动退出,由定时器下次调度继续。 ```php 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.php](../../../app/tools/controller/timer/ClearLog.php)、[ClearLogBase.php](../../../extend/base/tools/controller/timer/ClearLogBase.php) 并发与防刷建议: - 如需并发分片处理,在控制器中设置: - `protected $concurrency = N;` - 使用 `$this->concurrencyId` 做分片编号(0 ~ N-1) - 如希望防止外部重复请求(不仅是定时器自身节流),在控制器中设置: - `protected $frequency = 秒数;` - 这会启用 `TimerControllerBase::protectVisit()` 基于 URL 的防刷限制 #### B. call 类型(函数任务) 目标形态: - `target` 为 `call_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` 共三类)。完整说明与不支持覆盖的反例清单详见规则:[文件级覆盖机制](../../rules/ulthon-file-override-mechanism.md)。 字段说明(兜底默认值由 Base 层的 `initConfigItem()` 提供): - `name`:任务唯一名称(用于 Cache key),不可重复 - `type`:`site` 或 `call` - `target`: - `site`:以 `/` 开头的相对路径(建议指向 `tools/timer.*` 控制器的 `do` 方法) - `call`:callable - `frequency`:执行频率(秒),小于 0 会被修正为 0 - `concurrency`:并发数量(默认 1)。`site` 类型会自动把并发参数写入 query - `run_type`:调度策略(仅对 `site` 类型生效,`call` 类型不受影响)。可选值见「多节点协调 > run_type 调度」 示例(site): ```php return [ [ 'name' => 'clear_log', 'type' => 'site', 'target' => '/tools/timer.ClearLog/do', 'frequency' => 600, 'concurrency' => 1, ], ]; ``` 示例(call): ```php 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](../../../config/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 丢失导致脏标记漏检;`.env` 用 `TIMER_FORCE_RELOAD_INTERVAL` 覆盖 | | `trigger_ttl` | `300` | 手动触发 TTL(秒),超时未消费自动复位 `manual_trigger`;`.env` 用 `TIMER_TRIGGER_TTL` 覆盖 | | `drain_max_lifetime` | `86400` | drain 实例最长存活(秒),超时强制移除防永不 drain;`.env` 用 `TIMER_DRAIN_MAX_LIFETIME` 覆盖 | | `clear_log_days` | `3` | ClearLog 任务清理 debug_log 表的保留天数,支持从 `.env` 的 `TIMER_CLEAR_LOG_DAYS` 覆盖 | ## 常见坑位(快速自检) - `name` 重复:会导致 Cache key 冲突,表现为任务"莫名其妙不跑/跑得不对" - `concurrency` 与控制器侧 `$concurrency` 不一致:会触发 `concurrency id/count error` - 只依赖控制器侧 `$frequency`:它只是防刷,不是调度;调度频率以定时器侧 Cache 节流为准 - 本地开发忘记 `--local`:`site` 任务默认请求 `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 exists`。`php think clear --type=cache` 或手动删 `runtime/cache/` 即可 ## 多节点协调 定时器支持多节点部署,以数据库为协调中心。多个节点连接同一个数据库即自动组成集群,无需额外服务发现。 ### 设计决策 - **以数据库为协调中心**:多节点连接同一个数据库即自动组成集群 - **主节点自动选举**:第一个注册的节点自动成为主节点;管理员可在主机列表页面手动切换 - **节点身份持久化**:节点 ID(`{hostname}-{8位md5}`)写入 `runtime/node_id.lock`,重启保持不变 - **节点心跳**:通过 `system_host_register` call 任务每 30 秒自动注册一次 - **配置通过 UI 管理**:`run_type`、`status`、手动触发等均由管理后台维护 ### 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`(重启保持不变) - 主节点切换 API:`HostService::setMasterNode()` / `HostService::getMasterNode()` ### 自动机制(开发者无需手动调用) - **配置同步**:定时器启动时 `TimerService::syncConfigToDatabase()` 将 PHP 配置同步到 `system_timer_config` 表,不覆盖 `run_type`、`status`、`concurrency` 等管理字段(以 UI/DB 为准)。新任务进表时 `concurrency` 留 NULL 表示继承代码默认 - **热加载**:后台改 `concurrency` 或手动触发时,`TimerConfig` 控制器在 DB commit 成功后递增脏标记 cache key `timer_config_version`;`runLoop` 每轮检测该标记变化即触发 reload(按 task 粒度合并 diff,不全量重建)。另有每 15 秒的兜底 reload 防 cache 丢失。详见「动态控制」章节 - **执行日志**:`TimerControllerBase::execute()` 自动包裹 `do()` 并调用 `logStart()` / `logEnd()`。配置中写 `/do`,运行时 `TimerServiceBase` 自动重写为 `/execute` 以触发日志包裹。`host_id` 会自动注入到 site 任务的 URL 参数中 ### 相关数据表 - `ul_system_timer_config`:任务协调配置。核心字段:`run_type`、`status`、`manual_trigger`、`concurrency`(nullable,NULL=继承代码默认,正整数=覆盖)、`trigger_node_id`(手动触发定向节点)、`last_trigger_time`(触发时间戳,配合 TTL 自动复位)、`last_execute_node` / `last_execute_time`(auto 模式竞争用) - `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.php` 和 `app/admin/controller/system/TimerConfig.php`。 ### 动态并发机制(后台可调 + 热加载) #### 1. concurrency 优先级链 `concurrency` 的生效值按下面的链路解析,框架升级改默认值能自动惠及没定制过的任务: ``` 代码基线(timer/config.php 的 concurrency) ↓ 被覆盖 DB system_timer_config.concurrency(nullable) ↓ NULL 表示"继承代码默认" ↓ 正整数表示"覆盖代码默认" 生效值(再经下面的有效域钳制) ``` - DB 字段为 `NULL`:继承代码默认(存量任务、新任务进表时都留 NULL,向后兼容) - DB 字段为正整数:覆盖代码默认 - DB 字段为 `0` 或负数:会被钳制为 1 并告警(防静默吞任务,见常见坑位) #### 2. 热加载机制 后台改 `concurrency` 或触发手动执行时,`TimerConfig` 控制器在 DB commit 成功后递增脏标记: ```php // app/admin/controller/system/TimerConfig.php 的 trigger() / edit() $row->save([...]); Cache::inc('timer_config_version'); // 必须在 commit 成功之后(D18,防脏读) ``` `runLoop` 每轮检测这个标记变化: ```php // 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 秒内也能追上 > 多节点前提:脏标记跨节点传播依赖共享 cache(Redis)。文件 cache 下节点间互不可见,只能靠每 15 秒兜底 reload 兜底。生产多节点务必用 Redis cache。 #### 3. 有效域钳制 reload 时对每个任务的 concurrency 三重钳制: ``` effective = max(1, min(DB 值 ?? 代码值, 控制器 $concurrency cap, 剩余 max_handles 预算)) ``` - 控制器 cap:`TimerControllerBase::$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 key(`timer_request_{name}_{id}`),防下次扩容回原值被旧频率窗口误判跳过 - drain 最长存活上限(`drain_max_lifetime`,默认 86400 秒):draining 实例超过该时长强制移除,防任务卡死导致永不 drain drain 完成的判定与移除发生在 `runLoop` 遍历时: ```php // 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 每轮重新 foreach,reload 修改属性后下一轮可见,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_type`(auto / main / all / manual)的任务都能在后台手动触发一次。`shouldExecuteTask` 的判定顺序: 1. call 类型直接放行 2. **手动触发优先检查**(穿透 status):读 config,若 `manual_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` 的竞态: ```php // 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 窗口阻塞: ```php // 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: ```php // 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;正整数写 int;0 或负数或非数字直接拒绝。 #### 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 里。多节点要实时感知彼此的配置变更,**必须用共享 cache(Redis)**。文件 cache 下节点间互不可见,后台改并发只能靠每 15 秒的兜底 reload 追上,最坏有 15 秒延迟。 #### 3. drain 不跨进程重启 drain 状态(`state=draining`、`drain_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)。 ### 日志清理 ```bash php think admin:timer:log:clean --days=30 ```