问题背景

新换的公司,接手了一个遗留项目。当我第一次登录数据库服务器,看到一张表的数据量达到 29GB 时,我顿时就懵了。更让我惊讶的是,这个 29GB 的大表还需要与另一个 1GB 多的表做关联查询,而且这段代码跑在 PHP 上,数据库是 MySQL。

1
2
3
4
A(主表):  29GB  ≈ 5000万条记录
B(关联表): 1GB ≈ 200万条记录
查询方式: INNER JOIN + 全表扫描
执行时间: 经常超过 30 秒,直接超时

面对这种情况,PHP 的 max_execution_time 默认只有 30 秒,MySQL 的连接等待时间也有限制。每次执行这个查询,结果要么是 PHP 报 “Maximum execution time exceeded”,要么是 MySQL 报 “Lock wait timeout exceeded”。

大表查询性能问题

本文将从实战角度,详细记录我处理这个大表查询的 6 个优化策略。这些方案不仅适用于 PHP + MySQL 组合,其中的思路对其他语言和数据库同样有参考价值。


策略一:用 LIMIT 分段处理,避免一次性加载全表

问题分析

最致命的问题不是表大,而是一次性把所有数据都拉到内存中处理。原始代码是这样的:

1
2
3
4
5
6
7
// 错误示范:一次性查询全部数据
$sql = "SELECT * FROM big_table a INNER JOIN small_table b ON a.user_id = b.user_id";
$result = mysqli_query($conn, $sql);
while ($row = mysqli_fetch_assoc($result)) {
// 处理每一条记录...
processRecord($row);
}

这段代码会一次性将 5000 万条关联结果加载到内存,PHP 进程瞬间被撑爆。

解决方案:LIMIT 分批处理

使用 LIMIT offset, count 将大查询拆成多个小批次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?php
/**
* 使用 LIMIT 分批处理大表数据
* @param int $batchSize 每批处理的记录数
*/
function processBigTableInBatches($batchSize = 2000) {
$offset = 0;
$totalProcessed = 0;

while (true) {
$sql = sprintf(
"SELECT a.id, a.user_id, a.data, b.extra_info
FROM big_table a
INNER JOIN small_table b ON a.user_id = b.user_id
LIMIT %d OFFSET %d",
$batchSize,
$offset
);

$result = mysqli_query($conn, $sql);
$count = mysqli_num_rows($result);

if ($count === 0) {
break; // 没有更多数据,退出循环
}

while ($row = mysqli_fetch_assoc($result)) {
processRecord($row);
$totalProcessed++;
}

// 输出进度(CLI 环境下)
echo "已处理 {$totalProcessed} 条记录,offset: {$offset}\n";

$offset += $batchSize;

// 可选:每处理完一批,释放内存
mysqli_free_result($result);
gc_collect_cycles();
}

echo "全部完成,共处理 {$totalProcessed} 条记录\n";
}

processBigTableInBatches(2000);

为什么选择 2000 条/批?

这个数值是经过实验得出的经验值:

批次大小 单批耗时 内存占用 总耗时
500 0.8秒 5MB 过长(批次太多)
2000 1.2秒 12MB 最优
5000 2.5秒 25MB 尚可
10000 5.0秒 50MB 内存压力大
50000 15秒 200MB+ 容易超时

优化版:使用主键游标代替 OFFSET

当 offset 很大时(比如 offset = 40000000),MySQL 需要扫描并跳过前 4000 万条记录,这本身就很慢。更好的方式是使用主键游标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?php
/**
* 使用主键游标进行高效分页(推荐)
*/
function processWithCursor($batchSize = 2000) {
$lastId = 0;
$totalProcessed = 0;

while (true) {
$sql = sprintf(
"SELECT a.id, a.user_id, a.data, b.extra_info
FROM big_table a
INNER JOIN small_table b ON a.user_id = b.user_id
WHERE a.id > %d
ORDER BY a.id ASC
LIMIT %d",
$lastId,
$batchSize
);

$result = mysqli_query($conn, $sql);
$count = mysqli_num_rows($result);

if ($count === 0) {
break;
}

while ($row = mysqli_fetch_assoc($result)) {
processRecord($row);
$lastId = $row['id']; // 更新游标
$totalProcessed++;
}

echo "已处理 {$totalProcessed} 条记录,lastId: {$lastId}\n";
mysqli_free_result($result);
gc_collect_cycles();
}
}

这种方式避免了 OFFSET 的扫描开销,即使处理到第 5000 万条记录,查询速度也不会变慢。

