diff --git a/.agents/skills/ulthon-timer/SKILL.md b/.agents/skills/ulthon-timer/SKILL.md index 9949450..d130214 100644 --- a/.agents/skills/ulthon-timer/SKILL.md +++ b/.agents/skills/ulthon-timer/SKILL.md @@ -36,15 +36,17 @@ description: "内置秒级定时器(php think timer)的使用与扩展规范 长时间运行任务的推荐写法(仍用 `site`): - 设计为"可重入"的短任务:每次 `do()` 只处理一小批数据,处理进度写入缓存/表,下次继续 -- 结合 `concurrency` 做分片:按 `$this->concurrencyId` 划分数据范围,多个实例并行推进 +- 结合 `concurrency` 提高吞吐:多个实例并行消费。**注意:`concurrencyId` 只是消费者编号,不要用它预划分数据**(如 `id % N` 取余分片会导致索引失效);数据消费的标准模式(原子抢占/游标/Redis Stream 队列)见技能 [ulthon-queue](../ulthon-queue/SKILL.md) - 结合 `frequency` 控制节奏:用调度频率限制整体吞吐,避免单次占用过久 -#### 按时间窗口循环处理(队列消费推荐) +#### 按时间窗口循环处理(数据消费推荐) -适用于:需要持续消费队列/轮询数据的场景(如消息处理、订单状态同步、通知推送等)。 +适用于:需要持续消费数据表/轮询数据的场景(如消息处理、订单状态同步、通知推送等)。 核心思路:在 `do()` 方法内设置一个**最大执行时间窗口**,循环处理单条数据,超时后自动退出,由定时器下次调度继续。 +数据领取**必须用原子抢占**(读和标记压进一条条件 UPDATE),否则 `concurrency > 1` 时多个实例会读到同一条数据、重复处理。完整的消费模式设计(表字段标准、索引、超时回收、幂等、批量抢占)见技能 [ulthon-queue](../ulthon-queue/SKILL.md),此处是最小骨架: + ```php class MyQueueTask extends TimerController { @@ -71,19 +73,29 @@ class MyQueueTask extends TimerController protected function doItem() { - // 取一条待处理的数据 - $item = SomeModel::where('status', 0)->order('id', 'asc')->find(); + // 取一条候选(需要 (status, id) 复合索引) + $id = SomeModel::where('status', 0)->order('id')->value('id'); - if (empty($item)) { + if (empty($id)) { // 队列为空时短暂休眠,避免空转消耗 CPU sleep(1); return; } - // ... 处理单条数据的业务逻辑 ... + // 原子抢占:WHERE 带 status=0,被其他消费者抢先则 affected=0,跳过 + // (读和标记必须在一条原子语句里完成,find 后直接 save 在并发下会重复处理) + $affected = SomeModel::where('id', $id) + ->where('status', 0) + ->update(['status' => 1, 'claim_time' => time()]); - $item->status = 1; - $item->save(); + if ($affected === 0) { + return; + } + + // ... 处理单条数据的业务逻辑(此时不持任何锁)... + + // 幂等回写完成态:WHERE status=1 兜底重复消费 + SomeModel::where('id', $id)->where('status', 1)->update(['status' => 2]); } } ``` @@ -117,9 +129,10 @@ class MyQueueTask extends TimerController 并发与防刷建议: -- 如需并发分片处理,在控制器中设置: - - `protected $concurrency = N;` - - 使用 `$this->concurrencyId` 做分片编号(0 ~ N-1) +- 如需多实例并行消费,在控制器中设置: + - `protected $concurrency = N;`(N = 最大消费者数,后台可在 cap 内动态调整) + - `$this->concurrencyId` 是消费者编号(0 ~ N-1),仅用于日志排查与 worker 标识 + - **不要按 `concurrencyId` 预划分数据**(如 `id % N = concurrencyId`,索引失效全表扫描),数据竞争用原子抢占消解,完整模式见 [ulthon-queue](../ulthon-queue/SKILL.md) - 如希望防止外部重复请求(不仅是定时器自身节流),在控制器中设置: - `protected $frequency = 秒数;` - 这会启用 `TimerControllerBase::protectVisit()` 基于 URL 的防刷限制 @@ -548,7 +561,7 @@ drain 状态(`state=draining`、`drain_started_at`)是纯内存的。进程 #### 4. concurrency_id 稳定性仅单进程生命周期 -`concurrency_id` 的"缩容 drain 高 id / 扩容 max+1 续编"规则只在单进程生命周期内保证。进程重启后 id 重置为 0..N-1,分片编号会变。如果你的业务逻辑强依赖固定分片号(比如按 `concurrency_id` 哈希分桶),要意识到重启会重新分配。 +`concurrency_id` 的"缩容 drain 高 id / 扩容 max+1 续编"规则只在单进程生命周期内保证。进程重启后 id 重置为 0..N-1,分片编号会变。如果你的业务逻辑强依赖固定分片号(比如按 `concurrency_id` 哈希分桶),要意识到重启会重新分配——且**按分片号哈希分桶本身就是反模式**(`id % N` 导致索引失效),数据消费不应依赖分片号划分数据,正确做法见 [ulthon-queue](../ulthon-queue/SKILL.md)。 #### 5. 仅覆盖 site 类型