ETS - cache w pamięci bez Redisa, który obsługuje milion odczytów na sekundę
Nowy projekt. Zespół decyduje: "potrzebujemy cache'a". Ktoś mówi "Redis". Nikt nie pyta dlaczego. Redis jest w Dockerfile, w docker-compose, w konfiguracji CI, w Terraform, w monitoringu, w alertach. Dodatkowy serwer, dodatkowy port, dodatkowy punkt awarii. Zespół spędza dwa dni na konfiguracji connection poolingu, retry logic i failover.
A potrzebowali cache'a na cennik, który zmienia się raz na godzinę.
Elixir ma wbudowany key-value store, który jest szybszy od Redisa o dwa rzędy wielkości. Nie wymaga sieci, nie wymaga serializacji, nie wymaga żadnej dodatkowej infrastruktury. Nazywa się ETS - Erlang Term Storage. Jest częścią BEAM od 1986 roku. I jest prawdopodobnie najlepszym sekretem Erlanga, o którym nikt nie mówi.
ETS vs Redis - liczby
| Operacja | ETS | Redis (localhost) | Redis (sieć) |
|---|---|---|---|
| Pojedynczy odczyt | ~0.5-2 μs | ~50-100 μs | ~200-500 μs |
| Pojedynczy zapis | ~0.5-2 μs | ~50-100 μs | ~200-500 μs |
| Throughput | ~8-10M ops/s | ~100-200K ops/s | ~50-100K ops/s |
| Batch 1000 kluczy | ~0.35 ms | ~82 ms | ~200+ ms |
ETS jest 100-250x szybszy od Redisa. Nie 2x, nie 10x - sto do dwustu pięćdziesięciu razy.
Dlaczego? Trzy powody:
- Zero sieci - ETS jest w pamięci procesu BEAM. Żaden TCP roundtrip, żaden socket, żaden context switch kernela
- Zero serializacji - dane przechowywane jako natywne termy Erlanga. Żaden JSON, żaden msgpack, żaden encode/decode
- Współbieżny dostęp - wiele schedulerów czyta i pisze równolegle z fine-grained locking. Redis jest single-threaded
I ta różnica rośnie wraz ze współbieżnością. ETS skaluje się liniowo z liczbą schedulerów BEAM. Redis, nawet z multi-threaded I/O w wersji 7, wykonuje operacje sekwencyjnie.
Czym jest ETS
ETS to in-memory key-value store wbudowany w maszynę wirtualną BEAM. Nie jest biblioteką, nie jest zależnością, nie jest serwisem. Jest częścią runtime'u - tak jak garbage collector czy scheduler.
Typy tabel
| Typ | Struktura | Klucze | Zastosowanie |
|---|---|---|---|
set (domyślny) | Hash table, O(1) | Unikalne | Cache, sesje, config |
ordered_set | Drzewo AVL, O(log n) | Unikalne, posortowane | Range queries, leaderboardy |
bag | Hash table | Wiele wartości per klucz | Tagi, kategorie |
duplicate_bag | Hash table | Duplikaty dozwolone | Logi zdarzeń |
Tworzenie tabeli
:ets.new(:product_cache, [
:set, # Typ: hash table
:named_table, # Dostęp po nazwie zamiast referencji
:public, # Dowolny proces może czytać i pisać
read_concurrency: true, # Optymalizacja dla współbieżnych odczytów
write_concurrency: true # Optymalizacja dla współbieżnych zapisów
])Jeden wiersz. Tabela gotowa. Żadnego docker run, żadnego config/redis.exs, żadnego connection poola. Tabela istnieje w pamięci BEAM i jest dostępna z każdego procesu.
Wzorzec 1: Cache z TTL
Redis ma wbudowane EXPIRE. ETS nie ma. Ale implementacja TTL w Elixirze to ~50 linii kodu, które piszesz raz:
defmodule MyApp.Cache do
use GenServer
@cleanup_interval :timer.seconds(30)
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def get(key) do
case :ets.lookup(:app_cache, key) do
[{^key, value, expires_at}] ->
if System.monotonic_time(:millisecond) < expires_at do
{:ok, value}
else
:ets.delete(:app_cache, key)
:miss
end
[] ->
:miss
end
end
def put(key, value, ttl_ms \\ 60_000) do
expires_at = System.monotonic_time(:millisecond) + ttl_ms
:ets.insert(:app_cache, {key, value, expires_at})
:ok
end
def fetch(key, ttl_ms \\ 60_000, compute_fn) do
case get(key) do
{:ok, value} -> {:ok, value}
:miss ->
value = compute_fn.()
put(key, value, ttl_ms)
{:ok, value}
end
end
def delete(key), do: :ets.delete(:app_cache, key)
@impl true
def init(_opts) do
:ets.new(:app_cache, [
:set, :named_table, :public,
read_concurrency: true, write_concurrency: true
])
schedule_cleanup()
{:ok, %{}}
end
@impl true
def handle_info(:cleanup, state) do
now = System.monotonic_time(:millisecond)
# Match spec: usuń wpisy gdzie expires_at < now
:ets.select_delete(:app_cache, [{{:_, :_, :"$1"}, [{:<, :"$1", now}], [true]}])
schedule_cleanup()
{:noreply, state}
end
defp schedule_cleanup do
Process.send_after(self(), :cleanup, @cleanup_interval)
end
endUżycie:
# Cache cennika na 5 minut
MyApp.Cache.put("pricing:standard", pricing_data, :timer.minutes(5))
# Fetch-or-compute: jeśli brak w cache, oblicz i zapisz
{:ok, report} = MyApp.Cache.fetch("report:daily", :timer.hours(1), fn ->
MyApp.Reports.generate_daily()
end)GenServer jest właścicielem tabeli ETS i odpowiada za cleanup. Ale odczyty i zapisy idą bezpośrednio do ETS - bez przechodzenia przez GenServer. Zero bottlenecka. 500 procesów może czytać z cache'a jednocześnie bez żadnej kolejki.
Wzorzec 2: Rate limiting
Rate limiting z Redisem wymaga sieci per request. Z ETS - jest atomową operacją w pamięci:
defmodule MyApp.RateLimiter do
use GenServer
@window_ms 60_000
@max_requests 100
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def check_rate(key) do
now = System.monotonic_time(:millisecond)
window_key = {key, div(now, @window_ms)}
# Atomowy increment-and-read - zero race conditions
count = :ets.update_counter(
:rate_limiter,
window_key,
{2, 1}, # Inkrementuj pozycję 2 o 1
{window_key, 0} # Domyślna krotka jeśli klucz nie istnieje
)
if count <= @max_requests, do: :ok, else: {:error, :rate_limited}
end
@impl true
def init(_) do
:ets.new(:rate_limiter, [
:set, :named_table, :public,
write_concurrency: true,
decentralized_counters: true # OTP 25+ - minimalna kontencja
])
# Cleanup starych okien co 10 sekund
:timer.send_interval(:timer.seconds(10), :cleanup)
{:ok, %{}}
end
@impl true
def handle_info(:cleanup, state) do
current_window = div(System.monotonic_time(:millisecond), @window_ms)
:ets.select_delete(:rate_limiter, [
{{{:_, :"$1"}, :_}, [{:<, :"$1", current_window - 1}], [true]}
])
{:noreply, state}
end
end:ets.update_counter/4 jest atomowy. Nawet przy 10 000 requestów na sekundę z różnych procesów, żaden nie zobaczy niespójnego stanu. Żaden lock, żaden mutex, żadna kolejka - operacja jest zaimplementowana w C w rdzeniu BEAM.
Plug do Phoenix:
defmodule MyAppWeb.Plugs.RateLimit do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
key = conn.remote_ip |> :inet.ntoa() |> to_string()
case MyApp.RateLimiter.check_rate(key) do
:ok -> conn
{:error, :rate_limited} ->
conn
|> put_resp_header("retry-after", "60")
|> send_resp(429, "Rate limit exceeded")
|> halt()
end
end
endWzorzec 3: Feature flagi
Feature flagi w bazie danych = zapytanie SQL per request. W Redisie = roundtrip sieciowy per request. W ETS = sub-mikrosekundowy odczyt z pamięci, odświeżanie z bazy co 30 sekund:
defmodule MyApp.FeatureFlags do
use GenServer
@refresh_interval :timer.seconds(30)
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def enabled?(flag_name, context \\ %{}) do
case :ets.lookup(:feature_flags, flag_name) do
[{^flag_name, config}] -> evaluate(config, context)
[] -> false
end
end
@impl true
def init(_) do
:ets.new(:feature_flags, [
:set, :named_table, :protected,
read_concurrency: true
])
load_from_db()
:timer.send_interval(@refresh_interval, :refresh)
{:ok, %{}}
end
@impl true
def handle_info(:refresh, state) do
load_from_db()
{:noreply, state}
end
defp load_from_db do
flags = MyApp.Repo.all(MyApp.FeatureFlag)
entries = Enum.map(flags, fn flag ->
{flag.name, %{
enabled: flag.enabled,
percentage: flag.rollout_percentage,
allowed_users: flag.allowed_user_ids
}}
end)
:ets.insert(:feature_flags, entries)
end
defp evaluate(%{enabled: false}, _), do: false
defp evaluate(%{enabled: true, allowed_users: users}, %{user_id: uid})
when is_list(users) and users != [] do
uid in users
end
defp evaluate(%{enabled: true, percentage: pct}, %{user_id: uid})
when is_integer(pct) do
:erlang.phash2(uid, 100) < pct
end
defp evaluate(%{enabled: true}, _), do: true
endTabela jest :protected - tylko GenServer (właściciel) pisze, wszystkie inne procesy czytają. Zerowy koszt synchronizacji, bo odczyty z :protected ETS z read_concurrency: true nie wymagają żadnych locków.
# W LiveView lub kontrolerze
if MyApp.FeatureFlags.enabled?(:new_checkout, %{user_id: user.id}) do
render_new_checkout(conn)
else
render_old_checkout(conn)
endKoszt sprawdzenia flagi: ~1 μs. Przy 1000 requestów na sekundę, każdy sprawdzający 5 flag = 5000 odczytów/s. Dla ETS to nawet nie rozgrzewka.
:persistent_term - jeszcze szybciej dla danych read-only
Jeśli dane zmieniają się rzadziej niż raz na minutę, :persistent_term jest 3-5x szybszy od ETS:
| Cecha | :persistent_term | ETS |
|---|---|---|
| Odczyt | ~30-47M ops/s | ~8-10M ops/s |
| Zapis | Bardzo wolny (globalny GC) | Szybki |
| Model pamięci | Współdzielona (zero kopii przy odczycie) | Kopia do heapu procesu |
| Zastosowanie | Konfiguracja, routing, regex | Cache, liczniki, sesje |
Dlaczego :persistent_term jest szybszy? ETS kopiuje dane do heapu procesu przy każdym odczycie. :persistent_term zwraca wskaźnik do współdzielonej pamięci - zero kopii. Tradeoff: każdy zapis wymusza globalny skan GC wszystkich procesów.
defmodule MyApp.Config do
@key {__MODULE__, :config}
def load! do
config = %{
api_url: System.fetch_env!("API_URL"),
max_upload_mb: String.to_integer(System.get_env("MAX_UPLOAD_MB", "10")),
feature_map: compile_features()
}
:persistent_term.put(@key, config)
end
# Nanosekundowy dostęp, zero kopii
def get, do: :persistent_term.get(@key)
def get(field), do: Map.fetch!(get(), field)
endReguła: jeśli dane zmieniają się rzadko i są czytane często - :persistent_term. Jeśli dane zmieniają się często (cache z TTL, liczniki, sesje) - ETS.
Cachex - jeśli nie chcesz pisać cache'a sam
Cachex to najpopularniejsza biblioteka cache'ująca w Elixirze. Zbudowana na ETS, dostarcza TTL, limity rozmiaru, warmery, hooki i więcej - z pudełka:
# mix.exs
{:cachex, "~> 4.0"}
# W drzewie supervisorów
children = [
{Cachex, name: :product_cache, expiration: expiration(default: :timer.minutes(5))}
]# Zapis z customowym TTL
Cachex.put(:product_cache, "product:123", product, expire: :timer.hours(1))
# Fetch-or-compute (unika thundering herd)
Cachex.fetch(:product_cache, "expensive:query", fn ->
result = MyApp.Repo.expensive_query()
{:commit, result, expire: :timer.minutes(10)}
end)
# Atomowy increment
Cachex.incr(:product_cache, "views:product:123", 1)Cachex ma też warmery - moduły, które pre-loadują dane do cache'a przy starcie i odświeżają periodycznie:
defmodule MyApp.ProductWarmer do
use Cachex.Warmer
@impl true
def interval, do: :timer.minutes(15)
@impl true
def execute(_state) do
entries =
MyApp.Repo.all(MyApp.Product)
|> Enum.map(fn p -> {"product:#{p.id}", p} end)
{:ok, entries}
end
endCache gorący od pierwszego requestu. Zero cold-start, zero cache miss po restarcie.
Kiedy Redis jest nadal potrzebny
ETS nie jest zamiennikiem Redisa w każdym scenariuszu:
| Scenariusz | Dlaczego Redis wygrywa |
|---|---|
| Pub/Sub między serwisami w różnych technologiach | Python, Go, Java muszą subskrybować zdarzenia - Redis to lingua franca |
| Persystencja między restartami | Redis RDB/AOF przeżywa restart. ETS nie |
| Współdzielony stan między stackami | Jeśli Java i Elixir potrzebują tego samego cache'a |
| Złożone struktury danych | Redis Sorted Sets, HyperLogLog, Streams, Geo - brak odpowiedników w ETS |
| Istniejąca infrastruktura | Redis jest już wdrożony, monitorowany, zbackupowany |
Reguła: jeśli cały Twój backend jest w Elixirze i cache nie musi przeżywać restartów - ETS wystarczy. Jeśli masz heterogeniczny stack lub potrzebujesz persystencji - Redis ma sens.
Architektura hybrydowa
W większych systemach oba narzędzia współistnieją:
Phoenix App
├── ETS (L1: hot cache, sub-mikrosekundowy)
│ Sesje, rate limiting, feature flagi, cennik
│
├── Redis (L2: shared state)
│ Cross-service cache, pub/sub, persystentne kolejki
│
└── PostgreSQL (L3: źródło prawdy)
Trwałe dane, Oban, transakcjeETS jako L1 cache eliminuje 90% roundtripów do Redisa. Redis zostaje tam, gdzie musi - jako warstwa współdzielona między serwisami.
Monitoring pamięci ETS
ETS nie ma limitu pamięci. Zjada tyle RAM-u, ile mu dasz. Monitoring jest obowiązkowy:
defmodule MyApp.ETSMonitor do
use GenServer
@check_interval :timer.seconds(30)
@warning_mb 500
def start_link(_), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)
@impl true
def init(_) do
:timer.send_interval(@check_interval, :check)
{:ok, %{}}
end
@impl true
def handle_info(:check, state) do
total_bytes = :erlang.memory(:ets)
total_mb = total_bytes / 1_048_576
:telemetry.execute([:my_app, :ets, :memory], %{bytes: total_bytes}, %{})
if total_mb > @warning_mb do
top_tables =
:ets.all()
|> Enum.map(fn t ->
{
:ets.info(t, :name),
:ets.info(t, :size),
Float.round(:ets.info(t, :memory) * 8 / 1_048_576, 2)
}
end)
|> Enum.sort_by(&elem(&1, 2), :desc)
|> Enum.take(5)
require Logger
Logger.warning("ETS memory: #{Float.round(total_mb, 1)} MB. Top tables: #{inspect(top_tables)}")
end
{:noreply, state}
end
endPodsumowanie: kiedy co wybrać
| Potrzeba | Rozwiązanie |
|---|---|
| Cache z TTL | Cachex (lub własny GenServer + ETS) |
| Rate limiting | ETS update_counter + periodic cleanup |
| Feature flagi | ETS :protected + GenServer refresh |
| Konfiguracja read-only | :persistent_term |
| Sesje (single node) | ETS / Plug.Session.ETS |
| Cache rozproszony (klaster BEAM) | Nebulex z adapterem Partitioned |
| Cache cross-stack | Redis |
| Persystentny cache | Redis lub Mnesia |
ETS to nie "alternatywa dla Redisa". To domyślny wybór dla cache'owania w Elixirze. Redis powinien wchodzić do stacku dopiero wtedy, gdy ETS nie wystarczy - a w większości aplikacji Phoenix nigdy nie wystarczy.
Dodajesz Redisa do projektu "na wszelki wypadek"? Porozmawiajmy - pokażemy, ile infrastruktury możesz usunąć, zastępując ją tym, co BEAM daje z pudełka.