Przejdź do treści
Intum Dev

Jak robić liczniki (counters) w aplikacjach

Aktualizacja: 3 min czytania

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).

Czy ten wpis był pomocny?

Udostępnij

Komentarze