TL;DRMỗi hàng trong PostgreSQL phải vừa trong một trang 8 KB. Sau phần header overhead, còn lại đúng 8160 byte cho mỗi hàng. Chuyển các cột có độ dài thay đổi sang kiểu lưu trữ EXTENDED và PostgreSQL sẽ tự xử lý phần còn lại — nén giá trị và chuyển sang bảng TOAST một cách tự động:
ALTER TABLE your_table ALTER COLUMN large_column SET STORAGE EXTENDED;
-- Ghi lại các hàng hiện có để chiến lược mới có hiệu lực
UPDATE your_table SET large_column = large_column WHERE id IS NOT NULL;
Vẫn vượt giới hạn? Bạn cần chuẩn hóa bảng hoặc tách blob ra khỏi database hoàn toàn.
Nguyên nhân lỗiMỗi hàng trong PostgreSQL tồn tại bên trong một trang 8 KB. TOAST (The Oversized-Attribute Storage Technique) là lối thoát — khi một giá trị cột đơn lẻ vượt khoảng 2 KB, PostgreSQL sẽ nén nó và/hoặc chuyển sang một bảng TOAST riêng biệt. Điểm mấu chốt: TOAST hoạt động theo từng cột, không phải theo từng hàng.
Điều đó có nghĩa là một bảng với 30 cột, mỗi cột chứa 300 byte JSON, sẽ không bao giờ kích hoạt TOAST cho bất kỳ cột đơn lẻ nào. Nhưng 30 × 300 = 9000 byte — vượt xa giới hạn 8160 byte. Mọi lệnh INSERT đều thất bại với:
ERROR: row is too big: size 8208, maximum size 8160
Bẫy khác là kiểu lưu trữ PLAIN. Một số kiểu mảng có độ dài cố định — và bất kỳ cột nào bạn đã đặt rõ ràng thành PLAIN — không thể được nén hoặc chuyển ra ngoài dòng. Những byte đó không thể thương lượng.
Chẩn đoán trướcTrước khi thay đổi bất cứ điều gì, hãy tìm hiểu xem cột nào đang chặn TOAST:
SELECT
a.attname AS column_name,
t.typname AS data_type,
CASE a.attstorage
WHEN 'p' THEN 'PLAIN (no TOAST)'
WHEN 'm' THEN 'MAIN (compress inline, TOAST as last resort)'
WHEN 'x' THEN 'EXTENDED (compress + out-of-line)'
WHEN 'e' THEN 'EXTERNAL (out-of-line, no compress)'
END AS storage_strategy
FROM pg_attribute a
JOIN pg_type t ON a.atttypid = t.oid
WHERE a.attrelid = 'your_table'::regclass
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY a.attnum;
Sau đó đo mức độ ảnh hưởng:
SELECT pg_size_pretty(AVG(pg_column_size(t.*))::bigint) AS avg_row_size
FROM your_table t;
-- Tìm các cột chiếm nhiều dung lượng nhất
SELECT id, pg_column_size(t.*) AS row_bytes
FROM your_table t
ORDER BY row_bytes DESC
LIMIT 10;
Bất kỳ cột có độ dài thay đổi nào (text, varchar, jsonb, bytea) đang hiển thị kiểu lưu trữ MAIN hoặc PLAIN chính là nơi bạn cần bắt đầu.
Cách sửa 1 — Thay đổi chiến lược lưu trữ cộtThường chỉ cần một lệnh ALTER là đủ. Chuyển các cột MAIN sang EXTENDED và PostgreSQL sẽ nén giá trị trước, sau đó chuyển ra ngoài dòng nếu vẫn còn quá lớn:
-- Dành cho các cột text, varchar, jsonb, xml
ALTER TABLE events ALTER COLUMN notes SET STORAGE EXTENDED;
ALTER TABLE events ALTER COLUMN payload SET STORAGE EXTENDED;
-- Dành cho bytea khi bạn muốn lưu ngoài dòng nhưng không tốn chi phí nén
ALTER TABLE events ALTER COLUMN raw_blob SET STORAGE EXTERNAL;
Một điểm cần lưu ý: chiến lược mới chỉ áp dụng cho các hàng được ghi sau lệnh ALTER. Các hàng hiện có vẫn giữ định dạng lưu trữ cũ cho đến khi bạn ghi lại:
-- Ghi lại nhẹ nhàng (khóa từng hàng trong thời gian ngắn)
UPDATE events SET notes = notes;
-- Hoặc dùng pg_repack để ghi lại không downtime (yêu cầu extension pg_repack)
pg_repack -t events -d your_database
Cách sửa 2 — Gộp nhiều cột vào JSONBBảng có quá nhiều cột là thủ phạm điển hình — 15 đến 30 cột text, hầu hết là NULL ở bất kỳ hàng nào. Gộp các cột tùy chọn vào một cột JSONB duy nhất sẽ giảm cả độ rộng hàng lẫn độ phức tạp schema. JSONB mặc định dùng kiểu lưu trữ EXTENDED, nên việc nén là tự động:
-- Thêm cột JSONB để chứa các trường dư thừa
ALTER TABLE events ADD COLUMN metadata JSONB;
-- Di chuyển dữ liệu các hàng hiện có
UPDATE events
SET metadata = jsonb_build_object(
'extra_field_1', extra_field_1,
'extra_field_2', extra_field_2,
'extra_field_3', extra_field_3
);
-- Kiểm tra xong thì xóa các cột cũ
ALTER TABLE events
DROP COLUMN extra_field_1,
DROP COLUMN extra_field_2,
DROP COLUMN extra_field_3;
Phù hợp nhất cho các trường không cần index B-tree riêng lẻ và thường được đọc cùng nhau trong một truy vấn.
Cách sửa 3 — Chuẩn hóa bằng bảng con 1:1Khi schema bị khóa cứng — ORM, ràng buộc ngoài, hoặc ứng dụng legacy sở hữu DDL — việc chia bảng theo chiều dọc là giải pháp. Chuyển các cột cồng kềnh, ít truy cập sang bảng con và JOIN khi cần:
CREATE TABLE events_content (
event_id BIGINT PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE,
description TEXT,
raw_payload BYTEA,
audit_log TEXT
);
INSERT INTO events_content (event_id, description, raw_payload, audit_log)
SELECT id, description, raw_payload, audit_log FROM events;
ALTER TABLE events
DROP COLUMN description,
DROP COLUMN raw_payload,
DROP COLUMN audit_log;
-- JOIN khi cần lấy đầy đủ bản ghi
SELECT e.*, ec.description, ec.raw_payload
FROM events e
LEFT JOIN events_content ec ON ec.event_id = e.id
WHERE e.id = 123;
Cách sửa 4 — Chuyển blob ra khỏi databaseCác file nhị phân — hình ảnh, PDF, file ZIP — không nên lưu trong các hàng PostgreSQL. Một file PDF 500 KB đơn lẻ đã vượt giới hạn 8160 byte. Hãy lưu file trên S3, CDN, hoặc ổ đĩa cục bộ và chỉ giữ lại tham chiếu trong bảng:
-- Thêm cột tham chiếu đường dẫn/URL
ALTER TABLE documents ADD COLUMN file_url TEXT;
-- Tầng ứng dụng: upload file lên S3, ghi URL vào database
UPDATE documents
SET file_url = 'https://your-bucket.s3.amazonaws.com/docs/doc_123.pdf'
WHERE id = 123;
-- Xóa cột blob nội tuyến sau khi đã di chuyển xong
ALTER TABLE documents DROP COLUMN file_blob;
Xác minh kết quảChạy các truy vấn sau để xác nhận việc sửa đã thành công trước khi thử lại lệnh INSERT bị lỗi:
-- Kích thước hàng lớn nhất trong toàn bộ bảng
SELECT MAX(pg_column_size(t.*)) AS max_row_bytes
FROM your_table t;
-- Phải dưới 8160
-- Xác nhận bảng TOAST có dữ liệu ngoài dòng
SELECT
c.relname AS toast_table,
pg_size_pretty(pg_total_relation_size(c.oid)) AS toast_size
FROM pg_class c
JOIN pg_class parent ON parent.reltoastrelid = c.oid
WHERE parent.relname = 'your_table';
-- Thử lại lệnh insert bị lỗi ban đầu
INSERT INTO your_table (col1, col2, large_col) VALUES ('...', '...', '...');
-- Phải thành công mà không có ERROR: row is too big

