《上线前安全检查清单》中,我详细介绍了在向真实用户开放之前需要加固的内容。安全是及格/不及格的门槛:要么完成工作,要么暴露风险。性能则不同。如果仪表盘加载需要四秒,并没有「故障」,只是悄无声息地流失用户,而没有人会为此提交工单。

这是全栈 SaaS 大师班的一部分,这是一个「真实构建」系列,从《为 SaaS 选择合适的技术栈》开始,伴随一个应用从空文件夹走向生产环境。性能调优是本模块最后一次停靠站,紧接于结束前的就绪检查清单。

我想先说清楚角度。上线前的绝大多数 SaaS 产品只有少数真正慢的路径,以及一大堆暂时无关紧要的东西。上线前的工作就是找出那少数路径并修复它,同时抵制凭猜测去优化其余部分。挤出每一毫秒可以等以后再做。

先测量,再动手

我在上线前看到的最大性能错误,就是凭直觉而非数据进行优化。工程师认定 ORM 慢、前端 bundle 太大、或 Redis 需要缓存一切,于是花一周时间去优化。有时他们是对的,但更常见的情况是真正的瓶颈在于一张没人想到要检查的表缺少索引。

在修改代码前,先为关键用户流程获取三组数据:服务器响应时间、数据库查询时间,以及前端首次有意义绘制时间。上线阶段你不需要复杂的 APM。带 duration 字段的结构化请求日志,配合 PostgreSQL 的 pg_stat_statements,就能告诉你几乎所有信息。

-- 启用一次,之后随时查询即可找出最慢的查询
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

SELECT
  calls,
  round(mean_exec_time::numeric, 2) AS avg_ms,
  round(total_exec_time::numeric, 2) AS total_ms,
  query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

Enter fullscreen mode Exit fullscreen mode

这一视图对上线前的性能调优的作用,远超任何推测性优化。它按总耗时排序查询,既能找出真正慢的查询,也能找出虽然单次很快但每小时执行上万次的查询。按这个顺序修复。

数据库几乎总是第一个瓶颈

对于典型的 Postgres 后端 SaaS,响应时间真正消耗的地方就在数据库。两种模式占了绝大多数:N+1 查询和外键或过滤列上缺少索引。

N+1 查询更多是通过 ORM 而非原生 SQL 潜入,因为抽象层隐藏了循环。下面是多租户 NestJS 应用中使用 TypeORM 或 Prisma 时经常出现的模式:

// 差:为每个组织分别查询其所有者
async function getOrganizationsWithOwners(orgIds: string[]) {
  const orgs = await this.orgRepository.findByIds(orgIds);

  return Promise.all(
    orgs.map(async (org) => {
      const owner = await this.userRepository.findOne({ where: { id: org.ownerId } });
      return { ...org, owner };
    }),
  );
}

Enter fullscreen mode Exit fullscreen mode

// 更好:一次查询,使用 join 或关联加载
async function getOrganizationsWithOwners(orgIds: string[]) {
  return this.orgRepository.find({
    where: { id: In(orgIds) },
    relations: { owner: true },
  });
}

Enter fullscreen mode Exit fullscreen mode

修复方案通常并不巧妙,通常只是「加载你已经知道需要的关系,而不是循环中再去抓取」。之所以值得在上线前专门检查,是因为 N+1 模式在十行数据时看不出来,在一万行时就很痛苦。你的测试环境数据几乎永远不够多,无法暴露这个问题,这就是为什么它总是在第一批真实客户导入数据后才咬到团队。

索引是第二个杠杆,经验法则很简单:为所有用于过滤或 join 的外键,以及在行数将超过几千的表上用于 WHERE 过滤的列创建索引。对于多租户模式,这几乎总是包含 tenant_idorganization_id,因为应用中几乎所有查询都会按它过滤。

-- 多租户表中最常见查询模式的复合索引
CREATE INDEX idx_invoices_org_created_at
  ON invoices (organization_id, created_at DESC);

