T-CREATOR

Convex インデックス設計チートシート:複合キー・範囲・前方一致の定石まとめ

Convex インデックス設計チートシート:複合キー・範囲・前方一致の定石まとめ

Convex でリアルタイムアプリケーションを開発する際、データベースのクエリパフォーマンスは最も重要な課題の一つです。特にユーザー数が増えるにつれて、適切なインデックス設計がアプリケーションの成否を分ける要因となります。

本記事では、Convex におけるインデックス設計の定石を、複合キー、範囲クエリ、前方一致検索の 3 つの観点から体系的に解説します。実務で即座に活用できるチートシート形式でまとめましたので、ぜひご活用ください。

インデックス設計早見表

本記事で紹介する主要なインデックスパターンを早見表として整理しました。実装時の参考資料としてご利用いただけます。

インデックスタイプ別の定義方法

#タイプ定義例使用ケースパフォーマンス特性
1単一フィールド.index("by_author", ["author"])特定著者の検索O(log n) での検索開始
2複合 2 フィールド.index("by_channel_user", ["channel", "user"])チャネル内のユーザー検索2 段階絞り込み
3複合 3 フィールド.index("by_org_status_date", ["orgId", "status", "_creationTime"])組織 × ステータス × 日時3 段階絞り込み
4時系列インデックス.index("by_timestamp", ["_creationTime"])最新順の取得時系列ソート最適化

複合インデックスのフィールド順序パターン

#順序パターン推奨ケースクエリ例理由
1["channel", "user"]チャネル内のユーザー検索q.eq("channel", id).eq("user", uid)カーディナリティ高 → 低
2["userId", "_creationTime"]ユーザーの時系列データq.eq("userId", id).gt("_creationTime", t)等値 → 範囲の順
3["status", "priority", "dueDate"]タスク管理システムq.eq("status", "active").eq("priority", "high")選択性高 → 低
4["orgId", "category", "name"]階層的データq.eq("orgId", id).gte("name", "A")スコープ → 分類 → 識別

範囲クエリの構文パターン

#クエリパターン構文例用途注意点
1等値のみq.eq("author", "Asimov")完全一致検索最も高速
2等値+下限q.eq("author", "Asimov").gte("title", "F")前方一致検索の基礎上限も指定推奨
3等値+範囲q.eq("channel", id).gt("_creationTime", start).lt("_creationTime", end)期間指定検索インデックス順序を遵守
4複数等値+範囲q.eq("org", id).eq("status", "active").gte("date", d)複雑な条件検索最大 16 フィールドまで

前方一致検索の実装パターン

#パターン名実装方法使用例検索範囲
1基本前方一致.gte("name", "A").lt("name", "B")A で始まる名前検索["A", "B")
2複数文字前方一致.gte("title", "App").lt("title", "Apq")"App" で始まるタイトル["App", "Apq")
3組織内前方一致q.eq("orgId", id).gte("name", "S").lt("name", "T")組織内の S で始まる名前等値条件と組み合わせ
4大文字小文字対応正規化後に検索検索前に .toLowerCase()事前に正規化フィールド作成

背景

Convex のデータベースアーキテクチャ

Convex は、TypeScript ファーストなバックエンドプラットフォームとして、リアルタイムデータベース機能を提供しています。従来の NoSQL データベースとは異なり、クエリの型安全性とリアクティブな更新を両立させた設計が特徴です。

以下の図は、Convex におけるクエリ実行の基本フローを示しています。

mermaidflowchart TB
  client["クライアント<br/>アプリケーション"] -->|クエリ実行| query["Query<br/>関数"]
  query -->|withIndex 指定| idx["インデックス<br/>検索"]
  query -->|インデックスなし| scan["フルテーブル<br/>スキャン"]
  idx -->|範囲内ドキュメント| filter["filter() による<br/>追加絞り込み"]
  scan -->|全ドキュメント| filter
  filter -->|結果| client

  style idx fill:#a8e6cf
  style scan fill:#ffcccc
  style filter fill:#ffd9a8

