Przejdź do treści
Intum Dev

Jak robić liczniki (counters) w aplikacjach

Aktualizacja: 8 min czytania

Problem

W wielu miejscach aplikacji wyświetlamy licznik - ile maili ma konto, ile wyników formularza, ile nieprzypisanych wiadomości czeka w kolejce. Ten licznik musi się gdzieś brać.

Najprostsze podejście - policz za każdym razem (SELECT COUNT(*)) - działa do momentu gdy tabela jest mała i odczytów jest mało. Przy dużych tabelach i częstym wyświetlaniu (np. sidebar z liczbą maili na każdej stronie) robi się za wolno.

Dlatego większość aplikacji przechowuje gotowy licznik na tabeli rodzica i aktualizuje go przy każdej zmianie dziecka. W Rails robi to counter_cache albo gem counter_culture. Problem zaczyna się gdy ruch rośnie:

  • lock contention - każdy nowy mail blokuje wiersz konta na czas UPDATE licznika. Przy wielu równoczesnych mailach transakcje czekają na siebie
  • deadlocki - dwie transakcje trzymają locki na różnych kolumnach tego samego wiersza i czekają na siebie nawzajem
  • wolne zapisy - jeśli tabela rodzica jest ciężka (dużo kolumn, JSONB), sam UPDATE trwa sekundy zamiast milisekund

Poniżej kilka podejść do rozwiązania tego problemu - od najprostszych do stosowanych przez YouTube i Facebooka.

Rozwiązania - od najprostszego

1. Wyliczanie na żądanie (SELECT COUNT)

Najprostsze - nie przechowuj licznika, licz za każdym razem:

def results_count
  form_results.count
end

Działa przy małym ruchu i rzadkim odczycie. Przy dużych tabelach i częstym odczycie (np. sidebar z liczbą maili na każdej stronie) robi się wolne. Wtedy - patrz punkt 2 (cache).

2. Cache na counterach

Zamiast liczyć z bazy za każdym razem - cachuj wynik i invaliduj przy zmianie.

Wariant A: Rails.cache + invalidacja

def results_count
  Rails.cache.fetch("account:#{id}:results_count") { form_results.count }
end

# w Form::Result:
after_commit { Rails.cache.delete("account:#{account_id}:results_count") }

Pierwszy odczyt po invalidacji robi SELECT COUNT (może być wolne na dużych tabelach), kolejne z cache.

Wariant B: Redis INCR

after_create_commit { REDIS.hincrby("counters:#{account_id}", "results_count", 1) }
after_destroy_commit { REDIS.hincrby("counters:#{account_id}", "results_count", -1) }

def results_count
  REDIS.hget("counters:#{id}", "results_count").to_i
end

Submilisekundowe zapisy i odczyty, zero locków na bazie. Nie robi SELECT COUNT - trzyma gotowy licznik.

Oba warianty mają ten sam minus - licznik żyje poza bazą (w cache/Redis). Jeśli cache się wyczyści albo callback się nie odpali, licznik się rozsynchronizuje. Nightly job przeliczający z bazy jest obowiązkowy jako safety net.

3. Counter cache / counter_culture (gem)

W punktach 1 i 2 trzeba ręcznie obsługiwać increment/decrement albo periodycznie przeliczać wszystkie rekordy (nightly job). Counter cache robi to automatycznie - Rails sam aktualizuje licznik przy każdym create/update/destroy dziecka, bez dodatkowego kodu.

Rails ma wbudowany counter_cache, a gem counter_culture rozszerza go o warunkowe liczniki i automatyczne naprawianie:

# Rails counter_cache - prosty increment/decrement na tabeli rodzica
belongs_to :account, counter_cache: true

# counter_culture - warunkowe liczniki, fix_counts, wiele opcji
counter_culture :account, column_name: "results_count"
counter_culture :account,
  column_name: proc { \|r\| r.closed? ? nil : :new_results_count }

Działa dobrze do momentu gdy tabela rodzica jest duża (dużo kolumn, JSONB) i ruch wzrasta. Wtedy UPDATE na ciężkim wierszu powoduje lock contention i deadlocki.

