Ключи, индексы и курсоры

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


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

Схема таблицы задаётся строкой, где первичный ключ обозначается специальными префиксами:

  • ++ — автоинкрементный ключ
  • & — уникальный индекс (может быть использован как альтернативный ключ, но не первичный)
  • без префикса — обычное поле

Пример:

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

Здесь id — первичный ключ, который автоматически увеличивается при каждой вставке.

Явные и составные первичные ключи

Dexie.js поддерживает составные ключи:

db.version(1).stores({
  orders: '[userId+orderId], amount, createdAt'
});

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

Особенности составного ключа:

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

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

Индекс в Dexie.js — это отдельная структура, ускоряющая доступ к данным по определённому полю или комбинации полей. Индексы устраняют необходимость полного сканирования таблицы при фильтрации.

Объявление индексов происходит в схеме:

db.version(1).stores({
  users: '++id, &email, name, age, *tags, [lastName+firstName]'
});

Здесь присутствуют разные типы индексов:

  • &email — уникальный индекс
  • name, age — обычные индексы
  • *tags — multiEntry индекс
  • [lastName+firstName] — составной индекс

Уникальные индексы

Уникальный индекс гарантирует, что значения в поле не повторяются:

&email

Попытка вставить запись с уже существующим email приведёт к ошибке. Это поведение аналогично уникальным ограничениям в реляционных базах данных.

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

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

name, age

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


MultiEntry индексы

MultiEntry индекс (*) используется для массивов. Каждый элемент массива индексируется отдельно:

tags: '*tags'

Если запись содержит:

{
  id: 1,
  tags: ['js', 'indexeddb', 'storage']
}

то индекс создаёт три записи в индексной структуре. Это позволяет выполнять запросы по отдельным элементам массива:

db.users.where('tags').equals('js')

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


Составные индексы и правила лексикографического порядка

Составной индекс объявляется через []:

'[lastName+firstName]'

Он используется для:

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

Лексикографическое сравнение

Dexie.js (через IndexedDB) сравнивает составные ключи последовательно:

  1. сравнивается lastName;
  2. при равенстве — firstName.

Это означает, что запросы эффективно работают только при соблюдении левой части ключа (leftmost prefix rule).

Пример:

db.users.where('[lastName+firstName]')
  .between(['Ivanov', 'A'], ['Ivanov', 'Z']);

Такой запрос корректно использует индекс. Однако запрос только по firstName индекс использовать не сможет.


Использование индексов в запросах

Dexie.js предоставляет API where, которое автоматически выбирает индекс, если он существует.

Равенство

db.users.where('age').equals(25);

Используется индекс age, если он объявлен.

Диапазоны

db.users.where('age').above(18);
db.users.where('age').below(60);
db.users.where('age').between(18, 60);

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

Множественные значения

db.users.where('age').anyOf([20, 25, 30]);

Индекс используется для нескольких точек поиска без линейного прохода.


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

Метод orderBy использует индекс напрямую:

db.users.orderBy('age');

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

Дополнительные операции:

db.users.orderBy('age').reverse();

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


Курсоры и механизм обхода данных

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

Курсор — это указатель на текущую запись в индексированной структуре, который перемещается последовательно.

Итерация через Dexie.js

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

db.users.each(user => {
  console.log(user);
});

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

Аналогичные методы:

  • eachKey() — перебор только ключей
  • eachPrimaryKey() — перебор первичных ключей
  • eachUniqueKey() — перебор значений индекса без дубликатов

Работа курсоров на уровне коллекций

Dexie.js формирует коллекции через where и orderBy. Каждая коллекция может быть преобразована в поток курсора.

Пример:

db.users.where('age').above(18).each(user => {
  console.log(user.name);
});

На уровне IndexedDB это превращается в:

  • открытие курсора по индексу age;
  • установка границы > 18;
  • последовательный проход по записям.

Ограничение выборки и пагинация

Курсоры позволяют эффективно реализовывать пагинацию:

db.users.orderBy('id').offset(20).limit(10).toArray();
  • offset — пропуск первых элементов;
  • limit — ограничение количества записей.

Хотя offset удобен, он становится менее эффективным на больших наборах данных, поскольку курсор всё равно проходит пропущенные элементы.


Обратный порядок обхода

Dexie.js позволяет инвертировать курсор:

db.users.orderBy('age').reverse().each(user => {
  console.log(user.age);
});

Это не требует дополнительной сортировки — индекс просто читается в обратном направлении.


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

При изменении записи IndexedDB автоматически:

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

Dexie.js скрывает этот процесс, но он влияет на производительность при массовых обновлениях.


Особенности использования индексов в фильтрации

Метод filter выполняет пост-обработку:

db.users.filter(user => user.age > 18)

Такой код:

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

Правильный подход:

db.users.where('age').above(18)

Индексные ограничения и ошибки проектирования

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

  • отсутствие индекса для частых запросов;
  • чрезмерное использование multiEntry;
  • неправильный порядок полей в составных индексах;
  • попытка фильтрации через filter вместо where.

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


Внутренняя модель поиска через индексы

Dexie.js транслирует запросы в IndexedDB следующим образом:

  1. выбор подходящего индекса;
  2. открытие курсора с диапазоном;
  3. применение фильтров на уровне коллекции;
  4. возврат результата через промисы.

При этом:

  • индексы уменьшают пространство поиска;
  • курсоры обеспечивают последовательный доступ;
  • коллекции Dexie объединяют оба механизма в единый API.

Сочетание индексов и курсоров в реальных сценариях

Эффективные паттерны:

  • выбор диапазона через where;
  • сортировка через orderBy;
  • постраничная выборка через limit;
  • итерация через each вместо toArray при потоковой обработке.

Пример комбинированного подхода:

db.orders
  .where('createdAt')
  .between(startDate, endDate)
  .orderBy('createdAt')
  .reverse()
  .limit(100)
  .each(order => process(order));

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


Поведение индексов при масштабировании данных

При увеличении объёма данных эффективность индексов становится определяющим фактором:

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

Dexie.js не изменяет эту модель, но предоставляет инструменты для её правильного использования через декларативные запросы и управление коллекциями.