インデックスを適切に使用することで、データベースはバイナリサーチにより高速に検索開始位置を特定し、範囲内のドキュメントのみを走査できます。一方、インデックスを使用しない場合、全ドキュメントをスキャンする必要があり、パフォーマンスが大幅に低下します。

インデックスの基本概念

Convex のインデックスは、指定されたフィールドでソートされたドキュメントの配列として機能します。この仕組みにより、O(log n)の計算量で検索を開始し、その後は範囲内のドキュメントのみを順次処理できるのです。

重要な特徴として、すべてのインデックスには _creationTime が自動的に追加され、最終的なソートキーとして機能します。これにより、同じフィールド値を持つドキュメントも一意に並べ替えられます。

課題

不適切なインデックス設計がもたらす問題

実務で Convex を使用する際、多くの開発者が直面する典型的な問題があります。それは、インデックス設計の誤りによるパフォーマンス劣化です。

以下の図は、不適切なインデックス設計が引き起こす問題の連鎖を示しています。

mermaidflowchart LR
  problem1["インデックス<br/>未定義"] -->|結果| issue1["フルテーブル<br/>スキャン"]
  problem2["フィールド順序<br/>の誤り"] -->|結果| issue2["インデックス<br/>未使用"]
  problem3["過度な<br/>filter() 依存"] -->|結果| issue3["メモリ圧迫<br/>・遅延"]

  issue1 --> impact["パフォーマンス<br/>劣化"]
  issue2 --> impact
  issue3 --> impact
  impact --> result["ユーザー体験<br/>の悪化"]

  style problem1 fill:#ffcccc
  style problem2 fill:#ffcccc
  style problem3 fill:#ffcccc
  style impact fill:#ff9999
  style result fill:#ff6666

これらの問題は、特にデータ量が増加した際に顕在化します。開発初期段階では気づかず、本番運用後にパフォーマンス問題として表面化することが多いのです。

具体的な課題事例

課題 1:複合インデックスのフィールド順序ミス

最も頻繁に発生する問題は、複合インデックスのフィールド順序の選択ミスです。例えば、チャネル内のメッセージを時系列で取得したい場合、以下のような誤った設計をしてしまうケースがあります。

typescript// ❌ 誤った設計例
defineTable({
  channelId: v.string(),
  content: v.string(),
  _creationTime: v.number(),
}).index('by_time_channel', ['_creationTime', 'channelId']);

この設計では、特定チャネルのメッセージを取得する際、インデックスを効果的に活用できません。

課題 2:範囲クエリの制約理解不足

Convex のインデックス範囲式には、明確な順序制約があります。等値条件(.eq())、下限条件(.gt()/.gte())、上限条件(.lt()/.lte())の順序でのみ記述でき、この順序を逸脱するとエラーが発生します。

さらに、インデックスのフィールド順序を飛ばして条件を指定することはできません。例えば、["channel", "_creationTime"] のインデックスで、channel を指定せずに _creationTime だけで範囲指定することは不可能です。

課題 3:前方一致検索の非効率な実装

文字列の前方一致検索を実装する際、以下のように filter() メソッドのみに依存してしまうケースがあります。

typescript// ❌ 非効率な実装
const results = await ctx.db
  .query('products')
  .filter((q) => q.gte(q.field('name'), searchTerm))
  .collect();

この方法では、全ドキュメントをメモリに読み込んでからフィルタリングするため、大量のデータがある場合に重大なパフォーマンス問題を引き起こします。

解決策

複合インデックス設計の原則

適切なインデックス設計には、明確な原則があります。以下の図は、複合インデックスのフィールド順序を決定するための判断フローです。

