Späť na blog

PostgreSQL Serialization Failures: Viac ako len 'Retry'

|
| postgresql, database, concurrency, transactions, debugging

“ERROR: could not serialize access due to concurrent update” - každý PostgreSQL developer na to narazí. Štandardná rada je “len zopakuj transakciu.” Ale to je ako povedať “len reštartuj” pre akúkoľvek chybu. Skutočné otázky sú: Prečo sa to stalo? Mali by ste vôbec používať túto isolation level? A ako znížiť frekvenciu retry z 30% na 0.1%?

Prostredie: PostgreSQL 12+, aplikácie používajúce REPEATABLE READ alebo SERIALIZABLE isolation levels, vysoká konkurencia

Pochopenie chyby

Tri Isolation Levels

PostgreSQL ponúka tri isolation levels, každá s inými trade-offs:

┌────────────────────┬─────────────────┬──────────────────┬─────────────────────┐
│ Isolation Level    │ Dirty Reads     │ Non-Repeatable   │ Serialization       │
│                    │                 │ Reads            │ Failures            │
├────────────────────┼─────────────────┼──────────────────┼─────────────────────┤
│ READ COMMITTED     │ Nie             │ Áno              │ Nikdy               │
│ REPEATABLE READ    │ Nie             │ Nie              │ Áno (konkurentné    │
│                    │                 │                  │ updaty)             │
│ SERIALIZABLE       │ Nie             │ Nie              │ Áno (akýkoľvek      │
│                    │                 │                  │ konflikt)           │
└────────────────────┴─────────────────┴──────────────────┴─────────────────────┘

Kľúčový insight: READ COMMITTED (predvolená) nikdy nevyhodí serialization failure. Ak dostávate túto chybu, explicitne ste zvolili prísnejšiu isolation level.

Kedy nastáva ktorá chyba

-- REPEATABLE READ: Zlyhá pri konkurentnom UPDATE/DELETE na ten istý riadok
-- Transakcia A                     -- Transakcia B
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM accounts WHERE id = 1;
                                    BEGIN;
                                    UPDATE accounts SET balance = 50 WHERE id = 1;
                                    COMMIT;
UPDATE accounts SET balance = 100 WHERE id = 1;
-- ERROR: could not serialize access due to concurrent update

-- SERIALIZABLE: Zlyhá pri AKEJKOĽVEK read-write závislosti
-- Transakcia A                     -- Transakcia B
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT sum(balance) FROM accounts;
                                    BEGIN ISOLATION LEVEL SERIALIZABLE;
                                    INSERT INTO accounts (balance) VALUES (100);
                                    COMMIT;
INSERT INTO accounts (balance) VALUES (200);
COMMIT;
-- ERROR: could not serialize access due to read/write dependencies

Rozhodovací strom: Ktorú Isolation Level potrebujete?

Potrebujete zabrániť non-repeatable reads?

├─ NIE → Použite READ COMMITTED (predvolená)
│        Žiadne serialization failures, najlepší výkon
│        Riešte konkurentné updaty cez SELECT FOR UPDATE ak treba

└─ ÁNO → Potrebujete plnú serializovateľnosť (akoby transakcie bežali jedna za druhou)?

         ├─ NIE → Použite REPEATABLE READ
         │        Zlyhá len pri priamych konfliktoch na riadkoch
         │        Dobré pre: Reporty, read-heavy analytiku

         └─ ÁNO → Použite SERIALIZABLE
                  Zlyhá pri akejkoľvek read-write závislosti
                  Dobré pre: Finančné kalkulácie, inventory
                  Vyžaduje robustnú retry logiku

Kedy je READ COMMITTED vlastne dosť

90% aplikácií používajúcich REPEATABLE READ ju vlastne nepotrebuje:

-- Problém: "Potrebujem prečítať hodnotu a updatnúť na základe nej"
-- Zle: Použitie REPEATABLE READ
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;  -- Vráti 100
-- Aplikácia vypočíta: new_balance = 100 + 50
UPDATE accounts SET balance = 150 WHERE id = 1;
COMMIT;
-- Môže zlyhať so serialization error!

-- Dobre: Použitie READ COMMITTED s atomickým updateom
BEGIN;  -- Predvolená READ COMMITTED
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
COMMIT;
-- Nikdy nezlyhá so serialization error!
-- UPDATE vidí poslednú commitnutú hodnotu

-- Dobre: Použitie SELECT FOR UPDATE keď potrebujete hodnotu
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- Teraz máte zámok, žiadne konkurentné updaty nemožné
UPDATE accounts SET balance = 150 WHERE id = 1;
COMMIT;

Implementácia správnej Retry logiky

Ak naozaj potrebujete REPEATABLE READ alebo SERIALIZABLE, implementujte správny retry:

Základný Retry Pattern

