Đi đến nội dung
·36 phút đọc

Thiết Kế Schema Database: Các Pattern Trường Tồn Theo Thời Gian

Quy tắc normalization, quy ước đặt tên, soft deletes, audit trails, pattern multi-tenancy, chiến lược versioning, và các quyết định schema tôi đã hối hận. Tập trung vào PostgreSQL.

Chia sẻ:X / TwitterLinkedIn

Schema là một hợp đồng với chính bạn trong tương lai. Mỗi cột bạn thêm vào, mỗi constraint bạn bỏ qua, mỗi câu "chúng ta sẽ dọn dẹp sau" — tất cả đều tích lũy. Tôi đã làm việc trên các hệ thống mà một quyết định schema tồi từ ba năm trước giờ tiêu tốn của team cả một sprint mỗi quý để xử lý workaround.

Database tồn tại lâu hơn mọi thứ. Frontend framework sẽ thay đổi. API layer sẽ được viết lại. Chiến lược triển khai sẽ phát triển. Nhưng dữ liệu? Dữ liệu ở lại. Và hình dạng bạn đã cho nó từ ngày đầu tiên sẽ theo bạn mãi mãi, vì migrate một bảng có 200 triệu dòng không giống như refactor một component React.

Đây là những gì tôi đã học được về việc đưa ra các quyết định schema không ám ảnh bạn. Tập trung vào PostgreSQL, vì đó là thứ tôi sử dụng và tin tưởng, nhưng hầu hết các pattern này áp dụng cho bất kỳ cơ sở dữ liệu quan hệ nào.

Quy Ước Đặt Tên: Thứ Nhàm Chán Nhưng Quan Trọng Nhất#

Tôi đã thấy nhiều cuộc tranh cãi về quy ước đặt tên hơn cả các quyết định kiến trúc thực sự. Đây là những gì tôi đã chọn sau nhiều năm chuyển đổi giữa các dự án:

Snake_case cho mọi thứ. Bảng, cột, index, constraint. Không camelCase, không PascalCase. PostgreSQL tự động chuyển identifier không có dấu ngoặc kép sang chữ thường, nên createdAt sẽ thành createdat trừ khi bạn dùng dấu ngoặc kép ở mọi nơi. Đừng chống lại database.

Tên bảng số nhiều. Một bảng chứa nhiều dòng. users không phải user. orders không phải order. order_items không phải order_item. Điều này đọc tự nhiên trong câu truy vấn: SELECT * FROM users WHERE ... — bạn đang chọn từ một tập hợp.

Hậu tố _id cho foreign key. user_id, order_id, tenant_id. Primary key chỉ đơn giản là id. Điều này rõ ràng. Khi bạn join bảng, users.id = orders.user_id đọc như ngôn ngữ tự nhiên.

Hậu tố _at cho timestamp. created_at, updated_at, deleted_at, published_at, expires_at. Bạn luôn biết đó là một thời điểm.

Tiền tố is_ cho boolean. is_active, is_verified, is_published. Một số người dùng has_ cho boolean sở hữu (has_mfa_enabled), nhưng tôi giữ đơn giản và dùng is_ cho mọi thứ.

Hậu tố _count cho counter denormalize. comment_count, follower_count. Làm rõ đây là con số cached, không phải phép tính trực tiếp.

Vấn đề là thế này: tính nhất quán thắng sự hoàn hảo. Tôi đã thấy các team dành cả tuần tranh luận nên là email_address hay email hay email_addr. Chỉ cần chọn một pattern và áp dụng nhất quán. Quy ước đặt tên tồi nhất là quy ước được áp dụng không nhất quán.

