mcpField
C
convex-performance-patterns

convex-performance-patterns

github.com/op0aiデータ・データベース1781 回インストール41 週間前に更新
リポジトリ

convex-performance-patterns について

Convex のパフォーマンスを改善する TypeScript 開発者向けのスキルです。インデックス、非正規化、クエリ設計、並行処理を、実用的なコード例とともに扱います。

主な機能

  1. 01効率的なクエリ向けのインデックス設計
  2. 02非正規化による読み取りの削減
  3. 03N+1 と無制限クエリの回避
  4. 04シャーディングや Workpool による OCC 対応
  5. 05トランザクション読み取りと一括書き込みの指針

活用シーン

  1. 01遅い Convex クエリの最適化
  2. 02リアクティブクエリによる再レンダリングの削減
  3. 03高頻度書き込みの競合への対処
  4. 04カウンターと集計の設計

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/op0ai/claude-skills --skill convex-performance-patterns

インストール後、Claude Code で/convex-performance-patternsを入力して読み込まれたか確認します。

詳細

Convex のパフォーマンスパターン

概要

Convex はパフォーマンスを重視して設計されていますが、最適な結果を得るには特定のパターンが必要です。このスキルでは、非正規化の戦略、インデックスの設計、よくあるパフォーマンス上の落とし穴の回避、OCC(楽観的同時実行制御)による同時実行の処理について説明します。

TypeScript:any 型は絶対に使わない

重要なルール: このコードベースでは @typescript-eslint/no-explicit-any が有効になっています。any を使うとビルドに失敗します。

このスキルを使う場面

次のような場合に、このスキルを使ってください。

  • クエリの実行が遅い、または再レンダリングが多すぎる
  • 効率的なデータアクセスのためにインデックスを設計する
  • N+1 クエリパターンを回避する
  • 書き込みの競合が多い場合(OCC エラー)に対処する
  • 読み取りパフォーマンスを向上させるためにデータを非正規化する
  • リアクティブクエリを最適化する
  • カウンターや集計を扱う

パフォーマンスの基本原則

原則 1:クエリは O(n) ではなく O(log n) にする

Convex のクエリでは、効率的なデータ取得のためにインデックスを使用してください。テーブル全体をスキャンしているなら、やり方が間違っています。

原則 2:積極的に非正規化する

Convex には結合がありません。関連データを埋め込むか、ルックアップテーブルを維持してください。

原則 3:ドキュメントの読み取りを最小限にする

クエリ内のドキュメント読み取りは、それぞれ依存関係を作成します。読み取りが少ないほど、再レンダリングも減ります。

原則 4:ホットスポットを避ける

頻繁に書き込まれる単一のドキュメントは、OCC の競合を引き起こします。

非正規化パターン

パターン 1:関連データを埋め込む

❌ 悪い例:N+1 クエリ

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 };
  },
});

✅ 良い例:メンバー情報をチームに非正規化する

// 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
  },
});
パターン 2:非正規化したカウント

カウントするだけのために .collect() を使わないでください。

❌ 悪い例:上限のない読み取り

const messages = await ctx.db
  .query("messages")
  .withIndex("by_channel", (q) => q.eq("channelId", channelId))
  .collect();
const count = messages.length;

✅ 良い例:「99+」パターンを表示する

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

✅ 最良の例:非正規化したカウンターテーブル

// 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;
  },
});
パターン 3:非正規化したブール値フィールド

計算した条件でフィルタリングする必要がある場合は、結果を非正規化してください。

// 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();
  },
});

インデックス設計

複合インデックス戦略

インデックスはプレフィックス検索に対応しています。複数のクエリに対応できるよう、複合インデックスを設計してください。

// 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
インデックスの命名規則

すべてのフィールドを含めてください:by_field1_and_field2_and_field3

.index("by_channel", ["channelId"])
.index("by_channel_and_author", ["channelId", "authorId"])
.index("by_user_and_status_and_createdAt", ["userId", "status", "createdAt"])
フィルターの回避

.filter() は使わないでください。インデックスを使うか、TypeScript でフィルタリングしてください。

