docs(timer): 定时器动态管控技能文档补充四章节

新增并发数动态调整、手动触发定向、多节点协调、clamp 有效域钳制四章节,含 12 个示例对照与 4 个坑位说明。

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
augushong
2026-07-25 21:58:48 +08:00
parent 12fe271ce0
commit 1b0be9a29b

View File

@@ -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_typestatusmanual_trigger
- `ul_system_timer_config`:任务协调配置。核心字段:`run_type``status``manual_trigger``concurrency`nullableNULL=继承代码默认,正整数=覆盖)、`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.concurrencynullable
↓ 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 秒内也能追上
> 多节点前提:脏标记跨节点传播依赖共享 cacheRedis。文件 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 每轮重新 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_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正整数写 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=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