Thiết kế Schema MySQL E-commerce: Đừng để Database ‘nghẽn cổ chai’ khi Scale-up

MySQL tutorial - IT technology blog
MySQL tutorial - IT technology blog

Thiết kế Database E-commerce: Không chỉ là tạo vài cái bảng

Ngày đầu làm App, mình cứ ngỡ thiết kế DB bán hàng chỉ quanh quẩn vài bảng productsorders. Nhưng thực tế khắc nghiệt hơn nhiều. Khi hệ thống scale lên hàng nghìn SKU với đủ loại thuộc tính (màu sắc, size, chất liệu), những sai lầm nhỏ lúc đầu sẽ trở thành thảm họa hiệu năng.

Thiết kế sai khiến query chậm chạp và gây ra những lỗi chí mạng như sai lệch tồn kho hoặc mất dấu lịch sử giá. Sau nhiều lần vấp ngã và fix bug, mình rút ra rằng một schema tốt phải cân bằng được giữa tính linh hoạt và tốc độ. Mục tiêu là làm sao để hệ thống vẫn chạy mượt khi lượng đơn hàng tăng đột biến vào các dịp Mega Sale.

Normalization vs. Denormalization: Chọn phe nào?

Về lý thuyết, chúng ta luôn ưu tiên chuẩn hóa (Normalization) để tránh dư thừa dữ liệu. Tuy nhiên, với E-commerce, việc Denormalization (phi chuẩn hóa) có tính toán là chìa khóa để giảm bớt các cú JOIN nặng nề.

  • Product & Variants: Đừng nhồi nhét mọi thứ vào một bảng duy nhất. Một chiếc iPhone 15 có thể có 5 màu và 3 loại dung lượng, tạo ra 15 biến thể (SKU) khác nhau.
  • Snapshot dữ liệu: Đây là điểm cực kỳ quan trọng. Khi khách chốt đơn, bạn phải lưu ngay giá sản phẩm vào bảng order_items. Nếu chỉ lưu ID sản phẩm, khi tháng sau bạn tăng giá, hóa đơn cũ của khách cũng sẽ bị nhảy số theo – một lỗi cực kỳ sơ đẳng.
  • Inventory: Quản lý kho không đơn thuần là cột quantity. Bạn cần tách biệt rõ ràng giữa tồn kho thực tế và tồn kho khả dụng để tránh tình trạng khách thanh toán xong nhưng hết hàng.

Thực hành: Cấu trúc Schema tối ưu cho hệ thống lớn

1. Xử lý Sản phẩm và Biến thể (Product & Variants)

Để quản lý hàng nghìn thuộc tính động, mình thường dùng mô hình 1-N giữa productsproduct_variants. Cách này giúp tách biệt thông tin chung và thông tin định danh của từng SKU.

CREATE TABLE products (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    slug VARCHAR(255) UNIQUE NOT NULL,
    base_price DECIMAL(15, 2) NOT NULL,
    status ENUM('draft', 'published', 'hidden') DEFAULT 'draft',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

CREATE TABLE product_variants (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    product_id BIGINT UNSIGNED NOT NULL,
    sku VARCHAR(100) UNIQUE NOT NULL,
    price_modifier DECIMAL(15, 2) DEFAULT 0.00,
    attributes JSON, -- Lưu { "color": "Space Gray", "storage": "256GB" }
    INDEX (product_id),
    FOREIGN KEY (product_id) REFERENCES products(id)
) ENGINE=InnoDB;

Mẹo nhỏ: Kiểu dữ liệu JSON trong MySQL 8.0 rất mạnh mẽ. Thay vì tạo 20 bảng trung gian cho màu sắc hay kích cỡ, bạn có thể lưu thẳng vào cột attributes. Việc truy vấn vẫn rất nhanh nhờ JSON_EXTRACT hoặc tạo Virtual Column để đánh Index.

2. Quản lý Kho (Inventory) chống Race Condition

Đừng bao giờ cập nhật trực tiếp stock_quantity ngay khi khách bấm nút đặt hàng. Nếu khách bùng hàng hoặc thanh toán thất bại, việc hoàn kho rất dễ sai sót. Trong một dự án thực tế với 1 triệu bản ghi, mình đã áp dụng cơ chế “giữ chỗ” (Reserve) để giải quyết triệt để vấn đề này:

CREATE TABLE inventory (
    variant_id BIGINT UNSIGNED PRIMARY KEY,
    physical_stock INT DEFAULT 0, -- Hàng thực tế trong kho
    reserved_stock INT DEFAULT 0, -- Hàng đang chờ thanh toán
    FOREIGN KEY (variant_id) REFERENCES product_variants(id)
) ENGINE=InnoDB;

Công thức tính hàng có thể bán là physical_stock - reserved_stock. Khi khách đặt hàng, bạn chỉ tăng reserved_stock. Chỉ khi tiền đã về túi hoặc hàng đã xuất kho, bạn mới trừ cả hai cột.

3. Tính bất biến của Đơn hàng

Bảng order_items phải là một “bản sao đóng băng” của sản phẩm tại thời điểm giao dịch. Hãy lưu cả tên sản phẩm và giá bán lúc đó để làm bằng chứng pháp lý và đối soát sau này.

CREATE TABLE order_items (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    order_id BIGINT UNSIGNED NOT NULL,
    variant_id BIGINT UNSIGNED NOT NULL,
    quantity INT NOT NULL,
    price_at_purchase DECIMAL(15, 2) NOT NULL,
    product_name_snapshot VARCHAR(255),
    FOREIGN KEY (order_id) REFERENCES orders(id)
) ENGINE=InnoDB;

4. Tăng tốc truy vấn với Index chuẩn

Dữ liệu lớn mà thiếu Index thì SELECT một cái là Server “treo” ngay. Với bảng orders khoảng 50GB, mình đã giảm thời gian truy vấn lịch sử đơn hàng từ 2 giây xuống còn chưa tới 50ms nhờ Composite Index.

-- Tối ưu tìm kiếm đơn hàng theo user và thời gian
CREATE INDEX idx_user_id_created_at ON orders(user_id, created_at);

Hãy nhớ quy tắc: Cột nào xuất hiện trong WHEREORDER BY thường xuyên thì nên cân nhắc đánh Index cho nó.

Lời kết

Thiết kế Schema cho thương mại điện tử không có một công thức cố định nào. Tuy nhiên, hãy luôn khắc cốt ghi tâm: Dữ liệu tài chính phải chính xác tuyệt đối và có tính lịch sử.

Đừng cố làm mọi thứ phức tạp ngay từ ngày đầu. Hãy tập trung xử lý tốt mối quan hệ giữa Product, Variant và Order. Khi hệ thống chạm mốc vài triệu đơn hàng, bạn có thể tính đến Partitioning hoặc Sharding sau. Chúc các bạn xây dựng được hệ thống database đủ khỏe để gánh mọi đợt Flash Sale!

Share: