02性能5 min

后台列表是最先垮掉的那一块

内部面板写的时候没有任何上限,因为一开始只有四行数据。而当产品长大,恰恰就是这里全线炸掉。

作者 Salva Castells

封面图: Marijovanni, CC BY-SA 4.0.
本文目录

后台面板的开头永远一样:一张表格、四行测试数据,和一个没有 where 的 select。它能跑。而正因为它能跑,没人再看它第二眼。与此同时,用户表从四行长到四万行,那个没人监控的内部页面变成了整个产品最慢的一页。

这件事我在四个不同项目里修过,原因总是同样那三个。

后台写得潦草,因为没人看

公开页面从第一天就有分页。有人 review 它,有人度量它,慢了还有 Core Web Vitals 指标在叫。后台什么都没有。用它的只有两个人,而这两个人默认它慢是因为"它是后台嘛"。

在我维护的一个转录 API 里,server action getAdminUsers 和 getAdminFeedback 接收 limit 参数并直接传给查询。没有上限。几个月没人碰过,因为从 UI 传进来的永远是 10。问题不在 UI:而在于 server action 就是一个 endpoint。任何有管理员会话的人,或者任何脚本,都可以请求 limit=1000000,把整张表灌进 worker 的内存里。

修正只有三行,在一批安全整改里:

export const ADMIN_LIST_MAX_LIMIT = 1000;

const cappedLimit = Math.min(Math.max(limit, 1), ADMIN_LIST_MAX_LIMIT);

Math.max(limit, 1) 不是多余的疑神疑鬼:负数或零的 limit 在不同驱动下也会干出奇怪的事。关键是这个上限住在哪里。不是组件里,不是 hook 里:是在接收请求的那一层。它上面的一切都只是建议。

那个看起来免费、其实不是的 count

经典分页需要知道总共有几页,为此就得来一条 SELECT COUNT(*)。你想都不想就写下去,因为脑子里 count 很便宜。它不便宜:在 Postgres 里,精确的 count 会扫完整张表或整个索引,没有办法让它不扫。

在一个内部文档管理系统里,用户后台每次加载页面会并行做三件事:取当前页的 profile、取所有公司用于下拉框、以及统计 profile 总数。而 pageSize 是 5。也就是说,为了画出"第 3 页,共 47 页",它扫了整张表。

我去掉了 count,改了分页的契约:

setHasMore(profilesData.length === pageSize);

失去的是"共 47 页"。得到的是查询永远与页大小成正比。下一页按钮现在依赖 hasMore 而不是 currentPage < totalPages,显示的范围用手上已有的数据算出来,不再多问一句:

const endIndex = startIndex + companies.length - 1;

没人抱怨看不到总数。在一个内部面板里,这个数字决定不了任何事情。如果你真的需要它,pg_class 的 reltuples 能零成本给你一个估计值;要说"大概有 4 万用户",绰绰有余。

只取元数据,而不是整个对象

第三种模式更隐蔽。一个文件列表在文件还没有内容时一切正常。在一个协作笔记工具里,侧边栏调用 getProjectTextFiles,它做的是一个没有投影的 select:也就是说,把项目里每个文件的 content 列也一起取了。就为了画一列名字。

解决办法很无聊,而且有效:第二个函数,显式选出列表需要的那些列。

return await db
  .select({
    id: textFiles.id,
    projectId: textFiles.projectId,
    name: textFiles.name,
    isTemp: textFiles.isTemp,
    deletedAt: textFiles.deletedAt,
    createdAt: textFiles.createdAt,
    updatedAt: textFiles.updatedAt,
  })
  .from(textFiles)
  .where(and(eq(textFiles.projectId, projectId), isNull(textFiles.deletedAt)));

内容在选中某个文件时才请求,不提前。而这时会冒出一个只有做过才看得见的细节:如果用户快速点了两个文件,第一个的慢响应可能会盖掉第二个。写入 store 之前必须检查这个文件仍然是当前选中的那个:

if (fullFile && useAppStore.getState().selectedFileId === fileId) {
  useAppStore.getState().setSelectedFile(fullFile);
}

没有这个检查,你就是把一个性能问题换成了一个竞态 bug,而后者更糟,因为它没法随意复现。

只在后台面板出现的那个 bug

关于后台为什么值得认真对待,我见过最好的例子来自一个用 unstable_cache 缓存的分析看板。本地能跑,生产环境第一次访问也能跑。之后就挂在 Invalid time value 上。

unstable_cache 把 payload 序列化成 JSON。冷执行时写入的 Date,在所有缓存读取里都会变成 string。而 Intl.DateTimeFormat.format 不接受字符串。类型在撒谎:lastIngestedAt: Date | null 实际上是 Date | string | null。TypeScript 看不见,因为序列化边界是没有类型的。

我的修法是把两种形态都归一化,并把无法呈现的值当作缺失,而不是当作异常:

const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : formatter.format(date);

一个画不出来的日期数据应该显示成"无数据",而不是把整页拖垮。在一个看板里,一个坏掉的指标不能把另外十二个一起带走。

我今天对任何内部列表的做法

  • 上限放在服务端那一层。 从外部传进来的 limit 在入口处被夹紧,用一个有名字的常量。UI 不是校验。
  • 默认不做 count。 用 hasMore 分页。如果有人要总数,给他一个估计值,而且只在他要的时候给。
  • 显式投影。 列表选列,绝不 *。大字段在打开详情时再取。
  • 在你用来过滤的列上建索引。 在一个打卡应用里有 (user_id, date) 的复合索引,但只按 user_id 过滤的查询用不好它们;还得补一个单列索引。

后台面板是生产出问题时团队第一个去看的地方。而它恰好是系统里扛不住生产量级的那部分——这是个昂贵的讽刺。