1 亿条记录的“生死时速”:别让你的 PHP 服务器在分页请求中猝死
各位听众,大家好。
今天我们不谈什么“Hello World”,也不谈“上帝是 PHP 写的”。今天我们面对的是一个极其硬核、极其残酷、甚至可以说是“血腥”的场景。
想象一下,你手里有一本字典,这本字典不是普通的字典,它是一本全宇宙的化学品大全。这玩意儿厚得能砸死一头大象,里面密密麻麻记录着 1 亿种化合物:什么甲烷、乙烷、丙烷,甚至还有那种名字长得像绕口令一样的有机合成物。每一行数据都包含着 CAS 号(化学文摘社注册号,那是化学品的身份证)、分子量、毒性等级、别名,甚至还有一张全息投影图的 Base64 编码。
现在,你的老板站在你身后,手里拿着一杯冒着热气的拿铁,眼神犀利得像外科医生,指着数据库说:“这个接口响应有点慢,优化一下。用户想看第 1000 页,第 10000 页的数据。”
这时候,如果你脑海里蹦出一句:SELECT * FROM chemicals LIMIT 100000, 20,我建议你最好先找个地缝钻进去,或者直接转身去卖烤红薯。因为这一句话,不仅会让你的数据库 CPU 瞬间飙升到 100%,还会让你的 PHP-FPM 进程池瞬间被僵尸进程填满,最后导致整台服务器陷入死机边缘。
今天,我们就来聊聊,面对 1 亿条记录,如何用 PHP 把这场“生死时速”变成“闲庭信步”。
第一部分:当 LIMIT 遇上 1 亿条数据,它哭了
我们先来做个实验,把大脑里的偏见扔出去。
在数据量少于 1000 条的时候,LIMIT offset, size 就像是坐公交车,你喊一声“我要下车(获取第 N 页)”,司机马上给你开门,顺路把你拉过去。
但在 1 亿条数据面前,LIMIT 变成了把大象装冰箱的三步走——第一步:打开冰箱门;第二步:把大象塞进去;第三步:关上冰箱门。当你问数据库“给我第 1000 万行”的时候,它必须老老实实从第 1 行开始数,数到 1000 万行,然后把前面 999 万 9 千 9 百 9 十 9 行统统扔进碎纸机,最后才把剩下的 20 行给你。
这是什么概念?这叫“全表扫描 + 回表”。数据库的底层引擎会非常不爽,它不仅要处理这 1 亿行的数据,还要进行大量的随机 I/O 操作。如果是 InnoDB 引擎,它还得去读取大量的叶子节点。
在 PHP 中,如果数据库返回了 20 行,而你还要把每行数据里的 Base64 图片解码、把 JSON 字段解析、然后把它们塞进一个复杂的对象里,最后渲染成 HTML。这中间的延迟,足够你喝完一杯咖啡,然后再喝第二杯,老板都已经换了两轮女朋友了。
核心痛点:
- 深度分页性能灾难:页码越深,越慢。
- 网络传输开销:虽然只取 20 行,但 SQL 语句本身的构建和执行计划生成依然很重。
- 内存峰值:PHP 进程如果不小心把结果集全部塞进内存,服务器会 OOM(Out of Memory)。
第二部分:绝地求生——基于游标的分页
既然 OFFSET 走不通,我们就得换条路。这就像你在赶火车,如果你不知道第 1000 站叫什么名字,你就只能问列车员:“我要去第 1000 站,请带我过去。”
但列车员说:“对不起,我必须从第 1 站开到第 1000 站才能带你过去。”
那么,聪明的你会怎么做?你会问列车员:“上一站叫什么名字?如果我不下车,能不能直接告诉我下一站的售票口在哪里?” 这就是基于游标的分页,或者叫 Keyset 分页。
原理:
不要问“给我第 N 页”,而是问“给我所有比上一页最后一条记录大的记录”。
在化学品数据库中,假设我们有一个自增主键 id。当我们看第 1 页时,我们取 id > 0。当我们看第 2 页时,我们取 id > 上一页的最后一条 id。
代码实战:PHP 端的魔法
<?php
// 假设这是你的化学品模型类
class ChemicalModel {
private $db;
public function __construct(PDO $db) {
$this->db = $db;
}
/**
* 获取下一页数据
* @param int $lastId 上一页最后一条记录的 ID
* @param int $pageSize 每页大小
* @return array
*/
public function getNextPage($lastId, $pageSize = 20) {
// 这里的 SQL 非常简单,而且极其高效
// 只要索引在 id 上,这简直就是光速
$sql = "SELECT * FROM chemicals
WHERE id > :last_id
ORDER BY id ASC
LIMIT :page_size";
$stmt = $this->db->prepare($sql);
$stmt->bindValue(':last_id', $lastId, PDO::PARAM_INT);
$stmt->bindValue(':page_size', $pageSize, PDO::PARAM_INT);
$stmt->execute();
// 假设结果被转换成了对象数组
return $stmt->fetchAll(PDO::FETCH_OBJ);
}
/**
* 获取上一页数据
* 这需要反向思维,或者我们在应用层维护 ID 堆栈
* 这里演示一种简单的做法:取比当前页第一条 ID 小的记录
*/
public function getPrevPage($firstIdOfCurrentPage, $pageSize = 20) {
// 为了防止同 ID 冲突(虽然概率极低),我们通常取 <= first_id
// 但为了严格性,通常结合时间戳或复合键,这里简化演示
$sql = "SELECT * FROM chemicals
WHERE id < :first_id
ORDER BY id DESC
LIMIT :page_size";
$stmt = $this->db->prepare($sql);
$stmt->bindValue(':first_id', $firstIdOfCurrentPage, PDO::PARAM_INT);
$stmt->bindValue(':page_size', $pageSize, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_OBJ);
}
}
为什么这招好?
因为在数据库的 B+ 树索引中,id > X 的操作是基于范围的查询。数据库不需要扫描 1 亿行,它只需要从索引树的根部找到 X,然后顺着叶子节点往下走 20 个。这完全是顺序 I/O,速度是指数级提升的。
注意: 这种方法不支持“跳页”(比如直接跳到第 100 页)。用户通常需要通过“下一页”和“上一页”来浏览。这对于化学品这种列表展示来说,完全够用了,谁没事儿翻到第 100 万页去看那个早已灭绝的某种三甲基硅烷呢?
第三部分:化学品的特殊挑战——CAS 号索引
刚才我们用 id 做例子,但化学品的特殊性在哪里?在于排序。
如果你是按 ID 排序,那是很机械的。但用户可能会想:“我要找毒性最低的化学品”。这时候,数据库就得乱成一锅粥了。你需要按照 hazard_level 字段排序。
这里有个巨大的陷阱。
如果你直接用 ORDER BY hazard_level,数据库可能会说:“我需要先查所有行,排序,然后再取 limit 20”。这又回到了性能灾难的泥潭。
解决方案:复合索引
你必须建立一个复合索引。在 MySQL (InnoDB) 中,索引的结构是(字段1, 字段2, …)。
假设表结构如下:
CREATE TABLE chemicals (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
cas_number VARCHAR(20) NOT NULL, -- 化学品的身份证
hazard_level TINYINT, -- 毒性等级:1-5,1最毒
molecular_weight DECIMAL(10,2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY idx_cas_hazard (cas_number, hazard_level)
);
注意看,索引里是 (cas_number, hazard_level)。
这意味着数据库在物理存储上,是先按 CAS 号排序,CAS 号相同再按毒性排序。
查询策略:
如果用户想看“毒性最低”的,SQL 应该是:
SELECT * FROM chemicals
WHERE cas_number = '50-00-0' -- 假设用户在搜特定化学品
ORDER BY hazard_level ASC
LIMIT 20;
这时候,数据库极其聪明。它不需要看全表,它直接定位到 50-00-0 这个 CAS 号在 B+ 树的位置,然后向右扫描,把 hazard_level 小的取出来。速度快得飞起。
Keyset 分页进阶版:
如果是列表页,用户只是单纯想看“所有化学品按毒性排序的前 20 个”,那我们还是得用 Keyset 分页,但这里的 Keyset 是 (hazard_level, id)。
为什么是 (hazard_level, id)?
因为单纯按 hazard_level 排序有重复值(可能有 100 个化学品毒性都是 1)。如果只传 hazard_level=1 给数据库,数据库可能会随机返回其中几个(取决于内部存储机制,不一定是确定的),导致翻页时数据忽上忽下,很混乱。
所以,我们在游标里传递的应该是:WHERE hazard_level = :hazard AND id > :last_id。
代码示例:组合键游标
public function getNextPageByHazard($hazardLevel, $lastId, $pageSize = 20) {
// 这是一个非常经典的“范围查询 + 等值查询”的优化场景
// 只要索引是 (hazard_level, id),这就能瞬间完成
$sql = "SELECT * FROM chemicals
WHERE hazard_level = :hazard_level
AND id > :last_id
ORDER BY hazard_level, id
LIMIT :page_size";
$stmt = $this->db->prepare($sql);
$stmt->bindValue(':hazard_level', $hazardLevel, PDO::PARAM_INT);
$stmt->bindValue(':last_id', $lastId, PDO::PARAM_INT);
$stmt->bindValue(':page_size', $pageSize, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_OBJ);
}
这个查询,无论你的化学品库有多少亿条记录,只要是按索引走,速度都跟有 1 万条记录差不多。这就是索引的威力。
第四部分:当 PHP 自己成为瓶颈——连接池与连接复用
刚才我们在讨论数据库怎么跑得快。但如果 PHP 那边是个“拖延症晚期”的患者呢?
假设我们使用的是标准的 PDO 连接。每次处理一个请求,都要 new PDO(...)?那绝对是在烧钱。连接数据库就像拨号上网,建立连接的过程很慢。
优化策略:持久化连接
在 PHP 中,开启持久化连接是提升性能的最快手段之一(虽然这也伴随着一些争议,但在高并发大流量场景下,它是神器)。
// php.ini 或启动参数
$db_config = [
'dsn' => 'mysql:host=127.0.0.1;dbname=chem_db;port=3306;charset=utf8mb4',
'username' => 'root',
'password' => 'password',
// 关键点在这里:PDO::ATTR_PERSISTENT => true
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ,
PDO::ATTR_EMULATE_PREPARES => false, // 强制使用真实预处理,防止 SQL 注入,并利用服务端缓存
]
];
// PDO::__construct 会尝试复用已有的连接
try {
$pdo = new PDO($db_config['dsn'], $db_config['username'], $db_config['password'], $db_config['options']);
} catch (PDOException $e) {
// 错误处理
}
这意味着,当请求 A 执行完 SQL,关闭了 PHP 脚本的执行环境(脚本结束),连接并没有断开,而是被“挂起”在连接池里。当请求 B 来的时候,PHP 拿起这个连接,直接就能用,省去了 TCP 三次握手和 SSL 握手的时间。
对于分页这种高频操作,这能节省几毫秒到几十毫秒的延迟,累积起来就是巨大的吞吐量提升。
第五部分:终极杀招——Redis 缓存
前面说了,哪怕用了索引,每次请求还是得去数据库“读”数据。读硬盘是昂贵的,读内存是廉价的。
对于化学品这种数据,虽然会更新(比如更新了某个化合物的库存),但大部分时候是读多写少。用户可能只是想看“有机化学类”的前 100 个化合物,这种查询在短时间内可能有 1000 个用户在问。
如果 1000 个用户都去查数据库,数据库会崩。
策略:缓存中间件
我们可以把“当前页面”的数据缓存起来。
架构图(脑补):
用户请求 -> PHP -> Redis (命中) -> 返回 JSON -> 结束
用户请求 -> PHP -> Redis (未命中) -> MySQL (查) -> 放入 Redis -> 返回 JSON -> 结束
实战代码:
class ChemicalService {
private $redis;
private $db;
private $cacheTTL = 3600; // 缓存 1 小时,避免数据太旧
public function __construct() {
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379);
$this->db = new PDO(...);
}
public function getChemicalsPage($hazardLevel, $lastId, $pageSize) {
// 生成一个唯一的缓存 Key
// 格式:page:hazard:1:last_id:999999
$cacheKey = sprintf("chem:page:%d:%d:%d", $hazardLevel, $lastId, $pageSize);
// 1. 尝试从 Redis 获取
$data = $this->redis->get($cacheKey);
if ($data) {
// 这里的 json_decode 比 fetchAll PDO 对象再 serialize 快得多
return json_decode($data, true);
}
// 2. Redis 没有数据,去数据库
$stmt = $this->db->prepare("SELECT * FROM chemicals WHERE hazard_level = :hazard AND id > :last_id ORDER BY hazard_level, id LIMIT :size");
// ... 执行 ...
$results = $stmt->fetchAll(PDO::FETCH_ASSOC); // fetch_assoc 比 fetch_obj 快一点,且少个对象创建开销
// 3. 放入 Redis
// Redis 的序列化通常比 PHP 内置的快
$this->redis->setex($cacheKey, $this->cacheTTL, json_encode($results));
return $results;
}
}
注意:
- 缓存一致性:如果化学品数据更新了,你必须把对应的缓存 Key 删掉或者失效,否则用户会看到旧数据。
- 缓存雪崩:不要给所有 Key 设置相同的过期时间。比如,有的 Key 过期时间是 3600,有的是 3599,或者加上随机数。
第六部分:架构的进化——写入分离与读写分离
如果你的数据库在并发读取时扛不住了,这时候不能只靠 PHP 缓存了,得动用数据库的“分身术”——主从复制。
原理:
- 主库:负责写(插入新化学品,修改毒性等级)。只有主库能写。
- 从库:负责读(分页查询)。多个从库组成读集群。
PHP 端怎么做?
我们需要一个数据库连接池客户端(比如 Yaf 框架的扩展,或者 Swoole 的连接池,甚至是简单的自己实现),把写请求发给主库,读请求发给从库。
// 伪代码展示
class DatabaseRouter {
public function query($sql, $isRead = true) {
if ($isRead) {
$conn = $this->readPool->pop(); // 从读连接池拿一个
} else {
$conn = $this->writePool->pop(); // 从写连接池拿一个
}
// 执行 ...
$this->readPool->push($conn); // 放回去
}
}
这样,1 亿条记录的查询压力就被分散到了多台数据库服务器上。对于 1 亿条数据,哪怕你开 10 个从库,每个从库分 1000 万条,压力也会瞬间减半。
第七部分:当一切都不奏效时——搜索引擎
如果你发现,哪怕用了 Keyset 分页、加了索引、开了缓存、做了读写分离,你的 PHP 服务器依然在瑟瑟发抖。那只有一个原因:SQL 这种结构化查询语言,根本就不适合做这种大范围的全文搜索和复杂排序。
这时候,不要硬刚了。把数据库里的数据,通过程序导出到 Elasticsearch (ES) 或者 OpenSearch 里。
ES 是基于 Lucene 的,它天生就是为海量数据排序和搜索设计的。它的分页机制(从 from 到 size)虽然也有性能问题,但可以通过 search_after(类似 Keyset)来解决,而且速度是 MySQL 的几十倍甚至上百倍。
PHP + ES 架构:
use ElasticsearchClientBuilder;
$client = ClientBuilder::create()->build();
$params = [
'index' => 'chemicals',
'size' => 20,
'sort' => ['hazard_level' => 'asc', 'id' => 'asc'],
'search_after' => $lastId, // ES 的 Keyset
'query' => [
'match' => [
'cas_number' => '50-00-0' // 示例搜索
]
]
];
$response = $client->search($params);
虽然引入 ES 增加了架构的复杂度(需要维护同步数据的一致性),但对于 1 亿级数据,这是性价比最高、最稳定的方案。
第八部分:总结——不要为了优化而优化
好了,讲了这么多,我们来梳理一下面对 1 亿条化学品记录时的“生存法则”。
- 第一反应:如果 SQL 写的是
LIMIT 1000000, 20,赶紧改。 - 必杀技:使用 Keyset 分页 (
WHERE id > last_id)。这是对付大表最有效、最简单的手段。 - 锦上添花:利用 复合索引。把排序字段和游标字段组合在一起,让数据库不走全表扫描。
- 加速引擎:开启 PDO 持久化连接,减少 TCP 握手开销。
- 降低负载:使用 Redis 缓存热点分页数据。
- 终极方案:如果实在扛不住,上 Elasticsearch。
最后,我想送给大家一句话:数据库不是你的朋友,它是你的瓶颈。 不要试图把所有的逻辑都扔给它去处理。好的架构,是懂得“偷懒”,懂得“借力”,懂得在合适的地方用合适的工具。
现在,把你的 LIMIT 代码删了,去拥抱那个光速般的 WHERE id > ? 吧。
谢谢大家!