接手过不少PHP项目,也帮同行排查过线上故障,我最大的感受是:性能问题最忌讳“猜”。之前有个后台列表页,从点击到出数据要3秒多,高峰期直接卡死。我做的第一件事不是打开IDE撸代码,而是把Nginx访问日志、PHP-FPM慢日志和MySQL慢查询日志三样东西摆在桌面上,按顺序一点点往下剥。为什么要这么做?因为“慢”可能发生在任何一个环节:可能是代码写得烂,也极有可能是数据库没走索引、FPM进程池配置不合理、缓存策略缺失,甚至是前端资源加载拖了后腿。如果你一上来就埋头改函数,运气好能挤出三五毫秒,运气不好折腾一晚上毫无变化。
这篇文章就是把我日常做PHP性能分析与调优的完整思路梳理一遍,从定位瓶颈到实战优化,每个环节都尽量给出能直接照着用的命令、配置和判断方法。适合正在为线上接口变慢发愁的开发者,也适合刚接触性能优化、想建立系统排查思路的朋友。
1. 先别急着改代码:搞清楚慢的到底是哪一层
1.1 用户说“慢”,真的只是代码慢吗
一次HTTP请求从用户浏览器到服务器再返回,链路特别长:浏览器发起请求、DNS解析、网络传输、Nginx接收、PHP-FPM处理、PHP框架运行、访问MySQL/Redis、外部API调用、模板渲染、响应回传、浏览器加载资源并渲染页面。
你自己用浏览器访问一个页面,感觉“卡”的体感时间,是从点击到页面完整呈现的总时长。但这段总时长里,PHP代码执行往往只占一小部分。我见过不少案例,用户抱怨接口慢,实际抓包一看,是某个静态资源被外部引用拖到好几秒;还有人折腾半天代码优化,最后发现是Nginx配置里没开压缩,光传输就占了大部分时间。
所以第一步永远是:先分清楚慢在哪一层。
1.2 用手头的工具先把时间账算清楚
浏览器开发者工具是最快的入手点,打开Network面板,刷新页面,看瀑布图里每个请求的耗时分布。这里有个关键指标叫TTFB(Time To First Byte),指从发请求到收到响应第一个字节的时间。判断逻辑很简单:
- TTFB高,比如超过1秒,说明服务端处理是重灾区,问题大概率出在PHP、数据库或者Nginx配置上。
- TTFB低,但整个页面加载慢,说明重点在静态资源数量、资源体积、渲染阻塞这些前端因素上。
- 网络本身慢,比如请求在Waiting阶段耗时异常,可能是网络链路、DNS解析或代理的问题。
如果在服务器上,我更习惯用curl直接看接口耗时分解。在执行环境里敲这样一条命令:
curl -o /dev/null -s -w "DNS:%{time_namelookup} 连接:%{time_connect} TTFB:%{time_starttransfer} 总时间:%{time_total}\n" https://api.example.com/orders/list返回结果里每一项都对应一个阶段:DNS解析多久、建立连接多久、服务端处理到返回首个字节多久、整体多久。哪个环节异常,一目了然。TTFB高就锁定服务端,随后进入下一步排查。
1.3 别把精力浪费在“看起来很慢”的代码上
很多人一提起性能调优,就喜欢抠字符串拼接用单引号还是双引号、foreach和for哪个快、函数调用能不能少一层。这些都属于“看起来很专业但收益极低”的改动。以PHP 7以上的性能,一次普通的函数调用开销可以忽略不计,真正吃掉大头的永远是外部依赖。
根据我的经验,真实业务里80%的性能问题集中在这几处:
- 循环内查询数据库,一个列表页几百上千条记录,每条都查一次,累计下来就是秒级。
- SQL没走索引,数据量一大就全表扫描。
- 缺少缓存,同一个热数据每次请求都重新计算、重新查库。
- 框架/依赖初始化太重,一个简单接口加载了几百个类。
把这三类问题排查清楚,收益比抠代码细节高几个数量级。所以整个调优流程应该是:先测量、再定位、最后才动手改。
2. 搭一套能说话的性能观测体系:从日志到APM
2.1 系统层面先看三张报表:CPU、内存、磁盘I/O
进入服务器后,先别急着看代码,用几条基础命令把系统状态摸一遍。
第一条是top,看整体负载和CPU占用,重点观察php-fpm进程的数量和CPU占用率。如果worker进程数远小于请求并发,CPU还有大量空闲,说明瓶颈不在CPU,而是进程不够、请求在排队。如果CPU跑满,再配合其他工具确定是PHP代码计算密集,还是SQL执行量太大。
第二条是free -m,看内存余量。PHP-FPM的worker进程是很吃内存的,如果看到Swap使用量持续增长、系统频繁换页,说明内存严重不足,再好的代码也会被磁盘交换拖慢。
第三条是iostat -x 1,看磁盘I/O。很多团队忽略这个:日志写得太猛、临时文件读写频繁、MySQL落在机械盘上,都可能让请求变慢一个数量级。有一次我排查一个偶发抖动的问题,最后定位是PHP错误日志每分钟写入几十兆,磁盘I/O被打满,改完日志级别立刻恢复。
2.2 让Nginx和PHP-FPM把慢请求“吐”出来
系统指标只能说明“资源吃紧”,具体哪个接口慢,得靠日志说话。Nginx默认的访问日志只记时间戳和URI,把它扩展一下,就能直接看到哪些页面耗时高。
在Nginx的http块里自定义日志格式:
log_format timed '$remote_addr [$time_local] "$request" $status ' 'request_time=$request_time ' 'upstream_response_time=$upstream_response_time'; access_log /var/log/nginx/access_timed.log timed;这里request_time是Nginx处理请求的总时长,upstream_response_time是后端PHP-FPM实际执行耗时。两者差距大,说明耗时卡在Nginx与上游之间的排队或传输上;基本一致,说明是PHP本身执行慢。
PHP-FPM慢日志则更直接——它能把“执行超过阈值”的请求堆栈原样打出来。修改PHP-FPM的pool配置:
slowlog = /var/log/php-fpm-slow.log request_slowlog_timeout = 2s2秒以上的请求会被记录到slowlog,并且带上当时的PHP调用堆栈。这意味着一打开配置文件,就能知道慢请求卡在哪个函数、哪一行。这个文件,配合Nginx访问日志,基本能锁定80%的线上慢接口。
2.3 要不要上APM:按团队规模和现状来定
日志方案属于“事后知道慢”,如果想看得更细、更实时,可以引入APM(应用性能监控)工具。市面上的APM产品一般通过一个探针嵌入PHP进程,把一次请求拆成框架初始化、业务代码、数据库查询、外部HTTP调用等若干片段,并以火焰图或调用链的形式呈现。这对微服务、复杂调用链的排查效率提升非常明显。
不过APM也不是非上不可。我的判断标准是:如果项目接口数量少、调用链简单,Nginx日志加PHP-FPM慢日志加Profiler足够;如果项目微服务化、接口依赖多,或者线上问题呈偶发性(不是每次必现),那上APM能省大量排查时间。
选型上,开源自建可以看基于采样模式实现的工具,商业方案也有提供本地部署的版本。无论选哪个,原则都一样:探针本身的性能开销尽量低,别为了监控让系统变慢。
3. 用Profiler撕开代码的面具:热点函数一目了然
3.1 Profiler工作机制与怎么选
日志能告诉你“这个请求慢”,但不能直接告诉你“代码里哪个函数是元凶”。真要撕开函数级别的耗时分布,得靠Profiler。
Profiler大致分两种:一种是采样式,运行期间按固定频率采集当前正在执行的函数栈,开销低,适合线上环境短时间开启;另一种是注入式,对每个函数的进入和退出点做埋点统计,数据精确但性能开销大,适合开发环境。
对PHP开发者来说,最熟悉的注入式工具是Xdebug。本地开发环境开启profile模式的方法:
zend_extension=xdebug xdebug.mode=profile xdebug.output_dir=/tmp/prof xdebug.profiler_output_name=cachegrind.out.%t.%p配置完成后,跑一次目标请求,目录下会生成cachegrind格式的文件。这个文件可以用图形化工具打开,也可以直接用命令行工具解析统计,看到每个函数的调用次数和耗时。
生产环境不建议全程开启Xdebug profile,毕竟开销摆在那。我的做法是:先在预发或本地复现,开profiler拿到数据;如果必须在线上排查,用采样式工具,短时间开启、拿完数据立即关掉。
3.2 看懂一份Profiler报告,先抓三个数字
拿到Profiler报告后,新手容易盯着图发呆。其实核心指标就三个:
- 调用次数:一个函数被执行了多少次。如果次数上千甚至上万,基本可以怀疑有循环内重复调用。
- 自身耗时(exclusive time):函数体内部代码实际花掉的时间,不包括调用其他函数的时间。
- 累计耗时(inclusive time):本身耗时加上它内部所有调用的耗时,体现该函数对整个请求的贡献。
看报告时有一个很容易踩的误区:某个函数的自身耗时只有几毫秒,就以为不慢。但再看调用次数,发现它在循环里被执行了800次,累计就是秒级。有一个印象很深的案例——列表页慢得离谱,Profiler显示一个SQL查询函数自身耗时只有3毫秒,但调用次数被循环放大到800次,累计2.4秒,这才是真正的主犯。
3.3 从报告到行动:给热点排优先级
面对一堆耗时不等的函数,别眉毛胡子一把抓。我一般按三类处理:
- 自身耗时高的单个热点:考虑优化算法本身,检查是否在做无意义的重复计算、是否能提前break、是否能简化数据结构。
- 调用次数高的函数:考虑把循环内查询改成批量查询,把重复计算结果缓存起来。
- 累计耗时集中在下游调用的函数(比如查询、外部API):考虑能不能少调两次,或者把逻辑下推到数据库里用一次SQL完成。
第4章和第5章会展开这三类处理里最常碰到的两个战场:数据库和缓存。
4. 数据库往往是那只“房间里的大象”
4.1 慢查询日志是最直接的证据
如果Profiler显示大量时间花在SQL查询上,下一步就是打开MySQL的慢查询日志。这是定位数据库瓶颈最直接的手段。
修改MySQL配置(一般写在my.cnf或my.ini):
slow_query_log = 1 slow_query_log_file = /var/log/mysql-slow.log long_query_time = 1 log_queries_not_using_indexes = 1重启MySQL后,执行时间超过1秒、或者没走索引的查询会被记进日志。日志积累一段时间后,用自带工具归类统计:
mysqldumpslow -t 10 /var/log/mysql-slow.log它会把结构相似的SQL归并到一起,并显示平均耗时、执行次数。按执行次数乘以单次耗时估算影响面,优先处理“次数多又慢”的SQL——很多时候一条没索引的查询被某个列表接口反复调用,就是性能灾难的源头。
有个实际例子:某报表接口每次打开都要5秒,用户基本不敢用。慢日志一查,是一条count(*)拼了好几个表的统计SQL,扫了上百万行。解决方案加了个合理索引,直接降到300毫秒不到。
4.2 索引设计没有银弹,但有套路
加了索引并非万事大吉,很多慢查询其实“有索引也走得很烂”。排查SQL执行计划,用EXPLAIN:
EXPLAIN SELECT id, title, created_at FROM orders WHERE status = 1 ORDER BY created_at DESC LIMIT 20;执行结果里几个关键列要会看:
| 列名 | 含义 | 重点关注 |
|---|---|---|
| type | 访问类型 | ALL是灾难,ref/range/const都还好 |
| key | 实际用到的索引 | NULL说明没走索引 |
| rows | 预估扫描行数 | 越大越危险 |
| Extra | 附加信息 | 出现filesort/temporary要警惕 |
设计索引有几个经验可以分享。联合索引要遵守最左前缀原则,(status, created_at)能同时支持按status筛选和按created_at排序的场景。尽量用覆盖索引,让查询的所有字段都在索引里,省掉回表。排序字段最好跟索引匹配,否则会产生Using filesort,数据量大时性能很差。
但也别走上另一个极端:为每个查询建独立索引。索引本身占用空间,还会拖慢写入。综合评判查询频率与写入频率,保留收益最大的那几个就够了。
4.3 干掉N+1:把逐条查询改成批量取数
ORM确实提高了开发效率,但也催生了大量N+1查询——先查列表拿到N条记录,再循环N次去查每条的关联数据。这是我在代码Review里见得最多的性能杀手。
以常见的列表接口为例,原先伪代码是这样:
$orders = Order::where('status', 1)->get(); foreach ($orders as $order) { $user = User::find($order->user_id); // 每次循环都是一次SQL $order->user_name = $user->name; }改成批量取数:
$orders = Order::where('status', 1)->get(); $userIds = $orders->pluck('user_id')->all(); $users = User::whereIn('id', $userIds)->get()->keyBy('id'); foreach ($orders as $order) { $order->user_name = $users[$order->user_id]->name ?? ''; }同样是取800个用户信息,原来要发800次查询,现在一次搞定。如果ORM启用了关联预加载,也要记得在查询时同时加载关联模型,避免后续访问属性时触发惰性加载。
另外,做分页的时候要小心深分页。LIMIT 100000, 20这类写法,MySQL还是要扫描前10万行再丢弃。优化思路是提前定位上一页的最大主键,然后WHERE id > 上一页最大id ORDER BY id LIMIT 20,能省掉大量无效扫描。
5. 缓存不是银弹,但正确的缓存是真的香
5.1 白捡的性能:把OPcache配好
多数PHP项目默认没开OPcache,每来一次请求,PHP都要把一堆脚本从磁盘读出来、做词法分析、编译成opcode再执行。这一步纯属重复劳动——代码又没变,为什么每次都要重新编译?
在php.ini中开启:
opcache.enable=1 opcache.memory_consumption=128 opcache.interned_strings_buffer=8 opcache.max_accelerated_files=10000 opcache.validate_timestamps=1 opcache.revalidate_freq=60memory_consumption决定了能缓存多少编译产物,128MB对绝大多数项目够用;max_accelerated_files要大于项目实际PHP文件数,可以执行find . -name "*.php" | wc -l统计一下再定;validate_timestamps和revalidate_freq控制的是文件变更检测频率——开发环境开1秒方便调试,生产环境如果发布流程比较规范,可以设60秒甚至更高。
有一个坑必须提醒:如果OPcache内存设太小,缓存会被频繁淘汰,每次请求都重新编译,反而比不开还慢。上线后可以用opcache_get_status()检查缓存命中率,长期低于95%就要考虑调大内存。另外,代码发布后如果线上环境没关闭校验并执行了opcache_reset,就可能出现“代码改了但线上没变化”的幽灵问题,这点也要纳入发布流程。
5.2 Redis:缓存该存的,别缓存不该存的
Redis是应用层缓存的重武器,但用错了地方反而添乱。我的经验是,下面这几类数据放在Redis里性价比极高:
- 热点列表:首页推荐、分类导航、热门排行,数据变化不频繁,读多写少。
- 计数器:PV/UV、点赞数、库存余量(配合原子操作)。
- 会话数据:Session迁到Redis后,多实例部署也不用担心Session不同步。
- 重复计算结果:复杂的聚合统计、报表数据。
不适合缓存的是那些强一致要求极高的数据,比如库存扣减的关键环节,如果直接走Redis缓存,就会面临缓存与数据库不一致的麻烦。宁可让这部分直查数据库,也别为了一点性能给自己埋雷。
缓存方案的设计上,要特别注意三类典型问题:
- 缓存穿透:查询一个肯定不存在的数据,缓存没有,数据库也没有,每次请求都打到库。解决思路是空值也缓存,或者用布隆过滤器拦截。
- 缓存击穿:某个热点key过期瞬间,大量并发请求同时回源。用互斥锁保证只有一个请求去重建缓存,其他请求等待即可。
- 缓存雪崩:大量key同时过期,瞬时压力全部打向数据库。解决思路是过期时间加随机值,错开过期时刻。
这里给出一个典型的get-or-refresh代码骨架:
function getHotList(Redis $redis): array { $data = $redis->get('hot:list'); if ($data !== false) { return unserialize($data); } // 互斥锁防击穿 if ($redis->set('hot:list:lock', 1, ['nx', 'ex' => 5])) { $data = buildDataFromDb(); $redis->setex('hot:list', 300, serialize($data)); $redis->del('hot:list:lock'); return $data; } // 没抢到锁,等一会儿后读缓存 usleep(50000); return unserialize($redis->get('hot:list')); }实际开发里,还可以用“逻辑过期”的方案:缓存里保存一个过期时间戳,发现逻辑过期后再去刷新,期间旧数据继续服务,能减少锁等待。
5.3 HTTP层缓存:让Nginx替你挡流量
应用层缓存写好了,还有更大的流量可以在HTTP层消化。如果一台Nginx反向代理PHP-FPM,可以用FastCGI Cache把整个响应缓存住,命中时根本不用经过PHP进程。
在Nginx配置里启用最简单的用法:
fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=php_cache:10m inactive=60m; fastcgi_cache_key "$request_method$scheme$host$request_uri"; fastcgi_cache_valid 200 60m;这样,状态码为200的响应会被缓存60分钟。适合缓存的对象是通用信息类页面,比如公开的文章详情、帮助文档、公告列表。需要注意:如果接口跟登录用户相关,缓存粒度就不能只按URI来,得加入用户标识;如果请求带Cookie且内容因人而异,干脆跳过缓存。
这种方案的收益非常直观——以前1000个请求打过来,PHP-FPM要跑1000次;有了缓存,第一次回源后,剩下999次直接由Nginx返回,整个后端连日志都少写几条。
6. 往下钻:PHP进程模型与FPM调参背后的数学题
6.1 先弄懂PHP-FPM的三种管理模式
代码、SQL、缓存都优化到位了,另一个容易被忽视的瓶颈是PHP-FPM本身的进程管理。先搞清楚它的三种进程管理模式。
| 模式 | 特点 | 适用场景 |
|---|---|---|
| static | 启动时固定worker进程,不变 | 流量稳定、并发高 |
| dynamic | 按需在min和max之间伸缩 | 大多数业务系统 |
| ondemand | 空闲即回收,有请求才拉起 | 流量极低、内存紧张 |
static模式的优势是进程常驻、响应稳定,缺点是流量低谷也在白白占内存。dynamic模式灵活,但要小心参数设置不合理导致的频繁创建销毁进程。ondemand模式最省内存,但请求进来时如果恰好需要新建进程,首次请求会变慢。
我的建议是:大部分Web业务用dynamic,负载落差大;流量长期稳定偏高的,static更可控。
6.2 max_children不是拍脑袋拍出来的
进程池配置里,pm.max_children是最关键的参数,它决定最多同时有多少个PHP进程在跑。但很多人的配置方式是“网上抄个数字”,这非常危险——设太小高峰期排队,设太大内存直接打爆。
正确做法是先实测再计算:
- 用
top或ps观察一段时间,取单个PHP-FPM进程的内存峰值,记为M。常见业务在40~80MB之间,框架重的可能上百MB。 - 统计同一台机器上其他服务的内存占用,MySQL、Redis都是吃内存大户。
- 总内存中扣除系统预留(约0.5~1GB)和其他服务占用,再留出10%~20%余量,得到PHP可用内存。
计算公式大概是:
max_children ≈ (总内存 - 系统预留 - 其他服务占用) × 80% / 单个PHP进程内存举个例子:一台2核4G的云主机,同时跑了MySQL和Redis。系统预留约600MB,MySQL约800MB,Redis约200MB,剩下约2.4GB。单个PHP进程平均按60MB算,2400 × 80% ÷ 60 ≈ 32。所以max_children=32是合理值,设置成50以上反而危险。
配套的dynamic参数可以这样给:
pm = dynamic pm.max_children = 32 pm.start_servers = 8 pm.min_spare_servers = 4 pm.max_spare_servers = 12 pm.max_requests = 5000 request_terminate_timeout = 60启动进程数不要太高,避免一开机就占用大量内存;pm.max_requests的作用是让每个worker处理5000个请求后自动退出重建,配合预防内存泄漏/碎片化。
6.3 一个被忽略的进程级隐患:内存悄悄爬升
PHP-FPM的worker进程如果长期不重置,内存占用会随着框架加载、类实例累积而缓慢上涨。时间一长,进程内存从启动时的50MB涨到200MB以上,整个服务器内存被逐渐吃光。
处理方式就是上面的pm.max_requests——当worker处理到设定数量的请求后,它会被回收重建,内存回到初始水平。这个参数在长生命周期应用里几乎必设,一般3000~10000之间看实际情况调节。
另外还要注意:不要在PHP-FPM进程里跑超长任务,比如导入上万行Excel、循环发送大量邮件。这类任务会让worker长时间占用,挤占正常请求的处理能力。正确做法是把耗时的任务丢到消息队列,由独立消费进程慢慢处理。
7. 一次完整的调优实战:从3秒到300毫秒的拆解
7.1 一个典型的“慢后台”问题描述
用一个虚拟案例把前面讲的东西串起来。假设有一个内容管理后台,其中一个列表页,包含审核状态筛选、创建人姓名和统计计数。线上表现为:点击页面要3秒多才能看到数据,上午10点业务高峰时,部分请求直接502。用户抱怨反复出现,访问日志里大量超时。
7.2 按顺序的四步定位链路
我当时的排查顺序是这样的,也建议你照这个顺序来:
- 浏览器访问页面,跳过前端炒代码,直接看TTFB:2.8秒,问题锁定在服务端。
- 打开Nginx的
upstream_response_time日志,筛选出该列表接口,显示平均耗时3.2秒,P95接近4秒。 - 打开PHP-FPM慢日志,阈值设到2秒。几分钟后日志里出现了该接口的调用堆栈,指向
AuditController::index方法。 - 在开发环境用Profiler跑一遍同样数据量的请求,热点清晰可见:一个循环内查询创建人昵称的函数被调了800次;另有一条统计SQL没走索引,单次耗时就超过600ms。
排查结果的总结可以列成一张表:
| 排查步骤 | 工具 | 发现 |
|---|---|---|
| 确认服务端问题 | 浏览器TTFB / curl | TTFB 2.8s |
| 确定具体接口 | Nginx timed log | 列表接口 p95≈4s |
| 定位代码位置 | PHP-FPM slowlog | AuditController_list |
| 定位函数热点 | Profiler | 循环内SQL×800 + 全表count |
7.3 一处处改,一步步验证
接下来是修改。我坚持一个原则:一次只改一处,改完立刻压测,验证再不进入下一项。因为如果一次性改好几处,后面出了问题根本没法定位是哪个改动导致的回退。
第一步,给统计SQL加索引。把原本的全表扫描变成索引范围扫描,这条查询从600ms降到10ms左右。
第二步,解决N+1。不再在循环里查用户,而是先取出所有user_id,一次WHERE IN查询回来,在内存里组装。这一步把800次查询压缩成1次,接口耗时从2.4秒降到120ms。
第三步,把列表页的统计计数缓存到Redis,设置5分钟过期并加随机过期偏差,避免雪崩。高峰期的数据库压力明显下降,不再出现偶发超时。
第四步,顺手检查了FPM参数,发现max_children设置得远高于服务器能承受的数值,随即将进程管理调成dynamic,max_children按内存实测改为32,并设置pm.max_requests=5000。高峰期502问题从此消失。
最终效果:接口响应稳定在30~60ms,页面整体从3秒多降到300毫秒以内,同样的压测条件下QPS翻了将近10倍。整个过程并没有改什么高深的算法,全部是“先测量、再定位、精确打击”的常规操作。
8. 性能调优不是终点:建立长期防线
8.1 把性能测试塞进日常发布流程
很多团队只在出问题时才想起性能,问题解决了就回到常态,下次优化又从零开始。我的习惯是给关键接口建立性能基线,用简单的压测工具定期跑一遍。
ab是最容易上手的压测工具,一条命令就能拿到基础数据:
ab -n 1000 -c 50 https://api.example.com/orders/list关注结果里的Requests per second和Time per request,同时检查失败率。把关键接口的P95耗时写进监控看板,每次发布前跑一次基准,如果新版本比历史基线明显变差,就该在发布前把问题拦下来,而不是等到线上用户投诉。
虽然wrk这类工具可以打出更丰富的压力模型,但团队的落地成本才是关键。先用ab把习惯养起来,比工具本身花哨与否重要得多。
8.2 线上监控与告警:守住底线
上线后的监控指标至少要覆盖这几项:
- PHP-FPM进程数、worker内存是否持续上涨。
- Nginx请求耗时、5xx/502状态码数量。
- MySQL慢查询条数、连接数、临时表数量。
- Redis内存占用、命中率、慢命令执行数。
- 系统CPU/内存/磁盘I/O整体负载。
告警阈值别设得太敏感,否则会陷入告警疲劳,真正出问题的时候反而没人看。告警的价值是发现问题,不是增加噪音——先关注那些“一定会引起故障”的指标,再逐步优化敏感度。
8.3 代码Review时多问一句“性能呢”
代码提交阶段就把性能问题消灭,是成本最低的做法。我在做代码评审时会特别关注几个点:循环里有没有查询数据库?查询有没有用到索引?大数据量列表是全量加载进内存还是分页?同一个结果有没有重复计算而不是复用?外部API调用能不能合并?
另外一个建议:把每次优化记录下来,形成一份优化清单。格式很简单,记录“问题现象、定位手段、实际改动、优化前耗时、优化后耗时”这几个字段。这个清单既是团队的宝贵资产,也是新成员上手排查的好教材。
性能优化没有终点。业务在变、数据量在变、代码也在变,今天调好的系统,明天可能被一个新需求重新拉回瓶颈。但只要把“先定位再动手”的习惯培养起来,把观测手段和压测流程沉淀成日常规范,再复杂的性能问题,都可以被一步步拆解掉。