PostgreSQL Internals: PostgreSQL lưu trữ và truy vấn dữ liệu dưới đĩa như thế nào?
Khi làm việc với PostgreSQL, chắc hẳn bạn đã biết về sức mạnh lưu trữ và khả năng truy vấn dữ liệu mạnh mẽ của hệ quản trị này. Nhưng ở tầng bên dưới, PostgreSQL lưu trữ dữ liệu của các bảng như thế nào trên đĩa cứng? Trong bài viết này, chúng ta sẽ cùng đi sâu vào cơ chế lưu trữ vật lý cũng như cách PostgreSQL đọc/ghi các bản ghi (tuples).
1. Cấu trúc của Bảng
Mỗi bảng trong PostgreSQL được lưu trữ dưới dạng các Data files trên đĩa cứng.
- Mỗi Data file bao gồm nhiều trang (pages) với kích thước mặc định là 8192 bytes (8 KB).
- Các page trong Data file được đánh số thứ tự tăng dần bắt đầu từ 0, được gọi là Page Number.
- Khi một page bị đầy và không còn đủ khoảng trống chứa bản ghi mới, PostgreSQL sẽ tự động khởi tạo page mới và nối tiếp vào cuối Data file.
Thông tin thêm:
- Mặc định, mỗi Data file của PostgreSQL có kích thước tối đa là 1 GB (chứa khoảng 131,072 pages). Khi Data file bị đầy, PostgreSQL sẽ tự động khởi tạo thêm Data file mới.
- Để đơn giản hóa, trong bài viết này chúng ta sẽ giả định dữ liệu chỉ nằm gọn trong 1 Data file.
Dưới đây là mô hình miêu tả cấu trúc của Data file được lưu trữ trên ổ cứng.
Hình 1.1: Cấu trúc các thành phần bên trong một Data file của PostgreSQL.
- Một page tiêu chuẩn bao gồm 3 vùng dữ liệu chính:
-
Header Data: Nằm ở đầu mỗi page, chứa các thông tin quản lý quan trọng.
- pd_lsn: Ghi nhận LSN (Log Sequence Number) của bản ghi WAL gần nhất làm thay đổi page này (phục vụ Recovery/Replication)
- pd_checksum: Mã kiểm tra tính toàn vẹn dữ liệu của page khi đọc từ đĩa lên RAM (phát hiện Data Corruption).
- pd_lower: Con trỏ trỏ đến vị trí kết thúc của mảng Line Pointers (phát triển từ trên xuống).
- pd_upper: Con trỏ trỏ đến vị trí bắt đầu của bản ghi mới nhất (phát triển từ dưới lên).
- pd_special: Dùng riêng cho các loại Index (như B-tree, GiST, GIN). Đối với Table Page thông thường, con trỏ này trỏ thẳng đến cuối page.
-
Line Pointer(s):
- Là một mảng chứa các con trỏ, mỗi con trỏ trỏ đến vị trí của bản ghi (heap tuple).
- Các phần tử được đánh số từ 1 — gọi là Offset Number.
- Cặp (Page Number, Offset Number) tạo thành Tuple ID (TID) giúp PostgreSQL truy cập trực tiếp vào bản ghi mà không cần quét toàn bộ page.
-
Heap Tuple(s):
- Là nơi thực sự chứa dữ liệu của từng bản ghi (row), được xếp chồng tuần tự từ đáy page đi lên.
-
Lưu ý: Khoảng không gian nằm giữa
pd_lowervàpd_upperchính là Free Space (vùng trống chưa sử dụng). Dữ liệu mới thêm vào sẽ làmpd_lowerdịch xuống vàpd_upperdịch lên cho đến khi hai con trỏ này gặp nhau.
2. Cơ chế ghi
-
Giả sử chúng ta khởi tạo bảng
usersvà thực hiện chèn một bản ghi đầu tiên:CREATE TABLE users ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL ); INSERT INTO users (name) VALUES ('John');Table users: +----+------+ | id | name | |----+------| | 1 | John | +----+------+- Lúc này, dữ liệu bản ghi 'John' được lưu trữ bên trong page 0 như mô hình dưới đây.
Hình 1.2: Trạng thái lưu trữ của Page 0 sau khi chèn bản ghi 'John'.
- TID (Tuple ID) của bản ghi 'John' chính là (0, 1) — tương ứng với Page 0 và Offset 1. Chúng ta có thể kiểm tra trực tiếp bằng câu lệnh truy vấn trường ẩn ctid:
SELECT ctid, * from users WHERE ctid = '(0,1)';Kết quả: +-------+----+------+ | ctid | id | name | |-------+----+------| | (0,1) | 1 | John | +-------+----+------+
- Lúc này, dữ liệu bản ghi 'John' được lưu trữ bên trong page 0 như mô hình dưới đây.
-
Vậy điều gì sẽ xảy ra khi chúng ta chèn tiếp bản ghi thứ hai?
INSERT INTO users (name) VALUES ('Paul');- Lúc này, trang dữ liệu Page 0 sẽ được cập nhật thêm bản ghi mới với bố trí như sau:
- Bản ghi mới ('Paul') sẽ đặt vào vùng trống tiếp thep ở đáy page (phía trên bản ghi 'John').
- Line Pointer thứ 2 sẽ được thêm vào mảng Line Pointers (phát triển từ trên xuống), trỏ trực tiếp đến vị trí của bản ghi 'Paul'.
Hình 1.3: Trạng thái lưu trữ của Page 0 sau khi chèn bản ghi thứ 2.- Bản ghi thứ hai lúc này sở hữu TID = (0, 2) (Page 0, Offset 2). Ta kiểm tra lại bằng câu lệnh:
SELECT ctid, * from users WHERE ctid = '(0,2)'; +-------+----+------+ | ctid | id | name | |-------+----+------| | (0,2) | 2 | Paul | +-------+----+------+
- Lúc này, trang dữ liệu Page 0 sẽ được cập nhật thêm bản ghi mới với bố trí như sau:
Lưu ý:
- PostgreSQL không ghi trực tiếp thay đổi vào Data File ngay lập tức, mà ghi trước vào tệp WAL (Write-Ahead Log). Cơ chế này vừa tối ưu hiệu năng ghi, vừa đảm bảo tính toàn vẹn và khả năng phục hồi dữ liệu khi xảy ra sự cố. (tài liệu tham khảo về WAL)
3. Cơ chế đọc
- Trong PostgreSQL, có hai phương thức đọc dữ liệu từ Heap Table điển hình là Sequential Scan (Quét tuần tự) và B-tree Index Scan (Quét theo chỉ mục B-tree):
- Sequential Scan:
- PostgreSQL sẽ quét lần lượt từng Page từ đầu đến cuối Data File (
Block 0đếnBlock N). - Trong mỗi Page, hệ thống duyệt qua từng Line Pointer để đọc các Heap Tuple tương ứng và kiểm tra xem bản ghi đó có thỏa mãn điều kiện truy vấn hay không.
- PostgreSQL sẽ quét lần lượt từng Page từ đầu đến cuối Data File (
- B-tree Index Scan:
- Thay vì duyệt toàn bộ Heap Table, PostgreSQL sẽ tìm kiếm trên Index File (tệp chỉ mục B-tree) trước.
- Mỗi Index Tuple chứa thông tin Tuple ID (TID) trỏ trực tiếp đến bản ghi tương ứng trong Heap Table.
- Sequential Scan:
Lưu ý: Ngoài Sequential Scan và B-tree Index Scan, PostgreSQL hỗ trợ TID-Scan, Bitmap-Scan, và Index-Only-Scan. Trong bài viết này, mình sẽ chỉ đề cập đến Sequential Scan và B-tree Index Scan.
- Nào, cùng mình thử nghiệm thông qua thực tế nhé!
-
Thử nghiệm với Sequential Scan
-- 1. Tạo bảng users DROP TABLE IF EXISTS users; CREATE TABLE users ( id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL UNIQUE ); -- 2. Chèn 400 bản ghi INSERT INTO users (name, email) SELECT 'User_' || i, 'user_' || i || '@example.com' FROM generate_series(1, 400) AS i;-
Bây giờ, chúng ta chạy câu lệnh kiểm tra phương thức truy vấn bằng
EXPLAIN ANALYZE:EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'user_252@example.com';Seq Scan on users (cost=0.00..10.88 rows=1 width=1036) (actual time=0.110..0.143 rows=1.00 loops=1) Filter: ((email)::text = 'user_252@example.com'::text) Rows Removed by Filter: 399 Buffers: shared hit=4 Planning: Buffers: shared hit=16 Planning Time: 0.334 ms Execution Time: 0.239 msPhân tích:
- Phương thức: Seq Scan — PostgreSQL đã phải đọc tuần tự toàn bộ Data File.
- Rows Removed by Filter: 399 — PostgreSQL buộc phải đọc đủ 400 bản ghi và loại bỏ 399 dòng không khớp để lấy ra 1 kết quả thỏa mãn. Việc duyệt toàn bộ dữ liệu thay vì dừng ngay khi tìm thấy kết quả gây ra lãng phí chi phí I/O và CPU.
Hình 1.4: Mô phỏng cơ chế Sequential Scan quét qua các Page và Line Pointers để tìm bản ghi thỏa mãn.
-
-
Thử nghiệm với B-tree Index Scan
- Tiếp theo, chúng ta tiến hành tạo index cho cột email và chạy lại câu lệnh
EXPLAIN ANALYZE:-- 1. Tạo Index cho email CREATE INDEX idx_users_email ON users(email); EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'user_252@example.com';
Phân tích:Index Scan using idx_users_email on users (cost=0.27..8.29 rows=1 width=32) (actual time=0.368..0.371 rows=1.00 loops=1) Index Cond: ((email)::text = 'user_252@example.com'::text) Index Searches: 1 Buffers: shared hit=3 Planning Time: 0.781 ms Execution Time: 0.519 ms- Phương thức: Index Scan - PostgreSQL sử dụng index file để tìm ra chính xác vị trí Tuple ID (TID) của bản ghi thay vì phải đọc toàn bộ bảng.
- Trong trường hợp này, index tuple được trả về với TID là
(Block = 1, Offset = 1), PostgreSQL sẽ mở Page 1 và dùng Offset 1 để lấy ra đúng bản ghi cần tìm mà không phải quét các page khác.
Hình 1.5 Mô phỏng cơ chế Index Scan.
- Tiếp theo, chúng ta tiến hành tạo index cho cột email và chạy lại câu lệnh
-
4. Lời kết
Cảm ơn bạn đã cùng mình đi qua những khái niệm thú vị về kiến trúc lưu trữ đĩa vật lý của PostgreSQL. Hy vọng qua bài viết này, bạn đã hiểu rõ hơn về cấu trúc Page, Tuple ID (TID) cũng như sự khác biệt giữa Sequential Scan và Index Scan.
Nếu muốn đi sâu hơn nữa vào kiến trúc của PostgreSQL, mình rất khuyến khích (highly recommend) bạn tham khảo cuốn tài liệu tuyệt vời The Internals of PostgreSQL của tác giả Hironobu SUZUKI.
Nguồn tham khảo:
All rights reserved