Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Bài 24 — Constraints & Business Rules

Data Modeling and ERD Mastery Bài 24/60

Mở đầu — vì sao bài này quan trọng

Hãy tưởng tượng bạn vừa thiết kế xong một mô hình dữ liệu rất đẹp: các entity rõ ràng, quan hệ chuẩn chỉnh, khóa chính khóa ngoại đầy đủ. Nhưng ba tháng sau khi hệ thống chạy, bạn mở bảng customers lên và phát hiện: có khách hàng số điện thoại để trống, có đơn hàng total_amount bằng âm 5 triệu, có tài khoản ngân hàng số dư là NULL, và có hai khách hàng dùng chung một số CCCD. Mô hình của bạn đúng về cấu trúc, nhưng dữ liệu bên trong thì hỗn loạn.

Đây chính là khoảng trống mà bài học hôm nay lấp đầy. Một mô hình dữ liệu không chỉ là chuyện "có những bảng nào, nối với nhau ra sao". Nó còn phải trả lời câu hỏi: dữ liệu hợp lệ là dữ liệu như thế nào? Số dư tài khoản không được âm, ngày trả phòng phải sau ngày nhận phòng, một mã sản phẩm không được trùng, trạng thái đơn hàng chỉ được nhận một trong vài giá trị cho phép. Tất cả những quy tắc đó được gọi là business rules (quy tắc nghiệp vụ), và cách mạnh mẽ nhất để bảo vệ chúng là cài đặt constraints (ràng buộc) ngay tại tầng cơ sở dữ liệu.

Với vai trò Business Analyst hoặc người thiết kế dữ liệu, bạn không nhất thiết phải là người viết code SQL cuối cùng. Nhưng bạn là người phát hiện, đặc tả và truyền đạt các quy tắc này cho đội phát triển. Nếu bạn không nắm rõ constraint nào nên đặt ở đâu, bạn sẽ để lọt những lỗ hổng mà sau này phải trả giá bằng dữ liệu bẩn và những đêm trực sửa lỗi. Bài này giúp bạn hiểu cả về khái niệm lẫn cách áp dụng thực tế.

Khái niệm cốt lõi

Constraint là gì và vì sao nên đặt ở tầng database?

Constraint là một quy tắc mà cơ sở dữ liệu tự động bắt buộc tuân thủ mỗi khi có thao tác thêm, sửa, xóa dữ liệu. Nếu thao tác vi phạm constraint, database sẽ từ chối và trả về lỗi — dữ liệu xấu không bao giờ được ghi vào.

Một câu hỏi rất hay từ học viên: "Sao không kiểm tra ở tầng ứng dụng cho linh hoạt?" Câu trả lời nằm ở ba chữ: lớp phòng thủ cuối cùng. Trong thực tế, dữ liệu đi vào một bảng không chỉ từ một ứng dụng. Có thể từ web app, mobile app, một script import Excel của nhân viên vận hành, một job đồng bộ ban đêm, hay thậm chí một bạn dev mở công cụ quản trị gõ tay câu lệnh SQL. Nếu bạn chỉ kiểm tra ở tầng ứng dụng, mỗi cửa ngõ đều có thể là một lỗ hổng. Còn constraint ở database thì không cửa nào lọt qua được — đó là sự thật duy nhất, đặt ngay tại nơi dữ liệu cư trú.

Có ba lý do lớn để dùng constraint:

  • Toàn vẹn dữ liệu (data integrity): đảm bảo dữ liệu luôn nhất quán và hợp lệ.
  • Ngăn dữ liệu xấu ngay từ gốc: thà từ chối một bản ghi sai còn hơn để nó âm thầm phá hoại báo cáo sau này.
  • Tài liệu hóa quy tắc ngay trong schema: khi đọc cấu trúc bảng, người sau hiểu được "à, cột này không được trống, cột kia phải là một trong các giá trị này". Schema tự nó trở thành một dạng tài liệu sống.

Các loại constraint chính

Đây là phần "xương sống" của bài. Có sáu loại constraint bạn cần thuộc nằm lòng:

1. NOT NULL — cột bắt buộc phải có giá trị, không được để trống. Ví dụ: email của khách hàng, created_at của đơn hàng. Đừng nhầm NULL với chuỗi rỗng hay số 0 — NULL nghĩa là "không có dữ liệu / chưa biết", và nó có thể gây ra rất nhiều phiền toái trong tính toán (cộng một số với NULL ra NULL).

