Производительность запросов в 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)
браузер использует внутреннее дерево индексов.
Вместо проверки каждой записи происходит:
В результате количество операций сокращается на порядки.
Особенно заметна разница при таблицах размером:
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, индекс для
него будет бесполезным.
Избыточное индексирование приводит к обратному эффекту — операции записи начинают работать медленнее.
Выбор стратегии индексирования зависит от характера приложения.
Примеры:
Для таких систем оправдано создание большого количества индексов.
Пример:
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]
`
Типичные запросы:
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
`
Многие поля никогда не участвуют в поиске.
Ошибка:
db.users.filter(
user => user.status === 'active'
);
Если существует индекс:
status
следует использовать:
db.users
.where('status')
.equals('active');
Ошибка:
[city+country]
если большинство запросов начинается с country.
Лучше:
[country+city]
Каждый дополнительный индекс увеличивает стоимость записи:
add()
put()
bulkAdd()
bulkPut()
Поэтому индекс должен существовать только тогда, когда приносит реальную пользу запросам.
Эффективная схема индексирования обычно строится по следующему алгоритму:
Такой подход позволяет получить оптимальный баланс между скоростью чтения, скоростью записи и объёмом хранимых индексных структур, обеспечивая максимальную эффективность работы запросов в Dexie.js и IndexedDB.