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, но данные живут только в памяти.