ETS: быстрое хранилище в памяти

Когда состояния в процессе мало: общая таблица в памяти.

ETS (Erlang Term Storage) — встроенное хранилище данных в оперативной памяти, позволяющее быстро хранить и искать кортежи по ключу из множества процессов.

Хранить состояние в gen_server удобно, но у него есть предел: все запросы идут через один процесс, и при большой нагрузке он становится узким местом. Каждый вызов gen_server:call встаёт в очередь почтового ящика и обрабатывается строго по одному. Пока сервер занят тяжёлым запросом, остальные ждут — даже те, кто просто хочет прочитать одно поле. Это разумная цена за безопасность, когда данных немного и они часто меняются. Но когда нужно делить большой объём данных между процессами с быстрым доступом, последовательная обработка превращается в бутылочное горлышко, и на сцену выходит ETS.

Зачем нужен ETS

Представьте кэш на сотни тысяч записей, к которому одновременно обращаются тысячи процессов. Гонять всё через один gen_server — значит выстроить очередь длиной в тысячи запросов: каждый ждёт, пока сервер освободится. ETS решает это иначе. Таблица живёт вне процессов, в отдельной области памяти виртуальной машины, и читать из неё могут параллельно многие процессы одновременно, без посредника и без очереди. Аналогия проста: gen_server — это библиотекарь за стойкой, который выдаёт книги по одной; ETS — это открытый читальный зал, где каждый сам берёт нужную книгу с полки. Пока никто не переставляет книги, сотни читателей не мешают друг другу.

Важно понимать, что ETS не отменяет gen_server, а дополняет его. Распространённый зрелый приём — связка: gen_server владеет таблицей и отвечает за запись, а чтение процессы делают напрямую из ETS, минуя сервер. Так горячий путь чтения становится параллельным, а редкие записи остаются под контролем одного процесса.

Создание и работа с таблицей

%% Создаём таблицу
Tab = ets:new(users, [set, public]),

%% Вставляем кортежи (первый элемент — ключ)
ets:insert(Tab, {1, "Анна", 30}),
ets:insert(Tab, {2, "Борис", 25}),

%% Читаем по ключу
ets:lookup(Tab, 1).
%% [{1, "Анна", 30}]

Данные в ETS — обычные кортежи Erlang, ничего экзотического. По умолчанию ключом служит первый элемент кортежа, но это можно изменить опцией {keypos, N}, если ключ удобнее держать не на первой позиции (например, когда кортеж — это запись и идентификатор лежит во втором поле). Поиск по ключу очень быстр: для таблиц-хеш это в среднем константное время, не зависящее от размера таблицы. Можно положить десять записей или десять миллионов — стоимость lookup по ключу почти не изменится. Именно эта предсказуемость делает ETS удобным фундаментом для кэшей, индексов и счётчиков.

Помимо вставки и чтения, у ETS богатый набор операций: ets:delete/2 удаляет запись по ключу, ets:update_counter/3 атомарно увеличивает числовое поле (удобно для счётчиков без гонок), ets:tab2list/1 выгружает всё содержимое в список, а ets:info/1 возвращает метаданные таблицы — размер, тип, владельца. Эти функции покрывают большинство повседневных задач, а для сложных выборок есть match и select, к которым мы вернёмся ниже.

Типы таблиц

ТипСвойство
setодин объект на ключ (по умолчанию)
ordered_setодин на ключ, отсортированы по ключу
bagнесколько объектов на ключ, без дублей
duplicate_bagнесколько на ключ, дубли разрешены

Выбор типа — это выбор поведения и структуры данных под капотом. set берут по умолчанию: один объект на ключ, и новая вставка с тем же ключом перезаписывает старую — идеально для кэша «ключ — значение». ordered_set нужен, когда важен порядок: ключи хранятся отсортированными, и можно эффективно обходить диапазоны или искать ближайший ключ. bag и duplicate_bag позволяют держать под одним ключом несколько записей — удобно для отношения «один ко многим», например список заказов одного пользователя. Разница между ними в том, разрешает ли таблица абсолютно идентичные дубликаты. Выбрав тип один раз при создании, поменять его на лету уже нельзя — таблицу пришлось бы пересоздавать.

Права доступа

Опции public, protected, private задают, кто может читать и писать. protected (по умолчанию) — пишет владелец, читают все. public — пишут и читают все, удобно для разделяемого кэша, но требует аккуратности. private — и читает, и пишет только владелец; таблица превращается в приватное хранилище процесса, недоступное снаружи. Выбор уровня доступа — это решение об инвариантах: чем шире права на запись, тем больше процессов могут одновременно менять данные, и тем тщательнее нужно следить, чтобы они не затирали изменения друг друга. Хорошее правило: открывайте на запись ровно столько, сколько требует задача, и не больше. Если запись делает один процесс, оставьте protected — это и быстрее рассуждать, и безопаснее.

