祖传老代码重构:揪出假分页与N+1查询,用最小代价完成接口性能优化

本期敖行客研发实战日记,又迎来了新的挑战,这次的活儿听起来轻巧:一个网盘的文件列表接口 findList,用户反馈"数据一多就特别慢",100 多条数据要耗时 11 秒;而且这接口用的是"假分页",代码又老又乱。

诉求就两条

把它改成真分页、让它快起来

别影响到别的接口。

真正动手才发现,最难的不是写新代码,而是在一坨互相纠缠的老代码里,先弄清楚哪一根线不能碰。下面按排查顺序复盘。

一、先别急着改:那个"假分页"其实是对的

接口第一眼就能看出问题:JDBC 层把分页参数写死成了 page=1、limit=10000,不管前端翻到第几页,都先把整个目录最多一万行全部捞出来,转成对象、在内存里排好序,最后由 Controller 用 subList 切出当前页的 25 条。教科书级别的假分页。

但顺着前端抓包一看,翻页居然是正常的------第 1 页第 2 页数据都对得上。

**这里有个反直觉的点必须先想通:**分页结果之所以正确,恰恰是因为它查了全量。

既然整个目录都在内存里,subList 从哪切都对。

代价藏在别处:你翻任何一页,后端都把整个目录重新捞一遍、重新处理一遍。

目录里有 500 个文件,翻第 3 页也是 500 次完整处理。

图 1:假分页的数据流------查全量、内存排序、尾部切片,每翻一页都重跑一遍

经验:接到"优化慢接口",先分清"慢"和"错"。

这个接口分页不错、只是慢,而且慢的原因未必是你第一眼看到的那个。

先把行为摸清楚,再决定动哪儿。

二、11 秒花在哪:瓶颈是 N+1,不是分页

拿到那个关键数字算一笔账:11 秒 / 100 行,约等于每行 110 毫秒。

这个量级不可能是"多查了几千行"造成的------目录总共才 100 行,全量和分页的差距不过 4 倍。

真正的黑洞是尾部那个循环:它对捞回来的每一行,都要再单独查一遍库补数据、递归拼路径、查图标字典、查文件分片,文件夹还要递归遍历整棵子树去算大小,最后甚至在这个查询接口里写一次库。

其中最贵的一笔,是每行都调一次 Dubbo 去用户服务查用户名------一次跨服务调用二三十毫秒,100 行就是两三秒。单行八到十次 IO 叠起来,110 毫秒/行 严丝合缝。

这才是"数据一多就慢"的确切来源。

这也意味着优先级要反过来:只做真分页,把 100 行降到 25 行,也不过从 11 秒降到 3 秒;而把这些逐行查询批量掉,才是数量级的改善。

图 2:尾部循环里的 N+1------单行八到十次 IO,其中一次是 Dubbo 跨服务调用

经验:性能问题先定位再动手。

用"总耗时 ÷ 行数"估出单行成本,就能判断瓶颈是"查得太多"还是"每行太贵"。

数字会告诉你该先打哪儿,别凭第一印象。

三、最危险的一步:这个方法不是它一个人在用

要改,第一反应是去 JDBC 层把写死的 10000 换成真正的 limit。

但动手前先查了一遍调用方法------好在查了。

那个查询方法根本不是 findList 独占的:它同时被卡片视图接口 findListCard、以及另一个 OpenclawController 的同名接口调用。

一层层数下来,findList 依赖的六个查询方法,没有一个是它专属的。

更要命的是依赖的方式。findListCard 和 Openclaw 拿到 JDBC 返回的数据后,自己不做任何切片,直接就返回了------它们默默依赖着"JDBC 返回全量"这个行为。

我只要把 10000 改成 limit,卡片视图会突然只剩第一页数据,Openclaw 那个写死 limit=100 的接口也会跟着变。

原地改 JDBC 这条路,直接堵死。

图 3:六个查询方法的调用矩阵------每一行都不止一个调用方,全是共用的

经验:改一个共用方法之前,先把它的调用方全部列出来。

"我只动这一个方法"的前提,是这个方法只有一个主人;

共用方法上任何行为变化,都会顺着调用链漏到你没看的地方。

四、零外溢的解法:老方法一行不动,另起一套 V2

既然不能原地改,那就不改。

做法是给 findList 新增一套专用链路 :新写真分页的 V2 方法,把排序、授权过滤、分页全部下推到 SQL,当页需要补的用户名、图标、分片、授权,改成按页批量查询一次拿全。

老方法原封不动地留着,继续给 findListCard 和 Openclaw 用。

Controller 里只把 findList 切到新方法,并删掉尾部那段多余的 subList。

代价是诚实的:DiskFileJdbc 里从此新老两套方法并存,短期有重复。

但这正是"不影响别的接口"的必然代价------用一点暂时的冗余,换其他两个接口行为的零变化。

等哪天卡片视图和 Openclaw 也迁到 V2,再回头删掉老的那套,是一次干净的收尾,而不是这次就急着一锅端。

图 4:新老双链路并存------findList 走 V2,另外两个接口继续走老方法,互不干扰

经验:"不影响存量"往往要靠"容忍一点冗余"来换。

新增专用路径、让老路径原样退休,比就地改造一个共用方法安全得多,也更容易分批推进。

五、藏在 JOIN 里的隐形开关:少碰一列都是福气

