Id tăng mà `created_at` không tăng theo — chuyện gì đang xảy ra trong PostgreSQL?
Khi làm việc với PostgreSQL, rất nhiều người vô thức xem id tăng dần là "thứ tự thời gian", rồi mong created_at của bản ghi cũng đi cùng chiều. Nhưng trong thực tế, đặc biệt khi có nhiều transaction chạy đồng thời, giả định này rất dễ sai.
PostgreSQL cấp id qua sequence bằng nextval(), còn created_at lại phụ thuộc vào hàm thời gian bạn chọn — transaction_timestamp() hay clock_timestamp(). Hai cơ chế này hoàn toàn độc lập với nhau, nên khi có transaction dài, insert đồng thời, hoặc commit lệch nhau, bạn hoàn toàn có thể thấy bản ghi có id nhỏ hơn nhưng created_at lại muộn hơn, hoặc ngược lại.
Bài này sẽ phân tích 5 câu hỏi cốt lõi:
idtừ sequence thực sự được cấp khi nào?transaction_timestamp()vàclock_timestamp()khác nhau ở đâu?- Vì sao concurrency làm lệch thứ tự giữa
idvàcreated_at? - Vì sao đổi sang
clock_timestamp()chỉ giảm lệch chứ không xóa hoàn toàn? - Nên chọn hướng giải quyết nào tùy theo bài toán?
1. id từ sequence thực sự được cấp khi nào?
Trong PostgreSQL, sequence là một object riêng để phát số, và nextval(regclass) là hàm lấy số tiếp theo. nextval() là atomic — nhiều session gọi cùng lúc vẫn nhận các giá trị khác nhau, không bị trùng.
Quan trọng hơn: giá trị sequence chỉ bị tiêu thụ khi nextval() thực sự chạy, không phải ngay khi BEGIN mở transaction.
Ví dụ với bảng:
CREATE TABLE orders (
id bigint PRIMARY KEY DEFAULT nextval('orders_id_seq'),
created_at timestamptz NOT NULL DEFAULT transaction_timestamp(),
note text
);
Khi chạy:
BEGIN;
INSERT INTO orders(note) VALUES ('hello');
COMMIT;
id không được cấp ở BEGIN. Nó được cấp trong lúc PostgreSQL thực thi INSERT và cần tính giá trị cho cột id. Đây là điểm rất quan trọng — nó tách biệt rõ vòng đời transaction với thời điểm sequence được dùng.
2. Ba "đồng hồ" khác nhau trong PostgreSQL
PostgreSQL có nhiều hàm thời gian với semantics khác nhau:
| Hàm | Semantics |
|---|---|
transaction_timestamp() |
Thời điểm bắt đầu transaction. Tương đương now() / CURRENT_TIMESTAMP. Cố định trong suốt transaction. |
statement_timestamp() |
Thời điểm bắt đầu câu lệnh hiện tại. Cố định trong suốt một câu SQL, nhưng thay đổi giữa các câu lệnh. |
clock_timestamp() |
Thời gian thực tại đúng lúc gọi hàm. Có thể thay đổi ngay cả trong cùng một câu SQL. |
Hiểu đơn giản:
transaction_timestamp()bị "đóng băng" từ khiBEGIN— dù transaction kéo dài 10 giây, giá trị vẫn là mốc ban đầu.statement_timestamp()bị "đóng băng" tại thời điểm câu lệnh tới server — gọi lại nhiều lần trong cùng một câu SQL vẫn ra cùng một mốc.clock_timestamp()luôn trả về "bây giờ" đúng nghĩa đen — gọi trước và gọi sau trong cùng câu lệnh có thể khác nhau. Docs nói rõ: "its value changes even within a single SQL statement".
Hệ quả thực tế: nếu created_at DEFAULT transaction_timestamp(), giá trị đó phản ánh lúc transaction bắt đầu, không phải lúc row thực sự được insert. Transaction mở từ lâu rồi mới insert thì created_at sẽ mang "dấu thời gian cũ".
3. Sequence không phải đồng hồ thời gian
Một trong những nhầm lẫn phổ biến nhất là xem id từ sequence như "thời gian tạo bản ghi". PostgreSQL không hề định nghĩa sequence theo cách đó.
Sequence chỉ đảm bảo một thứ: cấp các số khác nhau một cách an toàn khi concurrent. Nó không hứa hẹn:
- Dãy số không có lỗ hổng (gap)
- Không reuse giá trị đã cấp sau rollback
- Đại diện cho commit order
PostgreSQL nói rất rõ: giá trị lấy bằng nextval() không được reclaim lại nếu transaction sau đó abort. Ngay cả INSERT ... ON CONFLICT cũng có thể tiêu thụ sequence trước khi phát hiện conflict. Đây là lý do sequence không dùng để tạo dãy số gapless.
Điều này cực kỳ quan trọng với các hệ thống hay ORDER BY id DESC rồi ngầm hiểu đó là "mới nhất". id chỉ phản ánh thứ tự gọi nextval(), không phản ánh thứ tự commit, và cũng không phản ánh thời điểm row thực sự xuất hiện với các transaction khác.
4. Tình huống lệch #1: Transaction mở sớm, insert muộn
Đây là case kinh điển khi created_at dùng transaction_timestamp().
-- Transaction A
BEGIN; -- 10:00:00
-- validate, gọi service ngoài, làm nhiều thứ...
INSERT INTO orders(note) VALUES ('A'); -- 10:00:10
COMMIT;
-- Transaction B (bắt đầu sau nhưng insert và commit trước)
BEGIN; -- 10:00:05
INSERT INTO orders(note) VALUES ('B'); -- 10:00:06
COMMIT;
Kết quả:
| Row | id |
created_at (transaction_timestamp) |
|---|---|---|
| A | 100 | 10:00:00 ← thời điểm BEGIN, không phải INSERT |
| B | 101 | 10:00:05 |
Row A có created_at sớm hơn B, dù thực tế A insert sau B. Đây không phải bug — đây là đúng semantics của PostgreSQL. transaction_timestamp() phản ánh lúc BEGIN, không phải lúc câu INSERT chạy.
5. Tình huống lệch #2: Đổi sang clock_timestamp() có đỡ hơn không?
Có, nhưng chỉ đỡ hơn, không phải bảo đảm tuyệt đối.
Vì clock_timestamp() lấy thời gian thực ngay lúc hàm được gọi, created_at sẽ gần hơn với thời điểm row được insert thực tế. Điều này giúp giảm đáng kể độ lệch do transaction mở sớm nhưng insert muộn.
Tuy nhiên, đổi sang clock_timestamp() không biến id và created_at thành một cặp có ordering tuyệt đối. Lý do:
idvẫn đến từnextval()— cơ chế hoàn toàn độc lập với time function- Các transaction concurrent vẫn chịu ảnh hưởng của isolation và thời điểm commit
clock_timestamp() làm cho created_at phản ánh thời điểm insert tốt hơn, nhưng không bảo đảm "bản ghi có id lớn hơn thì luôn có created_at lớn hơn".
6. Tình huống lệch #3: Commit order khác insert order
Ngay cả khi created_at = clock_timestamp(), bạn vẫn có thể gặp lệch vì commit order khác thời điểm cấp ID.
-- Transaction A: insert sớm, commit muộn
BEGIN;
INSERT INTO orders(note) VALUES ('A'); -- lấy id = 100 lúc 10:00:00.500
-- xử lý lâu...
COMMIT; -- 10:00:05
-- Transaction B: insert sau, commit trước
BEGIN;
INSERT INTO orders(note) VALUES ('B'); -- lấy id = 101 lúc 10:00:01.000
COMMIT; -- 10:00:01.100
Với một session khác đọc dữ liệu ở giữa: row B đã visible, còn row A thì chưa. Nếu hệ thống của bạn ngầm hiểu "id nhỏ hơn thì phải xuất hiện sớm hơn với mọi người", bạn sẽ sớm gặp bất ngờ.
PostgreSQL không hứa sequence đại diện cho commit order.
7. Isolation level làm vấn đề biểu hiện như thế nào?
Read Committed (mặc định): Mỗi query thấy snapshot tại thời điểm query bắt đầu. Hai câu SELECT liên tiếp trong cùng transaction có thể thấy dữ liệu khác nhau nếu giữa chúng có transaction khác commit. Đây là mức nhanh, phù hợp cho nhiều ứng dụng, nhưng không đủ để suy luận thứ tự business phức tạp chỉ bằng id hay created_at.
Repeatable Read: Transaction nhìn snapshot ổn định từ đầu, các SELECT liên tiếp trong cùng transaction không thấy commit mới từ transaction khác. Mạnh hơn, nhưng vẫn có thể phát sinh serialization anomaly ở mức ứng dụng. Cần chuẩn bị cho việc transaction bị rollback và retry.
Serializable: PostgreSQL cố mô phỏng kết quả như thể các transaction chạy tuần tự. Đổi lại, transaction có thể bị hủy với serialization failure và ứng dụng phải retry toàn bộ. Đây là hướng mạnh nếu bài toán đòi hỏi tính nhất quán business rule rất chặt — nhưng nó không biến sequence thành commit clock.
8. Minh họa: Vì sao transaction_timestamp() dễ "cũ" hơn thực tế
BEGIN;
SELECT transaction_timestamp() AS tx_start;
SELECT pg_sleep(3);
INSERT INTO orders(note) VALUES ('late insert');
COMMIT;
Nếu created_at dùng transaction_timestamp(), giá trị của row sẽ gần với tx_start — không gần với thời điểm sau 3 giây khi row thực sự được insert. Đây là đúng định nghĩa: transaction_timestamp() phản ánh lúc transaction bắt đầu.
Nếu đổi default:
ALTER TABLE orders
ALTER COLUMN created_at SET DEFAULT clock_timestamp();
Row mới sẽ nhận thời gian thực lúc INSERT chạy. Điều này giải quyết tốt bài toán "transaction mở sớm, insert muộn" — nhưng vẫn không giải quyết tận gốc câu chuyện commit order và concurrent visibility.
9. Minh họa: Sequence không rollback lại để giữ thứ tự đẹp
BEGIN;
INSERT INTO orders(note) VALUES ('temp');
ROLLBACK;
Giá trị sequence đã cấp cho row bị rollback không được lấy lại. Lần insert sau sẽ nhảy sang số tiếp theo. PostgreSQL thiết kế như vậy để tránh chặn các transaction concurrent đang cùng xin số từ sequence.
Hệ quả: nếu bạn muốn id vừa biểu diễn "thứ tự thời gian", vừa "liên tục không hổng số, không lệch do rollback" — đó là kỳ vọng không phù hợp với semantics của sequence trong PostgreSQL.
10. Vậy chọn transaction_timestamp() hay clock_timestamp()?
Chọn transaction_timestamp() khi:
created_atmang nghĩa "thời điểm transaction bắt đầu tạo ra bản ghi này"- Bạn cần giá trị ổn định trong suốt transaction (ví dụ: nhiều row cùng batch phải có cùng timestamp)
- Transaction thường ngắn, ít khi có độ trễ lớn giữa
BEGINvàINSERT
Chọn clock_timestamp() khi:
created_atmang nghĩa "thời điểm row thực sự được ghi gần nhất có thể"- Transaction của bạn thường mở sớm, xử lý nhiều bước rồi mới insert
- Bạn cần
created_atphản ánh thực tế insert chứ không phải thực tế "bắt đầu nghĩ đến việc insert"
Tuy vậy, đây chỉ là cải thiện semantics của created_at. Nó không thay đổi bản chất sequence, isolation hay commit order.
11. Giải pháp thực tế nên chọn gì?
Sau khi hiểu rõ mechanics bên dưới, câu hỏi thực tế là: bạn cần gì từ cột id và created_at?
Nếu bạn cần "thứ tự phát số" (ai gọi nextval trước)
Dùng sequence như bình thường, chấp nhận rằng id không phải commit clock. Đừng dùng id để suy luận thứ tự thời gian.
Nếu bạn cần "thời điểm tạo row gần thực tế nhất"
Đổi created_at sang clock_timestamp(). Đây là cải thiện đơn giản nhất và không có trade-off lớn:
ALTER TABLE orders
ALTER COLUMN created_at SET DEFAULT clock_timestamp();
Nếu bạn cần "thứ tự commit" — tức là row nào visible trước thì cũng phải có timestamp nhỏ hơn
Đây là bài toán khó hơn. Một số hướng tiếp cận:
Dùng clock_timestamp() kết hợp với index và query đúng cách. Thay vì ORDER BY id DESC, dùng ORDER BY created_at DESC. Chấp nhận rằng vẫn có thể có lệch nhỏ do concurrency, nhưng thực tế với hầu hết ứng dụng mức lệch này không đáng kể.
Dùng pg_xact_commit_timestamp(xmin) nếu cần commit timestamp thực sự. PostgreSQL có thể lưu commit timestamp nếu bật track_commit_timestamp = on trong config. Khi đó bạn có thể query:
-- Bật trong postgresql.conf
track_commit_timestamp = on
-- Query commit timestamp của row
SELECT id, note, pg_xact_commit_timestamp(xmin) AS committed_at
FROM orders
ORDER BY committed_at DESC;
Đây là cách duy nhất để có được thứ tự commit thực sự từ PostgreSQL. Trade-off là cần restart server khi bật, và có overhead nhỏ về storage.
Nếu cần ordering tuyệt đối trong business logic, hãy thiết kế ở tầng ứng dụng: dùng một distributed ID generator như ULID hoặc UUID v7. Chúng encode timestamp vào trong ID, và nếu timestamp đủ độ phân giải (millisecond), ordering theo ID sẽ gần với ordering theo thời gian thực tế.
-- UUID v7 (PostgreSQL 17+ có built-in, hoặc dùng extension pg_uuidv7)
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- hoặc uuidv7()
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
note text
);
Tóm tắt nhanh
| Bạn cần gì | Giải pháp |
|---|---|
| Thứ tự phát số đơn giản | BIGSERIAL / sequence, không dùng id để suy luận thời gian |
created_at gần thực tế nhất |
clock_timestamp() thay vì transaction_timestamp() |
| Commit timestamp thực sự | track_commit_timestamp = on + pg_xact_commit_timestamp(xmin) |
| Ordering tuyệt đối qua ID | UUID v7 hoặc ULID — encode timestamp vào ID |
| Business consistency rất chặt | Serializable isolation + retry logic |
Không có một giải pháp nào phù hợp cho tất cả trường hợp. Quan trọng nhất là hiểu rõ semantics của từng cơ chế để không đưa ra giả định sai về ordering.
All rights reserved