Составные индексы

Составные индексы в Dexie.js представляют собой механизм индексирования, при котором в качестве ключа используется комбинация нескольких полей объекта. Такая конструкция позволяет эффективно выполнять запросы по нескольким атрибутам одновременно, избегая необходимости сканирования всей таблицы и существенно ускоряя выборки при сложной фильтрации.

В основе Dexie.js лежит IndexedDB, где составные индексы реализованы через строковую декларацию вида [field1+field2+field3]. Порядок полей в такой конструкции имеет принципиальное значение: он определяет лексикографический порядок сортировки и правила использования индекса при запросах.


Составной индекс задаётся внутри схемы версии базы данных:

const db = new Dexie("app-db");

db.version(1).stores({
  users: "++id, firstName, lastName, age, [firstName+lastName]"
});

Здесь индекс [firstName+lastName] создаёт комбинированный ключ, по которому можно выполнять быстрые выборки.

Допускается также использование составного индекса как первичного ключа:

db.version(1).stores({
  users: "[firstName+lastName], age"
});

В этом случае комбинация firstName + lastName становится уникальным идентификатором записи, а отдельного id уже нет.


Принцип работы составного индекса

Составной индекс хранит значения как упорядоченную пару (или кортеж), которая сравнивается лексикографически:

[firstName, lastName]

Сравнение происходит поэтапно:

  1. Сначала сравнивается firstName
  2. Если они равны — сравнивается lastName

Это влияет на:

  • порядок сортировки результатов
  • возможность использования диапазонных запросов
  • эффективность поиска

Пример упорядочивания:

("Alex", "Brown")
("Alex", "Cooper")
("Bob", "Adams")
("Bob", "Smith")

Запросы по составному индексу

Dexie.js предоставляет API where для работы с индексами. Для составных индексов используется массив значений:

db.users
  .where("[firstName+lastName]")
  .equals(["Alex", "Brown"])
  .toArray();

Этот запрос использует индекс напрямую и выполняется за O(log n), избегая полного перебора таблицы.


Точное совпадение по нескольким полям

Составной индекс особенно полезен при строгом совпадении нескольких полей:

db.users
  .where("[firstName+lastName]")
  .equals(["John", "Doe"])
  .first();

Такой подход заменяет комбинацию:

  • where(firstName).equals(...)
    • дополнительную фильтрацию по lastName

что значительно эффективнее.


Диапазонные запросы

Составные индексы поддерживают диапазоны, но только с соблюдением правил лексикографического порядка.

Пример:

db.users
  .where("[firstName+lastName]")
  .between(
    ["A", ""],
    ["B", ""],
    true,
    true
  )
  .toArray();

Этот запрос выбирает все записи, где firstName находится в диапазоне от “A” до “B”.

Важно понимать, что диапазон корректно работает только по левому префиксу индекса.


Принцип левого префикса

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

Индекс:

[firstName+lastName+age]

Поддерживаемые сценарии:

  • ✔ firstName
  • ✔ firstName + lastName
  • ✔ firstName + lastName + age

Неподдерживаемые или неэффективные:

  • ✖ lastName + age
  • ✖ только age

Это связано с тем, что структура индекса физически отсортирована по первому полю.


Использование orderBy с составными индексами

Dexie позволяет сортировать данные по составному индексу:

db.users
  .orderBy("[firstName+lastName]")
  .toArray();

Результат будет отсортирован сначала по firstName, затем по lastName.

Если требуется обратный порядок:

db.users
  .orderBy("[firstName+lastName]")
  .reverse()
  .toArray();

Частичные совпадения и ограничения

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

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

db.users
  .where("[firstName+lastName]")
  .equals(["", "Smith"]);

Потому что firstName должен участвовать в поиске.

В таких случаях применяются альтернативные подходы:

  • дополнительный индекс [lastName]
  • фильтрация через .filter() (с потерей производительности)
  • перестройка схемы индексов

Уникальность составных индексов

Если индекс используется как уникальный, Dexie гарантирует отсутствие дубликатов комбинации значений:

db.version(1).stores({
  users: "++id, [email+country]"
});

Теперь невозможно добавить два объекта с одинаковыми email и country.


Вставка данных с составным индексом

Данные добавляются обычным способом:

await db.users.add({
  firstName: "Alex",
  lastName: "Brown",
  age: 32
});

Dexie автоматически формирует ключ индекса [firstName+lastName] из объекта.


Обновление данных и влияние на индекс

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

await db.users.update(1, {
  firstName: "Alexander"
});

В этом случае:

  • старый индекс удаляется
  • новый вычисляется и вставляется в B-tree структуру IndexedDB

Частые обновления индексируемых полей могут влиять на производительность.


Составные индексы и производительность

Использование составных индексов даёт значительный выигрыш при:

  • поиске по нескольким полям одновременно
  • сортировке сложных наборов данных
  • проверке уникальности комбинаций

Однако избыточное индексирование приводит к:

  • увеличению размера базы
  • замедлению вставки и обновления
  • росту затрат памяти IndexedDB

Баланс между количеством индексов и скоростью операций является ключевым фактором проектирования схемы.


Несколько составных индексов в одной таблице

Dexie позволяет объявлять несколько комбинированных индексов:

db.version(1).stores({
  users: "++id, firstName, lastName, age, [firstName+lastName], [lastName+age]"
});

Это даёт гибкость запросов:

  • поиск по имени и фамилии
  • сортировка по фамилии и возрасту

При этом каждый индекс поддерживает свою B-tree структуру.


Использование с методами chaining API

Составные индексы хорошо сочетаются с цепочками Dexie:

db.users
  .where("[firstName+lastName]")
  .between(["A", ""], ["M", "zzz"])
  .filter(user => user.age > 18)
  .limit(50)
  .toArray();

Здесь:

  • индекс ускоряет первичную выборку
  • .filter() применяется уже к уменьшенному набору данных

Сравнение с отдельными индексами

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

db.version(1).stores({
  users: "++id, firstName, lastName"
});

Однако запрос:

db.users
  .where("firstName").equals("Alex")
  .and(u => u.lastName === "Brown")

будет менее эффективным, чем:

.where("[firstName+lastName]").equals(["Alex", "Brown"])

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


Ключевые особенности поведения индексов

  • порядок полей критически важен
  • индекс хранится как единый составной ключ
  • сравнение происходит лексикографически
  • поддерживаются equality и range операции
  • правые поля без левых не используются эффективно

Типичные ошибки при проектировании

Часто встречающиеся проблемы:

  • неверный порядок полей в индексе
  • попытка фильтрации по “второму” полю без первого
  • создание избыточного числа комбинированных индексов
  • использование .filter() вместо корректного индекса

Эти ошибки приводят к деградации производительности и росту времени выборки.


Практическая модель выбора составного индекса

Составной индекс оправдан, если:

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

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