PostgreSQL Serialization Failures: Viac ako len 'Retry'
“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 READaleboSERIALIZABLEisolation 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:
- 90% aplikácií nepotrebuje REPEATABLE READ - atomický UPDATE alebo SELECT FOR UPDATE s READ COMMITTED rieši väčšinu prípadov
- Ak to potrebujete, implementujte správny retry - exponential backoff s jitter, nie okamžitý retry
- Znížte pravdepodobnosť konfliktov - krátke transakcie, správne indexy, konzistentné poradie
- 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
- PostgreSQL Read Replica Conflicts - Recovery konflikty na read replikách
- PostgreSQL Idle Transaction Playbook - Vyčerpanie spojení z idle transakcií
Súvisiace články
PostgreSQL Read Replica Konflikty: Prečo sa vaše dotazy rušia
Dotazy na read replikách zlyhávajú s 'canceling statement due to conflict with recovery'. Riešenie závisí od toho, ktorý z 5 typov konfliktov máte - tu je návod ako diagnostikovať a vyriešiť každý z nich.
Dvojité Účtovanie z Idempotency Keys: Pasca Replica Lag
Perfektná idempotency logika, ale zákazníci sú stále účtovaní dvakrát. Príčina: kontrola idempotency keys voči read replice ktorá je sekundy za primary počas špičiek.
PostgreSQL OOM by Design: work_mem × Parallel Workers × Plan Nodes
work_mem vyzerá malé na 256MB, ale parallel hash join so 4 workers naprieč 3 plan nodes používa 3GB. Tu je ako zabrániť PostgreSQL legitímne OOMnúť váš kontajner.
Index Ktorý Zabil Write Performance: Strata PostgreSQL HOT Updates
Pridanie indexu pre výkon spôsobilo 10x pomalšie zápisy. Kontra-intuitívna príčina: nový index rozbil HOT updaty, meniaci lacné in-place updates na drahé full-row rewrites s masívnym bloatom.
Citujte tento článok
Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.