首页 / 文章

在购买更大容量的数据库之前,先降低 Prisma 和 PostgreSQL 的负载。

十五种实用技巧,从EXPLAIN ANALYZE与复合索引,到N+1问题解决、计数器、连接池以及真空操作,旨在减少Prisma数据库的操作量。

发布者:REACTAPP.TOP阅读约 8 分钟1747 词

来源: I Reduced Prisma Database Operations by 60% Without Upgrading My Database

在基于 Prisma 和 PostgreSQL 的应用中,数据库往往会成为第一个真正的瓶颈。针对几百行数据、只需5毫秒即可返回结果的查询,一旦表中的数据量达到数百万行,执行时间就可能延长至数秒。人们的第一反应往往是购买更大规格的实例或更昂贵的套餐。以某款市场类应用为例,其数据库每天的操作量可达到5,000到6,000次,这样的使用量足以在一个月结束前就用完免费套餐的额度。针对这类情况,通过周末的时间进行性能分析和查询优化,即便不更换硬件,也能将操作量减少60%以上,加快API响应速度并降低费用。

以下这些技术从问题诊断、架构设计到维护保养依次排列,正是通常能带来显著改善的方法。

在做出任何更改之前先进行测量

不要直接认为某个查询速度慢,应先对其进行实际测量。有两种工具可以满足大多数需求:

  • EXPLAIN ANALYZE 可以显示 PostgreSQL 的执行计划,包括扫描的行数、是否使用了索引以及实际耗时。
  • Prisma 的查询日志会展示客户端生成的完整 SQL 语句。

对按销售商筛选的条件运行 EXPLAIN ANALYZE 的方式如下。需要注意的是 ANALYZE 会真正执行该语句,因此应将修改数据的查询放在事务中,并在需要时回滚。

EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE seller_id = 123;

即便 Prisma 的调用方式看起来很简洁,仍可能导致对整个表的顺序扫描;只有执行计划才能揭示这一点。pg_stat_statements(按总耗时对查询进行排序)、Prisma 的查询分析功能以及 Vercel 等平台提供的控制面板,都能帮助你确定首先需要修复的问题。如需系统化的解决方法,请参阅 如何找出慢速 Node.js 接口的真正瓶颈。

为实际执行的查询建立索引

索引通常是影响最大的优化措施。如果没有索引,像这样的查询可能会迫使 PostgreSQL 检查 orders 表中的每一行:

SELECT *
FROM orders
WHERE seller_id = 123;

在经过过滤的列上创建普通的 B 树索引,可以让查询计划直接跳转到匹配的行:

CREATE INDEX idx_orders_seller
ON orders(seller_id);

当应用程序经常对多个列同时进行过滤时,复合索引可以直接处理这种组合条件:

CREATE INDEX idx_orders_seller_status
ON orders(seller_id, status);

有了复合索引,针对销售商和状态的双重条件就能通过精确的索引查找来满足:

WHERE seller_id = 123
AND status = 'PAID'

在复合索引中,列的顺序很重要。(seller_id, status)有助于针对仅基于seller_id进行过滤的查询,但对仅基于status过滤的查询则没有帮助。一般原则是只为真正需要过滤、排序或连接的列创建索引,而非全部列;每个索引都会占用磁盘空间,并降低对表的每次写入速度。

消除N+1查询问题

N+1模式是ORM中常见的难题。例如,你先加载100条订单的列表:

const orders = await prisma.order.findMany();

然后在循环中逐个获取每条订单对应的用户信息:

for (const order of orders) {
  await prisma.user.findUnique({
    where: {
      id: order.userId
    }
  });
}

这样一页数据就需要101次查询。让Prisma在加载订单的同时一并加载关联数据就可以避免这种循环查询的情况。

const orders = await prisma.order.findMany({
  include: {
    user: true
  }
});

仅需一次调用即可替代循环,延迟和负载都大幅降低。根据版本和配置的不同,Prisma会将include操作作为连接查询或额外的批量查询来执行;无论哪种方式,都能避免逐行往返查询。

仅选择所需的列

减少传输的数据量是最省力的优化方式之一。普通的findMany会返回每行所有列的数据:

await prisma.user.findMany();

select语句能将查询结果限制为调用方实际需要的内容:

await prisma.user.findMany({
  select: {
    id: true,
    name: true
  }
});

当表中包含JSON数据、Markdown格式内容、图片或长文本时,这一措施就能显著缩短响应时间,同时还能避免将敏感列包含在API响应中。

用存储的计数器替代重复的聚合操作

分析仪表板往往会在每次页面加载时重新计算聚合数据:

COUNT(*)
SUM(amount)
AVG(...)

100 行时还好,但达到数百万行时就变得极其麻烦。例如,每次请求都要计算总销售额,这就意味着需要对整个表格求和:

SELECT SUM(amount)
FROM orders;

另一种方法是在写入数据时及时更新汇总信息,将总销售额等数值直接存储在用户记录中:

User
-------------
totalSales
totalOrders
totalRevenue

每当有一笔购买完成时更新这些字段,这样查询操作就能实现 O(1) 的快速查找,而无需扫描数百万行数据;在上面的场景中,这正是最大的优化效果之一。应以原子方式更新计数器(例如在创建订单的同一事务中使用 Prisma 的 increment 函数),并定期进行对账,因为非规范化的汇总数据可能会出现偏差。

为所有列表添加分页功能

表格的数据量不会一直保持较小,因此绝不能返回像这样无限制长度的列表:

await prisma.product.findMany();

使用 take 和 skip 可以获取有限数量的页面;此示例返回20页中的第2页:

await prisma.product.findMany({
  take: 20,
  skip: 20
});

