Fix PostgreSQL 'cached plan must not change result type' Sau Khi Thay Đổi Schema

intermediate🐘 PostgreSQL2026-07-04| PostgreSQL 12–16, mọi hệ điều hành (Linux/macOS/Windows), thường gặp với PgBouncer, SQLAlchemy, Django, Rails, hoặc bất kỳ ứng dụng nào dùng prepared statements

Error Message

ERROR: cached plan must not change result type
#postgresql#prepared-statement#schema-change#cached-plan#pgbouncer

Lỗi Xảy Ra

ERROR: cached plan must not change result type

Bạn vừa chạy một migration — thêm cột, đổi kiểu dữ liệu, thay thế view — và bây giờ ứng dụng báo lỗi này trên mỗi request. Log đầy ắp, người dùng gặp lỗi 500, trong khi bản thân migration trông hoàn toàn đúng.

Đây là lý do: PostgreSQL lưu cache các execution plan cho prepared statements. Khi schema thay đổi, plan đã cache không còn khớp với kết quả mà query sẽ trả về nữa. Thay vì âm thầm trả về dữ liệu sai, PostgreSQL từ chối thực thi. Về bản chất đây là hành vi đúng — chỉ là nó xảy ra vào lúc tệ nhất có thể.

Nguyên Nhân Kích Hoạt Lỗi

  • Chạy ALTER TABLE ... ADD COLUMN hoặc DROP COLUMN trong khi ứng dụng đang có kết nối hoạt động
  • Dùng CREATE OR REPLACE VIEW với danh sách cột hoặc kiểu dữ liệu khác
  • Thay đổi kiểu dữ liệu của cột bằng ALTER COLUMN ... TYPE
  • Dùng SELECT * trong prepared statement — bất kỳ cột mới nào cũng làm thay đổi shape của kết quả
  • PgBouncer ở chế độ session tái sử dụng kết nối qua các request

Kiểm Tra Những Gì Đang Được Cache

Bắt đầu bằng cách xem prepared statement nào đang hoạt động trên database:

SELECT name, statement, parameter_types
FROM pg_prepared_statements
ORDER BY name;

Đang dùng PgBouncer? Query này sẽ không trả về gì — pooler quản lý các kết nối backend, nên các plan đã cache nằm ở phía nó, không thể thấy từ đây. Hãy bỏ qua và chuyển thẳng xuống phần PgBouncer bên dưới.

Cách 1: Khởi Động Lại Ứng Dụng (Nhanh Nhất)

Lúc 2 giờ sáng đang xử lý sự cố, đây là việc cần làm ngay. Khởi động lại các tiến trình ứng dụng — mọi kết nối đóng lại, toàn bộ plan đã cache bị xóa, các kết nối mới bắt đầu với schema đã cập nhật.

# systemd
sudo systemctl restart your-app

# Docker
docker compose restart app

# PM2
pm2 restart all

Thô nhưng hiệu quả. Giải quyết nguyên nhân gốc rễ sau khi mọi thứ đã lắng xuống.

Cách 2: Giải Phóng Plan Mà Không Cần Khởi Động Lại

Không thể chịu downtime? Bạn có thể xóa các plan đã cache từ phía database mà không cần động đến ứng dụng.

Kết nối với quyền superuser và chạy lệnh này cho mỗi kết nối bị ảnh hưởng:

DEALLOCATE ALL;

Hoặc nhắm vào một plan cụ thể theo tên:

DEALLOCATE my_plan_name;

Điểm hạn chế: DEALLOCATE ALL chỉ xóa session hiện tại. Với một production pool có 20–50 kết nối, bạn phải chạy lệnh này trong từng kết nối một. Không thực tế chút nào.

Một cách tiếp cận chính xác hơn — terminate toàn bộ kết nối của ứng dụng và để chúng tự kết nối lại:

-- Tìm các kết nối từ ứng dụng của bạn
SELECT pid, usename, application_name, state
FROM pg_stat_activity
WHERE application_name = 'your-app-name';

-- Terminate chúng (sẽ tự kết nối lại ở request tiếp theo)
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE application_name = 'your-app-name'
  AND pid != pg_backend_pid();

Cách 3: PgBouncer — Thủ Phạm Phổ Biến

