后台面板的开头永远一样:一张表格、四行测试数据,和一个没有 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过滤的查询用不好它们;还得补一个单列索引。
后台面板是生产出问题时团队第一个去看的地方。而它恰好是系统里扛不住生产量级的那部分——这是个昂贵的讽刺。
