Postgres 19: Cuối cùng cũng có hỗ trợ Temporal Tables (Bảng thời gian) gốc
PostgreSQL 19 đang chuẩn bị ra mắt với hỗ trợ gốc cho Temporal Tables theo chuẩn SQL:2011, giải quyết bài toán quản lý lịch sử dữ liệu một cách hiệu quả. Tính năng mới này mang đến cú pháp đơn giản hơn như WITHOUT OVERLAPS và FOR PORTION OF, thay thế cho các giải pháp phức tạp trước đây.
Gần đây, một câu hỏi mới đã xuất hiện trong lĩnh vực cơ sở dữ liệu: "Dữ liệu này trông như thế nào vào thứ Ba tuần trước?". Có thể đó là giá của một sản phẩm trước khi đợt giảm giá mùa lễ bắt đầu, hoặc phòng ban mà nhân viên đó thuộc về trước khi tái cơ cấu diễn ra. Nếu không thêm toàn bộ hệ thống trigger kiểm toán, làm thế nào chúng ta có thể biết dữ liệu trông ra sao trước và sau khi thay đổi tại một ngày cụ thể?
Chuẩn SQL:2011 đã chính thức hóa một giải pháp phù hợp hơn một thập kỷ trước với các temporal tables (bảng thời gian). Các engine cơ sở dữ liệu khác đã áp dụng các phần của nó khá nhanh. Đặc trưng của Postgres là luôn đi chậm nhưng chắc. Nhưng Postgres 19 cuối cùng cũng mang đến hỗ trợ temporal tables gốc đến bữa tiệc — và nó thực sự đáng để chờ đợi.
Hãy cùng xem những gì chúng ta sẽ có được.
Cách làm cũ kỹ
Trước khi đi đến những tính năng mới hào nhoáng, hãy xem xét cách tiếp cận cũ kỹ để có thêm góc nhìn. Giả sử chúng ta muốn theo dõi giá sản phẩm theo thời gian. Một nỗ lực hợp lý đầu tiên có thể trông như sau:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE products (
product_id INT NOT NULL,
product_name TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL,
valid_from DATE NOT NULL,
valid_to DATE NOT NULL,
CONSTRAINT no_time_travel CHECK (valid_from < valid_to)
);
Đơn giản đủ. Chúng ta có một sản phẩm, một giá và phạm vi ngày hợp lệ cho giá đó. Thật không may, không có gì ngăn chặn chúng ta chèn hai hàng cho cùng một sản phẩm với các phạm vi ngày chồng chéo. Sản phẩm số 42 có thể là 9,99 USD và 14,99 USD vào cùng một thứ Ba. Kế toán viên của bạn có thể sẽ có vài lời chọn lọc khi phát hiện ra điều này.
Câu trả lời truyền thống của Postgres ở đây là tiện ích mở rộng btree_gist và một ràng buộc loại trừ (exclusion constraint):
ALTER TABLE products
ADD CONSTRAINT no_overlapping_prices
EXCLUDE USING gist (
product_id WITH =,
daterange(valid_from, valid_to) WITH &&
);
Cách này hoạt động. Nếu chúng ta cố chèn một hàng xung đột, Postgres sẽ bắt lỗi:
INSERT INTO products VALUES (1, 'Widget', 9.99, '2025-01-01', '2025-07-01');
INSERT INTO products VALUES (1, 'Widget', 12.99, '2025-06-01', '2026-01-01');
ERROR: conflicting key value violates exclusion constraint
"no_overlapping_prices"
Một vấn đề được giải quyết bằng cách sử dụng btree_gist! Vậy vấn đề là gì? À, có vài điều:
- Mọi người đều biết về BTREE và lập chỉ mục nói chung, nhưng GiST là đặc thù của Postgres và do đó yêu cầu kinh nghiệm để hiểu.
- Cú pháp ràng buộc loại trừ khá không trực quan. Nó có trong tài liệu, nhưng không có lý do gì để ai đó nghĩ đây là cách tiếp cận tiêu chuẩn.
- Không có nhận thức về thời gian được tích hợp sẵn trong chính bảng đó.
Về cơ bản, Postgres không hiểu rằng đây là dữ liệu thời gian. Nó chỉ là các cột và một ràng buộc bí ẩn sử dụng kiểu chỉ mục phức tạp. Mọi cập nhật thay đổi phạm vi thời gian đều yêu cầu chia và nối thủ công các hàng, điều này có nghĩa là các ứng dụng phải gánh toàn bộ gánh nặng về tính chính xác theo thời gian.
Đó là mức tối thiểu, và thành thật mà nói chúng ta có thể làm tốt hơn.
Lịch sử ngắn gọn về thời gian
Khao khát có sự hỗ trợ thời gian phù hợp trong Postgres không phải là mới. Chuẩn SQL:2011 đã giới thiệu các khoảng thời gian APPLICATION TIME, các ràng buộc WITHOUT OVERLAPS và cú pháp FOR PORTION OF cho temporal DML. Năm 2011 là một thời gian rất lâu rồi.
Henrietta Dombrovskaya (bạn bè gọi là Hetti) là một trong những người ủng hộ sớm nhất cho dữ liệu thời gian trong hệ sinh thái Postgres. Cùng với Chad Slaughter, bà đã phát triển tiện ích mở rộng pg_bitemporal. Đó là một khung công tác để quản lý các bảng bitemporal hoàn toàn trong Postgres bằng cách sử dụng PL/pgSQL.
Sự phân biệt này rất quan trọng. Valid time (thời gian hợp lệ) nói rằng "giá này có hiệu lực từ tháng Giêng đến tháng Sáu". Transaction time (thời gian giao dịch) là góc nhìn của cơ sở dữ liệu, nói rằng "hàng này được chèn vào lúc 3:47 chiều ngày 12 tháng 3 và bị thay thế lúc 9:01 sáng ngày 3 tháng 4". Kết hợp cả hai tạo ra một bảng bitemporal có thể trả lời các câu hỏi như "chúng ta nghĩ giá là bao nhiêu vào thứ Ba tuần trước, dựa trên những gì chúng ta biết vào thời điểm đó?"
Cách tiếp cận pg_bitemporal dựa nhiều vào cơ chế EXCLUDE USING gist giống như chúng ta đã thảo luận, nhưng nhân đôi: một sự loại trừ cho phạm vi hiệu quả (valid time) và một cái khác cho phạm vi khẳng định (transaction time).
Nó hoạt động! Nhưng một tiện ích mở rộng chỉ có thể đi xa đến mức nào. Nó không thể thay đổi cách trình lập kế hoạch truy vấn nhìn thấy các vị từ thời gian, không thể tích hợp với hệ thống ràng buộc ở cấp độ engine và không thể cung cấp cú pháp DML gốc. Để làm được điều đó, tính năng cần phải được đưa vào core (lõi).
Bây giờ Postgres 19 đáp ứng một nửa thời gian ứng dụng của một hệ thống bitemporal. Đó chưa phải là toàn bộ bức tranh, nhưng đó vẫn là một bước tiến khổng lồ theo đúng hướng.
Range đến giải cứu
Hãy xây dựng lại bảng sản phẩm của chúng ta theo cách Postgres 19. Thay vì sử dụng hai cột riêng biệt valid_from và valid_to, chúng ta sử dụng một cột kiểu range duy nhất:
CREATE TABLE products (
product_id INT NOT NULL,
product_name TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL,
valid_at DATERANGE NOT NULL,
PRIMARY KEY (product_id, valid_at WITHOUT OVERLAPS)
);
Chỉ vậy thôi. Không cần tiện ích mở rộng btree_gist. Không có ràng buộc loại trừ. Mệnh đề WITHOUT OVERLAPS trong khóa chính cho Postgres biết rằng product_id phải là duy nhất tại bất kỳ thời điểm nào, nhưng cùng một sản phẩm có thể có nhiều hàng miễn là các phạm vi valid_at của chúng không chồng chéo.
Vậy nó so sánh thế nào với cách tiếp cận cũ của chúng ta? Hãy đặt chúng cạnh nhau.
Cách cũ:
CREATE EXTENSION IF NOT EXISTS btree_gist;
-- Hai cột riêng biệt cho phạm vi
valid_from DATE NOT NULL,
valid_to DATE NOT NULL,
-- Xây dựng phạm vi thủ công trong ràng buộc
EXCLUDE USING gist (
product_id WITH =,
daterange(valid_from, valid_to) WITH &&
)
Cách mới:
-- Cột range duy nhất, không cần extension
valid_at DATERANGE NOT NULL,
-- Nhận thức thời gian được tích hợp trong khóa chính
PRIMARY KEY (product_id, valid_at WITHOUT OVERLAPS)
Nó sạch sẽ hơn, biểu đạt hơn và Postgres thực sự hiểu chúng ta đang làm gì. Bên dưới, ràng buộc WITHOUT OVERLAPS vẫn sử dụng chỉ mục GiST và nó vẫn yêu cầu btree_gist cho các cột không phải thời gian trong khóa. Nhưng Postgres xử lý sự phụ thuộc đó tự động khi khởi tạo ràng buộc. Rất tiện lợi.
Chia nhỏ và xử lý
Bây giờ đến phần thực sự thú vị. Giả sử giá của Widget cần thay đổi thành 10,99 USD, nhưng chỉ từ tháng 3 đến tháng 9 năm 2025. Trong thế giới cũ, chúng ta sẽ phải chia thủ công hàng hiện có thành các mảnh: xóa hoặc cập nhật bản gốc, chèn phạm vi giá mới và chèn các hàng thừa cho các phần chúng ta không thay đổi. Làm sai bất kỳ điều nào trong số đó, chúng ta sẽ có khoảng trống hoặc chồng chéo trong dòng thời gian của mình.
Với temporal tables, chúng ta chỉ nói những gì chúng ta muốn:
UPDATE products
FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-09-01'
SET price = 10.99
WHERE product_id = 1;
Hãy xem các hàng trông như thế nào bây giờ:
SELECT * FROM products WHERE product_id = 1 ORDER BY valid_at;
product_id | product_name | price | valid_at
------------+--------------+-------+-------------------------
1 | Widget | 9.99 | [2025-01-01,2025-03-01)
1 | Widget | 10.99 | [2025-03-01,2025-07-01)
1 | Widget | 10.99 | [2025-07-01,2025-09-01)
1 | Widget | 12.99 | [2025-09-01,2026-01-01)
1 | Widget | 11.99 | [2026-01-01,2026-07-01)
Đợi đã... Chuyện gì đã xảy ra ở đây? Chúng ta bắt đầu với ba hàng Widget và bây giờ có năm hàng!
Postgres thấy các bộ dữ liệu tại phạm vi [2025-01-01, 2025-07-01) giá 9,99 USD và phạm vi [2025-07-01, 2026-01-01) giá 12,99 USD và cần sửa các chồng chéo để chứa hàng mới. Kết quả là Postgres tự động chia nhỏ các hàng hiện có để tạo chỗ cho dữ liệu mới mà không tạo ra khoảng trống hay chồng lấn.
Kết quả cuối cùng là không có khoảng trống và không có chồng chéo, và đó là điều chúng ta không có khi sử dụng logic loại trừ thuần túy. Đó là rất nhiều sức mạnh được đóng gói trong một câu lệnh UPDATE duy nhất.
Xóa lịch sử
Mệnh đề FOR PORTION OF cũng hoạt động với các câu lệnh DELETE. Giả sử Gadget bị tạm thời rút khỏi danh mục giữa tháng 6 và tháng 10 năm 2025:
DELETE FROM products
FOR PORTION OF valid_at FROM '2025-06-01' TO '2025-10-01'
WHERE product_id = 2;
Lệnh delete đã cắt ra cửa sổ từ tháng Sáu đến tháng Mười. Hàng 22,99 USD ban đầu bao phủ [2025-04-01, 2026-01-01) bị chia thành hai phần thừa: một cái kết thúc vào tháng Sáu và một cái bắt đầu vào tháng Mười. Dữ liệu định giá trước và sau khoảng trống được bảo toàn với các giá trị gốc của nó.
Dù bằng cách nào, các cơ chế cơ bản của việc quản lý temporal table có nghĩa là tất cả đều được xử lý tự động. Không còn nguy cơ xóa quá nhiều hoặc để lại các mảnh cô lập ở tầng ứng dụng.
Sự thật trong quảng cáo
Temporal tables sẽ không hoàn chỉnh nếu không có temporal foreign keys (khóa ngoại thời gian). Postgres 19 hỗ trợ những điều này bằng cách sử dụng từ khóa PERIOD:
CREATE TABLE variants (
variant_id INT NOT NULL,
product_id INT NOT NULL,
variant_name TEXT NOT NULL,
valid_at DATERANGE NOT NULL,
PRIMARY KEY (variant_id, valid_at WITHOUT OVERLAPS),
FOREIGN KEY (product_id, PERIOD valid_at)
REFERENCES products (product_id, PERIOD valid_at)
);
Từ khóa PERIOD cho Postgres biết rằng chính khóa ngoại đó là thời gian. Do đó, sản phẩm được tham chiếu phải tồn tại trong toàn bộ thời lượng của phạm vi valid_at của biến thể. Không đủ để có một hàng sản phẩm khớp ở đâu đó trong thời gian. Sự kết hợp của tất cả các hàng khớp trong bảng được tham chiếu phải bao phủ hoàn toàn khoảng thời gian của hàng tham chiếu.
Có một hạn chế đáng kể ở đây: temporal foreign keys chỉ hỗ trợ NO ACTION cho các hành động tham chiếu. Điều này loại bỏ CASCADE, SET NULL và SET DEFAULT. Điều đó có nghĩa là xóa một hàng sản phẩm mà một biến thể phụ thuộc vào luôn dẫn đến lỗi.
Những bước đầu tiên
Vì vậy, chúng ta đã có temporal tables thời gian ứng dụng với khả năng ngăn chặn chồng chéo, temporal DML và temporal foreign keys. Còn gì nữa?
Sự thiếu sót lớn nhất là system time (thời gian hệ thống), đôi khi được gọi là transaction time. Chúng ta đã đề cập rằng thời gian ứng dụng theo dõi khi các sự kiện là đúng trong thế giới thực, và thời gian hệ thống theo dõi khi cơ sở dữ liệu biết về những sự kiện đó. Các hệ thống dựa vào temporal tables thường sử dụng cả hai. Đây chính là không gian mà tiện ích mở rộng pg_bitemporal đã lấp đầy kể từ năm 2015.
Tài liệu temporal table thừa nhận điều này trực tiếp, lưu ý rằng thời gian hệ thống không được hỗ trợ gốc nhưng có thể được mô phỏng. Dù nó đến trong Postgres 20 hay sau đó là điều ai cũng đoán được, nhưng nền tảng đã có ở đó rồi.
Lời kết
Temporal tables đã vắng mặt một thời gian. Cách tiếp cận EXCLUDE USING gist hoạt động, nhưng nó là một giải pháp thay thế thô sơ so với cái mới. Các tiện ích mở rộng như pg_bitemporal đã chứng minh khái niệm và giữ cho cuộc trò chuyện tiếp diễn. Bây giờ chúng ta đã tiến thêm một bước gần hơn với giấc mơ loại bỏ nhu cầu sử dụng chúng.
Đó cũng là một cách tiếp cận trực quan hơn nhiều so với việc sử dụng các ràng buộc loại trừ GiST. WITHOUT OVERLAPS trong khóa chính đọc giống như tiếng Anh thuần túy như SQL nên thế. FOR PORTION OF nói chính xác những gì nó làm. Việc chia hàng tự động trong các cập nhật và xóa theo thời gian loại bỏ toàn bộ một lớp lỗi tiềm ẩn.
Con đường từ SQL:2011 đến Postgres 19 là một con đường dài. Hetti và những người khác trong cộng đồng đã dành nhiều năm để chứng minh các mẫu này là cần thiết và khả thi. Bây giờ chúng đã ở trong core, và chúng sẽ chỉ tốt hơn từ đây. Hãy chú ý đến hỗ trợ thời gian hệ thống trong các bản phát hành tương lai.
Hiện tại, hãy thử nghiệm với WITHOUT OVERLAPS và FOR PORTION OF trên một bản kiểm thử. Không có thời gian nào như hiện tại!


