Giao diện
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:
- 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).
- 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 trongIf-Match; không khớp thì trả412 Precondition Failed. - ORM có sẵn: JPA
@Version, Railslock_version, Entity Framework[ConcurrencyCheck]/ rowversion.
| Optimistic (cột version) | Pessimistic (SELECT … FOR UPDATE) | |
|---|---|---|
| Cách chặn | Phát hiện xung đột lúc ghi | Khoá trước, người sau phải chờ |
| Hợp khi | Xung độ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ểm | Nhược điểm |
|---|---|---|
| App ghi trong cùng transaction | Biết “ai” và “vì sao” (lý do, request id) | Dễ sót: job batch, sửa tay trong DB không đi qua app |
| Trigger | Không sót lệnh ghi nào | Là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 ghi | Có độ 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)
| product_id | price | valid_from | valid_to |
|---|---|---|---|
| 42 | 150.000 | 2026-01-01 | 2026-03-15 |
| 42 | 180.000 | 2026-03-15 | 2026-07-01 |
| 42 | 165.000 | 2026-07-01 | NULL (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ùngvalid_to = '9999-12-31'thay cho NULL để điều kiện chỉ cònvalid_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 extensionbtree_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ách | Trả lời được | Chi phí |
|---|---|---|
| Cột version | Có ai sửa trong lúc tôi đang sửa không | Rất rẻ |
| Cột dấu vết | Ai sửa lần cuối, lúc nào | Rất rẻ |
| Bảng lịch sử | Mọi giá trị trước đây, ai đổi | Gấp đôi số lệnh ghi (hoặc CDC) |
| SCD type 2 | Giá trị tại thời điểm T, query thường xuyên | Query hiện tại phức tạp hơn một chút, cần chống chồng lấn |
| System-versioned / bitemporal | Hệ thống đã biết gì tại thời điểm T | Chỉ vài engine hỗ trợ sẵn |
| Event sourcing | Mọi thứ, kể cả “vì sao” | Kiến trúc phức tạp nhất |
Ghi nhớ
- Hai nghĩa: version chống ghi đè (optimistic lock) và version giữ lịch sử.
- Optimistic lock:
UPDATE … WHERE id = ? AND version = ?, 0 dòng là xung đột; dùng số nguyên, không dùngupdated_at. - 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. - 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.