import psycopg2
from psycopg2 import errors
import time
import random

def execute_with_retry(conn_params, transaction_func, max_retries=5):
    """Vykoná transakciu s exponential backoff retry."""

    for attempt in range(max_retries):
        conn = psycopg2.connect(**conn_params)
        try:
            conn.set_isolation_level(
                psycopg2.extensions.ISOLATION_LEVEL_REPEATABLE_READ
            )

            with conn.cursor() as cur:
                result = transaction_func(cur)
                conn.commit()
                return result

        except errors.SerializationFailure as e:
            conn.rollback()

            if attempt == max_retries - 1:
                raise  # Posledný pokus zlyhal

            # Exponential backoff s jitter
            wait_time = (2 ** attempt) * 0.1 + random.uniform(0, 0.1)
            time.sleep(wait_time)

        finally:
            conn.close()

# Použitie
def transfer_funds(cur):
    cur.execute("SELECT balance FROM accounts WHERE id = 1")
    balance = cur.fetchone()[0]
    cur.execute("UPDATE accounts SET balance = %s WHERE id = 1", (balance - 100,))
    cur.execute("UPDATE accounts SET balance = balance + 100 WHERE id = 2")
    return True

execute_with_retry(conn_params, transfer_funds)

Java/Spring Pattern

@Service
public class AccountService {

    @Autowired
    private AccountRepository accountRepository;

    @Retryable(
        value = {SerializationFailureException.class},
        maxAttempts = 5,
        backoff = @Backoff(delay = 100, multiplier = 2, random = true)
    )
    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findById(fromId).orElseThrow();
        Account to = accountRepository.findById(toId).orElseThrow();

        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));

        accountRepository.save(from);
        accountRepository.save(to);
    }

    @Recover
    public void transferFailed(SerializationFailureException e,
                               Long fromId, Long toId, BigDecimal amount) {
        log.error("Prevod zlyhal po max retries: {} -> {} suma {}",
                  fromId, toId, amount);
        throw new TransferFailedException("Max retries prekročené", e);
    }
}

Go Pattern

func ExecuteSerializable(db *sql.DB, fn func(*sql.Tx) error) error {
    maxRetries := 5

    for attempt := 0; attempt < maxRetries; attempt++ {
        tx, err := db.BeginTx(context.Background(), &sql.TxOptions{
            Isolation: sql.LevelSerializable,
        })
        if err != nil {
            return err
        }

        err = fn(tx)
        if err != nil {
            tx.Rollback()

            // Skontroluj či je to serialization failure (SQLSTATE 40001)
            if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "40001" {
                // Exponential backoff s jitter
                waitTime := time.Duration(math.Pow(2, float64(attempt))) * 100 * time.Millisecond
                jitter := time.Duration(rand.Int63n(100)) * time.Millisecond
                time.Sleep(waitTime + jitter)
                continue
            }
            return err
        }

        err = tx.Commit()
        if err != nil {
            // Commit môže tiež zlyhať so serialization error
            if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "40001" {
                continue
            }
            return err
        }

        return nil  // Úspech
    }

    return fmt.Errorf("max retries prekročené pre serializable transakciu")
}

Zníženie frekvencie konfliktov

Retry logika je nutná, ale zníženie potreby retry je lepšie:

1. Udržujte transakcie krátke

-- Zle: Dlhá transakcia s idle časom
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM products WHERE category = 'electronics';
-- Aplikácia spracováva dáta 5 sekúnd...
UPDATE products SET stock = stock - 1 WHERE id = 123;
COMMIT;
-- Vysoká pravdepodobnosť konfliktu!

-- Dobre: Minimálny čas transakcie
-- Spracovanie MIMO transakcie
products = fetch_products('electronics')  -- Samostatný query
process_products(products)  -- Aplikačná logika

BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE products SET stock = stock - 1 WHERE id = 123;
COMMIT;
-- Nízka pravdepodobnosť konfliktu

2. Použite indexy na zúženie rozsahu skenu

-- Bez indexu: Skenuje všetky riadky, vytvára široké read závislosti
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM orders WHERE customer_id = 123 AND status = 'pending';
-- Sekvenčný sken = read závislosť na VŠETKÝCH riadkoch

-- S indexom: Číta len konkrétne riadky
CREATE INDEX idx_orders_customer_status ON orders(customer_id, status);
-- Teraz vytvára závislosti len na riadkoch ktoré matchujú

3. Usporiadajte operácie konzistentne

-- Náchylné na deadlock: Rôzne poradie v rôznych transakciách
-- Transakcia A: UPDATE accounts SET ... WHERE id = 1; potom id = 2
-- Transakcia B: UPDATE accounts SET ... WHERE id = 2; potom id = 1

