Skip to content

2. Kiểu dữ liệu ​

Hiệu năng bắt đầu từ việc chọn đúng kiểu cho từng cột. Mỗi byte thừa bị nhân lên theo số dòng, rồi nhân tiếp theo số index chứa cột đó.

Vì sao kiểu dữ liệu quyết định hiệu năng ​

Khi hệ thống lớn dần, chi phí của kiểu dữ liệu “hơi to” lộ ra ở ba chỗ:

  • Disk: dữ liệu to hơn thì phải đọc ghi nhiều page hơn; backup, restore và replication cũng chậm hơn.
  • RAM: InnoDB giữ page dữ liệu và index trong buffer pool (xem chương 1). Dữ liệu nhỏ thì nhiều dòng nằm vừa RAM hơn. Khi phần dữ liệu hay được truy cập (working set) vượt quá buffer pool, đọc RAM chuyển thành đọc disk và latency nhảy bậc.
  • CPU: so sánh số nguyên rẻ hơn nhiều so với so sánh chuỗi, vì chuỗi phải so theo collation.

Ví dụ: cột status lưu bằng INT (4 byte) thay vì TINYINT (1 byte). Với 1 tỷ dòng, phần thừa là 3 GB, chưa tính các index có chứa cột này.

Nguyên tắc chọn kiểu ​

  1. Hiểu phạm vi dữ liệu. Có bao nhiêu trạng thái? Vài chục thì TINYINT là đủ. ID có thể vượt 2,1 tỷ (INT) hoặc 4,29 tỷ (INT UNSIGNED) không? Nếu có, dùng BIGINT ngay từ đầu, vì đổi kiểu cột khoá trên bảng lớn rất tốn kém.
  2. Ưu tiên số nguyên hơn chuỗi cho ID, trạng thái, mã loại. Index trên số nhỏ hơn và so sánh nhanh hơn. Số điện thoại, email vẫn phải là chuỗi (số 0 ở đầu, dấu +), nhưng index trên chúng sẽ to hơn.
  3. Đơn giản và đúng bản chất. Thời gian dùng DATE/DATETIME, không lưu chuỗi '20261013': chuỗi mất kiểm tra hợp lệ, không dùng được hàm ngày tháng, so sánh theo collation. Hash dùng BINARY.
  4. NOT NULL khi nghiệp vụ không có khái niệm “chưa biết”, kèm DEFAULT hợp lý. Lý do chính là tính đúng đắn của query, không phải dung lượng.

NULL: tốn bao nhiêu và bẫy ở đâu ​

Với row format COMPACT/DYNAMIC (mặc định), InnoDB dùng một NULL bitmap ở đầu mỗi dòng: mỗi cột cho phép NULL tốn 1 bit, làm tròn lên byte. Bảng có 8 cột nullable chỉ tốn thêm 1 byte mỗi dòng, và giá trị NULL không chiếm chỗ ở phần dữ liệu. Index trên cột nullable vẫn dùng bình thường; WHERE col IS NULL cũng dùng được index (EXPLAIN type ref_or_null).

Cái giá thật của NULL là logic ba giá trị (TRUE / FALSE / UNKNOWN), dễ làm query trả sai mà không báo lỗi:

  • WHERE status != 2 bỏ qua các dòng có status NULL.
  • WHERE id NOT IN (SELECT ref_id FROM …) trả về rỗng nếu subquery có dù chỉ một NULL. Dùng NOT EXISTS.
  • UNIQUE cho phép nhiều dòng cùng NULL.
  • COUNT(col), SUM, AVG bỏ qua NULL.

