Database chạy chậm? Thủ phạm có thể là kiểu dữ liệu
Mình nhớ hồi mới bắt đầu làm với MySQL, thứ quan tâm nhất là query có chạy không, index có đúng không. Chuyện chọn INT hay BIGINT, VARCHAR(255) hay VARCHAR(50) — cứ nghĩ “dư dả cho chắc” rồi thôi.
Đến khi database production của mình chạy MySQL 8.0 với khoảng 50GB data bắt đầu có dấu hiệu I/O cao bất thường, mình ngồi audit lại schema mới phát hiện hàng loạt vấn đề. Cột user_id dùng BIGINT trong khi data chưa bao giờ vượt 2 triệu records. Cột status lưu chuỗi "active"/"inactive" thay vì TINYINT. Cột email khai báo VARCHAR(1000) vì “cho an toàn”.
Sau khi refactor schema, query nhanh hơn đáng kể, buffer pool sử dụng hiệu quả hơn hẳn. Bài này ghi lại những mẹo thực tế từ quá trình đó — không phải lý thuyết sách giáo khoa.
Tại sao kiểu dữ liệu ảnh hưởng đến I/O và RAM?
MySQL đọc dữ liệu theo đơn vị page (InnoDB mặc định 16KB). Mỗi page chứa được bao nhiêu row phụ thuộc vào kích thước mỗi row. Row càng nhỏ, mỗi page nhét được càng nhiều row.
Hệ quả thực tế khi row “béo” do kiểu dữ liệu dư thừa:
- I/O tăng: Cần nhiều disk read hơn để scan cùng lượng data vì mỗi page chứa ít row hơn
- RAM lãng phí: InnoDB buffer pool (bộ nhớ cache) chứa ít row hơn trên cùng dung lượng RAM
- Index lớn hơn: B-tree index traversal chậm hơn khi key size lớn — và index lưu đúng kiểu dữ liệu của cột
Ví dụ cụ thể: bảng 10 triệu rows, nếu mỗi row tiết kiệm được 8 bytes nhờ chọn INT thay BIGINT, tổng tiết kiệm là 80MB data. Nhưng khi tính thêm index lưu primary key đó, con số thực tế cao hơn nhiều.
Thực hành chi tiết: Chọn đúng từng kiểu dữ liệu
1. Integer — đừng mặc định BIGINT cho mọi thứ
Đây là lỗi mình thấy nhiều nhất. Mỗi integer type có range và kích thước khác nhau:
-- Kích thước lưu trữ và range:
TINYINT -- 1 byte | max 127 (signed) / 255 (unsigned)
SMALLINT -- 2 bytes | max 32,767 / 65,535
MEDIUMINT -- 3 bytes | max 8,388,607 / 16,777,215
INT -- 4 bytes | max 2,147,483,647 / 4,294,967,295
BIGINT -- 8 bytes | max ~9.2 × 10^18
Áp dụng thực tế:
age,star_rating,quantitynhỏ → dùngTINYINT UNSIGNED(0–255), không cần INTyear,postal_code→SMALLINT UNSIGNEDhoặcYEARtypeuser_id,product_id→ nếu data dưới 4 tỷ,INT UNSIGNEDlà đủ, tiết kiệm 4 bytes/row so với BIGINT- Chỉ dùng
BIGINTkhi thực sự cần: financial transaction ID, Snowflake ID, hệ thống phân tán
-- Xấu: BIGINT cho cột user_id hệ thống nhỏ, lãng phí 4 bytes/row
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
status TINYINT NOT NULL DEFAULT 1
);
-- Tốt hơn: tiết kiệm 8 bytes/row (id + user_id)
CREATE TABLE orders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
status TINYINT UNSIGNED NOT NULL DEFAULT 1
);
2. String — VARCHAR(n) sát với thực tế, không phải “cho chắc”
VARCHAR(n) lưu đúng độ dài thực tế + 1–2 bytes overhead. Nhưng giá trị n ảnh hưởng đến bộ nhớ tạm khi MySQL cần sort hoặc group: nó cấp phát n × charset_bytes cho mỗi row trong temporary table, dù data thực tế ngắn hơn nhiều.
-- Khai báo sát với max thực tế:
email VARCHAR(254), -- RFC 5321: max email length là 254 ký tự
username VARCHAR(50), -- username hiếm khi dài hơn 50
phone VARCHAR(20), -- số điện thoại quốc tế dài nhất ~15 ký tự
slug VARCHAR(200), -- URL slug thực tế
country CHAR(2), -- ISO country code: VN, JP, US — dùng CHAR vì fixed-length
-- Tránh khai báo bừa:
-- email VARCHAR(1000) -- cấp phát 3000 bytes/row khi sort (UTF-8 × 3)
-- name VARCHAR(255) -- nếu thực tế max là 100, đừng khai báo 255
CHAR vs VARCHAR: Dùng CHAR(n) cho data có độ dài cố định như country code, mã bưu chính, UUID dạng string. Dùng VARCHAR cho data có độ dài biến thiên.
TEXT types: Không lưu inline trong row — InnoDB lưu ở page riêng, row chỉ chứa pointer. Điều này nghĩa là mỗi lần đọc cột TEXT là một I/O thêm. Chỉ dùng khi thực sự cần lưu nội dung dài:
-- Đúng: TEXT cho nội dung thực sự dài
article_content MEDIUMTEXT, -- bài viết blog
product_desc TEXT,
raw_log LONGTEXT
-- Sai: TEXT cho data ngắn
note TEXT -- nếu note chỉ dài vài chục ký tự
-- Đúng hơn:
note VARCHAR(500)
3. ENUM cho tập giá trị cố định
Cột status, role, type với vài giá trị cố định — đây là vị trí ENUM tỏa sáng:
-- VARCHAR lưu chuỗi thực, mỗi giá trị chiếm đúng len(value) bytes
status VARCHAR(20) -- 'active' = 6 bytes, 'inactive' = 8 bytes
-- ENUM lưu index 1-2 bytes, giá trị thực nằm trong table metadata
status ENUM('active', 'inactive', 'banned', 'pending') -- luôn 1 byte
role ENUM('admin', 'editor', 'viewer', 'guest')
Nhược điểm cần lưu ý: thêm/bớt giá trị ENUM yêu cầu ALTER TABLE (tốn kém với bảng lớn). Nếu tập giá trị thay đổi thường xuyên, dùng TINYINT UNSIGNED + constants trong application code sẽ linh hoạt hơn.
4. Kiểu ngày giờ — TIMESTAMP tiết kiệm 4 bytes/row so với DATETIME
-- DATETIME : 8 bytes, range 1000–9999, không timezone-aware
-- TIMESTAMP: 4 bytes, range 1970–2038, tự convert theo server timezone
-- DATE : 3 bytes, không lưu giờ phút giây
-- Thực tế:
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
birth_date DATE, -- 3 bytes, không cần time
event_start DATETIME(3) -- khi cần millisecond precision, hoặc range > 2038
Với hệ thống chạy ở 1 timezone và không cần lưu thời điểm sau năm 2038, mỗi cột TIMESTAMP thay DATETIME tiết kiệm 4 bytes. Bảng 10 triệu rows có 2 cột thời gian = 80MB tiết kiệm ngay.
5. NULL — khai báo NOT NULL khi có thể
Cột nullable tốn thêm 1 bit trong NULL bitmap. Impact không lớn bằng MyISAM, nhưng NULL làm query phức tạp hơn và đôi khi khiến optimizer đưa ra quyết định sai khi estimate cardinality.
-- Mặc định NOT NULL + DEFAULT cho cột luôn có giá trị
view_count INT UNSIGNED NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Chỉ để NULL khi cần phân biệt "chưa có" vs "giá trị rỗng"
deleted_at TIMESTAMP NULL DEFAULT NULL, -- soft delete pattern
verified_at TIMESTAMP NULL DEFAULT NULL -- chưa verify = NULL
6. Audit schema hiện tại với INFORMATION_SCHEMA
Trước khi optimize, mình thường chạy query này để phát hiện cột “béo”:
-- Liệt kê các cột có kiểu dữ liệu cần xem xét
SELECT
TABLE_NAME,
COLUMN_NAME,
COLUMN_TYPE,
CHARACTER_MAXIMUM_LENGTH,
IS_NULLABLE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'your_database'
AND (
DATA_TYPE IN ('text', 'mediumtext', 'longtext')
OR (DATA_TYPE = 'varchar' AND CHARACTER_MAXIMUM_LENGTH > 500)
OR (DATA_TYPE = 'bigint')
OR (DATA_TYPE = 'datetime')
)
ORDER BY TABLE_NAME, ORDINAL_POSITION;
-- Kiểm tra kích thước thực tế của từng bảng
SELECT
TABLE_NAME,
TABLE_ROWS,
ROUND(DATA_LENGTH / 1024 / 1024, 2) AS data_mb,
ROUND(INDEX_LENGTH / 1024 / 1024, 2) AS index_mb,
ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) AS total_mb
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'your_database'
ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC
LIMIT 20;
Kết luận: Nguyên tắc mình luôn áp dụng ngay từ đầu
Schema design không phải thứ “set and forget”. Khi data tăng trưởng, những quyết định sai từ đầu trở thành technical debt khó trả. Refactor bảng 100 triệu rows tốn kém gấp nhiều lần so với thiết kế đúng từ ban đầu.
Tóm lại các nguyên tắc mình giờ áp dụng từ khi tạo bảng:
- Integer nhỏ nhất đủ dùng: Estimate max value thực tế, chọn type tương ứng. Thêm
UNSIGNEDnếu không cần giá trị âm. - VARCHAR(n) sát thực tế: Không khai báo 255 hay 1000 “cho chắc” — ảnh hưởng đến memory sort.
- ENUM cho tập giá trị cố định ít thay đổi: status, role, type — không dùng VARCHAR.
- TIMESTAMP thay DATETIME khi data không vượt năm 2038 — tiết kiệm 4 bytes/row.
- NOT NULL + DEFAULT làm mặc định: Chỉ để NULL khi có lý do rõ ràng.
- Audit định kỳ với INFORMATION_SCHEMA: Phát hiện sớm trước khi data lớn.
Những optimization này nhìn qua có vẻ nhỏ, nhưng trên database 50GB như của mình, chúng tạo ra sự khác biệt thực sự — cả ở tốc độ query lẫn mức độ sử dụng RAM của buffer pool.