LIMIT分批处理


策略二:正确使用索引,让查询从全表扫描变成索引查找

问题分析

原始代码中,两个表的关联字段没有任何索引,导致 MySQL 必须做嵌套循环的全表扫描:

1
2
3
4
5
6
7
8
9
10
11
12
-- 表结构(优化前)
CREATE TABLE big_table (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT, -- 无索引!
data VARCHAR(255)
);

CREATE TABLE small_table (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT, -- 无索引!
extra_info TEXT
);

解决方案

为关联字段添加索引:

1
2
3
4
5
6
7
8
-- 为 big_table 的 user_id 添加索引
ALTER TABLE big_table ADD INDEX idx_user_id (user_id);

-- 为 small_table 的 user_id 添加索引(最好是唯一索引)
ALTER TABLE small_table ADD UNIQUE INDEX uk_user_id (user_id);

-- 如果 small_table.user_id 有重复值,使用普通索引
ALTER TABLE small_table ADD INDEX idx_user_id (user_id);

索引效果对比

场景 扫描行数 耗时
无任何索引 5000万 × 200万(全表扫描) >30分钟
仅 big_table 有索引 5000万(索引扫描)+ 200万(全表) 约3分钟
两个表都有索引 5000万(索引查找) 约30秒

策略三:用 EXPLAIN 分析查询执行计划

EXPLAIN 的核心价值

EXPLAIN 是 MySQL 提供的查询分析工具,它能告诉你 MySQL 是如何执行你的 SQL 的。这是所有优化的起点。

1
2
3
4
5
EXPLAIN
SELECT a.id, a.user_id, a.data, b.extra_info
FROM big_table a
INNER JOIN small_table b ON a.user_id = b.user_id
LIMIT 2000;

输出解读

执行后的典型输出:

1
2
3
4
5
6
+----+-------------+-------+------+---------------+-------------+---------+-----------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+-------------+---------+-----------+------+-------------+
| 1 | SIMPLE | b | ALL | idx_user_id | NULL | NULL | NULL | 200万 | Using where |
| 1 | SIMPLE | a | ref | idx_user_id | idx_user_id | 5 | b.user_id | 25 | NULL |
+----+-------------+-------+------+---------------+-------------+---------+-----------+------+-------------+

关键字段说明:

字段 含义 理想值
type 连接类型 至少是 ref,最好 const
possible_keys 可能使用的索引 应列出相关索引
key 实际使用的索引 不应为 NULL
rows 预估扫描行数 越小越好

type 字段从好到差的排序:

1
system > const > eq_ref > ref > range > index > ALL(全表扫描,最差)

EXPLAIN分析


策略四:使用 Redis 缓存热点数据

对于不经常变化但频繁查询的数据,引入缓存可以大幅减少数据库压力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php
/**
* 使用 Redis 缓存关联查询结果
*/
function getWithCache($userId) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$cacheKey = "user_data:{$userId}";

// 先从缓存读取
$cached = $redis->get($cacheKey);
if ($cached !== false) {
return json_decode($cached, true);
}

// 缓存未命中,查询数据库
$sql = "SELECT a.id, a.data, b.extra_info
FROM big_table a
INNER JOIN small_table b ON a.user_id = b.user_id
WHERE a.user_id = {$userId}";

$result = mysqli_query($conn, $sql);
$data = mysqli_fetch_all($result, MYSQLI_ASSOC);

// 写入缓存,设置 TTL 为 1 小时
$redis->setex($cacheKey, 3600, json_encode($data));

return $data;
}

缓存命中率统计

在引入缓存后,对于相同查询反复执行的场景:

  • 缓存命中率:可达 85%-95%
  • 数据库查询量:减少 90% 以上
  • 响应时间:从 2-3 秒降至 5-10ms

策略五:表分区(Partitioning)

当单表数据量过大时,可以考虑使用 MySQL 的分区功能。

1
2
3
4
5
6
7
8
-- 按时间范围分区(假设表有 create_time 字段)
ALTER TABLE big_table
PARTITION BY RANGE (YEAR(create_time)) (
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026),
PARTITION p_future VALUES LESS THAN MAXVALUE
);

分区后,查询如果带有时间条件,MySQL 会只扫描相关分区而不是全表:

1
2
3
-- 只扫描 p2024 分区,速度大幅提升
SELECT * FROM big_table
WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01';

策略六:异步处理 + 消息队列

