docs(skill): 修正定时器技能数据消费竞态示例,补充分片索引失效警示

This commit is contained in:
augushong
2026-10-02 01:15:39 +00:00
parent 504d9a38b9
commit 981a6d8baa

View File

@@ -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 类型