Khi chuyển nhà, đội bốc vác mang theo được mọi cái thùng — nhưng không mang được cái biết đồ nằm ở đâu: phản xạ với tay là thấy cái kéo, biết ngăn nào có pin. Nhà mới đầy đủ hết đồ mà bạn vẫn chậm chạp cả tuần, cho tới khi trí nhớ ấy dựng lại. Nâng cấp phiên bản lớn của PostgreSQL vấp đúng chỗ đó, và không biết điều này là lý do máy chủ vừa nâng xong "chậm bất thường vài giờ đầu". Đây là việc mỗi năm làm một lần nên không ai thuộc; phần này chạy pg_upgrade thật từ 16 lên 17, đo từng bước, và tìm ra thứ nó cố tình bỏ lại.

Bố trí

Cơ sở dữ liệu 603 MB: 3.000.000 dòng, 6 bảng, 3 chỉ mục, 1 view, 1 vai trò có quyền SELECT.

Cần một môi trường có cả hai bộ nhị phân. Trên Alpine:

FROM postgres:17-alpine
RUN apk add --no-cache postgresql16 postgresql16-client

Bản 17 nằm ở /usr/local/bin, bản 16 ở /usr/libexec/postgresql16.

Bốn bước, bốn con số

Bước Thời gian
initdb cụm mới 0,7 s
pg_upgrade --check 0,9 s
pg_upgrade (sao chép, mặc định) 2,4 s
pg_upgrade --link 1,8 s

--check kết thúc bằng:

*Clusters are compatible*

Bước này chạy được khi máy chủ cũ đang chạy và không đổi gì cả. Nó kiểm mọi thứ mà bản nâng cấp thật sẽ kiểm: kiểu dữ liệu không còn hỗ trợ, thư viện thiếu, giao dịch chuẩn bị sẵn còn treo, tablespace.

Chạy nó vài ngày trước ngày nâng cấp là cách rẻ nhất để biết mình sẽ gặp gì.

Sau nâng cấp, kiểm lại: PostgreSQL 17.11, 3.000.000 dòng, 6 bảng, 3 chỉ mục, 1 view, và vai trò lẫn quyền đều còn nguyên. Khác với pg_dump ở phần 49 — pg_upgrade làm việc ở mức cụm nên nó mang theo mọi thứ.

Lần thử đầu tiên của tôi thất bại:

could not create hard link between old and new data directories: Cross-device link
In link mode the old and new data directories must be on the same file system.

Tôi để hai cụm ở hai volume Docker khác nhau — hai hệ tệp. Liên kết cứng không bắc qua được.

Đặt cả hai vào cùng một volume thì chạy: 1,8 s so với 2,4 s.

Chênh lệch nhỏ ở đây vì cơ sở dữ liệu chỉ 603 MB. Điểm quan trọng là cách hai chế độ tăng theo kích thước:

Cơ chế Tăng theo dữ liệu
Sao chép Chép từng tệp sang thư mục mới Có
--link Tạo liên kết cứng Gần như không

Trên 500 GB, chế độ sao chép là hàng chục phút; --link vẫn là vài phút. Đó là lý do nó tồn tại.

Cái giá của --link: sau khi nâng cấp, cụm cũ không dùng lại được. Hai cụm chia sẻ cùng các tệp dữ liệu, nên chạy cụm cũ sẽ làm hỏng cụm mới. Không có đường lùi ngoài bản sao lưu.

Với chế độ sao chép thì cụm cũ vẫn nguyên vẹn và khởi động lại được — đó là lưới an toàn đáng giá nếu bạn có thời gian và dung lượng.

Cái bẫy lớn nhất: thống kê không được mang sang

Ngay sau khi pg_upgrade báo thành công:

select count(*) from pg_stats where tablename = 'd';
--  0

select reltuples from pg_class where relname = 'd';
--  -1

Không có một dòng thống kê nào. reltuples = -1 là dấu "chưa biết gì" — không phải "0 dòng" mà là "chưa từng đo".

Hậu quả trên một truy vấn tra cứu:

Ước lượng Thực tế Lệch
Ngay sau pg_upgrade 19.715 300 66×
Sau ANALYZE 300 300 chính xác

Sai 66 lần. Ở truy vấn nhỏ này thời gian gần như không đổi, nhưng phần 29 đã đo một trường hợp mà đúng tình trạng này — bộ lập lịch không có thống kê nên dùng độ chọn lọc mặc định — làm truy vấn chậm 800 lần.

Đây là lý do máy chủ vừa nâng cấp xong thường "chậm bất thường trong vài giờ đầu", rồi tự khỏi khi autovacuum kịp chạy ANALYZE.

Cách chữa nằm ngay trong thông báo cuối của pg_upgrade:

vacuumdb --all --analyze-in-stages

Mất 0,8 giây trên cơ sở dữ liệu này. Trên cơ sở dữ liệu lớn thì lâu hơn nhiều, và đó chính là lý do có cờ --analyze-in-stages: nó chạy ba lượt với default_statistics_target tăng dần — lượt đầu rất nhanh và cho thống kê thô để hệ thống dùng được ngay, hai lượt sau tinh dần.

Chạy nó ngay sau khi khởi động cụm mới, trước khi mở cho người dùng nếu có thể.

Thứ tự chạy và thời gian ngừng dịch vụ

# 1. kiểm trước — máy chủ cũ vẫn đang chạy, không đổi gì
pg_upgrade --old-datadir=/old --new-datadir=/new \
           --old-bindir=/usr/libexec/postgresql16 --new-bindir=/usr/local/bin --check

