Giao diện
3. Schema trong MySQL
Kiến thức thiết kế chung (chuẩn hoá, quan hệ, EAV, versioning, soft delete, sổ cái, multi-tenant) nằm ở phần Database. Chương này chỉ gồm những gì riêng của MySQL/InnoDB: chọn khoá và ràng buộc, cột JSON, một schema mẫu hoàn chỉnh, và cách đổi schema khi hệ thống đang chạy.
Khoá và ràng buộc trong MySQL
- Khoá chính: dùng khoá thay thế
BIGINTtự tăng (hoặc ID có thứ tự như UUIDv7), không dùng khoá tự nhiên như email hay mã số thuế. Trong InnoDB, khoá chính là clustered index và bị chép vào mọi secondary index (chương 4); khoá tự nhiên có thể đổi và thường dài. Khoá tự nhiên để ở cột riêng cóUNIQUE. - NOT NULL và DEFAULT theo nghiệp vụ (chương 2).
- UNIQUE cho mọi thứ không được trùng, kể cả khoá idempotency. MySQL không có partial index, nên UNIQUE “chỉ trên dòng chưa xoá” phải dùng cột sinh:
active_email AS (IF(deleted_at IS NULL, email, NULL))+ UNIQUE (Soft delete). - CHECK chỉ được thực thi từ 8.0.16. Phiên bản cũ hơn chấp nhận cú pháp nhưng âm thầm bỏ qua.
- FOREIGN KEY trong InnoDB:
- Khai báo bằng
FOREIGN KEY (…) REFERENCES …ở mức bảng.REFERENCESviết ngay sau định nghĩa cột bị MySQL 8.x bỏ qua mà không báo lỗi. - InnoDB tự tạo index trên cột khoá ngoại nếu chưa có.
- Mỗi lệnh ghi vào bảng con phải kiểm tra bảng cha và đặt shared lock trên dòng cha: ghi nhiều vào bảng con có thể chờ lock vì bảng cha.
- Với MySQL 8.0/8.4, thay đổi do
ON DELETE CASCADEđược InnoDB tự thực hiện bên trong và không xuất hiện thành sự kiện riêng trong binlog, nên công cụ CDC như Debezium không thấy các dòng con bị xoá theo. Kiểm tra lại nếu dùng bản mới hơn. - gh-ost không hỗ trợ bảng có khoá ngoại.
- Hệ thống nhỏ và vừa nên giữ FK; hệ thống rất lớn thường bỏ FK, kiểm tra ở tầng app và chạy job đối soát.
- Khai báo bằng
Cột JSON trong MySQL
Kiểu JSON lưu dạng nhị phân, đọc một trường không phải parse cả chuỗi. Lọc theo trường trong JSON phải qua cột sinh có index hoặc multi-valued index (8.0.17) cho mảng; kiểm tra cấu trúc bằng CHECK (JSON_SCHEMA_VALID(…, attrs)). Ví dụ đầy đủ và khi nào nên dùng JSON thay cho EAV: EAV, JSON & thuộc tính động.
Ví dụ: đơn hàng thương mại điện tử
order_items.unit_price là snapshot giá lúc đặt, không phải bản sao của products.price.sql
CREATE TABLE customers (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
full_name VARCHAR(100) NOT NULL,
phone VARCHAR(20),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uq_email (email)
);
CREATE TABLE products (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
sku VARCHAR(40) NOT NULL,
name VARCHAR(200) NOT NULL,
price BIGINT UNSIGNED NOT NULL, -- VNĐ
stock INT UNSIGNED NOT NULL DEFAULT 0,
UNIQUE KEY uq_sku (sku)
);
CREATE TABLE orders (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NOT NULL,
status TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 0 chờ, 1 đã trả, 2 đang giao, 3 xong, 9 huỷ
total_amount BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
KEY idx_customer (customer_id, created_at), -- “đơn của tôi”, mới nhất trước
KEY idx_status (status, created_at), -- hàng đợi xử lý theo trạng thái
CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id)
);
CREATE TABLE order_items (
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
unit_price BIGINT UNSIGNED NOT NULL, -- snapshot giá lúc đặt
quantity INT UNSIGNED NOT NULL,
PRIMARY KEY (order_id, product_id),
KEY idx_product (product_id),
CHECK (quantity > 0),
CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES orders (id),
CONSTRAINT fk_items_product FOREIGN KEY (product_id) REFERENCES products (id)
);order_itemsdùng khoá chính ghép(order_id, product_id): các dòng của một đơn nằm liền nhau trong clustered index, đọc một đơn chỉ cần một vài page, và mỗi sản phẩm chỉ xuất hiện một lần trong một đơn.unit_pricelà snapshot, không phải bản sao củaproducts.price(Chuẩn hoá & phá chuẩn).- Hai index trên
ordersđặt cột so sánh bằng trước, cột sort sau, theo quy tắc ESR (chương 4).
Thay đổi schema khi hệ thống đang chạy
| Thuật toán DDL | Ví dụ | Ảnh hưởng |
|---|---|---|
INSTANT | Thêm hoặc xoá cột (8.0.29+), đổi DEFAULT, thêm giá trị ENUM vào cuối, đổi tên cột hoặc index | Chỉ sửa metadata, xong ngay |
INPLACE | Thêm index, tăng độ dài VARCHAR trong cùng mức tiền tố độ dài | Không khoá ghi nhưng có thể rebuild, tốn I/O, gây lag replica |
COPY | Đổi kiểu cột (INT → BIGINT), đổi charset | Dựng lại cả bảng và chặn ghi suốt thời gian đó |
- Ghi rõ
ALGORITHM=INSTANThoặcALGORITHM=INPLACE, LOCK=NONEtrong câu lệnh: nếu MySQL không làm được theo cách đó thì báo lỗi ngay, thay vì âm thầm chuyển sang COPY. - Bảng lớn cần COPY: dùng gh-ost hoặc pt-online-schema-change (tạo bảng bóng, chép dần, rồi đổi tên).
- Migration phải chạy được với cả code đang chạy lẫn code sắp deploy, vì trong lúc deploy hai phiên bản cùng tồn tại.
Bẫy metadata lock
Mọi DDL, kể cả INSTANT, cần metadata lock độc quyền trong chốc lát. Nếu có một transaction đang mở lâu trên bảng đó (một câu SELECT chưa commit cũng tính), DDL phải chờ. Trong lúc DDL chờ, mọi query mới trên bảng đó xếp hàng sau nó, và hệ thống đứng hình. Trước khi chạy DDL, đặt SET SESSION lock_wait_timeout = 5 để DDL tự bỏ cuộc rồi thử lại, và kiểm tra transaction dài trong information_schema.INNODB_TRX.
Expand – contract: đổi cột không cần downtime
Đổi tên hoặc đổi định dạng một cột (ví dụ chuẩn hoá phone sang dạng quốc tế ở cột phone_e164) không làm trong một bước. Chia thành các bước, mỗi bước đều có thể quay lại bước trước:
| # | Bước | Cột trong bảng | App | Đọc | Ghi |
|---|---|---|---|---|---|
| 0 | Ban đầu | phone | v1 | phone | phone |
| 1 | ADD COLUMN phone_e164, ALGORITHM=INSTANT | phone, phone_e164 (rỗng) | v1 | phone | phone |
| 2 | Deploy app v2: ghi cả hai cột | phone, phone_e164 | v2 | phone | phone + phone_e164 |
| 3 | Backfill dữ liệu cũ theo batch | phone, phone_e164 (đầy đủ) | v2 | phone | phone + phone_e164 |
| 4 | Deploy app v3: đọc cột mới | phone, phone_e164 | v3 | phone_e164 | phone + phone_e164 |
| 5 | Deploy app v4: chỉ dùng cột mới | phone, phone_e164 | v4 | phone_e164 | phone_e164 |
| 6 | DROP COLUMN phone, ALGORITHM=INSTANT | phone_e164 | v4 | phone_e164 | phone_e164 |
App v1 đọc và ghi cột phone. Mục tiêu: chuyển sang cột phone_e164 (dạng +84…) mà không dừng hệ thống.
Ghi nhớ
- Khoá chính
BIGINTtăng dần; khoá tự nhiên để UNIQUE; UNIQUE trên dòng chưa xoá dùng cột sinh. - CHECK chỉ có hiệu lực từ 8.0.16; FK phải khai báo ở mức bảng, có chi phí lock và không đi qua CDC khi cascade.
- DDL: ghi rõ ALGORITHM, đặt
lock_wait_timeoutngắn, đổi cột theo expand–contract.