[Intum Dev](https://intum.dev.md) / [Technologia](https://intum.dev/technologia.md)

# [Jak robić liczniki (counters) w aplikacjach](https://intum.dev/technologia/jak-robic-liczniki-counters-w-aplikacjach.md)

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

```ruby
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:

```ruby
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):

```ruby
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:

```ruby
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:

```ruby
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:

```sql
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:

```ruby
# cron, raz dziennie
Model.counter_culture_fix_counts
```

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