convex-performance-patterns
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseConvex Performance Patterns
Convex性能优化模式
Overview
概述
Convex is designed for performance, but requires specific patterns to achieve optimal results. This skill covers denormalization strategies, index design, avoiding common performance pitfalls, and handling concurrency with OCC (Optimistic Concurrency Control).
Convex专为性能设计,但需要特定模式才能达到最佳效果。本指南涵盖反规范化策略、索引设计、避免常见性能陷阱以及使用OCC(乐观并发控制)处理并发问题。
TypeScript: NEVER Use any
Type
anyTypeScript:切勿使用any
类型
anyCRITICAL RULE: This codebase has enabled. Using will cause build failures.
@typescript-eslint/no-explicit-anyany重要规则: 本代码库已启用规则。使用会导致构建失败。
@typescript-eslint/no-explicit-anyanyWhen to Use This Skill
适用场景
Use this skill when:
- Queries are running slowly or causing too many re-renders
- Designing indexes for efficient data access
- Avoiding N+1 query patterns
- Handling high-contention writes (OCC errors)
- Denormalizing data to improve read performance
- Optimizing reactive queries
- Working with counters or aggregations
在以下场景中使用本指南:
- 查询运行缓慢或导致过多重渲染
- 设计索引以实现高效数据访问
- 避免N+1查询模式
- 处理高竞争写入(OCC错误)
- 反规范化数据以提升读取性能
- 优化响应式查询
- 处理计数器或聚合操作
Core Performance Principles
核心性能原则
Principle 1: Queries Should Be O(log n), Not O(n)
原则1:查询应保持O(log n)复杂度,而非O(n)
Convex queries should use indexes for efficient data retrieval. If you're scanning entire tables, you're doing it wrong.
Convex查询应使用索引实现高效数据检索。如果您正在扫描整个表,说明方式有误。
Principle 2: Denormalize Aggressively
原则2:积极进行反规范化
Convex has no joins. Embed related data or maintain lookup tables.
Convex不支持联表查询。应嵌入相关数据或维护查找表。
Principle 3: Minimize Document Reads
原则3:减少文档读取次数
Each document read in a query creates a dependency. Fewer reads = fewer re-renders.
查询中的每次文档读取都会创建一个依赖关系。读取次数越少,重渲染次数就越少。
Principle 4: Avoid Hot Spots
原则4:避免热点
Single documents that are frequently written will cause OCC conflicts.
频繁写入的单个文档会导致OCC冲突。
Denormalization Patterns
反规范化模式
Pattern 1: Embed Related Data
模式1:嵌入相关数据
❌ BAD: N+1 queries
typescript
export const getTeamWithMembers = query({
args: { teamId: v.id("teams") },
returns: v.null(),
handler: async (ctx, args) => {
const team = await ctx.db.get(args.teamId);
if (!team) return null;
// ❌ This triggers N additional reads, each causing re-renders
const members = await Promise.all(
team.memberIds.map((id) => ctx.db.get(id))
);
return { team, members };
},
});✅ GOOD: Denormalize member info into team
typescript
// Schema: teams.members: v.array(v.object({ userId: v.id("users"), name: v.string(), avatar: v.string() }))
export const getTeamWithMembers = query({
args: { teamId: v.id("teams") },
returns: v.union(
v.object({
_id: v.id("teams"),
_creationTime: v.number(),
name: v.string(),
members: v.array(
v.object({
userId: v.id("users"),
name: v.string(),
avatar: v.string(),
})
),
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.get(args.teamId); // Single read, includes members
},
});❌ 错误示例:N+1查询
typescript
export const getTeamWithMembers = query({
args: { teamId: v.id("teams") },
returns: v.null(),
handler: async (ctx, args) => {
const team = await ctx.db.get(args.teamId);
if (!team) return null;
// ❌ 这会触发N次额外读取,每次都会导致重渲染
const members = await Promise.all(
team.memberIds.map((id) => ctx.db.get(id))
);
return { team, members };
},
});✅ 正确示例:将成员信息反规范化到团队文档中
typescript
// Schema: teams.members: v.array(v.object({ userId: v.id("users"), name: v.string(), avatar: v.string() }))
export const getTeamWithMembers = query({
args: { teamId: v.id("teams") },
returns: v.union(
v.object({
_id: v.id("teams"),
_creationTime: v.number(),
name: v.string(),
members: v.array(
v.object({
userId: v.id("users"),
name: v.string(),
avatar: v.string(),
})
),
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.get(args.teamId); // 单次读取,包含成员信息
},
});Pattern 2: Denormalized Counts
模式2:反规范化计数
Never just to count.
.collect()❌ BAD: Unbounded read
typescript
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.collect();
const count = messages.length;✅ GOOD: Show "99+" pattern
typescript
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.take(100);
const count = messages.length === 100 ? "99+" : String(messages.length);✅ BEST: Denormalized counter table
typescript
// Maintain a separate "channelStats" table with messageCount field
// Update it in the same mutation that inserts messages
export const getMessageCount = query({
args: { channelId: v.id("channels") },
returns: v.number(),
handler: async (ctx, args) => {
const stats = await ctx.db
.query("channelStats")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.unique();
return stats?.messageCount ?? 0;
},
});
export const addMessage = mutation({
args: { channelId: v.id("channels"), content: v.string() },
returns: v.id("messages"),
handler: async (ctx, args) => {
const messageId = await ctx.db.insert("messages", {
channelId: args.channelId,
content: args.content,
});
// Update denormalized count
const stats = await ctx.db
.query("channelStats")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.unique();
if (stats) {
await ctx.db.patch(stats._id, { messageCount: stats.messageCount + 1 });
} else {
await ctx.db.insert("channelStats", {
channelId: args.channelId,
messageCount: 1,
});
}
return messageId;
},
});切勿仅为了计数而使用。
.collect()❌ 错误示例:无限制读取
typescript
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.collect();
const count = messages.length;✅ 推荐示例:"99+"模式
typescript
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.take(100);
const count = messages.length === 100 ? "99+" : String(messages.length);✅ 最佳示例:反规范化计数字段
typescript
// 维护一个单独的"channelStats"表,包含messageCount字段
// 在插入消息的同一个mutation中更新该字段
export const getMessageCount = query({
args: { channelId: v.id("channels") },
returns: v.number(),
handler: async (ctx, args) => {
const stats = await ctx.db
.query("channelStats")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.unique();
return stats?.messageCount ?? 0;
},
});
export const addMessage = mutation({
args: { channelId: v.id("channels"), content: v.string() },
returns: v.id("messages"),
handler: async (ctx, args) => {
const messageId = await ctx.db.insert("messages", {
channelId: args.channelId,
content: args.content,
});
// 更新反规范化的计数字段
const stats = await ctx.db
.query("channelStats")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.unique();
if (stats) {
await ctx.db.patch(stats._id, { messageCount: stats.messageCount + 1 });
} else {
await ctx.db.insert("channelStats", {
channelId: args.channelId,
messageCount: 1,
});
}
return messageId;
},
});Pattern 3: Denormalized Boolean Fields
模式3:反规范化布尔字段
When you need to filter by computed conditions, denormalize the result:
typescript
// Schema
export default defineSchema({
posts: defineTable({
body: v.string(),
tags: v.array(v.string()),
// Denormalized: computed on write
isImportant: v.boolean(),
}).index("by_important", ["isImportant"]),
});
// Mutation: compute on write
export const createPost = mutation({
args: { body: v.string(), tags: v.array(v.string()) },
returns: v.id("posts"),
handler: async (ctx, args) => {
return await ctx.db.insert("posts", {
body: args.body,
tags: args.tags,
isImportant: args.tags.includes("important"), // Denormalize!
});
},
});
// Query: O(log n) lookup
export const getImportantPosts = query({
args: {},
returns: v.array(
v.object({
_id: v.id("posts"),
_creationTime: v.number(),
body: v.string(),
isImportant: v.boolean(),
})
),
handler: async (ctx) => {
return await ctx.db
.query("posts")
.withIndex("by_important", (q) => q.eq("isImportant", true))
.collect();
},
});当您需要根据计算条件进行过滤时,将结果反规范化:
typescript
// Schema
export default defineSchema({
posts: defineTable({
body: v.string(),
tags: v.array(v.string()),
// 反规范化字段:在写入时计算
isImportant: v.boolean(),
}).index("by_important", ["isImportant"]),
});
// Mutation:在写入时计算
export const createPost = mutation({
args: { body: v.string(), tags: v.array(v.string()) },
returns: v.id("posts"),
handler: async (ctx, args) => {
return await ctx.db.insert("posts", {
body: args.body,
tags: args.tags,
isImportant: args.tags.includes("important"), // 反规范化!
});
},
});
// Query:O(log n)查找
export const getImportantPosts = query({
args: {},
returns: v.array(
v.object({
_id: v.id("posts"),
_creationTime: v.number(),
body: v.string(),
isImportant: v.boolean(),
})
),
handler: async (ctx) => {
return await ctx.db
.query("posts")
.withIndex("by_important", (q) => q.eq("isImportant", true))
.collect();
},
});Index Design
索引设计
Compound Index Strategy
复合索引策略
Indexes are prefix-searchable. Design compound indexes to serve multiple queries.
typescript
// Schema
export default defineSchema({
messages: defineTable({
channelId: v.id("channels"),
authorId: v.id("users"),
content: v.string(),
isDeleted: v.boolean(),
})
// ✅ This single index serves THREE query patterns:
// 1. All messages in channel: .eq("channelId", id)
// 2. Messages by author in channel: .eq("channelId", id).eq("authorId", id)
// 3. Non-deleted messages by author: .eq("channelId", id).eq("authorId", id).eq("isDeleted", false)
.index("by_channel_author_deleted", ["channelId", "authorId", "isDeleted"]),
});
// ❌ REDUNDANT: Don't create by_channel if you have by_channel_author_deleted
// The compound index can serve channel-only queries by partial prefix match索引支持前缀搜索。设计复合索引以满足多种查询模式。
typescript
// Schema
export default defineSchema({
messages: defineTable({
channelId: v.id("channels"),
authorId: v.id("users"),
content: v.string(),
isDeleted: v.boolean(),
})
// ✅ 单个索引可支持三种查询模式:
// 1. 频道中的所有消息:.eq("channelId", id)
// 2. 频道中某作者的消息:.eq("channelId", id).eq("authorId", id)
// 3. 频道中某作者未删除的消息:.eq("channelId", id).eq("authorId", id).eq("isDeleted", false)
.index("by_channel_author_deleted", ["channelId", "authorId", "isDeleted"]),
});
// ❌ 冗余:如果已有by_channel_author_deleted,则无需创建by_channel
// 复合索引可通过部分前缀匹配来支持仅按频道查询的场景Index Naming Convention
索引命名规范
Include all fields:
by_field1_and_field2_and_field3typescript
.index("by_channel", ["channelId"])
.index("by_channel_and_author", ["channelId", "authorId"])
.index("by_user_and_status_and_createdAt", ["userId", "status", "createdAt"])包含所有字段:
by_field1_and_field2_and_field3typescript
.index("by_channel", ["channelId"])
.index("by_channel_and_author", ["channelId", "authorId"])
.index("by_user_and_status_and_createdAt", ["userId", "status", "createdAt"])Avoiding Filter
避免使用Filter
Never use . Use indexes or filter in TypeScript.
.filter()❌ BAD: filter() scans entire table
typescript
const activeUsers = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("status"), "active"))
.collect();✅ GOOD: Index-based
typescript
const activeUsers = await ctx.db
.query("users")
.withIndex("by_status", (q) => q.eq("status", "active"))
.collect();✅ ACCEPTABLE: Small dataset, complex filter
typescript
// Only if the dataset is bounded!
const allUsers = await ctx.db.query("users").take(1000);
const filtered = allUsers.filter(
(u) => u.status === "active" && u.role !== "bot"
);切勿使用。应使用索引或在TypeScript中进行过滤。
.filter()❌ 错误示例:filter()会扫描整个表
typescript
const activeUsers = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("status"), "active"))
.collect();✅ 正确示例:基于索引查询
typescript
const activeUsers = await ctx.db
.query("users")
.withIndex("by_status", (q) => q.eq("status", "active"))
.collect();✅ 可接受示例:小型数据集+复杂过滤
typescript
// 仅适用于数据集大小有限的情况!
const allUsers = await ctx.db.query("users").take(1000);
const filtered = allUsers.filter(
(u) => u.status === "active" && u.role !== "bot"
);Concurrency & OCC (Optimistic Concurrency Control)
并发与OCC(乐观并发控制)
Convex uses OCC for transactions. When two mutations read and write the same document simultaneously, one will be retried automatically.
Convex使用OCC处理事务。当两个mutation同时读写同一个文档时,其中一个会自动重试。
Problem: Hot Spots
问题:热点
❌ BAD: Counter that's always conflicting
typescript
export const incrementCounter = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const counter = await ctx.db.query("counters").unique();
await ctx.db.patch(counter!._id, { count: counter!.count + 1 });
return null;
},
});
// If 100 users click at once, 99 will retry → cascading OCC errors❌ 错误示例:始终冲突的计数器
typescript
export const incrementCounter = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const counter = await ctx.db.query("counters").unique();
await ctx.db.patch(counter!._id, { count: counter!.count + 1 });
return null;
},
});
// 如果100个用户同时点击,99个请求会重试 → 引发连锁OCC错误Solution 1: Sharding
解决方案1:分片
Split hot data across multiple documents:
typescript
// Schema: counterShards table
export default defineSchema({
counterShards: defineTable({
shardId: v.number(),
delta: v.number(),
}).index("by_shard", ["shardId"]),
});
// On write: pick random shard
export const incrementCounter = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const shardId = Math.floor(Math.random() * 10);
await ctx.db.insert("counterShards", { shardId, delta: 1 });
return null;
},
});
// On read: sum all shards
export const getCount = query({
args: {},
returns: v.number(),
handler: async (ctx) => {
const shards = await ctx.db.query("counterShards").collect();
return shards.reduce((sum, s) => sum + s.delta, 0);
},
});将热点数据拆分到多个文档中:
typescript
// Schema:counterShards表
export default defineSchema({
counterShards: defineTable({
shardId: v.number(),
delta: v.number(),
}).index("by_shard", ["shardId"]),
});
// 写入时:随机选择分片
export const incrementCounter = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const shardId = Math.floor(Math.random() * 10);
await ctx.db.insert("counterShards", { shardId, delta: 1 });
return null;
},
});
// 读取时:汇总所有分片数据
export const getCount = query({
args: {},
returns: v.number(),
handler: async (ctx) => {
const shards = await ctx.db.query("counterShards").collect();
return shards.reduce((sum, s) => sum + s.delta, 0);
},
});Solution 2: Workpool (convex-helpers)
解决方案2:Workpool(convex-helpers)
Serialize writes to avoid conflicts:
typescript
import { Workpool } from "@convex-dev/workpool";
import { components } from "./_generated/api";
const counterPool = new Workpool(components.counterWorkpool, {
maxParallelism: 1, // Serialize all counter updates
});
export const incrementCounter = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
await counterPool.enqueueMutation(ctx, internal.counters.doIncrement, {});
return null;
},
});序列化写入以避免冲突:
typescript
import { Workpool } from "@convex-dev/workpool";
import { components } from "./_generated/api";
const counterPool = new Workpool(components.counterWorkpool, {
maxParallelism: 1, // 序列化所有计数器更新
});
export const incrementCounter = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
await counterPool.enqueueMutation(ctx, internal.counters.doIncrement, {});
return null;
},
});Solution 3: Aggregate Component
解决方案3:聚合组件
For counts/sums, use the Convex Aggregate component:
typescript
import { Aggregate } from "@convex-dev/aggregate";
// Atomic increments without OCC conflicts
await aggregate.insert(ctx, "pageViews", 1);
const total = await aggregate.sum(ctx);对于计数/求和操作,使用Convex Aggregate组件:
typescript
import { Aggregate } from "@convex-dev/aggregate";
// 无OCC冲突的原子增量操作
await aggregate.insert(ctx, "pageViews", 1);
const total = await aggregate.sum(ctx);When to Use Workpool vs Scheduler
Workpool与Scheduler的使用场景对比
- Use for one-off background jobs with no coordination needs.
ctx.scheduler - Use Workpool when you need concurrency control, fan-out parallelism, or serialization to avoid OCC conflicts.
- 当您需要执行一次性后台任务且无需协调时,使用。
ctx.scheduler - 当您需要并发控制、扇出并行处理或序列化写入以避免OCC冲突时,使用Workpool。
Transaction Boundaries
事务边界
Consolidate Reads
合并读取操作
Multiple calls in an action are NOT transactional:
ctx.runQuery❌ BAD: Race condition between queries
typescript
export const processTeam = action({
args: { teamId: v.id("teams") },
returns: v.null(),
handler: async (ctx, args) => {
const team = await ctx.runQuery(internal.teams.getTeam, {
teamId: args.teamId,
});
const owner = await ctx.runQuery(internal.users.getUser, {
userId: team.ownerId,
});
// Owner might have changed between the two queries!
return null;
},
});✅ GOOD: Single transactional query
typescript
export const processTeam = action({
args: { teamId: v.id("teams") },
returns: v.null(),
handler: async (ctx, args) => {
const teamWithOwner = await ctx.runQuery(internal.teams.getTeamWithOwner, {
teamId: args.teamId,
});
// Team and owner fetched atomically
return null;
},
});Action中的多个调用不具备事务性:
ctx.runQuery❌ 错误示例:查询之间存在竞争条件
typescript
export const processTeam = action({
args: { teamId: v.id("teams") },
returns: v.null(),
handler: async (ctx, args) => {
const team = await ctx.runQuery(internal.teams.getTeam, {
teamId: args.teamId,
});
const owner = await ctx.runQuery(internal.users.getUser, {
userId: team.ownerId,
});
// 两次查询之间,所有者信息可能已更改!
return null;
},
});✅ 正确示例:单个事务性查询
typescript
export const processTeam = action({
args: { teamId: v.id("teams") },
returns: v.null(),
handler: async (ctx, args) => {
const teamWithOwner = await ctx.runQuery(internal.teams.getTeamWithOwner, {
teamId: args.teamId,
});
// 团队和所有者信息是原子性获取的
return null;
},
});Batch Writes
批量写入操作
Multiple mutations in an action are NOT atomic:
❌ BAD: Partial failure possible
typescript
export const createUsers = action({
args: { users: v.array(v.object({ name: v.string() })) },
returns: v.null(),
handler: async (ctx, args) => {
for (const user of args.users) {
await ctx.runMutation(internal.users.insert, { user });
}
// If third insert fails, first two still exist!
return null;
},
});✅ GOOD: Single transaction
typescript
export const createUsers = mutation({
args: { users: v.array(v.object({ name: v.string() })) },
returns: v.array(v.id("users")),
handler: async (ctx, args) => {
const ids: Id<"users">[] = [];
for (const user of args.users) {
ids.push(
await ctx.db.insert("users", { name: user.name, createdAt: Date.now() })
);
}
return ids; // All succeed or all fail together
},
});Action中的多个mutation不具备原子性:
❌ 错误示例:可能出现部分失败
typescript
export const createUsers = action({
args: { users: v.array(v.object({ name: v.string() })) },
returns: v.null(),
handler: async (ctx, args) => {
for (const user of args.users) {
await ctx.runMutation(internal.users.insert, { user });
}
// 如果第三个插入失败,前两个仍会存在!
return null;
},
});✅ 正确示例:单个事务
typescript
export const createUsers = mutation({
args: { users: v.array(v.object({ name: v.string() })) },
returns: v.array(v.id("users")),
handler: async (ctx, args) => {
const ids: Id<"users">[] = [];
for (const user of args.users) {
ids.push(
await ctx.db.insert("users", { name: user.name, createdAt: Date.now() })
);
}
return ids; // 要么全部成功,要么全部失败
},
});Query Optimization
查询优化
Use take() with Reasonable Limits
使用take()并设置合理限制
typescript
// ❌ BAD: Unbounded collect
const allMessages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.collect();
// ✅ GOOD: Bounded with take()
const recentMessages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.order("desc")
.take(50);typescript
// ❌ 错误示例:无限制collect
const allMessages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.collect();
// ✅ 正确示例:使用take()限制数量
const recentMessages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.order("desc")
.take(50);Parallel Data Fetching
并行数据获取
typescript
export const getDashboard = query({
args: { userId: v.id("users") },
returns: v.object({
user: v.object({ _id: v.id("users"), name: v.string() }),
stats: v.object({ messageCount: v.number(), channelCount: v.number() }),
}),
handler: async (ctx, args) => {
// Fetch in parallel - both queries run simultaneously
const [user, stats] = await Promise.all([
ctx.db.get(args.userId),
ctx.db
.query("userStats")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.unique(),
]);
if (!user) throw new Error("User not found");
return {
user: { _id: user._id, name: user.name },
stats: stats ?? { messageCount: 0, channelCount: 0 },
};
},
});typescript
export const getDashboard = query({
args: { userId: v.id("users") },
returns: v.object({
user: v.object({ _id: v.id("users"), name: v.string() }),
stats: v.object({ messageCount: v.number(), channelCount: v.number() }),
}),
handler: async (ctx, args) => {
// 并行获取数据 - 两个查询同时运行
const [user, stats] = await Promise.all([
ctx.db.get(args.userId),
ctx.db
.query("userStats")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.unique(),
]);
if (!user) throw new Error("User not found");
return {
user: { _id: user._id, name: user.name },
stats: stats ?? { messageCount: 0, channelCount: 0 },
};
},
});Avoid Collecting When You Need One
避免在仅需单个结果时使用Collect
typescript
// ❌ BAD: Collecting then taking first
const users = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", email))
.collect();
const user = users[0];
// ✅ GOOD: Use .first() or .unique()
const user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", email))
.first();
// For exactly-one semantics (throws if multiple)
const user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", email))
.unique();typescript
// ❌ 错误示例:先collect再取第一个结果
const users = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", email))
.collect();
const user = users[0];
// ✅ 正确示例:使用.first()或.unique()
const user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", email))
.first();
// 用于确保结果唯一的场景(如果存在多个结果会抛出错误)
const user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", email))
.unique();Common Pitfalls
常见陷阱
Pitfall 1: N+1 Query Pattern
陷阱1:N+1查询模式
❌ WRONG:
typescript
const posts = await ctx.db.query("posts").take(10);
const postsWithAuthors = await Promise.all(
posts.map(async (post) => ({
...post,
author: await ctx.db.get(post.authorId), // N additional queries!
}))
);✅ CORRECT: Denormalize or batch
typescript
// Option 1: Denormalize author info into posts
// Schema: posts.author: v.object({ id: v.id("users"), name: v.string() })
// Option 2: Batch fetch with getAll (from convex-helpers)
import { getAll } from "convex-helpers/server/relationships";
const posts = await ctx.db.query("posts").take(10);
const authorIds = [...new Set(posts.map((p) => p.authorId))];
const authors = await getAll(ctx.db, authorIds);
const authorMap = new Map(authors.map((a) => [a._id, a]));
const postsWithAuthors = posts.map((post) => ({
...post,
author: authorMap.get(post.authorId),
}));❌ 错误示例:
typescript
const posts = await ctx.db.query("posts").take(10);
const postsWithAuthors = await Promise.all(
posts.map(async (post) => ({
...post,
author: await ctx.db.get(post.authorId), // N次额外查询!
}))
);✅ 正确示例:反规范化或批量获取
typescript
// 选项1:将作者信息反规范化到帖子中
// Schema: posts.author: v.object({ id: v.id("users"), name: v.string() })
// 选项2:使用getAll批量获取(来自convex-helpers)
import { getAll } from "convex-helpers/server/relationships";
const posts = await ctx.db.query("posts").take(10);
const authorIds = [...new Set(posts.map((p) => p.authorId))];
const authors = await getAll(ctx.db, authorIds);
const authorMap = new Map(authors.map((a) => [a._id, a]));
const postsWithAuthors = posts.map((post) => ({
...post,
author: authorMap.get(post.authorId),
}));Pitfall 2: Unbounded Queries Without Indexes
陷阱2:无索引的无限制查询
❌ WRONG:
typescript
// Full table scan!
const allItems = await ctx.db.query("items").collect();✅ CORRECT:
typescript
// With pagination or limits
const items = await ctx.db.query("items").take(100);
// Or with index if filtering
const items = await ctx.db
.query("items")
.withIndex("by_status", (q) => q.eq("status", "active"))
.take(100);❌ 错误示例:
typescript
// 全表扫描!
const allItems = await ctx.db.query("items").collect();✅ 正确示例:
typescript
// 使用分页或限制数量
const items = await ctx.db.query("items").take(100);
// 或在过滤时使用索引
const items = await ctx.db
.query("items")
.withIndex("by_status", (q) => q.eq("status", "active"))
.take(100);Pitfall 3: Single Document Hot Spot
陷阱3:单个文档热点
❌ WRONG:
typescript
// Global counter - constant OCC conflicts under load
const global = await ctx.db.query("globals").unique();
await ctx.db.patch(global!._id, { viewCount: global!.viewCount + 1 });✅ CORRECT: Use sharding or aggregates
typescript
// Sharded counter
const shardId = Math.floor(Math.random() * 10);
await ctx.db.insert("viewShards", { shardId, delta: 1, timestamp: Date.now() });
// Periodic aggregation job consolidates shards❌ 错误示例:
typescript
// 全局计数器 - 高负载下会持续引发OCC冲突
const global = await ctx.db.query("globals").unique();
await ctx.db.patch(global!._id, { viewCount: global!.viewCount + 1 });✅ 正确示例:使用分片或聚合
typescript
// 分片计数器
const shardId = Math.floor(Math.random() * 10);
await ctx.db.insert("viewShards", { shardId, delta: 1, timestamp: Date.now() });
// 定期聚合任务合并分片数据Performance Checklist
性能检查清单
Before deploying, verify:
- All queries use indexes (no on database)
.filter() - No unbounded calls without
.collect()take(n) - Related data is denormalized to avoid N+1 patterns
- High-write documents use sharding or Workpool
- Compound indexes serve multiple query patterns
- No redundant indexes (compound indexes cover prefixes)
- Counts use denormalized counters, not
.collect().length - Mutations batch related writes in single transactions
部署前,请验证以下内容:
- 所有查询均使用索引(不在数据库层面使用)
.filter() - 无限制的调用均搭配
.collect()使用take(n) - 相关数据已反规范化,以避免N+1模式
- 高写入文档使用分片或Workpool
- 复合索引可支持多种查询模式
- 无冗余索引(复合索引已覆盖前缀场景)
- 计数使用反规范化计数器,而非
.collect().length - Mutation将相关写入批量处理在单个事务中
Quick Reference
快速参考
Query Patterns
查询模式
| Pattern | Method | Use Case |
|---|---|---|
| Get by ID | | Single document lookup |
| Get multiple | | Multiple documents (use |
| Get first | | First matching document |
| Get unique | | Exactly one document (throws if multiple) |
| Indexed query | | Efficient filtered query |
| 模式 | 方法 | 使用场景 |
|---|---|---|
| 按ID获取 | | 单个文档查找 |
| 获取多个文档 | | 获取多个文档(搭配 |
| 获取第一个匹配文档 | | 获取第一个匹配的文档 |
| 获取唯一文档 | | 获取唯一匹配的文档(如果存在多个则抛出错误) |
| 索引查询 | | 高效的过滤查询 |
Index Usage
索引使用
typescript
// Equality on all fields
.withIndex("by_a_b_c", (q) => q.eq("a", 1).eq("b", 2).eq("c", 3))
// Prefix match (uses first N fields)
.withIndex("by_a_b_c", (q) => q.eq("a", 1).eq("b", 2))
// Range on last field
.withIndex("by_a_b_c", (q) => q.eq("a", 1).eq("b", 2).gt("c", 0))
// Cannot skip fields in the middle!
// ❌ .withIndex("by_a_b_c", (q) => q.eq("a", 1).eq("c", 3))typescript
// 所有字段均为等值匹配
.withIndex("by_a_b_c", (q) => q.eq("a", 1).eq("b", 2).eq("c", 3))
// 前缀匹配(使用前N个字段)
.withIndex("by_a_b_c", (q) => q.eq("a", 1).eq("b", 2))
// 最后一个字段为范围匹配
.withIndex("by_a_b_c", (q) => q.eq("a", 1).eq("b", 2).gt("c", 0))
// 不能跳过中间的字段!
// ❌ .withIndex("by_a_b_c", (q) => q.eq("a", 1).eq("c", 3))