-- Bezpečné: Vždy rovnaké poradie
-- Zoraďte podľa ID pred updateom
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- Obe transakcie používajú rovnaké poradie = žiadne deadlocky

4. Zvážte Optimistic Locking namiesto toho

-- Pridajte version stĺpec
ALTER TABLE products ADD COLUMN version INTEGER DEFAULT 1;

-- Čítanie s verziou
SELECT id, name, stock, version FROM products WHERE id = 123;
-- Vráti: (123, 'Widget', 10, 5)

-- Update s kontrolou verzie (READ COMMITTED stačí!)
UPDATE products
SET stock = 9, version = version + 1
WHERE id = 123 AND version = 5;

-- Ak affected rows = 0, niekto iný updatol prvý → retry v aplikácii

Monitorovanie Serialization Failures

-- Skontrolujte mieru serialization failures
SELECT datname,
       xact_commit,
       xact_rollback,
       conflicts,
       round(100.0 * xact_rollback / nullif(xact_commit + xact_rollback, 0), 2)
         as rollback_pct
FROM pg_stat_database
WHERE datname = current_database();

-- Nájdite queries spôsobujúce konflikty (vyžaduje pg_stat_statements)
SELECT query,
       calls,
       total_time / calls as avg_time_ms
FROM pg_stat_statements
WHERE query ILIKE '%ISOLATION LEVEL%'
ORDER BY calls DESC
LIMIT 20;

Prometheus Alertovanie

groups:
  - name: postgresql-serialization
    rules:
      - alert: HighSerializationFailureRate
        expr: |
          rate(pg_stat_database_xact_rollback[5m]) /
          (rate(pg_stat_database_xact_commit[5m]) +
           rate(pg_stat_database_xact_rollback[5m])) > 0.05
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Vysoká miera rollbackov transakcií ({{ $value | humanizePercentage }})"

      - alert: SerializationRetriesExhausted
        expr: |
          rate(app_serialization_retries_exhausted_total[5m]) > 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Transakcie zlyhávajú po max retries"

Špecifiká manažovaného PostgreSQL

AWS RDS

# RDS nemá špeciálne nastavenia pre serializáciu
# Ale limity spojení ovplyvňujú retry stormy
max_connections = 100  # Predvolené sa líši podľa veľkosti inštancie

# Monitorujte cez Performance Insights
# Hľadajte wait events: Lock:transactionid

Google Cloud SQL

# Rovnaké PostgreSQL správanie
# Monitorujte cez Cloud SQL Insights
# Filtrujte podľa: Serialization failures

Azure Database for PostgreSQL

# Štandardné PostgreSQL isolation levels
# Použite Query Performance Insight pre monitorovanie

Checklist

## Riešenie Serialization Failures

### Diagnostika
- [ ] Potvrďte že naozaj potrebujete REPEATABLE READ/SERIALIZABLE
- [ ] Skontrolujte či by READ COMMITTED so SELECT FOR UPDATE fungovalo
- [ ] Zmerajte aktuálnu mieru zlyhaní z pg_stat_database

### Ak potrebujete prísnu izoláciu
- [ ] Implementujte retry s exponential backoff + jitter
- [ ] Nastavte max retry limit (5 je zvyčajne dosť)
- [ ] Logujte retries pre monitorovanie

### Znížte frekvenciu zlyhaní
- [ ] Udržujte transakcie čo najkratšie
- [ ] Pridajte indexy na zúženie read závislostí
- [ ] Používajte konzistentné poradie pre multi-row updaty
- [ ] Zvážte optimistic locking namiesto toho

### Monitorovanie
- [ ] Alert na rollback rate > 5%
- [ ] Alert na vyčerpanie retries
- [ ] Sledujte priemerné retries na transakciu

Záver

“Could not serialize access” nie je naozaj chyba - je to PostgreSQL hovoriac vám, že konkurentné transakcie sa nedali usporiadať konzistentne. Otázka je, či túto garanciu naozaj potrebujete.

Kľúčové poznatky:

  1. 90% aplikácií nepotrebuje REPEATABLE READ - atomický UPDATE alebo SELECT FOR UPDATE s READ COMMITTED rieši väčšinu prípadov
  2. Ak to potrebujete, implementujte správny retry - exponential backoff s jitter, nie okamžitý retry
  3. Znížte pravdepodobnosť konfliktov - krátke transakcie, správne indexy, konzistentné poradie
  4. Monitorujte rollback rate - ak je nad 5%, niečo je zle s vašimi prístupovými vzormi

Najlepší serialization failure je ten, ktorý sa nikdy nestane, pretože ste zvolili správnu isolation level pre váš prípad použitia.


Súvisiace články

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "PostgreSQL Serialization Failures: Viac ako len 'Retry'". https://www.michal-drozd.com/sk/blog/postgresql-serialization-failure-retry/ (Publikované 15. januára 2025).