Späť na blog

PostgreSQL 'cached plan must not change result type' Počas Zero-Downtime Migrácií

|
| postgresql, debugging, migrations, jdbc, zero-downtime

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:

  1. **Vyhni sa SELECT *** - explicitné stĺpce prežijú zmeny schémy
  2. Zrecykluj spojenia po DDL - soft-evict alebo zníž max-lifetime
  3. Zváž prepareThreshold=0 - bezpečnejšie deploye, mierne zníženie výkonu
  4. Fázuj migrácie - pridaj stĺpce pred kódom ktorý ich používa

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 'cached plan must not change result type' Počas Zero-Downtime Migrácií". https://www.michal-drozd.com/sk/blog/postgresql-cached-plan-schema-change/ (Publikované 11. decembra 2024).