Tại Sao PostgreSQL Chậm Dần: Phân Tích Hiện Tượng Ghi Dữ Liệu Gia Tăng (Write Amplification)

1. Khái Niệm Về Ghi Dữ Liệu Gia Tăng (WAL Write Amplification) trong PostgreSQL

Trong các hệ thống PostgreSQL có tần suất ghi dữ liệu cao, chúng ta thường quan sát thấy một lượng lớn nhật ký WAL (Write-Ahead Log) được tạo ra, vượt xa đáng kể kích thước dữ liệu thực tế được cập nhật. Hiện tượng này được gọi là "Ghi Dữ Liệu Gia Tăng WAL" (WAL Write Amplification).

Có hai nguyên nhân chính dẫn đến tình trạng này:

  1. Ghi Toàn Trang (Full Page Writes): Sau một điểm kiểm tra (checkpoint), lần sửa đổi đầu tiên trên một trang dữ liệu sẽ yêu cầu PostgreSQL ghi toàn bộ nội dung của trang đó vào WAL. Mục đích của cơ chế ghi toàn trang là để đảm bảo khả năng phục hồi dữ liệu trong trường hợp hệ thống gặp sự cố đột ngột (như mất điện), ngăn chặn tình trạng ghi một phần khối dữ liệu có thể làm hỏng cơ sở dữ liệu.
  2. Cập Nhật Chỉ Mục (Index Updates): Khi một bản ghi được cập nhật và vị trí mới của nó (ctid) thay đổi, các bản ghi chỉ mục tương ứng cũng phải được cập nhật. Những thay đổi trong chỉ mục này cũng phải được ghi vào WAL. Nghiêm trọng hơn, việc cập nhật chỉ mục đôi khi có thể gây ra hiện tượng ghi toàn trang cho các trang chỉ mục, làm tăng thêm mức độ ghi dữ liệu gia tăng vào WAL.

Lượng WAL quá lớn có thể tiêu tốn đáng kể tài nguyên hệ thống, do đó cần có các biện pháp tối ưu hóa phù hợp:

  • Tác động đến I/O Đĩa: Mặc dù việc ghi WAL thường là ghi tuần tự, vốn hiệu quả ngay cả trên các ổ đĩa kém, nhưng lượng WAL khổng lồ vẫn có thể gây áp lực.
  • Tác động đến I/O Mạng: Đối với sao chép cục bộ, vấn đề có thể không quá lớn, nhưng với sao chép từ xa, băng thông mạng có thể trở thành nút thắt cổ chai.
  • Không gian Đĩa: Nếu thực hiện lưu trữ WAL (WAL archiving), dung lượng đĩa cần thiết để lưu trữ các tệp WAL có thể là rất lớn.

Hiện Tượng Ghi Dữ Liệu Gia Tăng (Write Amplification)

Hiện tượng ghi dữ liệu gia tăng trong PostgreSQL chủ yếu xuất phát từ cơ chế MVCC (Multi-Version Concurrency Control) và thiết kế WAL (Write-Ahead Log) của nó. Dưới đây là tóm tắt các vấn đề cốt lõi:

Các Trường Hợp Kiểm Thử Ghi Dữ Liệu Gia Tăng

Chuẩn bị môi trường kiểm thử với PostgreSQL phiên bản 15.3.


-- 1. Tạo cơ sở dữ liệu kiểm thử
CREATE DATABASE kiem_tra_ghi_du_lieu;
\c kiem_tra_ghi_du_lieu

-- Bảng để ghi lại kết quả giám sát (tùy chọn)
CREATE TABLE ket_qua_kiem_tra (
    ten_bai_kiem_tra TEXT,
    thoi_gian_bat_dau TIMESTAMP,
    thoi_gian_ket_thuc TIMESTAMP,
    kich_thuoc_bang_truoc BIGINT,
    kich_thuoc_bang_sau BIGINT,
    kich_thuoc_wal_truoc BIGINT,
    kich_thuoc_wal_sau BIGINT,
    so_dong_bi_anh_huong BIGINT,
    ty_le_gia_tang NUMERIC
);
    

Kiểm Thử 1: Ghi Dữ Liệu Gia Tăng do MVCC

Bài kiểm thử này minh họa cách cơ chế MVCC của PostgreSQL có thể dẫn đến việc tăng kích thước dữ liệu và WAL khi các bản ghi được cập nhật.


