问题背景 新换的公司,接手了一个遗留项目。当我第一次登录数据库服务器,看到一张表的数据量达到 29GB 时,我顿时就懵了。更让我惊讶的是,这个 29GB 的大表还需要与另一个 1GB 多的表做关联查询 ,而且这段代码跑在 PHP 上,数据库是 MySQL。
1 2 3 4 表A (主表): 29 GB ≈ 5000 万条记录 表B (关联表): 1 GB ≈ 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 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 ++; } 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 万条记录,查询速度也不会变慢。
策略二:正确使用索引,让查询从全表扫描变成索引查找 问题分析 原始代码中,两个表的关联字段没有任何索引 ,导致 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 ALTER TABLE big_table ADD INDEX idx_user_id (user_id);ALTER TABLE small_table ADD UNIQUE INDEX uk_user_id (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_infoFROM big_table aINNER JOIN small_table b ON a.user_id = b.user_idLIMIT 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 (全表扫描,最差)
策略四:使用 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 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); $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 ALTER TABLE big_tablePARTITION 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 SELECT * FROM big_tableWHERE 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 = new Redis (); $redis ->lpush ('batch_jobs' , json_encode ([ 'task_id' => $taskId , 'created_at' => time (), 'status' => 'pending' ])); return $taskId ; } 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 nohup php worker.php > /var/log/batch_worker.log 2>&1 &[program:batch-worker] command =php /var/www/worker.phpautostart=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 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 ); } }); } });
总结 :处理大数据表的核心思路是”化整为零”——分批处理、索引加速、缓存热点、异步执行。不管技术如何演进,这些底层原理都不会过时。关键是根据实际业务场景,选择最合适的组合方案。