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.