把"我的收藏""我的创建"这两个分支改成 JOIN 主表一次拿全时,差点顺手多选了一列。

这两个分支原本查的是收藏表、创建表,表里没有大小和时间,老代码才不得不逐行回主表补------JOIN 掉正好消灭这个 N+1。

既然都 JOIN 了,把主表的 filekind 列一起选出来看似天经地义。

但停住了。

老代码里这两个分支的 bean,filekind 一直是空字符串;而 Controller 尾部有个判断"是文件夹且属于团队共享"才会触发递归算大小、并写回数据库。

空字符串让这个条件恒不成立,于是收藏和创建的文件夹从来不会走那段递归和写库。

我要是顺手把 filekind 选出来,等于悄悄打开了这个开关,行为就变了------一个查询接口会突然开始写库。

所以 V2 里刻意不选 filekind,保持老 bean 的形状。

图 5:本次优化问题总览(现象 → 根因 → 解法)

经验:重构老代码时,一个字段的空值、一个恒假的分支,都可能是被默默依赖的隐形开关。

改动要贴着旧行为走,"看起来该补上的"未必真该补------先确认没人在依赖它现在的样子。

六、克制的边界:发现了 bug,也不顺手改

过程中还捞出几个真 bug。

比如根目录某两个入口是真分页,结果又被尾部 subList 切了第二刀,翻到第 2 页恒为空;

又比如"我的收藏"的大小,底层那个统计方法只按团队过滤、没按人过滤,算出来的其实是"全团队当前页那几条"的大小之和------这个数字本身就是错的。

第 2 页恒空这个,顺着真分页的改造自然就修好了,属于搂草打兔子。

但"我的收藏"大小算错那个,我选择原样保留、只做批量化,没顺手"修正"它。

原因很简单:**这次的任务边界是性能,不是纠正业务数字;**那个数字错了多久、前端有没有别的地方在依赖它当前的表现,我并不清楚。

在一次以"别影响别的接口"为前提的优化里,任何超出边界的"好心修正",都是新的风险来源。

记下来、单独提出去,比就地改掉更负责。

经验:优化老代码时,克制和改进同样重要。

分清"这次该修的"和"这次不该碰的":顺着任务自然修好的顺手做掉,越过边界的"好心修正"先记账、单独走,别让一次性能优化夹带无人验证的业务改动。

小结

  • 先分清"慢"和"错":这个接口分页是对的、只是慢,慢的原因也不是你第一眼看到的假分页。

  • 性能问题先定位:用总耗时除以行数估单行成本,瓶颈是 N+1 跨服务调用,不是分页。

  • 改共用方法前先列全调用方法:findList 依赖的六个方法没有一个是它独占的,原地改就是在改别人。

  • 用冗余换零外溢:新增 V2 专用链路,老方法一行不动,其他接口行为完全不变。

  • 贴着旧行为走:一个字段的空值可能是隐形开关,重构时少碰一列都是福气。

  • 守住任务边界:顺手能修的 bug 修掉,越界的"好心修正"先记账,别夹带无人验证的改动。

至此,findList 从查全量的假分页变成了真分页,逐行查询收敛成按页批量,100 多条数据 11 秒的接口回到了亚秒级,而 findListCard 和 Openclaw 两个接口一行代码都没动。

优化老代码没有银弹,能做的就是先把这坨代码看懂,再用最小的、边界清晰的改动把问题摁死------而不是推倒重来。

敖行客介绍:

敖行客(Allthinker)聚焦服务企业研发团队及开发者,以搭载自研企业级智能体引擎的 AT Work-Agent 研发工作台为核心支撑,打造 AI 原生一体化研发协同体系,依托企业智能体重构研发协作范式,致力于赋能各类研发团队轻量化完成智能化升级。

AT Work介绍:

AT Work-Agent 研发工作台是国内首个分钟级部署、AI 原生全链路研发协同平台,依托企业级智能体赋能研发全流程,零门槛打造专属 AI 研发团队,实现研发效率与数据安全的双重飞跃。

官网:www.allthinker.com

邮箱:allthinker@allthinker.com

相关推荐
承渊政道2 小时前
从设备数据到AI洞察:时序数据的多模融合实践
数据库·人工智能·性能优化·金仓数据库·多模融合
小林ixn12 小时前
深入 React useState:从闭包陷阱到性能优化的完整指南
react.js·性能优化
jieyucx16 小时前
Nuxt4阶段五:渲染模式与性能优化
性能优化·vue·nuxt
谷无姜1 天前
为什么你的性能优化无效?可能是"木桶效应"在作祟
前端·性能优化
爱喝水的鱼丶1 天前
SAP-ABAP:ALV上线前测试 Checklist——保障报表生产环境稳定运行
性能优化·sap·abap·开发交流·交流学习
FrameNotWork2 天前
组件冻结与按需加载:HarmonyOS 6.0 性能优化的核心手段
华为·性能优化·harmonyos
AI人工智能+电脑小能手2 天前
【大白话说Java面试题 第188题】【08_Kafka篇】第4题:Kafka 大量消息积压时该如何处理?
java·性能优化·kafka·故障排查·消息积压
不是光头 强2 天前
接口性能优化报告
网络协议·性能优化·rpc
qq_401700412 天前
Qt容器性能优化:QVector、QHash、QMap到底应该怎么选?
开发语言·qt·性能优化