- 6 张表加 node_id 字段 + position/stat 改复合唯一键(migration) - 6 个 Scheme 同步 node_id 注解 + 唯一键 - Reader 带 node_id 参数 + 懒加载接管(getLastPosition 回退查 node_id='') - Aggregator 全局行(node_id='')+ 按节点循环 insertAggregatesForScope - Stat 所有查询方法加 where node_id 条件 - Dashboard 控制器/视图加节点筛选下拉框 + AJAX 带 node_id - AccessLog 列表加节点列 + 采集进度卡片显示节点信息 - import 定时任务 run_type 改 all(每节点采集自己的日志) - v2.4.0 升级脚本追加 ALTER TABLE(幂等 check-then-alter) - 菜单调整:去掉 Nginx 顶级菜单 + 读取位置管理,改为系统管理下挂两个子菜单 - ulthon-timer 文档补充 run_type=all 多节点部署注意事项
30 KiB
name, description
| name | description |
|---|---|
| ulthon-timer | 内置秒级定时器(php think timer)的使用与扩展规范;用于新增/调整定时任务(site/call、并发分片、TimerController 防刷)。 |
timer(内置秒级定时器)
核心机制(你要记住的 4 件事)
- 任务配置统一从
app_file_path('common/command/timer/config.php')读取 - 每个配置会按
concurrency自动展开成多份任务实例,并自动注入concurrency_id/concurrency_count;concurrency支持后台动态覆盖(DB 字段为 NULL 时继承代码默认,详见「动态控制 > 动态并发机制」) - "是否该执行"主要由定时器侧的 Cache 节流控制(可选叠加控制器侧的防刷保护)
- 进程运行期间支持热加载:后台改并发、手动触发某节点跑一次,都无需重启
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/config.php 注册任务 + 以 site 或 call 的方式实现目标逻辑。
1) 选择任务类型
site:通过 HTTP 访问一个控制器地址(默认优先使用)。即使任务需要“长时间运行”,也尽量设计成site模式(分片/分批/可重入),以复用框架的页面/接口同体、鉴权、日志、事务、限流等能力。call:直接执行一个 PHP callable(不推荐;除非万不得已)。仅在确实不适合走 HTTP 上下文、且不希望暴露为控制器入口时使用。
长时间运行任务的推荐写法(仍用 site):
- 设计为"可重入"的短任务:每次
do()只处理一小批数据,处理进度写入缓存/表,下次继续 - 结合
concurrency做分片:按$this->concurrencyId划分数据范围,多个实例并行推进 - 结合
frequency控制节奏:用调度频率限制整体吞吐,避免单次占用过久
按时间窗口循环处理(队列消费推荐)
适用于:需要持续消费队列/轮询数据的场景(如消息处理、订单状态同步、通知推送等)。
核心思路:在 do() 方法内设置一个最大执行时间窗口,循环处理单条数据,超时后自动退出,由定时器下次调度继续。
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、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共三类)。完整说明与不支持覆盖的反例清单详见规则:文件级覆盖机制。
字段说明(兜底默认值由 Base 层的 initConfigItem() 提供):
name:任务唯一名称(用于 Cache key),不可重复type:site或calltarget:site:以/开头的相对路径(建议指向tools/timer.*控制器的do方法)call:callable
frequency:执行频率(秒),小于 0 会被修正为 0concurrency:并发数量(默认 1)。site类型会自动把并发参数写入 queryrun_type:调度策略(仅对site类型生效,call类型不受影响)。可选值见「多节点协调 > run_type 调度」
示例(site):
return [
[
'name' => 'clear_log',
'type' => 'site',
'target' => '/tools/timer.ClearLog/do',
'frequency' => 600,
'concurrency' => 1,
],
];
示例(call):
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 模式下会执行:
nohup php /var/www/html/think timer --local --quiet &
即 timer 进程与 web 服务(nginx + php-fpm)在同一容器内并行运行,--quiet 抑制 "no request" 输出。
多节点部署:每个容器各自启动 timer 进程,runtime/node_id.lock 在各自容器内独立生成(天然隔离),连同一个数据库即自动组成多节点集群。
多节点部署注意(run_type=all 的任务):
run_type=all的定时任务(如nginx_log_import)要求每节点各自采集本机数据,每节点必须用php think timer --local启动,确保 site 请求发往本机localhost而非共享site_domain/LB,否则请求会落在随机节点上,导致采集错位(节点 A 的日志被节点 B 处理)。框架内置 Docker 部署栈已默认带--local,手动/裸机部署时务必显式加上。
本地调试(指定请求 Host)
site 任务会按站点域名发起请求,默认从 sysconfig('site','site_domain') 读取。
- 本地调试:
php think timer --local --local-host=http://localhost --local-port=8000
运行模式
配置在 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_registercall 任务每 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_registercall 任务注册心跳(每 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 keytimer_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 成功后递增脏标记:
// app/admin/controller/system/TimerConfig.php 的 trigger() / edit()
$row->save([...]);
Cache::inc('timer_config_version'); // 必须在 commit 成功之后(D18,防脏读)
runLoop 每轮检测这个标记变化:
// 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 遍历时:
// 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 的判定顺序:
- call 类型直接放行
- 手动触发优先检查(穿透 status):读 config,若
manual_trigger=1,原子条件 UPDATE 抢占 status=0拦截自动调度(手动已在步骤 2 穿透,所以已停用的任务也能手动触发一次)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+allrun_type 自动每节点跑),不靠手动触发
4. 原子条件 UPDATE(防多节点竞态)
抢占用原子条件 UPDATE,避免旧实现 find → update 的竞态:
// 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 窗口阻塞:
// 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:
// 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)。
日志清理
php think admin:timer:log:clean --days=30