thinkphp 8.0 处理千万级数据必须用 cursor() 实现低内存遍历,要求版本≥8.0.0、主键或索引字段单调递增且非空;需升序排序、禁用 join/with/子查询,分页时通过 next_cursor 手动推进并建议开启签名保护。

ThinkPHP 8.0 处理千万级数据表时,直接用 select() 或 paginate() 会瞬间吃光 PHP 内存,进程被系统 kill;必须用 cursor() 实现低内存逐行遍历,但错一步就漏数据、卡死或报 Cursor value is invalid。
确认环境与前提条件
确保你用的是 ThinkPHP 8.0.0 及以上版本(低于 8.0 的 TP 根本没有 cursor() 方法);数据库表必须有【单调递增且非空的索引字段】,比如自增 id 或带索引的 create_time;该字段不能含 NULL,否则游标起点不可靠。
执行 php think version 验证版本,输出中必须包含 v8.x;检查表结构:DESC your_table,确认 id 字段类型为 BIGINT / INT、Extra 含 auto_increment、Key 含 PRI 或 MUL 索引。
若字段含 NULL 或无索引,cursor() 会静默失效——查着查着就跳段、重复或中断,且不报错。
基础游标遍历(单次全量流式读取)
第一步:调用 cursor(),指定每批拉取条数、游标字段名、起始游标值(首次传 null):
$cursor = Db::name('user_log')->where('status', 1)->order('id', 'asc')->cursor(500, 'id');
这一步必须用 【升序 order('id', 'asc')】,降序不支持;where 条件可加,但不能干扰 id 的单调性(例如禁止 whereRaw('id % 2 = 0'))。
第二步:用 foreach 直接遍历,每轮只加载一行到内存:
foreach ($cursor as $row) { processRow($row); }
注意:$cursor 是 Generator 对象,不能 toArray()、count() 或用 array_ 函数处理,否则整张表数据会强行载入内存。
分页式游标推进(适合后台任务/导出)
方法一:手动提取 next_cursor 并循环请求
首次查询后,结果集末尾自动附带 next_cursor 字段:
$first = Db::name('user_log')->where('status', 1)->order('id', 'asc')->cursor(500, 'id');
遍历完这批后,从最后一条记录里取出 next_cursor 值(不是 last()['id']!):
$nextCursor = $first->next_cursor ?? null;
下一轮请求带上它:Db::name('user_log')->where('status', 1)->order('id', 'asc')->cursor(500, 'id', $nextCursor);
方法二:开启签名保护(生产环境必开)
在 app.php 配置中添加:'cursor_key' => 'your_strong_secret_here';调用时第四个参数传 true:
Db::name('user_log')->order('id', 'asc')->cursor(500, 'id', null, true);
这样生成的 next_cursor 是 HMAC 签名 Base64 字符串,用户篡改会直接报 Cursor value is invalid 错误。
规避常见崩盘陷阱
不要在 cursor 查询中使用 join、with 或子查询——TP 8.0 的游标逻辑只认主表排序字段,关联后结果顺序无法保证单调,游标值会错乱。
避免在 foreach 循环内修改当前行的 id 值(如 $row['id'] = 999),会导致下一轮游标计算偏移,轻则漏数据,重则无限循环。
如果业务需要按 created_at 降序处理(如查最新日志),不能直接用 cursor();改用 where + order + limit 手动游标分页:->where('create_time', 'order('create_time', 'desc'),并确保 create_time 有联合索引(如 INDEX idx_status_ct (status, create_time))。