2. UNIQUE — giá trị trong cột (hoặc tổ hợp cột) không được trùng nhau. Ví dụ: số CCCD, mã số thuế, username. Lưu ý quan trọng: UNIQUE vẫn cho phép nhiều dòng NULL (tùy DBMS), nên nếu muốn vừa duy nhất vừa bắt buộc, hãy kết hợp UNIQUE với NOT NULL.

3. PRIMARY KEY — kết hợp của UNIQUE và NOT NULL, định danh duy nhất mỗi bản ghi. Bài 6 đã đi sâu vào khóa chính, nên ở đây ta chỉ nhắc đến nó như một dạng constraint.

4. FOREIGN KEY — đảm bảo giá trị trong cột tham chiếu phải tồn tại trong bảng cha. Ví dụ: order.customer_id phải trỏ tới một customer có thật. Đây là constraint giữ cho các quan hệ không bị "mồ côi" (orphan record). Nó còn đi kèm các hành vi ON DELETE / ON UPDATE như CASCADE, RESTRICT, SET NULL — quyết định điều gì xảy ra khi bản ghi cha bị xóa.

5. CHECK — đây là loại linh hoạt và "nghiệp vụ" nhất, cho phép bạn viết một biểu thức điều kiện. Ví dụ: CHECK (total_amount >= 0), CHECK (discount_percent BETWEEN 0 AND 100), hay CHECK (end_date >= start_date). Phần lớn business rule cụ thể được cài đặt qua CHECK.

6. DEFAULT — không hẳn là ràng buộc cấm đoán, mà là giá trị mặc định gán tự động khi không truyền vào. Ví dụ: status DEFAULT 'pending', created_at DEFAULT CURRENT_TIMESTAMP. Nó giúp giảm rủi ro thiếu dữ liệu và đảm bảo tính nhất quán.

Phân biệt: quy tắc nào nên là constraint, quy tắc nào không?

Không phải mọi business rule đều nên (hoặc có thể) biến thành constraint trong DDL. Hãy phân loại:

  • Quy tắc tĩnh, áp dụng cho một dòng dữ liệu: rất hợp với CHECK constraint. Ví dụ "giá phải dương", "phần trăm trong khoảng 0–100".
  • Quy tắc về quan hệ giữa các bảng: dùng FOREIGN KEY.
  • Quy tắc phức tạp, liên quan nhiều bảng hoặc tính toán động: thường vượt khả năng của CHECK đơn giản và cần đến trigger, stored procedure, hoặc logic tầng ứng dụng. Ví dụ "tổng tiền đơn hàng phải bằng tổng các dòng chi tiết" — quy tắc này tham chiếu nhiều bảng, không nên nhồi vào một CHECK.
Là BA, việc của bạn là phân loại được mỗi quy tắc thuộc nhóm nào, rồi ghi rõ trong tài liệu đặc tả: "Quy tắc R-12: số dư không âm — cài đặt bằng CHECK constraint trên cột balance".

Tình huống thực tế

Tình huống 1: Ngân hàng số Timo và bài học số dư âm

Một ngân hàng số tại Việt Nam (gọi tên giả định là "Timo") trong giai đoạn đầu xây dựng hệ thống ví điện tử đã gặp một sự cố kinh điển. Bảng accounts có cột balance kiểu DECIMAL(18,2) nhưng không có CHECK constraint nào kiểm tra số dư. Logic trừ tiền được viết hoàn toàn ở tầng ứng dụng.

Vấn đề xảy ra khi hai giao dịch rút tiền gần như đồng thời (race condition) trên cùng một tài khoản có 100.000 đồng. Cả hai cùng đọc thấy số dư đủ, cùng cho phép rút 80.000 đồng, và kết quả là tài khoản về -60.000 đồng. Tầng ứng dụng đã kiểm tra, nhưng vì kiểm tra ở thời điểm đọc chứ không phải lúc ghi, nó để lọt.

Bài học rút ra: Nếu trên cột balance có một constraint CHECK (balance >= 0), thì giao dịch thứ hai khi cố ghi số dư âm sẽ bị database từ chối thẳng thừng, buộc ứng dụng phải xử lý lại. Database trở thành chốt chặn cuối cùng mà không một race condition nào vượt qua được. Sau sự cố, đội kỹ thuật bổ sung constraint này như một nguyên tắc bất di bất dịch cho mọi cột tiền tệ nhạy cảm. Chi phí: một dòng DDL. Giá trị bảo vệ: hàng tỷ đồng và uy tín thương hiệu.