❌ 悪い例:filter() はテーブル全体をスキャンする

const activeUsers = await ctx.db
  .query("users")
  .filter((q) => q.eq(q.field("status"), "active"))
  .collect();

✅ 良い例:インデックスを使用

const activeUsers = await ctx.db
  .query("users")
  .withIndex("by_status", (q) => q.eq("status", "active"))
  .collect();

✅ 許容される例:小規模なデータセットで複雑なフィルターを使う

// 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"
);

同時実行と OCC(楽観的同時実行制御)

Convex はトランザクションに OCC を使用します。2 つのミューテーションが同時に同じドキュメントを読み書きすると、片方は自動的に再試行されます。

問題:ホットスポット

❌ 悪い例:常に競合するカウンター

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
解決策 1:シャーディング

ホットなデータを複数のドキュメントに分散します。

// 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);
  },
});
解決策 2:Workpool(convex-helpers)

書き込みを直列化して競合を回避します。

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;
  },
});
解決策 3:Aggregate コンポーネント

カウントや合計には、Convex Aggregate コンポーネントを使います。

import { Aggregate } from "@convex-dev/aggregate";

// Atomic increments without OCC conflicts
await aggregate.insert(ctx, "pageViews", 1);
const total = await aggregate.sum(ctx);
Workpool と Scheduler の使い分け
  • 調整が不要な単発のバックグラウンドジョブには ctx.scheduler を使います。
  • 同時実行の制御、ファンアウトによる並列処理、または OCC 競合を避けるための直列化が必要な場合は、Workpool を使います。

トランザクションの境界

読み取りをまとめる

アクション内の複数の ctx.runQuery 呼び出しは、トランザクションではありません。

❌ 悪い例:クエリ間の競合状態

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;
  },
});

✅ 良い例:単一のトランザクションクエリ

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;
  },
});
書き込みをまとめる

アクション内の複数のミューテーションは、アトミックではありません。

❌ 悪い例:一部だけ失敗する可能性がある

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;
  },
});

✅ 良い例:単一のトランザクション

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
  },
});

クエリの最適化

妥当な上限を指定して take() を使う
// ❌ 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);
データを並列に取得する
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 },
    };
  },
});
1 件だけ必要な場合は collect を避ける
// ❌ 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();

よくある落とし穴

落とし穴 1:N+1 クエリパターン

❌ 間違い:

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!
  }))
);

✅ 正しい例:非正規化するか、まとめて取得する

// 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),
}));
落とし穴 2:インデックスのない上限のないクエリ

❌ 間違い:

// Full table scan!
const allItems = await ctx.db.query("items").collect();

✅ 正しい例:

// 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);
落とし穴 3:単一ドキュメントのホットスポット

❌ 間違い:

// 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 });

✅ 正しい例:シャーディングまたは集計を使う

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

パフォーマンスチェックリスト

デプロイする前に、次の点を確認してください。

  • すべてのクエリでインデックスを使用している(データベースに対する .filter() を使用していない)
  • take(n) を指定しない、上限のない .collect() の呼び出しがない
  • N+1 パターンを避けるため、関連データを非正規化している
  • 書き込み頻度の高いドキュメントでシャーディングまたは Workpool を使用している
  • 複合インデックスで複数のクエリパターンに対応している
  • 冗長なインデックスがない(複合インデックスがプレフィックスをカバーしている)
  • 件数の取得に .collect().length ではなく、非正規化したカウンターを使用している
  • ミューテーションで、関連する書き込みを単一のトランザクションにまとめている

クイックリファレンス

クエリパターン
パターンメソッドユースケース
ID で取得ctx.db.get(id)単一ドキュメントの検索
複数取得ctx.db.query().collect()複数ドキュメント(take(n) を使用)
最初の項目を取得ctx.db.query().first()条件に一致する最初のドキュメント
一意の項目を取得ctx.db.query().unique()ドキュメントがちょうど 1 つ(複数ある場合はエラー)
インデックス付きクエリ.withIndex("name", q => ...)効率的に絞り込んだクエリ
インデックスの使用
// 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))

コメント