Junie's Blog

同一个导师列表,后台管理秒开,首页却卡顿 20 秒的排查

全文共 1902预计阅读 7 分钟

在 Auto Email Sender 里有两个极其相似的页面:

  • “导师管理页”:负责编辑导师基本信息、打标签、分组、归档;
  • “首页列表”:负责日常筛选、查看匹配度、批量生成草稿并点对点发信。

当系统里的导师数量积累到大约 5,000 人时,诡异的分水岭出现了: 在“导师管理页”点翻页,唰的一下瞬间加载完毕; 而一旦切回“首页”,浏览器直接假死,小菊花硬生生转了将近 20 秒

前端同学的第一反应很委屈:“你看,这俩页面展示的明明都是这 5,000 位导师,每页也都是 20 条,肯定是首页那一堆花里胡哨的徽章组件把 React 给卡死了!”

但当我把 Chrome DevTools 的 Network 面板拉开,看到那个耗时 18.6 秒、体积高达几十兆的 API 请求时,我意识到:冤枉 React 了,后端的 ORM 正在深水区里裸泳。

表面双胞胎,背后负重完全不同

这两个页面在 UI 上确实长得很像,但接口所背负的业务代价有天壤之别:

数据契约的隐藏差异
  • 管理页接口: 只需要静态数据(姓名、学校、职称、邮箱、标签);
  • 首页接口: 除了上述基础信息,还要实时展示复杂的动态业务流转状态——“有没有生成过草稿”、“是否已发送”、“对方有没有回信”、“回信内容是正面还是拒绝”。

为了把这些状态拼齐,旧代码在后端写了一段非常“随性”的逻辑:

# 伪代码还原当年的“豪爽”操作
professors = db.query(Professor).all() # 1. 捞出 5000 位导师
tasks = db.query(EmailTask).all()      # 2. 捞出所有邮件发送任务
logs = db.query(EmailLog).all()        # 3. 顺便把所有邮件日志全捞出来
# 4. 在内存里用 Python 遍历三层循环,拼装每位导师的最新联系状态
for prof in professors:
    prof.status = calculate_status(prof, tasks, logs)
return professors # 5. 全量返回给前端,前端本地再 slice(0, 20)
通俗比喻:只想看 20 个人的名字,却把 5000 户人家的家具全搬来了

为什么首页加载会卡整整 20 秒? 想象你想看今天报纸头条上的 20 个获奖者名字:

  • 正常分页查询:报社编辑直接把这 20 个名字写在一张便签纸上递给你,半秒钟搞定;
  • 旧代码的操作:他把全城 5000 户人家这辈子所有的账本、信件、日记本(包括几万封带排版的邮件大正文)用卡车全部拉到你的客厅,堆成一座小山。然后在客厅里戴着老花镜一页页翻,把前 20 个人的状态挑出来,最后把整座小山全塞给你。

你的客厅(浏览器网络带宽与前端内存)当场被挤爆,小菊花不转 20 秒才怪。

看到这段代码,哪怕不懂数据库的人也会倒吸一口凉气。

更要命的是,这里根本没有真正的“分页查询”。后端的所谓分页,是把 5,000 条数据连同深层对象在内存里组装好、全量序列化成 JSON 吐给前端,最后靠前端数组 slice(0, 20) 来做表面分页!

致命的“几万封邮件大正文”

导师只有 5,000 人,可每个导师在几个月里可能来回沟通了好几封邮件,邮件日志表(EmailLog)里早早积累了上万条记录。

而首页计算导师联系状态时,到底需要日志里的哪些字段?

  • professor_id(属于哪个导师)
  • direction(是发出的还是收到的)
  • created_at(什么时间发生的)
  • is_replied(状态位)

就这四列数字和布尔值,加起来连 50 个字节都不到。

但因为写了 db.query(EmailLog).all(),SQLAlchemy 这个忠心耿耿的 ORM,老老实实把每条日志里的 body_text(几千字的邮件正文)、body_html(富文本排版标签)、甚至是服务商返回的完整调试日志,统统从磁盘读进内存,并在 Python 里构建成了几万个沉重的 ORM 数据对象!

内存与磁盘的惨烈实测

我们造了 5,000 位导师 + 20,000 条真实邮件日志做对比基准:

  • ORM 默认全量对象映射: 耗时 0.88 秒(更别提 20 秒环境下的高并发和旧机器限制);
  • 字段投影(仅查所需 4 列): 耗时 0.087 秒,耗时直接暴跌 90%

磁盘 I/O 少读了几十兆的大文本,Python 解释器少实例化了几万个对象,内存压力瞬间清空。

永远警惕 SELECT *

很多性能优化教程一上来就教人加索引、加 Redis 缓存。但在真实工程里,90% 的首屏崩溃都是因为取了根本用不着的巨型文本字段。缩小 SQL 查询的投影范围(Projection),永远是收益最高、风险最小的第一刀。

为什么加索引没有起死回生?

在排查中途,有同事建议:“在 EmailLogprofessor_idcreated_at 上建个联合索引,速度不就上去了吗?”

我们做了严格的 EXPLAIN QUERY PLAN 分析,联合索引加上后,SQLite 确实省去了排序阶段的临时 B-Tree 构建,执行计划看起来优雅极了。

但回到端到端耗时,总时间几乎纹丝不动!

为什么?因为索引解决的是**“如何更快地在海里找到这 2 万条数据”,解决不了“你要把这 2 万头鲸鱼全捞上岸做鱼汤”**的物理开销!当数据传输和对象构造是绝对大头时,索引做得再好也只是杯水车薪。

前端不是主犯,但确实在“落井下石”

后端把数据瘦身之后,首屏返回从 18 秒下降到了 1.2 秒左右,但前端在渲染时依然有一股明显的卡顿感。

检查前端代码发现,管理页早早用上了 useMemo,而首页在组件内部毫无防备地裸跑计算:

  • 每次组件 re-render(比如用户弹了个 Toast 或者点了个复选框);
  • 前端就会把这 5,000 个导师遍历一遍构建下拉筛选列表;
  • 再遍历一遍执行本地过滤;
  • 再执行一次快速排序;
  • 最后重新统计选中的 Checkbox 数量。

虽然 V8 引擎执行 5,000 次纯内存循环只要几十毫秒,但当用户频繁输入搜索词或者点击勾选时,每秒几十次的重复计算和重新创建数组,就会让帧率瞬间掉到 20 帧以下。

补齐依赖精确的 useMemo,把纯计算隔绝在状态变化之外,页面的操作丝滑度才真正追上了管理页。

治理层面致命病因治本手段收益体现
后端 ORM盲目加载几万封邮件的大正文和 HTML严格字段投影(只取 4 列) + 消除重复查询首次请求耗时暴跌 90%
后端架构内存拼装假分页剔除大字段,未来平滑演进真 SQL 分页内存峰值从上百兆缩减到几兆
前端 React无记忆化裸跑 5000 节点的多轮过滤排序useMemo 缓存派生计算结果彻底消除点击交互和筛选时的掉帧卡顿

总结

面对复杂的 Web 列表性能问题,最忌讳的就是凭直觉互相甩锅:

  1. 别看表象,看数据契约: 两个看起来一模一样的表格,底层可能背负着两个数量级的不同依赖;
  2. 索引不是万能灵药: 在盲目堆索引之前,先检查你的 SQL 到底读了多少无用的大字段;
  3. 分清瓶颈的阶梯: 20 秒的问题在后端 I/O 与对象构造,100 毫秒的卡顿在前端重绘。先砍掉后端的秒级大山,再抠前端的毫秒级体验,系统才能既跑得快,又跑得稳。

评论