Enter fullscreen mode Exit fullscreen mode

在上线前对 pg_stat_statements 列表中的前五个查询执行 EXPLAIN ANALYZE。如果在一张即将承载真实生产数据的表上出现顺序扫描,这就是最明确的信号。

连接池也值得一提。它更多表现为上线周的故障模式,而非慢查询。每条 Postgres 连接都会消耗服务器真实内存,当 NestJS 应用在负载下每个请求都无限制地打开新连接时,会很快耗尽默认连接池。在依赖默认值之前,显式设置一个合理且明确的池大小(TypeORM 和 Prisma 都提供此选项)。当后端实例超过一个时,在 Postgres 前部署 PgBouncer,让连接被共享而不是按进程倍增。

为有理由而非习惯添加缓存

Redis 之所以能进入技术栈,正是因为这种场景:一个昂贵、频繁读取且不需要绝对新鲜的查询。这就是需要寻找的特征。对每次写入都变化的数据,或很少有人读取的数据进行缓存,只会增加失效复杂度,却没有真正收益。

cache-aside 模式覆盖了上线前绝大多数缓存需求,且在压力下也足够容易理解:

async function getDashboardSummary(orgId: string): Promise<DashboardSummary> {
  const cacheKey = `dashboard:summary:${orgId}`;
  const cached = await this.redis.get(cacheKey);

  if (cached) {
    return JSON.parse(cached) as DashboardSummary;
  }

  const summary = await this.computeDashboardSummary(orgId);

  await this.redis.set(cacheKey, JSON.stringify(summary), 'EX', 60);

  return summary;
}

Enter fullscreen mode Exit fullscreen mode

一个较短的 TTL(本例为 60 秒)就能获得大部分收益,而无需制定对正确性至关重要的失效策略。如果底层数据发生变化,而仪表盘有一分钟的延迟,对于摘要视图来说是一个可以接受的权衡。但对于账户余额来说,这就不可接受了。

值得直接指出的陷阱:缓存不是修复慢查询或缺失索引的替代方案。我见过团队缓存执行未索引扫描的查询,这会在缓存命中时掩盖问题,但在每次缓存未命中和冷启动后完全暴露。应该先修复查询,再考虑缓存。

前端性能主要取决于你发送了什么,而不是它运行得多快

在 Next.js 侧,上线前最大的收益几乎总是减少发送到浏览器的数据量,而不是微优化渲染性能。一个为仅含三个小部件的页面发送 400KB JavaScript 的仪表盘,无论 React 组件写得多好,在中端笔记本上都会感觉慢,在移动设备上则会非常糟糕。

上线前 consistently 重要的几项检查:

  • 默认使用 Server Components。 如果你使用 App Router,请尽量保持交互式客户端组件小且位置靠下。一个页面不需要仅因为一个按钮需要 onClick 就在顶部添加 'use client'
  • 对任何沉重且非关键的内容使用动态导入。 图表库、富文本编辑器和 PDF 生成器是常见罪魁祸首。按需加载它们,而不是放在初始 bundle 中。
  • 对任何面向用户的内容使用 next/image 它默认处理响应式尺寸和懒加载,覆盖了营销页和仪表盘页的大部分感知加载时间。
  • 在数据允许时使用静态生成或 ISR。 营销页、定价页和公开文档几乎不需要每次请求都进行服务端渲染。带重新验证的静态生成可以从关键路径中移除整个请求-响应周期。
// 延迟加载沉重且非关键的组件,而不是提前打包
import dynamic from 'next/dynamic';

const BillingChart = dynamic(() => import('./billing-chart'), {
  loading: () => <ChartSkeleton />,
  ssr: false,
});

Enter fullscreen mode Exit fullscreen mode

以上都不需要性能专家,只需要有人真正打开真实用户最常访问页面的网络标签——对于 SaaS 来说,通常是登录流程、主仪表盘,以及驱动核心价值主张的页面。

