mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 12:45:32 +08:00
新增 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>
560 lines
29 KiB
Markdown
560 lines
29 KiB
Markdown
---
|
||
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`(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
|
||
```
|