Cơ sở dữ liệu quan hệRelational Databases
Thuộc bộ kiến thức Backend Roadmap.
Tổng quan
Một relational database lưu dữ liệu dưới dạng tập hợp các table (relation), trong đó mỗi table chứa các row (bản ghi) được mô tả bởi một tập cố định các column (thuộc tính). Các table liên kết với nhau qua các key, và bạn truy vấn cũng như thay đổi dữ liệu bằng SQL (Structured Query Language). Mô hình này — do Edgar F. Codd đề xuất năm 1970 — đã là cách mặc định để lưu dữ liệu nghiệp vụ giao dịch suốt năm thập kỷ, vì nó kết hợp một mô hình tư duy đơn giản (những bảng tính tham chiếu lẫn nhau) với nền tảng toán học vững chắc (relational algebra) và các đảm bảo tính đúng đắn nghiêm ngặt (transaction ACID).
Với một backend engineer, relational database thường là system of record: nơi lưu trữ sự thật có thẩm quyền và bền vững mà mọi thứ khác (cache, search index, pipeline phân tích) đều được suy ra từ đó. Thiết kế đúng schema, query và hành vi transactional quan trọng hơn gần như mọi quyết định backend khác, bởi vì dữ liệu sống lâu hơn code — bạn sẽ viết lại service ba lần trước khi kịp migrate bảng users.
Ghi chú này trình bày mô hình quan hệ, SQL, ACID và transaction, các isolation level, normalization, indexing, cùng một khảo sát các relational database management system (RDBMS) chính. Các chủ đề kỹ thuật sâu hơn và xuyên nhiều database — ORM, schema migration, replication, sharding và horizontal scaling — nằm ở Database Design & Scaling. Về các kho lưu trữ phi bảng, xem NoSQL Databases; về giảm tải cho database, xem Caching.
Kiến thức nền tảng
Mô hình quan hệ (relational model)
Ý tưởng cốt lõi: mô hình hóa domain của bạn thành các relation (table) và các relationship giữa chúng, rồi để một ngôn ngữ truy vấn khai báo (declarative) lo việc lấy dữ liệu. Bạn mô tả cái gì bạn muốn, không phải cách lấy nó — query planner của database sẽ tự tìm ra cách thực thi.
| Thuật ngữ | Lý thuyết quan hệ | Tên thực tế | Ví dụ |
|---|---|---|---|
| Relation | Relation | Table | customers |
| Tuple | Tuple | Row / record | một customer |
| Attribute | Attribute | Column / field | email |
| Domain | Domain | Kiểu dữ liệu + ràng buộc | VARCHAR(255) NOT NULL |
| Cardinality | Số lượng tuple | Số row | 10.000 customer |
| Degree | Số lượng attribute | Số column | 6 column |
Hai quy tắc phân biệt một relation với một bảng tính:
- Row không có thứ tự và duy nhất — không có khái niệm “row thứ 5”; bạn xác định một row qua giá trị của nó (thông qua key), không qua vị trí.
- Mỗi ô chứa một giá trị nguyên tử (atomic) — không có list hay bảng lồng bên trong một ô (đây chính là quy tắc “First Normal Form” bên dưới).
Key
Key là cách các row được định danh và cách các table tham chiếu lẫn nhau.
- Primary key (PK) — một hoặc nhiều column định danh duy nhất mỗi row. Không được
NULL, phải duy nhất. Mọi table nên có một PK. Surrogate key là PK nhân tạo không mang ý nghĩa nghiệp vụ (mộtidtự tăng hoặc mộtUUID); natural key là một thuộc tính thực tế có tính duy nhất (mã quốc gia ISO). Surrogate key thường được ưu tiên vì giá trị nghiệp vụ hay thay đổi. - Foreign key (FK) — một column tham chiếu tới primary key của table khác, đảm bảo referential integrity: bạn không thể insert một
ordervớicustomer_idkhông tồn tại, và (tùy quy tắc) không thể xóa một customer vẫn còn order. - Candidate key — bất kỳ tập column nào có thể làm PK (duy nhất + tối thiểu). Một cái được chọn làm PK, phần còn lại là alternate key.
- Composite key — key gồm nhiều column (thường gặp ở join table, ví dụ
(order_id, product_id)). - Unique key / constraint — đảm bảo tính duy nhất trên một column không phải PK (ví dụ
email).
CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY, -- surrogate key
email VARCHAR(255) NOT NULL UNIQUE, -- alternate/candidate key
full_name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
total_cents INTEGER NOT NULL CHECK (total_cents >= 0),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Relationship (quan hệ)
Foreign key biểu diễn ba loại relationship:
| Relationship | Ý nghĩa | Cách hiện thực | Ví dụ |
|---|---|---|---|
| One-to-one (1:1) | Một row liên kết với tối đa một row | FK kèm ràng buộc UNIQUE, hoặc dùng chung PK | user ↔ user_profile |
| One-to-many (1:N) | Một row liên kết với nhiều row | FK ở phía “many” | customer → nhiều orders |
| Many-to-many (M:N) | Nhiều liên kết với nhiều | Junction/join table với hai FK | students ↔ courses qua enrollments |
-- Many-to-many qua junction table
CREATE TABLE enrollments (
student_id BIGINT NOT NULL REFERENCES students(id),
course_id BIGINT NOT NULL REFERENCES courses(id),
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (student_id, course_id) -- composite PK ngăn ghi danh trùng
);
Schema
Schema là cấu trúc của database: các table, column, kiểu dữ liệu và ràng buộc. Trong PostgreSQL và SQL Server, “schema” còn là một namespace bên trong database (ví dụ public.users, billing.invoices) dùng để nhóm các object và quản lý phân quyền. Relational database theo mô hình schema-on-write: cấu trúc được định nghĩa trước và được kiểm tra ở mỗi lần insert, giúp bắt lỗi dữ liệu sớm — ngược lại với cách schema-on-read phổ biến ở NoSQL.
Kiểu dữ liệu (tiêu biểu)
| Nhóm | Kiểu thường gặp | Ghi chú |
|---|---|---|
| Số nguyên | SMALLINT, INTEGER, BIGINT | Dùng BIGINT cho ID sẽ tăng nhiều |
| Số chính xác | NUMERIC(p,s) / DECIMAL | Dùng cho tiền tệ — không dùng FLOAT |
| Số thực dấu phẩy động | REAL, DOUBLE PRECISION | Cho giá trị khoa học, không phải tiền |
| Văn bản | VARCHAR(n), TEXT, CHAR(n) | Ưu tiên TEXT/VARCHAR; tránh CHAR |
| Ngày/giờ | DATE, TIME, TIMESTAMP, TIMESTAMPTZ | Lưu timestamp theo UTC (TIMESTAMPTZ) |
| Boolean | BOOLEAN | |
| Nhị phân | BYTEA, BLOB | Nên dùng object storage cho blob lớn |
| Có cấu trúc | JSON/JSONB, array, UUID, ENUM | JSONB bắc cầu giữa mô hình relational + document |
Khái niệm chính
SQL: DDL vs DML (và các nhóm khác)
SQL được chia thành các nhóm con theo mục đích:
| Nhóm con | Mục đích | Câu lệnh |
|---|---|---|
| DDL — Data Definition | Định nghĩa/thay đổi cấu trúc | CREATE, ALTER, DROP, TRUNCATE |
| DML — Data Manipulation | Đọc/thay đổi dữ liệu | SELECT, INSERT, UPDATE, DELETE |
| DCL — Data Control | Phân quyền | GRANT, REVOKE |
| TCL — Transaction Control | Ranh giới transaction | BEGIN, COMMIT, ROLLBACK, SAVEPOINT |
(Lưu ý: TRUNCATE đôi khi được xếp vào DDL vì nó nhanh và thường không transactional/không log theo từng row; hành vi khác nhau tùy engine.)
SELECT: câu lệnh chủ lực
Thứ tự đánh giá logic quan trọng hơn thứ tự viết. SQL được viết SELECT … FROM … WHERE … GROUP BY … HAVING … ORDER BY … LIMIT, nhưng được đánh giá đại khái theo:
FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT
Đây là lý do bạn không thể dùng alias của cột trong SELECT ở WHERE (alias chưa tồn tại), nhưng lại dùng được ở ORDER BY.
SELECT status, COUNT(*) AS order_count, SUM(total_cents) AS revenue_cents
FROM orders
WHERE created_at >= '2026-01-01'
GROUP BY status
HAVING SUM(total_cents) > 100000 -- lọc theo group (aggregate)
ORDER BY revenue_cents DESC
LIMIT 10;
WHERElọc row trước khi group;HAVINGlọc group sau khi aggregate.- Hàm aggregate:
COUNT,SUM,AVG,MIN,MAX.COUNT(*)đếm row;COUNT(col)bỏ qua các giá trịNULL.
JOIN
JOIN kết hợp các row từ hai table dựa trên một column liên quan.
| Join | Trả về |
|---|---|
INNER JOIN | Chỉ những row có match ở cả hai table |
LEFT [OUTER] JOIN | Tất cả row bên trái; cột bên phải là NULL khi không match |
RIGHT [OUTER] JOIN | Tất cả row bên phải; cột bên trái NULL khi không match |
FULL [OUTER] JOIN | Tất cả row của cả hai; NULL ở nơi không match |
CROSS JOIN | Tích Descartes (mỗi row trái × mỗi row phải) |
SELF JOIN | Một table join với chính nó (ví dụ nhân viên → quản lý) |
-- Customer và tổng chi tiêu, gồm cả customer chưa có order nào
SELECT c.id, c.full_name, COALESCE(SUM(o.total_cents), 0) AS lifetime_cents
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.full_name
ORDER BY lifetime_cents DESC;
Cặp LEFT JOIN + COALESCE là cách giữ lại những row không có row con (một INNER JOIN sẽ loại bỏ customer chưa có order).
Subquery và CTE
Subquery là một query lồng bên trong query khác. Correlated subquery tham chiếu tới query bên ngoài và chạy theo từng row của query ngoài.
-- Scalar subquery: customer chi tiêu trên mức trung bình giá trị order toàn hệ thống
SELECT full_name
FROM customers c
WHERE (SELECT AVG(total_cents) FROM orders o WHERE o.customer_id = c.id)
> (SELECT AVG(total_cents) FROM orders);
-- CTE (Common Table Expression) — thường rõ ràng hơn subquery lồng nhau
WITH customer_spend AS (
SELECT customer_id, SUM(total_cents) AS spend
FROM orders
GROUP BY customer_id
)
SELECT c.full_name, cs.spend
FROM customer_spend cs
JOIN customers c ON c.id = cs.customer_id
WHERE cs.spend > 500000;
-- Window function: xếp hạng order theo từng customer mà không gộp row
SELECT id, customer_id, total_cents,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at) AS nth_order
FROM orders;
Ưu tiên CTE và window function hơn các correlated subquery lồng sâu — chúng dễ đọc hơn và thường được plan tốt hơn. EXISTS / IN / NOT EXISTS là các cách chuẩn để kiểm tra membership; NOT EXISTS thường an toàn hơn NOT IN vì NOT IN cho kết quả bất ngờ khi danh sách chứa NULL.
Thuộc tính ACID
ACID là tập hợp các đảm bảo mà relational database đưa ra về transaction — các đơn vị công việc phải thành công hoặc thất bại như một khối.
| Thuộc tính | Đảm bảo | Ngăn chặn điều gì |
|---|---|---|
| Atomicity (Tính nguyên tử) | Mọi câu lệnh trong transaction đều commit, hoặc không câu nào commit | Chuyển tiền dở dang (đã trừ tiền nhưng chưa cộng) |
| Consistency (Tính nhất quán) | Transaction đưa DB từ trạng thái hợp lệ này sang trạng thái hợp lệ khác, tôn trọng mọi ràng buộc | Vi phạm invariant (FK mồ côi, số dư âm qua một CHECK) |
| Isolation (Tính cô lập) | Các transaction đồng thời không làm hỏng lẫn nhau; kết quả trông như thể chạy tuần tự (tùy level) | Race condition giữa các user đồng thời |
| Durability (Tính bền vững) | Một khi đã commit, dữ liệu tồn tại qua crash/mất điện | Mất các write đã được xác nhận |
Ví dụ kinh điển là chuyển khoản ngân hàng: trừ tài khoản A và cộng tài khoản B. Atomicity đảm bảo bạn không bao giờ trừ mà không cộng; Consistency buộc không số dư nào âm; Isolation ngăn transaction khác đọc thấy số tiền đang “trên đường”; Durability đảm bảo một khi app được báo “thành công”, giao dịch vẫn còn sau khi crash.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- cả hai áp dụng nguyên tử; ROLLBACK sẽ hủy cả hai
Transaction & isolation level
Isolation là một dải quang phổ: isolation càng mạnh càng ngăn nhiều bất thường (anomaly) nhưng giảm concurrency (và có thể tăng locking/deadlock). Chuẩn SQL định nghĩa bốn level theo các read anomaly mà chúng cho phép.
Các read anomaly:
- Dirty read — đọc thấy thay đổi chưa commit của transaction khác.
- Non-repeatable read — đọc lại cùng một row trong một transaction cho ra giá trị khác vì transaction khác đã commit một
UPDATEở giữa. - Phantom read — chạy lại cùng một range query cho ra thêm row mới vì transaction khác đã commit một
INSERT/DELETEkhớp điều kiện. - Lost update / write skew — hai transaction đọc-rồi-ghi và một cái đè lên thay đổi của cái kia (write skew tinh vi hơn: hai lần đọc dữ liệu chồng lấn dẫn tới một kết quả chung không hợp lệ).
| Isolation level | Dirty read | Non-repeatable read | Phantom read | Ghi chú |
|---|---|---|---|---|
| Read Uncommitted | Có thể | Có thể | Có thể | Yếu nhất. Trong PostgreSQL nó hành xử như Read Committed. |
| Read Committed | Không | Có thể | Có thể | Mặc định ở PostgreSQL, Oracle, SQL Server. Mỗi câu lệnh thấy một snapshot mới. |
| Repeatable Read | Không | Không | Có thể* | Mặc định ở MySQL/InnoDB. *Repeatable Read của PostgreSQL (snapshot isolation) còn chặn cả phantom. |
| Serializable | Không | Không | Không | Mạnh nhất — như thể transaction chạy lần lượt từng cái. Có thể abort với serialization error mà bạn phải retry. |
Hướng dẫn thực tế: Read Committed là mặc định hợp lý cho hầu hết công việc OLTP. Dùng Serializable khi tính đúng đắn dưới concurrency là tối quan trọng (invariant tài chính, tồn kho) và sẵn sàng retry khi gặp serialization failure. Để ngăn lost update mà không cần serializable đầy đủ, dùng explicit locking (SELECT … FOR UPDATE) hoặc optimistic concurrency (một column version).
-- Pessimistic locking: khóa row để không ai khác update được cho tới khi ta commit
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- ... tính số dư mới trong app ...
UPDATE accounts SET balance = 50 WHERE id = 1;
COMMIT;
Locking và deadlock
Để đảm bảo isolation, database lấy lock — shared (read) lock và exclusive (write) lock — trên row, range hoặc table. Các engine hiện đại (PostgreSQL, Oracle, MySQL/InnoDB) dùng MVCC (Multi-Version Concurrency Control): reader thấy một snapshot nhất quán mà không chặn writer, và writer không chặn reader, giúp giảm đáng kể contention.
Deadlock xảy ra khi transaction A giữ lock mà B cần, còn B giữ lock mà A cần — một chu trình. Database phát hiện điều này và abort một transaction (“deadlock victim”) với một lỗi; app phải retry nó. Giảm deadlock bằng cách:
- Lấy lock theo thứ tự nhất quán xuyên suốt codebase (ví dụ luôn khóa account theo id tăng dần).
- Giữ transaction ngắn — đừng giữ lock qua các network call hay thời gian người dùng suy nghĩ.
- Dùng isolation level thấp nhất mà vẫn đúng.
Normalization (chuẩn hóa)
Normalization tổ chức các column và table để giảm dư thừa và ngăn các anomaly khi update/insert/delete. Mỗi normal form là một quy tắc chặt hơn được xây trên cái trước.
Xét một table orders chưa chuẩn hóa:
| order_id | customer_name | customer_email | products |
|---|---|---|---|
| 1 | Alice | a@x.com | ”Pen, Notebook” |
| 2 | Alice | a@x.com | ”Pen” |
Vấn đề: ô products không atomic, email của Alice bị lặp (update anomaly — sửa ở một row là không nhất quán), và bạn không thể lưu một customer chưa có order (insert anomaly).
- 1NF (First Normal Form) — giá trị atomic, không có nhóm lặp, mỗi row duy nhất. Tách “Pen, Notebook” thành các row/table riêng.
- 2NF — 1NF và mọi column không phải key phụ thuộc vào toàn bộ primary key (loại bỏ partial dependency; liên quan khi PK là composite).
- 3NF — 2NF và không có column không-key nào phụ thuộc vào một column không-key khác (loại bỏ transitive dependency). Email customer phụ thuộc customer, không phải order → chuyển nó sang
customers. - BCNF (Boyce–Codd) — một dạng 3NF chặt hơn: mọi determinant phải là một candidate key. Xử lý các trường hợp biên hiếm mà 3NF bỏ sót.
Kết quả sau chuẩn hóa:
customers(id PK, name, email)
products(id PK, name, price_cents)
orders(id PK, customer_id FK, created_at)
order_items(order_id FK, product_id FK, quantity, PRIMARY KEY(order_id, product_id))
Giờ email chỉ nằm ở đúng một chỗ, và mọi order có thể tham chiếu tới bất kỳ customer và bất kỳ product nào.
Nguyên tắc chung: mặc định chuẩn hóa tới 3NF. Đây là điểm cân bằng — đủ để tránh anomaly mà không phân mảnh quá mức.
Khi nào nên denormalize: cố ý đưa vào dư thừa để tăng tốc độ đọc khi các JOIN đã chuẩn hóa quá chậm ở quy mô lớn — ví dụ một column cache order_total_cents, một comment_count trên posts, hoặc một reporting table. Denormalization đánh đổi độ phức tạp khi ghi (bạn phải giữ các bản sao đồng bộ) lấy tốc độ đọc. Chỉ làm sau khi đã đo đạc, và ghi rõ invariant. Xem thêm về đánh đổi này ở Database Design & Scaling.
Index
Một index là một cấu trúc dữ liệu riêng cho phép database tìm row mà không cần quét toàn bộ table — giống như phần index cuối một cuốn sách. Kiểu index mặc định là B-tree (cây cân bằng), giữ các key được sắp xếp và hỗ trợ tra cứu bằng nhau (=), theo khoảng (<, >, BETWEEN), và prefix/ORDER BY với O(log n) thay vì O(n).
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
-- Giờ "WHERE customer_id = 42" là một index lookup nhanh thay vì full table scan.
Cách nó tăng tốc query: không có index, WHERE email = 'a@x.com' buộc phải làm sequential (full table) scan, đọc mọi row. Với một B-tree trên email, database đi xuống cây tới đúng leaf — chỉ vài lần đọc page kể cả trên hàng triệu row.
Composite (multi-column) index tuân theo quy tắc left-prefix: một index trên (a, b, c) phục vụ được các query lọc trên a, a,b, hoặc a,b,c, và có thể giúp ORDER BY a, b — nhưng không phục vụ query chỉ lọc trên b. Thứ tự column nên khớp với các predicate chọn lọc/phổ biến nhất của bạn.
CREATE INDEX idx_orders_cust_created ON orders(customer_id, created_at);
-- Phục vụ: WHERE customer_id = 42 ORDER BY created_at
-- KHÔNG phục vụ: WHERE created_at > '2026-01-01' (created_at không phải column dẫn đầu)
Covering index: nếu một index chứa tất cả column mà query cần, database trả lời hoàn toàn từ index mà không chạm vào table (một index-only scan). PostgreSQL hỗ trợ INCLUDE cho các column payload không phải key:
CREATE INDEX idx_orders_cust_covering
ON orders(customer_id) INCLUDE (total_cents, status);
-- SELECT total_cents, status FROM orders WHERE customer_id = 42; -> index-only scan
Các kiểu index khác (tùy engine): Hash (chỉ bằng nhau), GIN (full-text, JSONB, array), GiST/SP-GiST (hình học, range), BRIN (table cực lớn chỉ append), cùng partial index (WHERE status = 'active') và expression index (ON lower(email)).
Khi nào KHÔNG index:
- Mỗi index làm chậm write — mỗi
INSERT/UPDATE/DELETEphải bảo trì mọi index, và index tiêu tốn disk cùng cache. - Column low-cardinality (ví dụ boolean
is_activemà 90% là true) — index ít giúp ích vì không thu hẹp kết quả được nhiều (trừ khi là partial index trên giá trị hiếm). - Table nhỏ — full scan vốn đã rẻ.
- Column bị bọc trong hàm ở mệnh đề
WHERE(WHERE lower(email) = …) sẽ không dùng được index thường — bạn cần expression index. - Index dư thừa — một index trên
(a)là thừa nếu bạn đã có(a, b).
EXPLAIN cho biết execution plan mà query planner chọn; EXPLAIN ANALYZE thực sự chạy nó và báo cáo thời gian cùng số row thật. Đây là công cụ chính để chẩn đoán query chậm.
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42;
Đọc output để phân biệt Seq Scan (tệ trên table lớn — thường là thiếu index) với Index Scan / Index Only Scan (tốt), và so sánh số row ước lượng của planner với số row thực tế — chênh lệch lớn nghĩa là statistics cũ (chạy ANALYZE) hoặc một query mà planner khó ước lượng.
Khảo sát các RDBMS chính
| RDBMS | License | Phù hợp nhất cho | Điểm mạnh | Cần lưu ý |
|---|---|---|---|---|
| PostgreSQL | Open source (PostgreSQL License) | OLTP đa mục đích, query phức tạp, geospatial, JSON | Tuân chuẩn tốt, nhiều kiểu phong phú (JSONB, array, range), mở rộng được (PostGIS, extension), concurrency mạnh (MVCC), tính năng SQL đầy đủ (CTE, window func) | Trước đây nặng theo mỗi connection (dùng pooler như PgBouncer) |
| MySQL | Kép: GPL + thương mại (Oracle) | Web app, workload nặng đọc, LAMP stack | Phổ biến, đọc đơn giản nhanh, hệ sinh thái lớn, replication dễ, InnoDB vững | Vài điểm SQL khác thường, trước đây bộ tính năng yếu hơn; thuộc sở hữu Oracle |
| MariaDB | Open source (GPL) | Thay thế MySQL với một fork cộng đồng | Fork của MySQL, hầu hết tương thích wire, có thêm storage engine (ColumnStore), quản trị bởi cộng đồng | Dần khác biệt với MySQL; cần kiểm tra parity tính năng |
| Microsoft SQL Server | Thương mại (có bản Express/Developer miễn phí) | Doanh nghiệp/hệ .NET, hệ sinh thái Windows | Công cụ tuyệt vời (SSMS), T-SQL, tích hợp BI/analytics mạnh, hỗ trợ tốt | Chi phí khi mở rộng; trước đây thiên về Windows (nay chạy được trên Linux) |
| Oracle Database | Thương mại (đắt) | Doanh nghiệp lớn, hệ thống trọng yếu, workload phức tạp | Cực kỳ nhiều tính năng, clustering RAC, trưởng thành, tốt cho OLTP khổng lồ | Chi phí license và độ phức tạp cao; vendor lock-in |
| SQLite | Public domain | Nhúng, mobile, local/dev, app nhỏ, edge | Serverless (một file), không cần cấu hình, cực kỳ đáng tin, có mặt khắp nơi | Ghi đồng thời hạn chế (một writer); không dành cho server concurrency cao |
Cách chọn: cho một backend mới, PostgreSQL là mặc định an toàn — miễn phí, mạnh mẽ, và mở rộng cùng bạn. Chọn MySQL/MariaDB cho hệ sinh thái sẵn có hoặc do đội quen vận hành, SQL Server/Oracle khi cần hỗ trợ/công cụ doanh nghiệp hoặc do đầu tư sẵn có quyết định, và SQLite cho database nhúng, local, on-device hoặc dùng cho test.
Best Practices
- Luôn định nghĩa primary key cho mọi table; ưu tiên surrogate key
BIGINT/UUID. - Dùng foreign key để đảm bảo referential integrity — để database bảo vệ dữ liệu, đừng chỉ dựa vào code ứng dụng.
- Chuẩn hóa tới 3NF trước, rồi denormalize một cách có chủ đích và chỉ sau khi đo được một nút thắt đọc thực sự.
- Chọn đúng kiểu dữ liệu:
NUMERIC/DECIMALcho tiền (không bao giờ dùng float),TIMESTAMPTZtheo UTC cho thời gian, và ràng buộc bằngNOT NULL,CHECK,UNIQUE. - Bọc các thay đổi nhiều câu lệnh trong transaction, giữ chúng ngắn, và lấy lock theo thứ tự nhất quán để tránh deadlock; retry khi gặp lỗi serialization/deadlock.
- Index theo pattern query của bạn, không làm mù quáng. Index các column FK và các column
WHERE/JOIN/ORDER BYthường dùng; tôn trọng quy tắc left-prefix; xóa index không dùng/dư thừa. - Profile bằng
EXPLAIN ANALYZEtrước và sau khi thêm index; giữ statistics của table luôn mới (ANALYZE). - Không bao giờ dựng SQL bằng cách nối chuỗi — dùng parameterized query / prepared statement để chống SQL injection (xem security).
- Tránh
SELECT *trong code ứng dụng — nêu tên column để thay đổi schema không âm thầm phá vỡ code và để covering index có thể áp dụng. - Coi chừng N+1 query (một query cho mỗi row trong vòng lặp) — dùng JOIN hoặc tra cứu theo lô
IN (...)thay thế. Vấn đề này nặng nhất khi qua ORM; xem Database Design & Scaling. - Đặt đúng isolation level một cách có ý thức; đừng cho rằng mặc định trùng với mặc định của engine khác.
- Backup và test khôi phục. Một backup chưa test không phải là backup. Dùng point-in-time recovery cho production.
- Giảm tải các truy vấn đọc nặng bằng cache (Caching) và chỉ dùng NoSQL khi access pattern thực sự không hợp mô hình quan hệ.
Tài liệu tham khảo
- PostgreSQL Documentation — chuẩn vàng về SQL và nội bộ RDBMS
- PostgreSQL: Transaction Isolation
- PostgreSQL: Indexes
- Use The Index, Luke! — hướng dẫn sâu và thực tế về indexing và hiệu năng SQL
- MySQL Reference Manual
- SQLite: How it works & When to use
- Wikipedia: Database normalization
- Wikipedia: ACID
Part of the Backend Roadmap knowledge base.
Overview
A relational database stores data as a set of tables (relations), where each table holds rows (records) described by a fixed set of columns (attributes). Tables are linked to each other through keys, and you query and modify the data with SQL (Structured Query Language). This model — proposed by Edgar F. Codd in 1970 — has been the default way to store transactional business data for five decades because it combines a simple mental model (spreadsheets that reference each other) with strong mathematical foundations (relational algebra) and hard correctness guarantees (ACID transactions).
For a backend engineer, the relational database is usually the system of record: the authoritative, durable store of truth that everything else (caches, search indexes, analytics pipelines) is derived from. Getting the schema, the queries, and the transactional behavior right matters more than almost any other backend decision, because data outlives code — you will rewrite your service three times before you migrate your users table.
This note covers the relational model, SQL, ACID and transactions, isolation levels, normalization, indexing, and a survey of the major relational database management systems (RDBMS). Deeper cross-database engineering topics — ORMs, schema migrations, replication, sharding, and horizontal scaling — live in Database Design & Scaling. For non-tabular stores see NoSQL Databases, and for reducing database load see Caching.
Fundamentals
The relational model
The core idea: model your domain as relations (tables) and the relationships between them, then let a declarative query language do the retrieval. You describe what you want, not how to fetch it — the database’s query planner figures out the execution.
| Term | Relational theory | Practical name | Example |
|---|---|---|---|
| Relation | Relation | Table | customers |
| Tuple | Tuple | Row / record | one customer |
| Attribute | Attribute | Column / field | email |
| Domain | Domain | Data type + constraints | VARCHAR(255) NOT NULL |
| Cardinality | Number of tuples | Row count | 10,000 customers |
| Degree | Number of attributes | Column count | 6 columns |
Two rules distinguish a relation from a spreadsheet:
- Rows are unordered and unique — there is no “row 5”; you identify a row by its values (via a key), not by position.
- Each cell holds one atomic value — no lists or nested tables inside a single cell (that’s the “First Normal Form” rule, below).
Keys
Keys are how rows are identified and how tables reference one another.
- Primary key (PK) — one or more columns that uniquely identify each row. Cannot be
NULL, must be unique. Every table should have one. A surrogate key is a synthetic PK with no business meaning (an auto-incrementingidor aUUID); a natural key is a real-world unique attribute (an ISO country code). Surrogate keys are usually preferred because business values change. - Foreign key (FK) — a column that references the primary key of another table, enforcing referential integrity: you cannot insert an
orderfor acustomer_idthat doesn’t exist, and (depending on the rule) you cannot delete a customer who still has orders. - Candidate key — any column set that could serve as the PK (is unique + minimal). One is chosen as PK; the rest are alternate keys.
- Composite key — a key made of more than one column (common in join tables, e.g.
(order_id, product_id)). - Unique key / constraint — enforces uniqueness on a column that is not the PK (e.g.
email).
CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY, -- surrogate key
email VARCHAR(255) NOT NULL UNIQUE, -- alternate/candidate key
full_name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
total_cents INTEGER NOT NULL CHECK (total_cents >= 0),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Relationships
Foreign keys express three kinds of relationships:
| Relationship | Meaning | Implementation | Example |
|---|---|---|---|
| One-to-one (1:1) | One row relates to at most one row | FK with a UNIQUE constraint, or shared PK | user ↔ user_profile |
| One-to-many (1:N) | One row relates to many rows | FK on the “many” side | customer → many orders |
| Many-to-many (M:N) | Many relate to many | A junction/join table with two FKs | students ↔ courses via enrollments |
-- Many-to-many via a junction table
CREATE TABLE enrollments (
student_id BIGINT NOT NULL REFERENCES students(id),
course_id BIGINT NOT NULL REFERENCES courses(id),
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (student_id, course_id) -- composite PK prevents duplicate enrollment
);
Schema
A schema is the structure of the database: the tables, columns, types, and constraints. In PostgreSQL and SQL Server, “schema” is also a namespace inside a database (e.g. public.users, billing.invoices) used to group objects and manage permissions. Relational databases are schema-on-write: the shape is defined up front and enforced on every insert, which catches bad data early — the opposite of the schema-on-read approach common in NoSQL.
Data types (representative)
| Category | Common types | Notes |
|---|---|---|
| Integer | SMALLINT, INTEGER, BIGINT | Use BIGINT for IDs that grow |
| Exact numeric | NUMERIC(p,s) / DECIMAL | Use for money — never FLOAT |
| Floating point | REAL, DOUBLE PRECISION | Scientific values, not currency |
| Text | VARCHAR(n), TEXT, CHAR(n) | Prefer TEXT/VARCHAR; avoid CHAR |
| Date/time | DATE, TIME, TIMESTAMP, TIMESTAMPTZ | Store timestamps in UTC (TIMESTAMPTZ) |
| Boolean | BOOLEAN | |
| Binary | BYTEA, BLOB | Prefer object storage for large blobs |
| Structured | JSON/JSONB, arrays, UUID, ENUM | JSONB bridges relational + document models |
Key Concepts
SQL: DDL vs DML (and friends)
SQL is split into sub-languages by purpose:
| Sub-language | Purpose | Statements |
|---|---|---|
| DDL — Data Definition | Define/alter structure | CREATE, ALTER, DROP, TRUNCATE |
| DML — Data Manipulation | Read/change data | SELECT, INSERT, UPDATE, DELETE |
| DCL — Data Control | Permissions | GRANT, REVOKE |
| TCL — Transaction Control | Transaction boundaries | BEGIN, COMMIT, ROLLBACK, SAVEPOINT |
(Note: TRUNCATE is sometimes classified as DDL because it’s fast and typically not transactional/row-logged; behavior varies by engine.)
SELECT: the workhorse
Logical evaluation order matters more than the written order. SQL is written SELECT … FROM … WHERE … GROUP BY … HAVING … ORDER BY … LIMIT, but it’s evaluated roughly:
FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT
This is why you can’t use a SELECT column alias in WHERE (the alias doesn’t exist yet), but you can in ORDER BY.
SELECT status, COUNT(*) AS order_count, SUM(total_cents) AS revenue_cents
FROM orders
WHERE created_at >= '2026-01-01'
GROUP BY status
HAVING SUM(total_cents) > 100000 -- filter groups (aggregates)
ORDER BY revenue_cents DESC
LIMIT 10;
WHEREfilters rows before grouping;HAVINGfilters groups after aggregation.- Aggregate functions:
COUNT,SUM,AVG,MIN,MAX.COUNT(*)counts rows;COUNT(col)skipsNULLs.
JOINs
A JOIN combines rows from two tables based on a related column.
| Join | Returns |
|---|---|
INNER JOIN | Only rows with a match in both tables |
LEFT [OUTER] JOIN | All left rows; right columns are NULL when no match |
RIGHT [OUTER] JOIN | All right rows; left columns NULL when no match |
FULL [OUTER] JOIN | All rows from both; NULL where no match |
CROSS JOIN | Cartesian product (every left row × every right row) |
SELF JOIN | A table joined to itself (e.g. employee → manager) |
-- Customers and their total spend, including customers with zero orders
SELECT c.id, c.full_name, COALESCE(SUM(o.total_cents), 0) AS lifetime_cents
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.full_name
ORDER BY lifetime_cents DESC;
The LEFT JOIN + COALESCE pattern is how you keep rows that have no matching child (an INNER JOIN would drop customers with no orders).
Subqueries and CTEs
A subquery is a query nested inside another. A correlated subquery references the outer query and runs per outer row.
-- Scalar subquery: customers who spent above the overall average order value
SELECT full_name
FROM customers c
WHERE (SELECT AVG(total_cents) FROM orders o WHERE o.customer_id = c.id)
> (SELECT AVG(total_cents) FROM orders);
-- CTE (Common Table Expression) — often clearer than nested subqueries
WITH customer_spend AS (
SELECT customer_id, SUM(total_cents) AS spend
FROM orders
GROUP BY customer_id
)
SELECT c.full_name, cs.spend
FROM customer_spend cs
JOIN customers c ON c.id = cs.customer_id
WHERE cs.spend > 500000;
-- Window function: rank orders per customer without collapsing rows
SELECT id, customer_id, total_cents,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at) AS nth_order
FROM orders;
Prefer CTEs and window functions over deeply nested correlated subqueries — they read better and often plan better. EXISTS / IN / NOT EXISTS are the standard ways to test membership; NOT EXISTS is usually safer than NOT IN because NOT IN behaves surprisingly when the list contains NULL.
ACID properties
ACID is the set of guarantees a relational database makes about transactions — units of work that must succeed or fail as a whole.
| Property | Guarantee | What it prevents |
|---|---|---|
| Atomicity | All statements in a transaction commit, or none do | Half-completed transfers (money debited but not credited) |
| Consistency | A transaction moves the DB from one valid state to another, respecting all constraints | Broken invariants (orphan FK, negative balance via a CHECK) |
| Isolation | Concurrent transactions don’t corrupt each other; results look as if run serially (depending on level) | Race conditions between concurrent users |
| Durability | Once committed, data survives crashes/power loss | Losing acknowledged writes |
The canonical example is a bank transfer: debit account A and credit account B. Atomicity guarantees you never debit without crediting; Consistency enforces that no balance goes negative; Isolation stops another transaction from reading the money “in transit”; Durability ensures that once the app is told “success”, the transfer survives a crash.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- both apply atomically; ROLLBACK would undo both
Transactions & isolation levels
Isolation is a spectrum: stronger isolation prevents more anomalies but reduces concurrency (and can increase locking/deadlocks). The SQL standard defines four levels by which read anomalies they permit.
The read anomalies:
- Dirty read — reading another transaction’s uncommitted changes.
- Non-repeatable read — re-reading the same row in one transaction returns different values because another transaction committed an
UPDATEin between. - Phantom read — re-running the same range query returns new rows because another transaction committed an
INSERT/DELETEmatching the predicate. - Lost update / write skew — two transactions read-then-write and one overwrites the other’s change (write skew is subtler: two reads of overlapping data lead to a jointly-invalid result).
| Isolation level | Dirty read | Non-repeatable read | Phantom read | Notes |
|---|---|---|---|---|
| Read Uncommitted | Possible | Possible | Possible | Weakest. In PostgreSQL it behaves as Read Committed. |
| Read Committed | No | Possible | Possible | Default in PostgreSQL, Oracle, SQL Server. Each statement sees a fresh snapshot. |
| Repeatable Read | No | No | Possible* | Default in MySQL/InnoDB. *PostgreSQL’s Repeatable Read (snapshot isolation) also blocks phantoms. |
| Serializable | No | No | No | Strongest — as if transactions ran one at a time. May abort with serialization errors you must retry. |
Practical guidance: Read Committed is the right default for most OLTP work. Reach for Serializable when correctness under concurrency is critical (financial invariants, inventory) and be ready to retry on serialization failures. To prevent lost updates without full serializable, use explicit locking (SELECT … FOR UPDATE) or optimistic concurrency (a version column).
-- Pessimistic locking: lock the row so no one else can update it until we commit
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- ... compute new balance in app ...
UPDATE accounts SET balance = 50 WHERE id = 1;
COMMIT;
Locking and deadlocks
To provide isolation, databases take locks — shared (read) locks and exclusive (write) locks — on rows, ranges, or tables. Modern engines (PostgreSQL, Oracle, MySQL/InnoDB) use MVCC (Multi-Version Concurrency Control): readers see a consistent snapshot without blocking writers, and writers don’t block readers, which dramatically reduces contention.
A deadlock happens when transaction A holds a lock B wants, and B holds a lock A wants — a cycle. The database detects this and aborts one transaction (a “deadlock victim”) with an error; the app must retry it. Reduce deadlocks by:
- Acquiring locks in a consistent order across your codebase (e.g. always lock accounts by ascending id).
- Keeping transactions short — don’t hold locks across network calls or user think-time.
- Using the lowest isolation level that’s correct.
Normalization
Normalization organizes columns and tables to reduce redundancy and prevent update/insert/delete anomalies. Each normal form is a stricter rule built on the previous one.
Consider an unnormalized orders table:
| order_id | customer_name | customer_email | products |
|---|---|---|---|
| 1 | Alice | a@x.com | ”Pen, Notebook” |
| 2 | Alice | a@x.com | ”Pen” |
Problems: the products cell isn’t atomic, Alice’s email is duplicated (update anomaly — change it in one row and it’s inconsistent), and you can’t record a customer with no order (insert anomaly).
- 1NF (First Normal Form) — atomic values, no repeating groups, each row unique. Split “Pen, Notebook” into separate rows/tables.
- 2NF — 1NF and every non-key column depends on the whole primary key (removes partial dependency; relevant when the PK is composite).
- 3NF — 2NF and no non-key column depends on another non-key column (removes transitive dependency). Customer email depends on the customer, not the order → move it to
customers. - BCNF (Boyce–Codd) — a stricter 3NF: every determinant must be a candidate key. Handles rare edge cases 3NF misses.
Normalized result:
customers(id PK, name, email)
products(id PK, name, price_cents)
orders(id PK, customer_id FK, created_at)
order_items(order_id FK, product_id FK, quantity, PRIMARY KEY(order_id, product_id))
Now the email lives in exactly one place, and any order can reference any customer and any products.
Rule of thumb: normalize to 3NF by default. It’s the sweet spot — enough to avoid anomalies without over-fragmenting.
When to denormalize: deliberately introduce redundancy for read performance when normalized JOINs are too slow at scale — e.g. a cached order_total_cents column, a comment_count on posts, or a reporting table. Denormalization trades write complexity (you must keep copies in sync) for read speed. Only do it after measuring, and document the invariant. More on this trade-off in Database Design & Scaling.
Indexes
An index is a separate data structure that lets the database find rows without scanning the whole table — like the index at the back of a book. The default index type is the B-tree (balanced tree), which keeps keys sorted and supports equality (=), range (<, >, BETWEEN), and prefix/ORDER BY lookups in O(log n) instead of O(n).
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
-- Now "WHERE customer_id = 42" is a fast index lookup instead of a full table scan.
How it speeds queries: without an index, WHERE email = 'a@x.com' forces a sequential (full table) scan, reading every row. With a B-tree on email, the database walks the tree to the exact leaf — a handful of page reads even on millions of rows.
Composite (multi-column) indexes follow the left-prefix rule: an index on (a, b, c) can serve queries filtering on a, a,b, or a,b,c, and can help ORDER BY a, b — but not a query filtering only on b. Column order should match your most selective/common predicates.
CREATE INDEX idx_orders_cust_created ON orders(customer_id, created_at);
-- Serves: WHERE customer_id = 42 ORDER BY created_at
-- Does NOT serve: WHERE created_at > '2026-01-01' (created_at isn't the leading column)
Covering index: if an index contains all columns a query needs, the database answers entirely from the index without touching the table (an index-only scan). PostgreSQL supports INCLUDE for non-key payload columns:
CREATE INDEX idx_orders_cust_covering
ON orders(customer_id) INCLUDE (total_cents, status);
-- SELECT total_cents, status FROM orders WHERE customer_id = 42; -> index-only scan
Other index types (engine-dependent): Hash (equality only), GIN (full-text, JSONB, arrays), GiST/SP-GiST (geometric, ranges), BRIN (huge append-only tables), and partial indexes (WHERE status = 'active') and expression indexes (ON lower(email)).
When NOT to index:
- Every index slows down writes — each
INSERT/UPDATE/DELETEmust maintain every index, and indexes consume disk and cache. - Low-cardinality columns (e.g. a boolean
is_activewhere 90% are true) — the index rarely helps because it doesn’t narrow results much (unless it’s a partial index on the rare value). - Small tables — a full scan is already cheap.
- Columns wrapped in functions in the
WHEREclause (WHERE lower(email) = …) won’t use a plain index — you need an expression index. - Redundant indexes — an index on
(a)is redundant if you already have(a, b).
EXPLAIN shows the query planner’s chosen execution plan; EXPLAIN ANALYZE actually runs it and reports real timing and row counts. This is the primary tool for diagnosing slow queries.
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42;
Read the output for Seq Scan (bad on a large table — usually a missing index) vs Index Scan / Index Only Scan (good), and compare the planner’s estimated rows against actual rows — a large gap means stale statistics (run ANALYZE) or a query the planner can’t estimate well.
Survey of major RDBMS
| RDBMS | License | Best for | Strengths | Watch-outs |
|---|---|---|---|---|
| PostgreSQL | Open source (PostgreSQL License) | General-purpose OLTP, complex queries, geospatial, JSON | Standards-compliant, rich types (JSONB, arrays, ranges), extensible (PostGIS, extensions), strong concurrency (MVCC), robust SQL features (CTEs, window funcs) | Historically heavier per-connection (use a pooler like PgBouncer) |
| MySQL | Dual: GPL + commercial (Oracle) | Web apps, read-heavy workloads, LAMP stack | Ubiquitous, fast simple reads, huge ecosystem, easy replication, InnoDB is solid | Some SQL quirks, historically weaker feature set; ownership by Oracle |
| MariaDB | Open source (GPL) | Drop-in MySQL replacement wanting a community fork | Fork of MySQL, mostly wire-compatible, extra storage engines (ColumnStore), community-governed | Diverging from MySQL over time; check feature parity |
| Microsoft SQL Server | Commercial (free Express/Developer editions) | Enterprise/.NET shops, Windows ecosystems | Excellent tooling (SSMS), T-SQL, strong BI/analytics integration, robust support | Cost at scale; historically Windows-centric (now runs on Linux) |
| Oracle Database | Commercial (expensive) | Large enterprises, mission-critical, complex workloads | Extremely feature-rich, RAC clustering, mature, great for huge OLTP | High license cost and complexity; vendor lock-in |
| SQLite | Public domain | Embedded, mobile, local/dev, small apps, edge | Serverless (single file), zero-config, incredibly reliable, ships everywhere | Limited concurrent writes (single writer); not for high-concurrency servers |
Choosing: for a new backend, PostgreSQL is the safe default — free, powerful, and it scales with you. Pick MySQL/MariaDB for existing ecosystems or ops familiarity, SQL Server/Oracle when enterprise support/tooling or an existing investment dictates it, and SQLite for embedded, local, on-device, or test databases.
Best Practices
- Always define a primary key on every table; prefer a surrogate
BIGINT/UUIDkey. - Use foreign keys to enforce referential integrity — let the database protect your data, don’t rely only on application code.
- Normalize to 3NF first, then denormalize deliberately and only after measuring a real read bottleneck.
- Pick the right types:
NUMERIC/DECIMALfor money (never floats),TIMESTAMPTZin UTC for time, and constrain withNOT NULL,CHECK,UNIQUE. - Wrap multi-statement changes in transactions, keep them short, and acquire locks in a consistent order to avoid deadlocks; retry serialization/deadlock errors.
- Index for your query patterns, not blindly. Index FK columns and frequent
WHERE/JOIN/ORDER BYcolumns; respect the left-prefix rule; remove unused/redundant indexes. - Profile with
EXPLAIN ANALYZEbefore and after adding an index; keep table statistics fresh (ANALYZE). - Never build SQL by string concatenation — use parameterized queries / prepared statements to prevent SQL injection (see security).
- Avoid
SELECT *in application code — name columns so schema changes don’t silently break you and covering indexes can apply. - Beware N+1 queries (one query per row in a loop) — use JOINs or batched
IN (...)lookups instead. This bites hardest through ORMs; see Database Design & Scaling. - Set the right isolation level consciously; don’t assume the default matches another engine’s default.
- Back up and test restores. An untested backup is not a backup. Use point-in-time recovery for production.
- Offload heavy reads with a cache (Caching) and reach for NoSQL only when the access pattern genuinely doesn’t fit a relational model.
References
- PostgreSQL Documentation — the gold standard for SQL and RDBMS internals
- PostgreSQL: Transaction Isolation
- PostgreSQL: Indexes
- Use The Index, Luke! — a deep, practical guide to SQL indexing and performance
- MySQL Reference Manual
- SQLite: How it works & When to use
- Wikipedia: Database normalization
- Wikipedia: ACID