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

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

Каждая таблица IndexedDB представляет собой объектное хранилище (Object Store), внутри которого могут существовать первичный ключ и набор вторичных индексов. Поиск по индексированному полю выполняется значительно быстрее, чем последовательный просмотр всех записей.

Пример определения индексов:

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

В данном случае создаются:

  • первичный ключ id;
  • индекс email;
  • индекс age;
  • индекс country.

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


Стоимость полного сканирования таблицы

Наиболее медленной стратегией является чтение всех записей и последующая фильтрация в памяти.

Пример:

const users = await db.users
    .filter(user => user.country === 'Germany')
    .toArray();

Несмотря на внешнюю простоту, такой код заставляет IndexedDB последовательно читать каждую запись.

Сложность операции:

O(n)

где n — количество объектов в таблице.

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

Более эффективный вариант:

const users = await db.users
    .where('country')
    .equals('Germany')
    .toArray();

Теперь поиск использует индекс и не требует полного обхода данных.


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

Когда запрос выполняется через индекс:

db.users.where('email').equals(email)

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

Вместо проверки каждой записи происходит:

  1. переход к нужному узлу индекса;
  2. поиск значения;
  3. получение ссылки на объект;
  4. чтение объекта.

В результате количество операций сокращается на порядки.

Особенно заметна разница при таблицах размером:

100 000+
500 000+
1 000 000+

записей.


Когда индекс обязателен

Индекс необходим для полей, которые:

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

Например:

db.version(1).stores({
    orders: '++id, customerId, status, createdAt'
});

Хорошие кандидаты для индексации:

customerId
status
createdAt

Если запросы регулярно обращаются к этим полям, индекс практически обязателен.


Когда индекс не нужен

Создание индекса также требует ресурсов.

Каждый индекс:

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

Например:

db.version(1).stores({
    users: `
        ++id,
        email,
        firstName,
        lastName,
        phone,
        city,
        country,
        avatar,
        birthDate,
        gender
    `
});

Если поиск никогда не выполняется по avatar, индекс для него будет бесполезным.

Избыточное индексирование приводит к обратному эффекту — операции записи начинают работать медленнее.


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

Выбор стратегии индексирования зависит от характера приложения.

Системы с преобладанием чтения

Примеры:

  • каталоги товаров;
  • CRM;
  • справочники;
  • аналитические панели.

Для таких систем оправдано создание большого количества индексов.

Пример:

products:
'++id,
 category,
 brand,
 price,
 rating,
 stock,
 createdAt'

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

Системы с преобладанием записи

Примеры:

  • журналы событий;
  • системы телеметрии;
  • логирование;
  • сбор метрик.

Для них количество индексов желательно минимизировать.

Например:

logs:
'++id, timestamp'

Избыточные индексы будут замедлять массовую запись.


Использование первичного ключа

Самый быстрый запрос в IndexedDB — поиск по первичному ключу.

Пример:

const user = await db.users.get(15);

или:

const user = await db.users.where('id').equals(15).first();

Первый вариант предпочтительнее.

Метод get() сразу обращается к первичному ключу и не создает дополнительный запрос.


Выбор правильного первичного ключа

Первичный ключ влияет не только на идентификацию объектов, но и на скорость работы.

Автоинкрементный ключ:

'++id'

обычно обеспечивает наиболее стабильную производительность.

Пример объекта:

{
    id: 125,
    name: 'John'
}

Альтернативой может быть строковый ключ:

'email'

или

'uuid'

Например:

{
    uuid: 'c8b4e00c-8ec0-4e49-b357-0f3a4882dbe9'
}

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


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

Очень распространённая ошибка — использование нескольких одиночных индексов вместо одного составного.

Пусть существует таблица:

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

Запрос:

db.users
    .where('country')
    .equals('Germany')
    .and(user => user.city === 'Berlin')

использует индекс только для country.

Проверка города выполняется уже после получения данных.

Более эффективная схема:

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

Запрос:

db.users
    .where('[country+city]')
    .equals(['Germany', 'Berlin']);

Теперь обе части условия используют один индекс.


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

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

Для индекса:

[country+city+age]

доступны запросы:

country
country + city
country + city + age

Например:

.where('[country+city+age]')
.between(
    ['Germany', 'Berlin', 20],
    ['Germany', 'Berlin', 40]
)

Но запрос только по возрасту:

age = 30

не сможет использовать такой индекс.

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


Оптимизация диапазонных запросов

Одно из главных преимуществ индексов — быстрые диапазонные выборки.

Пример:

db.orders
    .where('createdAt')
    .between(
        startDate,
        endDate
    );

При наличии индекса операция выполняется эффективно даже на больших объёмах данных.

Без индекса пришлось бы:

db.orders
    .filter(order =>
        order.createdAt >= startDate &&
        order.createdAt <= endDate
    );

что потребовало бы просмотра всей таблицы.


Производительность сортировки

Многие разработчики не учитывают стоимость сортировки.

Медленный вариант:

const products = await db.products.toArray();

products.sort(
    (a, b) => a.price - b.price
);

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

Лучше:

await db.products
    .orderBy('price')
    .toArray();

Если поле индексировано, сортировка фактически выполняется самим индексом.


Индекс для пагинации

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

Пример эффективного решения:

await db.messages
    .orderBy('createdAt')
    .offset(0)
    .limit(50)
    .toArray();

Поле:

createdAt

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

Тогда сообщения извлекаются уже в правильном порядке.


Анализ реальных сценариев

Каталог интернет-магазина

Типичные запросы:

category
brand
price range
rating

Оптимальная схема:

products:
`
++id,
category,
brand,
price,
rating,
[category+brand]
`

CRM-система

Типичные запросы:

email
company
status
createdAt

Подходящая схема:

customers:
`
++id,
email,
company,
status,
createdAt,
[status+createdAt]
`

Чат-приложение

Основные запросы:

roomId
createdAt
roomId + createdAt

Схема:

messages:
`
++id,
roomId,
createdAt,
[roomId+createdAt]
`

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


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

При оптимизации полезно измерять время выполнения операций.

Пример:

const start = performance.now();

await db.users
    .where('country')
    .equals('Germany')
    .toArray();

const end = performance.now();

console.log(`Время: ${end - start} ms`);

Аналогично можно сравнивать:

where()

против

filter()

или

compound index

против

single index

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


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

Индексирование каждого поля

Ошибка:

users:
`
++id,
name,
surname,
phone,
avatar,
bio,
address,
notes
`

Многие поля никогда не участвуют в поиске.

Использование filter() вместо where()

Ошибка:

db.users.filter(
    user => user.status === 'active'
);

Если существует индекс:

status

следует использовать:

db.users
    .where('status')
    .equals('active');

Неправильный порядок полей в составном индексе

Ошибка:

[city+country]

если большинство запросов начинается с country.

Лучше:

[country+city]

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

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

add()
put()
bulkAdd()
bulkPut()

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


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

Эффективная схема индексирования обычно строится по следующему алгоритму:

  1. Определяются самые частые запросы.
  2. Выделяются поля поиска.
  3. Выделяются поля сортировки.
  4. Анализируются диапазонные выборки.
  5. Выявляются сочетания полей, регулярно используемые вместе.
  6. Создаются составные индексы для таких сочетаний.
  7. Исключаются индексы, не участвующие в реальных запросах.
  8. Выполняются измерения производительности на реальных объёмах данных.

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