Files

271 lines
12 KiB
Markdown
Raw Permalink 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内置秒级定时器
## 核心机制(你要记住的 3 件事)
1) 任务配置统一从 `app_file_path('common/command/timer/config.php')` 读取
2) 每个配置会按 `concurrency` 自动展开成多份任务实例,并自动注入 `concurrency_id` / `concurrency_count`
3) “是否该执行”主要由定时器侧的 Cache 节流控制(可选叠加控制器侧的防刷保护)
相关实现入口:
- 系统入口(优先从 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 未提供时回落到该文件)
字段说明(兜底默认值由 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 超时(秒) |
| `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` 会直接打到线上
## 多节点协调
定时器支持多节点部署以数据库MySQL作为协调中心。多个节点连接同一个数据库即可自动组成集群。
### 节点注册
每个节点启动后通过 `system_host_register` call 任务自动注册心跳(每 30 秒一次)。节点 ID 生成规则为 `{hostname}-{8位md5}`,持久化在 `runtime/node_id.lock` 文件中,重启后保持不变。
### 主节点选举
- 第一个注册的节点自动成为主节点
- 管理员可在主机列表页面手动切换主节点
- 相关 API`HostService::setMasterNode()` / `HostService::getMasterNode()`
### run_type 调度
`run_type` 仅对 `site` 类型任务生效,`call` 类型任务始终在所有节点执行。可选值:
| run_type | 行为 | 适用场景 |
|----------|------|----------|
| `auto`(默认) | 两阶段 DB 行锁竞争BEGIN / SELECT FOR UPDATE / UPDATE / COMMIT 抢占,抢占成功后释放锁再执行。每个频率窗口内只有一个节点执行 | 通用场景 |
| `main` | 仅主节点执行 | 需要集中处理的任务 |
| `all` | 所有节点各自独立执行 | 节点本地清理等 |
| `manual` | 仅当 DB 中 `manual_trigger=1` 时执行,执行后自动重置为 0。通过管理后台触发 | 运维手动触发 |
### 配置同步
`TimerService::syncConfigToDatabase()` 在定时器启动时运行,将 PHP 配置文件中的任务同步到 `system_timer_config` 表。同步时不会覆盖数据库中已管理的字段(`run_type``status`),以管理后台的设置为准。
### 执行日志
`TimerControllerBase::execute()` 自动包裹 `do()` 并调用 `logStart()` / `logEnd()` 记录执行日志,开发者无需手动调用。配置中写 `/do`,运行时 `TimerServiceBase` 自动重写为 `/execute` 以触发日志包裹。`host_id` 会自动注入到 site 任务的 URL 参数中,用于标识执行节点。
### 管理后台
| 页面 | 路径 | 功能 |
|------|------|------|
| 定时器配置 | `/admin/system.timer_config/index` | 管理 run_type、status、手动触发 |
| 定时器日志 | `/admin/system.timer_log/index` | 只读执行日志查看 |
| 主机列表 | `/admin/system.host/index` | 节点状态、主节点管理 |
### 日志清理
```bash
php think admin:timer:log:clean --days=30
```
### 相关数据表
- `ul_system_timer_config`任务协调配置run_type、status、manual_trigger 等)
- `ul_system_timer_log`:执行日志记录
- `ul_system_host`:新增 `is_master` 字段,标识主节点