直接SELECT *在千万级表中会卡死或超时,因需全表扫描、构建结果集、网络传输和客户端内存加载,任一环节均可能崩溃;应避免OFFSET分页,改用主键/时间戳游标分页,并流式处理结果集。

为什么直接 SELECT * 会卡死或超时
当表有千万级以上行、单次查询返回几百万条记录时,数据库要完成全表扫描、构建结果集、网络传输、客户端内存加载四步。中间任意一环都可能崩:MySQL 默认 max_allowed_packet 限制结果大小,PostgreSQL 的 work_mem 不足会写磁盘,Java 应用端 ResultSet 未流式消费就 next() 全读,极易 OOM。
- 不要用
OFFSET分页(LIMIT 1000000, 1000)——偏移越大,性能越差,本质仍是全扫前 N 行 - 避免在高并发场景下让多个请求同时执行相同的大查询
- 确认业务是否真的需要“全部数据”:导出报表?同步到数仓?还是只是前端分页展示?不同目的对应不同解法
用主键/时间戳做游标分页(推荐)
核心是把“第 N 页”转换成“大于上一批最后一条的 id 或 created_at”,跳过排序和跳过成本。前提是字段有索引、单调递增(或至少不重复)、能覆盖全部数据。
SELECT * FROM orders WHERE id > 12345678 ORDER BY id ASC LIMIT 1000;
- 必须
ORDER BY索引字段,且WHERE条件与之匹配,否则索引失效 - 如果用时间戳,注意
created_at可能重复,需加二级排序(如ORDER BY created_at, id)并确保WHERE同时覆盖两者 - 首次查询需先取最小值:
SELECT MIN(id) FROM orders,后续每次用上批末尾id继续
客户端如何安全流式处理结果集
不是把所有数据 load 到内存再循环,而是边 fetch 边处理。不同语言驱动行为差异大,关键看是否支持流式游标(cursor)和自动 fetch size 控制。
- Java JDBC:设
statement.setFetchSize(Integer.MIN_VALUE)(MySQL)或setFetchSize(1000)(PostgreSQL),并禁用ResultSet.CONCUR_UPDATABLE - Python psycopg2:用
cursor.name声明命名游标,配合fetchmany(1000);避免cursor.fetchall() - Node.js pg:启用
stream模式,监听data事件,不等全部返回就处理 - 切记关闭自动提交(
autocommit=False),否则每批 commit 会拖慢速度甚至锁表
临时表 or 物化视图预计算(适合固定维度聚合)
如果每次都是同样条件查同样字段(比如“近 30 天各城市订单量”),硬扛原始表扫描毫无必要。应提前聚合、去重、裁剪列,把耗时操作从查询时移到写入时或定时任务中。
- MySQL:建
CREATE TEMPORARY TABLE tmp_daily_stats AS SELECT city, COUNT(*) c FROM orders WHERE dt >= '2024-01-01' GROUP BY city,再查这个临时表 - PostgreSQL:用
CREATE MATERIALIZED VIEW mv_orders_summary AS ... REFRESH EVERY 1 HOUR - 注意物化视图刷新期间可能不可读(PG 9.4+ 支持并发刷新,但需显式指定
CONCURRENTLY)
游标分页的边界容易漏掉新插入的数据(比如分页过程中有新 id 插入到当前批次范围内),如果业务强一致要求,得加额外版本号或时间窗口校验。这一步多数人会忽略,直到线上对不上数才回头查。











