性能优化:N+1、索引策略与读副本
本教程共 54 篇 · 第 40 篇 · 更新于 2026-08-11 · 约 7 分钟阅读
本节目标:识别并消灭 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 } });
}
四种修法,按场景选择:
- include 预加载。一次取用户,再一次按 id IN 批量取文章,共 2 次查询。最通用。
- in 过滤。先取用户 id 列表,再用
authorId in 列表查文章,也是 2 次。适合已经在内存里拿到 id 列表的场景。 - relationLoadStrategy: “join”。让数据库 JOIN,只发 1 次查询。预览功能(Preview)。
- 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 } },
});
Tipinclude 里可以嵌套 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