0

PostgreSQL Bài 8: Kiểu dữ liệu nâng cao: UUID, INET, MACADDR và khi nào nên dùng

Ở Bài 6 và 7, chúng ta đã đi qua các kiểu dữ liệu và ràng buộc chuẩn hóa. Tuy nhiên, khi hệ thống mở rộng sang kiến trúc phân tán (Microservices, Event-Driven), hoặc các bài toán an ninh mạng, IoT và viễn thông, các kiểu dữ liệu cổ điển như BIGINT hay VARCHAR bộc lộ nhiều điểm hạn chế.

Thay vì lưu địa chỉ IP hay mã định danh dưới dạng chuỗi VARCHAR, PostgreSQL cung cấp các kiểu dữ liệu chuyên biệt: UUID, INET / CIDR và MACADDR. Bài học này sẽ mổ xẻ cơ chế lưu trữ nội bộ, hiệu năng và các cạm bẫy thực chiến khi sử dụng chúng.

1. Kiểu UUID: Mã định danh toàn cục & Bài toán Phân mảnh Index

UUID (Universally Unique Identifier) là chuỗi số 128-bit được thiết kế để đảm bảo tính duy nhất trên toàn cầu mà không cần một máy chủ điều phối trung tâm.

1.1. Bản chất lưu trữ: UUID vs VARCHAR(36)

Nhiều người có thói quen khai báo VARCHAR(36) để lưu UUID (ví dụ: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11).

Tiêu chí Kiểu UUID trong PostgreSQL Kiểu VARCHAR(36)
Dung lượng lưu trữ Đúng 16 bytes (Lưu dưới dạng nhị phân 128-bit) 37 bytes (36 ký tự ASCII + 1 byte header)
Dung lượng Index (B-Tree) Gọn nhẹ hơn 2.3 lần Cồng kềnh, nhanh chóng làm tràn RAM
So sánh / Sắp xếp So sánh số nguyên 128-bit cực nhanh So sánh chuỗi ký tự theo Collation (chậm)
Kiểm tra tính hợp lệ Engine tự động chặn chuỗi sai định dạng Cho phép nhét chuỗi rác bất kỳ

Quy tắc: Luôn dùng kiểu dữ liệu gốc UUID, không bao giờ lưu UUID bằng VARCHAR hoặc TEXT.

1.2. Cạm bẫy hiệu năng của UUID v4 (Random) trên Primary Key

Trong ứng dụng backend, UUID v4 (tạo ngẫu nhiên hoàn toàn) rất được ưa chuộng để tránh lộ số lượng bản ghi (tránh ID tự tăng dễ bị cào dữ liệu qua API). Tuy nhiên, nếu dùng UUID v4 làm Clustered / Primary Key trên bảng hàng chục triệu dòng, bạn sẽ gặp thảm họa hiệu năng mang tên: B-Tree Index Fragmentation & Cache Churn.

Cơ chế chèn B-Tree với ID tuần tự (BIGINT / UUID v7):
Dữ liệu mới luôn ghi vào trang lá cuối cùng (Right-most leaf page)
[Page 1] ──► [Page 2] ──► [Page 3 (Ghi liên tục tại đây)] ──► RAM Cache luôn ấm (Hot Page)

Cơ chế chèn B-Tree với UUID v4 (Ngẫu nhiên hoàn toàn):
Ghi ngẫu nhiên vào bất kỳ trang lá nào trong cây B-Tree
┌──────────────┬──────────────┬──────────────┐
│  [Page 1]    │   [Page 2]   │   [Page 3]   │  ──► Gây Page Split (chẻ trang) liên tục
└──────▲───────┴──────▲───────┴──────▲───────┘
       │              │              │
    UUID_v4        UUID_v4        UUID_v4  ──► Đẩy các trang hữu ích ra khỏi shared_buffers (I/O đĩa tăng vọt)

  • Hậu quả: Khi cây Index phình to vượt quá kích thước RAM (shared_buffers), mỗi thao tác INSERT một bản ghi mới đều phải đọc một trang index ngẫu nhiên từ ổ đĩa lên RAM để chèn, sau đó lại ghi xuống. Tốc độ ghi sẽ sụt giảm từ hàng chục nghìn dòng/giây xuống còn vài trăm dòng/giây.

1.3. Giải pháp hiện đại: Sử dụng UUID v7 (Time-Ordered UUID)

