Skip to content

Versioning & lịch sử dữ liệu ​

“Đánh version” trong database có hai nghĩa khác hẳn nhau, và phỏng vấn có thể hỏi cả hai:

  1. Version để chống ghi đè: hai người cùng sửa một bản ghi, không để người lưu sau âm thầm xoá thay đổi của người lưu trước (optimistic locking).
  2. Version để giữ lịch sử: trả lời được “giá sản phẩm ngày 1/3 là bao nhiêu”, “ai đổi địa chỉ khách lúc nào”, “khôi phục bản trước”.

1. Version chống ghi đè (optimistic locking) ​

Hai nhân viên cùng mở form sửa sản phẩm lúc 10:00. Người A lưu lúc 10:03, người B lưu lúc 10:05 với dữ liệu đọc từ 10:00: thay đổi của A biến mất (lost update). Lock dòng suốt 5 phút người dùng ngồi sửa form là không thể.

Thêm một cột version và chỉ cho ghi khi version chưa đổi:

sql
-- lúc mở form: đọc cả version
SELECT id, name, price, version FROM products WHERE id = 42;   -- version = 7

-- lúc lưu: chỉ ghi nếu không ai sửa trong lúc đó
UPDATE products
SET name = ?, price = ?, version = version + 1
WHERE id = 42 AND version = 7;
-- ảnh hưởng 0 dòng → đã có người sửa trước: báo xung đột, cho người dùng xem bản mới rồi quyết định
  • Dùng số nguyên, không dùng updated_at: độ phân giải thời gian, đồng hồ lệch giữa các máy, hai lần ghi trong cùng một mili giây đều có thể làm so sánh sai.
  • Qua HTTP: trả version trong header ETag, client gửi lại trong If-Match; không khớp thì trả 412 Precondition Failed.
  • ORM có sẵn: JPA @Version, Rails lock_version, Entity Framework [ConcurrencyCheck] / rowversion.
Optimistic (cột version)Pessimistic (SELECT … FOR UPDATE)
Cách chặnPhát hiện xung đột lúc ghiKhoá trước, người sau phải chờ
Hợp khiXung đột hiếm, thao tác kéo dài (người dùng sửa form)Xung đột nhiều, transaction rất ngắn (trừ tồn kho)
Khi xung độtẢnh hưởng 0 dòng, app tự xử lýChờ lock, có thể deadlock

2. Version để giữ lịch sử ​

a. Cột dấu vết ​

created_at, updated_at, created_by, updated_by trên mọi bảng. Rẻ, nên có ở mọi bảng, nhưng chỉ biết lần sửa cuối, không biết giá trị trước đó.

b. Bảng lịch sử (audit log) ​

Mỗi lần thay đổi ghi thêm một dòng vào bảng lịch sử, bảng chính vẫn chỉ giữ trạng thái hiện tại.

sql
CREATE TABLE products_history (
  history_id BIGINT PRIMARY KEY,
  product_id BIGINT       NOT NULL,
  op         CHAR(1)      NOT NULL,        -- I / U / D
  name       VARCHAR(200),
  price      BIGINT,
  changed_at TIMESTAMP    NOT NULL,
  changed_by BIGINT
);
CREATE INDEX idx_history_product ON products_history (product_id, changed_at);

Ghi bằng cách nào:

Cách ghiƯu điểmNhược điểm
App ghi trong cùng transactionBiết “ai” và “vì sao” (lý do, request id)Dễ sót: job batch, sửa tay trong DB không đi qua app
TriggerKhông sót lệnh ghi nàoLàm chậm mọi lệnh ghi, khó biết user của app
CDC đọc binlog/WAL (MySQL chương 9)Không sót, không chạm code, không làm chậm lệnh ghiCó độ trễ; muốn biết “ai” thì app phải ghi cột updated_by

Lưu snapshot đủ cột thì dễ xem lại; lưu diff dạng JSON ({"price": [150000, 180000]}) thì gọn hơn nhưng muốn dựng lại một phiên bản phải cộng dồn.

c. SCD type 2: mỗi phiên bản là một dòng có thời gian hiệu lực ​

Khi cần hỏi “giá trị tại thời điểm T” thường xuyên (giá, thuế suất, bảng lương, hợp đồng), lưu mỗi phiên bản thành một dòng với valid_from và valid_to. Đổi giá là đóng dòng cũ và mở dòng mới, không sửa hay xoá gì.

Thử: giá sản phẩm 42 tại một ngày bất kỳ (SCD type 2)
v1 · 150.000v2 · 180.000v3 · 165.000T1T2T3T4T5T6T7T8T9T10T11T12
product_idpricevalid_fromvalid_to
42150.0002026-01-012026-03-15
42180.0002026-03-152026-07-01
42165.0002026-07-01NULL (hiện tại)
SELECT price FROM product_prices
WHERE product_id = 42
  AND valid_from <= '2026-07-20'
  AND (valid_to > '2026-07-20' OR valid_to IS NULL);   -- → 165.000

Không dòng nào bị sửa giá trị hay bị xoá: phiên bản cũ chỉ được “đóng lại” bằng valid_to. Kéo thanh về trước 2026-10-13 vẫn ra giá cũ, đúng như lúc đó.

  • Index (product_id, valid_from). Một số team dùng valid_to = '9999-12-31' thay cho NULL để điều kiện chỉ còn valid_from <= T AND T < valid_to, dễ dùng index hơn.
  • Các phiên bản của cùng một sản phẩm không được chồng lấn. PostgreSQL chặn được ngay trong database bằng exclusion constraint: EXCLUDE USING gist (product_id WITH =, daterange(valid_from, valid_to) WITH &&) (cần extension btree_gist). MySQL không có, phải đóng–mở trong một transaction có khoá dòng.
  • Tên SCD (slowly changing dimension) đến từ data warehouse: type 1 ghi đè (không giữ lịch sử), type 2 thêm dòng mới (như trên), type 3 thêm cột previous_value (chỉ giữ một bản trước).

d. Temporal table và bitemporal ​

Có hai loại thời gian khác nhau:

  • Valid time: khi nào sự thật đó đúng ngoài đời (giá áp dụng từ 1/3).
  • Transaction time: khi nào hệ thống ghi nhận nó (nhập vào lúc 5/3).

Ví dụ điều chỉnh lương hồi tố: ngày 10/5 quyết định tăng lương áp dụng từ 1/4. Báo cáo chạy ngày 20/4 phải tái hiện được đúng con số đã biết lúc đó. Lưu cả hai trục thời gian gọi là bitemporal.

SQL:2011 chuẩn hoá system-versioned table (database tự lưu transaction time và cho truy vấn FOR SYSTEM_TIME AS OF): MariaDB (WITH SYSTEM VERSIONING) và SQL Server có sẵn; PostgreSQL cần extension hoặc trigger; MySQL không có, phải tự làm bằng bảng lịch sử hoặc CDC.

e. Event sourcing ​

Không lưu trạng thái hiện tại làm nguồn chính, mà lưu chuỗi sự kiện bất biến: OrderPlaced, ItemAdded, OrderPaid, OrderShipped. Trạng thái hiện tại có được bằng cách phát lại (replay) các sự kiện, có snapshot định kỳ để không phải phát lại từ đầu. Màn hình đọc dùng read model dựng sẵn từ sự kiện (CQRS).

  • Được: lịch sử đầy đủ và chính xác, audit tự nhiên, dựng lại trạng thái ở bất kỳ thời điểm nào, thêm read model mới từ sự kiện cũ.
  • Mất: phức tạp hơn nhiều; đổi cấu trúc sự kiện phải xử lý sự kiện cũ (upcasting); read model là eventual consistency; xoá dữ liệu cá nhân trong sự kiện bất biến rất khó.
  • Dùng khi miền nghiệp vụ vốn là chuỗi sự kiện (sổ cái, đặt chỗ, quy trình nhiều bước). Không dùng cho CRUD thông thường.

f. Lịch sử trạng thái ​

Đơn hàng, ticket, hồ sơ đi qua nhiều trạng thái. Cột status giữ trạng thái hiện tại, bảng order_status_history(order_id, from_status, to_status, changed_at, changed_by, reason) giữ từng lần chuyển. Chuyển trạng thái an toàn khi có request song song bằng compare-and-set:

sql
UPDATE orders SET status = 3 WHERE id = ? AND status = 2;
-- ảnh hưởng 0 dòng: trạng thái đã bị đổi trước, không ghi lịch sử

g. Phiên bản tài liệu ​

Wiki, hợp đồng, cấu hình cần xem lại và khôi phục bản cũ: documents(id, current_version) + document_versions(document_id, version_no, content, created_by, created_at) với khoá chính (document_id, version_no). Khôi phục là tạo một version mới có nội dung của bản cũ, không sửa hay xoá lịch sử.

So sánh ​

CáchTrả lời đượcChi phí
Cột versionCó ai sửa trong lúc tôi đang sửa khôngRất rẻ
Cột dấu vếtAi sửa lần cuối, lúc nàoRất rẻ
Bảng lịch sửMọi giá trị trước đây, ai đổiGấp đôi số lệnh ghi (hoặc CDC)
SCD type 2Giá trị tại thời điểm T, query thường xuyênQuery hiện tại phức tạp hơn một chút, cần chống chồng lấn
System-versioned / bitemporalHệ thống đã biết gì tại thời điểm TChỉ vài engine hỗ trợ sẵn
Event sourcingMọi thứ, kể cả “vì sao”Kiến trúc phức tạp nhất

Ghi nhớ

  1. Hai nghĩa: version chống ghi đè (optimistic lock) và version giữ lịch sử.
  2. Optimistic lock: UPDATE … WHERE id = ? AND version = ?, 0 dòng là xung đột; dùng số nguyên, không dùng updated_at.
  3. Cần “giá trị tại thời điểm T”: SCD type 2 với valid_from/valid_to, đóng dòng cũ mở dòng mới trong một transaction.
  4. Bảng lịch sử ghi bằng CDC thì không sót; event sourcing chỉ khi miền nghiệp vụ là chuỗi sự kiện.