Dữ liệu thật của ứng dụng nằm trong cơ sở dữ liệu. SQLite giữ tất cả trong một file — cùng đi qua cách bảng hoạt động, cách chúng liên kết, và cách SQLite tìm nhanh dòng dữ liệu, mỗi truy vấn đặt cạnh kết quả thật.
← / → để chuyển slide · chuyển EN/VI bên dưới · 60 slide ví dụ
Người ship dành nhiều thời gian hiểu dữ liệu, không kém gì code.
Code của ứng dụng bạn quyết định điều gì xảy ra: quy tắc, luồng, UI. Đó là phần chỉ dẫn, không phải nội dung.
Người dùng, đơn hàng, sản phẩm — những thứ sống sót sau khi ứng dụng khởi động lại. Mất chúng là mất luôn cả doanh nghiệp.
Bảng, chỉ mục, toàn bộ dữ liệu — một file như shop.db, chạy như thư viện trong tiến trình ứng dụng. Không cần cài đặt hay khởi động gì.
Sao lưu CSDL là copy một file. Chuyển sang máy khác là gửi một file.
Một file chứa từ 1 dòng tới hàng triệu dòng. Lựa chọn mặc định cho prototype, app mobile/desktop và web app nhỏ.
Gỡ lỗi, xuất dữ liệu, sửa nhanh, 'vì sao dòng này sai?' — bạn sẽ đọc và viết dữ liệu này liên tục.
File không phải byte ngẫu nhiên — nó được tổ chức để nhanh.
File là chuỗi các trang cỡ cố định (thường 4 KB). Bảng và chỉ mục nằm trong các trang này.
Mỗi bảng là một B-tree — cấu trúc cân bằng, đã sắp xếp, giúp SQLite nhảy tới dòng thay vì đọc cả file.
Chỉ mục là một cây sắp xếp riêng, chỉ từ giá trị cột tới trang chứa dòng. Chi tiết ở Phần 4.
Write-ahead log cho phép reader vẫn đọc trong lúc có ghi — song song mượt mà cho app nhỏ.
Từ vựng giống bảng tính, nhưng có cấu trúc — định nghĩa một lần, dùng ở khắp nơi.
Một tập hợp các thứ liên quan: bảng users, bảng orders.
Một bản ghi — một người dùng, một đơn hàng.
Một thuộc tính, cùng loại giá trị ở mọi dòng: email, total, status.
Hình dạng dữ liệu — có những bảng nào, cột gì, cho phép gì. Khai báo một lần.
-- shop.db — the whole database is this one file
CREATE TABLE users (
id INTEGER PRIMARY KEY, -- unique per user
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER, -- -> users.id (foreign key)
total REAL,
status TEXT
);
| id | name | |
|---|---|---|
| 1 | Ana | ana@ex.com |
| 2 | Bo | bo@ex.com |
| 3 | Cyd | cyd@ex.com |
| id | user_id | total | status |
|---|---|---|---|
| 10 | 1 | 120.00 | paid |
| 11 | 1 | 45.00 | shipped |
| 12 | 2 | 80.00 | paid |
Chúng ta dùng cửa hàng này (users + orders + line_items) ở gần như mọi ví dụ bên dưới. Chú ý đường liên kết đã xuất hiện: orders.user_id trỏ tới users.id.
Năm động từ đáp ứng gần như mọi việc ứng dụng làm với dữ liệu. Bạn cần đọc hiểu trôi chảy; AI sẽ viết giúp bạn.
CREATE TABLE users (id INTEGER PRIMARY KEY,
name TEXT, email TEXT);
INSERT INTO users (id, name, email) VALUES
(1, 'Ana', 'ana@ex.com'),
(2, 'Bo', 'bo@ex.com'),
(3, 'Cyd', 'cyd@ex.com'),
(4, 'Dee', 'dee@ex.com');
| id | name | |
|---|---|---|
| 1 | Ana | ana@ex.com |
| 2 | Bo | bo@ex.com |
| 3 | Cyd | cyd@ex.com |
| 4 | Dee | dee@ex.com |
Tạo ra → thêm vào. INSERT thêm dòng vào bảng — mỗi dòng là một bản ghi hoàn chỉnh.
SELECT name, email FROM users
WHERE id = 1;
UPDATE users SET name = 'Anna'
WHERE id = 1;
DELETE FROM users WHERE id = 4;
| name | |
|---|---|
| Ana | ana@ex.com |
| id | name | |
|---|---|---|
| 1 | Anna | ana@ex.com |
| 2 | Bo | bo@ex.com |
| 3 | Cyd | cyd@ex.com |
| 4 | Dee | dee@ex.com |
| id | name | |
|---|---|---|
| 1 | Anna | ana@ex.com |
| 2 | Bo | bo@ex.com |
| 3 | Cyd | cyd@ex.com |
Đọc ra → sửa → xóa. Vòng lặp CREATE → INSERT → SELECT → UPDATE → DELETE là 95% SQL hằng ngày của ứng dụng.
SQLite khá thoáng ('type affinity' — tương thích kiểu): không than vãn nếu bạn lưu kiểu lạ. Nhưng hãy chọn kiểu hợp lý: so sánh, sắp xếp và chỉ mục sẽ hoạt động như mong đợi.
| Kiểu | Dùng cho | Giá trị ví dụ |
|---|---|---|
| INTEGER | id, số đếm, cờ, năm | id = 1 · qty = 2 |
| TEXT | chuỗi: tên, email, trạng thái | 'Ana' · 'paid' |
| REAL | tiền, đo lường | 120.50 |
| BLOB | dữ liệu nhị phân: ảnh, file | byte của ảnh |
| TIMESTAMP | thời gian (lưu dạng text/số) | '2026-10-01 12:00' |
CREATE TABLE users (
id INTEGER PRIMARY KEY, -- auto id
name TEXT NOT NULL, -- may not be empty
email TEXT NOT NULL UNIQUE, -- one email, ever
age INTEGER DEFAULT 30, -- default when omitted
vip INTEGER NOT NULL DEFAULT 0
CHECK (vip IN (0, 1)) -- only 0 or 1
);
Ràng buộc là hàng rào an toàn: chính CSDL từ chối dòng sai, nên ứng dụng không thể làm hỏng dữ liệu. Một lúc nữa bạn sẽ thấy các lần từ chối.
Chính năm dòng sẽ dùng trong các ví dụ tiếp theo.
-- every row, every column
SELECT * FROM users;
-- only the columns you need
SELECT name, email FROM users;
-- cap the number of rows
SELECT name FROM users LIMIT 2;
| name |
|---|
| Ana |
| Bo |
SELECT là động lực chính — phần lớn traffic của app là đọc. * tiện khi dùng CLI; trong code app, hãy liệt kê cột cần.
WHERE là nơi diễn ra việc lọc — và (Phần 4) nơi chỉ mục khiến nó nhanh.
| Toán tử | Công dụng | Ví dụ |
|---|---|---|
| = != | khớp chính xác / khác | WHERE status = 'paid' |
| < > <= >= | so sánh số hoặc ngày | WHERE total > 100 |
| LIKE | mẫu text có ký tự đại diện | WHERE name LIKE 'A%' |
| IN NOT IN | giá trị nằm (không) trong danh sách | WHERE status IN ('paid','shipped') |
| AND OR | kết hợp các điều kiện | WHERE total > 50 AND status = 'paid' |
-- 4 users: Ana, Bo, Cyd, Dee
-- 4 orders: 10 (u1, 120.00, paid) 11 (u1, 45.00, shipped)
-- 12 (u2, 80.00, paid) 13 (u4, 9.50, pending)
SELECT name FROM users
WHERE name = 'Bo';
SELECT * FROM orders
WHERE total > 50;
| name |
|---|
| Bo |
| id | user_id | total | status |
|---|---|---|---|
| 10 | 1 | 120.00 | paid |
| 12 | 2 | 80.00 | paid |
Đọc mỗi truy vấn như một câu: 'cho tôi những dòng thỏa điều kiện'. Kết quả chỉ là các dòng khớp.
| Mẫu | Ý nghĩa | Sản phẩm khớp |
|---|---|---|
| k% | bắt đầu bằng k | keyboard |
| %r | kết thúc bằng r | keyboard |
| %r | kết thúc bằng r | monitor |
| m%e | đầu m, cuối e, giữa tùy ý | mouse |
| %hub | kết thúc bằng 'hub' | usb-c hub |
| _onitor | một ký tự bất kỳ, rồi 'onitor' | monitor |
Mỗi dòng tô sáng = một sản phẩm khớp mẫu đó (trên cả 5 sản phẩm: keyboard, mouse, usb-c hub, monitor, adapter). % = chuỗi bất kỳ (kể cả rỗng) · _ = đúng một ký tự — cách làm truy vấn 'ô tìm kiếm'.
SELECT * FROM orders
WHERE status = 'paid' AND total > 100;
SELECT name FROM users
WHERE name = 'Bo' OR name = 'Dee';
SELECT * FROM orders
WHERE status IN ('paid','shipped');
| id | user_id | total | status |
|---|---|---|---|
| 10 | 1 | 120.00 | paid |
| name |
|---|
| Bo |
| Dee |
| id | user_id | total | status |
|---|---|---|---|
| 10 | 1 | 120.00 | paid |
| 11 | 1 | 45.00 | shipped |
| 12 | 2 | 80.00 | paid |
AND = chặt hơn (ít kết quả hơn), OR = rộng hơn. IN chỉ là cách viết gọn của chuỗi = OR = OR ...
Khóa là thứ khiến các dòng đáng tin cậy và các bảng liên kết được.
Cột định danh mỗi dòng — thường là id. Duy nhất, không rỗng, dùng để tìm dòng.
Cột trỏ tới PK của bảng khác — orders.user_id → users.id. Đây là cách các bảng liên kết.
Ràng buộc: không có hai dòng nào dùng chung giá trị này — ví dụ email, để không ai đăng ký trùng.
Mọi thao tác ghi sai bên dưới đều bị SQLite từ chối — ứng dụng không bao giờ thấy dòng đó.
-- biggest first
SELECT * FROM orders
ORDER BY total DESC;
-- first two, alphabetically
SELECT name FROM users
ORDER BY name LIMIT 2;
-- result: Ana, Bo
| id | user_id | total | status |
|---|---|---|---|
| 10 | 1 | 120.00 | paid |
| 12 | 2 | 80.00 | paid |
| 11 | 1 | 45.00 | shipped |
| 13 | 4 | 9.50 | pending |
ORDER BY sắp xếp (mặc định ASC tăng dần, DESC giảm dần); LIMIT giới hạn số dòng. Kết hợp thành 'top 10', '20 mới nhất', phân trang.
-- how many orders total?
SELECT COUNT(*) FROM orders; -- 4
-- per user: how many orders, how much spent?
SELECT user_id, COUNT(*) AS n, SUM(total) AS spent
FROM orders
GROUP BY user_id;
| user_id | n | spent |
|---|---|---|
| 1 | 2 | 165.0 |
| 2 | 1 | 80.0 |
| 4 | 1 | 9.5 |
GROUP BY gom các dòng thành một dòng cho mỗi nhóm, rồi hàm tổng hợp tóm tắt từng nhóm. Cyd (id 3) không có đơn nên không có nhóm.
import sqlite3
conn = sqlite3.connect("shop.db")
row = conn.execute("SELECT name FROM users WHERE id = ?", (1,))
print(row.fetchone()) # ('Ana',)
sql = "SELECT name, total FROM orders WHERE user_id =?"
rows = conn.execute(sql, (1,)).fetchall()
print(rows) # [('Ana', 120.0), ('Ana', 45.0)]
connect → execute(truy vấn, giá trị) → fetch. SQL bên trong chính là những gì bạn đã đọc — ứng dụng chỉ là người đưa thư.
Một chương trình terminal nhỏ: mở file, xem, truy vấn, thoát.
Quyết định thiết kế quan trọng nhất của mọi ứng dụng không phải code — mà là có những bảng nào và chúng liên kết thế nào.
Cách bạn chia dữ liệu là quyết định thiết kế quan trọng nhất — kiến trúc, chứ không phải cú pháp.
Mỗi đơn hàng lặp lại tên & địa chỉ người dùng. Sửa địa chỉ → phải cập nhật 500 dòng. Lặp lại dữ liệu sinh ra lỗi.
Và bảng phình to, cùng những giá trị lặp đi lặp lại.
Thông tin người dùng nằm một lần ở users; mỗi đơn chỉ lưu user_id. Sửa một lần — JOIN ghép lại khi cần.
Đây là 'chuẩn hóa' — nguyên tắc lưu mỗi dữ kiện đúng một lần.
Gần như mọi mối quan hệ bạn mô hình hóa đều thuộc hai dạng này.
Một người dùng → nhiều đơn. FK nằm ở phía 'nhiều': orders.user_id. Một người dùng, nhiều dòng trỏ tới.
Một sách có nhiều tác giả; một tác giả viết nhiều sách. Bảng nào cũng không thể chứa một FK cho mối quan hệ này.
Bảng thứ ba (book_authors) chỉ chứa hai FK: book_id + author_id. Nhiều:nhiều = hai quan hệ 1:nhiều qua bảng đó.
Mỗi đơn thuộc về một người dùng; mỗi đơn có nhiều mục; mỗi mục thuộc về một đơn. Đó là toàn bộ câu chuyện của JOIN.
Đọc như bản đồ: các cột FK là sợi dây buộc các thẻ với nhau — JOIN là cách lấy dữ liệu xuyên qua những sợi dây đó.
Một sách có nhiều tác giả, một tác giả viết nhiều sách. Bảng book_authors chứa đúng hai khóa ngoại, và biến 'nhiều:nhiều' thành hai quan hệ 1:nhiều.
Mỗi dòng trong book_authors nói: 'tác giả này đồng tác giả sách này'. Không có cột 'list tác giả' nào ở trong books.
Làm điều này trước khi viết SQL — đây là phần việc của kiến trúc sư.
'Người dùng đặt đơn chứa các mục' → users, orders, line_items. Danh từ thành bảng.
Mỗi bảng = một thứ; các cột = thuộc tính của thứ đó (bạn cần biết gì về nó?).
1:nhiều → FK ở phía 'nhiều'. Nhiều:nhiều → thêm bảng trung gian với hai FK.
PK cho mọi bảng; FK cho cột liên kết; UNIQUE cho giá trị không được lặp (email).
Viết CREATE TABLE kèm sơ đồ. Sơ đồ là thứ bạn cho người khác (và AI) xem để thống nhất mô hình.
Nếu sai bảng và sai mối liên kết, dù viết bao nhiêu SQL cũng không cứu được. Mô hình chính là kiến trúc.
Dữ liệu được tách ra nhiều bảng có chủ đích. JOIN là cách ghép lại khi cần — cùng xem nó diễn ra, từng dòng một.
Một cơ chế, ba hệ quả.
SQLite ghép các dòng của hai bảng khi khóa bằng nhau: orders.user_id = users.id.
Mỗi dòng kết quả ghép cột của cả hai bảng — tên người dùng cạnh total đơn hàng của họ.
Bỗng bạn hỏi được 'ai chi nhiều nhất?' — câu hỏi mà một bảng riêng lẻ không trả lời được.
| id | name |
|---|---|
| 1 | Ana |
| 2 | Bo |
| 3 | Cyd |
| 4 | Dee |
| id | user_id | total | status |
|---|---|---|---|
| 10 | 1 | 120.00 | paid |
| 11 | 1 | 45.00 | shipped |
| 12 | 2 | 80.00 | paid |
| 13 | 4 | 9.50 | pending |
SELECT u.name, o.total, o.status
FROM users u
JOIN orders o ON o.user_id = u.id
ORDER BY o.id;
| name | total | status |
|---|---|---|
| Ana | 120.00 | paid |
| Ana | 45.00 | shipped |
| Bo | 80.00 | paid |
| Dee | 9.50 | pending |
Đọc là: 'mỗi đơn hàng, tìm người dùng và đặt tên cạnh total'. Cyd biến mất vì INNER JOIN chỉ giữ các dòng khớp.
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
ORDER BY u.id;
LEFT JOIN = INNER JOIN, nhưng giữ lại mọi dòng của bảng trái dù không có dòng khớp bên phải.
| name | total |
|---|---|
| Ana | 120.00 |
| Ana | 45.00 |
| Bo | 80.00 |
| Cyd | NULL |
| Dee | 9.50 |
Dòng Cyd vẫn hiện với NULL — 'người dùng này không có đơn'. RIGHT và FULL JOIN là hai hướng còn lại; mặc định (INNER) = chỉ dòng khớp.
| id | user_id |
|---|---|
| 10 | 1 |
| 11 | 1 |
| 12 | 2 |
| 13 | 4 |
| id | order_id | product | qty |
|---|---|---|---|
| 1 | 10 | keyboard | 1 |
| 2 | 10 | mouse | 2 |
| 3 | 11 | usb-c hub | 1 |
| 4 | 12 | monitor | 1 |
| 5 | 13 | adapter | 3 |
SELECT o.id AS order_id, li.product, li.qty
FROM orders o
JOIN line_items li ON li.order_id = o.id
ORDER BY o.id, li.id;
| order_id | product | qty |
|---|---|---|
| 10 | keyboard | 1 |
| 10 | mouse | 2 |
| 11 | usb-c hub | 1 |
| 12 | monitor | 1 |
| 13 | adapter | 3 |
Mỗi JOIN thêm cột của bảng kế tiếp, liên kết bằng FK của nó. Mẫu 'bảng → con → cháu' này là hình dạng của hầu hết các báo cáo.
SELECT li.product, li.qty
FROM users u
JOIN orders o ON o.user_id = u.id
JOIN line_items li ON li.order_id = o.id
WHERE u.email = 'ana@ex.com';
| product | qty |
|---|---|
| keyboard | 1 |
| mouse | 2 |
| usb-c hub | 1 |
Thứ tự tư duy: JOIN trước (ghép bảng), sau đó WHERE (thu hẹp). Dạng 'tìm X thuộc về Y' này là truy vấn phổ biến nhất của mọi ứng dụng.
-- Forgot the ON clause:
SELECT u.name, o.total
FROM users u, orders o;
| name | total |
|---|---|
| Ana | 120.00 |
| Ana | 45.00 |
| Ana | 80.00 |
| Ana | 9.50 |
| Bo | 120.00 |
| Bo | 45.00 |
| Bo | 80.00 |
| Bo | 9.50 |
| … 16 dòng tổng cộng — phần lớn vô nghĩa | |
Không có khóa để khớp, CSDL ghép mọi dòng với mọi dòng: 4 users × 4 orders = 16 dòng. Nếu kết quả nhiều dòng hơn bình thường — chắc chắn bạn đang thiếu ON.
Cơ chế bên trong rất đơn giản — và có thể tối ưu bằng chỉ mục.
Với mỗi dòng của bảng trái, SQLite tìm các dòng khớp ở bảng phải — lặp đi lặp lại.
Có chỉ mục trên orders.user_id, mỗi lần tìm tức thì. Không có: quét toàn bộ orders cho từng user.
JOIN nhanh hay chậm tùy thuộc vào chỉ mục của các cột được nối. Index cả hai phía.
Không có chỉ mục, SQLite quét bảng từng dòng một. Chỉ mục giúp bỏ qua bước quét — đây là cách cụ thể, kèm bằng chứng.
Tạo bằng một dòng: CREATE INDEX idx_orders_user ON orders(user_id);
Trang mục lục của sách chỉ từ tên → số trang. Không có nó, muốn tìm một tên phải lật từng trang.
Chỉ mục là bản sao nhỏ, đã sắp xếp của các giá trị một cột kèm con trỏ tới dòng — nhảy thẳng tới kết quả thay vì quét 100.000 dòng.
-- find all orders of user 1 (100,000 rows total)
-- no index → full scan:
-- read row 1... user_id = 7 ✗
-- read row 2... user_id = 3 ✗
-- ... 100,000 comparisons
-- with index → sorted lookup:
-- index says: user_id 1 → rows 10, 11
-- jump straight to those 2 rows
-- ~100,000 steps → ~17 steps
Chỉ mục là danh sách đã sắp xếp, nên SQLite tìm nhị phân (~log2 của 100.000 ≈ 17 bước nhảy) thay vì so sánh từng dòng.
SQLite sẽ nói thẳng với bạn: nó quét hay dùng chỉ mục.
-- index columns that appear in WHERE, ORDER BY, and ON:
CREATE INDEX idx_orders_user ON orders(user_id);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_status ON orders(status);
-- don't index:
-- * a column you never query (pure overhead)
-- * every column (each one taxes every write)
Cột FK xứng đáng có chỉ mục dù bạn chỉ dùng chúng trong JOIN — đó là quy tắc Phần 3 trong thực tế.
Quy tắc: index cái bạn đọc nhiều. Đừng index tất cả — chỉ mục là sự đánh đổi tốc độ đọc/tốc độ ghi.
WHERE, ORDER BY và JOIN trên cột đó nhanh hơn hẳn — từ quét tất cả sang nhảy thẳng.
Mỗi INSERT/UPDATE còn phải cập nhật cả chỉ mục → ghi chậm hơn một chút, và file to thêm đôi chút.
Gắn tất cả lại: các phiên thật, code app an toàn, khi nào nó là công cụ đúng — và những lỗi nên tránh.
Đếm dòng, hỏi một câu xuyên bảng, xem schema — tất cả từ CLI.
email = request.args.get('email') # user input
sql = "SELECT * FROM users WHERE email = ?"
rows = conn.execute(sql, (email,)).fetchall()
# ? is a placeholder — SQLite receives the query
# AND the value separately. The value is always
# treated as data, never as SQL.
Chỉ một thói quen — không bao giờ dán input của user vào chuỗi SQL — đã ngăn cả một lớp lỗi bảo mật.
Injection là một trong những lỗi bảo mật phổ biến nhất trong thực tế. Cách dùng ? ở trên là cách sửa chuẩn.
q = "SELECT * FROM users WHERE name = '" + user_input + "'"
Nếu user_input = x' OR '1'='1, truy vấn thành 'name = 'x' OR '1'='1'' — đúng với mọi dòng. Text của user vừa trở thành code SQL.
q = "SELECT * FROM users WHERE name = ?" values = (user_input,)
Dù user_input chứa gì — dấu nháy, OR, DROP TABLE — nó chỉ được so sánh như một giá trị. Không có gì bị thực thi.
Nó là mặc định cho tới khi bạn gặp một bức tường cụ thể.
SQLite cho phép một lần ghi tại một thời điểm (reader chạy song song). Đủ cho đa số app nhỏ/vừa.
Điện thoại, app desktop, Raspberry Pi — SQLite đã có sẵn. Nó được sinh ra đúng cho việc này.
Vài chục MB tới vài GB, hàng triệu dòng — vẫn nhanh trên ổ cứng hiện đại. Đừng 'scale trước'.
Nhiều app server ghi cùng lúc, ghi từ nhiều máy → CSDL dạng server (PostgreSQL, MySQL).
Bạn sẽ gặp cả bốn thứ này trong các dự án thật.
Lưu tên/email user trong từng dòng đơn. Sửa một địa chỉ = 500 lần cập nhật. Hãy tách bảng.
JOIN và WHERE trên user_id chậm dần khi bảng lớn. Index các cột bạn JOIN.
colors = 'red,blue,green' không thể truy vấn, đếm hay JOIN. Đó phải là một bảng với các dòng riêng.
Tạo bảng tùy tiện ở nhiều nơi, không có nguồn sự thật duy nhất. Giữ một file schema để mọi người đọc.
Bạn sở hữu mô hình; AI viết truy vấn; CLI là công cụ kiểm chứng của bạn.
Vẽ bảng và mối liên kết (1:nhiều / m:n) trước khi có SQL. Cho người thật xem để kiểm tra.
Mô tả mô hình + câu hỏi cho AI; nó viết SELECT/JOIN. Đọc lại nó so với sơ đồ của bạn.
Mở .db bằng sqlite3, chạy, nhìn các dòng thật. Sai số dòng? Truy vấn hoặc mô hình đang lệch.
Chạy EXPLAIN QUERY PLAN; thấy SCAN ở chỗ cần nhanh, thêm index cho cột nóng.
Vẫn các động từ, khóa và JOIN — giờ áp dụng cho một schema hoàn chỉnh, thực tế. Mỗi bảng có một nhiệm vụ; đây là lý do mỗi bảng tồn tại.
users + orders là phần bài tập. Một cửa hàng thật phải xoay xở được với tiền bạc, vận chuyển và thời gian.
Tiền (thanh toán, hoàn tiền), vận chuyển kèm mã theo dõi, tồn kho, đánh giá, mã giảm giá, giỏ hàng bỏ dở — không thứ nào nằm gọn trong users + orders.
Mỗi danh từ một bảng, mỗi dữ kiện lưu đúng một lần — quy tắc Phần 2, áp dụng ở quy mô đầy đủ.
orders.user_id, order_items.order_id, reviews.product_id… khóa là mối liên kết; JOIN là cách truy vấn xuyên qua chúng.
Giá và địa chỉ được sao chép vào đơn lúc mua, nên việc sửa sau này không bao giờ ghi đè lịch sử.
Mỗi bảng = một thứ: người, đơn, mục đơn, sản phẩm, danh mục, địa chỉ, tiền. FK là sợi dây nối; mọi báo cáo chỉ là JOIN xuyên qua chúng.
Giỏ là 'đang nghĩ sẽ mua', tách khỏi 'đã mua'; shipments tách khỏi vòng đời đơn; reviews là mối quan hệ product↔user có dữ liệu đi kèm; coupons phục vụ kế toán khuyến mãi.
Đọc cột thứ ba như một bài kiểm tra: nếu bảng nào không giải thích được vì sao nó tồn tại, hãy xóa — dữ liệu đó có lẽ thuộc về nơi khác.
| Bảng | Cột chính | Vì sao cần bảng này |
|---|---|---|
| users | id (PK) · email (UNIQUE) | Trục trung tâm: một tài khoản → nhiều đơn, địa chỉ, đánh giá. Mọi thứ gắn với người dùng bắt đầu ở đây. |
| addresses | id (PK) · user_id (FK) · street · city | Người dùng có nhiều địa chỉ; đơn hàng 'chụp' lại địa chỉ đã dùng, nên chuyển nhà không ghi đè các đơn cũ. |
| products | id (PK) · name · price · category_id (FK) | Mặt hàng trong danh mục, độc lập với đơn. price là giá HIỆN TẠI — giá bạn trả nằm ở order_items. |
| categories | id (PK) · name | Nhóm 1:nhiều để điều hướng. Mỗi sản phẩm một danh mục chính — cố ý đơn giản, không làm m:n. |
| orders | id (PK) · user_id (FK) · total · status | Một dòng cho mỗi lần thanh toán: đã đặt gì, total + status tại thời điểm đặt — cột sống của doanh nghiệp. |
| order_items | order_id (FK) · product_id (FK) · qty · unit_price | Bảng trung gian m:n CÓ thuộc tính: qty + unit_price chụp tại lúc mua — giá đổi về sau, lịch sử thì không. |
Cùng bài kiểm tra như trên: mỗi bảng xứng đáng tồn tại vì giữ những dữ kiện mà không bảng nào khác nên giữ.
| Bảng | Cột chính | Vì sao cần bảng này |
|---|---|---|
| payments | id (PK) · order_id (FK) · method · amount | Lưu chuyển tiền được kiểm toán riêng khỏi đơn hàng: hoàn tiền, chia thanh toán là 1:nhiều, đơn hàng vẫn sạch. |
| shipments | id (PK) · order_id (FK) · carrier · tracking_no | Sự kiện vận chuyển tách khỏi vòng đời đơn — cho phép chia lô: một đơn, hai nhà vận chuyển, hai mã theo dõi. |
| carts / cart_items | cart_id (FK) · product_id (FK) · qty | 'Đang nghĩ sẽ mua' tách khỏi 'đã mua'. Thanh toán copy các dòng giỏ sang orders + order_items. |
| reviews | product_id (FK) · user_id (FK) · rating · text | Mối quan hệ m:n product↔user mang rating/text — chính mối quan hệ cũng có thuộc tính riêng. UNIQUE(product_id, user_id): một đánh giá cho mỗi cặp. |
| coupons | code (UNIQUE) · discount · expires | Lịch sử dùng mã giảm giá theo từng đơn để kế toán: mã nào, giảm bao nhiêu, và hết hiệu lực khi nào. |
Nhận ra các hình dạng này trong mọi schema, dù của bạn hay của người khác.
order_items.unit_price và orders.address_id lưu bản sao tại thời điểm sự kiện, nên sửa sau này không bao giờ ghi đè lịch sử.
order_items qty/unit_price, reviews rating, cart_items added_at — chính mối quan hệ cũng có dữ liệu.
products.category_id: mỗi sản phẩm một danh mục. Chỉ m:n thật (orders↔products) mới cần bảng trung gian.
Danh sách yêu thích, điểm thân thiết — danh từ mới, bảng mới, đúng các quy tắc như cũ.
Đa tiền tệ và thuế, nhật ký kiểm toán — lưu chuyển tiền và trách nhiệm giải trình cũng có bảng riêng.
Thiết kế mô hình trước: danh từ → bảng → liên kết → khóa. Mỗi bảng có một nhiệm vụ; kiểm chứng từng bảng bằng CLI sqlite3.
SELECT p.name, SUM(oi.qty * oi.unit_price) AS revenue
FROM order_items oi
JOIN products p ON p.id = oi.product_id
JOIN orders o ON o.id = oi.order_id
WHERE o.status IN ('paid','shipped')
GROUP BY p.name
ORDER BY revenue DESC
LIMIT 5;
SELECT o.id, o.total, o.status
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE u.email = 'ana@ex.com'
ORDER BY o.id DESC;
| name | revenue |
|---|---|
| monitor | 1299.00 |
| keyboard | 452.00 |
| usb-c hub | 310.00 |
| mouse | 186.00 |
| adapter | 94.00 |
| id | total | status |
|---|---|---|
| 13 | 9.50 | pending |
| 11 | 45.00 | shipped |
| 10 | 120.00 | paid |
Đúng các kỹ thuật đã học: JOIN chuỗi nhiều bảng, WHERE lọc, GROUP BY tổng hợp, ORDER BY + LIMIT top-N — giờ chỉ khác ở quy mô của mô hình.
Mô hình dữ liệu — có những bảng nào, chúng liên hệ thế nào — là quyết định của kiến trúc sư. SQL chỉ là cách ứng dụng nói chuyện với nó.
Thiết kế mô hình trước — rồi để AI viết truy vấn, kiểm chứng trong CLI.