2.5 性能优化:缩短旅客的候车时间 本节摘要:性能优化的第一戒律是先测量再动手——凭感觉优化大概率把力气花在刀背上。本节给出测量的最小工具集,讲清请求全链路上四个提速点:OPcache 让脚本免于重复编译,分层缓存把热点数据放在离计算最近的地方,查询优化让数据库少做无用功,懒加载让昂贵操作推迟到真正需要时。读完你应当拿到一张"哪里最可能堵、先查哪里"的作业顺序表。 先测量再动手 动手前先回答两个问题:现在多慢?慢在哪?没有数据就没有发言权。最小测量工具是时钟函数,埋在嫌疑代码两端: 程序级别的耗时分解之外,更该养成看两份"官方账本"的习惯:数据库慢查询日志(记录超过阈值的查询)与 PHP 的请求级剖析工具(按函数分解耗时)。
本节摘要:性能优化的第一戒律是先测量再动手——凭感觉优化大概率把力气花在刀背上。本节给出测量的最小工具集,讲清请求全链路上四个提速点:OPcache 让脚本免于重复编译,分层缓存把热点数据放在离计算最近的地方,查询优化让数据库少做无用功,懒加载让昂贵操作推迟到真正需要时。读完你应当拿到一张"哪里最可能堵、先查哪里"的作业顺序表。
动手前先回答两个问题:现在多慢?慢在哪?没有数据就没有发言权。最小测量工具是时钟函数,埋在嫌疑代码两端:
<?php $start = hrtime(true) / 1e6; // 毫秒级高精度计时 // 嫌疑段落:一次全量班次查询 $stmt = $pdo->query('SELECT * FROM trips'); $rows = $stmt->fetchAll(); $cost = hrtime(true) / 1e6 - $start; printf("查询耗时 %.1f 毫秒,取回 %d 行\n", $cost, count($rows));
程序级别的耗时分解之外,更该养成看两份"官方账本"的习惯:数据库慢查询日志(记录超过阈值的查询)与 PHP 的请求级剖析工具(按函数分解耗时)。经验分布大致是:十次性能问题里七八次出在数据库与网络往返,一两字在算法与循环,纯语言执行速度反而很少是瓶颈——所以先查数据链路,再怀疑语言,顺序别颠倒。
1.1 节讲过 PHP 每个请求都重新读取并执行脚本,但完整流程其实是"读取源码 → 编译成字节码 → 执行"。编译这一步与业务无关、结果又不变,白白重复。OPcache 把编译产物缓存在共享内存里,后续请求直接执行字节码:
; php.ini 中开启(多数发行版默认已启用,确认即可) opcache.enable=1 opcache.memory_consumption=128 opcache.validate_timestamps=1 ; 开发期按文件时间自动刷新 opcache.revalidate_freq=2
它的收益常被低估:官方口径的典型提速幅度从近一半到翻倍不等,而且是零代码改动的纯配置收益。生产环境可以把 validate_timestamps 关掉(发布时手动重置缓存),进一步省掉每次请求的文件时间检查。
缓存的核心思路是"算过一次的别再算",层次从近到远:进程内 OPcache 存编译产物,应用级缓存存计算结果,页面级缓存存整个响应。应用级最常用的是 Redis 这类外置缓存,跨请求、跨进程共享:
<?php function hotTrips(PDO $pdo, Redis $cache): array { $key = 'hot_trips_v1'; // 键里带版本号,改逻辑时换号即失效 $hit = $cache->get($key); if ($hit !== false) { return json_decode($hit, true); // 命中:直接用缓存 } $rows = $pdo->query('SELECT trip, fare FROM trips ORDER BY fare DESC LIMIT 10') ->fetchAll(); // 未命中:回源查询 $cache->setex($key, 300, json_encode($rows, JSON_UNESCAPED_UNICODE)); // 缓存五分钟 return $rows; }

缓存的三个纪律比语法重要:键带版本号(业务逻辑改了换版本,旧缓存自然失效);必设过期时间(没有过期的缓存是定时炸弹);只缓存读多写少的数据(班次表可以缓存,库存数缓存要慎之又慎)。
数据库侧最常见的两类病。第一类是缺索引:查询条件列没索引,数据量一上来全表扫描。验证方法是看执行计划:
EXPLAIN SELECT * FROM passengers WHERE email = 'a@b.com'; -- type 列为 ALL 即全表扫描,给 email 列加索引后变成 ref CREATE INDEX idx_passengers_email ON passengers (email);
第二类是循环查询(所谓逐条查询问题):列表页先查一批旅客,又在循环里逐个查他们的票——每页几十条就是几十次往返。解法是把循环查询合并成一次带关联的批量查询:
<?php // 病态写法:循环内逐条查(往返 N 加一次) foreach ($passengers as $p) { $stmt = $pdo->prepare('SELECT * FROM orders WHERE passenger_id = ?'); $stmt->execute([$p['id']]); $p['orders'] = $stmt->fetchAll(); } // 改进写法:一次 IN 批量取回,内存里按人分组 $ids = array_column($passengers, 'id'); $marks = implode(',', array_fill(0, count($ids), '?')); $stmt = $pdo->prepare("SELECT passenger_id, trip, fare FROM orders WHERE passenger_id IN ($marks)"); $stmt->execute($ids); $byPerson = []; foreach ($stmt->fetchAll() as $row) { $byPerson[$row['passenger_id']][] = $row; } foreach ($passengers as &$p) { $p['orders'] = $byPerson[$p['id']] ?? []; }
往返次数从"每人一次"降到"全场一次",页面耗时通常一个数量级地降。补一个合并时的边界细节:IN 清单可能很长(几百上千个编号),占位符数量有上限且回包过大也不划算,按批切分(比如每批两百个)循环执行即可;若编号清单本身来自另一次查询,优先考虑把两次查询合成一条关联查询,让数据库在库内完成匹配,省掉应用层的中转。
懒加载补最后一块:昂贵操作(生成报表、调外部接口)推迟到确认需要时再做,配合 2.4 的生成器,"取十行只算十行"。
⚠️ 给所有列都上索引:索引拖慢写入还占空间,按查询频率与区分度建,别见列就上。
⚠️ 缓存雪崩:一批键设了相同过期时间,同时失效瞬间打穿数据库——过期时间加随机偏移是标准解法。
hrtime 埋点加慢查询日志,先定位堵点再动手,数据链路优先于语言本身。下一节是安检专题:速度快了,危险品拦截也得跟上。