先说个真事。三年前我给某个电商后台做过一次所谓的大型优化,当时服务端日志里一堆慢请求,单次接口耗时经常跑到两千毫秒以上。一开始大家很统一地认为是SQL慢,把能加索引的字段全加了一遍。结果呢?平均耗时降了大概120毫秒,跟抽奖似的。后来抓了全链路才发现,真正让线程池卡死的是订单导出功能——那个接口居然用同步方式去拉三年内的流水,每页查两次库再在内存里做聚合,单请求就把整个Tomcat公共线程池占了四秒多。这一层做掉之后,P99直接掉到200ms。这事让我特别想讲一个观点:大部分人做性能优化,脑子里都是加法思维,加缓存、加线程、加机器,但真正的瓶颈往往是你做了太多不该做的事。
第二个想聊的是缓存。我看过很多团队一到性能不达标就缓存先行,Redis、LocalCache双管齐下。但缓存这个东西不是银弹,它更像一个留级生:代码是简单了,可一旦穿透、击穿、雪崩发生在同一个接口上,你的数据库会被瞬时流量打得亲妈都不认识。给大家一个具体参数参考:之前我们有个TOC的首页聚合接口,扛四万多QPS全靠三层缓存——Nginx层缓存5秒、Redis缓存30秒、进程内Caffeine缓存1分钟。听着是不是特稳?然后某次发布脚本误操作触发了缓存整体清空,Redis和Local同时没有预热,冷启动那几秒直接把下游会员系统的连接池打满了,最后靠限流才没引起全站故障。所以请大家记住一个反直觉的结论——缓存方案里,TTL不是越大越好,而是越小越安全;多层缓存不是越多越好,而是每一层都要有独立的降级预案。实际调优时,我们最终把缓存时间从30秒降到了8秒,命中率只掉了不到2个百分点,但故障影响范围缩小了一个量级。
再来说说代码层面。很多人问我你怎么判断一段代码到底要不要重写,其实只看一条:它有没有在循环里做IO操作。这个标准扫一遍线上代码,至少能救回来60%的烂性能。我举个具体的雷:一个券包上的详情接口,需要查每个sku的店铺和优惠信息,写之前一看也就十来个sku,结果循环里塞了二十多次Redis操作。你单测没压力根本看不出毛病来,一到高峰期连接被占满,线程全部block在jedis上,假死没商量。解决办法也不难:把循环里的那堆操作改成批量mget,一次最多5毫秒,以前每个逻辑加起来平均300毫秒。但我坦白讲,有时候代码不背全部的锅。那次我们加好了批量操作也没有明显好转,最后排查到docker容器CPU限额被别的应用抢了,服务本身根本没拿到允许的核数。你调优调到最后,有时候调的是运维配置和容量规划,根本不在代码里。
如果你只是做应用层开发,那我建议在性能调优这件事上多留一个心眼:很多需求方嘴里说的‘性能要快’,其实只是‘别再超时了’的意思。你不需要分库分表,不需要上多级缓存,更不需要为了一个偶尔爆发的批处理任务自研一套消息队列。这个坑我踩过太多次了。有一次处理慢SQL,DBA同事评估了所有方案之后建议直接把一张历史表归档掉,记录数少了5600万行,查询从1.8秒降到45毫秒。你看,看起来是个调优难题,实际上是数据治理。所以现在不少技术人问我要方案时,我会反问一句:你有没有想过,你是在调性能,还是在调业务设计?系统一旦被硬塞了太多本不属于它的职责,再怎么优化都像在一个漏水的桶里不断加桶盖。
有一句适合贴显示器上的话是这么说的:如果你的架构本身是拧巴的,那任何底层的性能优化都是在给一个坏地基加固。举个最直白的案例——早期我们有个积分模块,库存扣减要跨两个服务做远程调用,先扣积分,再调用优惠券服务,两步中间没有任何重试和幂等机制。线上高峰期百分之七八的概率出现扣了对账不平。后来一帮人讨论半天灰度方案,有人提议加分布式锁,有人建议上事务消息,最后重构的老哥只做了一件事,把两个服务内的操作合并到一个本地事务里,积分表跟券模板表直接放同一个库,接口耗时从180ms降到30ms,一致性也稳了。他的理由很干脆:为什么非得拆成两个服务?当初拆服务是为了团队协作,而不是为了让用户承担两个服务间的网络耗时和一致性问题。这就是我想说的第三个反常识点——性能问题,大多数时候其实是架构优雅性问题;你的系统如果在设计上足够简单直接,性能通常不差。至于那些真需要靠中间件去硬扛规模的系统,你的团队得先具备维护一到两套中间件的能力,否则照样是杯水车薪。
最后放一段我在公司内部做培训时的结尾。性能调优最激动人心的不是把数字降下来之后贴出来的那条曲线,而是你终于想明白了系统是为什么慢的那个瞬间。这活儿靠的是经验和直觉,不是凭一堆监控图表堆出来的。如果你现在正为接口慢发愁,我真诚的建议是关掉你手上那些诊断工具,把核心代码路径从头到尾读一遍,读到哪觉得别扭,通常那就是你跟瓶颈之间最短的距离。不用每行都对,但至少找到那种忍不住想重构的感觉。