← Backend← Backend
BackendBackend19 Th7, 2026Jul 19, 202623 phút đọc19 min read

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ụ
RelationRelationTablecustomers
TupleTupleRow / recordmột customer
AttributeAttributeColumn / fieldemail
DomainDomainKiểu dữ liệu + ràng buộcVARCHAR(255) NOT NULL
CardinalitySố lượng tupleSố row10.000 customer
DegreeSố lượng attributeSố column6 column

Hai quy tắc phân biệt một relation với một bảng tính:

  1. 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í.
  2. 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.

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ĩaCách hiện thựcVí dụ
One-to-one (1:1)Một row liên kết với tối đa một rowFK kèm ràng buộc UNIQUE, hoặc dùng chung PKuseruser_profile
One-to-many (1:N)Một row liên kết với nhiều rowFK ở phía “many”customer → nhiều orders
Many-to-many (M:N)Nhiều liên kết với nhiềuJunction/join table với hai FKstudentscourses 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ómKiểu thường gặpGhi chú
Số nguyênSMALLINT, INTEGER, BIGINTDùng BIGINT cho ID sẽ tăng nhiều
Số chính xácNUMERIC(p,s) / DECIMALDùng cho tiền tệ — không dùng FLOAT
Số thực dấu phẩy độngREAL, DOUBLE PRECISIONCho giá trị khoa học, không phải tiền
Văn bảnVARCHAR(n), TEXT, CHAR(n)Ưu tiên TEXT/VARCHAR; tránh CHAR
Ngày/giờDATE, TIME, TIMESTAMP, TIMESTAMPTZLưu timestamp theo UTC (TIMESTAMPTZ)
BooleanBOOLEAN
Nhị phânBYTEA, BLOBNên dùng object storage cho blob lớn
Có cấu trúcJSON/JSONB, array, UUID, ENUMJSONB 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 conMục đíchCâu lệnh
DDL — Data DefinitionĐịnh nghĩa/thay đổi cấu trúcCREATE, ALTER, DROP, TRUNCATE
DML — Data ManipulationĐọc/thay đổi dữ liệuSELECT, INSERT, UPDATE, DELETE
DCL — Data ControlPhân quyềnGRANT, REVOKE
TCL — Transaction ControlRanh giới transactionBEGIN, 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:

FROMJOINWHEREGROUP BYHAVINGSELECTDISTINCTORDER BYLIMIT

Đây là lý do bạn không thể dùng alias của cột trong SELECTWHERE (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;

JOIN

JOIN kết hợp các row từ hai table dựa trên một column liên quan.

JoinTrả về
INNER JOINChỉ những row có match ở cả hai table
LEFT [OUTER] JOINTất cả row bên trái; cột bên phải là NULL khi không match
RIGHT [OUTER] JOINTất cả row bên phải; cột bên trái NULL khi không match
FULL [OUTER] JOINTất cả row của cả hai; NULL ở nơi không match
CROSS JOINTích Descartes (mỗi row trái × mỗi row phải)
SELF JOINMộ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 INNOT 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ảoNgă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 commitChuyể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ộcVi 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ệnMấ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:

Isolation levelDirty readNon-repeatable readPhantom readGhi chú
Read UncommittedCó thểCó thểCó thểYếu nhất. Trong PostgreSQL nó hành xử như Read Committed.
Read CommittedKhôngCó thểCó thểMặc định ở PostgreSQL, Oracle, SQL Server. Mỗi câu lệnh thấy một snapshot mới.
Repeatable ReadKhôngKhôngCó thể*Mặc định ở MySQL/InnoDB. *Repeatable Read của PostgreSQL (snapshot isolation) còn chặn cả phantom.
SerializableKhôngKhôngKhôngMạ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:

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_idcustomer_namecustomer_emailproducts
1Alicea@x.com”Pen, Notebook”
2Alicea@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).

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:

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

RDBMSLicensePhù hợp nhất choĐiểm mạnhCần lưu ý
PostgreSQLOpen source (PostgreSQL License)OLTP đa mục đích, query phức tạp, geospatial, JSONTuâ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)
MySQLKép: GPL + thương mại (Oracle)Web app, workload nặng đọc, LAMP stackPhổ biến, đọc đơn giản nhanh, hệ sinh thái lớn, replication dễ, InnoDB vữngVà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
MariaDBOpen source (GPL)Thay thế MySQL với một fork cộng đồngFork 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 đồngDần khác biệt với MySQL; cần kiểm tra parity tính năng
Microsoft SQL ServerThương mại (có bản Express/Developer miễn phí)Doanh nghiệp/hệ .NET, hệ sinh thái WindowsCông cụ tuyệt vời (SSMS), T-SQL, tích hợp BI/analytics mạnh, hỗ trợ tốtChi phí khi mở rộng; trước đây thiên về Windows (nay chạy được trên Linux)
Oracle DatabaseThương mại (đắt)Doanh nghiệp lớn, hệ thống trọng yếu, workload phức tạpCự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
SQLitePublic domainNhúng, mobile, local/dev, app nhỏ, edgeServerless (một file), không cần cấu hình, cực kỳ đáng tin, có mặt khắp nơiGhi đồ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

