首页 / Prisma ORM 入门教程 / 性能优化:N+1、索引策略与读副本

Prisma ORM 入门教程

性能优化:N+1、索引策略与读副本

本教程共 54 篇 · 第 40 篇 · 更新于 2026-08-11 · 约 7 分钟阅读

N+1性能优化索引读取副本createMany缓存Query InsightsrelationLoadStrategy

本节目标:识别并消灭 N+1 查询,学会用索引、批量操作和读取副本把查询提速。

慢查询的常见原因就几类:取太多数据、缺索引、重复查询、全表扫描。本节逐个给解法。

N+1 问题:循环里发查询

N+1 指先查一次拿到 N 条记录,再在循环里为每条记录各发一次查询,总共 1+N 次。用户列表 100 人,就要查 101 次数据库:

// 坏:1 + N 次查询
const users = await prisma.user.findMany();
for (const user of users) {
  const posts = await prisma.post.findMany({ where: { authorId: user.id } });
}

四种修法,按场景选择:

  1. include 预加载。一次取用户,再一次按 id IN 批量取文章,共 2 次查询。最通用。
  2. in 过滤。先取用户 id 列表,再用 authorId in 列表 查文章,也是 2 次。适合已经在内存里拿到 id 列表的场景。
  3. relationLoadStrategy: “join”。让数据库 JOIN,只发 1 次查询。预览功能(Preview)。
  4. Fluent API。GraphQL 场景每个字段一个 resolver,改用 findUnique().posts() 链式调用,dataloader 会把同一 tick 里的 findUnique 自动批量合并成一条 IN 查询。

为什么 include 是 2 次而不是 1 次?Prisma 的嵌套读取会先查主表,再按主表 id 批量查关系表。两条 SQL 都走索引,比 N+1 高效得多。除非用 join 策略,否则不要追求单条 SQL。

// 修法一:include
const usersWithPosts = await prisma.user.findMany({
  include: { posts: { where: { published: true }, take: 10 } },
});

// 修法二:in 过滤
const userIds = (await prisma.user.findMany({ select: { id: true } })).map((u) => u.id);
const posts = await prisma.post.findMany({
  where: { authorId: { in: userIds } },
});

// 修法三:JOIN
const posts = await prisma.post.findMany({
  relationLoadStrategy: "join",
  where: { authorId: { in: userIds } },
});
Tip

include 里可以嵌套 select 同时瘦身:只取要用的字段,别把大文本字段拖回来。

include 的深度也要克制。嵌套层级过深时,一次查询拉回整棵对象树,序列化和内存开销都不小;多层浅查询往往更快。子表行数多时,在 include 里加 take 限制条数,避免一次拉回上万行。

select 瘦身与批量写入

不写 select 时,默认返回模型全部列。大表上每多一列都是网络和内存开销:

const userSummaries = await prisma.user.findMany({
  select: {
    id: true,
    name: true,
    email: true,
    _count: { select: { posts: true } },
  },
});

批量写入同理。循环里逐条 create 最慢;createMany 把多条合成一条多值 INSERT,比串行 create 快约 40 倍(社区基准,来自 tech-insider 教程,实际提升视环境而定):

await prisma.user.createMany({
  data: users,
  skipDuplicates: true,
});

多条独立查询也可以放进 $transaction([...]) 数组里批量执行,一次往返完成,适合互相无依赖的读操作:

const [users, posts] = await prisma.$transaction([
  prisma.user.findMany(),
  prisma.post.findMany(),
]);
Note

批量写入单条 INSERT 最多能带的行数有限(受数据库参数上限约束),几万行建议分批提交,每批 1000 行左右。

索引策略

索引是查询提速的根本。过滤条件和排序字段建索引,复合索引按左前缀匹配:

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  authorId  Int
  published Boolean

  @@index([authorId, published]) // 覆盖"按作者查已发布文章"
}

