convex-performance-patterns

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Convex 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

TypeScript:切勿使用
any
类型

CRITICAL RULE: This codebase has
@typescript-eslint/no-explicit-any
enabled. Using
any
will cause build failures.
重要规则: 本代码库已启用
@typescript-eslint/no-explicit-any
规则。使用
any
会导致构建失败。

When 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
.collect()
just to count.
❌ 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_field3
typescript
.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_field3
typescript
.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
.filter()
. Use indexes or filter in TypeScript.
❌ 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"
);
切勿使用
.filter()
。应使用索引或在TypeScript中进行过滤。
❌ 错误示例: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
    ctx.scheduler
    for one-off background jobs with no coordination needs.
  • 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
ctx.runQuery
calls in an action are NOT transactional:
❌ 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
    .filter()
    on database)
  • No unbounded
    .collect()
    calls without
    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

查询模式

PatternMethodUse Case
Get by ID
ctx.db.get(id)
Single document lookup
Get multiple
ctx.db.query().collect()
Multiple documents (use
take(n)
)
Get first
ctx.db.query().first()
First matching document
Get unique
ctx.db.query().unique()
Exactly one document (throws if multiple)
Indexed query
.withIndex("name", q => ...)
Efficient filtered query
模式方法使用场景
按ID获取
ctx.db.get(id)
单个文档查找
获取多个文档
ctx.db.query().collect()
获取多个文档(搭配
take(n)
使用)
获取第一个匹配文档
ctx.db.query().first()
获取第一个匹配的文档
获取唯一文档
ctx.db.query().unique()
获取唯一匹配的文档(如果存在多个则抛出错误)
索引查询
.withIndex("name", q => ...)
高效的过滤查询

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))