PgBouncer ở chế độ session là nguồn gốc phổ biến nhất của lỗi này trong môi trường production. Pooler duy trì các kết nối lâu dài tới PostgreSQL. Schema thay đổi làm invalidate các plan đã cache trên những kết nối đó — nhưng PgBouncer vẫn tiếp tục phân phối chúng.

Khởi động lại PgBouncer để đóng toàn bộ pooled connection cùng lúc:

sudo systemctl restart pgbouncer

# Hoặc qua admin console:
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer
RECONNECT mydb;

Đang dùng chế độ transaction? Dùng DISCARD ALL — PgBouncer chặn lệnh này và xử lý ở cấp pool:

DISCARD ALL;

Để giải quyết lâu dài, hãy chuyển sang chế độ transaction và thêm server_reset_query = DISCARD ALL vào pgbouncer.ini. Cấu hình này tự động xóa session state giữa các kết nối:

[pgbouncer]
pool_mode = transaction
server_reset_query = DISCARD ALL

Cách 4: Phòng Ngừa Trong Quá Trình Migration

Tốt hơn nữa: ngăn race condition này xảy ra ngay từ đầu.

Phương án A: Khởi động lại ngay sau khi migrate

# 1. Chạy migration
python manage.py migrate
# hoặc
bundle exec rails db:migrate

# 2. Khởi động lại trước khi traffic chạm vào schema mới
sudo systemctl restart your-app

Phương án B: Drain các instance cũ trước khi schema thay đổi được áp dụng

Với blue-green hoặc rolling deployment, hãy đảm bảo các instance ứng dụng cũ hoàn thành các request đang xử lý và ngắt kết nối trước khi migration chạy. Đây là vấn đề về thứ tự triển khai hơn là vấn đề PostgreSQL.

Phương án C: Ngừng dùng SELECT * trong prepared statements

Liệt kê cột tường minh sẽ tránh hoàn toàn lỗi này. Bất kỳ cột nào thêm vào bảng sẽ không ảnh hưởng đến kiểu kết quả mà plan đã cache kỳ vọng:

-- Rủi ro: bất kỳ cột mới nào cũng phá vỡ plan đã cache
SELECT * FROM users WHERE id = $1;

-- An toàn: thêm cột vào schema không ảnh hưởng kiểu kết quả
SELECT id, email, created_at FROM users WHERE id = $1;

Trong SQLAlchemy, chú ý với session.query(User) — mặc định có hành vi SELECT *. Hãy chuyển sang chỉ định cột tường minh bằng .with_entities(User.id, User.email). Trong Django, .values('id', 'email') làm điều tương tự cho các query quan trọng.

Xác Nhận Đã Khắc Phục

Kiểm tra xem các plan cũ đã được xóa chưa:

-- Nên trả về 0 hàng nếu bạn vừa deallocate xong
SELECT name, statement FROM pg_prepared_statements;

Sau đó test trực tiếp endpoint đang gặp lỗi. Nếu lỗi đến từ một query cụ thể, hãy tái hiện trong psql để xác nhận đã sạch:

PREPARE test_plan AS SELECT id, email FROM users WHERE id = $1;
EXECUTE test_plan(1);
DEALLOCATE test_plan;

Nếu Lỗi Cứ Quay Lại

Lặp đi lặp lại mà không có migration mới? Có hai điều cần kiểm tra. Thứ nhất, tìm các job tự động đang chạy DDL — các lệnh CREATE OR REPLACE VIEW âm thầm thay đổi định nghĩa cột là thủ phạm thường gặp. Thứ hai, kiểm tra thời gian tồn tại tối đa của kết nối trong pool.

Một pool không bao giờ tái tạo kết nối sẽ tích lũy các plan cũ theo thời gian. Hãy đặt thời gian sống tối đa trong cấu hình pool:

# Ví dụ với SQLAlchemy
engine = create_engine(
    DATABASE_URL,
    pool_recycle=3600,  # đóng và mở lại kết nối sau 1 giờ
    pool_pre_ping=True,  # kiểm tra kết nối trước khi dùng, loại bỏ kết nối chết
)

pool_pre_ping=True đáng bật lên bất kể hoàn cảnh — nó phát hiện kết nối chết trước khi chúng đến tay code ứng dụng, thay vì sau khi đã gây lỗi.

Related Error Notes