-- Cấu trúc bảng kiểm thử MVCC
CREATE TABLE lich_su_cap_nhat (
    ma_muc SERIAL PRIMARY KEY,
    noi_dung_du_lieu TEXT,
    thoi_gian_tao TIMESTAMP DEFAULT now()
);

-- Chèn dữ liệu ban đầu (ví dụ: 500,000 dòng)
INSERT INTO lich_su_cap_nhat (noi_dung_du_lieu)
SELECT md5(random()::text) FROM generate_series(1, 500000);

-- Ghi lại trạng thái ban đầu của bảng và tổng WAL
SELECT
    pg_size_pretty(pg_relation_size('lich_su_cap_nhat')) AS kich_thuoc_bang_ban_dau,
    pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0')) AS tong_wal_ban_dau;
-- Ví dụ kết quả:
-- kich_thuoc_bang_ban_dau | tong_wal_ban_dau
-- ------------------------+------------------
-- 36 MB                   | 150 MB

-- Thực hiện thao tác cập nhật (sẽ tạo ra phiên bản mới của dòng, gây gia tăng)
UPDATE lich_su_cap_nhat SET noi_dung_du_lieu = md5(random()::text) WHERE ma_muc <= 250000;

-- Ghi lại trạng thái cuối cùng và tính toán tỷ lệ gia tăng (tổng WAL từ đầu cluster)
SELECT
    pg_size_pretty(pg_relation_size('lich_su_cap_nhat')) AS kich_thuoc_bang_sau_cap_nhat,
    pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0')) AS tong_wal_sau_cap_nhat;
-- Ví dụ kết quả:
-- kich_thuoc_bang_sau_cap_nhat | tong_wal_sau_cap_nhat
-- -----------------------------+-----------------------
-- 55 MB                        | 220 MB
    

Kiểm Thử 2: Ghi Dữ Liệu Gia Tăng do Chỉ Mục

Bài kiểm thử này cho thấy việc có nhiều chỉ mục trên một bảng có thể làm tăng đáng kể lượng ghi WAL và kích thước đĩa khi các cột được lập chỉ mục bị cập nhật.


-- Tạo bảng với nhiều chỉ mục
CREATE TABLE bang_chi_muc_lon (
    id_pk SERIAL PRIMARY KEY,
    ten_san_pham TEXT,
    so_luong_kho INTEGER,
    ngay_cap_nhat TIMESTAMP
);

-- Tạo nhiều chỉ mục trên các cột khác nhau
CREATE INDEX idx_ten_san_pham ON bang_chi_muc_lon(ten_san_pham);
CREATE INDEX idx_so_luong ON bang_chi_muc_lon(so_luong_kho);
CREATE INDEX idx_ngay_cap_nhat ON bang_chi_muc_lon(ngay_cap_nhat);
CREATE INDEX idx_ket_hop_sp_sl ON bang_chi_muc_lon(ten_san_pham, so_luong_kho);

-- Chèn dữ liệu ban đầu (ví dụ: 50,000 dòng)
INSERT INTO bang_chi_muc_lon (ten_san_pham, so_luong_kho, ngay_cap_nhat)
SELECT
    md5(random()::text),
    (random() * 500)::integer,
    now() - (random() * 180)::integer * '1 day'::interval
FROM generate_series(1, 50000);

-- Ghi lại kích thước của từng chỉ mục
SELECT
    indexname AS ten_chi_muc,
    pg_size_pretty(pg_relation_size(indexname::regclass)) AS kich_thuoc_chi_muc
FROM pg_indexes
WHERE tablename = 'bang_chi_muc_lon';
-- Ví dụ kết quả:
-- ten_chi_muc           | kich_thuoc_chi_muc
-- ----------------------+--------------------
-- bang_chi_muc_lon_pkey | 1104 kB
-- idx_ten_san_pham      | 3800 kB
-- idx_so_luong          | 480 kB
-- idx_ngay_cap_nhat     | 536 kB
-- idx_ket_hop_sp_sl     | 3800 kB

-- Thực hiện cập nhật các cột có chỉ mục (quan sát chi phí duy trì chỉ mục)
UPDATE bang_chi_muc_lon
SET ten_san_pham = md5(random()::text), so_luong_kho = (random() * 500)::integer
WHERE id_pk BETWEEN 10000 AND 30000; -- Cập nhật 20,000 dòng

