Sử Dụng pg_repack để Khắc Phục Bloat Bảng và Index trong PostgreSQL Không Cần Khóa Bảng

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Khi PostgreSQL Bắt Đầu Ngốn Hết Đĩa Cứng (và Tại Sao VACUUM Không Đủ)

Vài năm trước, tôi đang xử lý sự cố trên một instance PostgreSQL chậm tại server production của khách hàng. Những query đáng lẽ phải hoàn thành trong vài millisecond lại chạy như rùa. pg_relation_size() cho thấy một bảng nặng 40 GB — nhưng số lượng row thực tế chỉ tương đương khoảng 8 GB. 32 GB còn lại là gánh nặng vô dụng: bloat.

Đã làm việc với MySQL, PostgreSQL và MongoDB qua nhiều dự án khác nhau, mỗi hệ thống có điểm mạnh riêng — nhưng mô hình MVCC (Multi-Version Concurrency Control) của PostgreSQL vừa là tính năng nổi bật nhất, vừa là nguyên nhân gốc rễ của vấn đề này. Mỗi lệnh UPDATE hay DELETE không ghi đè trực tiếp lên row; thay vào đó nó đánh dấu phiên bản cũ là dead và ghi phiên bản mới. Theo thời gian, đặc biệt trên các bảng có nhiều thao tác ghi, những dead tuple này tích lũy dần và làm phình to kích thước bảng cùng index.

Giải pháp tiêu chuẩn là VACUUM và AUTOVACUUM. Chúng thu hồi không gian dead tuple và cho phép tái sử dụng — nhưng không thu nhỏ file vật lý trên đĩa. Cách duy nhất để thu hồi hoàn toàn không gian đó là VACUUM FULL, vốn ghi lại toàn bộ bảng. Vấn đề là: VACUUM FULL yêu cầu exclusive lock, nghĩa là toàn bộ bảng sẽ offline trong thời gian chạy. Trên database production với hàng triệu row, đó có thể là vài phút đến vài giờ downtime.

Đó là lúc pg_repack phát huy tác dụng. Nó rebuild các bảng và index bị bloat ngay khi hệ thống đang chạy, chỉ cần một lock ngắn ở cuối để hoán đổi phiên bản cũ và mới. Production vẫn hoạt động bình thường. Người dùng không nhận ra bất kỳ sự gián đoạn nào.

Hiểu Rõ Bloat Trước Khi Khắc Phục

Trước khi cài đặt bất cứ thứ gì, bạn nên xác nhận rằng mình thực sự có vấn đề bloat. Chạy query này để ước tính bloat của bảng trong database:

SELECT
  schemaname,
  tablename,
  pg_size_pretty(pg_relation_size(schemaname || '.' || tablename)) AS table_size,
  pg_size_pretty(
    pg_relation_size(schemaname || '.' || tablename) -
    (reltuples * 8192 / fillfactor::float)::bigint
  ) AS estimated_bloat
FROM pg_stat_user_tables
JOIN pg_class ON relname = tablename
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_relation_size(schemaname || '.' || tablename) DESC
LIMIT 20;

Cách chính xác hơn là dùng extension pgstattuple:

CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT
  table_len,
  tuple_count,
  dead_tuple_count,
  dead_tuple_percent,
  free_space,
  free_percent
FROM pgstattuple('public.orders');

Nếu dead_tuple_percent hoặc free_percent vượt quá 20–30%, bảng đó là ứng viên cần repack.

Cài Đặt pg_repack

pg_repack là một extension của PostgreSQL — nó chạy code phía server trong quá trình repack. Bạn cần cài cả extension server lẫn công cụ CLI phía client.

Trên Ubuntu / Debian

sudo apt update
sudo apt install postgresql-16-repack  # Thay 16 bằng phiên bản PG của bạn

Trên RHEL / CentOS / Rocky Linux

sudo dnf install pg_repack_16  # Từ PGDG repo

Từ mã nguồn (khi không có gói cài đặt sẵn)

git clone https://github.com/reorg/pg_repack.git
cd pg_repack
make
sudo make install

Sau khi cài đặt, kích hoạt extension trên database mục tiêu:

-- Kết nối đến database của bạn
CREATE EXTENSION IF NOT EXISTS pg_repack;

Xác nhận đã tải thành công:

SELECT * FROM pg_extension WHERE extname = 'pg_repack';

Cấu Hình và Chạy pg_repack

pg_repack hoạt động bằng cách tạo một bản sao shadow của bảng, phát lại tất cả các thay đổi xảy ra trong quá trình sao chép thông qua trigger, sau đó hoán đổi bảng với một lock ACCESS EXCLUSIVE ngắn — thường dưới một giây với hầu hết các bảng.

Repack một bảng bị bloat

pg_repack \
  --host=localhost \
  --port=5432 \
  --username=postgres \
  --dbname=mydb \
  --table=public.orders

Chỉ repack index của bảng (không ghi lại bảng)

Đôi khi chỉ có index bị bloat — cách này nhanh hơn và ít ảnh hưởng hơn:

pg_repack \
  --host=localhost \
  --port=5432 \
  --username=postgres \
  --dbname=mydb \
  --table=public.orders \
  --only-indexes

Repack tất cả bảng trong database

pg_repack \
  --host=localhost \
  --port=5432 \
  --username=postgres \
  --dbname=mydb \
  --all

Dùng flag --dry-run trước để xem pg_repack sẽ làm gì mà không thực sự thực thi:

pg_repack --dbname=mydb --table=public.orders --dry-run

Kiểm Soát Mức Sử Dụng Tài Nguyên