对于大型数据集或无限滚动场景,建议使用游标分页:偏移量能让 PostgreSQL 跳过所有被跳过的行,而基于索引列的游标则能从上一页结束的位置直接继续读取。

缓存那些很少变化的读取操作

最快的查询其实是根本不会被执行的查询。那些变化频率较低的数据非常适合缓存:产品页面、公共商店界面、应用程序设置以及分类列表。常见的缓存方案包括 Upstash Redis、Next.js 缓存以及进程内的 LRU 缓存。将高频读取操作移出 PostgreSQL 可以大大减轻其压力;但这样做的代价是数据需要重新验证,因此需确定数据的有效期限,并在数据发生变化时及时重新验证。

使用批量写入而非循环处理

每一次等待的调用都涉及一次网络往返,这类操作很快就会累积起来。在循环中逐个创建记录就是一个典型例子:

for (const item of items) {
  await prisma.item.create({
    data: item
  });
}

createMany 会通过一条语句插入整个数组:

await prisma.item.createMany({
  data: items
});

一次往返比数百次都要高效。请注意,createMany 不会创建嵌套关系,且返回的是记录数而非实际行数;如果需要获取这些记录,请查阅你所使用的 Prisma 版本的文档。

在事务中批量处理相关操作

创建订单通常还涉及其他操作,比如调整库存、更新余额以及生成收据。这些变更属于同一组操作,$transaction 可确保它们要么全部成功,要么全部回滚:

await prisma.$transaction([
  prisma.order.create(...),
  prisma.user.update(...),
  prisma.balance.update(...)
]);

真正的优势在于一致性而非速度:如果操作中途出错,就必须更新平衡状态。应保持事务长度较短,因为事务会占用锁资源及连接资源。

重用同一个 Prisma Client

每次请求都创建新的 PrismaClient 会耗费大量资源,还可能导致连接耗尽;在开发环境中,热重载功能甚至会在每次文件更改时都生成新的客户端。使用单例模式可以避免这些问题。首先导入该客户端:

import { PrismaClient } from "@prisma/client";

然后在非生产环境中将实例缓存到 globalThis 上,这样重新加载的模块就能使用现有的客户端,而无需再创建新的。

const globalForPrisma =
  globalThis as unknown as {
    prisma: PrismaClient | undefined;
  };export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient();if (process.env.NODE_ENV !== "production")
  globalForPrisma.prisma = prisma;

通过 as unknown as 类型转换,可以让 globalThis 拥有带类型的 prisma 属性;在生产环境中,模块只需加载一次,因此无需全局缓存。

合理管理连接池,尤其是在无服务器环境中

无服务器平台可以启动数千个并发函数实例,每个实例都需要独立的数据库连接。如果没有连接池机制,早在CPU成为问题之前,PostgreSQL的可用连接数就已经耗尽。Prisma Accelerate和PgBouncer都位于应用程序与数据库之间,使得大量请求能够共享更少数量的真实PostgreSQL连接。

为筛选用到的JSONB字段创建索引

PostgreSQL的JSONB类型功能强大,但如果没有索引就对嵌套字段进行筛选,通常需要进行全表扫描。如果你经常查询JSON文档中的数据,应在该列上创建GIN索引(或为经常筛选的特定键创建表达式索引),从而将缓慢的文档搜索转变为基于索引的查询。

唯一值查找优先使用findUnique

当通过主键或其他唯一字段进行查询时,可使用以下方法:

findUnique()

这告诉 Prisma 查找目标仅为一行,从而使 Prisma 能将同一时刻的多个 findUnique 调用合并为一个查询,这在 GraphQL 解析器中非常有用。例如:

await prisma.user.findUnique({
  where: {
    id
  }
});

当字段为唯一值时,应使用此方法而非以下方式:

findFirst()

对于非唯一值的过滤条件,findFirst 仍是合适的选择,因为此时你实际上需要的是“第一行匹配结果”。

将旧记录归档而非永久软删除

软删除很方便,只需为该行标记特殊状态即可:

isDeleted = true

然而随着时间推移,每个查询都需过滤掉已删除的记录,否则表格和索引会变得臃肿。在数据量较大的系统中,应定期将已删除的记录移至归档表中:更小的表格意味着更小的索引和更快的查询速度。针对未删除记录创建部分索引则是一种折中方案。

保持自动清理功能正常运行

PostgreSQL采用MVCC(多版本并发控制)机制,因此被更新或删除的行不会立即被移除,而是以“死元组”的形式存在。自动清理功能就是负责清除这些死元组的后台进程。合理配置的自动清理功能可以:

  • 释放被死元组占用的空间;
  • 更新查询规划器的相关统计信息;
  • 保持索引的高效性;
  • 确保查询计划的准确性。

若忽视这一功能,会导致数据库体积膨胀以及查询计划质量下降,尤其是在像上述计数表这类频繁被更新的表上。

关键要点

大多数数据库性能问题并非通过升级服务器就能解决,而是需要改进数据访问方式。在上述案例中,服务器提供商和数据库规模均未改变,性能提升得益于:

  • 对高耗资源查询进行性能分析而非凭猜测处理;
  • 添加与实际过滤器匹配的索引;
  • 消除 N+1 循环及不必要的列;
  • 对重复读取进行缓存;
  • 用维护好的计数器替代成本较高的聚合操作;
  • 对每个列表设置限制并实现分页功能。
  • 这些措施共同使得操作次数减少了 60% 以上,API 响应速度更快,同时还能承受更高的访问量。在扩大规模之前,先了解数据库的实际使用情况;现有的查询往往掩盖了影响性能的关键因素。