Thiết kế database là bước quan trọng nhất nhưng bị bỏ qua nhiều nhất trong phát triển phần mềm. Giống như xây nhà — nếu móng yếu, dù sơn phết đẹp cũng sẽ nứt tường sau vài năm. Theo nghiên cứu của IBM Systems Sciences Institute, chi phí sửa lỗi thiết kế database ở giai đoạn production đắt gấp 100 lần so với sửa ở giai đoạn thiết kế. Một khảo sát trên Stack Overflow cho thấy 34% developer thừa nhận đã gặp vấn đề nghiêm trọng do thiết kế database sai từ đầu.
Lead magnet: Tải miễn phí Database Design Checklist & ERD Template — checklist 30 điểm kiểm tra trước khi “đóng gói” thiết kế database.
Tại sao thiết kế database quan trọng?
Hậu quả của database thiết kế sai
| Vấn đề | Triệu chứng | Chi phí sửa |
|---|---|---|
| Data redundancy | Cùng thông tin lưu ở 5 nơi, update 1 nơi quên 4 nơi | 5–20 triệu VND (refactor) |
| Data inconsistency | Cùng khách hàng có 3 tên khác nhau trong hệ thống | Không thể sửa hoàn toàn nếu đã mất data gốc |
| Performance bottleneck | Query chạy 10 giây thay vì 0.1 giây | 10–50 triệu VND (optimization) |
| Scale limit | Hệ thống không chịu nổi 10.000 users đồng thời | 50–200 triệu VND (re-architect) |
| Feature limitation | Không thể thêm tính năng mới vì data model không hỗ trợ | 20–100 triệu VND (migration) |
Ví dụ thực tế
Trường hợp: Một sàn TMĐT lưu địa chỉ giao hàng trực tiếp trong bảng orders:
orders: id, customer_name, phone, address, city, product, quantity, price
Vấn đề phát sinh:
- Khách hàng đổi số điện thoại → phải update tất cả đơn cũ
- Muốn thêm tính năng “lưu nhiều địa chỉ giao hàng” → phải redesign toàn bộ
- Báo cáo “doanh thu theo khách hàng” sai vì cùng 1 người nhưng ghi tên khác nhau
- Khi có 1 triệu đơn → query báo cáo chạy 30 giây
Nguyên tắc 1: Normalization (Chuẩn hóa dữ liệu)
Normalization là gì?
Normalization là quá trình tổ chức dữ liệu để giảm thiểu redundancy (dữ liệu trùng lặp) và dependency (phụ thuộc không cần thiết).
3 dạng chuẩn phổ biến
First Normal Form (1NF): Không có giá trị lặp
Sai:
| OrderID | Products |
|---|---|
| 1 | Áo thun, Quần jean, Giày |
| 2 | Áo sơ mi, Cà vạt |
Đúng:
| OrderID | Product |
|---|---|
| 1 | Áo thun |
| 1 | Quần jean |
| 1 | Giày |
| 2 | Áo sơ mi |
| 2 | Cà vạt |
Quy tắc: Mỗi cell chỉ chứa 1 giá trị. Không lưu danh sách trong 1 cột.
Second Normal Form (2NF): Phụ thuộc vào toàn bộ primary key
Sai:
| OrderID | ProductID | ProductName | ProductPrice | Quantity |
|---|---|---|---|---|
| 1 | P001 | Áo thun | 200.000 | 2 |
| 2 | P001 | Áo thun | 200.000 | 1 |
ProductName và ProductPrice phụ thuộc vào ProductID, không phụ thuộc vào OrderID.
Đúng: Tách thành 2 bảng:
products: ProductID, ProductName, ProductPrice order_items: OrderID, ProductID, Quantity
Third Normal Form (3NF): Không phụ thuộc bắc cầu
Sai:
| CustomerID | Name | City | CityPopulation |
|---|---|---|---|
| 1 | Nguyễn Văn A | HCM | 9.000.000 |
| 2 | Trần Thị B | HN | 8.000.000 |
CityPopulation phụ thuộc vào City, không phụ thuộc trực tiếp vào CustomerID.
Đúng: Tách CityPopulation sang bảng cities.
Khi nào nên denormalize?
Normalization không phải lúc nào cũng tốt. Denormalization (ngược lại) được chấp nhận khi:
| Khi nào denormalize | Ví dụ |
|---|---|
| Read-heavy application | Dashboard báo cáo cần tổng hợp nhanh |
| Tránh JOIN phức tạp | Lưu customer_name trong orders để hiển thị nhanh |
| Caching data | Materialized views cho báo cáo |
| Historical data | Lưu giá sản phẩm tại thời điểm đặt hàng (không lấy giá hiện tại) |
Quy tắc: Normalize first, denormalize for performance.
Nguyên tắc 2: Thiết kế ERD (Entity Relationship Diagram)
ERD là gì?
ERD là sơ đồ thể hiện các entity (thực thể) trong hệ thống và mối quan hệ giữa chúng. Đây là “bản vẽ kiến trúc” của database.
3 loại quan hệ
| Quan hệ | Ký hiệu | Ví dụ |
|---|---|---|
| One-to-One (1:1) | 1 — 1 | User — UserProfile |
| One-to-Many (1:N) | 1 — N | Customer — Orders |
| Many-to-Many (M:N) | M — N | Students — Courses (cần bảng trung gian) |
Ví dụ ERD cho website e-commerce
[customers] 1 ——— N [orders]
|
N
|
[order_items] N ——— 1 [products]
|
N
|
[product_categories] N ——— 1 [categories]
[customers] 1 ——— N [addresses]
[customers] 1 ——— N [reviews]
[products] 1 ——— N [reviews]
[products] 1 ——— N [product_images]
Các bảng chi tiết
customers:
- id (PK, auto-increment)
- email (UNIQUE, NOT NULL)
- password_hash (NOT NULL)
- full_name (NOT NULL)
- phone (VARCHAR 15)
- created_at (TIMESTAMP)
- updated_at (TIMESTAMP)
orders:
- id (PK, auto-increment)
- customer_id (FK → customers.id)
- status (ENUM: pending, confirmed, shipping, delivered, cancelled)
- total_amount (DECIMAL 12,2)
- shipping_address_id (FK → addresses.id)
- note (TEXT, nullable)
- created_at (TIMESTAMP)
order_items:
- id (PK, auto-increment)
- order_id (FK → orders.id)
- product_id (FK → products.id)
- quantity (INT, NOT NULL)
- unit_price (DECIMAL 12,2) — giá tại thời điểm đặt hàng
- subtotal (DECIMAL 12,2)
products:
- id (PK, auto-increment)
- name (VARCHAR 255, NOT NULL)
- slug (VARCHAR 255, UNIQUE)
- description (TEXT)
- price (DECIMAL 12,2, NOT NULL)
- compare_price (DECIMAL 12,2, nullable)
- stock_quantity (INT, DEFAULT 0)
- category_id (FK → categories.id)
- is_active (BOOLEAN, DEFAULT true)
- created_at (TIMESTAMP)
Nguyên tắc 3: Naming Convention (Quy tắc đặt tên)
Bảng quy tắc đặt tên
| Element | Convention | Ví dụ đúng | Ví dụ sai |
|---|---|---|---|
| Tên bảng | snake_case, số nhiều | order_items | OrderItem, tblOrderItems |
| Tên cột | snake_case | created_at | CreatedAt, createdAt |
| Primary key | id | id | customer_id, CustomerID |
| Foreign key | {table_singular}_id | customer_id | cust_id, fk_customer |
| Boolean | is_, has_, can_ | is_active | active, status |
| Timestamp | _at | created_at, deleted_at | create_date, DateCreated |
| Index | idx_{table}_{columns} | idx_orders_customer_id | index1 |
Quy tắc bổ sung
- Không dùng reserved words: Tránh tên bảng/cột là
order,user,group,table(thêm prefix:user_accounts) - Không viết tắt:
quantitythay vìqty,descriptionthay vìdesc - Consistent: Chọn 1 convention và dùng xuyên suốt
Nguyên tắc 4: Data Types — Chọn đúng kiểu dữ liệu
Chọn data type phù hợp
| Dữ liệu | Đúng | Sai | Lý do |
|---|---|---|---|
| Giá tiền | DECIMAL(12,2) | FLOAT, DOUBLE | Float có lỗi làm tròn (200.000 → 199.999) |
| SĐT | VARCHAR(15) | INT | SĐT có thể bắt đầu bằng 0, có dấu + |
| VARCHAR(255) | TEXT | VARCHAR có index, TEXT không nên index | |
| Mô tả | TEXT | VARCHAR(255) | Mô tả có thể dài hơn 255 ký tự |
| Trạng thái | ENUM hoặc TINYINT | VARCHAR | ENUM tiết kiệm dung lượng, tránh typo |
| Ngày tháng | DATE, DATETIME | VARCHAR | Cho phép so sánh, sort, tính toán |
| UUID | CHAR(36) hoặc BINARY(16) | VARCHAR(255) | Tiết kiệm dung lượng, index hiệu quả |
| JSON data | JSON/JSONB | TEXT | Cho phép query JSON trực tiếp |
Lưu ý tiền VND
-- Đúng: DECIMAL cho tiền VND
price DECIMAL(15, 0) -- 15 chữ số, không cần phần thập phân (VND không có xu)
-- Hoặc
price BIGINT -- Lưu bằng đồng, chia 1000 khi hiển thị
-- Sai: FLOAT/DOUBLE
price FLOAT -- 1.999.999 VND có thể thành 1.999.998.97
Nguyên tắc 5: Indexing — Tăng tốc query
Index là gì?
Index giống như mục lục sách — thay vì đọc hết 500 trang để tìm 1 từ, bạn tra mục lục và nhảy thẳng đến trang đó.
Khi nào cần index?
| Nên đánh index | Không nên đánh index |
|---|---|
| Cột dùng trong WHERE thường xuyên | Bảng < 1.000 rows |
| Cột dùng trong JOIN (foreign keys) | Cột thường xuyên INSERT/UPDATE |
| Cột dùng trong ORDER BY | Cột có ít giá trị unique (boolean) |
| Cột UNIQUE (email, phone) | Cột TEXT/BLOB |
Loại index
| Loại | Use case | Ví dụ |
|---|---|---|
| B-Tree (default) | So sánh =, <, >, BETWEEN, LIKE ‘abc%‘ | Hầu hết trường hợp |
| Hash | So sánh = only | Lookup bằng ID |
| Full-text | Tìm kiếm text | Tìm sản phẩm theo tên |
| Composite | Query với nhiều cột | WHERE status = ‘active’ AND city = ‘HCM’ |
| Partial (PostgreSQL) | Index có điều kiện | Chỉ index orders WHERE status = ‘pending’ |
Ví dụ indexing cho e-commerce
-- Foreign keys (luôn index)
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_order_items_product_id ON order_items(product_id);
-- Tìm kiếm phổ biến
CREATE INDEX idx_products_category_id ON products(category_id);
CREATE INDEX idx_products_is_active ON products(is_active) WHERE is_active = true;
-- Composite index cho filter
CREATE INDEX idx_products_category_price ON products(category_id, price);
-- Full-text search
CREATE FULLTEXT INDEX idx_products_search ON products(name, description);
-- Unique constraints
CREATE UNIQUE INDEX idx_customers_email ON customers(email);
Nguyên tắc 6: Soft Delete vs Hard Delete
Hard Delete
DELETE FROM customers WHERE id = 123;
-- Dữ liệu bị xóa vĩnh viễn, không khôi phục được
Soft Delete (khuyến nghị)
-- Thêm cột deleted_at
ALTER TABLE customers ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL;
-- "Xóa" = đánh dấu
UPDATE customers SET deleted_at = NOW() WHERE id = 123;
-- Query chỉ lấy records chưa xóa
SELECT * FROM customers WHERE deleted_at IS NULL;
Ưu điểm soft delete:
- Khôi phục được dữ liệu đã xóa
- Giữ lịch sử cho audit
- Không break foreign key constraints
- Dễ implement “thùng rác” như Gmail
Nhược điểm:
- Tất cả query phải thêm
WHERE deleted_at IS NULL - Database lớn dần theo thời gian
- Cần cleanup job định kỳ
Nguyên tắc 7: Audit Trail
Luôn lưu lại ai thay đổi gì, khi nào:
-- Mỗi bảng nên có
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by INT REFERENCES users(id),
updated_by INT REFERENCES users(id)
Với dữ liệu nhạy cảm (tài chính, hợp đồng), tạo bảng audit log riêng:
CREATE TABLE audit_logs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
table_name VARCHAR(100) NOT NULL,
record_id BIGINT NOT NULL,
action ENUM('INSERT', 'UPDATE', 'DELETE') NOT NULL,
old_values JSON,
new_values JSON,
user_id INT REFERENCES users(id),
ip_address VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Checklist thiết kế database
| # | Hạng mục | Kiểm tra |
|---|---|---|
| 1 | ERD đã vẽ và review | ☐ |
| 2 | Tất cả bảng đạt 3NF (hoặc denormalize có lý do) | ☐ |
| 3 | Naming convention nhất quán | ☐ |
| 4 | Data types phù hợp (đặc biệt tiền, ngày) | ☐ |
| 5 | Primary key cho mọi bảng | ☐ |
| 6 | Foreign keys với ON DELETE constraint | ☐ |
| 7 | Index trên foreign keys | ☐ |
| 8 | Index trên cột tìm kiếm thường xuyên | ☐ |
| 9 | UNIQUE constraint cho email, phone, slug | ☐ |
| 10 | NOT NULL cho các cột bắt buộc | ☐ |
| 11 | DEFAULT values hợp lý | ☐ |
| 12 | created_at, updated_at cho mọi bảng | ☐ |
| 13 | Soft delete (deleted_at) cho bảng quan trọng | ☐ |
| 14 | Charset UTF8MB4 cho tiếng Việt | ☐ |
| 15 | Seed data cho testing | ☐ |
FAQ — Câu hỏi thường gặp
Database bao nhiêu bảng là bình thường?
Tùy độ phức tạp: Blog đơn giản (5–10 bảng), E-commerce (20–40 bảng), ERP/CRM (50–200 bảng). Không có con số “chuẩn” — quan trọng là mỗi bảng có lý do tồn tại rõ ràng. Nếu bạn thấy 1 bảng có > 30 cột, có thể cần tách thành nhiều bảng.
Nên dùng auto-increment ID hay UUID?
Auto-increment ID cho hệ thống đơn giản, 1 database. UUID khi cần merge data từ nhiều nguồn, microservices, hoặc không muốn expose thứ tự ID ra ngoài (bảo mật). Hybrid: dùng auto-increment cho internal, UUID cho public-facing API.
Có nên lưu file/ảnh trong database?
Không nên lưu file trực tiếp trong database (chậm, tốn dung lượng, khó backup). Thay vào đó: lưu file trên cloud storage (S3, Google Cloud Storage), lưu URL/path trong database. Ngoại lệ: file < 1MB và cần transaction integrity.
Kết luận
Thiết kế database đúng từ đầu không mất nhiều thời gian hơn — nhưng tiết kiệm hàng trăm giờ sửa lỗi sau này. 7 nguyên tắc trong bài viết này cover 90% những gì bạn cần biết để thiết kế database cho một ứng dụng SME.
Nếu bạn đang bắt đầu dự án mới hoặc cần review thiết kế database hiện tại, hãy liên hệ Trinh Digital — chúng tôi giúp bạn thiết kế kiến trúc dữ liệu vững chắc, sẵn sàng scale khi doanh nghiệp phát triển.