convex-performance-patterns について
Convex のパフォーマンスを改善する TypeScript 開発者向けのスキルです。インデックス、非正規化、クエリ設計、並行処理を、実用的なコード例とともに扱います。
主な機能
- 01効率的なクエリ向けのインデックス設計
- 02非正規化による読み取りの削減
- 03N+1 と無制限クエリの回避
- 04シャーディングや Workpool による OCC 対応
- 05トランザクション読み取りと一括書き込みの指針
活用シーン
- 01遅い Convex クエリの最適化
- 02リアクティブクエリによる再レンダリングの削減
- 03高頻度書き込みの競合への対処
- 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))