Tài liệu tham khảo

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.

TermRelational theoryPractical nameExample
RelationRelationTablecustomers
TupleTupleRow / recordone customer
AttributeAttributeColumn / fieldemail
DomainDomainData type + constraintsVARCHAR(255) NOT NULL
CardinalityNumber of tuplesRow count10,000 customers
DegreeNumber of attributesColumn count6 columns

Two rules distinguish a relation from a spreadsheet:

  1. Rows are unordered and unique — there is no “row 5”; you identify a row by its values (via a key), not by position.
  2. 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.

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:

RelationshipMeaningImplementationExample
One-to-one (1:1)One row relates to at most one rowFK with a UNIQUE constraint, or shared PKuseruser_profile
One-to-many (1:N)One row relates to many rowsFK on the “many” sidecustomer → many orders
Many-to-many (M:N)Many relate to manyA junction/join table with two FKsstudentscourses 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)

CategoryCommon typesNotes
IntegerSMALLINT, INTEGER, BIGINTUse BIGINT for IDs that grow
Exact numericNUMERIC(p,s) / DECIMALUse for money — never FLOAT
Floating pointREAL, DOUBLE PRECISIONScientific values, not currency
TextVARCHAR(n), TEXT, CHAR(n)Prefer TEXT/VARCHAR; avoid CHAR
Date/timeDATE, TIME, TIMESTAMP, TIMESTAMPTZStore timestamps in UTC (TIMESTAMPTZ)
BooleanBOOLEAN
BinaryBYTEA, BLOBPrefer object storage for large blobs
StructuredJSON/JSONB, arrays, UUID, ENUMJSONB bridges relational + document models

Key Concepts

SQL: DDL vs DML (and friends)

SQL is split into sub-languages by purpose:

Sub-languagePurposeStatements
DDL — Data DefinitionDefine/alter structureCREATE, ALTER, DROP, TRUNCATE
DML — Data ManipulationRead/change dataSELECT, INSERT, UPDATE, DELETE
DCL — Data ControlPermissionsGRANT, REVOKE
TCL — Transaction ControlTransaction boundariesBEGIN, 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:

FROMJOINWHEREGROUP BYHAVINGSELECTDISTINCTORDER BYLIMIT

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;

JOINs

A JOIN combines rows from two tables based on a related column.

JoinReturns
INNER JOINOnly rows with a match in both tables
LEFT [OUTER] JOINAll left rows; right columns are NULL when no match
RIGHT [OUTER] JOINAll right rows; left columns NULL when no match
FULL [OUTER] JOINAll rows from both; NULL where no match
CROSS JOINCartesian product (every left row × every right row)
SELF JOINA 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.

PropertyGuaranteeWhat it prevents
AtomicityAll statements in a transaction commit, or none doHalf-completed transfers (money debited but not credited)
ConsistencyA transaction moves the DB from one valid state to another, respecting all constraintsBroken invariants (orphan FK, negative balance via a CHECK)
IsolationConcurrent transactions don’t corrupt each other; results look as if run serially (depending on level)Race conditions between concurrent users
DurabilityOnce committed, data survives crashes/power lossLosing 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:

Isolation levelDirty readNon-repeatable readPhantom readNotes
Read UncommittedPossiblePossiblePossibleWeakest. In PostgreSQL it behaves as Read Committed.
Read CommittedNoPossiblePossibleDefault in PostgreSQL, Oracle, SQL Server. Each statement sees a fresh snapshot.
Repeatable ReadNoNoPossible*Default in MySQL/InnoDB. *PostgreSQL’s Repeatable Read (snapshot isolation) also blocks phantoms.
SerializableNoNoNoStrongest — 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:

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_idcustomer_namecustomer_emailproducts
1Alicea@x.com”Pen, Notebook”
2Alicea@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).

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:

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

RDBMSLicenseBest forStrengthsWatch-outs
PostgreSQLOpen source (PostgreSQL License)General-purpose OLTP, complex queries, geospatial, JSONStandards-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)
MySQLDual: GPL + commercial (Oracle)Web apps, read-heavy workloads, LAMP stackUbiquitous, fast simple reads, huge ecosystem, easy replication, InnoDB is solidSome SQL quirks, historically weaker feature set; ownership by Oracle
MariaDBOpen source (GPL)Drop-in MySQL replacement wanting a community forkFork of MySQL, mostly wire-compatible, extra storage engines (ColumnStore), community-governedDiverging from MySQL over time; check feature parity
Microsoft SQL ServerCommercial (free Express/Developer editions)Enterprise/.NET shops, Windows ecosystemsExcellent tooling (SSMS), T-SQL, strong BI/analytics integration, robust supportCost at scale; historically Windows-centric (now runs on Linux)
Oracle DatabaseCommercial (expensive)Large enterprises, mission-critical, complex workloadsExtremely feature-rich, RAC clustering, mature, great for huge OLTPHigh license cost and complexity; vendor lock-in
SQLitePublic domainEmbedded, mobile, local/dev, small apps, edgeServerless (single file), zero-config, incredibly reliable, ships everywhereLimited 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

References