对于不需要实时返回结果的批量处理任务,可以将其改为异步执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?php
/**
* 将大批量处理任务放入队列异步执行
*/
function enqueueBatchJob($taskId) {
// 将任务信息写入 Redis 队列
$redis = new Redis();
$redis->lpush('batch_jobs', json_encode([
'task_id' => $taskId,
'created_at' => time(),
'status' => 'pending'
]));

return $taskId;
}

// 后台 Worker 脚本(通过 CLI 运行,无超时限制)
// worker.php
while (true) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$job = $redis->rpop('batch_jobs');

if ($job) {
$task = json_decode($job, true);
echo "开始处理任务: {$task['task_id']}\n";

try {
processBigTableInBatches(2000);
echo "任务完成: {$task['task_id']}\n";
} catch (Exception $e) {
echo "任务失败: {$e->getMessage()}\n";
}
}

sleep(1); // 避免空转
}

启动 Worker:

1
2
3
4
5
6
7
8
9
10
11
# 后台运行 Worker
nohup php worker.php > /var/log/batch_worker.log 2>&1 &

# 或使用 Supervisor 管理进程
# /etc/supervisor/conf.d/worker.conf
[program:batch-worker]
command=php /var/www/worker.php
autostart=true
autorestart=true
stderr_logfile=/var/log/worker.err.log
stdout_logfile=/var/log/worker.out.log

踩坑经验

踩坑一:INNER JOIN vs LEFT JOIN 的误区

我在原笔记中提到”建议用 LEFT JOIN 代替 INNER JOIN”,这里需要纠正:这不是绝对的

  • INNER JOIN 只返回两个表都有匹配的行,如果关联字段有索引且选择性好,INNER JOIN 其实可以很快
  • LEFT JOIN 返回左表所有行,即使右表没有匹配,在某些场景下反而会扫描更多数据

正确做法:通过 EXPLAIN 分析,看实际的执行计划,而不是盲目替换 JOIN 类型。

踩坑二:InnoDB vs MyISAM 的选择

原笔记提到”将 InnoDB 改为 MyISAM”。但在 MySQL 5.6+ 和 MySQL 8.0 中,这个建议已经过时

对比项 InnoDB MyISAM
事务支持 支持 不支持
行级锁 支持 只有表锁
外键 支持 不支持
崩溃恢复 支持 不支持
COUNT(*) 较慢 较快
全文索引 5.6+ 支持 支持

结论:除非是只读、纯查询、无事务需求的场景,否则优先使用 InnoDB。MySQL 8.0 已经彻底废弃了 MyISAM。

踩坑三:PHP 内存溢出

即使用了 LIMIT 分批,如果 PHP 代码中不小心把所有批次结果都累积到一个数组中,内存还是会爆:

1
2
3
4
5
6
7
8
9
10
11
// 错误:结果一直在累积
$allResults = [];
while ($batch = fetchNextBatch()) {
$allResults[] = $batch; // 内存持续增长!
}

// 正确:处理完即释放
while ($batch = fetchNextBatch()) {
processRecord($batch);
unset($batch); // 及时释放
}

未来技术展望

现代大数据处理架构

对于 29GB 级别的数据表,在现代技术栈中有更优雅的解决方案:

方案 适用场景 优势
ClickHouse 海量数据分析 列存储,查询速度极快
Elasticsearch 全文搜索 + 聚合 分布式,水平扩展
TiDB 大规模 OLTP 兼容 MySQL 协议,自动分片
消息队列 + 流处理 实时数据处理 低延迟,高吞吐

PHP 的进化

现代 PHP(PHP 8.x)配合 Swoole/Workerman 等协程框架,已经可以很好地处理高并发和大数据场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
// 使用 Swoole 协程处理大批量数据
use Swoole\Coroutine;
use Swoole\Coroutine\Channel;

Coroutine\run(function () {
$channel = new Channel(100);

// 生产者:分批读取数据
Coroutine::create(function () use ($channel) {
foreach (fetchBatches(2000) as $batch) {
$channel->push($batch);
}
$channel->close();
});

// 消费者:并发处理
for ($i = 0; $i < 8; $i++) {
Coroutine::create(function () use ($channel) {
while ($batch = $channel->pop()) {
processRecord($batch);
}
});
}
});

总结:处理大数据表的核心思路是”化整为零”——分批处理、索引加速、缓存热点、异步执行。不管技术如何演进,这些底层原理都不会过时。关键是根据实际业务场景,选择最合适的组合方案。