PostgreSQL 'cached plan must not change result type' Počas Zero-Downtime Migrácií
Cached plan nas pohryzol hned po zmene schemy. “Deploy prebehol dobre, potom o 5 minút: ERROR: cached plan must not change result type.” Príčina: JDBC server-side prepared statements cachovali query plán pred zmenou schémy, a teraz stĺpce cachovného plánu nezodpovedajú realite.
Prostredie: PostgreSQL 12+, JDBC s HikariCP, server-side prepared statements (prepareThreshold), zero-downtime DDL migrácie
Problém
Oneskorená Explózia
Časová os migrácie:
T+0:00 ALTER TABLE orders ADD COLUMN discount_code VARCHAR(50);
Migrácia úspešná, žiadne chyby
T+0:30 Rolling deploy štartuje, nová verzia appky používa discount_code
T+2:00 Staré inštancie appky stále bežia, cachované prepared statements
T+5:00 Connection sa vracia do poolu, znovu použitý pre request
ERROR: cached plan must not change result type
T+5:01 Výnimky sa šíria cez connection pool
Aplikácia čiastočne dole napriek "úspešnej" migrácii
Chyba
-- Aplikácia spúšťa tento query
SELECT * FROM orders WHERE id = $1;
-- PostgreSQL vracia
ERROR: cached plan must not change result type
DETAIL: Expected type character varying for column 15, but got nothing.
-- Alebo horšie, tiché zlé dáta ak sa stĺpce preusporiadali!
Príčina
Server-Side Prepared Statements
Ako JDBC prepared statements fungujú s PostgreSQL:
Prvých N vykonaní (N = prepareThreshold, default 5):
┌─────────────────────────────────────────────────────────────┐
│ Klient: SELECT * FROM orders WHERE id = ? │
│ Server: Parse → Plan → Execute → Return │
│ (plán vytvorený nanovo každý krát) │
└─────────────────────────────────────────────────────────────┘
Po prekročení prahu:
┌─────────────────────────────────────────────────────────────┐
│ Klient: PREPARE stmt AS SELECT * FROM orders WHERE id = $1 │
│ Server: Uloží sparsovaný statement + cachovaný plán │
│ │
│ Nasledujúce volania: │
│ Klient: EXECUTE stmt(123) │
│ Server: Používa CACHOVANÝ plán (žiadne re-parse, re-plan!) │
└─────────────────────────────────────────────────────────────┘
Nastane zmena schémy:
┌─────────────────────────────────────────────────────────────┐
│ ALTER TABLE orders ADD COLUMN discount_code VARCHAR(50); │
│ │
│ Cachovaný plán: očakáva 14 stĺpcov │
│ Skutočná tabuľka: teraz má 15 stĺpcov │
│ │
│ Ďalší EXECUTE → cached plan must not change result type! │
└─────────────────────────────────────────────────────────────┘
Prečo Je To Oneskorené
// Životný cyklus connection poolu to zhoršuje
// T+0: Connection C1 pripravuje statement (5+ použití)
// T+1: C1 sa vracia do poolu
// T+2: Zmena schémy
// T+3: C1 stále v poole so zastaraným cachovaným plánom
// T+5: C1 vyzdvihnutý pre nový request → BUM
// Oneskorenie závisí od:
// - Connection pool idle timeout
// - Connection max lifetime
// - Traffic patterns
// - Kedy bol prekročený prah
Diagnostika
Identifikuj Problematické Queries
-- Nájdi prepared statements na aktuálnom spojení
SELECT name, statement, prepare_time
FROM pg_prepared_statements;
-- Skontroluj či sa katalóg zmenil od prepare
SELECT relname, relfilenode
FROM pg_class
WHERE relname = 'orders';
-- Po zmene schémy sa relfilenode zmení
-- Ale cachované plány stále referencujú starú štruktúru
Skontroluj JDBC Konfiguráciu
// Hľadaj tieto nastavenia v datasource
Properties props = new Properties();
props.getProperty("prepareThreshold"); // Default: 5
props.getProperty("preparedStatementCacheQueries"); // Default: 256
props.getProperty("preparedStatementCacheSizeMiB"); // Default: 5
// Ak prepareThreshold > 0, používaš server-side prepared statements
Nájdi Problémové Queries
-- V pg_stat_statements, hľadaj SELECT * vzory
SELECT query, calls, mean_exec_time
FROM pg_stat_statements
WHERE query LIKE 'SELECT *%'
ORDER BY calls DESC;
-- Tieto sa najskôr rozbijú pri zmenách schémy
Riešenie
Možnosť 1: Vyhni Sa SELECT * v Aplikačnom Kóde
// NEROB toto - rozbije sa pri akejkoľvek zmene stĺpca
@Query("SELECT * FROM orders WHERE id = :id")
Order findById(Long id);
// ROB toto namiesto toho - explicitné stĺpce
@Query("SELECT id, customer_id, total, created_at FROM orders WHERE id = :id")
Order findById(Long id);
// Alebo použi mapovanie stĺpcov ktoré zvládne extra stĺpce
@Entity
@Table(name = "orders")
public class Order {
// JPA/Hibernate mapuje len deklarované polia
// Extra stĺpce sú ignorované
}
Možnosť 2: Recyklácia Connection Poolu Pri Deploy
# HikariCP - vynúť refresh spojení
spring:
datasource:
hikari:
max-lifetime: 1800000 # 30 minút max
idle-timeout: 600000 # 10 minút idle
connection-timeout: 30000
# Počas migrácií, dočasne zníž:
# max-lifetime: 60000 # 1 minúta - vynúť rýchly refresh
// Programatický pool refresh po migrácii
@EventListener(MigrationCompletedEvent.class)
public void refreshPool() {
HikariDataSource ds = (HikariDataSource) dataSource;
HikariPoolMXBean pool = ds.getHikariPoolMXBean();
// Soft evict všetkých spojení (dokonči aktuálnu prácu, potom zatvor)
pool.softEvictConnections();
log.info("Eviktovaných {} spojení po migrácii",
pool.getTotalConnections());
}
Možnosť 3: Vypni Server-Side Prepared Statements
# application.properties - nukleárna možnosť
spring.datasource.hikari.data-source-properties.prepareThreshold=0
# Alebo per-connection
spring.datasource.url=jdbc:postgresql://host/db?prepareThreshold=0
// Trade-off: Mierne zníženie výkonu za bezpečnosť
// Každý query sa re-parsuje na serveri
// Zvyčajne akceptovateľné pre OLTP workloady
Možnosť 4: Použi DEALLOCATE Po Zmenách Schémy
-- Spusti po migrácii, pred obnovením traffic
-- Invaliduje všetky prepared statements na aktuálnom spojení
DEALLOCATE ALL;
-- Alebo cieľ na konkrétny statement
DEALLOCATE stmt_name;
// V migračnom skripte alebo post-migračnom hooku
@Transactional
public void invalidatePreparedStatements() {
entityManager.createNativeQuery("DEALLOCATE ALL").executeUpdate();
}
Možnosť 5: Fázovaný Prístup k Migrácii
-- Fáza 1: Pridaj stĺpec ako nullable (bezpečné)
ALTER TABLE orders ADD COLUMN discount_code VARCHAR(50);
-- Fáza 2: Deploy nový kód ktorý zvládne NULL discount_code
-- Počkaj na cyklus connection poolu (max-lifetime)
-- Fáza 3: Backfill dát
UPDATE orders SET discount_code = 'NONE' WHERE discount_code IS NULL;
-- Fáza 4: Pridaj NOT NULL constraint (ak potrebné)
ALTER TABLE orders ALTER COLUMN discount_code SET NOT NULL;
Monitoring
groups:
- name: postgresql-prepared-statements
rules:
- alert: CachedPlanErrors
expr: |
rate(postgresql_errors_total{error="cached_plan_must_not_change"}[5m]) > 0
for: 1m
labels:
severity: critical
annotations:
summary: "Cached plan chyby po zmene schémy"
- alert: PreparedStatementCount
expr: |
pg_prepared_statements_count > 1000
for: 10m
labels:
severity: warning
annotations:
summary: "Vysoký počet prepared statements - riziko memory pressure"
Checklist
## PostgreSQL Cached Plan Zmena Schémy
### Pred Migráciou
- [ ] Prezri queries pre SELECT * vzory
- [ ] Skontroluj prepareThreshold nastavenie
- [ ] Naplánuj stratégiu refresh connection poolu
- [ ] Testuj migráciu v stagingu s traffic
### Počas Migrácie
- [ ] Spusti ALTER TABLE počas nízkej prevádzky
- [ ] Monitoruj cached plan chyby
- [ ] Buď pripravený soft-evictovať spojenia
### Po Migrácii
- [ ] Over že nie sú cached plan chyby v logoch
- [ ] Potvrď že connection pool sa zrecykloval
- [ ] Monitoruj výkon queries (re-planning overhead)
### Dlhodobé Riešenia
- [ ] Eliminuj SELECT * z aplikačného kódu
- [ ] Zváž prepareThreshold=0 pre bezpečnejšie deploye
- [ ] Pridaj migračné hooky pre DEALLOCATE ALL
Záver
Lekcia: server-side prepared statements sú optimalizácia výkonu ktorá sa stáva záťažou pri zmenách schémy. Cachovaný query plán nevie že sa štruktúra tabuľky zmenila.
Kľúčové princípy:
- **Vyhni sa SELECT *** - explicitné stĺpce prežijú zmeny schémy
- Zrecykluj spojenia po DDL - soft-evict alebo zníž max-lifetime
- Zváž prepareThreshold=0 - bezpečnejšie deploye, mierne zníženie výkonu
- Fázuj migrácie - pridaj stĺpce pred kódom ktorý ich používa
Súvisiace články
- Zero-Downtime PostgreSQL Migrations - Bezpečné DDL vzory
- PostgreSQL Prepared Statements Trap - Generic vs specific plány
Súvisiace články
Zero-downtime migrácie PostgreSQL: Expand/Contract, backfill a rollback stratégie
Praktický playbook pre bezpečné databázové migrácie v produkcii. Od expand/contract patternu cez online indexy až po monitoring a rollback.
EXPLAIN vám klamal: PostgreSQL Prepared Statement Plan Cliff
Váš EXPLAIN vyzerá perfektne ale produkcia horí. Vinník: PostgreSQL ticho prepol z custom plánu na generic plán po dostatočnom počte vykonaní, a generic plán je katastrofálne zlý.
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.
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.
Citujte tento článok
Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.