Trên server production đang tải nặng, bạn không muốn pg_repack làm quá tải I/O. Hai flag sau sẽ giúp ích:

pg_repack \
  --dbname=mydb \
  --table=public.orders \
  --wait-timeout=10 \
  --no-kill-backend
  • --wait-timeout: Số giây chờ lock xung đột trước khi từ bỏ (mặc định: 60s)
  • --no-kill-backend: Không kill các kết nối khác — chỉ chờ hoặc hủy bỏ

Với các bảng có lượng ghi lớn, hãy cân nhắc chạy pg_repack trong khung giờ ít truy cập nhất (thường là 3–5 giờ sáng) và đóng gói trong một shell script:

#!/bin/bash
# repack_bloated.sh — chạy qua cron vào giờ thấp điểm

DB="mydb"
PG_USER="postgres"
TABLES=("public.orders" "public.events" "public.logs")

for TABLE in "${TABLES[@]}"; do
  echo "[$(date)] Đang repack $TABLE..."
  pg_repack \
    --dbname=$DB \
    --username=$PG_USER \
    --table=$TABLE \
    --wait-timeout=30 \
    --no-kill-backend
  echo "[$(date)] Hoàn thành: $TABLE"
done

Thêm vào crontab để bảo trì hàng tuần:

crontab -e
# Chạy mỗi Chủ nhật lúc 3 giờ sáng
0 3 * * 0 /usr/local/bin/repack_bloated.sh >> /var/log/pg_repack.log 2>&1

Xác Nhận và Giám Sát

Sau khi chạy pg_repack, hãy xác nhận bloat đã thực sự được xóa. So sánh kích thước bảng trước và sau:

SELECT
  pg_size_pretty(pg_relation_size('public.orders')) AS table_size,
  pg_size_pretty(pg_indexes_size('public.orders')) AS indexes_size,
  pg_size_pretty(pg_total_relation_size('public.orders')) AS total_size;

Chạy lại pgstattuple để xác nhận dead tuple đã được xóa sạch:

SELECT
  dead_tuple_count,
  dead_tuple_percent,
  free_percent
FROM pgstattuple('public.orders');

Một bảng được repack tốt sẽ hiển thị dead_tuple_count = 0 và free_percent gần 0 (hoặc gần với fillfactor bạn đã cấu hình).

Theo Dõi Sự Tăng Trưởng Bloat Theo Thời Gian

Bloat sẽ tăng trở lại — điều đó là bình thường. Mục tiêu là giữ nó ở mức kiểm soát được. Thiết lập một query theo dõi có thể chạy hàng tuần hoặc đưa vào Grafana/Datadog:

SELECT
  schemaname || '.' || relname AS table_name,
  n_dead_tup,
  n_live_tup,
  ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
  last_autovacuum,
  last_vacuum
FROM pg_stat_user_tables
WHERE n_live_tup > 10000
ORDER BY dead_pct DESC NULLS LAST
LIMIT 20;

Nếu dead_pct liên tục tăng trên một bảng cụ thể dù autovacuum chạy đều đặn, thường có nghĩa là autovacuum không theo kịp tốc độ ghi. Bạn có thể điều chỉnh theo từng bảng:

ALTER TABLE public.orders SET (
  autovacuum_vacuum_scale_factor = 0.01,   -- Kích hoạt khi 1% dead tuple
  autovacuum_analyze_scale_factor = 0.005
);

Những Gì Cần Theo Dõi Trong Log Production

pg_repack ghi log tiến trình ra stdout. Các dòng quan trọng để xác nhận quá trình chạy thành công:

INFO: repacking table "public"."orders"
INFO: repacking index "orders_pkey"
INFO: repacking index "orders_user_id_idx"
INFO: applying last changes on the target table
INFO: cleaning up temprary objects

Nếu thấy ERROR: query was canceled due to conflict with recovery, nghĩa là bạn đang chạy trên standby replica — pg_repack chỉ chạy trên primary. Nếu thấy ERROR: lock wait timeout exceeded, hãy tăng --wait-timeout hoặc đợi lúc ít traffic hơn.

Một Số Điều Tôi Học Được Qua Kinh Nghiệm Thực Tế

pg_repack cần đủ dung lượng đĩa trống để giữ một bản sao đầy đủ của bảng trong quá trình rebuild. Nếu bảng orders 40 GB của bạn đang được repack, bạn cần ít nhất 40 GB trống. Kiểm tra trước khi bắt đầu:

df -h /var/lib/postgresql/

Ngoài ra, pg_repack tạm thời cài đặt trigger trên bảng nguồn để theo dõi các thay đổi trong giai đoạn sao chép. Những trigger đó thêm một chút overhead vào thao tác ghi. Với các bảng có throughput cực cao (hàng triệu lần ghi mỗi phút), hãy kiểm thử trên staging trước để xác nhận overhead ở mức chấp nhận được.

Cuối cùng — pg_repack không thể repack các bảng không có primary key. Nếu bạn có bảng HEAP nào không có PK, hãy thêm PK trước hoặc dùng CLUSTER như phương án dự phòng (cái này có lock, nhưng có thể chấp nhận được trong maintenance window).

Sự kết hợp giữa chạy pg_repack hàng tuần, autovacuum được tinh chỉnh phù hợp cho từng bảng có nhiều lần ghi, và một query theo dõi bloat là cấu hình đã giúp tôi quản lý tốt nhiều deployment PostgreSQL production. Không hào nhoáng gì, nhưng đây là thói quen vận hành giúp query luôn nhanh và chi phí đĩa dự đoán được.

Share: