Tại sao thiết kế schema ‘tử tế’ lại quan trọng?
Ngày đầu làm dev, mình cứ tưởng tạo vài cái table, quăng đại mấy cái cột vào là xong. Mọi thứ vẫn ổn cho đến khi dự án chạm mốc 10 triệu record. Lúc này, những câu query đơn giản bỗng chạy mất 15-20 giây, dữ liệu bắt đầu bị sai lệch (anomaly) không rõ nguyên nhân. Đó là cái giá phải trả cho việc thiết kế schema cẩu thả.
Dù bạn dùng MySQL, PostgreSQL hay MongoDB, tư duy thiết kế schema vẫn là cái gốc của mọi vấn đề. Một schema tốt giúp hệ thống phản hồi trong vài miligiây thay vì vài giây. Nó tiết kiệm tài nguyên server và giữ cho dữ liệu luôn nhất quán. Ngược lại, schema tồi sẽ ép code backend của bạn phải gánh những logic xử lý cồng kềnh để bù đắp cho lỗ hổng dữ liệu.
Thiết kế database thực chất là một bài toán đánh đổi (trade-off). Bạn phải cân bằng giữa tốc độ đọc, tốc độ ghi và tính toàn vẹn. Hãy cùng mình đi sâu vào cách triển khai thực tế nhé.
1. Chuẩn hóa (Normalization): Giữ dữ liệu sạch và gọn
Chuẩn hóa là cách tổ chức database để dẹp bỏ sự trùng lặp. Với hầu hết dự án web/app, nắm chắc 3 dạng chuẩn đầu tiên (1NF, 2NF, 3NF) là bạn đã đủ tự tin chinh chiến.
Dạng chuẩn 1 (1NF): Tính nguyên tố
Mỗi ô dữ liệu chỉ được chứa một giá trị duy nhất. Đừng bao giờ lưu danh sách số điện thoại kiểu "090..., 091..." vào một cột phones. Hãy tách chúng ra để sau này còn dễ dàng tìm kiếm hoặc đánh index.
Dạng chuẩn 2 (2NF): Phụ thuộc hàm đầy đủ
Mọi cột không phải khóa chính phải phụ thuộc hoàn toàn vào khóa chính đó. Ví dụ: Trong bảng OrderDetails, đừng nhét product_name vào. Nếu tên sản phẩm thay đổi, bạn sẽ phải đi update hàng nghìn dòng order cũ. Thay vào đó, hãy chỉ lưu product_id.
Dạng chuẩn 3 (3NF): Không phụ thuộc bắc cầu
Triệt tiêu việc một cột phụ thuộc vào một cột khác (mà cột đó cũng không phải khóa). Giả sử bảng Users có province_id và province_name. Vì province_name phụ thuộc vào province_id, bạn nên tách thông tin tỉnh thành sang một bảng riêng.
-- Tách bảng để đạt chuẩn 3NF, tránh dư thừa tên tỉnh
CREATE TABLE Provinces (
id INT PRIMARY KEY,
name VARCHAR(100)
);
CREATE TABLE Users (
id SERIAL PRIMARY KEY,
username VARCHAR(50),
province_id INT REFERENCES Provinces(id)
);
2. Khi nào nên “phá chuẩn” (Denormalization)?
Chuẩn hóa quá đà đôi khi lại là thảm họa về hiệu năng. Khi hệ thống cần lấy dữ liệu từ 7-8 bảng khác nhau, việc JOIN liên tục sẽ khiến CPU server quá tải. Đây là lúc chúng ta áp dụng Denormalization — chấp nhận dư thừa dữ liệu có kiểm soát để tăng tốc truy vấn.
Mình thường chọn Denormalize trong 3 trường hợp sau:
- Làm Dashboard/Báo cáo: Thay vì bắt database
COUNT(*)hàng triệu dòng mỗi khi user load trang, hãy lưu sẵn một cộttotal_orderstrong bảngUsers. - Dữ liệu mang tính thời điểm: Bảng
OrderItemsnên lưu luôn giá tiền tại lúc mua. Nếu sau này giá sản phẩm có tăng hay giảm, hóa đơn cũ của khách hàng vẫn phải giữ nguyên con số cũ. - Giảm độ sâu của JOIN: Nếu chỉ để lấy một cái tên hiển thị mà phải JOIN qua 4 bảng, hãy cân nhắc copy cái tên đó sang bảng đích.
Với PostgreSQL, bạn có một vũ khí hạng nặng là JSONB. Nó cho phép lưu dữ liệu phi cấu trúc ngay trong bảng quan hệ. Rất phù hợp cho các thuộc tính sản phẩm hay thay đổi như màu sắc, kích cỡ mà không cần tạo hàng chục bảng phụ.
-- Dùng JSONB để linh hoạt schema mà vẫn giữ được tốc độ
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(255),
specs JSONB -- Lưu màu sắc, chất liệu, cân nặng...
);
-- Truy vấn trực tiếp vào field trong JSONB cực nhanh
SELECT * FROM products WHERE specs->>'color' = 'black';
3. Những “hố tử thần” (Anti-patterns) cần né gấp
Qua nhiều lần review code, mình thấy anh em rất hay dính phải 3 lỗi kinh điển này:
Lỗi 1: Thiết kế kiểu EAV (Entity-Attribute-Value)
Nhiều người tạo bảng kiểu “vạn năng” với các cột key và value. Cách này nhìn thì linh hoạt nhưng query cực hình. Để filter 3 điều kiện, bạn phải JOIN chính cái bảng đó 3 lần. Hiệu năng sẽ sụt giảm thê thảm khi data lớn dần.
Lỗi 2: Lưu mảng bằng String phân tách dấu phẩy
Lưu tags = "1,2,3" là một sai lầm chết người. Bạn không thể đánh Index, không thể JOIN chính xác và việc xóa một tag khỏi chuỗi đó là một cực hình về logic code.
Lỗi 3: Coi thường khóa ngoại (Foreign Key)
Đừng nghe ai bảo bỏ Foreign Key cho “nhẹ” database. Thiếu khóa ngoại, chỉ sau vài tháng vận hành, database của bạn sẽ đầy rẫy “rác”. Những đơn hàng trỏ về user không tồn tại sẽ khiến hệ thống crash lúc nào không hay.
4. Đo lường và tối ưu thực tế
Thiết kế xong chưa phải là kết thúc. Bạn cần soi xem nó thực sự chạy thế nào trên môi trường production.
Dùng EXPLAIN ANALYZE: Hãy tập thói quen chạy lệnh này cho các query quan trọng. Nếu thấy Seq Scan trên bảng lớn, đó là tiếng chuông cảnh báo bạn đang thiếu Index hoặc schema đang bị thiết kế sai hướng.
Dọn dẹp Index thừa: Index giúp đọc nhanh nhưng lại làm chậm tốc độ Ghi. Đừng tạo index vô tội vạ. Dưới đây là câu query mình hay dùng trên Postgres để tìm các index “ngồi chơi xơi nước”:
SELECT s.relname AS table_name,
indexrelname AS index_name,
i.idx_scan
FROM pg_stat_user_indexes AS i
JOIN pg_stat_user_tables AS s ON s.relid = i.relid
WHERE i.idx_scan = 0; -- Index chưa từng được dùng đến
Quản lý thay đổi bằng Migration: Đừng bao giờ sửa schema bằng tay trực tiếp trên DB. Hãy dùng Flyway, Liquibase hoặc các bộ Migration của Laravel, Django. Việc này giúp team kiểm soát được lịch sử và rollback nhanh khi có biến.
Tóm lại, thiết kế schema là nghệ thuật của sự cân bằng. Đừng quá máy móc về chuẩn hóa, cũng đừng tùy tiện đến mức biến DB thành bãi rác. Hy vọng những kinh nghiệm thực chiến này giúp anh em tự tin hơn khi đặt bút vẽ những table đầu tiên cho dự án.

