Выбор правильных индексов

Индексы в Dexie.js определяют структуру доступа к данным и напрямую влияют на производительность запросов в IndexedDB. Неправильный выбор индексов приводит либо к избыточному расходу памяти и замедлению записи, либо к деградации чтения и необходимости полного перебора таблиц.

Dexie.js выступает тонким слоем над IndexedDB и наследует его модель индексации. Каждая таблица может иметь один первичный ключ и набор вторичных индексов. Индекс создаёт дополнительную структуру данных, позволяющую выполнять выборки не через полный перебор, а через B-tree поиск.

Каждый индекс дублирует часть данных таблицы в отсортированном виде. Это означает:

  • ускорение операций поиска по полю;
  • увеличение стоимости записи и обновления;
  • рост потребления дискового пространства.

Баланс между этими факторами и определяет архитектуру индексов.

Типы индексов в Dexie.js

Dexie.js поддерживает несколько форм индексации, каждая из которых решает отдельный класс задач.

Обычные индексы

Обычный индекс создаётся для одного поля:

db.version(1).stores({
  users: '++id, email, age'
});

В этом случае email и age индексируются независимо. Такие индексы подходят для фильтрации и сортировки по отдельным атрибутам.

Особенность заключается в том, что каждый индекс оптимизирован под одиночный ключ, а комбинирование условий происходит уже на уровне выполнения запроса.

Составные индексы (compound indexes)

Составной индекс объединяет несколько полей:

db.version(2).stores({
  orders: '++id, [userId+createdAt]'
});

Такой индекс полезен при запросах, где одновременно используются несколько условий:

db.orders
  .where('[userId+createdAt]')
  .between([1, 0], [1, Date.now()])

Порядок полей в составном индексе критически важен. Индекс [userId+createdAt] эффективен для фильтрации по userId, а затем по диапазону createdAt. Обратный порядок делает такие запросы невозможными или неэффективными.

MultiEntry индексы

MultiEntry индекс применяется к массивам:

db.version(3).stores({
  posts: '++id, tags',
}).upgrade(tx => {
  tx.table('posts').toCollection().modify(post => {
    post.tags = post.tags || [];
  });
});

Индекс может быть объявлен как multiEntry:

db.version(4).stores({
  posts: '++id, tags',
}).upgrade(tx => {
  db.posts.schema.indexes.forEach(i => i.multi = true);
});

Идея заключается в том, что каждый элемент массива становится отдельной точкой индексации. Это позволяет эффективно выполнять запросы по тегам, категориям и другим множественным признакам.

Критерии выбора индексов

Проектирование индексов требует анализа характера запросов, а не структуры данных.

Частота запросов

Индексы должны соответствовать наиболее частым операциям чтения. Поля, которые используются редко, не должны индексироваться без необходимости, поскольку каждая запись будет дороже.

Кардинальность значений

Кардинальность определяет количество уникальных значений:

  • высокая кардинальность (email, uuid) — индекс эффективен;
  • низкая кардинальность (status, boolean) — индекс менее полезен.

При низкой кардинальности индекс часто возвращает большие наборы данных, что снижает его ценность.

Комбинации фильтров

Если запросы часто используют несколько полей одновременно, предпочтительнее составной индекс вместо нескольких одиночных.

Пример неэффективной схемы:

users: '++id, country, city'

Запрос:

db.users.where('country').equals('KZ')
  .and(user => user.city === 'Karaganda')

Такой подход приводит к дополнительной фильтрации после выборки.

Оптимизированный вариант:

users: '++id, [country+city]'

Порядок полей в составных индексах

Порядок в compound индексах определяет возможность использования диапазонов.

Правило выбора:

  • поле с фильтрацией равенства — ближе к началу;
  • поле диапазона — ближе к концу.

Пример:

[status+createdAt]

Позволяет выполнять:

  • status = 'active' AND createdAt BETWEEN ...
  • status = 'active' AND createdAt > ...

Но не позволяет эффективно выполнять:

  • createdAt BETWEEN ... без указания status.

Избыточные индексы

Каждый индекс увеличивает стоимость операций записи. Избыточность возникает, когда индекс покрывает поля, которые редко используются в запросах.

Типичный антипример:

users: '++id, email, phone, age, country, city, gender, createdAt'

Такая схема приводит к:

  • замедлению вставки;
  • увеличению времени миграций;
  • росту размера IndexedDB;
  • усложнению оптимизации запросов.

Индексирование должно быть минимально достаточным.

Покрывающие индексы и оптимизация чтения

Dexie.js не поддерживает классические covering indexes как SQL, но эффект достигается за счёт структуры запроса. Если индекс содержит все поля, используемые в фильтрации и сортировке, чтение выполняется без обращения к основной таблице.

Пример:

db.orders.where('[userId+status+createdAt]').between(...)

При правильной структуре индекса большинство операций выполняется на уровне B-tree без дополнительных lookups.

Индексы и сортировка

IndexedDB использует индексы для сортировки результатов. Если запрос требует orderBy, но индекс отсутствует, происходит полный перебор.

Пример:

db.users.orderBy('createdAt')

Оптимальный вариант — наличие индекса:

users: '++id, createdAt'

Сортировка по неиндексированному полю приводит к загрузке всей коллекции в память.

Динамические сценарии и ограничения индексов

Dexie.js не поддерживает произвольные SQL-подобные планы выполнения. Это означает, что структура индексов должна заранее учитывать все возможные формы запросов.

Типичная проблема:

  • необходимость фильтрации по нескольким независимым полям;
  • отсутствие составного индекса под конкретную комбинацию.

В таких случаях применяется компромисс:

  • либо добавляется compound индекс;
  • либо используется несколько запросов с последующей агрегацией;
  • либо выполняется фильтрация в памяти, что ухудшает производительность.

Версионирование схемы и миграция индексов

Изменение индексов требует увеличения версии базы:

db.version(5).stores({
  users: '++id, [country+city], createdAt'
});

При изменении структуры:

  • старые индексы удаляются;
  • создаются новые;
  • выполняется переиндексация данных.

Переиндексация является дорогостоящей операцией и особенно заметна при больших объёмах данных.

Ошибки проектирования индексов

Наиболее распространённые ошибки:

  • индексация всех полей без анализа запросов;
  • игнорирование порядка полей в compound индексах;
  • использование индексов для полей с низкой селективностью;
  • отсутствие индекса под сортировку;
  • попытка заменить индексами логику бизнес-фильтрации.

Каждая из этих ошибок приводит либо к избыточной нагрузке на запись, либо к деградации чтения.

Стратегия проектирования индексов

Рациональный подход строится вокруг анализа запросов:

  • фиксируются реальные паттерны чтения;
  • выделяются поля фильтрации и сортировки;
  • группируются часто используемые комбинации;
  • минимизируется число индексов;
  • проверяется влияние на операции записи.

Эта модель приводит к схеме, где каждый индекс обслуживает конкретный класс запросов, а не абстрактную структуру данных.

Баланс между чтением и записью

Dexie.js и IndexedDB в целом оптимизированы под сценарии, где чтение преобладает над записью. Индексы усиливают этот дисбаланс: чтение становится быстрее, запись — дороже.

Оптимальная конфигурация индексов всегда определяется профилем нагрузки:

  • read-heavy приложения допускают больше индексов;
  • write-heavy системы требуют минимальной индексации;
  • гибридные сценарии требуют строгого отбора compound индексов.