没有这条索引时,数据量越大越可能全表扫描。三个要点:

  • 复合索引字段顺序重要,最常用的等值条件放前面。
  • 覆盖高频过滤条件的部分索引(partial index)更省空间。
  • 没人用的索引拖慢写入,定期清理。

查询要能命中索引,还要看排序。orderBy 的字段最好和索引一致,比如 publishedAt desc 配合 @@index([publishedAt(sort: Desc)]),分页和排序都保持在对数级。

Tip

索引不是越多越好。每个索引都占用磁盘、拖慢写入。先看 Query Insights 或数据库慢查询日志,确认哪些查询在走全表扫描,再针对性建索引。

缓存分层

缓存是兜底手段,顺序一般是:应用内缓存(Redis/内存)→ 数据库前缓存(Accelerate)→ 查询本身优化。v7.4 起客户端内置查询计划缓存(透明优化,LRU 复用编译后的查询计划,可用 queryPlanCacheMaxSize 调节);带 ttl/swr 的 cacheStrategy 响应缓存是 Accelerate 专属,需要 $extends(withAccelerate()) 接入加速通道(详见第 50 章)。查询加 cacheStrategy 即可按 TTL 缓存,swr 模式让过期数据先返回、后台再刷新:

const posts = await prisma.post.findMany({
  where: { published: true },
  cacheStrategy: { ttl: 60, swr: 600 },
});
Note

缓存只在读多写少的场景划算。写完立刻要读到自己写的数据时,别缓存或主动失效。

应用层缓存自己控制,最常用 Redis:查询结果序列化后存一份,带过期时间。注意缓存键要包含查询条件,否则不同参数的用户拿到同一份数据。缓存分层的好处是逐层兜底:Redis 命中就不用碰数据库,数据库压力下来了,连接池和索引的压力也一起下来。

读取副本:主写从读

读多写少的应用可以把读流量分流到只读副本(read replicas)。官方扩展 @prisma/extension-read-replicas(v7.1 起支持)负责路由:读操作自动走副本,写操作和事务走主库:

import { readReplicas } from "@prisma/extension-read-replicas";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client";

const mainClient = new PrismaClient({
  adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
const replicaClient = new PrismaClient({
  adapter: new PrismaPg({ connectionString: process.env.REPLICA_URL }),
});

export const prisma = mainClient.$extends(readReplicas({ replicas: [replicaClient] }));

// 读走副本
await prisma.post.findMany();
// 写走主库
await prisma.post.create({ data: { title: "hello" } });

配置多个副本时随机挑一个执行。想强制走主库用 $primary(),强制走副本用 $replica()

读取副本的收益要算清楚:副本数量越多,能扛的读流量越大,但每多一个副本就多一份存储和同步开销。小流量阶段用不上,等读 QPS 明显压过主库再上。

Note

副本有复制延迟,读到的可能是旧数据。对一致性敏感的操作,要么走主库,要么接受最终一致。

用 Query Insights 找慢查询

排查性能问题先定位慢查询。Prisma Postgres 自带 Query Insights,能看到哪些查询慢、为什么慢。想让 ORM 调用(模型名、操作类型)出现在 Insights 里,装 @prisma/sqlcommenter-query-insights 并传给构造函数:

import { prismaQueryInsights } from "@prisma/sqlcommenter-query-insights";

const prisma = new PrismaClient({
  adapter,
  comments: [prismaQueryInsights()],
});
Tip

本地先用 query 日志看单条耗时,线上用 Query Insights 聚合分析,两头结合定位最快。

综合起来,优化的顺序是:先看日志和 Query Insights 找出慢在哪,再按「N+1 → 字段瘦身 → 索引 → 缓存 → 读取副本」逐层处理。每改一处,用日志里的 duration 对比前后耗时,确认收益再继续。

参考来源

  • Prisma 官方文档:Query optimization / Read replicas
  • Mapagam:Optimizing Prisma Performance
  • Tech Insider:Prisma ORM Tutorial: Build a Type-Safe API in 13 Steps
  • HireNodeJS:Prisma ORM for Node.js: The Complete Production Guide 2026