4. execute_after_commit (counter_culture)

Jednolinijkowa zmiana rozwiązująca deadlocki. UPDATE licznika odpala się po COMMIT transakcji, nie w jej trakcie:

counter_culture :account, execute_after_commit: true

Wymaga gemu after_commit_action. Lock na rodzicu jest krótszy (poza transakcją), ale UPDATE nadal idzie na tę samą ciężką tabelę. Przy bardzo dużym ruchu lock contention może wrócić.

Dla bulk operacji można grupować updaty:

CounterCulture.aggregate_counter_updates do
  # wiele operacji - jeden zbiorczy UPDATE
end

5. Osobna tabela na liczniki

Gdy tabela rodzica jest duża (dużo kolumn, JSONB, indeksy) - UPDATE na niej trwa długo nawet z execute_after_commit. Rozwiązanie: zamiast UPDATE na ciężkiej tabeli rodzica (~2KB wiersz z JSONBami), UPDATE na lekkiej tabeli (~20B):

create_table :account_counters do \|t\|
  t.references :account, null: false, index: { unique: true }
  t.integer :emails_count, default: 0
  t.integer :results_count, default: 0
end

Lock trwa mikrosekundy zamiast sekund. Odczyt bez dodatkowego query (eager load).

6. Sharded counters

Podejście stosowane przez Google (Cloud Firestore), YouTube i Twitter. Rozwinięcie pomysłu z osobną tabelą. Zamiast jednego wiersza per licznik tworzysz N wierszy (shardów). Każdy zapis losuje shard i go inkrementuje, odczyt sumuje wszystkie. To jak 100 koszyków zamiast jednego - nikt się nie przepycha.

To jest rozwinięcie opcji 2 (osobna tabela na liczniki) - jeśli masz już osobną tabelę, wystarczy dodać kolumnę shard_id i zmienić logikę zapisu/odczytu. Osobna tabela daje 1 lekki wiersz per licznik (rozwiązuje problem ciężkiego rodzica). Sharding dodaje N wierszy per licznik (rozwiązuje problem zbyt wielu zapisów na jeden wiersz, nawet lekki). Migracja jest prosta - zaczynasz od osobnej tabeli, a jak ruch rośnie, dodajesz sharding.

Google Cloud Firestore ma sharded counters wbudowane jako feature. W Postgres/MySQL robisz to sam. W wersji Big Tech (YouTube) zapis idzie do Redis, Redis flushuje do sharded tabeli, odczyt z cache/materialized view.

Dla ekstremalnego ruchu (>10k req/s na jeden wiersz). Zamiast jednego wiersza per licznik - 100 shardów:

UPDATE counters SET value = value + 1
WHERE entity_id = 5 AND shard_id = floor(random() * 100)

-- odczyt: suma
SELECT SUM(value) FROM counters WHERE entity_id = 5

Szybkie zapisy (lock na 1 ze 100 wierszy), wolniejsze odczyty (SUM). YouTube i Twitter używają tego podejścia.

Odczyt w aplikacji - żeby nie robić SUM przy każdym wyświetleniu strony:

# opcja A: cache sumy
def likes_count
  Rails.cache.fetch("post:#{id}:likes_count", expires_in: 30.seconds) do
    ShardedCounter.where(entity_id: id).sum(:value)
  end
end

# opcja B: materialized view (przeliczany co X sekund)
# CREATE MATERIALIZED VIEW counter_totals AS
#   SELECT entity_id, SUM(value) as total FROM sharded_counters GROUP BY entity_id;
# REFRESH MATERIALIZED VIEW CONCURRENTLY counter_totals;

# opcja C: osobna kolumna z gotową sumą, aktualizowana periodycznie

Trade-off: im więcej shardów, tym szybsze zapisy ale wolniejsze odczyty. Liczba shardów powinna być dobrana do ruchu - popularny post dostaje więcej, mało popularny mniej.

7. Kafka + stream processing

Podejście stosowane przez Facebook i Netflix. Eventy (like, view) trafiają do Kafki, stream processor (Flink) agreguje je w oknach czasowych i robi zbiorczy UPDATE:

User action -> Kafka topic -> Stream processor -> Batch UPDATE do DB

Skala: miliony eventów na sekundę. Overkill dla typowego SaaS.

Kiedy co wybrać

Kiedy Rozwiązanie Kto używa
Mały ruch, rzadki odczyt SELECT COUNT na żądanie małe aplikacje
Częstszy odczyt Cache (Rails.cache / Redis) na SELECT COUNT większość SaaS
Częsty zapis i odczyt counter_culture (ew. z execute_after_commit) Rails aplikacje
j.w. ale duża tabela rodzica (JSONB) Osobna tabela na liczniki Intum
1k-10k req/s Redis INCR + async flush Instagram, Slack
> 10k req/s na wiersz Sharded counters Google, YouTube, Twitter
Miliony eventów/s Kafka + stream processing Facebook, Uber, Netflix

Dla typowego SaaS w Rails najczęściej wystarczy execute_after_commit: true. Jeśli tabela rodzica jest ciężka (dużo kolumn, JSONB) - osobna tabela na liczniki. Reszta to optymalizacje dla naprawdę dużego ruchu.

Safety net

Niezależnie od podejścia, warto mieć nightly job przeliczający liczniki z bazy:

# cron, raz dziennie
Model.counter_culture_fix_counts

Naprawia ewentualne rozsynchronizowania (zgubiony callback, crash w trakcie update).

Jak robią to duże serwisy

YouTube - buforuje wyświetlenia w pamięci (sharded counters + Redis). Stąd efekt “utknięcia” na 301 widokach po publikacji - YouTube celowo zamraża licznik po przekroczeniu progu żeby zweryfikować czy wyświetlenia nie są botami, dopiero potem robi batch update do prawdziwej wartości.

Instagram - w 2019 ukrył liczniki lajków. Częściowo z powodów społecznych, ale duży wpływ miało to że liczenie lajków w real-time na skali Instagrama (miliardy lajków dziennie) jest kosztowne. Używają Cassandry z eventual consistency - licznik aktualizuje się z opóźnieniem.

Twitter/X - sharded counters dla lajków i retweetów. Licznik jest przybliżony - widoczna wartość może się różnić od rzeczywistej o kilka procent. Przy milionach interakcji na popularnym tweecie nikt nie zauważy różnicy między 14 523 a 14 519.

Reddit - vote counting przez Kafkę + stream processing. Upvote/downvote trafia do kolejki, konsumery agregują i zapisują batchami. Przy dużym ruchu (np. Pi Day outage 2023) kolejka się zapychała i głosy “znikały” na kilka minut.

Facebook - CRDT (Conflict-free Replicated Data Types) do replikacji counterów między data centers. PN-Counter osobno śledzi incrementy i decrementy na każdej replice, suma daje wynik. Replikacja asynchroniczna - eventual consistency między kontynentami.

Uber - exactly-once semantics dla counterów reklamowych (kliknięcia w reklamy = pieniądze, nie mogą się dublować). Apache Flink + Kafka, eventy agregowane w minutowych oknach, deduplikacja przez unikalne identyfikatory. Trzy warstwy Flink jobów zanim licznik trafi do bazy.

Discord - tryliony wiadomości w ScyllaDB (fork Cassandry). Nie liczą wiadomości per kanał w real-time - zamiast tego “unread” to marker pozycji ostatnio przeczytanej wiadomości, nie counter.

Intum - osobna tabela dla counterów z lekkimi wierszami, increment/decrement w after_commit callbackach. Tam gdzie to nie będzie wystarczało planujemy dodać sharded counters - wystarczy dorzucić kolumnę shard_id do istniejącej tabeli i zmienić logikę zapisu/odczytu.

Wspólne wzorce - żaden z tych serwisów nie robi synchronicznego UPDATE na jednym wierszu przy dużej skali. Eventual consistency jest akceptowalna dla counterów (użytkownicy nie zauważają opóźnienia kilku sekund). Write-behind caching (buforuj lokalnie, flushuj batchami) to podstawowa technika.

Czy ten wpis był pomocny?

Udostępnij

Komentarze