Выборки по образцу

ETS умеет искать не только по ключу. match и select позволяют выбирать записи по шаблону, что напоминает простой запрос к таблице. В шаблоне '_' означает «любое значение в этой позиции», а переменные вида '$1', '$2' отмечают поля, которые нужно вернуть. Это похоже на pattern matching, только описанный данными, а не синтаксисом языка. select мощнее: он принимает так называемую match-спецификацию, которая помимо шаблона умеет задавать условия (например, «возраст больше 18») и форму результата. Важная оговорка про производительность: и match, и select по полям, не являющимся ключом, перебирают таблицу целиком — это полный скан. Если такой поиск выполняется часто, разумнее завести вторую ETS-таблицу-индекс, где ключом будет нужное поле, и держать её в синхроне с основной.

%% Найти всех с возрастом 30 (третий элемент)
ets:match(Tab, {'_', '$1', 30}).
%% Вернёт имена тех, кому 30

Важное ограничение: владение

У таблицы есть владелец — процесс, её создавший. Если владелец умирает, таблица по умолчанию исчезает вместе с ним. Это прямое следствие философии Erlang: ресурс привязан к процессу, и судьба ресурса разделяет судьбу его хозяина. На практике это значит, что таблицы, которые должны пережить отдельные сбои, нельзя создавать в коротком обработчике запроса или временном рабочем процессе — стоит ему упасть, и кэш пропадёт. Такие таблицы создают в долгоживущем процессе под супервизором, чтобы их время жизни совпадало со временем жизни всей подсистемы.

Есть и инструменты на пограничные случаи. Опция {heir, Pid, Data} при создании назначает «наследника»: если владелец умрёт, таблица не исчезнет, а перейдёт указанному процессу вместе с сообщением. А функция ets:give_away/3 позволяет передать владение другому процессу вручную — например, чтобы временный процесс подготовил таблицу, а затем отдал её постоянному хранителю. Эти механизмы решают типичную задачу: пережить рестарт владельца под супервизором, не потеряв накопленные данные.

Как работает под капотом

ETS хранит данные в нативных структурах вне куч процессов, поэтому при чтении/записи термы копируются между ETS и процессом. Это плата за разделяемость: процессы по-прежнему не делят свою память, но ETS даёт общий «склад». Когда процесс делает lookup, найденный кортеж копируется из памяти таблицы в кучу процесса; когда делает insert — наоборот, копируется из кучи в таблицу. Никаких ссылок на чужую память не возникает, и это сохраняет ключевое свойство модели акторов: один процесс не может случайно «дотянуться» до данных другого. Внутри set и bag используют хеш-таблицы (доступ за константное время в среднем), а ordered_set — сбалансированное дерево (доступ за логарифм, зато с порядком).

Чтения хорошо распараллеливаются, что и делает ETS быстрым под нагрузкой. По умолчанию операции над таблицей атомарны и изолированы на уровне отдельного вызова: одна вставка либо целиком видна, либо нет — половинчатого состояния не бывает. Но это не транзакции: две операции, идущие подряд, не образуют единого атомарного блока, и между ними может вклиниться чужое изменение. Для современных нагрузок есть опции read_concurrency и write_concurrency, которые подсказывают виртуальной машине, как настроить внутреннюю блокировку под преобладающий характер доступа — много чтений или много параллельных записей. Если же нужны настоящие многошаговые транзакции и хранение на диске, поверх ETS строят Mnesia, а для простого дискового хранилища существует DETS.

Частые ошибки

  • Создавать таблицу в недолговечном процессе. Умрёт процесс — пропадёт таблица.
  • Считать ETS персистентным. Это память; при перезапуске узла данные исчезнут (для диска есть DETS/Mnesia).
  • Часто копировать большие термы. Помните: каждое чтение/запись копирует данные.

Итоги

  • ETS — быстрое хранилище кортежей в памяти, доступное многим процессам.
  • Типы таблиц: set, ordered_set, bag, duplicate_bag.
  • Доступ настраивается через public/protected/private; владелец — создавший процесс.
  • ETS снимает узкое место одного gen_server, но данные живут только в памяти.
Проверьте себя
1. Чем ETS полезен по сравнению с хранением состояния в gen_server?
AОн персистентный
BПозволяет многим процессам параллельно читать данные без единого процесса-посредника
CОн медленнее, но надёжнее
DОн работает только на диске
2. Что произойдёт с таблицей ETS, если её процесс-владелец умрёт?
AТаблица сохранится на диск
BПо умолчанию таблица исчезнет вместе с владельцем
CВладельцем станет супервизор автоматически
DНичего
3. Чем тип ordered_set отличается от set?
AНичем
Bordered_set хранит записи отсортированными по ключу (дерево), set — хеш-таблица без порядка
Cset допускает дубликаты
Dordered_set хранит данные на диске