Mikhail Kuzmin

Datomic

Я удалил Datomic из проекта.

В апреле 2023 Datomic стал бесплатным, и я загорелся идеей добавить его в проект, т.к. была потребность в транзакционной БД и кэшировании данных в приложении. За эти 2 года он так и не прижился у нас. Расскажу в этой заметке немного про его внутренности и недостатки.

Чтобы понимать мою экспертную экспертность в вопросе

  1. я контрибьютил в DataScript
  2. я написал Hazel - читателя индексов DataScript на js.

Т.е. я глубже всех понимаю дизайн и внутреннее устройство Datomic OnPrem.

Изначально я рассматривал датомик для

  1. генерации коротких (int64) id для сущностей
  2. хранения настроек приложений
  3. простой проверки, что данные изменились
  4. хранения больших документов - описаний дашбордов
  5. локального кэша с низкой задержкой, т.к. данные запрашиваются очень часто

Утечка T-counter

Датомик хранит данные тройками [entity attribute value]. entity тут как раз номер - идентификатор сущности.

Узнать подробности о структуре битов можно из статьи Datomic Entity Id and Datom Internals.

Для нас тут важно, что независимо от партиции на t-counter выделяется 42 бита. Этот счетчик монотонно растет при создании новой сущности.

Давайте прикинем на сколько этого хватит

2^42 / 5000TPS / 60 / 60 / 24 / 365 = 27 years. Т.е. мы можем коммитить 27 лет 5000 транзакций в секунду, где создается только одна сущность - сама транзакция. Т.к. датомик создает сущность, описывающую транзакцию. В реальности же на одну транзакцию будет создание пары сущностей.

Вроде бы числа получаются большие, но недостаточно.

Например, если мы выполняли транзакцию, которая не изменила данные, то счетчик все равно увеличится, т.к. датомик записывает время выполнения транзакции, а значит создает сущность транзакции. Это важно понимать при выполнении “upsert”.

Запросы к N базам данных (партицирование)

Казалось бы, если не хватает одной базы данных, сделай по базе данных на каждый год или месяц.

Хотя Датомик и позволяет делать запросы одновременно к нескольким БД, число этих БД должно быть заранее известно:

(d/q '[:find ,,,
       :in $1 $2 ,,,
       :where
       [$1 ,,,]
       [$2 ,,,] ,,,]
     (d/db conn1)
     (d/db conn2)
     ,,,)

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

  1. найти нужные данные
  2. в какой именно БД лежат эти данные
(d/q '[:find $db
       :in [$db ...] ,,, ;; так нельзя :'(
       :where
       [$db ,,,] ,,,
     [(d/db conn1)
      (d/db conn2)
      ,,,])

Декантирование

В сообществе появился термин Decanting - переливание данных из одного инстанса Datomic в новый путем чтения лога транзакций и фильтрацией ненужных записей.

Это нужно, когда история занимает слишком много данных, или нужно удалить много данных.

Возвращаясь к утечке t-counter - декантирование может помочь, но только если entid оставались приватными, а в качестве публичных ключей использовался UUID или что-то похожее. Т.е. при декантировании мы могли бы изменять entid сущностей.

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

API для удаления данных

Для некоторых проектов важно иметь возможность надежно удалить данные. В datomic есть api для этого. Но там есть много нюансов, в том числе связанных с производительностью. Например удаление может остановить транзакции.

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

Multitenancy

Может показаться, раз у нас БД как значение и мы можем применять d/filter, то проблем с multitenancy нет. Но это не так. В документации или сообществе нет никаких наработок в этой области.

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

От себя замечу, что если можно ограничить счетчик клиентов сверку в полмиллиона (524288), то можно разделять их по разным implicit partitions. Но это именно счетчик, а не количество активных клиентов, этот счетчик так же может утекать.

Индексы

Есть EAVT, AEVT, AVET, VAET. Для поиска по значению атрибута будет использоваться AVET. И это одна из причин, почему все говорят, что датомик медленный. Он не медленный, просто нужна экспертиза, чтобы учитывать раскладку данных в индексе.