-- Kiểm tra lại sự thay đổi kích thước của chỉ mục
SELECT
    indexname AS ten_chi_muc,
    pg_size_pretty(pg_relation_size(indexname::regclass)) AS kich_thuoc_chi_muc
FROM pg_indexes
WHERE tablename = 'bang_chi_muc_lon';
-- Ví dụ kết quả sau cập nhật:
-- ten_chi_muc           | kich_thuoc_chi_muc
-- ----------------------+--------------------
-- bang_chi_muc_lon_pkey | 1660 kB
-- idx_ten_san_pham      | 5500 kB
-- idx_so_luong          | 600 kB
-- idx_ngay_cap_nhat     | 670 kB
-- idx_ket_hop_sp_sl     | 5500 kB
    

Kiểm Thử 3: Ghi Dữ Liệu Gia Tăng do WAL

Bài kiểm thử này so sánh ảnh hưởng của việc thực hiện các giao dịch nhỏ thường xuyên so với một giao dịch lớn duy nhất đến lượng WAL được tạo ra. Về lý thuyết, nhiều giao dịch nhỏ sẽ tạo ra nhiều bản ghi commit WAL hơn, nhưng đôi khi sự khác biệt có thể không đáng kể đối với lượng dữ liệu nhỏ.


-- Bảng kiểm thử WAL
CREATE TABLE ghi_log_wal (ma_ban_ghi SERIAL PRIMARY KEY, noi_dung_du_lieu TEXT);

-- Lưu LSN (Log Sequence Number) ban đầu để tính toán chính xác WAL được tạo
SELECT pg_current_wal_lsn() AS lsn_bat_dau_kiem_tra; -- Ghi lại LSN này

-- Test 1: Các giao dịch nhỏ, thường xuyên (chi phí WAL cao hơn do nhiều commit)
DO $$
DECLARE
    lsn_truoc_tx_nho pg_lsn;
    lsn_sau_tx_nho pg_lsn;
BEGIN
    lsn_truoc_tx_nho := pg_current_wal_lsn();
    FOR i IN 1..500 LOOP
        INSERT INTO ghi_log_wal (noi_dung_du_lieu) VALUES (md5(random()::text));
        COMMIT; -- Mỗi INSERT đều được commit riêng biệt
    END LOOP;
    lsn_sau_tx_nho := pg_current_wal_lsn();
    RAISE NOTICE 'WAL được tạo bởi % giao dịch nhỏ: %', 500, pg_size_pretty(pg_wal_lsn_diff(lsn_sau_tx_nho, lsn_truoc_tx_nho));
END $$;
-- Ví dụ kết quả (thường cao hơn một chút so với giao dịch lớn):
-- NOTICE:  WAL được tạo bởi 500 giao dịch nhỏ: 255 MB

TRUNCATE ghi_log_wal; -- Dọn dẹp bảng cho lần kiểm thử tiếp theo
ALTER SEQUENCE ghi_log_wal_ma_ban_ghi_seq RESTART WITH 1; -- Đặt lại sequence

-- Test 2: Giao dịch lớn, commit một lần (chi phí WAL thấp hơn)
DO $$
DECLARE
    lsn_truoc_tx_lon pg_lsn;
    lsn_sau_tx_lon pg_lsn;
BEGIN
    lsn_truoc_tx_lon := pg_current_wal_lsn();
    FOR i IN 1..500 LOOP
        INSERT INTO ghi_log_wal (noi_dung_du_lieu) VALUES (md5(random()::text));
    END LOOP;
    COMMIT; -- Tất cả các INSERT được commit cùng một lúc
    lsn_sau_tx_lon := pg_current_wal_lsn();
    RAISE NOTICE 'WAL được tạo bởi % giao dịch lớn: %', 500, pg_size_pretty(pg_wal_lsn_diff(lsn_sau_tx_lon, lsn_truoc_tx_lon));
END $$;
-- Ví dụ kết quả (thường thấp hơn một chút so với giao dịch nhỏ):
-- NOTICE:  WAL được tạo bởi 500 giao dịch lớn: 250 MB
    

Kiểm Thử 4: Ghi Dữ Liệu Gia Tăng do TOAST

Bài kiểm thử này khám phá cách các trường dữ liệu lớn (TEXT, BYTEA) được lưu trữ bằng cơ chế TOAST có thể gây ra hiện tượng ghi dữ liệu gia tăng khi chúng được cập nhật.


