Problem
Aplikacja liczy ile rekordów ma dany rodzic - np. ile maili ma konto, ile wyników formularzy, ile tasków ma projekt. Standardowe podejście (counter_cache w Rails, counter_culture) aktualizuje kolumnę na tabeli rodzica synchronicznie przy każdym zapisie dziecka. Przy dużym ruchu to powoduje:
- lock contention - wiele transakcji blokuje ten sam wiersz
- deadlocki - dwie transakcje czekają na siebie nawzajem
- wolne zapisy - UPDATE na dużej tabeli z JSONBami trwa sekundy zamiast milisekund
Rozwiązania - od najprostszego
1. execute_after_commit (counter_culture)
Najmniejsza zmiana. Zamiast aktualizować licznik w ramach transakcji, robi to po COMMIT:
counter_culture :account, execute_after_commit: true
Wymaga gemu after_commit_action. Deadlocki znikają bo UPDATE licznika nie trzyma locka na rodzicu w czasie transakcji. Licznik może być przez chwilę nieaktualny (eventual consistency).
Dla bulk operacji można grupować updaty:
CounterCulture.aggregate_counter_updates do
# wiele operacji - jeden zbiorczy UPDATE
end
2. Osobna tabela na liczniki
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).
3. Cache + invalidacja
Nie przechowuj licznika - licz na żądanie i cachuj:
def results_count
Rails.cache.fetch("account:#{id}:results_count") do
Form::Result.where(account_id: id).count
end
end
# w Form::Result:
after_commit { Rails.cache.delete("account:#{account_id}:results_count") }
Zero zapisów przy zmianie dziecka (tylko cache.delete ~0.1ms). Pierwszy odczyt po invalidacji robi SELECT COUNT - przy dużych tabelach może być wolne.
4. Redis INCR
Atomowy increment w Redis, flush do bazy periodycznie:
after_create_commit { REDIS.hincrby("counters:#{account_id}", "emails", 1) }
after_destroy_commit { REDIS.hincrby("counters:#{account_id}", "emails", -1) }
def emails_count
REDIS.hget("counters:#{id}", "emails").to_i
end
Submilisekundowe zapisy i odczyty. Minus - dane poza bazą, trzeba safety net (nightly recalculate).
5. Sharded counters (Google, Twitter)
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.
6. Kafka + stream processing (Facebook, 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ć
| Skala | Rozwiązanie |
|---|---|
| < 1k req/s | execute_after_commit w counter_culture |
| Duża tabela rodzica (JSONB) | Osobna tabela na liczniki |
| Rzadko czytany licznik (admin panel) | Cache + invalidacja |
| 1k-10k req/s | Redis INCR + async flush |
| > 10k req/s na wiersz | Sharded counters |
| Miliony eventów/s | Kafka + stream processing |
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).