# 2. dừng máy chủ cũ  <- bắt đầu tính thời gian ngừng
pg_ctl -D /old stop

# 3. nâng cấp
pg_upgrade --old-datadir=/old --new-datadir=/new \
           --old-bindir=/usr/libexec/postgresql16 --new-bindir=/usr/local/bin --link

# 4. khởi động máy chủ mới  <- kết thúc thời gian ngừng
pg_ctl -D /new start

# 5. thống kê
vacuumdb --all --analyze-in-stages

# 6. chỉ khi đã chắc
./delete_old_cluster.sh

Thời gian ngừng dịch vụ là từ bước 2 tới bước 4 — 1,8 giây trong phép đo này. Bước 5 chạy được khi đã mở lại cho người dùng, dù hệ thống sẽ chậm cho tới khi nó xong.

Bước 6 là bước tôi khuyên đợi ít nhất một ngày. Kịch bản delete_old_cluster.sh xoá vĩnh viễn cụm cũ, và với chế độ sao chép thì đó là đường lùi duy nhất của bạn.

Ba việc kiểm trước ngày nâng cấp

Extension. pg_upgrade không nâng cấp extension. Nếu bạn dùng postgis hay timescaledb, phiên bản tương thích phải được cài trên cụm mới trước khi chạy pg_upgrade. Đây là nguyên nhân thất bại phổ biến nhất, và --check bắt được nó — thông báo "Checking for presence of required libraries".

Sao chép. Máy dự phòng không được nâng cấp theo. Sau pg_upgrade, mọi replica phải dựng lại từ đầu bằng pg_basebackup — phần 53 đã đo con số đó: 78,9 giây cho 38 MB, và nó tăng theo kích thước.

Nếu bạn không chấp nhận được khoảng thời gian không có máy dự phòng, cách khác là dùng sao chép logic (phần 52): dựng cụm mới ở phiên bản mới, sao chép logic sang, rồi chuyển đổi. Thời gian ngừng dịch vụ tính bằng giây, nhưng bạn phải tự lo chuỗi, quyền, và DDL.

default_statistics_target và các tham số khác. pg_upgrade không mang postgresql.conf sang. Cụm mới dùng cấu hình mặc định của initdb, nên mọi thứ đã đo ở phần 44 tới 48 — shared_buffers, work_mem, max_wal_size, checkpoint_completion_target — đều trở về mặc định.

Đây là chỗ dễ mất nhiều nhất mà không ai để ý: cụm mới chạy đúng, dữ liệu đủ, và chậm hơn hẳn vì shared_buffers về 128 MB.

Mẫu số chung

Nhìn lại danh sách những gì pg_upgrade bỏ lại — thống kê, postgresql.conf, máy dự phòng — chúng có chung một bản chất: có hai loại trạng thái. Loại thứ nhất là dữ liệu bạn cố ý lưu; loại thứ hai là trạng thái hệ thống học được hoặc tích luỹ để chạy nhanh. Một cuộc di trú chép trung thành loại đầu và lặng lẽ vứt loại sau. Triệu chứng luôn giống nhau: đúng nhưng chậm — mọi thứ hoạt động, chỉ là kém đi — và nó vô hình cho tới khi bạn chủ động dựng lại. Thống kê chính là "trí nhớ chỗ để đồ" của bộ lập lịch.

Đúng cái bẫy này lặp ở mọi nơi có di trú hay khởi động lại:

  • Trong Java, dời một dịch vụ sang JVM mới là mất sạch hồ sơ mà JIT tích luỹ được — mã đã biên dịch nóng, quyết định nội tuyến. Instance mới đúng nhưng nguội và chậm cho tới khi tầng biên dịch làm nóng lại; nhiều nơi phải "warm-up" chủ động trước khi nhận tải thật, đúng vai trò của ANALYZE ở đây.
  • Trong Go, khởi động lại một tiến trình là xoá mọi cache trong bộ nhớ, nội dung sync.Pool, và trạng thái ấm của pool kết nối — đúng dữ liệu, nhưng lạnh.
  • Ngay trong cơ sở dữ liệu, sau một lần failover hay khởi động lại, bộ đệm trang của hệ điều hành và của chính engine về rỗng: truy vấn hôm qua chạy trong bộ nhớ nay phải chạm đĩa, cho tới khi cache ấm lại. Một bản pg_restore cũng cần ANALYZE y hệt pg_upgrade.

Nên câu hỏi đúng sau mỗi lần di trú hay khởi động lại không phải "dữ liệu còn đủ không" — nó gần như luôn đủ, đó chính là cái bẫy — mà là "trạng thái học được nào vừa bị xoá, và tôi dựng lại nó chủ động hay ngồi chờ". Chờ thì hệ thống tự khỏi, nhưng vài giờ đầu người dùng lãnh đủ. Muốn tự đo trước, chạy --check trên hệ thống của bạn ngay hôm nay:

pg_upgrade --old-datadir=... --new-datadir=... \
           --old-bindir=... --new-bindir=... --check

Nó không đổi gì, chạy được khi máy chủ đang phục vụ, và mất chưa tới một giây trên cơ sở dữ liệu vừa. Nếu nó báo lỗi extension, bạn vừa tiết kiệm được một buổi tối.

Và so cấu hình hai bên — danh sách phải chép sang tay:

select name, setting from pg_settings
where source = 'configuration file' order by name;

Phần sau đo bảo mật: vai trò, quyền, và bảo mật ở mức dòng — từng lớp chặn được cái gì.