Tình huống 2: Sàn thương mại điện tử và đơn hàng "mồ côi"

Một startup thương mại điện tử quy mô vừa (khoảng 50.000 đơn/tháng) thiết kế bảng order_items tham chiếu tới products qua cột product_id, nhưng cố tình bỏ FOREIGN KEY với lý do "để insert cho nhanh, đỡ phải lo thứ tự ghi".

Sáu tháng sau, đội marketing chạy một chiến dịch dọn kho và team kỹ thuật xóa khoảng 2.000 sản phẩm ngừng kinh doanh khỏi bảng products. Hậu quả: hàng nghìn dòng order_items bỗng trỏ tới product_id không còn tồn tại. Khi khách hàng mở lại lịch sử mua hàng, hệ thống báo lỗi vì không tìm thấy sản phẩm. Báo cáo doanh thu theo sản phẩm cũng sai lệch vì join bị mất dòng.

Bài học rút ra: Nếu có FOREIGN KEY với ON DELETE RESTRICT, thao tác xóa sản phẩm đang được tham chiếu sẽ bị database chặn lại, buộc đội phải dùng cách "ẩn" sản phẩm (soft delete bằng cờ is_active = false) thay vì xóa cứng. Constraint không chỉ bảo vệ dữ liệu mà còn ép quy trình nghiệp vụ đi đúng hướng. Đôi khi một FOREIGN KEY chính là thứ ngăn cả một quyết định vận hành sai lầm.

Tình huống 3: Hệ thống đặt phòng khách sạn và ngày tháng vô lý

Một chuỗi khách sạn ở Đà Nẵng triển khai hệ thống đặt phòng nội bộ. Bảng bookingscheck_in_datecheck_out_date. Nhân viên lễ tân nhập liệu qua một màn hình admin, và do nhập vội, nhiều bản ghi có check_out_date sớm hơn check_in_date — tức khách trả phòng trước khi nhận phòng.

Khi đội vận hành chạy báo cáo "số đêm lưu trú", công thức check_out_date - check_in_date cho ra số âm, kéo tổng doanh thu dự báo xuống thấp một cách khó hiểu. Phải mất hai ngày dò tìm họ mới phát hiện ra gốc rễ là dữ liệu ngày tháng vô lý.

Bài học rút ra: Một CHECK constraint dạng CHECK (check_out_date > check_in_date) sẽ chặn ngay từ lúc nhập, hiển thị lỗi để lễ tân sửa lại tại chỗ. Quy tắc nghiệp vụ "ngày trả phòng phải sau ngày nhận phòng" tưởng hiển nhiên với con người nhưng database không tự biết — bạn phải nói cho nó nghe bằng constraint.

Hướng dẫn từng bước

Đây là quy trình bạn có thể áp dụng để biến business rules thành constraints một cách bài bản.

Bước 1 — Thu thập và liệt kê business rules. Ngồi với stakeholder (nghiệp vụ, vận hành) và viết ra mọi quy tắc dưới dạng câu khẳng định rõ ràng. Ví dụ: "Mỗi nhân viên có một mã số thuế duy nhất", "Lương cơ bản phải lớn hơn 0", "Đơn hàng phải thuộc về một khách hàng có thật".

Bước 2 — Phân loại từng quy tắc. Với mỗi quy tắc, hỏi: nó áp dụng cho một cột (CHECK/NOT NULL/UNIQUE), cho quan hệ (FOREIGN KEY), hay cho logic phức tạp đa bảng (cần trigger/ứng dụng)? Ghi nhãn rõ ràng.

Bước 3 — Ánh xạ sang loại constraint cụ thể. Lập một bảng đối chiếu:

Business ruleCột/BảngLoại constraint
Mã số thuế không trùngemployees.tax_codeUNIQUE + NOT NULL
Lương cơ bản dươngemployees.base_salaryCHECK (base_salary > 0)
Đơn thuộc khách có thậtorders.customer_idFOREIGN KEY
Trạng thái hợp lệorders.statusCHECK (status IN (...))
Bước 4 — Viết DDL. Diễn đạt thành câu lệnh. Ví dụ minh họa (cú pháp chuẩn ANSI/PostgreSQL):