Khắc phục triệt để nhược điểm của v4, UUID v7 tích hợp một timestamp (độ chính xác mili-giây) ở các bit đầu tiên, phần còn lại là entropy ngẫu nhiên.

  • Ưu điểm: Vừa đảm bảo tính duy nhất ngẫu nhiên toàn cục, vừa sắp xếp tuần tự theo thời gian (k-sortable), giúp B-Tree Index ghi tuần tự tương tự như BIGINT.

Cách triển khai UUID trong PostgreSQL:

SQL

-- Cách 1: Sử dụng UUID v4 truyền thống (Cần extension pgcrypto hoặc uuid-ossp trên PG cũ)
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

CREATE TABLE api_keys (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    key_name TEXT NOT NULL
);

-- Cách 2: Tạo hàm sinh UUID v7 thuần trong PostgreSQL
CREATE OR REPLACE FUNCTION generate_uuid_v7() 
RETURNS UUID AS $$
DECLARE
    unix_time_ms BYTEA;
    uuid_bytes BYTEA;
BEGIN
    -- Lấy thời gian hiện tại đổi sang mili-giây (48 bits đầu)
    unix_time_ms := substring(int8send(floor(extract(epoch from clock_timestamp()) * 1000)::bigint) from 3 for 6);
    -- 10 bytes còn lại lấy ngẫu nhiên
    uuid_bytes := unix_time_ms || gen_random_bytes(10);
    -- Set version = 7 (0111) tại byte thứ 7
    uuid_bytes := set_byte(uuid_bytes, 6, (get_byte(uuid_bytes, 6) & 15) | 112);
    -- Set variant = RFC 4122 (10xx) tại byte thứ 9
    uuid_bytes := set_byte(uuid_bytes, 8, (get_byte(uuid_bytes, 8) & 63) | 128);
    RETURN encode(uuid_bytes, 'hex')::uuid;
END;
$$ LANGUAGE plpgsql VOLATILE;