mermaidflowchart TD
  start["複合インデックス<br/>設計開始"] --> q1{"等値条件で<br/>使用するフィールドは?"}
  q1 -->|複数ある| q2{"カーディナリティ<br/>(値の多様性)は?"}
  q1 -->|1つ| order1["そのフィールドを<br/>最初に配置"]

  q2 -->|高い| order2["カーディナリティが<br/>高い順に配置"]
  q2 -->|同程度| q3{"選択性<br/>(絞り込み効果)は?"}

  q3 -->|差がある| order3["選択性が<br/>高い順に配置"]
  q3 -->|同程度| order4["クエリ頻度が<br/>高い順に配置"]

  order1 --> range{"範囲検索<br/>フィールドは?"}
  order2 --> range
  order3 --> range
  order4 --> range

  range -->|ある| final["等値条件の後に<br/>範囲フィールドを配置"]
  range -->|ない| done["インデックス<br/>設計完了"]
  final --> done

  style start fill:#a8e6cf
  style done fill:#a8e6cf

この判断フローに従うことで、最適なフィールド順序を体系的に決定できます。

原則 1:等値条件を最初に、範囲条件を後に

複合インデックスの最も基本的な原則は、等値条件(.eq())で使用するフィールドを先に配置し、範囲条件(.gt().lt() など)で使用するフィールドを後に配置することです。

これは、Convex のクエリエンジンが「前から順にフィールドで絞り込む」仕組みを採用しているためです。

typescript// ✅ 正しい設計例
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
  messages: defineTable({
    channelId: v.string(),
    userId: v.string(),
    content: v.string(),
  })
    // 等値条件(channelId)→ 範囲条件(_creationTime)の順
    .index('by_channel_time', [
      'channelId',
      '_creationTime',
    ]),
});

このインデックス定義により、特定チャネルの特定期間のメッセージを効率的に取得できます。

原則 2:カーディナリティの高いフィールドを優先

複数の等値条件フィールドがある場合、カーディナリティ(値の多様性)が高いフィールドを先に配置します。カーディナリティが高いほど、最初の段階で大きく絞り込めるからです。

typescript// ✅ カーディナリティを考慮した設計
export default defineSchema({
  tasks: defineTable({
    userId: v.id('users'), // カーディナリティ: 高(数千〜数万)
    status: v.string(), // カーディナリティ: 低("todo", "done" など数個)
    priority: v.string(), // カーディナリティ: 低("high", "medium", "low")
  })
    // userId(高)→ status(低)→ priority(低)の順
    .index('by_user_status_priority', [
      'userId',
      'status',
      'priority',
    ]),
});

この順序により、最初にユーザーで大きく絞り込み、その後ステータスや優先度で細かく絞り込めます。

原則 3:範囲クエリの構文制約を遵守

Convex の範囲クエリには、明確な構文制約があります。以下の順序でのみ条件を記述できます。

  1. 等値条件(.eq()):インデックスのフィールド順に前から
  2. 下限条件(.gt() または .gte()):1 つのフィールドのみ
  3. 上限条件(.lt() または .lte()):下限と同じフィールドのみ
typescript// ✅ 正しい範囲クエリの構文
const messages = await ctx.db
  .query('messages')
  .withIndex(
    'by_channel_time',
    (q) =>
      q
        .eq('channelId', channelId) // 1. 等値条件
        .gt('_creationTime', startTime) // 2. 下限条件
        .lt('_creationTime', endTime) // 3. 上限条件
  )
  .collect();

この制約を理解することで、エラーを回避し、効率的なクエリを記述できます。

前方一致検索の実装パターン

文字列の前方一致検索は、範囲クエリを活用することで効率的に実装できます。基本的な考え方は、「検索文字列で始まり、次の文字で始まる直前まで」という範囲を指定することです。

typescript// 前方一致検索のヘルパー関数
function getPrefixRange(prefix: string): [string, string] {
  // 最後の文字コードを1増やした文字列を上限とする
  const lastChar = prefix.charAt(prefix.length - 1);
  const nextChar = String.fromCharCode(
    lastChar.charCodeAt(0) + 1
  );
  const upperBound = prefix.slice(0, -1) + nextChar;

  return [prefix, upperBound];
}

このヘルパー関数を使用することで、前方一致検索を簡潔に実装できます。

typescript// ✅ 効率的な前方一致検索
const [lower, upper] = getPrefixRange('App');

const products = await ctx.db
  .query('products')
  .withIndex('by_name', (q) =>
    q.gte('name', lower).lt('name', upper)
  )
  .collect();

// "App"、"Apple"、"Application" などが取得される
// "Apology" は含まれない("Apo" < "App")

具体例

実例 1:チャットアプリケーションのメッセージ検索

実際のチャットアプリケーションでは、以下のような要件があります。

  • 特定チャネルの全メッセージを取得
  • 特定チャネルの最新 N 件のメッセージを取得
  • 特定チャネルの特定期間のメッセージを取得
  • 特定ユーザーが送信したメッセージを取得

これらの要件を満たすスキーマ設計を見てみましょう。

スキーマ定義

typescriptimport { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
  messages: defineTable({
    channelId: v.id('channels'),
    userId: v.id('users'),
    content: v.string(),
    editedAt: v.optional(v.number()),
  })
    // チャネル内の時系列メッセージ取得用
    .index('by_channel', ['channelId', '_creationTime'])
    // 特定ユーザーのメッセージ取得用
    .index('by_user', ['userId', '_creationTime']),
});

この設計では、2 つのインデックスを定義しています。それぞれ異なるアクセスパターンに最適化されています。

クエリ実装例 1:最新メッセージの取得

チャネルの最新 20 件のメッセージを取得する実装です。

typescriptimport { query } from './_generated/server';
import { v } from 'convex/values';

// 最新メッセージ取得クエリ
export const getRecentMessages = query({
  args: {
    channelId: v.id('channels'),
    limit: v.number(),
  },
  handler: async (ctx, args) => {
    const messages = await ctx.db
      .query('messages')
      .withIndex('by_channel', (q) =>
        q.eq('channelId', args.channelId)
      )
      .order('desc') // 降順で取得(最新が先)
      .take(args.limit);

    return messages;
  },
});

.order("desc") により、最新のメッセージから順に取得されます。.take() メソッドで件数を制限することで、パフォーマンスを最適化しています。

クエリ実装例 2:期間指定メッセージの取得

特定期間のメッセージを取得する実装です。

typescript// 期間指定メッセージ取得クエリ
export const getMessagesByDateRange = query({
  args: {
    channelId: v.id('channels'),
    startTime: v.number(),
    endTime: v.number(),
  },
  handler: async (ctx, args) => {
    const messages = await ctx.db
      .query('messages')
      .withIndex('by_channel', (q) =>
        q
          .eq('channelId', args.channelId)
          .gt('_creationTime', args.startTime)
          .lt('_creationTime', args.endTime)
      )
      .collect();

    return messages;
  },
});

等値条件でチャネルを絞り込んだ後、範囲条件で期間を指定することで、効率的に検索できます。

クエリ実装例 3:ページネーション対応

大量のメッセージを効率的に取得するため、ページネーション機能を実装します。

typescriptimport { paginationOptsValidator } from 'convex/server';

// ページネーション対応メッセージ取得
export const getMessagesPaginated = query({
  args: {
    channelId: v.id('channels'),
    paginationOpts: paginationOptsValidator,
  },
  handler: async (ctx, args) => {
    const result = await ctx.db
      .query('messages')
      .withIndex('by_channel', (q) =>
        q.eq('channelId', args.channelId)
      )
      .order('desc')
      .paginate(args.paginationOpts);

    return result;
  },
});

.paginate() メソッドは、自動的に継続トークンを管理し、効率的なページング処理を実現します。

実例 2:E コマースサイトの商品検索

次に、E コマースサイトにおける商品検索の実装例を見ていきます。以下の要件を想定します。

  • カテゴリ別の商品一覧
  • 価格帯での絞り込み
  • 商品名の前方一致検索
  • 在庫ありの商品のみ表示

スキーマ定義

typescriptexport default defineSchema({
  products: defineTable({
    name: v.string(),
    category: v.string(),
    price: v.number(),
    inStock: v.boolean(),
    sellerId: v.id('sellers'),
  })
    // カテゴリ別商品一覧用
    .index('by_category_price', ['category', 'price'])
    // 商品名検索用
    .index('by_name', ['name'])
    // 販売者の商品一覧用
    .index('by_seller', ['sellerId', 'category']),
});

複数のアクセスパターンに対応するため、3 つのインデックスを定義しています。

クエリ実装例 1:カテゴリ別・価格帯での絞り込み

特定カテゴリの特定価格帯の商品を取得します。

typescript// カテゴリ別・価格帯絞り込みクエリ
export const getProductsByPriceRange = query({
  args: {
    category: v.string(),
    minPrice: v.number(),
    maxPrice: v.number(),
  },
  handler: async (ctx, args) => {
    let productsQuery = ctx.db
      .query('products')
      .withIndex('by_category_price', (q) =>
        q
          .eq('category', args.category)
          .gte('price', args.minPrice)
          .lte('price', args.maxPrice)
      );

    const products = await productsQuery.collect();

    return products;
  },
});

インデックスにより、カテゴリと価格の両方で効率的に絞り込めます。

クエリ実装例 2:在庫フィルタの追加

インデックス範囲では在庫状態を指定できないため、.filter() メソッドを組み合わせます。

typescript// 在庫ありの商品のみ取得
export const getInStockProducts = query({
  args: {
    category: v.string(),
    minPrice: v.number(),
    maxPrice: v.number(),
  },
  handler: async (ctx, args) => {
    const products = await ctx.db
      .query('products')
      .withIndex('by_category_price', (q) =>
        q
          .eq('category', args.category)
          .gte('price', args.minPrice)
          .lte('price', args.maxPrice)
      )
      // インデックス範囲外の条件はfilterで追加
      .filter((q) => q.eq(q.field('inStock'), true))
      .collect();

    return products;
  },
});

重要なポイントは、インデックスで大部分を絞り込んだ後に .filter() を適用することで、パフォーマンスを維持していることです。

クエリ実装例 3:商品名の前方一致検索

商品名の前方一致検索を実装します。ユーザーが入力した文字列で始まる商品を検索する機能です。

typescript// 前方一致検索ヘルパー関数
function getNextString(str: string): string {
  if (str.length === 0) return str;
  const lastCharCode = str.charCodeAt(str.length - 1);
  return (
    str.slice(0, -1) + String.fromCharCode(lastCharCode + 1)
  );
}

ヘルパー関数を定義した後、実際の検索クエリを実装します。

typescript// 商品名前方一致検索クエリ
export const searchProductsByName = query({
  args: {
    searchTerm: v.string(),
  },
  handler: async (ctx, args) => {
    // 空文字の場合は全商品を返す
    if (args.searchTerm.length === 0) {
      return await ctx.db.query('products').collect();
    }

    const lowerBound = args.searchTerm;
    const upperBound = getNextString(args.searchTerm);

    const products = await ctx.db
      .query('products')
      .withIndex('by_name', (q) =>
        q.gte('name', lowerBound).lt('name', upperBound)
      )
      .collect();

    return products;
  },
});

この実装により、「App」と入力すると「Apple」「Application」などが効率的に検索されます。

実例 3:タスク管理アプリケーション

最後に、複雑な検索条件を持つタスク管理アプリケーションの例を見ていきます。

スキーマ定義

typescriptexport default defineSchema({
  tasks: defineTable({
    organizationId: v.id('organizations'),
    projectId: v.id('projects'),
    assigneeId: v.id('users'),
    status: v.union(
      v.literal('todo'),
      v.literal('in_progress'),
      v.literal('done')
    ),
    priority: v.union(
      v.literal('low'),
      v.literal('medium'),
      v.literal('high')
    ),
    dueDate: v.number(),
    title: v.string(),
  })
    // 組織内のタスク検索用
    .index('by_org_status_due', [
      'organizationId',
      'status',
      'dueDate',
    ])
    // 担当者のタスク検索用
    .index('by_assignee_status', [
      'assigneeId',
      'status',
      '_creationTime',
    ])
    // プロジェクト内のタスク検索用
    .index('by_project_priority', [
      'projectId',
      'priority',
      'dueDate',
    ]),
});

この設計では、3 つの主要なアクセスパターンに対応したインデックスを定義しています。

クエリ実装:期限切れタスクの検索

期限切れの未完了タスクを検索する実装です。

typescript// 期限切れタスク検索クエリ
export const getOverdueTasks = query({
  args: {
    organizationId: v.id('organizations'),
  },
  handler: async (ctx, args) => {
    const now = Date.now();

    // "todo" と "in_progress" の両方を取得する必要がある
    const todoTasks = await ctx.db
      .query('tasks')
      .withIndex('by_org_status_due', (q) =>
        q
          .eq('organizationId', args.organizationId)
          .eq('status', 'todo')
          .lt('dueDate', now)
      )
      .collect();

    const inProgressTasks = await ctx.db
      .query('tasks')
      .withIndex('by_org_status_due', (q) =>
        q
          .eq('organizationId', args.organizationId)
          .eq('status', 'in_progress')
          .lt('dueDate', now)
      )
      .collect();

    // 2つの結果を結合してソート
    return [...todoTasks, ...inProgressTasks].sort(
      (a, b) => a.dueDate - b.dueDate
    );
  },
});

この実装では、複数のステータスに対して個別にクエリを実行し、結果を結合しています。これは、インデックスの制約上、OR 条件を直接表現できないためです。

優先度別タスク取得

プロジェクト内の優先度別タスクを取得します。

typescript// 優先度別タスク取得クエリ
export const getTasksByPriority = query({
  args: {
    projectId: v.id('projects'),
    priority: v.union(
      v.literal('low'),
      v.literal('medium'),
      v.literal('high')
    ),
  },
  handler: async (ctx, args) => {
    const tasks = await ctx.db
      .query('tasks')
      .withIndex('by_project_priority', (q) =>
        q
          .eq('projectId', args.projectId)
          .eq('priority', args.priority)
      )
      .order('asc') // 期限の早い順
      .collect();

    return tasks;
  },
});

インデックスの 3 番目のフィールドに dueDate を配置しているため、期限順にソートされた状態で取得できます。

まとめ

Convex におけるインデックス設計は、アプリケーションのパフォーマンスを左右する重要な要素です。本記事では、複合キー、範囲クエリ、前方一致検索の 3 つの観点から、実務で活用できる定石をまとめました。

重要なポイントを再度整理しておきます。

複合インデックスの設計原則は、等値条件のフィールドを先に、範囲条件のフィールドを後に配置することです。さらに、等値条件フィールド間ではカーディナリティの高い順に並べることで、効率的な絞り込みが実現できます。

範囲クエリの構文制約を理解することも重要です。等値条件、下限条件、上限条件の順序を守り、インデックスのフィールド順序を飛ばさないように注意しましょう。

前方一致検索は、範囲クエリの応用として実装できます。検索文字列を下限とし、次の文字を上限とすることで、効率的な前方一致検索が可能です。

また、インデックス範囲では表現できない条件については、.filter() メソッドを活用します。ただし、インデックスで可能な限り絞り込んだ後に .filter() を適用することが、パフォーマンス維持の鍵となります。

これらの原則を理解し、実際のアプリケーション要件に応じて適切なインデックスを設計することで、スケーラブルで高速な Convex アプリケーションを構築できるでしょう。

関連リンク

本記事の作成にあたり、以下の公式ドキュメントを参照しました。より詳しい情報は、こちらのリソースをご確認ください。

;