sql
-- Tốt: nhất quán, dễ đọc, không bất ngờ
CREATE TABLE users (
    id              BIGSERIAL PRIMARY KEY,
    email           TEXT NOT NULL,
    display_name    TEXT NOT NULL,
    password_hash   TEXT NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT true,
    is_verified     BOOLEAN NOT NULL DEFAULT false,
    login_count     INTEGER NOT NULL DEFAULT 0,
    last_login_at   TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Xấu: casing không nhất quán, quy ước lẫn lộn, tên mơ hồ
CREATE TABLE User (
    UserID          SERIAL PRIMARY KEY,
    EmailAddress    VARCHAR(255),
    name            VARCHAR(100),
    passwd          VARCHAR(255),
    active          BOOLEAN,
    verified        INT,             -- đây là 0/1? hay mức xác minh?
    lastLogin       TIMESTAMP,       -- camelCase trong thế giới snake_case
    created         TIMESTAMP        -- created cái gì? khi nào?
);

Thêm một điều nữa: đừng bao giờ dùng từ khóa dành riêng làm tên cột. type, order, user, group, table — tất cả đều hoạt động nếu bạn đặt trong dấu ngoặc kép, nhưng chúng sẽ gây rắc rối trong ORM, query builder, và mọi công cụ tạo SQL động. Dùng kind thay vì type, sort_order thay vì order. Bản thân bạn trong tương lai sẽ cảm ơn.

Các Cột Chuẩn: Mỗi Bảng Đều Có#

Mỗi bảng trong schema của tôi đều bắt đầu với cùng một bộ khung. Không ngoại lệ.

sql
CREATE TABLE things (
    id          BIGSERIAL PRIMARY KEY,
    -- ... các cột domain ...
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Tranh Luận UUID vs BIGSERIAL#

Đây là một trong những quyết định tạo ra nhiều nhiệt hơn ánh sáng. Đây là sự đánh đổi thực tế:

BIGSERIAL (số nguyên tự tăng):

  • 8 byte. Gọn nhẹ. Index và join nhanh.
  • Có thứ tự — bạn có thể sort theo id để lấy thứ tự chèn.
  • Dự đoán được — người dùng có thể đoán ID khác bằng cách tăng ID của mình.
  • Không hoạt động tốt trong hệ thống phân tán (cần đồng bộ hóa).

UUID v4 (ngẫu nhiên):

  • 16 byte. Index lớn hơn, join chậm hơn (nhưng hiếm khi là nút thắt cổ chai).
  • Không dự đoán được — không rò rỉ thông tin.
  • Hoạt động trong hệ thống phân tán mà không cần đồng bộ hóa.
  • Locality index tệ — UUID ngẫu nhiên làm phân mảnh B-tree index.

UUID v7 (sắp xếp theo thời gian, RFC 9562):

  • 16 byte, nhưng sắp xếp theo thời gian nên locality B-tree tuyệt vời.
  • Đủ không dự đoán được cho sử dụng bên ngoài.
  • Tương đối mới, nhưng PostgreSQL 17+ có gen_random_uuid() và bạn có thể dùng uuid_generate_v7() với extension.

Quan điểm hiện tại của tôi: BIGSERIAL cho bảng nội bộ, UUID v7 cho bất kỳ thứ gì lộ ra bên ngoài. Nếu một ID xuất hiện trong URL, phản hồi API, hay webhook payload, dùng UUID. Nếu đó là bảng join thuần túy mà người dùng không bao giờ thấy, BIGSERIAL là đủ.

sql
-- Cho resource hướng API
CREATE TABLE projects (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    owner_id    UUID NOT NULL REFERENCES users(id),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Cho bảng mapping nội bộ
CREATE TABLE project_members (
    id          BIGSERIAL PRIMARY KEY,
    project_id  UUID NOT NULL REFERENCES projects(id),
    user_id     UUID NOT NULL REFERENCES users(id),
    role        TEXT NOT NULL DEFAULT 'member',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (project_id, user_id)
);

Timestamp: Luôn Dùng TIMESTAMPTZ#

Dùng TIMESTAMPTZ, không phải TIMESTAMP. Phiên bản "TZ" lưu giá trị ở UTC và chuyển đổi khi đọc dựa trên timezone của session. Phiên bản không có TZ lưu bất cứ gì bạn đưa vào mà không có ngữ cảnh timezone — nghĩa là nếu hai server ở các timezone khác nhau ghi vào cùng một bảng, bạn sẽ bị hỏng dữ liệu âm thầm.

sql
-- Luôn dùng cái này
created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
 
-- Đừng bao giờ dùng cái này
created_at  TIMESTAMP NOT NULL DEFAULT NOW()

Trigger updated_at#

PostgreSQL không có ON UPDATE CURRENT_TIMESTAMP như MySQL. Bạn cần một trigger:

sql
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
-- Áp dụng cho mỗi bảng có updated_at
CREATE TRIGGER set_updated_at
    BEFORE UPDATE ON users
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();
 
CREATE TRIGGER set_updated_at
    BEFORE UPDATE ON projects
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

Đúng, bạn cần một trigger cho mỗi bảng. Đúng, nó nhàm chán. Viết một helper migration tạo trigger tự động khi bạn thêm bảng. Nó đáng giá, vì phương án thay thế là phải nhớ đặt updated_at = NOW() trong mọi câu UPDATE trên toàn bộ ứng dụng — và bạn sẽ quên.

Normalization: Khi Nào Phá Luật#

Mỗi khóa học CS đều dạy normalization đến 3NF (Third Normal Form). Các quy tắc là:

  • 1NF: Mỗi cột chứa một giá trị nguyên tử duy nhất. Không có array, không có danh sách phân tách bằng dấu phẩy.
  • 2NF: Mỗi cột không phải key phụ thuộc vào toàn bộ primary key (liên quan đến composite key).
  • 3NF: Không có phụ thuộc bắc cầu. Nếu cột A xác định cột B, và B xác định C, thì C không nên ở cùng bảng với A.

Trong thực tế, 3NF là điểm tối ưu cho các bảng giao dịch. Bạn nên bắt đầu từ đó và chỉ đi chệch khi có lý do cụ thể, đo lường được.

Đây là một hệ thống đặt hàng được normalize đúng cách:

sql
CREATE TABLE customers (
    id              BIGSERIAL PRIMARY KEY,
    email           TEXT NOT NULL UNIQUE,
    display_name    TEXT NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE products (
    id              BIGSERIAL PRIMARY KEY,
    sku             TEXT NOT NULL UNIQUE,
    name            TEXT NOT NULL,
    price_cents     INTEGER NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT true,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE orders (
    id              BIGSERIAL PRIMARY KEY,
    customer_id     BIGINT NOT NULL REFERENCES customers(id),
    status          TEXT NOT NULL DEFAULT 'pending'
                    CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled')),
    total_cents     INTEGER NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE order_items (
    id              BIGSERIAL PRIMARY KEY,
    order_id        BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id      BIGINT NOT NULL REFERENCES products(id),
    quantity        INTEGER NOT NULL CHECK (quantity > 0),
    unit_price_cents INTEGER NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Lưu ý unit_price_cents trên order_items. Đây là denormalization có chủ đích. Chúng ta snapshot giá tại thời điểm đặt hàng, vì giá sản phẩm có thể thay đổi sau đó. Đây là một trong những lý do phổ biến và đúng đắn nhất để denormalize.

Khi Nào Denormalization Là Lựa Chọn Đúng#

Bảng báo cáo. Nếu dashboard phân tích cần join 8 bảng để render, hãy tạo bảng báo cáo denormalize và populate bằng background job. Schema giao dịch của bạn giữ sạch, và truy vấn báo cáo giữ nhanh.

sql
-- Bảng báo cáo denormalize, được populate bởi cron job
CREATE TABLE order_reports (
    id                  BIGSERIAL PRIMARY KEY,
    order_id            BIGINT NOT NULL,
    customer_email      TEXT NOT NULL,
    customer_name       TEXT NOT NULL,
    product_names       TEXT[] NOT NULL,        -- array ổn ở đây
    item_count          INTEGER NOT NULL,
    total_cents         INTEGER NOT NULL,
    order_status        TEXT NOT NULL,
    ordered_at          TIMESTAMPTZ NOT NULL,
    report_generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Materialized view. MATERIALIZED VIEW của PostgreSQL bị đánh giá thấp. Nó là snapshot denormalize mà bạn làm mới theo yêu cầu. Hoàn hảo cho dashboard.

sql
CREATE MATERIALIZED VIEW monthly_revenue AS
SELECT
    DATE_TRUNC('month', o.created_at) AS month,
    COUNT(DISTINCT o.id) AS order_count,
    SUM(o.total_cents) AS revenue_cents,
    COUNT(DISTINCT o.customer_id) AS unique_customers
FROM orders o
WHERE o.status != 'cancelled'
GROUP BY DATE_TRUNC('month', o.created_at)
ORDER BY month DESC;
 
-- Làm mới hàng đêm
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue;

Cột JSON cho thuộc tính động. Khi các dòng khác nhau cần các trường khác nhau — biến thể sản phẩm, form submission, tùy chỉnh người dùng — một cột JSONB thường tốt hơn bảng EAV (Entity-Attribute-Value) ác mộng. Sẽ nói thêm ở phần sau.

Quy Tắc Tôi Không Bao Giờ Phá#

Đừng bao giờ denormalize bảng nguồn chân lý. Denormalize các bản sao, snapshot, báo cáo, và cache. Dữ liệu chính thức giữ nguyên normalize. Khi bản sao denormalize trở nên cũ hoặc hỏng (và nó sẽ như vậy), bạn xây dựng lại từ nguồn đã normalize.

Foreign Key & Constraint: Code Tốt Nhất Bạn Không Bao Giờ Cần Viết#

Tôi đã nghe mọi lý do để bỏ qua foreign key. "Chúng làm chậm ghi." "Chúng tôi enforce trong ứng dụng." "Chúng tôi cần sự linh hoạt."

Tất cả đều sai.

Foreign key là thứ có tác động lớn nhất bạn có thể thêm vào schema. Chúng ngăn chặn cả một danh mục bug mà không có lượng code ứng dụng nào có thể bắt được — race condition, partial failure, orphaned row từ transaction thất bại. Foreign key là sự đảm bảo từ chính database engine, được enforce ở tầng storage. Hook beforeDelete trong ORM của bạn chỉ là một gợi ý.

sql
-- Luôn làm điều này
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_customer
    FOREIGN KEY (customer_id) REFERENCES customers(id);
 
-- Phương án "chúng tôi sẽ xử lý trong code":
-- Hy vọng. Hy vọng không phải là chiến lược.

Chiến Lược ON DELETE#

Đây là phần cần suy nghĩ kỹ. Điều gì xảy ra khi bạn xóa một dòng cha?

RESTRICT (mặc định): Lệnh xóa thất bại nếu có dòng con. Dùng cho hầu hết các mối quan hệ. Bạn không thể xóa một khách hàng có đơn hàng — đó là logic nghiệp vụ được mã hóa trong schema.

sql
-- Khách hàng không thể bị xóa khi vẫn có đơn hàng
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_customer
    FOREIGN KEY (customer_id) REFERENCES customers(id)
    ON DELETE RESTRICT;

CASCADE: Các dòng con tự động bị xóa. Dùng tiết kiệm và có chủ đích. Tốt cho mối quan hệ "thuộc về" khi thực thể con không có ý nghĩa nếu thiếu cha.

sql
-- Xóa đơn hàng sẽ xóa các mặt hàng — chúng vô nghĩa khi đứng một mình
ALTER TABLE order_items
    ADD CONSTRAINT fk_order_items_order
    FOREIGN KEY (order_id) REFERENCES orders(id)
    ON DELETE CASCADE;
 
-- Xóa dự án sẽ xóa các thành viên
ALTER TABLE project_members
    ADD CONSTRAINT fk_project_members_project
    FOREIGN KEY (project_id) REFERENCES projects(id)
    ON DELETE CASCADE;

SET NULL: Cột foreign key được đặt thành NULL. Dùng khi mối quan hệ là tùy chọn và dòng con vẫn có ý nghĩa khi đứng một mình.

sql
-- Nếu quản lý rời đi, nhân viên vẫn tồn tại — chỉ chưa được gán
ALTER TABLE employees
    ADD CONSTRAINT fk_employees_manager
    FOREIGN KEY (manager_id) REFERENCES employees(id)
    ON DELETE SET NULL;

Nguyên tắc kinh nghiệm của tôi: mặc định là RESTRICT, dùng CASCADE cho mối quan hệ composition, dùng SET NULL cho liên kết tùy chọn. Nếu không chắc, RESTRICT luôn là lựa chọn an toàn — nới lỏng constraint dễ hơn nhiều so với phục hồi dữ liệu đã xóa.

Check Constraint: Bảo Hiểm Giá Rẻ#

Check constraint gần như không tốn gì khi ghi và ngăn dữ liệu rác xâm nhập hệ thống mãi mãi:

sql
CREATE TABLE products (
    id              BIGSERIAL PRIMARY KEY,
    name            TEXT NOT NULL CHECK (LENGTH(name) > 0),
    price_cents     INTEGER NOT NULL CHECK (price_cents >= 0),
    discount_pct    NUMERIC(5,2) CHECK (discount_pct BETWEEN 0 AND 100),
    weight_kg       NUMERIC(10,3) CHECK (weight_kg > 0),
    status          TEXT NOT NULL CHECK (status IN ('draft', 'active', 'archived')),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Mỗi check constraint bạn thêm là một bug validation bớt đi mà bạn sẽ không bao giờ phải debug trên production. Database là tuyến phòng thủ cuối cùng. Hãy tận dụng nó.

Unique Constraint và Partial Unique Index#

Unique constraint khá đơn giản cho trường hợp đơn giản:

sql
ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email);

Nhưng chúng trở nên thú vị với partial index — unique constraint chỉ áp dụng cho một số dòng nhất định:

sql
-- Chỉ một subscription active cho mỗi user (nhưng họ có thể có nhiều subscription đã hủy)
CREATE UNIQUE INDEX uq_active_subscription
    ON subscriptions (user_id)
    WHERE status = 'active';
 
-- Chỉ một địa chỉ chính cho mỗi user
CREATE UNIQUE INDEX uq_primary_address
    ON addresses (user_id)
    WHERE is_primary = true;

Đây là một trong những tính năng killer của PostgreSQL. Hãy tận dụng nó.

Soft Delete: Pattern Ai Cũng Thích Ghét#

Soft delete đơn giản về khái niệm: thay vì DELETE FROM users WHERE id = 42, bạn làm UPDATE users SET deleted_at = NOW() WHERE id = 42. Dòng vẫn ở trong database nhưng bị lọc ra khỏi các truy vấn thông thường.

sql
CREATE TABLE users (
    id          BIGSERIAL PRIMARY KEY,
    email       TEXT NOT NULL,
    display_name TEXT NOT NULL,
    is_active   BOOLEAN NOT NULL DEFAULT true,
    deleted_at  TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Partial index: lọc bỏ dòng đã xóa một cách hiệu quả
CREATE INDEX idx_users_active ON users (email) WHERE deleted_at IS NULL;

Mọi truy vấn trong ứng dụng giờ đây cần WHERE deleted_at IS NULL. Mọi. Truy. Vấn. ORM giúp phần nào (Prisma có middleware, Drizzle có .where(isNull(deletedAt))), nhưng đó là một khoản thuế trên mỗi thao tác đọc. Bỏ sót một lần và bạn đang hiển thị dữ liệu "đã xóa" cho người dùng.

Vấn Đề Unique Constraint#

Đây là nơi soft delete trở nên rắc rối. Nếu bạn có UNIQUE (email) và người dùng soft-delete tài khoản, email vẫn còn trong bảng. Họ không thể đăng ký lại với cùng email. Người dùng mới cũng không thể đăng ký với email đó.

Cách khắc phục là partial unique index:

sql
-- Email phải duy nhất, nhưng chỉ trong số user chưa bị xóa
CREATE UNIQUE INDEX uq_users_email_active
    ON users (email)
    WHERE deleted_at IS NULL;

Cách này hoạt động, nhưng giờ bạn cần nhớ pattern này cho mỗi cột unique trên mỗi bảng có soft delete. Có thể quản lý được với kỷ luật, nhưng đó là độ phức tạp bạn đã chọn gánh chịu.

Vấn Đề Foreign Key#

Soft delete tương tác tệ với foreign key. Nếu orders.user_id tham chiếu users.id với ON DELETE RESTRICT, và bạn soft-delete user... không có gì xảy ra. FK không kích hoạt vì bạn thực sự không xóa dòng. User đã "biến mất" theo góc nhìn ứng dụng nhưng vẫn hoàn toàn hiện diện trong database.

Điều này có nghĩa là code ứng dụng phải xử lý trường hợp thực thể được tham chiếu đã bị soft-delete. Mỗi join, mỗi lookup, mỗi khi bạn theo foreign key — bạn cần kiểm tra deleted_at IS NULL trên bảng được tham chiếu nữa. Hoặc không, và ứng dụng hiển thị "Đơn hàng của [user đã xóa]" — đó là bug hay tính năng tùy vào bạn hỏi ai.

Cách Tiếp Cận Của Tôi#

Tôi chỉ dùng soft delete cho các thực thể hướng người dùng mà việc khôi phục là yêu cầu nghiệp vụ — tài khoản user, dự án, tài liệu. Những thứ mà nhân viên hỗ trợ có thể cần khôi phục. Cho mọi thứ khác, tôi dùng hard delete với audit trail (sẽ nói thêm ở phần tiếp).

sql
-- Soft delete: hướng người dùng, có thể khôi phục
CREATE TABLE documents (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title       TEXT NOT NULL,
    content     TEXT NOT NULL,
    owner_id    UUID NOT NULL REFERENCES users(id),
    deleted_at  TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_documents_active ON documents (owner_id) WHERE deleted_at IS NULL;
 
-- Hard delete: nội bộ, không khôi phục từ UI (nhưng có audit)
CREATE TABLE api_keys (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    key_hash    TEXT NOT NULL,
    last_used_at TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Khi xóa, nó biến mất. audit_log ghi lại rằng nó đã tồn tại.

Audit Trail: Biết Gì Đã Thay Đổi và Ai Đã Làm#

Mọi ứng dụng không tầm thường đều cần câu trả lời cho "đã xảy ra gì với bản ghi này?" tại một thời điểm nào đó. Audit trail là cách bạn cung cấp câu trả lời đó mà không cần đào bới log ứng dụng.

Pattern Bảng Audit Riêng#

Cách tiếp cận đơn giản nhất: một bảng audit_log duy nhất ghi lại mọi thay đổi.

sql
CREATE TABLE audit_log (
    id              BIGSERIAL PRIMARY KEY,
    table_name      TEXT NOT NULL,
    record_id       TEXT NOT NULL,          -- text để xử lý cả UUID và BIGINT PK
    operation       TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
    old_values      JSONB,                  -- NULL cho INSERT
    new_values      JSONB,                  -- NULL cho DELETE
    changed_fields  TEXT[],                 -- cột nào thay đổi (cho UPDATE)
    changed_by      UUID REFERENCES users(id),
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    ip_address      INET,
    user_agent      TEXT
);
 
CREATE INDEX idx_audit_log_table_record ON audit_log (table_name, record_id);
CREATE INDEX idx_audit_log_changed_by ON audit_log (changed_by);
CREATE INDEX idx_audit_log_changed_at ON audit_log (changed_at);

Thách thức là populate nó. Bạn có thể làm trong code ứng dụng (rõ ràng, nhưng dễ quên) hoặc bằng trigger (tự động, nhưng khó truyền ngữ cảnh như changed_by).

Cách Tiếp Cận Dùng Trigger#

Trigger bắt mọi thay đổi tự động, kể cả từ SQL trực tiếp hoặc thao tác quản trị database:

sql
CREATE OR REPLACE FUNCTION audit_trigger_function()
RETURNS TRIGGER AS $$
DECLARE
    old_data JSONB;
    new_data JSONB;
    changed  TEXT[];
    col      TEXT;
BEGIN
    IF TG_OP = 'DELETE' THEN
        old_data := to_jsonb(OLD);
        INSERT INTO audit_log (table_name, record_id, operation, old_values, changed_at)
        VALUES (TG_TABLE_NAME, OLD.id::TEXT, 'DELETE', old_data, NOW());
        RETURN OLD;
    ELSIF TG_OP = 'UPDATE' THEN
        old_data := to_jsonb(OLD);
        new_data := to_jsonb(NEW);
        -- Tìm các trường đã thay đổi
        FOR col IN SELECT key FROM jsonb_each(new_data)
        LOOP
            IF old_data->col IS DISTINCT FROM new_data->col THEN
                changed := array_append(changed, col);
            END IF;
        END LOOP;
        -- Chỉ ghi log nếu thực sự có gì đó thay đổi
        IF array_length(changed, 1) > 0 THEN
            INSERT INTO audit_log (table_name, record_id, operation, old_values, new_values, changed_fields, changed_at)
            VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'UPDATE', old_data, new_data, changed, NOW());
        END IF;
        RETURN NEW;
    ELSIF TG_OP = 'INSERT' THEN
        new_data := to_jsonb(NEW);
        INSERT INTO audit_log (table_name, record_id, operation, new_values, changed_at)
        VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'INSERT', new_data, NOW());
        RETURN NEW;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;
 
-- Áp dụng cho các bảng bạn muốn audit
CREATE TRIGGER audit_users
    AFTER INSERT OR UPDATE OR DELETE ON users
    FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();
 
CREATE TRIGGER audit_orders
    AFTER INSERT OR UPDATE OR DELETE ON orders
    FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();

Nhược điểm: trigger không biết user ứng dụng nào đã thực hiện thay đổi. Bạn có thể xử lý bằng biến session:

sql
-- Trong ứng dụng, trước khi thực hiện truy vấn:
SET LOCAL app.current_user_id = 'uuid-of-current-user';
 
-- Trong hàm trigger:
changed_by_id := current_setting('app.current_user_id', true)::UUID;

Cách này hoạt động nhưng cảm giác mong manh. Trong thực tế, tôi dùng cách tiếp cận kết hợp: trigger cho việc bắt dữ liệu, và code ứng dụng để thiết lập ngữ cảnh session.

Pattern Bảng Lịch Sử#

Cho các bảng mà bạn cần toàn bộ lịch sử phiên bản (không chỉ "cái gì đã thay đổi" mà "trạng thái tại thời điểm T là gì"), bảng lịch sử riêng biệt sẽ gọn gàng hơn:

sql
CREATE TABLE documents (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title           TEXT NOT NULL,
    content         TEXT NOT NULL,
    version         INTEGER NOT NULL DEFAULT 1,
    owner_id        UUID NOT NULL REFERENCES users(id),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE document_history (
    id              BIGSERIAL PRIMARY KEY,
    document_id     UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    title           TEXT NOT NULL,
    content         TEXT NOT NULL,
    version         INTEGER NOT NULL,
    changed_by      UUID REFERENCES users(id),
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (document_id, version)
);

Trước mỗi lần update documents, copy trạng thái hiện tại sang document_history và tăng version. Giờ bạn có thể tái tạo tài liệu tại bất kỳ thời điểm nào, hiển thị diff giữa các phiên bản, và thậm chí khôi phục phiên bản cũ.

Đánh đổi là dung lượng lưu trữ. Nếu cột content lớn và thay đổi thường xuyên, bảng lịch sử có thể tăng nhanh. Với hầu hết ứng dụng, điều này ổn — lưu trữ rẻ và bạn có thể archive phiên bản cũ sang cold storage nếu cần.

Multi-Tenancy: Ba Cách Tiếp Cận, Chọn Nỗi Đau#

Multi-tenancy là một trong những thứ dễ thêm vào lúc đầu và gần như không thể thêm sau. Nếu có bất kỳ khả năng nào ứng dụng sẽ phục vụ nhiều tổ chức, hãy xây dựng nó từ ngày đầu.

Row-Level: tenant_id Trên Mỗi Bảng#

Cách tiếp cận phổ biến nhất. Mỗi bảng có cột tenant_id, và mỗi truy vấn lọc theo nó.

sql
CREATE TABLE tenants (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    slug        TEXT NOT NULL UNIQUE,
    plan        TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'enterprise')),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE projects (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id),
    name        TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TABLE tasks (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id),
    project_id  UUID NOT NULL REFERENCES projects(id),
    title       TEXT NOT NULL,
    status      TEXT NOT NULL DEFAULT 'todo',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Mỗi index nên bao gồm tenant_id để tối ưu hiệu suất truy vấn
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_tasks_tenant_project ON tasks (tenant_id, project_id);

Rủi ro là rò rỉ dữ liệu — một lần thiếu WHERE tenant_id = ... và bạn đang hiển thị dữ liệu Tenant A cho Tenant B. Row-Level Security (RLS) của PostgreSQL loại bỏ hoàn toàn loại bug này:

sql
-- Bật RLS trên bảng
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
 
-- Tạo policy dựa trên biến session
CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Bắt buộc RLS ngay cả cho owner của bảng
ALTER TABLE projects FORCE ROW LEVEL SECURITY;

Giờ đây, ngay cả khi code ứng dụng quên mệnh đề WHERE tenant_id = ..., PostgreSQL tự động thêm vào. Đây là phòng thủ theo chiều sâu, và đó là một trong những lập luận mạnh nhất cho PostgreSQL trong hệ thống multi-tenant.

sql
-- Trong middleware kết nối của ứng dụng:
SET LOCAL app.current_tenant_id = 'tenant-uuid-here';
 
-- Giờ mọi truy vấn trên bảng có RLS được tự động lọc
SELECT * FROM projects;
-- PostgreSQL tự thêm: WHERE tenant_id = 'tenant-uuid-here'

Ưu điểm: Một database duy nhất, vận hành đơn giản, sử dụng tài nguyên hiệu quả, dễ truy vấn liên tenant cho admin. Nhược điểm: Cần kỷ luật (hoặc RLS), mỗi truy vấn đụng tenant_id, khó cho tenant backup/restore riêng.

Schema-Per-Tenant#

Mỗi tenant có schema PostgreSQL riêng. Tất cả schema chia sẻ cùng database, nhưng các bảng được cách ly bởi namespace.

sql
-- Tạo schema cho mỗi tenant
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;
 
-- Bảng nằm trong schema của tenant
CREATE TABLE tenant_acme.projects (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Đặt search_path cho tenant hiện tại
SET search_path TO tenant_acme, public;
 
-- Giờ truy vấn không cần tên đầy đủ sẽ truy cập schema của tenant
SELECT * FROM projects;  -- truy vấn tenant_acme.projects

Ưu điểm: Cách ly mạnh, không rủi ro rò rỉ dữ liệu liên tenant, dễ backup/restore theo tenant, tenant có thể có biến thể schema. Nhược điểm: Phức tạp khi migration schema (bạn cần migrate N schema), quản lý connection pool, PostgreSQL có giới hạn thực tế khoảng ~10,000 schema.

Database-Per-Tenant#

Mỗi tenant có database riêng. Cách ly tối đa.

Ưu điểm: Cách ly hoàn toàn, scale độc lập, dễ backup/restore, có thể đặt tenant lớn trên phần cứng chuyên dụng. Nhược điểm: Quản lý kết nối phức tạp, không thể truy vấn liên tenant, migration phải chạy N lần, overhead vận hành đáng kể.

Chọn Cái Nào?#

Cho hầu hết ứng dụng SaaS: bắt đầu với row-level + RLS. Đơn giản nhất để vận hành, và RLS cung cấp cách ly đủ mạnh cho đại đa số trường hợp. Chuyển sang schema-per-tenant chỉ khi bạn có yêu cầu cách ly theo hợp đồng (khách hàng enterprise, ngành được quản lý). Database-per-tenant là cho khi bạn tuyệt đối phải đảm bảo cách ly vật lý — và ngay cả khi đó, hãy xem xét managed database nơi gánh nặng vận hành được xử lý cho bạn.

Cột JSON/JSONB: Lối Thoát Hiểm#

JSONB của PostgreSQL thật đáng chú ý. Nó mang lại sự linh hoạt của document-database bên trong hệ thống quan hệ. Nhưng như bất kỳ công cụ mạnh mẽ nào, nó dễ bị lạm dụng.

Khi Nào JSONB Là Lựa Chọn Đúng#

Thuộc tính động do người dùng định nghĩa. Nền tảng thương mại điện tử nơi mỗi danh mục sản phẩm có thuộc tính khác nhau — giày có sizecolor, điện tử có voltagewattage. Thay vì bảng EAV hoặc một cột cho mỗi thuộc tính có thể:

sql
CREATE TABLE products (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            TEXT NOT NULL,
    category_id     UUID NOT NULL REFERENCES categories(id),
    price_cents     INTEGER NOT NULL CHECK (price_cents >= 0),
    attributes      JSONB NOT NULL DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Dữ liệu ví dụ:
-- Giày: {"size": "10", "color": "black", "material": "leather"}
-- Laptop: {"ram_gb": 16, "storage_gb": 512, "screen_inches": 14.0}

Cài đặt và cấu hình ứng dụng. Tùy chỉnh người dùng, feature flag, cài đặt thông báo — những thứ thay đổi hình dạng thường xuyên và không cần tính toàn vẹn quan hệ.

sql
CREATE TABLE user_settings (
    user_id     UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    preferences JSONB NOT NULL DEFAULT '{
        "theme": "system",
        "notifications": {"email": true, "push": false},
        "locale": "en",
        "timezone": "UTC"
    }',
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Webhook payload, phản hồi API, dữ liệu sự kiện. Bất cứ thứ gì có schema được điều khiển bởi hệ thống bên ngoài và có thể thay đổi mà không báo trước.

GIN Index Trên JSONB#

Không có index, truy vấn bên trong JSONB đòi hỏi full table scan. GIN index làm cho nó nhanh:

sql
-- Index toàn bộ cột JSONB (tốt cho truy vấn containment @>)
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);
 
-- Truy vấn: tìm tất cả sản phẩm có color = "black"
SELECT * FROM products
WHERE attributes @> '{"color": "black"}';
 
-- Truy vấn: tìm sản phẩm có bất kỳ thuộc tính nào trong danh sách
SELECT * FROM products
WHERE attributes ?| ARRAY['color', 'size'];

Cho các path cụ thể bạn truy vấn thường xuyên, targeted index hiệu quả hơn:

sql
-- Index một path cụ thể
CREATE INDEX idx_products_color ON products ((attributes->>'color'));
 
-- Giờ đây đây là lookup B-tree thông thường
SELECT * FROM products
WHERE attributes->>'color' = 'black';

Bẫy Migration#

Đây là nơi JSONB cắn bạn: bạn không thể dễ dàng thêm NOT NULL constraint hoặc default value cho các trường bên trong JSON. Nếu bạn thêm trường bắt buộc mới vào thuộc tính sản phẩm, bạn cần backfill mọi dòng hiện có. Với cột thông thường, migration xử lý điều này nguyên tử. Với JSONB, bạn viết câu UPDATE động chạm mọi dòng và hy vọng code ứng dụng xử lý tốt trường hợp thiếu trường cho đến khi backfill hoàn tất.

sql
-- Thêm cột mới: sạch, nguyên tử, một câu lệnh
ALTER TABLE products ADD COLUMN weight_kg NUMERIC(10,3);
 
-- Thêm trường JSONB mới: lộn xộn, cần backfill
UPDATE products
SET attributes = attributes || '{"weight_kg": null}'
WHERE NOT (attributes ? 'weight_kg');
-- Cộng thêm cập nhật code ứng dụng để xử lý weight_kg bị thiếu
-- Cộng thêm cập nhật logic validation
-- Cộng thêm cập nhật mọi API response chứa attributes

Quy tắc của tôi: nếu bạn truy vấn một trường JSONB trong mệnh đề WHERE nhiều hơn hai lần mỗi tuần, nó có lẽ nên là một cột. JSONB là lối thoát hiểm tuyệt vời. Nhưng nó là mặc định tồi tệ.

Schema Migration: Đừng Phá Sản Production Lúc 3 Giờ Sáng#

Schema migration là nơi lý thuyết gặp thực tế. Schema trông tuyệt vời trên giấy, nhưng giờ bạn cần alter bảng 50 triệu dòng trong giờ làm việc mà không có downtime.

Công Cụ Migration#

Tôi đã dùng hầu hết. Nhận xét ngắn:

Drizzle (TypeScript): Yêu thích hiện tại của tôi. Schema-as-code, truy vấn type-safe được sinh từ schema, SQL migration sạch. Lệnh push cho phát triển rất nhanh.

Prisma (TypeScript): DX tuyệt vời cho schema đơn giản. Gặp khó khăn với tính năng PostgreSQL nâng cao (partial index, custom type, RLS). Engine migration có thể đưa ra quyết định bất ngờ.

Flyway (Java/CLI): Vững chắc, đã được thử thách thực chiến, SQL-first. Nếu bạn viết SQL migration thuần, Flyway theo dõi chúng đáng tin cậy. Không phép thuật, không bất ngờ.

golang-migrate (Go/CLI): Tương tự Flyway nhưng nhẹ hơn. Tuyệt vời cho dự án Go hoặc khi bạn chỉ cần một runner migration up/down đơn giản.

Vấn Đề Zero-Downtime#

Những thay đổi schema nguy hiểm nhất là những thứ lock bảng. Trong PostgreSQL, ALTER TABLE ... ADD COLUMN với giá trị mặc định từng lock toàn bộ bảng suốt thời gian rewrite. Từ PostgreSQL 11, default đơn giản (hằng số) chỉ là metadata và tức thời. Nhưng các thao tác khác vẫn lock:

sql
-- AN TOÀN: chỉ metadata, tức thời (PostgreSQL 11+)
ALTER TABLE users ADD COLUMN bio TEXT;
ALTER TABLE users ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false;
 
-- NGUY HIỂM: rewrite toàn bộ bảng, full table lock
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(500);
ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT compute_score();
 
-- NGUY HIỂM: scan toàn bộ bảng để validate
ALTER TABLE orders ADD CONSTRAINT check_total CHECK (total_cents >= 0);

Thêm Cột NOT NULL An Toàn#

Bạn không thể chỉ thêm NOT NULL vào bảng hiện có có dữ liệu — nó thất bại vì các dòng hiện có không có giá trị. Cách tiếp cận ngây thơ:

sql
-- Cách này lock bảng và rewrite. Đừng làm trên bảng lớn.
ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT 'https://example.com/default.png';

Từ PostgreSQL 11, điều này thực sự an toàn cho default hằng số — chỉ là metadata. Nhưng nếu default của bạn là hàm hoặc bạn cần backfill với giá trị tính toán, hãy dùng pattern expand-contract.

Pattern Expand-Contract#

Đây là tiêu chuẩn vàng cho thay đổi schema zero-downtime. Ba giai đoạn:

Giai đoạn 1: Mở rộng — Thêm cột mới dạng nullable. Deploy code ứng dụng ghi vào cả cột cũ và mới.

sql
-- Migration 1: Thêm cột nullable
ALTER TABLE users ADD COLUMN normalized_email TEXT;

Giai đoạn 2: Di chuyển — Backfill các dòng hiện có theo batch. Ứng dụng đã ghi vào cột mới cho dữ liệu mới.

sql
-- Migration 2: Backfill theo batch (đừng làm trong một câu lệnh cho bảng lớn)
UPDATE users SET normalized_email = LOWER(TRIM(email))
WHERE normalized_email IS NULL
LIMIT 10000;
-- Lặp lại cho đến khi tất cả dòng được backfill

Giai đoạn 3: Thu hẹp — Khi tất cả dòng đã được backfill, thêm NOT NULL constraint và xóa cột cũ (nếu cần).

sql
-- Migration 3: Thêm constraint (dùng NOT VALID để tránh full table scan, rồi validate riêng)
ALTER TABLE users ADD CONSTRAINT users_normalized_email_not_null
    CHECK (normalized_email IS NOT NULL) NOT VALID;
 
-- Migration 4: Validate constraint (lấy ShareUpdateExclusiveLock, không phải AccessExclusiveLock)
ALTER TABLE users VALIDATE CONSTRAINT users_normalized_email_not_null;

Thủ thuật NOT VALID + VALIDATE CONSTRAINT rất quan trọng. Thêm check constraint thông thường scan toàn bộ bảng trong khi giữ lock nặng. NOT VALID thêm constraint mà không scan (chỉ áp dụng cho write mới), và VALIDATE CONSTRAINT scan với lock nhẹ hơn không chặn read hay write.

Tạo Index#

Tạo index trên bảng lớn mặc định sẽ chặn write. Luôn dùng CONCURRENTLY:

sql
-- CHẶN WRITE: đừng làm trên bảng đang hoạt động
CREATE INDEX idx_users_email ON users (email);
 
-- KHÔNG CHẶN: dùng cách này thay thế
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);

CONCURRENTLY mất nhiều thời gian hơn và không thể chạy trong transaction, nhưng nó không lock bảng. Sự đánh đổi luôn xứng đáng trong production.

Những Quyết Định Tôi Đã Hối Hận#

Mỗi schema đều có điều hối hận. Đây là của tôi.

Dùng SERIAL Thay Vì UUID Cho ID Bên Ngoài#

Đầu một dự án, tôi dùng primary key SERIAL và expose trực tiếp trong URL: /users/42, /orders/1337. Điều này rò rỉ thông tin (đối thủ có thể đoán số lượng user), làm phân trang dự đoán được, và hỏng khi chúng tôi cần merge database từ hai region. Chuyển sang UUID cho ID hướng bên ngoài đòi hỏi migration nhiều tháng.

Bài học: dùng UUID cho bất cứ thứ gì lộ ra ngoài database. Dùng SERIAL/BIGSERIAL cho bảng join nội bộ nếu muốn, nhưng đừng bao giờ để số nguyên tự tăng xuất hiện trong URL.

Không Có Constraint ("Chúng Tôi Sẽ Thêm Sau")#

Chúng tôi ra mắt bảng không có check constraint vì "chúng tôi đang chạy nhanh và sẽ thêm sau." Trong vòng hai tuần, dữ liệu có giá âm, tên trống, và giá trị order_status"oof" mà ai đó gõ trong lúc testing và không bao giờ dọn dẹp.

Thêm constraint sau đòi hỏi:

  1. Tìm tất cả dữ liệu không hợp lệ
  2. Quyết định xử lý thế nào (sửa, xóa, hay giữ nguyên)
  3. Viết migration backfill/sửa dữ liệu VÀ thêm constraint

Điều này mất nhiều thời gian hơn việc thêm constraint từ ngày đầu. Bắt đầu với constraint. Nới lỏng nếu cần. Đừng bao giờ làm ngược lại.

Chuỗi Phân Tách Bằng Dấu Phẩy Thay Vì Array Hoặc Bảng Join#

Tôi từng lưu tag dạng chuỗi phân tách bằng dấu phẩy: "javascript,react,nextjs". Truy vấn là cơn ác mộng:

sql
-- Đây là cách truy vấn giá trị phân tách bằng dấu phẩy. Đừng làm vậy.
SELECT * FROM posts WHERE tags LIKE '%react%';
-- Cái này cũng match "react-native" và "preact"
 
-- Điều tôi nên làm:
CREATE TABLE post_tags (
    post_id     UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    tag_id      UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (post_id, tag_id)
);

Hoặc ít nhất, dùng kiểu array native của PostgreSQL:

sql
CREATE TABLE posts (
    id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title   TEXT NOT NULL,
    tags    TEXT[] NOT NULL DEFAULT '{}',
    -- ...
);
 
CREATE INDEX idx_posts_tags ON posts USING GIN (tags);
 
-- Truy vấn sạch
SELECT * FROM posts WHERE 'react' = ANY(tags);
SELECT * FROM posts WHERE tags @> ARRAY['react', 'nextjs'];

Array chấp nhận được cho danh sách đơn giản không cần thuộc tính riêng. Khi bạn cần metadata về mối quan hệ (như "ai đã thêm tag này" hoặc "khi nào được thêm"), bạn cần bảng join.

Dùng "type" Làm Tên Cột#

sql
-- Lúc đó trông có vẻ ổn
CREATE TABLE notifications (
    id      BIGSERIAL PRIMARY KEY,
    type    TEXT NOT NULL,  -- "email", "sms", "push"
    -- ...
);
 
-- Rồi bạn cố truy vấn nó trong bất kỳ ORM nào:
-- notification.type  ← xung đột với hệ thống type của mọi ngôn ngữ
-- "type" là từ khóa dành riêng trong hầu hết SQL dialect
-- Bạn phải đặt ngoặc kép ở mọi nơi: SELECT "type" FROM notifications

Dùng kind, category, hoặc notification_type. Bất cứ gì ngoại trừ type. Tương tự, tránh order (dùng sort_order hoặc position), user (dùng account hoặc thêm tiền tố), và group (dùng team hoặc group_name).

Không Thêm created_at Từ Đầu#

Một bảng không có created_at vì "chúng tôi không cần." Ba tháng sau, chúng tôi cần debug một vấn đề và không biết khi nào bản ghi được tạo. Thêm retroactive có nghĩa là tất cả dòng hiện có nhận cùng timestamp (timestamp của migration), khiến dữ liệu lịch sử trở nên vô dụng.

Mỗi bảng đều có created_at. Không ngoại lệ. Chi phí là một cột. Chi phí của việc không có nó là không thể biết cho đến khi bạn cần.

Đặt Logic Nghiệp Vụ Trong Database View#

Tôi từng tạo chuỗi view — active_users lọc từ users, premium_active_users lọc từ active_users, và view báo cáo join tất cả. Nó hoạt động tuyệt vời cho đến khi ai đó thay đổi bảng users và cả ba view đều hỏng âm thầm. Query planner cũng gặp khó khăn khi tối ưu qua nhiều lớp view.

View tuyệt vời cho sự tiện lợi và pattern truy cập read-only. Chúng tệ khi dùng để mã hóa logic nghiệp vụ thay đổi. Giữ quy tắc nghiệp vụ trong code ứng dụng nơi chúng được version, test, và deployable. Dùng view cho phím tắt báo cáo, không phải khối xây dựng kiến trúc.

Tổng Hợp Tất Cả#

Đây là schema được thiết kế tốt trông như thế nào khi bạn kết hợp các pattern này. Một hệ thống quản lý dự án đơn giản hóa:

sql
-- Extension
CREATE EXTENSION IF NOT EXISTS "pgcrypto";  -- cho gen_random_uuid()
 
-- Hàm trigger updated_at (tái sử dụng)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
-- Tenant
CREATE TABLE tenants (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            TEXT NOT NULL CHECK (LENGTH(name) > 0),
    slug            TEXT NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9-]+$'),
    plan            TEXT NOT NULL DEFAULT 'free'
                    CHECK (plan IN ('free', 'pro', 'enterprise')),
    settings        JSONB NOT NULL DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON tenants
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
-- User
CREATE TABLE users (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    email           TEXT NOT NULL,
    display_name    TEXT NOT NULL CHECK (LENGTH(display_name) > 0),
    password_hash   TEXT NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT true,
    is_verified     BOOLEAN NOT NULL DEFAULT false,
    last_login_at   TIMESTAMPTZ,
    deleted_at      TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE UNIQUE INDEX uq_users_email_per_tenant
    ON users (tenant_id, email)
    WHERE deleted_at IS NULL;
 
CREATE INDEX idx_users_tenant ON users (tenant_id) WHERE deleted_at IS NULL;
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON users
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
-- Bật RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Dự án
CREATE TABLE projects (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    name            TEXT NOT NULL CHECK (LENGTH(name) > 0),
    description     TEXT NOT NULL DEFAULT '',
    status          TEXT NOT NULL DEFAULT 'active'
                    CHECK (status IN ('active', 'archived', 'deleted')),
    owner_id        UUID NOT NULL REFERENCES users(id),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_projects_owner ON projects (owner_id);
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON projects
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Task
CREATE TABLE tasks (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    project_id      UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    title           TEXT NOT NULL CHECK (LENGTH(title) > 0),
    description     TEXT NOT NULL DEFAULT '',
    status          TEXT NOT NULL DEFAULT 'todo'
                    CHECK (status IN ('todo', 'in_progress', 'review', 'done')),
    priority        INTEGER NOT NULL DEFAULT 0 CHECK (priority BETWEEN 0 AND 4),
    assignee_id     UUID REFERENCES users(id) ON DELETE SET NULL,
    due_at          TIMESTAMPTZ,
    completed_at    TIMESTAMPTZ,
    sort_order      INTEGER NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_tasks_tenant_project ON tasks (tenant_id, project_id);
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id) WHERE assignee_id IS NOT NULL;
CREATE INDEX idx_tasks_status ON tasks (tenant_id, status) WHERE status != 'done';
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON tasks
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tasks
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Bình luận task
CREATE TABLE task_comments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    task_id         UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
    author_id       UUID NOT NULL REFERENCES users(id),
    body            TEXT NOT NULL CHECK (LENGTH(body) > 0),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_task_comments_task ON task_comments (task_id);
 
CREATE TRIGGER set_updated_at BEFORE UPDATE ON task_comments
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
 
ALTER TABLE task_comments ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON task_comments
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
 
-- Audit log (không RLS — bảng chỉ admin)
CREATE TABLE audit_log (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    table_name      TEXT NOT NULL,
    record_id       TEXT NOT NULL,
    operation       TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
    old_values      JSONB,
    new_values      JSONB,
    changed_fields  TEXT[],
    changed_by      UUID REFERENCES users(id),
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_audit_log_tenant_table ON audit_log (tenant_id, table_name, record_id);
CREATE INDEX idx_audit_log_changed_at ON audit_log (changed_at);

Lưu ý các pattern:

  • Mỗi bảng đều có tenant_id và RLS được bật (trừ tenantsaudit_log).
  • Mỗi bảng đều có created_atupdated_at với trigger.
  • UUID cho tất cả primary key (thực thể hướng bên ngoài).
  • Check constraint trên status enum, độ dài, và phạm vi.
  • Foreign key với hành vi ON DELETE phù hợp.
  • Index được thiết kế cho pattern truy cập (tenant + project, tenant + status).
  • Soft delete chỉ trên users (nơi cần khôi phục tài khoản), archival dựa trên status cho projects.

Lời Kết#

Thiết kế schema không hào nhoáng. Không ai từng đưa ra bài thuyết trình hội nghị với tiêu đề "Tôi Thêm Check Constraint Vào Mỗi Bảng Và Nó Tiết Kiệm Sáu Tháng Debug." Nhưng đó chính xác là điều thiết kế schema tốt mang lại — nó ngăn chặn vấn đề một cách im lặng đến mức bạn không bao giờ biết chúng sẽ tồn tại.

Các pattern trong bài này không mới lạ. Chúng là kết quả của nhiều năm viết migration, debug hỏng dữ liệu, và refactor schema dưới tải production. Mỗi pattern tồn tại vì tôi, hoặc ai đó tôi từng làm việc cùng, đã làm theo cách khác trước và trả giá.

Bắt đầu với constraint. Dùng foreign key. Thêm created_at vào mọi thứ. Chọn quy ước đặt tên và áp dụng nghiêm ngặt. Dùng RLS cho multi-tenancy. Cẩn thận với JSONB. Test migration với dữ liệu kích thước production trước khi deploy.

Database là nền tảng. Làm đúng, và mọi thứ xây dựng trên đó trở nên đơn giản hơn. Làm sai, và không có lượng code ứng dụng thông minh nào cứu được bạn.

Bài viết liên quan