Backend

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

1748 字约 6 分钟

摘要两个页面都展示 5000 位导师,管理页很快,首页却转了将近 20 秒。排查发现问题不在 React 渲染,也不是缺少数据库索引,而是 ORM 把几万封邮件的正文全部加载到了内存里。

在 Auto Email Sender 中,有两个很相似的页面:

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

当导师数量积累到约 5,000 人时,出现了一个奇怪的现象:在“导师管理页”翻页,瞬间就能加载完成;而切回“首页”,浏览器就卡住不动,加载指示器转了将近 20 秒。

前端同学的第一反应是:“这两个页面展示的都是这 5,000 位导师,每页也都是 20 条。肯定是首页那些徽章组件把 React 拖慢了。”

但我打开 Chrome DevTools 的 Network 面板,看到一个耗时 18.6 秒、体积达到几十 MB 的 API 请求。问题显然在后端:ORM 把大量不需要的数据都加载进来了。

两个页面看似相同,实际负担不同

这两个页面的界面很像,但接口需要处理的数据量差别很大:

数据需求的差异
  • 管理页接口: 只需要静态数据,如姓名、学校、职称、邮箱、标签;
  • 首页接口: 除了上述信息,还要实时显示动态的业务状态:“是否已生成草稿”“是否已发送”“对方是否回信”,以及“回信是正面还是拒绝”。

为了拼出这些状态,旧代码的后端是这样写的:

# 伪代码还原当年的写法
professors = db.query(Professor).all() # 1. 查出 5000 位导师
tasks = db.query(EmailTask).all()      # 2. 查出所有邮件发送任务
logs = db.query(EmailLog).all()        # 3. 查出所有邮件日志
# 4. 在内存里用三层循环拼出每位导师的最新联系状态
for prof in professors:
    prof.status = calculate_status(prof, tasks, logs)
return professors # 5. 全部返回给前端,由前端用 slice(0, 20) 取前 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 把每条日志的所有字段都读入内存,包括 body_text(几千字的邮件正文)、body_html(富文本标签),甚至服务商返回的完整调试日志,并在 Python 中构造出几万个 ORM 对象。

实际测试结果

我用 5,000 位导师和 20,000 条邮件日志做了对比:

  • ORM 默认的全字段映射: 耗时 0.88 秒(在生产环境的高并发和较慢的机器上,实际耗时更长);
  • 字段投影(只查需要的 4 列): 耗时 0.087 秒,耗时下降了 90%。

少读取了几十 MB 的大文本,Python 也少创建了几万个对象,内存压力明显减轻。

尽量避免 SELECT *

许多性能优化教程一上来就建议加索引或加 Redis 缓存。但在实际项目中,很多首屏慢的问题都是因为查询了不需要的大字段。缩小 SQL 查询的字段范围(Projection),通常是收益最高、风险最小的第一步。

为什么加索引没有明显效果

排查过程中,有同事建议:“在 EmailLog 的 professor_id 和 created_at 上建一个联合索引,速度不就上去了吗?”

我们用 EXPLAIN QUERY PLAN 检查了执行计划。加上索引之后,SQLite 确实不再需要为排序创建临时 B-Tree,执行计划更好了。

但端到端的耗时几乎没有变化。

原因在于,索引解决的是“如何在大量数据中更快地找到这两万条记录”,而问题在于“要把这两万条记录的完整内容全部取出来并构造成对象”。当数据传输和对象构造是主要开销时,索引的作用很有限。

前端也有问题,但不是主要原因

后端减少数据量之后,首屏返回时间从 18 秒降到了 1.2 秒左右。但前端渲染时仍有明显的卡顿。

检查代码后发现,管理页使用了 useMemo,而首页的计算都在组件内部直接执行:

  • 每次组件重新渲染(比如弹出提示或点击复选框)时;
  • 前端都会遍历一遍 5,000 位导师,生成下拉筛选列表;
  • 再遍历一遍,执行本地过滤;
  • 再做一次排序;
  • 最后重新统计选中的复选框数量。

V8 执行 5,000 次纯内存循环只需要几十毫秒。但用户频繁输入搜索词或勾选复选框时,每秒钟可能触发几十次重复计算和数组重建,帧率会降到 20 帧以下。

补上依赖正确的 useMemo,把纯计算隔离在状态变化之外之后,首页的操作才和管理页一样流畅。

层面主要问题解决方法效果
后端 ORM加载了几万封邮件的正文和 HTML严格限定字段(只取 4 列),去掉重复查询首次请求耗时下降 90%
后端架构在内存中拼出假分页去掉大字段,之后可以平滑过渡到真正的 SQL 分页内存峰值从上百 MB 降到几 MB
前端 React每次渲染都重新执行 5000 条数据的多轮过滤和排序用 useMemo 缓存派生的计算结果点击和筛选时不再掉帧

总结

遇到复杂的 Web 列表性能问题时,不要凭直觉互相推诿:

  1. 看数据需求,不要只看界面: 两个外观相似的表格,背后的数据依赖可能相差几个数量级;
  2. 索引不能解决所有问题: 在添加索引之前,先检查 SQL 读取了多少无用的大字段;
  3. 先处理瓶颈最大的部分: 20 秒的问题出在后端的 I/O 和对象构造,100 毫秒级的卡顿出在前端的重绘。先去掉后端的秒级瓶颈,再优化前端的毫秒级体验,系统才能既快又稳。

评论