From 1b0be9a29baad8f7019cdc23bdc7108b63382f3d Mon Sep 17 00:00:00 2001 From: augushong Date: Sat, 25 Jul 2026 21:58:48 +0800 Subject: [PATCH] =?UTF-8?q?docs(timer):=20=E5=AE=9A=E6=97=B6=E5=99=A8?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E7=AE=A1=E6=8E=A7=E6=8A=80=E8=83=BD=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E8=A1=A5=E5=85=85=E5=9B=9B=E7=AB=A0=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增并发数动态调整、手动触发定向、多节点协调、clamp 有效域钳制四章节,含 12 个示例对照与 4 个坑位说明。 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .agents/skills/ulthon-timer/SKILL.md | 288 ++++++++++++++++++++++++++- 1 file changed, 280 insertions(+), 8 deletions(-) diff --git a/.agents/skills/ulthon-timer/SKILL.md b/.agents/skills/ulthon-timer/SKILL.md index ce7c834..a864090 100644 --- a/.agents/skills/ulthon-timer/SKILL.md +++ b/.agents/skills/ulthon-timer/SKILL.md @@ -5,11 +5,12 @@ description: "内置秒级定时器(php think timer)的使用与扩展规范 # timer(内置秒级定时器) -## 核心机制(你要记住的 3 件事) +## 核心机制(你要记住的 4 件事) -1) 任务配置统一从 `app_file_path('common/command/timer/config.php')` 读取 -2) 每个配置会按 `concurrency` 自动展开成多份任务实例,并自动注入 `concurrency_id` / `concurrency_count` -3) “是否该执行”主要由定时器侧的 Cache 节流控制(可选叠加控制器侧的防刷保护) +1) 任务配置统一从 `app_file_path('common/command/timer/config.php')` 读取 +2) 每个配置会按 `concurrency` 自动展开成多份任务实例,并自动注入 `concurrency_id` / `concurrency_count`;`concurrency` 支持后台动态覆盖(DB 字段为 NULL 时继承代码默认,详见「动态控制 > 动态并发机制」) +3) "是否该执行"主要由定时器侧的 Cache 节流控制(可选叠加控制器侧的防刷保护) +4) 进程运行期间支持热加载:后台改并发、手动触发某节点跑一次,都无需重启 `php think timer`(详见「动态控制」章节) 相关实现入口: @@ -209,6 +210,9 @@ site 任务会按站点域名发起请求,默认从 `sysconfig('site','site_do | `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` 覆盖 | ## 常见坑位(快速自检) @@ -217,6 +221,10 @@ site 任务会按站点域名发起请求,默认从 `sysconfig('site','site_do - `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/` 即可 ## 多节点协调 @@ -239,7 +247,7 @@ site 任务会按站点域名发起请求,默认从 `sysconfig('site','site_do | `auto`(默认) | 两阶段 DB 行锁竞争:BEGIN / SELECT FOR UPDATE / UPDATE / COMMIT 抢占,抢占成功后释放锁再执行。每个频率窗口内只有一个节点执行 | 通用场景 | | `main` | 仅主节点执行 | 需要集中处理的任务 | | `all` | 所有节点各自独立执行 | 节点本地清理等 | -| `manual` | 仅当 DB 中 `manual_trigger=1` 时执行,执行后自动重置为 0。通过管理后台触发 | 运维手动触发 | +| `manual` | 该类型的任务**永不自动执行**,执行全靠后台手动触发(`manual_trigger=1` 时被消费)。注意:手动触发现在跨 `run_type` 通用,任何 `run_type` 的任务都能在后台手动触发一次,`manual` 类型只是"只接受手动触发"的极端形态(详见「动态控制 > 手动触发升级」) | 仅手动触发的任务 | ### 节点注册与主节点管理 @@ -249,12 +257,13 @@ site 任务会按站点域名发起请求,默认从 `sysconfig('site','site_do ### 自动机制(开发者无需手动调用) -- **配置同步**:定时器启动时 `TimerService::syncConfigToDatabase()` 将 PHP 配置同步到 `system_timer_config` 表,不覆盖 `run_type`、`status` 等管理字段(以 UI 设置为准) +- **配置同步**:定时器启动时 `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 等) +- `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` 字段标识主节点) @@ -262,10 +271,273 @@ site 任务会按站点域名发起请求,默认从 `sysconfig('site','site_do | 页面 | 路径 | 功能 | |------|------|------| -| 定时器配置 | `/admin/system.timer_config/index` | 管理 run_type、status、手动触发 | +| 定时器配置 | `/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