-- Ứng dụng UUID v7 làm Primary Key tối ưu ghi:
CREATE TABLE events (
    id UUID PRIMARY KEY DEFAULT generate_uuid_v7(),
    event_type VARCHAR(50) NOT NULL,
    payload JSONB,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

2. Kiểu dữ liệu Mạng: INET và CIDR

Khi xây dựng các bảng audit_logs, chặn IP (blacklist), hoặc kiểm soát truy cập phân vùng mạng, việc dùng VARCHAR(45) để lưu IP là một sai lầm lớn. PostgreSQL hỗ trợ 2 kiểu dữ liệu mạng nguyên bản: INET và CIDR.

2.1. Phân biệt INET vs CIDR

  • INET (7 hoặc 19 bytes): Dùng để lưu trữ địa chỉ của một host cụ thể, có thể đi kèm subnet mask (ví dụ: 192.168.1.100/24 hoặc 2001:db8::1).

    • Cho phép các bit host nằm ngoài subnet mask có giá trị khác 0.
  • CIDR (7 hoặc 19 bytes): Dùng để lưu trữ toàn bộ một dải mạng.

    • Nghiêm ngặt hơn: Nếu bạn khai báo subnet mask là /24, tất cả các bit phía sau bắt buộc phải bằng 0 (ví dụ 192.168.1.0/24). Nếu bạn nhập 192.168.1.100/24, PostgreSQL sẽ ném lỗi ngay lập tức.

2.2. Các toán tử chuyên biệt cực mạnh của INET

PostgreSQL cho phép bạn thực hiện các phép toán mạng ngay trong câu lệnh SQL mà không cần xử lý bằng code backend:

SQL

CREATE TABLE security_firewall (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    rule_name TEXT NOT NULL,
    blocked_network CIDR NOT NULL
);

INSERT INTO security_firewall (rule_name, blocked_network) VALUES
('Block Local Subnet', '192.168.1.0/24'),
('Block Corporate Range', '10.200.0.0/16');

Truy vấn kiểm tra xem một IP có thuộc dải mạng bị chặn hay không bằng toán tử chứa << (is contained by) hoặc >> (contains):

SQL

-- Kiểm tra xem IP 192.168.1.55 có nằm trong danh sách đen không:
SELECT * FROM security_firewall 
WHERE '192.168.1.55'::INET << blocked_network;

-- Lấy netmask, broadcast và địa chỉ IP gốc:
SELECT 
    ip,
    host(ip) AS host_address,     -- Lấy dạng chuỗi thuần không kèm subnet mask
    netmask(ip) AS subnet_mask,   -- Trích xuất Netmask
    broadcast(ip) AS broadcast_ip -- Tính địa chỉ Broadcast tự động
FROM (VALUES ('192.168.1.50/24'::INET)) AS t(ip);

Kết quả trả về:

Plaintext

       ip        | host_address |  subnet_mask  |  broadcast_ip   
-----------------+--------------+---------------+-----------------
 192.168.1.50/24 | 192.168.1.50 | 255.255.255.0 | 192.168.1.255/24

Hỗ trợ Index vượt trội: Bạn có thể đánh index kiểu GiST hoặc SP-GiST trên cột INET để tối ưu các truy vấn kiểm tra dải mạng (<<, >>, &&), đem lại tốc độ tra cứu tức thì trên hàng triệu dòng log.

3. Kiểu địa chỉ phần cứng: MACADDR và MACADDR8

Trong các hệ thống quản lý thiết bị, viễn thông, IoT hoặc theo dõi thiết bị mạng (DHCP server, Network switches):

  • MACADDR (6 bytes): Lưu địa chỉ MAC phần cứng chuẩn EUI-48 (ví dụ: 08:00:2b:01:02:03).

  • MACADDR8 (8 bytes): Lưu địa chỉ MAC chuẩn EUI-64 (dùng cho kiến trúc mạng thế hệ mới, Zigbee, IPv6 interface ID).

Ưu điểm vượt trội so với VARCHAR(17):

  1. Tiết kiệm dung lượng: Chỉ tốn đúng 6 bytes thay vì 18 bytes khi dùng VARCHAR(17).

  2. Khả năng chuẩn hóa thông minh: PostgreSQL chấp nhận nhiều định dạng nhập vào khác nhau và tự động chuẩn hóa về một định dạng thống nhất:

SQL

CREATE TABLE network_devices (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    device_name VARCHAR(100),
    mac_address MACADDR NOT NULL UNIQUE
);

-- Cả 4 định dạng sau đều được chấp nhận và lưu cùng một giá trị 6-byte:
INSERT INTO network_devices (device_name, mac_address) VALUES
('Switch Core 01', '08:00:2b:01:02:03'),
('Sensor Temp 02', '08-00-2b-01-02-04'),
('Gateway 03',     '08002b:010205'),
('Router 04',      '0800.2b01.0206');

SELECT * FROM network_devices;

Kết quả:

Plaintext

 id |  device_name   |    mac_address    
----+----------------+-------------------
  1 | Switch Core 01 | 08:00:2b:01:02:03
  2 | Sensor Temp 02 | 08:00:2b:01:02:04
  3 | Gateway 03     | 08:00:2b:01:02:05
  4 | Router 04      | 08:00:2b:01:02:06

4. Bảng tổng kết kịch bản lựa chọn

Kiểu dữ liệu Dung lượng Trường hợp nên dùng Tuyệt đối tránh
UUID (v7) 16 bytes Primary Key cho Microservices, Distributed Systems, ID public qua API. Dùng UUID v4 làm Clustered/PK trên bảng ghi dữ liệu tần suất cao.
INET 7 hoặc 19 bytes Lưu IP Client, User Activity Logs, Kiểm tra IP có nằm trong Subnet hay không. Dùng VARCHAR(45) (không hỗ trợ toán tử subnet, tốn RAM).
CIDR 7 hoặc 19 bytes Thiết lập dải mạng, Rule tường lửa, cấu hình phân vùng mạng LAN/VPC. Lưu IP của một thiết bị đơn lẻ mà không có ý nghĩa dải mạng.
MACADDR 6 bytes Quản lý thiết bị IoT, Inventory hạ tầng mạng, bảng ARP/DHCP leases. Dùng VARCHAR(17).

5. Tóm tắt & Bài tiếp theo

  • Kiểu UUID lưu trữ 128-bit nhị phân (16 bytes), nhỏ hơn và so sánh nhanh hơn gấp nhiều lần VARCHAR(36).

  • Tránh dùng UUID v4 cho Primary Key của bảng lớn do gây phân mảnh B-Tree; hãy thay thế bằng UUID v7 có tích hợp thời gian.

  • INET và CIDR cung cấp các toán tử mạng nguyên bản (<<, >>) và hỗ trợ index chuyên dụng để lọc IP tức thì.

  • MACADDR tự động chuẩn hóa định dạng địa chỉ vật lý và chỉ tiêu tốn 6 bytes lưu trữ.

Bài 9 xem tiếp: Xử lý chuỗi và Văn bản: So sánh VARCHAR, TEXT, CHAR và cách hoạt động của TOAST — chúng ta sẽ giải mã cơ chế TOAST (The Oversized-Attribute Storage Technique), cách PostgreSQL "lách" giới hạn trang 8KB để lưu trữ các văn bản hoặc tệp nhị phân lên tới 1GB mà không làm chậm việc quét bảng.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí