Атомарность update и её реальные границы

idb-keyval — это небольшая обёртка над IndexedDB, упрощающая работу с ключ–значение хранилищем в браузере. Одной из ключевых операций является функция update, позволяющая модифицировать текущее значение по ключу атомарно. Под атомарностью здесь подразумевается, что операция чтения, модификации и записи выполняется как единый блок: промежуточные состояния недоступны другим операциям.

Простейший пример использования update:

import { update } from 'idb-keyval';

update('counter', value => (value || 0) + 1)
  .then(newValue => console.log('Обновлённое значение:', newValue));

В этом примере:

  • Сначала считывается текущее значение по ключу 'counter'.
  • Затем применяется функция преобразования (value || 0) + 1.
  • Наконец, результат записывается обратно в базу.

На первый взгляд операция выглядит атомарной: никто не видит промежуточное состояние, и итоговое значение всегда корректно увеличено на 1.


Реальные границы атомарности

Несмотря на обещание атомарности, idb-keyval не превращает update в полноценную транзакцию на уровне всей базы. Основные ограничения:

  1. Ограничение на одну запись update работает атомарно только для одного ключа. Если требуется обновление нескольких ключей одновременно, никакой встроенной защиты от гонок нет: два параллельных update на разные ключи могут пересекаться, создавая потенциальные конфликты при логике, зависящей от нескольких значений.

  2. Нет блокировок на уровне базы IndexedDB обеспечивает атомарность транзакций в пределах одной записи, но не блокирует другие операции. Это значит, что параллельные вызовы update на один ключ могут считывать одно и то же исходное значение и одновременно его перезаписать. Результат может быть неожиданным:

    // Допустим, значение counter = 0
    const increment = () => update('counter', v => (v || 0) + 1);
    
    Promise.all([increment(), increment()]).then(console.log);
    // Возможные результаты: 1, 1 или 2

    Здесь два обновления могут сработать на одной и той же исходной базе 0, и оба записать 1, что ломает ожидаемую атомарность.

  3. Асинхронная природа операций update возвращает Promise, но сама модификация делается в рамках одной транзакции IndexedDB. Любые дополнительные операции до или после update происходят асинхронно и не гарантируют согласованности с другими параллельными обновлениями.

  4. Сложные функции обновления Функция преобразования может быть синхронной или асинхронной. Если функция возвращает Promise, idb-keyval корректно ждёт её завершения, но атомарность всё равно ограничена отдельным ключом:

    update('userSettings', async settings => {
      const newTheme = await fetchThemeFromServer();
      return { ...settings, theme: newTheme };
    });

    Здесь update гарантирует, что для userSettings промежуточного состояния не будет, но параллельные вызовы могут привести к потерям обновлений.


Практические рекомендации

  • Использовать update для простых ключей: счётчики, флаги, небольшие объекты, где вероятность параллельных изменений невелика.
  • Для критически важных данных применять собственную систему блокировок или очередь обновлений. Например, можно использовать mutex на уровне Jav * aScript:
let lock = Promise.resolve();

function lockedUpdate(key, fn) {
  lock = lock.then(() => update(key, fn));
  return lock;
}
  • Избегать параллельных асинхронных операций на один ключ, если точность важна. В противном случае возможны гонки и потеря данных.
  • Проверять результаты update, особенно при цепочках асинхронных операций, чтобы убедиться, что значение изменилось ожидаемым образом.

Механизм работы update внутри idb-keyval

  1. Открывается транзакция readwrite для выбранного хранилища (store) IndexedDB.
  2. Выполняется get(key), считывается текущее значение.
  3. Вызывается пользовательская функция обновления.
  4. Полученный результат записывается обратно через put(key, newValue).
  5. Транзакция коммитится, и промис возвращает новое значение.

Важно отметить, что транзакция закрывается после завершения одной записи. IndexedDB не предоставляет встроенной блокировки на уровне ключа или базы, поэтому атомарность update следует рассматривать как «атомарность отдельной транзакции для одного ключа», а не как универсальную защиту от гонок.


Заключение о границах

update обеспечивает удобный способ изменять значения без явного get и put, сокращая шаблон кода и минимизируя риск ошибок. Однако его атомарность строго ограничена:

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

Для сценариев, где требуется атомарная обработка нескольких ключей или предотвращение гонок при частых параллельных обновлениях, idb-keyval нужно использовать совместно с внешними механизмами синхронизации.