Files
ulthon_admin/.agents/skills/ulthon-timer/SKILL.md
augushong f48e0947cb docs(timer): 技能文档补充 Docker 模式自动启动说明
新增 Docker 部署章节:server 模式自动启动 timer(nohup + --local --quiet),多节点天然隔离(runtime/node_id.lock 独立)。

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-26 09:53:45 +08:00

560 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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`
### Docker 模式(自动启动)
使用框架内置的 Docker 部署模式(`docker compose up` / `docker run ... server`)时,定时器会**自动启动**,无需手动运行 `php think timer`
容器启动脚本(`source/stack/<mode>/source/docker/run.sh`)在 server 模式下会执行:
```bash
nohup php /var/www/html/think timer --local --quiet &
```
即 timer 进程与 web 服务nginx + php-fpm在同一容器内并行运行`--quiet` 抑制 "no request" 输出。
多节点部署:每个容器各自启动 timer 进程,`runtime/node_id.lock` 在各自容器内独立生成(天然隔离),连同一个数据库即自动组成多节点集群。
### 本地调试(指定请求 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`nullableNULL=继承代码默认,正整数=覆盖)、`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.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
php think admin:timer:log:clean --days=30
```