在真实用户之前进行负载测试

上线前是唯一可以安全找到系统瓶颈而不会被真实客户发现的时候。对测试环境进行基础负载测试,针对两到三个最重要的端点,可以告诉你系统会在哪里崩溃,以及这个上限是否明显高于你预期的上线流量。

# 使用 autocannon 对测试端点进行快速负载测试
npx autocannon -c 50 -d 30 -m POST \
  -H "Content-Type: application/json" \
  -b '{"email":"[email protected]","password":"test1234"}' \
  https://staging.example.com/api/auth/login

Enter fullscreen mode Exit fullscreen mode

关注结果的形状,而不是原始的每秒请求数。这个数字完全取决于你的基础设施,我不会在这里编造一个。延迟是否会随着并发增加保持大致平稳,还是在某个点突然断崖式下跌?断崖会精确告诉你连接池、速率限制器或事件循环在哪里耗尽了余量。在负载测试中发现这一点,远比在第一次流量高峰中发现要便宜得多。

关键要点

  • 先测量再优化。pg_stat_statements 和基础请求计时能找到真正的瓶颈;猜测通常会找到错误的点。
  • N+1 查询和缺失索引是典型 SaaS 中数据库慢的主要原因,两者在真实数据量暴露之前都不可见。
  • 显式设置连接池大小,当后端实例超过一个时,在 Postgres 前部署 PgBouncer。
  • 对昂贵、频繁读取、能容忍短暂陈旧的数据使用 Redis 缓存。先修复底层查询,再缓存。
  • 在前台,减少发送内容(动态导入、Server Components、next/image、静态生成)通常优于微优化渲染代码。
  • 在上线前对测试环境进行基础负载测试,以便在自己的条件下找到上限。

常见问题

如何在上线前判断 Postgres 查询是否慢?
启用 pg_stat_statements 并按总执行时间而非单次平均时间查询。一个运行频繁的较快查询,总体成本可能高于一个罕见的慢查询。对前几名结果执行 EXPLAIN ANALYZE,确认执行计划是否在可以使用索引的地方进行了顺序扫描。

NestJS 应用中 Postgres 的连接池大小应该设为多少?
没有通用数字,因为它取决于数据库的 max_connections 设置以及你将运行的应用实例数量。从保守值开始,显式设置池大小而非依赖 ORM 默认值,当运行超过一个后端实例时,添加 PgBouncer 的事务模式,使连接被共享而非按进程倍增。

即使现在不需要,是否也应该在上线前添加 Redis 缓存?
不。只有当你发现了一个昂贵、频繁读取且能容忍短时间陈旧的查询时才添加。在有明确信号之前提前缓存,只会增加失效复杂度而没有实测收益,还可能掩盖你仍需修复的查询问题。

上线前实际需要做多少前端性能工作?
专注于发送到浏览器的内容:保持客户端组件小,对沉重且非关键的小部件使用动态导入,使用 next/image,并对不需要每次请求数据的页面进行静态生成。这通常比微优化组件渲染性能更重要。

在没有专门性能团队的情况下,如何在上线前进行负载测试?
使用 autocannon 或 k6 等工具,指向测试环境中两到三个最高流量的端点,足以找出随着并发增加延迟开始上升的位置。你需要关注曲线的形状——是逐渐上升还是断崖式下跌,而不是要达到的具体数值。

如果某些页面在上线时仍然较慢,是否是问题?
不一定。上线前的目标是修复真实用户频繁访问的流程,如登录和主仪表盘。一个很少使用的设置页面加载需要 1.5 秒,是合理的权衡,否则你会在上线周追逐它而不是发布产品。

延伸阅读


关于作者

你好,我是 Aman Singh —— 专注于可扩展 SaaS 产品、分布式系统、云架构和 AI 驱动应用的高级全栈工程师。

我撰写关于系统设计、全栈工程、分布式系统、Redis、PostgreSQL、AWS、Node.js 和 NestJS 的内容。