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

OperacjaETSRedis (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:

  1. Zero sieci - ETS jest w pamięci procesu BEAM. Żaden TCP roundtrip, żaden socket, żaden context switch kernela
  2. Zero serializacji - dane przechowywane jako natywne termy Erlanga. Żaden JSON, żaden msgpack, żaden encode/decode
  3. 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

TypStrukturaKluczeZastosowanie
set (domyślny)Hash table, O(1)UnikalneCache, sesje, config
ordered_setDrzewo AVL, O(log n)Unikalne, posortowaneRange queries, leaderboardy
bagHash tableWiele wartości per kluczTagi, kategorie
duplicate_bagHash tableDuplikaty dozwoloneLogi 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
end

Uż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
end

Wzorzec 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
end

Tabela 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)
end

Koszt 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_termETS
Odczyt~30-47M ops/s~8-10M ops/s
ZapisBardzo wolny (globalny GC)Szybki
Model pamięciWspółdzielona (zero kopii przy odczycie)Kopia do heapu procesu
ZastosowanieKonfiguracja, routing, regexCache, 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)
end

Reguł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
end

Cache 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:

ScenariuszDlaczego Redis wygrywa
Pub/Sub między serwisami w różnych technologiachPython, Go, Java muszą subskrybować zdarzenia - Redis to lingua franca
Persystencja między restartamiRedis RDB/AOF przeżywa restart. ETS nie
Współdzielony stan między stackamiJeśli Java i Elixir potrzebują tego samego cache'a
Złożone struktury danychRedis Sorted Sets, HyperLogLog, Streams, Geo - brak odpowiedników w ETS
Istniejąca infrastrukturaRedis 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, transakcje

ETS 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
end

Podsumowanie: kiedy co wybrać

PotrzebaRozwiązanie
Cache z TTLCachex (lub własny GenServer + ETS)
Rate limitingETS update_counter + periodic cleanup
Feature flagiETS :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-stackRedis
Persystentny cacheRedis 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.