Например, у нас есть Платформа приложения (java, ruby, ….) и Имя приложения. И зная их, мы хотим найти Идентификатор приложения (пусть будет UUID). Кроме того, они содержатся в аккаунтах, но оставим это за скобками.

Нам нужно выбрать, какой атрибут поставить первым в :where: Платформу или Имя. Нужно выбрать Имя, как самый высоко кардинальный (селективный) атрибут. Но что, если и платформ тоже много, и одно Имя встречается во многих Платформах? Тогда мы выберем много лишних данных, что снизит скорость запроса.

Решение этой проблемы - Composite tuples. С их помощью мы сможем положить в один атрибут и Платформу и Имя, тем самым увеличив кардинальность множества значений.

Вот пример Composite tuples:

{:db/ident       :my-proj/account+platform+name
 :db/valueType   :db.type/tuple
 :db/tupleAttrs  [:my-proj/account :my-proj/platform :my-proj/name]
 :db/cardinality :db.cardinality/one
 :db/unique      :db.unique/identity}

У этого есть проблемы. Нужно учитывать это при создании схемы. А если мы не учли, то нужен процесс миграции, чтобы добавить этот атрибут существующим сущностям. А это куда сложнее, чем CREATE INDEX CONCURRENTLY.

AEVT

Я так и не придумал зачем нужен этот индекс. Если кто-то знает - расскажите.

Проблема в том, что из-за него данные хранятся 2 раза: в EAVT и в AEVT.

Транзакции не видят свои изменения

Transaction function это функция, которая выполняется в транзакторе. Она принимает базу, параметры и возвращает tx-data.

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

Health check

У датомика есть дурацкая проблема с количеством тредов, отведенных на health-check endpoint, внутри контейнера. ping-concurrency

Почему я должен разбираться в этом?

Docker

Нет официального docker-image. Нет конфигурации через env.

Deploy

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

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

Доступ к данным без репл

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

Бывает, что БД живет дольше, чем код приложения. Т.е. приложение устаревает, пишут новое приложение, а БД остается.

С Датомиком это почти наверняка не сработает.

Масштабирование

Я не знаю истории, и предпосылок появления Датомика. Но за эти 10 лет сервера стали очень мощными. Если вы не “Яндекс”, то вы очень долго сможете расти вертикально.

Многие БД позволяют потоково читать данные. И это решение, если вам нужно сделать отчет по всем данным, что не помещается в память приложения.

Т.е. масштабирование чтения (Peer) в датомике теряет актуальность.

Локальный кэш

В Датомике есть in-memory или дисковый (Valcache) кэш.

И важно отметить, что если вы берете SQL DB + Cache, то все ваши запросы становятся key-value, вы теряете QL.

А у датомика кэшируются части B+ tree индекса, что оставляет полноценные запросы.

Я не делал полноценные бенчмарки, но select count(*) ... в Postgres в докере в виртуалке с большим отрывом обгонял подобный запрос на полностью закэшированной в Valcache бд в Датомике. Да, сравнение никакое, но заставляет задуматься.

С другой стороны, а часто ли нужно делать консистентный поиск по диапазонам в кэше?

Полнотекстовый поиск

В Датомике такая поддержка полнотекста, что считай, что ее нет.

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

Добавление новой БД полностью убивает все преимущества Датомика.

  1. даже используя in-memory Датомика в тестах, вам нужно поднимать еще одну БД
  2. ее нужно деплоить и скейлить
  3. держать в голове еще одну парадигму хранения данных

Большие строки

В Датомике не стоит хранить большие строки, а тем более их индексировать. И документация рекомендует положить их “куда-то еще”.

Если датомик использует другие базы данных для хранения сегментов, то почему бы не сделать api для хранения BLOB?

Типы данных

Вы не можете сделать свой тип атрибута. Конечно, я слышал, что вроде бы можно как-то подлезть в fressian.

java.time уже много лет доступно, но команда не торопится добавлять поддержку этих новых типов.

Заключение

Если вы знаете примеры успешного использования Datomic - поделитесь, пожалуйста, опытом.