-- Tạo bảng kiểm thử TOAST
CREATE TABLE du_lieu_lon_toast (
    ma_id SERIAL PRIMARY KEY,
    du_lieu_nho TEXT,
    du_lieu_lon_chuoi TEXT -- Trường này có thể được TOAST
);

-- Chèn dữ liệu chứa các đối tượng lớn
INSERT INTO du_lieu_lon_toast (du_lieu_nho, du_lieu_lon_chuoi)
SELECT
    'du_lieu_ngan',
    repeat('Z', 5000) -- Kích thước lớn hơn ngưỡng TOAST mặc định (thường 2KB)
FROM generate_series(1, 500);

-- Ghi lại kích thước ban đầu của bảng chính và bảng TOAST liên quan
SELECT
    pg_size_pretty(pg_relation_size('du_lieu_lon_toast')) AS kich_thuoc_bang_chinh_truoc,
    pg_size_pretty(pg_relation_size((SELECT reltoastrelid FROM pg_class WHERE relname = 'du_lieu_lon_toast'))) AS kich_thuoc_toast_truoc;
-- Ví dụ kết quả (bảng TOAST ban đầu có thể có kích thước nhỏ hoặc 0 nếu chưa có dữ liệu nào bị đẩy vào đó):
-- kich_thuoc_bang_chinh_truoc | kich_thuoc_toast_truoc
-- ----------------------------+------------------------
-- 104 kB                      | 0 bytes
-- (Lưu ý: Kích thước TOAST có thể là 0 bytes nếu chưa có dữ liệu lớn nào thực sự được đẩy vào bảng TOAST hoặc nếu dữ liệu được nén hiệu quả)

-- Cập nhật trường dữ liệu lớn
UPDATE du_lieu_lon_toast SET du_lieu_lon_chuoi = repeat('Y', 5000) WHERE ma_id <= 250;

-- Kiểm tra sự tăng trưởng của bảng TOAST sau khi cập nhật
SELECT
    pg_size_pretty(pg_relation_size('du_lieu_lon_toast')) AS kich_thuoc_bang_chinh_sau,
    pg_size_pretty(pg_relation_size((SELECT reltoastrelid FROM pg_class WHERE relname = 'du_lieu_lon_toast'))) AS kich_thuoc_toast_sau;
-- Ví dụ kết quả (kích thước bảng TOAST tăng lên đáng kể):
-- kich_thuoc_bang_chinh_sau | kich_thuoc_toast_sau
-- --------------------------+----------------------
-- 152 kB                    | 1208 kB
    

Kiến Thức Mở Rộng: TOAST

TOAST (The Oversized-Attribute Storage Technique) là một kỹ thuật cốt lõi trong PostgreSQL để xử lý và lưu trữ các giá trị thuộc tính (cột) quá lớn.

Trong PostgreSQL, kích thước trang dữ liệu cố định là 8KB và không cho phép một hàng dữ liệu vật lý trải dài qua nhiều trang. Khi một giá trị cột vượt quá giới hạn này, cơ chế TOAST sẽ được kích hoạt để xử lý. TOAST sẽ nén hoặc chia nhỏ giá trị quá lớn thành nhiều phần nhỏ hơn và lưu trữ chúng trong một bảng TOAST chuyên biệt, sau đó bảng gốc chỉ giữ một con trỏ (TOAST pointer) đến dữ liệu đó.

Cơ Chế Mô Tả
Điều kiện kích hoạt Khi kích thước một hàng dữ liệu vượt quá toast_tuple_threshold (mặc định 2KB), TOAST sẽ được kích hoạt.
Phương thức xử lý Giá trị cột lớn sẽ được nén hoặc cắt thành các mảnh nhỏ và lưu vào một bảng TOAST riêng biệt. Bảng chính chỉ chứa một con trỏ tới dữ liệu TOAST.
Lưu trữ phân mảnh Các trường dữ liệu siêu lớn được chia thành nhiều "chunk" (mặc định tối đa 2KB mỗi chunk), mỗi chunk được lưu trữ như một hàng trong bảng TOAST.
Hỗ trợ nén Hỗ trợ các thuật toán nén (như LZ4, PGLZ). PostgreSQL ưu tiên nén trước; nếu vẫn còn quá lớn, nó sẽ tiến hành chia mảnh.

Thẻ: PostgreSQL Write Amplification WAL MVCC Indexes

Đăng vào ngày 16 tháng 6 lúc 21:29