Thiết kế database tệ thường không nổ ngay ngày đầu. Vấn đề chỉ lộ ra sau vài tháng khi dữ liệu bắt đầu lệch, bảng này nói một kiểu, bảng kia nói một kiểu, code phải vá bằng if-else, migration ngày càng khó, và mỗi lần sửa business rule là lo hỏng dữ liệu cũ.
Nếu mục tiêu của bạn là một database dễ sống lâu, thì thứ cần ưu tiên đầu tiên không phải “tối ưu query” mà là tính nhất quán trong design.
Kết luận nhanh
Nếu chỉ cần nhớ một checklist ngắn khi design database:
- mỗi entity phải có một nguồn dữ liệu gốc rõ ràng
- naming phải nhất quán giữa bảng, cột và khóa
- mọi quan hệ quan trọng nên có
foreign key - các rule như duy nhất, bắt buộc, giới hạn giá trị phải đẩy xuống database bằng
unique,not null,check - đừng duplicate dữ liệu nếu chưa có lý do rõ ràng
- transaction phải bọc các thao tác cập nhật nhiều bảng
Làm đúng 6 điểm này sẽ tránh được phần lớn lỗi “data bị lệch” về sau.
Tính nhất quán trong database là gì?
Trong ngữ cảnh thiết kế database, tính nhất quán thường có 2 lớp:
- consistency của dữ liệu: dữ liệu không mâu thuẫn, không orphan, không duplicate vô nghĩa
- consistency của design: cách đặt tên, kiểu cột, cách tách bảng, cách lưu trạng thái giống nhau giữa các phần của hệ thống
Ví dụ không nhất quán:
- bảng
usersdùngid, bảng khác lại dùnguserId, bảng khác nữa dùnguid - chỗ thì lưu
status = active, chỗ khác lại lưuis_active = 1 - bảng
ordersxóa user rồi nhưngorder.user_idvẫn còn trỏ tới record không tồn tại - email bị trùng vì chỉ check ở code mà không có
unique constraint
Những lỗi kiểu này ban đầu nhìn nhỏ, nhưng càng nhiều bảng thì chi phí sửa càng lớn.
Khi nào database dễ mất nhất quán nhất?
Rủi ro tăng mạnh khi:
- nhiều dev cùng sửa schema nhưng không có convention rõ
- business rule nằm hết ở app, database gần như không có constraint
- copy dữ liệu từ bảng này sang bảng khác cho “tiện query”
- update nhiều bảng trong một flow nhưng không dùng transaction
- soft delete, status, audit field mỗi bảng lưu một kiểu khác nhau
Đó là lý do database design nên có nguyên tắc ngay từ đầu, không nên để mỗi bảng tự tiến hoá theo kiểu riêng.
Nguyên tắc 1: Mỗi entity phải có một nguồn dữ liệu gốc
Một rule rất quan trọng là:
Một thông tin quan trọng chỉ nên có một nơi được xem là nguồn đúng nhất.
Ví dụ:
- tên user nên lấy từ bảng
users - giá sản phẩm hiện tại nên lấy từ bảng
products - trạng thái đơn hàng nên có một cột chuẩn trong
orders
Nếu cùng một thông tin nằm ở nhiều nơi mà không có lý do rõ ràng, sớm muộn sẽ lệch.
Ví dụ xấu:
users.email
orders.customer_email
invoices.customer_email
Nếu đây đều là “email hiện tại của user” thì rất dễ lệch. Chỉ nên duplicate khi bạn cố ý snapshot dữ liệu lịch sử, ví dụ email tại thời điểm phát hành invoice.
Nguyên tắc 2: Naming phải nhất quán ngay từ đầu
Consistency trong naming giúp giảm rất nhiều lỗi code và migration.
Nên chốt sớm:
- dùng
snake_casehaycamelCase - primary key có luôn là
idkhông - foreign key có luôn là
xxx_idkhông - timestamp có luôn là
created_at,updated_atkhông
Một convention phổ biến và dễ sống lâu:
users
orders
order_items
id
user_id
order_id
created_at
updated_at
deleted_at
Điểm quan trọng không phải là bạn chọn kiểu nào, mà là đã chọn thì giữ cho đồng đều.
Nguyên tắc 3: Dùng primary key và foreign key rõ ràng
Primary key là định danh chính của record. Foreign key là cách database hiểu quan hệ giữa các bảng.
Ví dụ:
CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE
);
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL,
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users(id)
);
Lợi ích thực tế của foreign key:
- chặn record mồ côi
- giúp migration và query rõ nghĩa hơn
- ép dev tôn trọng quan hệ dữ liệu thay vì update bừa
Không phải hệ thống nào cũng bật full FK ở mọi nơi, nhưng nếu bỏ FK thì bạn phải có lý do thật rõ, không nên bỏ chỉ vì “cho nhanh”.
Nguyên tắc 4: Rule quan trọng phải nằm ở database, không chỉ ở code
Nhiều đội chỉ check ở API:
- email không được trùng
- status chỉ được là một số giá trị
- một dòng bắt buộc phải có
user_id
Nhưng nếu chỉ check ở code:
- job nền có thể ghi sai
- script import có thể ghi sai
- service khác có thể bypass rule
Vì vậy các rule core nên đẩy xuống database:
NOT NULLUNIQUECHECKDEFAULT
Ví dụ:
CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL CHECK (status IN ('active', 'inactive', 'blocked')),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
App vẫn cần validate để trả lỗi đẹp hơn, nhưng database mới là lớp chặn cuối cùng.
Nguyên tắc 5: Chuẩn hoá vừa đủ, đừng duplicate quá sớm
Một lỗi phổ biến là mới làm đã duplicate dữ liệu để “query cho tiện”.
Ví dụ:
- lưu
user_nametrongorders - lưu
product_pricetrongcart_items - lưu
department_nameở nhiều bảng
Trừ khi bạn cố ý lưu snapshot lịch sử, phần lớn các trường kiểu này nên tham chiếu từ bảng gốc.
Mục tiêu là:
- dữ liệu gốc có một nơi
- dữ liệu dẫn xuất chỉ tạo khi thật sự cần
Chuẩn hoá quá mức cũng không tốt nếu query trở nên quá phức tạp. Nhưng với bài toán CRUD thông thường, ưu tiên chống lệch dữ liệu trước rồi mới tối ưu sau.
Nguyên tắc 6: Cùng một kiểu dữ liệu nên dùng cùng một format
Ví dụ:
- ID chỗ dùng
INT, chỗ dùngVARCHAR - tiền chỗ dùng
FLOAT, chỗ dùngDECIMAL - status chỗ dùng string, chỗ dùng number
- thời gian chỗ dùng local time, chỗ dùng UTC
Những khác biệt này làm code mapping rất mệt.
Một số rule thực dụng:
- tiền nên dùng
DECIMAL, tránhFLOAT - timestamp nên thống nhất UTC
- foreign key phải cùng kiểu với primary key mà nó tham chiếu
- trạng thái nên chốt một kiểu biểu diễn từ đầu
Nguyên tắc 7: Các thao tác cập nhật nhiều bảng phải có transaction
Đây là phần nhiều hệ thống bị lệch dữ liệu dù schema nhìn có vẻ ổn.
Ví dụ flow tạo đơn hàng:
- tạo record trong
orders - tạo nhiều record trong
order_items - trừ tồn kho
- ghi payment log
Nếu step 3 fail mà step 1 và 2 đã commit, data bắt đầu lệch.
Các flow kiểu này phải bọc trong transaction:
BEGIN;
-- insert orders
-- insert order_items
-- update inventory
COMMIT;
Consistency không chỉ đến từ schema, mà còn đến từ cách ghi dữ liệu.
Nguyên tắc 8: Soft delete, status, audit field phải có chuẩn chung
Một hệ thống dễ loạn rất nhanh nếu:
- bảng này dùng
is_deleted - bảng kia dùng
deleted - bảng khác lại dùng
deleted_at
Tương tự với audit field:
created_atupdated_atcreated_byupdated_by
Không nhất thiết bảng nào cũng cần full audit, nhưng nếu đã dùng thì nên theo cùng một pattern.
Một kiểu thường dễ dùng:
created_at
updated_at
deleted_at
deleted_at IS NULL thường rõ nghĩa hơn cờ boolean kiểu is_deleted.
Nguyên tắc 9: Index phục vụ query, không thay thế design
Index rất quan trọng, nhưng không cứu được một schema đã lệch logic.
Ví dụ:
- duplicate dữ liệu sai thì thêm index vẫn sai
- quan hệ không rõ thì index không sửa được integrity
- status lưu lung tung thì query nhanh hơn cũng không làm data đúng hơn
Thứ tự nên là:
- schema nhất quán
- constraint rõ
- query đúng
- rồi mới tối ưu bằng index
Một schema nhỏ nhưng nhất quán thường trông như thế nào?
Ví dụ:
CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
full_name VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL CHECK (status IN ('pending', 'paid', 'cancelled')),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE order_items (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL CHECK (quantity > 0),
unit_price DECIMAL(12,2) NOT NULL CHECK (unit_price >= 0),
CONSTRAINT fk_order_items_order FOREIGN KEY (order_id) REFERENCES orders(id)
);
Điểm đáng chú ý ở đây không phải là schema quá “cao siêu”, mà là:
- naming đồng đều
- constraint rõ
- relation rõ
- kiểu dữ liệu nhất quán
Những lỗi design database rất hay gặp
1. Không có unique ở field đáng lẽ phải duy nhất
Ví dụ:
- email user
- order code
- transaction reference
Chỉ check ở app là chưa đủ.
2. Trạng thái được lưu bằng nhiều kiểu khác nhau
Ví dụ:
active1enabled
Ba cách này cùng chỉ một nghĩa nhưng làm query và code trở nên rối.
3. Dữ liệu gốc bị copy ra nhiều bảng
Nếu không phải snapshot có chủ đích, đây thường là nguồn gốc của inconsistency.
4. Thiếu transaction cho flow nhiều bước
Schema có đẹp nhưng flow ghi dữ liệu không atomic thì vẫn lệch như thường.
5. Foreign key cùng tên nhưng khác kiểu dữ liệu
Ví dụ users.id là BIGINT nhưng orders.user_id lại là INT hoặc VARCHAR. Những mismatch kiểu này tạo ra lỗi khó chịu về sau.
Kết luận
Thiết kế database nhất quán không phải là làm schema thật hàn lâm. Mục tiêu thực tế hơn là:
- dữ liệu không tự mâu thuẫn
- business rule quan trọng được chặn ở database
- mọi bảng đi theo cùng một convention
- flow ghi dữ liệu nhiều bước không để lại trạng thái nửa vời
Nếu phải ưu tiên ít thứ nhất có thể, hãy chốt trước:
- naming convention
- primary key / foreign key
not null,unique,check- transaction cho các flow nhiều bảng
Chỉ bốn phần đó thôi đã giúp database dễ sống lâu hơn rất nhiều.