本期敖行客研发实战日记,又迎来了新的挑战,这次的活儿听起来轻巧:一个网盘的文件列表接口 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 研发团队,实现研发效率与数据安全的双重飞跃。