1. 我先忍住了改代码的冲动:5年老项目的病根是怎么挖出来的
五年的业务系统,平时还能跑,一到高峰期就卡成幻灯片。这是我在一次技术复盘会上听到的客服原话,当时会议室里没人反驳,因为线上监控已经出卖了我们:核心接口平均耗时两秒往上,慢查询告警一天刷几十条,订单列表页在晚高峰的P95都快摸到5秒了。客服群里用户花式催进度,业务方天天问“什么时候能优化一下”,技术团队则陷入“每次发版都在救火”的循环。
重构这个词,大家喊了半年,但一直没人敢真正动手。原因很简单:五年的老项目,代码像一栋经历了多次违章搭建的老楼,牵一发而动全身。我们当时最清醒的一个决定,是没有立刻去“重构”,而是先花了两周时间,把病根彻底挖出来。因为后来被验证的一个事实是:如果按照最初所有人的直觉去加缓存、加机器、加索引,大概率只能把问题压住两三天,然后以更隐蔽的方式爆发。
1.1 第一轮排查:数据库慢查询只是表象
一开始DBA团队丢给我们一大串慢查询列表,整整十七页。大家的自然反应是“那就优化SQL吧”,于是我们挑了几个典型的执行计划来看,加了几个索引,上线后确实快了一点,但第二天就恢复了原样。
这中间有个反直觉的现象:有一个订单列表接口,我们给它加了普通复合索引之后,最核心的查询反而变慢了。原因是MySQL优化器在数据分布变化后选错了索引,导致回表次数暴增。我们当时为了验证是不是索引失效,手动把索引删掉,结果查询速度又回来了。这件事给了我一个很大的震动:在数据量、数据分布已经失控的五年老库里,凭直觉做单点优化,有时候是负优化。
那段时间我把线上请求日志翻了个底朝天,配合链路追踪把一次请求内部涉及的所有数据库查询、远程调用、序列化开销全部列出来,才看明白真实的全貌。比如那个最慢的订单列表接口,外部看是一个HTTP请求,实际上整个调用链里执行了37次SQL查询、6次RPC调用和3次Redis访问。单看每一次SQL,都是在几十毫秒内完成的,看起来“正常”,但串在一起,用户感知到的就是两三秒的卡顿。
1.2 真正的元凶:隐式N+1、小请求风暴和缓存穿透
链路追踪上墙之后,所有问题都藏不住了。我们把一个列表页的完整调用链打出来,肉眼可见的问题有三类。
第一类是隐式N+1查询。前端只需要展示20条订单,但后端是“先查订单主表拿20条,再循环查用户表、商品表、物流表、优惠券表”,循环里每次查询都有一次完整的网络往返。这是ORM框架用多了之后特别容易养出来的坏习惯,代码看起来干净,实际上一接口一晚上能打垮数据库。我们统计了一下,这一类问题贡献了整个接口耗时的35%左右。
第二类是小请求风暴。服务之间接口拆分得太碎,订单服务查一次用户信息要调用户中心,查一次商品信息要调商品中心,这些调用还是串行的。一次前端请求,内部要先等用户信息返回、再等商品信息返回、再等物流信息返回,每一步的网络延迟都叠加上去。高峰期网络抖动一下,接口耗时直接翻倍。
第三类是缓存穿透。我们的缓存策略很简单,先查Redis,查不到就查数据库。本来这个策略在低并发时没问题,但系统体量上来后,某些热点数据一旦缓存过期,瞬间就会有几百个请求同时打到数据库上。而且有些查询条件本身查不到数据,缓存里永远不会有值,每次请求都会穿透到DB。我们当时线上有一个查询历史订单的接口,因为一个月前的订单被归档了,缓存命中率长期是0,用户每次点开都是直接打库。
这些问题的共性是:没有一个是靠“加服务器”能解决的。它们本质上是用时间为代价换代码可读性的结果,所有等待时间都在无意义地消耗,我们把它叫做“结构性浪费”。
1.3 从用户体感反推:哪些接口最值得优先优化
两周诊断做完,手里的信息不是“哪里慢”,而是“哪里最让用户痛”。我们把线上所有接口按调用量排名、按P95耗时排名,再结合客服投诉关键词,列了一个优先级矩阵。最后只圈定了四个核心场景:订单列表页、订单详情页、首页数据聚合、用户中心信息查询。这四个场景覆盖了80%以上的用户访问量,也覆盖了客服反馈里九成以上的“系统卡”。
做出这个决定之后,整个优化目标变得极其清晰:不是“把所有接口都优化一遍”,而是“先把用户最常走的四条路修成高速路”。我们给这次优化定了个量化目标,核心接口P95从2秒以上压到200毫秒以内,整体系统吞吐至少翻五倍。
有人可能会问,为什么不把全部接口都纳入优化范围?我的看法是,一个五年老项目里大量低频接口本身并不构成性能压力,花同样的力气去优化它们,收益远低于核心链路。重构也好、优化也罢,本质上是资源分配问题,把有限的精力投到用户能感知的地方,才叫有效重构。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 不搞推倒重来:用“绞杀者策略”把5年债务一点点还掉
确定优化目标后,第一个分歧出现在方案选型上。当时团队里有两种声音:一种是“趁这次彻底点,用新语言新框架重写”,另一种是“继续在原有代码上打补丁”。我全程站在了中间偏第三种的位置:既不推倒重来,也不继续打补丁,按“绞杀者模式”逐步把旧系统替换掉。
所谓绞杀者模式,名字听着吓人,思路其实很朴素:不去动旧系统的全部,而是从边缘或者最痛的点出发,围绕旧系统长出一个新系统,让流量逐步迁移到新系统上,旧系统随着时间被自然淘汰。就像藤蔓慢慢绞杀一棵老树,不砍倒它,但让它逐渐退出舞台。
2.1 为什么拒绝“换Go重写”这种提议
先说为什么不推倒重来。五年项目,核心业务逻辑全部沉淀在Java代码和存储过程里,里面有大量复杂的业务规则:价格计算、库存扣减、优惠券叠加、退款状态机、对账逻辑。这些规则是五年里一百多个版本迭代磨出来的,很多边界情况连文档都没有,只存在于代码注释和几个老同事的脑子里。
如果用Go重写,技术团队要重新培训,所有业务规则要重新翻译,测试案例要重新积累,回归范围覆盖整个业务域。业务方不可能给你一年的空窗期,公司更不可能接受线上业务停摆来等一次“完美重写”。业内那些重写后翻车的案例,绝大多数不是因为新团队技术不行,而是因为业务知识在翻译过程中大量丢失。
但继续在旧代码上打补丁也不行。我们分析过,旧系统的核心查询链路耦合太深,订单模块直接依赖用户模块的内部实现,想在原结构上加缓存、加并发控制,改造成本和新写一个服务差不多,而且改一处崩三处的风险极高。打补丁的本质是用战术上的勤奋掩盖战略上的懒惰,最终只会让系统更乱。
所以我们选了一条中间路线:保持技术栈不变,仍是Java,但把用户感知最强的读链路独立出来,新建一个只读查询服务,专门承接订单列表、订单详情、首页聚合这些高频读请求。旧的写链路和低频接口暂时不动,等新服务稳定后,再把读流量逐步切换过去。
2.2 冷热分离:把用户能感知的读链路先抽出来
新查询服务的第一件事,是做数据冷热分离。我们把访问频率最高的“热数据”做了分类:近30天的订单、最近浏览过的商品、常用收货地址、优惠券可用状态,这些数据有一个共同特点——读多写少,而且实时性要求相对较低。
实现方式不是直接去和旧服务抢数据库,而是通过监听数据库binlog,把核心表的数据实时同步到新服务的独立存储层。同步链路用的是现成的Canal组件,从MySQL的binlog解析变更事件,推送到新服务的缓存和查询库中。新服务的所有读请求,一律先走本地缓存和Redis,Cache Miss了才落到自己的查询库,不再直接访问老库。
有同事担心这个同步链路会有延迟,导致用户看到的数据不是最新的。我们的处理策略是:订单状态这种强一致场景,仍然实时走老库查询,但只查单条主键;列表页、首页聚合这种弱一致场景,完全走新服务的缓存数据。实际上binlog同步在主从延迟正常的网络环境下,端到端延迟能控制在100毫秒以内,对列表页来说这个误差几乎无感。
最终的效果是:老数据库的读压力大幅下降,新查询服务则承担了90%以上的读流量,旧系统反而变得轻松了,写操作的响应速度也跟着提上来了。这个结果是我们当时没有预料到的,但回头一想也合理:当一个系统90%的读请求不再压到主库上,主库的CPU降下来,所有事务性操作自然都变快了。
2.3 灰度切换与代码兼容:重构期间线上不能挂
重构期最怕的不是技术问题,而是线上事故。我们采用了一个很保守但非常有效的灰度方案:新老服务双跑,流量逐步切换。
具体做法是引入一个流量开关层,按用户ID的哈希值分配流量。第一周切10%的流量到新服务,观察错误率和耗时;确认稳定后提高到30%,然后50%、80%,最后100%。每提升一档,都会至少稳定运行24小时再继续。同时,旧服务一直保持在线,开关随时可以一键回切。
除此之外,我们还做了一个新服务对外接口的兼容层。旧服务的接口字段命名和返回结构已经有很多调用方在依赖,我们不能因为新服务“更规范”就擅自改字段名、改分页结构、改错误码,否则下游系统会全线报错。兼容层的核心规则是:接口语义不变、字段名不变、错误码不变,哪怕内部实现已经换了一套全新的逻辑,对外暴露的协议必须和旧版完全一致。
这期间我们还特意做了一个“事故演练”:由一位后端同事模拟新服务出现大面积故障,手动触发回切开关。整个过程我们记录了从发现异常到流量完全回切到旧服务的时间,第一次跑了12分钟,后来优化到3分钟以内。这个演练虽然折腾,但后来真的帮了我们大忙——有一次新服务因为缓存集群抖动告警,团队两分钟内就完成了回切,用户几乎没有感知。
3. 性能提升10倍的三板斧:缓存、批量、并发
新查询服务的骨架搭起来之后,真正的硬骨头来了:怎么把单接口的耗时从秒级压到百毫秒级?我们最后总结出来,真正起决定性作用的就三件事:缓存层级怎么设计、批量接口怎么做、并发调用怎么控制。这三件事环环相扣,缺一个都很难实现数量级的提升。
先把话放这里:性能优化不是调参,不是加几个注解就完事。它的本质是消除等待——把线程空转、网络往返、不必要的串行依赖全部干掉,让每一个CPU周期都花在真正该花的地方。
3.1 缓存设计:二级缓存解决“缓存穿透+缓存雪崩”组合问题
缓存是性能优化里最立竿见影的手段,也是翻车率最高的手段。我们的方案采用了Caffeine本地缓存 + Redis分布式缓存两级结构,思路是:
- 第一级Caffeine,放在应用进程内,命中耗时差不多是零点几毫秒;
- 第一级Miss后再查第二级Redis,命中耗时1到3毫秒;
- 第二级也Miss了才查查询库,并把结果回填到两级缓存。
这里最关键的设计细节是缓存穿透防护。我们用了两种手段组合:布隆过滤器拦截“肯定不存在的key”,以及“对查不到的数据也缓存一个空值”。比如用户查询一个被删除的订单,这种key在布隆过滤器里会被直接挡住,根本到不了数据库。而对于某些条件查询(比如按手机号查三个月前已归档订单),我们会把空结果也缓存一分钟,避免每次请求都穿透到DB。
缓存雪崩的防护也做了。我们给每个key的过期时间加了一个随机偏移量,比如baseTTL是300秒,实际过期时间会在240到360秒之间随机取值,避免大量key在同一秒集体失效。这里有个具体的教训:第一版上线时我们偷懒没加随机值,结果零点一过,所有热点数据同时过期,数据库CPU瞬间冲高,还好当时有兜底限流,否则就是一次事故。
代码层面,Caffeine的配置也很关键。我们用的是这样的核心配置:
java复制Cache<String, OrderSummary> orderCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(300))
.recordStats()
.build();
注意recordStats()这一行,它能把缓存命中率暴露到监控系统里。我们上线后一直盯着这个指标,本地缓存命中率稳定在85%以上,加上Redis这一级,整体读请求命中率超过了97%,数据库的读压力基本可以忽略。
3.2 批量接口改造:一次网络往返干完以前五次的活
缓存解决的是重复查询的问题,但还有一类场景是“每次查询的数据都不一样”,缓存帮不上忙,必须真实去查。这类场景最大的问题不是数据库慢,而是调用次数太多。
最典型的是订单列表页:后端需要为列表里的每一条订单补充用户昵称、商品缩略图、物流状态。旧代码是循环里逐条调用用户服务、商品服务、物流服务,总共要发几十个RPC。我们当时做了一次手术:把这些查询全部改成批量接口。
用户服务新增了一个批量查询接口,入参是用户ID列表,返回Map<Long, UserInfo>;商品服务、物流服务同理。列表组装逻辑变成:
- 查出20条订单主记录;
- 收集需要的用户ID、商品ID、物流单号;
- 分别调用三次批量接口,一次性取回所有关联信息;
- 在内存里组装最终返回结构。
这一步改造把原来37次SQL加多次RPC的调用链,压缩到了3次批量查询加一次内存组装。接口P95耗时直接从2秒多掉到了400毫秒以内。
另外,我们还把首页数据聚合做成了BFF层(Backend for Frontend,服务于前端的后端),由BFF层并发调用各个子服务,把结果聚合好后一次性返回给前端。前端不再需要为了渲染首页而连续发起五六个请求,一次请求就把所有模块的数据拿齐了。前端同事反馈页面加载速度“像换了一个产品”。
3.3 数据库侧优化:索引、分页与连接池参数的真实调优记录
虽然缓存挡掉了绝大部分读流量,但漏到数据库上的请求依然要好好伺候。数据库侧的优化我们做了三件具体的事。
第一件事是覆盖索引。之前列表页排序字段和查询字段不一致,每次排序都要回表。我们重新设计了几个核心查询的联合索引,把WHERE条件、ORDER BY字段、需要返回的字段尽量都塞进索引里,让查询在索引覆盖下完成,减少回表次数。这个调整对深分页场景特别有效,典型的深分页SQL改法是:
sql复制-- 改造前:深翻页会导致MySQL先读取前10010行再丢弃前10000行
SELECT id, order_no, status, amount
FROM orders
WHERE user_id = ?
ORDER BY create_time DESC
LIMIT 10000, 20;
-- 改造后:先通过覆盖索引定位id,再回表取完整数据
SELECT o.id, o.order_no, o.status, o.amount
FROM orders o
INNER JOIN (
SELECT id
FROM orders
WHERE user_id = ?
ORDER BY create_time DESC
LIMIT 10000, 20
) t ON o.id = t.id;
第二件事是分页逻辑改造。面对深翻页场景,我们把基于offset的传统分页改成了基于游标的分页:列表页不再传页码,而是传上一页最后一条记录的ID和时间戳,SQL直接用id < ?定位。这个改法彻底消掉了大offset的扫描开销。代价是用户不能随便跳转到第100页了,但我们的产品场景里,运营在看用户订单时基本都是逐页翻看,游标分页完全够用。
第三件事是连接池参数。一开始我们的HikariCP配置是最大连接数200,想着“连接数越多越能抗压”,结果高峰期大量线程阻塞在获取连接上。后来把核心参数调成了这样:
yaml复制maximum-pool-size: 30
minimum-idle: 10
connection-timeout: 2000
validation-timeout: 1500
max-lifetime: 1800000
连接数调小之后,数据库的并发度得到控制,反而减少了行锁竞争和上下文切换,整体吞吐上升。这个反直觉的结论后来我在好几个团队分享过:连接池不是越大越好,它应该和数据库的CPU核数、磁盘IO能力匹配,大而空的连接池比小的更危险。
4. 从压测数据到线上验证:10倍提升是如何被证明的
有了前面的缓存、批量、并发三板斧,核心接口的耗时已经肉眼可见地降下来了。但技术圈有一句话:优化没压测,都是在自嗨。我们特意花了很大精力设计压测方案,确保“性能提升10倍”这个结论不是拍脑袋说出来的,而是有数据支撑、经得起复盘的。
压测最忌讳的一件事,是只报一个最高QPS。脱离延迟谈QPS、脱离错误率谈性能,都是耍流氓。我们要回答的真实问题是:在P95延迟小于200毫秒这个前提条件下,系统能承受多大的流量,以及高峰期能不能扛得住。
4.1 压测方案设计:不能拿最高QPS糊弄人
我们选了k6作为压测工具,压测环境直接复用生产环境的数据库备份,数据量级和生产一致。压测模型也不是均匀打请求,而是按照线上实际的流量曲线放大倍数:白天时段工作日高峰是每分钟6000次请求,峰值在晚上8点到10点,我们按峰值流量的三倍来压。
压测指标只看三个:P50延迟、P95延迟、错误率。达标线是P95小于200毫秒、错误率低于0.1%。在压测过程中我们同步开启了APM监控,观察每个阶段是CPU先到瓶颈、数据库连接先到瓶颈,还是GC先恶化。这一步很重要,因为压测的意义不只是“测出能不能扛住”,更是找出系统最薄弱的环节。
压测的过程中还真发现了一个隐藏问题:新查询服务在连接数升高时,偶尔出现几百毫秒的GC停顿。我们用jstat盯着GC日志,发现是本地缓存Caffeine存储了太多大对象,老年代回收频繁。后来把maximumSize从10万降到1万,并给大对象单独设置了更短的过期时间,GC停顿才消失。这个坑如果不压测,线上峰值时一定会爆。
4.2 核心接口新旧对比:P95从2.3s到210ms
压测结束后,我们拿到了完整的新旧系统对比数据。这里直接放一批最核心的:
| 接口场景 | 优化前平均耗时 | 优化前P95 | 优化后平均耗时 | 优化后P95 | 优化前TPS | 优化后TPS |
|---|---|---|---|---|---|---|
| 订单列表 | 935ms | 2.3s | 82ms | 210ms | 15 | 420 |
| 订单详情 | 620ms | 1.5s | 65ms | 180ms | 30 | 780 |
| 首页聚合接口 | 3.8s | 5.2s | 180ms | 680ms | 6 | 240 |
| 用户信息查询 | 260ms | 580ms | 28ms | 98ms | 80 | 1800 |
注意看首页聚合接口的P95是680ms,比平均耗时180ms高出一截。原因是这个接口在大促期间要拼装20多个数据模块,有些模块是弱依赖,我们已经加了超时降级,允许部分模块返回兜底数据。即使这样,P95也控制在700毫秒内,相比优化前的5.2秒,提升依然是数量级的。
从平均耗时的角度看,订单列表93倍、订单详情8倍、首页聚合21倍、用户信息9倍,整体上“性能提升10倍”这个说法没有任何水分。这里需要说明的是,10倍并不是一个单一技术带来的,而是N+1消除、批量改造、缓存分层、连接池调优这几项叠加后的联合结果。每一项单独拎出来可能只提升30%到50%,但叠加在一起,消除了90%以上的无用等待,数量级的提升就出现了。
4.3 线上灰度后的真实反馈:用户好评和资源成本下降
数据好看还不够,线上真实用户说了才算。我们在灰度阶段就持续盯着客服工单、应用商店评分、以及业务方反馈。第一周灰度10%流量时,客服收到的“页面打不开”“加载很慢”类工单,环比下降了约四成;灰度到50%后,这类工单基本消失了。业务方主动跑来问“你们是不是偷偷换了服务器”,因为运营同学在后台按订单列表查询时,明显感觉“一点就出来”。
还有一个数字让我印象很深。之前晚高峰时段,订单服务的CPU使用率长期在75%到90%之间徘徊,数据库主库的CPU也是70%以上。经过这次优化,订单服务CPU稳定在20%以下,数据库主库CPU降到30%左右。机器资源以前高峰期要硬扛,现在反而有大量余量。
后来在季度复盘时,我们把服务器成本也拉出来算了笔账:系统整体节省了差不多一半的机器资源。原来40台应用服务器减到16台,数据库从一主两从变成一主一从,额外的只读实例也退了两台。性能优化的结果不光是用户满意,公司账本上也直接体现出来了。
5. 重构最容易翻车的几个地方:我踩过的坑和避坑记录
写到这里,前面讲的都是方法论和成功经验。但任何一个做过重构的人都知道,重构过程不可能一帆风顺。我们这次优化整体顺利,回头看主要是因为有几类坑提前预判到了,或者踩进去之后快速把方案调整过来了。我挑几个最值得分享的教训出来,希望能帮后来者少走弯路。
5.1 缓存过期时间固定导致的数据不一致
第一版缓存上线后,我们很快遇到了一个数据一致性问题:商品价格在后台已经修改了,但App端依然显示旧价格,持续了将近五分钟才刷新。
排查下来,原因是我们把商品缓存的有效期写死了,固定是300秒。如果价格修改发生在缓存刚被刷新后的第10秒,那么用户要等290秒才能看到新价格。这个延迟对某些业务场景是可以接受的,但对价格、库存这类高频变更的数据来说,不可接受。
最终的解决方案分了两层:对价格、库存这类强时效数据,写入时主动删除缓存,让下一次请求重新回源数据库;对订单状态这类以最终一致性为目标的场景,仍然保留TTL,但TTL值会加随机偏移,避免雪崩。这让我意识到:缓存过期策略不能一刀切,必须按数据维度分类管理,强一致走主动失效,弱一致走TTL。
5.2 为了性能破坏接口语义,把下游坑惨了
这个坑是在设计批量接口时踩的。当时我们图省事,直接把用户服务的单查接口“改造”成了批量接口,所有调用方传一个ID就返回一条数据,传多个ID就返回多条,想着“兼容就好”。结果一个重点合作方因为没升级SDK,仍然按单查接口的方式解析批量返回结构,导致线上展示的用户信息全部错乱。
那次事故给我们的教训非常深刻:涉及对外接口,永远不要“改”旧接口的语义,而是要“新增”一个批量接口,让需要批量能力的调用方显式接过去。旧接口保持原样,哪怕它继续慢一点,至少不破坏现有调用方的假设。重构和优化,绝对不能拿兼容性去赌,一旦破坏下游契约,信任崩塌比性能糟糕更可怕。
5.3 监控缺失时“优化成功”只是你自己的错觉
还有一件事让我意识到,没有监控做基础的优化,就是在裸奔。刚开始重构时,我们其实还犯过一个错误:对某个接口做了一次优化,本地测试觉得快了不少,就直接上了。上线后业务方没有投诉,我们就认为优化成功了。
后来有一次做容量评估,才发现那个接口的调用量本身就非常小,优化它带来的业务收益接近于零。如果不是建立了全链路监控和调用量排行榜,我们根本不会发现自己在优先级排序上做了无用功。现在我们的规矩是先接OpenTelemetry全链路追踪,按调用量、P95、错误率生成接口排行,再根据排行决定优化顺序。优化完还要回填数据对比,没有数据证明的优化,不叫优化,叫自我感动。
5.4 回滚预案只在事故演练时才有用
最后想提醒一点:回滚预案一定要演练,不能只写在文档里。我们第一次做流量切换演练时,光跑回滚脚本就花了20多分钟,因为缺少一键回切开关,需要手动改十几个配置项。后来我们专门做了一个回切平台,把开关集中管理,演练时间压缩到3分钟内。
后来有一次新服务因为缓存集群抖动,P95突然飙到1秒,团队立刻执行了回切,用户几乎无感知。如果没有提前演练,那次抖动很可能就会演变成一次线上事故。重构不害怕出问题,害怕的是出了问题无法快速回到安全状态。
我现在回头想这段重构经历,最深的体会是:老项目重构最重要的不是“改了什么技术”,而是“先搞清楚要改哪里、为什么改、怎么保证不出事”。一个五年项目积攒下来的不只有技术债,还有对业务规则的理解和团队对系统的信任。性能提升10倍当然让人兴奋,但更让我踏实的,是链路追踪、灰度开关、回滚预案这些看似不“性感”的基础设施,成了整个团队接下来继续演进的底气。
