blog.dopana

Back

Khi mô hình hóa một tập nhỏ các giá trị định sẵn trong PostgreSQL, bạn có vài lựa chọn:

  • Kiểu ENUM gốc của PostgreSQL
  • Cột SMALLINT với ràng buộc CHECK
  • Cột SMALLINT với khóa ngoại tham chiếu bảng tra cứu
  • Cột SMALLINT thuần chỉ được kiểm tra bởi ứng dụng

Mỗi cách có đánh đổi khác nhau về type safety, linh hoạt, hiệu năng và bảo trì.

Bài viết này so sánh chúng và giải thích khi nào nên dùng cách nào.

Vấn đề#

Giả sử đơn hàng có một trong ba trạng thái:

0 = Đang chờ
1 = Đã thanh toán
2 = Đã hủy
text

Ta muốn lưu trạng thái hiệu quả đồng thời ngăn các giá trị không hợp lệ như:

99
-1
1234
text

Câu hỏi là: nên mô hình hóa điều này trong PostgreSQL như thế nào?

Cách 1: SMALLINT với ràng buộc CHECK#

Cách đơn giản nhất là lưu giá trị enum dưới dạng SMALLINT và giới hạn giá trị cho phép bằng ràng buộc CHECK.

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,

    status SMALLINT NOT NULL
        CHECK (status IN (0, 1, 2))
);
sql

Ứng dụng định nghĩa ý nghĩa:

0 = Đang chờ
1 = Đã thanh toán
2 = Đã hủy
text

Giá trị không hợp lệ bị PostgreSQL từ chối:

INSERT INTO orders (status)
VALUES (99);
sql
ERROR: new row violates check constraint
text

Ưu điểm#

  • Đơn giản
  • Hiệu quả
  • Được cơ sở dữ liệu đảm bảo hoàn toàn
  • Không cần bảng phụ
  • Dễ truy vấn
SELECT *
FROM orders
WHERE status = 1;
sql

Nhược điểm#

Ý nghĩa của giá trị không hiện ra từ chính cột:

status = 1
sql

1 có nghĩa là gì?

Mã ứng dụng hoặc tài liệu phải định nghĩa điều đó.

Một vấn đề khác là thay đổi giá trị cho phép yêu cầu sửa ràng buộc bảng:

ALTER TABLE orders
DROP CONSTRAINT orders_status_check;

ALTER TABLE orders
ADD CONSTRAINT orders_status_check
CHECK (status IN (0, 1, 2, 3));
sql

Tuy nhiên, với một tập giá trị nhỏ và ổn định, đây thường là giải pháp tuyệt vời.

Cách 2: SMALLINT không có ràng buộc CHECK#

Bạn cũng có thể định nghĩa đơn giản:

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    status SMALLINT NOT NULL
);
sql

Ứng dụng định nghĩa các giá trị:

0 = Đang chờ
1 = Đã thanh toán
2 = Đã hủy
text

Đây là schema cơ sở dữ liệu đơn giản nhất.

Ưu điểm#

  • Schema tối thiểu
  • Không cần bảo trì ràng buộc
  • Rất linh hoạt
  • Kích thước lưu trữ rất nhỏ

Nhược điểm#

Cơ sở dữ liệu chấp nhận mọi giá trị SMALLINT hợp lệ:

INSERT INTO orders (status)
VALUES (32767);
sql

Câu lệnh này thành công.

Cơ sở dữ liệu không biết liệu 32767 có phải là trạng thái kinh doanh hợp lệ hay không.

Do đó, cách này hoàn toàn dựa vào kiểm tra ở tầng ứng dụng.

Điều đó có thể chấp nhận được khi:

  • Chỉ có một ứng dụng ghi vào cơ sở dữ liệu
  • Cơ sở dữ liệu được xem như chi tiết triển khai nội bộ
  • Việc kiểm tra được tập trung hóa
  • Bạn cố tình muốn schema cơ sở dữ liệu duy trì tính dễ dãi

Tuy nhiên, nó có thể trở nên nguy hiểm khi nhiều dịch vụ, script hoặc quản trị viên ghi trực tiếp vào cơ sở dữ liệu.

Cách 3: SMALLINT với khóa ngoại#

Một lựa chọn mạnh mẽ là dùng bảng tra cứu.

CREATE TABLE order_status (
    id SMALLINT PRIMARY KEY,
    name TEXT NOT NULL UNIQUE
);
sql

Thêm các trạng thái hợp lệ:

INSERT INTO order_status (id, name)
VALUES
    (0, 'pending'),
    (1, 'paid'),
    (2, 'cancelled');
sql

Sau đó tham chiếu bảng:

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,

    status SMALLINT NOT NULL
        REFERENCES order_status(id)
);
sql

PostgreSQL ngăn các giá trị không hợp lệ:

INSERT INTO orders (status)
VALUES (99);
sql

Câu lệnh thất bại vì 99 không tồn tại trong order_status.

Ưu điểm#

  • Không cần ràng buộc CHECK
  • Kiểm tra ở cấp cơ sở dữ liệu
  • Ý nghĩa từng giá trị được lưu trong cơ sở dữ liệu
  • Dễ thêm siêu dữ liệu
  • Dễ mở rộng

Ví dụ:

CREATE TABLE order_status (
    id SMALLINT PRIMARY KEY,
    name TEXT NOT NULL UNIQUE,
    description TEXT,
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    sort_order SMALLINT NOT NULL
);
sql

Bảng trạng thái giờ có thể chứa siêu dữ liệu nghiệp vụ.

Nhược điểm#

  • Cần thêm một bảng phụ
  • Cần một khóa ngoại
  • Schema phức tạp hơn một chút
  • Cần quản lý dữ liệu tham chiếu

Đây thường là lựa chọn tốt nhất khi bạn muốn biểu diễn SMALLINT nhưng vẫn muốn kiểm tra mạnh ở cấp cơ sở dữ liệu mà không dùng ràng buộc CHECK.

Cách 4: ENUM gốc của PostgreSQL#

PostgreSQL cũng cung cấp kiểu enum gốc:

CREATE TYPE order_status AS ENUM (
    'pending',
    'paid',
    'cancelled'
);
sql

Sau đó:

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    status order_status NOT NULL
);
sql

Cơ sở dữ liệu hiểu trực tiếp các giá trị hợp lệ:

INSERT INTO orders (status)
VALUES ('paid');
sql

Giá trị không hợp lệ bị từ chối:

INSERT INTO orders (status)
VALUES ('unknown');
sql

Ưu điểm#

  • Type safety mạnh
  • Rất dễ đọc
  • Kiểm tra bởi cơ sở dữ liệu
  • Không cần ánh xạ số

Câu này dễ hiểu:

status = 'paid'
sql

so với:

status = 1
sql

Nhược điểm#

Enum của PostgreSQL kém linh hoạt hơn khi tập giá trị thay đổi.

Thêm giá trị thì được:

ALTER TYPE order_status
ADD VALUE 'refunded';
sql

Nhưng xóa hoặc đổi tên giá trị enum thường phức tạp hơn so với sửa dòng trong bảng tra cứu.

Điều này khiến enum gốc phù hợp cho các giá trị thực sự là một phần của mô hình miền ổn định của cơ sở dữ liệu.

So sánh#

Cách tiếp cậnKiểm tra DBDùng SMALLINTDễ thay đổiLưu ý nghĩa trong DBĐộ phức tạp
SMALLINT thuầnKhôngRất dễKhôngThấp
SMALLINT + CHECKTrung bìnhMột phầnThấp
SMALLINT + khóa ngoạiDễTrung bình
PostgreSQL ENUMKhôngTrung bìnhThấp

Nên chọn cách nào?#

Dùng SMALLINT + CHECK cho tập nhỏ, ổn định#

status SMALLINT NOT NULL
    CHECK (status IN (0, 1, 2))
sql

Đây là lựa chọn mặc định tốt khi:

  • Giá trị ổn định
  • Bạn muốn lưu trữ nhỏ gọn
  • Bạn muốn kiểm tra ở cơ sở dữ liệu
  • Bạn không cần thêm siêu dữ liệu

Dùng SMALLINT + khóa ngoại khi cần mở rộng#

status SMALLINT NOT NULL
    REFERENCES order_status(id)
sql

Chọn cách này khi:

  • Trạng thái có thể thay đổi
  • Bạn cần mô tả hoặc siêu dữ liệu
  • Bạn muốn bật/tắt trạng thái
  • Bạn muốn tránh ràng buộc CHECK
  • Giá trị được quản lý như dữ liệu tham chiếu

Đây thường là thiết kế linh hoạt nhất.

Dùng PostgreSQL ENUM cho kiểu miền ổn định#

CREATE TYPE order_status AS ENUM (
    'pending',
    'paid',
    'cancelled'
);
sql

Đây là lựa chọn tốt khi:

  • Tập giá trị nhỏ
  • Giá trị là nền tảng cho miền
  • Bạn muốn SQL dễ đọc
  • Bạn không mong đợi thay đổi thường xuyên

Dùng SMALLINT thuần chỉ khi kiểm tra ứng dụng là chủ ý#

status SMALLINT NOT NULL
sql

Cách này hoàn toàn có thể chấp nhận được, nhưng cần hiểu rõ sự đánh đổi:

Cơ sở dữ liệu sẽ không biết giá trị nào là hợp lệ.

Cách này hoạt động tốt nhất khi kiểm tra được tập trung hóa trong mã ứng dụng và việc ghi trực tiếp vào cơ sở dữ liệu được kiểm soát chặt chẽ.

Khuyến nghị thực tế#

Với hầu hết ứng dụng, tôi chọn một trong hai thiết kế sau:

Giá trị ổn định#

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    status SMALLINT NOT NULL
        CHECK (status IN (0, 1, 2))
);
sql

Giá trị động hoặc giàu siêu dữ liệu#

CREATE TABLE order_status (
    id SMALLINT PRIMARY KEY,
    name TEXT NOT NULL UNIQUE
);

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    status SMALLINT NOT NULL
        REFERENCES order_status(id)
);
sql

Điểm khác biệt chính rất đơn giản:

Dùng ràng buộc CHECK khi giá trị hợp lệ là bất biến của bảng. Dùng bảng tra cứu khi giá trị hợp lệ là dữ liệu có thể tiến hóa. Dùng ENUM của PostgreSQL khi giá trị là kiểu cơ sở dữ liệu ổn định.

Và nếu bạn chọn SMALLINT thuần không có ràng buộc cơ sở dữ liệu, hãy làm điều đó một cách có chủ đích — bởi vì bạn đang chuyển trách nhiệm về tính toàn vẹn dữ liệu từ PostgreSQL sang ứng dụng.

Tài liệu tham khảo#