Số nguyên, số thực, tiền ​

  • TINYINT / SMALLINT / MEDIUMINT / INT / BIGINT: 1 / 2 / 3 / 4 / 8 byte. Chọn theo mức mở rộng dự kiến.
  • UNSIGNED dời phạm vi sang không âm, nên giá trị tối đa gấp đôi. Bẫy: phép trừ cho ra số âm sẽ báo lỗi BIGINT UNSIGNED value is out of range.
  • Số trong ngoặc như INT(11) chỉ là độ rộng hiển thị, không đổi dung lượng hay phạm vi, đã deprecated từ 8.0.17. BOOLEAN chính là TINYINT(1).
  • FLOAT / DOUBLE nhanh nhưng xấp xỉ (0,1 + 0,2 ≠ 0,3). Không bao giờ dùng cho tiền.
  • DECIMAL(p,s) chính xác tuyệt đối, khoảng 4 byte cho mỗi 9 chữ số.
  • Tiền: VNĐ không có phần lẻ nên BIGINT là lựa chọn gọn và nhanh. Hệ đa tiền tệ: lưu đơn vị nhỏ nhất (xu, cent) bằng BIGINT kèm cột mã tiền tệ, hoặc DECIMAL(19,4).

Chuỗi ​

  • Charset: dùng utf8mb4. Trong MySQL, utf8 là utf8mb3 (tối đa 3 byte), không lưu được emoji.
  • VARCHAR(n): độ dài thay đổi, tốn độ dài thật cộng 1 byte (nếu tối đa ≤ 255 byte) hoặc 2 byte. Đặt n vừa đủ, đừng mặc định 255: bảng tạm và sort buffer có thể cấp phát theo độ dài tối đa. Khoá index tối đa 3072 byte, tức VARCHAR(768) với utf8mb4.
  • CHAR(n): chỉ thật sự cố định độ dài khi charset 1 byte (latin1, ascii). Với utf8mb4, InnoDB lưu CHAR(n) như kiểu thay đổi độ dài (từ n tới 4n byte). Dùng cho mã ngắn, đều nhau như mã quốc gia CHAR(2).
  • Hash, UUID: lưu nhị phân. MD5 vào BINARY(16) qua UNHEX(MD5(…)): 16 byte thay vì 32 ký tự, so sánh nhị phân nhanh hơn.
  • ENUM: gọn (1–2 byte) nhưng có nhược điểm: sắp xếp theo số thứ tự nội bộ chứ không theo chữ; thêm giá trị vào giữa danh sách phải rebuild bảng (thêm vào cuối thì INSTANT); so sánh với số dễ nhầm sang chỉ số. Nhiều team dùng TINYINT kèm hằng số trong code, hoặc bảng lookup.
  • TEXT / BLOB / JSON: giá trị dài nằm ở page tràn (off-page). Không SELECT các cột này khi không cần; bảng tạm có cột TEXT dễ phải xuống disk.

Thời gian ​

  • TIMESTAMP (4 byte): khi lưu, đổi từ time_zone của session sang UTC; khi đọc, đổi ngược lại. Phạm vi 1970-01-01 00:00:01 tới 2038-01-19 03:14:07 UTC.
  • DATETIME (5 byte): lưu nguyên giá trị, không đổi múi giờ, phạm vi năm 1000 tới 9999.
  • Cả hai thêm 0–3 byte nếu có phần lẻ giây (DATETIME(3) cho mili giây).

Chênh lệch 1 byte không đáng kể. Khác biệt quan trọng là múi giờ và giới hạn năm 2038: ngày hết hạn hợp đồng 15 năm hay ngày đáo hạn khoản vay dài hạn đã vượt mốc này. Cách làm phổ biến: DATETIME và quy ước app luôn ghi giờ UTC.

Đồng hồ 2038

Còn … tới khi cột TIMESTAMP không lưu được thời điểm mới nữa.

Kiểu cho ID và khoá chính ​

ID xuất hiện ở mọi nơi: join, điều kiện WHERE, khoá ngoại. Trong InnoDB, khoá chính còn bị chép vào mọi secondary index (xem chương Index). Chọn sai kiểu ID làm phình tất cả index, join chậm và buffer pool kém hiệu quả.

  • Dùng INT UNSIGNED hoặc BIGINT tự tăng.
  • Cột khoá ngoại cùng kiểu với cột được tham chiếu, kể cả dấu UNSIGNED và charset/collation nếu là chuỗi.
  • Tránh UUID dạng chuỗi CHAR(36): 36 byte, so sánh theo collation.
  • Cần UUID (sinh ID ở client, gộp dữ liệu nhiều nguồn) thì lưu BINARY(16) và chọn loại có thứ tự: UUIDv7 (phần đầu là timestamp) hoặc UUID_TO_BIN(uuid, 1) với UUIDv1. UUIDv4 ngẫu nhiên khiến mỗi insert rơi vào một page bất kỳ trong clustered index: page split, phân mảnh, page chỉ lấp khoảng 69%.
  • Hệ phân tán cần ID tăng dần theo thời gian: ID kiểu Snowflake (timestamp + máy + số thứ tự) vừa với BIGINT.
Thử: dung lượng theo kiểu khoá chính

Ước tính thô: mỗi dòng clustered tốn thêm 18 byte (header 5, trx_id 6, roll_ptr 7); mỗi entry secondary = header 5 + khoá 8 + PK. Page lấp 15/16 khi insert tăng dần, khoảng 69% khi insert ngẫu nhiên.

Bảng (clustered index)Các secondary index
INT UNSIGNED, tự tăngBảng: 75,7 GBSecondary index: 36,3 GB112 GBBIGINT, tự tăngBảng: 77,9 GBSecondary index: 44,8 GB123 GBBINARY(16), UUIDv7Bảng: 82,1 GBSecondary index: 61,9 GB144 GBBINARY(16), UUIDv4Bảng: 112 GBSecondary index: 61,9 GB173 GBCHAR(36), UUIDv4Bảng: 126 GBSecondary index: 105 GB231 GB

Với 500 triệu dòng và 4 secondary index, CHAR(36) UUIDv4 tốn khoảng 231 GB, so với 123 GB của BIGINT (gấp 1,9 lần). Một phần do chuỗi dài hơn và bị lặp trong mọi index, phần còn lại do insert ngẫu nhiên làm page chỉ lấp khoảng 69%. UUIDv7 dạng BINARY(16) giữ được thứ tự tăng dần: 144 GB.

Bảng tra nhanh ​

KiểuBytePhạm vi / ghi chú
TINYINT1−128..127, UNSIGNED 0..255. Trạng thái, cờ
SMALLINT2±32.767, UNSIGNED 0..65.535
INT4±2,1 tỷ, UNSIGNED 0..4,29 tỷ
BIGINT8±9,2×10¹⁸. ID lớn, tiền theo đơn vị nhỏ nhất
DECIMAL(p,s)~4 / 9 chữ sốChính xác tuyệt đối
FLOAT / DOUBLE4 / 8Xấp xỉ
DATE31000-01-01..9999-12-31
DATETIME(fsp)5 + 0..3Không đổi múi giờ
TIMESTAMP(fsp)4 + 0..3Đổi sang UTC; tới 2038-01-19 03:14:07 UTC
CHAR(n) utf8mb4n..4nMã ngắn, đều độ dài
VARCHAR(n)độ dài + 1 hoặc 2Đặt n vừa đủ
BINARY(16)16UUID, MD5 dạng nhị phân
TEXT / BLOB / JSONtuỳOff-page; tránh SELECT khi không cần

Ghi nhớ

  1. Nhỏ nhất mà vẫn đủ cho tương lai gần.
  2. Đúng bản chất: số là số, thời gian là thời gian, hash là nhị phân.
  3. NOT NULL khi nghiệp vụ không có “chưa biết”; cẩn thận NOT IN và != với NULL.
  4. Khoá chính nhỏ và tăng dần, vì nó nằm trong mọi index.