CREATE TABLE orders (
    order_id      BIGINT       PRIMARY KEY,
    customer_id   BIGINT       NOT NULL REFERENCES customers(customer_id),
    total_amount  DECIMAL(18,2) NOT NULL CHECK (total_amount >= 0),
    status        VARCHAR(20)  NOT NULL DEFAULT 'pending'
                  CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled')),
    created_at    TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Bước 5 — Đặt tên constraint có ý nghĩa. Thay vì để database tự sinh tên ngẫu nhiên, hãy đặt tên như chk_orders_total_positive hay fk_orders_customer. Khi lỗi xảy ra, thông báo sẽ chỉ rõ constraint nào bị vi phạm, giúp gỡ lỗi nhanh hơn rất nhiều.

Bước 6 — Kiểm thử bằng dữ liệu xấu. Cố tình insert dữ liệu vi phạm (số âm, status sai, customer_id không tồn tại) để xác nhận constraint thực sự chặn được. Nếu không thử thì bạn chưa chắc nó hoạt động.

Bước 7 — Tài liệu hóa. Ghi lại trong data dictionary (sẽ học kỹ ở Bài 17) rằng cột nào có constraint gì và phục vụ quy tắc nghiệp vụ nào. Đây là cầu nối giữa schema kỹ thuật và ngôn ngữ nghiệp vụ.

Lỗi thường gặp & mẹo

Lỗi 1 — Dồn hết kiểm tra lên tầng ứng dụng. Như đã phân tích, đây là sai lầm phổ biến nhất. Tầng ứng dụng nên kiểm tra để cho trải nghiệm người dùng tốt (báo lỗi đẹp, kịp thời), nhưng database vẫn phải là chốt chặn cuối. Hai tầng bổ trợ nhau, không thay thế nhau.

Lỗi 2 — Quên xử lý NULL trong CHECK. Một CHECK constraint chỉ thất bại khi điều kiện trả về FALSE. Nếu cột chứa NULL, biểu thức thường trả về UNKNOWN và constraint vẫn cho qua. Ví dụ CHECK (age >= 18) sẽ cho phép age = NULL lọt qua. Mẹo: nếu cột không được trống, hãy kèm NOT NULL.

Lỗi 3 — Lạm dụng CHECK cho logic động. Đừng cố nhồi quy tắc kiểu "ngày sinh phải trước ngày hôm nay" bằng CHECK (birth_date < CURRENT_DATE). Nhiều DBMS không cho phép hàm không tất định (non-deterministic) trong CHECK, và ngay cả khi được thì ý nghĩa cũng dễ gây nhầm lẫn. Loại logic phụ thuộc thời điểm nên đặt ở ứng dụng hoặc trigger.

Lỗi 4 — Đặt FOREIGN KEY mà quên nghĩ về hành vi xóa. Mặc định nhiều người để RESTRICT mà không cân nhắc. Hãy chủ động chọn: CASCADE (xóa cha thì xóa con — hợp với chi tiết đơn hàng), RESTRICT (cấm xóa cha khi còn con — hợp với sản phẩm/khách hàng), hay SET NULL (gỡ liên kết). Chọn sai có thể xóa nhầm dữ liệu hàng loạt.

Lỗi 5 — Thêm constraint vào bảng đã có dữ liệu bẩn. Khi bạn ALTER TABLE để thêm constraint trên một bảng đang chạy, nếu dữ liệu hiện có đã vi phạm thì lệnh sẽ thất bại. Mẹo: làm sạch dữ liệu cũ trước, hoặc dùng cơ chế NOT VALID (PostgreSQL) để áp dụng cho dữ liệu mới trước rồi validate dần.

Mẹo vàng: Hãy coi mỗi constraint là một câu khẳng định về sự thật của nghiệp vụ. Khi đọc lại schema sau một năm, bạn (và đồng nghiệp) sẽ hiểu ngay những giả định nào đang được bảo vệ. Constraint chính là tài liệu không bao giờ lỗi thời, vì nếu nó sai, database sẽ "kêu" lên ngay.

Bài tập thực hành

Hãy lấy một hệ thống quản lý phòng khám (clinic) đơn giản và thực hành.

Đề bài: Bạn có ba bảng: patients (bệnh nhân), doctors (bác sĩ), appointments (lịch hẹn). Dưới đây là các business rules thu thập được từ phía phòng khám:

  • Mỗi bệnh nhân phải có số CCCD, và số này không được trùng.
  • Họ tên bệnh nhân không được để trống.
  • Mỗi lịch hẹn phải gắn với một bệnh nhân và một bác sĩ có thật trong hệ thống.
  • Thời gian kết thúc khám phải sau thời gian bắt đầu khám.
  • Trạng thái lịch hẹn chỉ được là một trong: scheduled, completed, cancelled, no_show.
  • Phí khám không được âm; nếu không nhập thì mặc định là 0.
  • Khi một bác sĩ nghỉ việc và bị xóa khỏi hệ thống, các lịch hẹn đã hoàn thành của họ không được phép bị xóa theo.
Yêu cầu:

  • (a) Với mỗi quy tắc, xác định nó tương ứng với loại constraint nào (NOT NULL, UNIQUE, FOREIGN KEY, CHECK, DEFAULT).
  • (b) Viết câu lệnh CREATE TABLE cho bảng appointments thể hiện đầy đủ các constraint liên quan, đặt tên constraint rõ ràng.
  • (c) Với quy tắc số 7, bạn chọn hành vi ON DELETE nào? Giải thích.
  • (d) Quy tắc nào trong danh sách trên KHÔNG nên cài bằng constraint đơn giản mà cần cân nhắc giải pháp khác? (Gợi ý: hãy nghĩ tới quy tắc "một bác sĩ không được có hai lịch hẹn trùng giờ" — nếu đề bài mở rộng thêm quy tắc này, bạn sẽ xử lý ra sao?)
Gợi ý chấm điểm cho bản thân: Quy tắc 1 → UNIQUE + NOT NULL; quy tắc 2 → NOT NULL; quy tắc 3 → FOREIGN KEY; quy tắc 4 → CHECK; quy tắc 5 → CHECK ... IN; quy tắc 6 → CHECK >= 0 kèm DEFAULT 0; quy tắc 7 → FOREIGN KEY với ON DELETE RESTRICT (hoặc dùng soft delete cho bác sĩ thay vì xóa cứng). Quy tắc "không trùng giờ khám" là loại đa dòng/đa điều kiện, vượt khả năng CHECK đơn giản và thường cần exclusion constraint (PostgreSQL) hoặc kiểm tra ở tầng ứng dụng/trigger.

Tóm tắt

Constraints là cách bạn dạy cho cơ sở dữ liệu hiểu thế nào là dữ liệu hợp lệ, biến những business rules trừu tượng thành những quy tắc được database tự động thực thi. Chúng tồn tại để bảo vệ toàn vẹn dữ liệu, ngăn dữ liệu xấu ngay từ gốc, và tài liệu hóa quy tắc nghiệp vụ ngay trong schema.

Sáu loại constraint cốt lõi cần nhớ: NOT NULL (bắt buộc có giá trị), UNIQUE (không trùng lặp), PRIMARY KEY (định danh duy nhất), FOREIGN KEY (toàn vẹn quan hệ), CHECK (điều kiện nghiệp vụ tùy biến), và DEFAULT (giá trị mặc định). Quy trình áp dụng gồm: liệt kê quy tắc, phân loại, ánh xạ sang constraint, viết DDL, đặt tên rõ ràng, kiểm thử bằng dữ liệu xấu, và tài liệu hóa.

Quan trọng nhất, hãy nhớ triết lý: database là lớp phòng thủ cuối cùng. Dù bạn kiểm tra kỹ ở tầng ứng dụng đến đâu, vẫn nên có constraint ở database để không một cửa ngõ nào — script import, dev gõ tay, job đồng bộ — có thể làm bẩn dữ liệu. Ba tình huống thực tế (số dư âm ở ngân hàng số, đơn hàng mồ côi ở sàn thương mại điện tử, ngày tháng vô lý ở khách sạn) đều cho thấy cùng một bài học: một dòng DDL nhỏ có thể ngăn được những thảm họa dữ liệu lớn. Là một BA giỏi, năng lực nhận diện và đặc tả đúng constraint chính là thứ phân biệt một mô hình dữ liệu "trông đẹp" với một mô hình dữ liệu thực sự đáng tin cậy.