GĐ05 — Database: SQL/Postgres, ORM, Migration, Redis (Dự án 2)
Study note cho FE engineer (JS/TS mạnh) chuyển sang Backend. Mỗi concept: định nghĩa → tại sao quan trọng → cơ chế → ví dụ → pitfall. Ở FE bạn quen
array.filter()trong RAM. Ở BE, "state thật" nằm trong database — persistent, shared, concurrent. Đây là thay đổi tư duy lớn nhất.
Hộp phiên bản — Prisma 7.10. Ví dụ Prisma trong file chạy trên Prisma 7.10 (stable, kiểm chứng ngày 2026-10-05, thử với PostgreSQL 17), Node ≥ 20.19, ESM. Phải ghim phiên bản: tag
latestcủa góiprismatrên npm đang trỏ tới RC của Prisma 8 (8.0.0-rc.19) trong khi@prisma/clientlatest là 7.10.0, nênnpm i -D prismacó thể kéo CLI và client lệch major.bashReadyKhác Prisma 6 (những chỗ làm ví dụ cũ hỏng):
generator clientdùngprovider = "prisma-client"và bắt buộc cóoutput; import client từ thư mục đó, không còn@prisma/client.- Driver adapter bắt buộc (
@prisma/adapter-pgcho Postgres). Pool cấu hình ở driver (max), không còn?connection_limit=trong URL (xem mục 11).- URL kết nối nằm ở
prisma.config.ts(datasource.url), không còn trongschema.prisma. Ở 7.10,prisma initđặt tên file làprisma7.config.ts; Prisma nhận cả hai tên (đã chạyprisma validatevới từng tên), đổi thànhprisma.config.tscho gọn.initcòn ghi thêm thư mục skills (.claude/,.agents/,.windsurf/): xoá nếu không dùng.prisma migrate devkhông tự chạygeneratevà không tự seed: gọinpx prisma generatevànpx prisma db seed(đã chạy: saumigrate devthư mục output chưa tồn tại).- Prisma 7.x không hỗ trợ MongoDB (dùng Mongoose, GĐ06). Prisma 8 mới ở bản RC, tài liệu này chưa dùng.
textReadytypescriptReadytypescriptReady
prisma validatevàprisma generatekhông kết nối DB, nên không cần Postgres đang chạy để kiểm cú pháp schema. Các snippet schema/migration/seed/N+1 dưới đây đã chạy trên Prisma 7.10.0 + PostgreSQL 17; riêng snippet SQL thuần chỉ đối chiếu tài liệu.
Kiểm chứng ngày 2026-10-05. Các phần mới ở mục 2, 3, 4, 5, 6, 8, 9, 13 đã chạy trên PostgreSQL 17.9, Redis 8.6.1 (server tạm của tôi), Prisma 7.10.0, Node 24, và code TypeScript qua
tsc --strict(TypeScript 7.0.2); chỗ nào không chạy thì ghi "chưa chạy" ngay tại đó. Nguồn: Postgres: Transaction Isolation (Repeatable Read chặn phantom; xung đột tuần tự hoá có SQLSTATE40001, app phải thử lại cả transaction) và Redis: Key eviction (danh sách chính sách,noevictiontrả lỗi khi ghi,volatile-*hoạt động nhưnoevictionnếu không key nào có TTL) và Redis: Persistence (RDB, AOF,appendfsync everysecmặc định). Chưa xác minh: hành vi trên PostgreSQL 18 (bản current; số liệu ở đây đo trên 17), và trang tra mã lỗiP2002/P2025trên prisma.io (hai mã này chỉ được quan sát thật trên Prisma 7.10.0).
1. Vì sao học SQL/PostgreSQL trước NoSQL#
Định nghĩa. SQL (Structured Query Language) là ngôn ngữ khai báo để truy vấn RDBMS (relational database). PostgreSQL là một RDBMS mã nguồn mở, chuẩn mực, giàu tính năng. NoSQL (MongoDB, Redis, DynamoDB...) là nhóm database phi-quan-hệ, mỗi loại tối ưu cho một mô hình dữ liệu khác.
Tại sao quan trọng.
- SQL là kỹ năng nền tảng, transferable: cú pháp gần như giống nhau giữa Postgres/MySQL/SQLite/SQL Server. Học một lần dùng khắp nơi.
- Postgres ép bạn hiểu schema, quan hệ, constraint, transaction — chính là những khái niệm NoSQL cũng cần nhưng che giấu đi. Học SQL trước → sang NoSQL thấy dễ; học NoSQL trước → sang SQL bị hụt nền tảng.
- 80% ứng dụng CRUD (e-commerce, SaaS, fintech) hợp với relational. NoSQL là exception, không phải default.
Cơ chế. RDBMS lưu dữ liệu thành bảng (table) = tập hàng (row) + cột (column) có kiểu chặt. Các bảng liên kết qua foreign key. Engine đảm bảo ACID (mục 5) và cho phép query phức tạp bằng SQL declarative — bạn mô tả cái gì cần, engine tự quyết làm thế nào (query planner).
Ví dụ.
Pitfall / case thực tế. FE hay nghĩ "MongoDB dễ vì lưu JSON như object JS". Sự dễ đó là ảo: khi dữ liệu có quan hệ (user → order → product), NoSQL bắt bạn tự xử lý join ở tầng application, tự đảm bảo tính nhất quán — khó hơn nhiều so với một câu JOIN của SQL. Chọn NoSQL vì "quen JSON" là lý do sai.
2. Quan hệ dữ liệu & Normalization#
Định nghĩa. Normalization là quá trình tổ chức dữ liệu để mỗi sự thật (fact) chỉ lưu một chỗ, tránh trùng lặp. Ba loại quan hệ cơ bản:
- 1-1 (one-to-one): một user có một profile.
- 1-n (one-to-many): một user có nhiều order.
- n-n (many-to-many): một student học nhiều course, một course có nhiều student → cần bảng trung gian (junction/join table).
Tại sao quan trọng. Trùng lặp dữ liệu = nguồn gốc của bug nhất quán. Nếu email user được copy vào 50 dòng order, đổi email phải update 50 chỗ — quên một chỗ là data sai. Normalization đẩy dữ liệu vào single source of truth.
Cơ chế.
- 1-n: đặt foreign key ở phía "n".
orders.user_id → users.id. Một user_id lặp ở nhiều order; một order chỉ trỏ một user. - n-n: không thể đặt FK trực tiếp. Tạo bảng trung gian chứa 2 FK:
enrollments(student_id, course_id). Mỗi dòng = một cặp ghép. - 1-1: FK +
UNIQUEconstraint (hoặc dùng chung primary key).
Ví dụ (SQL — n-n).
Ví dụ (Prisma — n-n).
Khi nào denormalize. Có chủ đích copy dữ liệu để tăng tốc đọc:
- Cột đếm/tổng hợp:
posts.comment_countthay vìCOUNT(*)mỗi lần load. - Snapshot lịch sử:
order_items.price_at_purchase— giá sản phẩm đổi sau này không được sửa hóa đơn cũ. Đây không phải denormalize xấu mà là đúng nghiệp vụ: giá lúc mua là một fact riêng.
Pitfall. Denormalize sớm khi chưa có bottleneck = tự tạo bug nhất quán không cần thiết. Quy tắc: normalize trước, denormalize sau khi đo được là chậm. Ngược lại, copy giá vào order lại là bắt buộc — nhầm hai case này là lỗi thiết kế kinh điển.
Xoá bản ghi cha: tuỳ chọn onDelete#
Khi xoá một User mà vẫn còn Todo trỏ tới nó, FK quyết định chuyện gì xảy ra. Prisma khai báo qua onDelete trên @relation:
| Khai báo | SQL sinh ra | Xoá User còn Todo |
|---|---|---|
quan hệ bắt buộc, không ghi onDelete | ON DELETE RESTRICT | báo lỗi, không xoá |
onDelete: Cascade | ON DELETE CASCADE | xoá luôn các Todo |
quan hệ tuỳ chọn (userId String?), không ghi onDelete | ON DELETE SET NULL | Todo còn lại, userId thành NULL |
Đã chạy (Prisma 7.10.0, prisma migrate diff để xem SQL; xoá một user có 2 todo trên PostgreSQL 17): bản không ghi onDelete sinh ON DELETE RESTRICT, bản tuỳ chọn sinh SET NULL, bản Cascade làm số todo giảm từ 10 xuống 8.
Chọn theo nghiệp vụ, không theo thói quen: todo của user thì Cascade hợp lý; hoá đơn (Invoice) thì không cascade, vì xoá user không được xoá lịch sử tiền. Với dữ liệu cần giữ vết, dùng xoá mềm (deletedAt) thay vì xoá thật (xem GĐ14 mục 1).
3. JOIN#
Định nghĩa. JOIN ghép các dòng từ nhiều bảng dựa trên điều kiện liên kết (thường là FK = PK). Các loại chính:
- INNER JOIN: chỉ giữ dòng khớp ở CẢ HAI bảng.
- LEFT JOIN: giữ TẤT CẢ dòng bảng trái, bảng phải không khớp →
NULL. - RIGHT JOIN: ngược lại LEFT (giữ hết bảng phải). Hiếm dùng — người ta đảo thứ tự bảng rồi dùng LEFT.
Tại sao quan trọng. Vì dữ liệu đã normalize (mục 2), thông tin nằm rải nhiều bảng. JOIN là cách ghép lại — thao tác đọc phổ biến nhất trong backend.
Cơ chế. Engine duyệt bảng trái, với mỗi dòng tìm dòng khớp ở bảng phải theo điều kiện ON. INNER loại dòng không match; LEFT giữ lại và điền NULL.
Ví dụ.
Vì sao FE hay bối rối.
- FE quen "lấy user, rồi loop gọi API lấy order từng cái" (tư duy tuần tự, imperative). JOIN là tư duy tập hợp (set-based) — làm một phát ra cả bảng ghép.
- Nhầm INNER vs LEFT: dùng INNER khi cần "cả user chưa có order" → mất dữ liệu âm thầm, không báo lỗi.
- Bẫy
COUNT(*)vsCOUNT(o.id)với LEFT JOIN:COUNT(*)đếm cả dòng NULL → user 0 order ra1(SAI);COUNT(o.id)bỏ qua NULL → ra0(ĐÚNG).
Pitfall. LEFT JOIN + điều kiện lọc bảng phải đặt sai chỗ: viết WHERE o.status='paid' sẽ âm thầm biến LEFT thành INNER (vì NULL không thỏa điều kiện). Muốn giữ tính LEFT, đặt điều kiện trong ON: LEFT JOIN orders o ON o.user_id=u.id AND o.status='paid'.
Window function, CTE, EXISTS và NULL#
Bốn công cụ này là chỗ ORM hay bất lực (mục 8), nên biết viết tay. Bảng mẫu: users2(id, name, manager_id) và orders(id, user_id, total, status).
Đã chạy trên PostgreSQL 17 với 4 user và 5 order: row_number ra một dòng mỗi user có order; running của user 1 là 100 rồi 150; cây đệ quy ra độ sâu 0, 1, 1, 2; EXISTS ra An, Binh, Chi và NOT EXISTS ra Dung.
NULL không bằng gì cả, kể cả chính nó. Đây là nguồn bug im lặng. Các kết quả sau đã chạy, trên bảng orders đã có thêm order thứ 6 (user_id là NULL, xem bullet cuối) và một order có total là NULL:
NULL = NULLraNULL(không phảitrue), nênWHERE x = NULLkhông bao giờ khớp. DùngIS NULL, hoặcIS DISTINCT FROMkhi muốn so sánh coiNULLlà một giá trị.WHERE total <> 50bỏ luôn dòng cótotallàNULL(4 dòng);WHERE total IS DISTINCT FROM 50giữ chúng (5 dòng).COUNT(total)bỏ quaNULL,COUNT(*)thì không;SUM/AVGcũng bỏ quaNULL(6 dòng nhưngCOUNT(total)ra 5).NOT INvới danh sách chứaNULLra rỗng. Thêm một order cóuser_idlàNULLrồi chạySELECT name FROM users2 WHERE id NOT IN (SELECT user_id FROM orders)ra 0 dòng, trong khi Dung rõ ràng chưa có order. DùngNOT EXISTS(như trên) thay choNOT INvới truy vấn con.
4. Index#
Định nghĩa. Index là cấu trúc dữ liệu phụ (thường B-tree) giúp DB tìm dòng theo giá trị cột mà không quét toàn bảng. Giống mục lục cuối sách so với đọc từng trang.
Tại sao quan trọng. Không index → mọi query lọc phải Seq Scan (đọc hết bảng), O(n). Bảng triệu dòng → query chậm hàng giây. Index đưa về ~O(log n).
Cơ chế (B-tree). Cây cân bằng, các key sắp thứ tự. Tìm kiếm đi từ root xuống lá theo so sánh — vài bước là tới. Hỗ trợ tốt: =, <, >, BETWEEN, ORDER BY, prefix LIKE 'abc%'. Postgres còn có GIN (JSONB/full-text), GiST, Hash — nhưng B-tree là mặc định và phổ biến nhất.
Composite index (nhiều cột). INDEX(a, b) sắp theo a trước, rồi b. Dùng hiệu quả cho query lọc a, hoặc a AND b, nhưng kém hiệu quả nếu chỉ lọc b (leftmost prefix rule — như tra danh bạ sắp theo họ rồi tên: biết tên mà không biết họ thì mục lục chẳng giúp được bao nhiêu). Chính xác hơn "không dùng được": Postgres vẫn có thể quét cả index, chỉ là mất lợi thế nhảy thẳng tới khoảng cần tìm; từ PostgreSQL 18 có skip scan cho B-tree nên trường hợp cột đầu có ít giá trị phân biệt có thể đỡ hơn (chưa đo ở đây). Đã đo trên 200 nghìn dòng (WHERE status = 'failed', 1% dòng): index (user_id, status) vẫn được chọn (Bitmap Index Scan), nhưng riêng bước duyệt index đọc 173 buffer; với index riêng (status) cả truy vấn chạy 0,67 ms so với 1,1 ms (số đo một lần, chỉ để thấy chiều hướng).
Ví dụ.
Đánh đổi. Index không free:
- Ghi chậm hơn: mỗi INSERT/UPDATE/DELETE phải cập nhật cả bảng lẫn mọi index. 10 index = 10 lần bảo trì mỗi lần ghi.
- Tốn disk & RAM. Index cũng chiếm dung lượng.
→ Chỉ index cột thực sự dùng trong
WHERE/JOIN/ORDER BY. Đừng index bừa mọi cột.
Index KHÔNG dùng được khi.
- Bọc cột trong hàm:
WHERE lower(email)='x'không dùngidx(email)→ cần functional indexCREATE INDEX ... ON users(lower(email)). LIKE '%abc'(wildcard đầu) — không dùng B-tree được.- Cột low cardinality (vd
is_activechỉ true/false): planner thấy quét bảng còn nhanh hơn nên bỏ qua index. - So sánh sai kiểu: cột
textmà so với số → có thể cast ngầm làm hỏng index.
Pitfall. Tạo index rồi tưởng "chắc chắn nhanh". Luôn xác minh bằng EXPLAIN ANALYZE (mục 7) xem planner có thực sự dùng index không — nhiều khi nó chọn Seq Scan vì thống kê cho thấy như vậy tối ưu hơn.
Partial, expression, covering, GIN và BRIN#
B-tree thường là đủ, nhưng năm biến thể sau giải quyết đúng những case hay gặp. Mọi plan dưới đây lấy từ EXPLAIN trên bảng big 200 nghìn dòng (PostgreSQL 17, đã ANALYZE).
| Loại | Dùng khi | Ví dụ | Kết quả đã đo |
|---|---|---|---|
| Partial | chỉ truy vấn một tập con nhỏ | CREATE INDEX ON big(created_at) WHERE status = 'failed' | Index Scan Backward cho ORDER BY created_at DESC LIMIT 5; index 64 kB so với 1376 kB của index đầy đủ |
| Expression | lọc qua hàm | CREATE INDEX ON big(lower(email)) | trước: Parallel Seq Scan; sau: Index Scan cho WHERE lower(email) = '...' |
Covering (INCLUDE) | muốn đọc cột phụ mà không chạm bảng | CREATE INDEX ON big(created_at) INCLUDE (status) | Index Only Scan (sau VACUUM, vì cần visibility map) |
| GIN | jsonb, mảng, full-text (mục 14) | CREATE INDEX ON big USING GIN (meta) | phục vụ meta @> '{"tag":"vip"}'; với vip chỉ chiếm 1% dòng, planner vẫn chọn Seq Scan cho truy vấn ... LIMIT 5 (chỉ dùng GIN khi ép enable_seqscan = off), nên luôn kiểm bằng EXPLAIN |
| BRIN | bảng rất lớn, cột tăng dần theo vị trí lưu (thời gian) | CREATE INDEX ON big USING BRIN (created_at) | 24 kB so với 6176 kB của B-tree covering ở trên; chỉ thu hẹp khoảng khối, kém chính xác hơn B-tree |
Partial index còn là cách chuẩn để ép unique chỉ trên bản ghi chưa xoá mềm:
Đã chạy: chèn a@x.io, xoá mềm, chèn lại cùng email thì được; chèn lần thứ ba (cả hai đang sống) báo lỗi trên. Chưa xác minh schema.prisma của Prisma 7.10 có khai báo được partial index hay không; cách chắc chắn dùng được là migration SQL viết tay (xem --create-only ở mục 9).
5. Transaction & ACID#
Định nghĩa. Transaction là nhóm nhiều thao tác được coi là một đơn vị nguyên tử: hoặc tất cả thành công (COMMIT), hoặc tất cả bị hủy (ROLLBACK). ACID = 4 đảm bảo:
- Atomicity — cả gói hoặc không gì cả.
- Consistency — giữ nguyên mọi ràng buộc (constraint, FK).
- Isolation — các transaction chạy song song không giẫm lên nhau.
- Durability — đã COMMIT thì tồn tại kể cả mất điện.
Tại sao quan trọng. Backend chạy concurrent: nhiều request cùng lúc đọc/ghi cùng dữ liệu. Không có transaction → dữ liệu hỏng giữa chừng (tiền trừ mà không cộng, tồn kho âm). Đây là điều FE gần như không bao giờ gặp vì FE thao tác state cục bộ, single-user.
Cơ chế. BEGIN mở transaction → các câu lệnh chạy trong không gian tạm → COMMIT ghi bền / ROLLBACK vứt bỏ. Trước khi COMMIT, thay đổi chưa "thật".
Ví dụ (chuyển tiền — atomicity).
Kết quả mong đợi (suy ra từ cơ chế, chưa chạy): nếu lệnh update thứ hai ném lỗi (id 2 không tồn tại thì Prisma ném P2025), $transaction rollback và số dư tài khoản 1 vẫn như cũ. Đặt hai await prisma.account.update(...) rời nhau (không tx) thì lệnh đầu đã commit và tiền "bốc hơi".
Isolation levels (tóm tắt, từ lỏng → chặt).
- Read Committed (mặc định Postgres): chỉ đọc dữ liệu đã COMMIT. Vẫn có thể gặp non-repeatable read (đọc lại cùng dòng ra giá trị khác vì transaction khác vừa commit).
- Repeatable Read: trong cùng transaction đọc lại luôn thấy nhất quán (snapshot). Postgres bản này chặn cả phantom, nhưng vẫn lọt write skew (hai transaction đọc cùng một tập, mỗi bên ghi một dòng khác nhau, kết quả vi phạm luật chung); ghi đè một dòng đã bị người khác sửa thì bị từ chối với lỗi
40001. - Serializable (chặt nhất): kết quả như thể mọi transaction chạy tuần tự. An toàn nhất nhưng có thể bị serialization failure → app phải retry.
Deadlock. Hai transaction giữ khóa và chờ nhau vòng tròn: T1 khóa hàng A chờ B, T2 khóa B chờ A. Postgres tự phát hiện và giết một transaction (deadlock detected). Phòng: luôn truy cập tài nguyên theo cùng thứ tự (vd luôn khóa account id nhỏ trước).
Pitfall. FE-lên-BE hay update nhiều bảng bằng nhiều câu riêng lẻ không bọc transaction → gặp lỗi giữa chừng để lại dữ liệu nửa vời. Bất kỳ nghiệp vụ nào đụng ≥2 dòng phụ thuộc nhau (tiền, tồn kho, điểm) phải nằm trong một transaction.
Lost update, khoá dòng và thử lại khi xung đột#
Lost update: hai request cùng đọc số dư 100, mỗi bên cộng 10 ở phía app rồi ghi 110. Kết quả 110 thay vì 120, và cả hai transaction đều COMMIT thành công. Bọc BEGIN ... COMMIT quanh "đọc rồi ghi" không cứu được ở mức mặc định Read Committed. Bốn cách sửa, đo trên PostgreSQL 17 bằng hai kết nối chạy song song (số dư đầu 100, mỗi bên cộng 10):
| Cách | Cơ chế | Số dư cuối |
|---|---|---|
| đọc rồi ghi ở app (Read Committed) | không khoá gì | 110 (mất một lần cộng) |
SELECT ... FOR UPDATE rồi ghi | kết nối thứ hai chờ tới khi kết nối đầu COMMIT, rồi đọc giá trị mới | 120 |
UPDATE ... SET balance = balance + 10 | phép cộng nằm trong một câu, DB tự khoá dòng | 120 |
khoá lạc quan: cột version | UPDATE ... WHERE id = $1 AND version = $2; ai ghi sau trên bản cũ nhận rowCount 0 | một bên thắng (1), một bên thua (0) |
Ưu tiên cách 3 khi phép tính viết được thành một câu. Dùng FOR UPDATE khi cần đọc, kiểm điều kiện (đủ tiền không) rồi mới ghi. Dùng khoá lạc quan khi người dùng sửa một form lâu (không giữ khoá DB suốt lúc họ gõ) và xung đột hiếm:
Đã chạy (Prisma 7.10.0): hai lệnh renameTodo cùng version song song cho một fulfilled, một rejected, và version tăng đúng 1.
Khoá nhiều dòng theo thứ tự cố định để tránh deadlock. Đã chạy hai transaction cập nhật account 1 rồi 2 và account 2 rồi 1 song song: một bên nhận 40P01 (deadlock detected), bên kia thành công; khi cả hai cùng khoá id nhỏ trước thì cả hai thành công.
Đã chạy: bốn lệnh chuyển song song giữa hai tài khoản (ba lệnh hợp lệ, một lệnh 500 vượt số dư): ba fulfilled, một rejected: INSUFFICIENT_FUNDS, số dư cuối 75 và 125 (tổng không đổi).
Repeatable Read và Serializable: lỗi 40001 là bình thường, phải thử lại. Đo bằng hai kết nối:
- Read Committed: đọc lần 1 ra 100, sau khi bên kia commit 500, đọc lần 2 ra 500 (non-repeatable read). Repeatable Read: cả hai lần ra 100; nếu lúc đó ghi đè dòng đã bị sửa thì lỗi
40001 could not serialize access due to concurrent update. - Write skew (luật: tổng hai tài khoản phải ≥ 1; mỗi bên đọc tổng rồi rút về 0 một tài khoản khác nhau): Repeatable Read cho cả hai COMMIT, tổng cuối bằng 0 (vi phạm luật); Serializable từ chối commit thứ hai bằng
40001.
Đã chạy: hai job write skew chạy qua serializable() đều fulfilled sau 3 lần chạy tổng cộng (một job bị từ chối rồi chạy lại), tổng cuối vẫn là 1. Khi chưa có vòng thử lại, job bị từ chối ném DriverAdapterError với originalCode 40001 và kind TransactionWriteConflict. Hàm trong fn phải không có tác dụng phụ ngoài DB (gửi email, gọi API) vì nó có thể chạy nhiều lần.
6. N+1 Problem#
Định nghĩa. N+1 là anti-pattern: chạy 1 query lấy danh sách N item, rồi lặp N lần mỗi item một query lấy dữ liệu liên quan → tổng 1 + N query round-trip tới DB.
Tại sao quan trọng. Mỗi query có overhead network + parse + plan. 1 request load 100 user → 101 query → chậm gấp nhiều lần một query JOIN. Đây là nguyên nhân #1 khiến API "chạy được lúc dev, sập lúc prod".
Cơ chế (vì sao FE dễ dính). Tư duy loop tự nhiên của JS:
Code trông rất "bình thường" với FE nhưng ẩn N round-trip.
Đã chạy (Prisma 7.10.0, 4 user mỗi user 2 todo, đếm sự kiện query bắt đầu bằng SELECT): bản vòng lặp ra 5 câu (1 + N với N = 4), bản include ra 2 câu (User, rồi Todo với userId IN (...)), bản gom bằng IN ở dưới cũng ra 2. Thêm user thì bản vòng lặp tăng theo, hai bản kia đứng yên. Việc include cố định ở 2 câu là vì schema bài không bật preview relationJoins (theo bản Prisma 7 đã kiểm, relationLoadStrategy chỉ có khi bật preview này; chưa chạy phần này). Bật preview kèm relationLoadStrategy: "join" thì ra 1 câu.
Cách fix.
Cả ba đoạn (và vòng lặp N+1 ở trên) đã qua tsc --strict với client Prisma 7.10.0 sinh từ schema Dự án 2.
Pitfall. ORM giấu N+1 rất kín. Prisma không có lazy loading: user.todos không tồn tại trong kết quả nếu không include (TypeScript báo lỗi), nên N+1 xuất hiện khi bạn tự gọi query trong vòng lặp, như ở trên. Các ORM có lazy relation (truy cập user.orders tự bắn query) mới dính "âm thầm" kiểu đó. Cách phát hiện chắc chắn: bật query logging (log: ['query'] khi tạo client, xem lib/prisma.ts ở hộp phiên bản đầu file) và đếm số query cho một endpoint. Thấy số query tỉ lệ với số dòng dữ liệu → có N+1.
7. EXPLAIN ANALYZE#
Định nghĩa. EXPLAIN cho query plan dự kiến của planner. EXPLAIN ANALYZE thực sự chạy query và trả thời gian + số dòng thật ở mỗi bước.
Tại sao quan trọng. Đây là công cụ số một để hiểu vì sao query chậm và index có được dùng không. Không đoán mò — đo.
Cơ chế / đọc plan cơ bản. Plan là cây, đọc từ trong ra ngoài / từ dưới lên. Các node hay gặp:
- Seq Scan — quét toàn bảng. Chấp nhận được với bảng nhỏ; cờ đỏ với bảng lớn có điều kiện lọc.
- Index Scan — dùng index tìm dòng. Tốt khi lọc trả ít dòng.
- Bitmap Heap Scan — trung gian, khi trả lượng dòng vừa phải.
- Nested Loop / Hash Join / Merge Join — các chiến lược join.
Con số cần nhìn: cost= (ước lượng), actual time= (thật), và đặc biệt chênh lệch giữa rows ước lượng vs thật — lệch lớn nghĩa là thống kê cũ (cần ANALYZE).
Ví dụ.
Pitfall. (1) Chạy EXPLAIN ANALYZE với câu UPDATE/DELETE sẽ thực thi thật — bọc trong BEGIN; ... ROLLBACK; khi thử. (2) Test index trên bảng dev vài chục dòng: planner luôn chọn Seq Scan (bảng nhỏ quét còn rẻ hơn) → tưởng index vô dụng. Phải đánh giá trên dữ liệu đủ lớn (giống prod).
8. ORM vs Query Builder vs Raw SQL#
Định nghĩa. Ba tầng trừu tượng để nói chuyện với DB từ code:
- Raw SQL: viết chuỗi SQL tay, gửi thẳng. Toàn quyền, không type-safe.
- Query Builder (Knex, Kysely): API JS ghép câu SQL từng mảnh, gần sát SQL.
- ORM (Prisma, TypeORM): map bảng ↔ object/model; bạn thao tác object, ORM sinh SQL.
Tại sao quan trọng. Chọn đúng tầng ảnh hưởng năng suất, type-safety, và khả năng tối ưu. FE mạnh TS → ORM type-safe cho DX tuyệt vời và bắt lỗi lúc compile.
Cơ chế / so sánh.
| Tiêu chí | Prisma | TypeORM |
|---|---|---|
| Type-safety | Xuất sắc (client sinh từ schema, TS types tự động) | Khá, dựa decorator, dễ lệch runtime |
| Schema | File schema.prisma (khai báo, một nguồn) | Decorator trên class entity |
| Migration | prisma migrate tự sinh từ diff schema | Sinh/viết tay, dễ rối |
| Pattern | Data Mapper (client tách khỏi model) | Active Record hoặc Data Mapper |
| Hợp với TS | Rất hợp, DX hiện đại | Cũ hơn, nhiều footgun |
Ưu / nhược ORM.
- Ưu: ít boilerplate CRUD, type-safe, migration tự động, dễ đọc, chống SQL injection mặc định (tham số hóa).
- Nhược: query phức tạp (window function, CTE đệ quy, aggregate lồng) khó hoặc bất khả; dễ vô tình sinh SQL kém tối ưu / N+1; thêm một lớp "ma thuật" phải học.
Ví dụ.
Khi nào drop xuống raw SQL.
- Aggregate/analytics phức tạp: window functions,
GROUP BY ... HAVING, CTE đệ quy (cây phân cấp). - Cần tối ưu tay một query nóng mà ORM sinh plan kém.
- Bulk operation lớn cần đúng một câu SQL hiệu quả.
→ Luôn dùng tham số hóa (
$queryRawtagged template), không nối chuỗi input người dùng → tránh SQL injection.
Pitfall. Xem ORM như "khỏi cần biết SQL". Sai. ORM là tiện ích, không thay thế hiểu biết. Khi query chậm hoặc sinh N+1, bạn vẫn phải đọc SQL nó tạo ra (bật log) và hiểu EXPLAIN. Không biết SQL → mù trước bug hiệu năng.
Ngoài phạm vi: Drizzle ORM (schema viết bằng TypeScript, gần SQL hơn Prisma) là lựa chọn khác cùng nhóm; lộ trình này dùng Prisma và không đi sâu Drizzle, xem tài liệu chính thức của Drizzle nếu dự án bạn chọn nó.
Ánh xạ lỗi Prisma sang 409 và 404#
Hai lỗi Prisma gặp nhiều nhất ở tầng service, và cách trả về client:
| Mã | Khi nào | Trả về |
|---|---|---|
P2002 | vi phạm unique (đăng ký trùng email, ghi trùng khoá) | 409 Conflict |
P2025 | update/delete (không phải updateMany/deleteMany) trên bản ghi không tồn tại | 404 Not Found |
Gọi hàm này trong error handler tập trung (GĐ04 mục 6: middleware lỗi 4 tham số) trước khi trả 500. Đã chạy (Prisma 7.10.0): user.create với email trùng ném P2002; todo.update và todo.delete với id không có ném P2025; còn todo.updateMany với id không có không ném mà trả { count: 0 } (vì vậy lời giải Dự án 2 kiểm count === 0). Khi cần "tạo nếu chưa có" mà tránh đua nhau giữa kiểm tra và ghi, dùng upsert hoặc bắt P2002, đừng "tìm rồi tạo" ở hai câu riêng.
9. Migration#
Định nghĩa. Migration là thay đổi schema có phiên bản (versioned): mỗi thay đổi cấu trúc DB (thêm bảng, đổi cột, thêm index) được ghi thành file có thứ tự, commit vào git, chạy được lặp lại trên mọi môi trường.
Tại sao quan trọng. Schema DB phải khớp giữa dev/staging/prod và giữa các thành viên team. Sửa tay không kiểm soát → mỗi máy một schema, deploy vỡ, không ai biết prod đang ở cấu trúc nào. Migration = "git cho cấu trúc database".
Cơ chế. prisma migrate dev so sánh schema.prisma với trạng thái DB hiện tại, sinh file SQL diff trong prisma/migrations/<timestamp>_name/, áp lên DB, ghi vào bảng _prisma_migrations để nhớ đã chạy cái nào. Prod dùng prisma migrate deploy — chỉ áp các migration chưa chạy, không sinh mới.
Ví dụ.
Vì sao KHÔNG sửa DB tay ở prod.
- Không để lại lịch sử → không ai biết đã đổi gì, không reproduce được.
- Dev/prod lệch schema → code chạy dev, vỡ prod.
- Không rollback được có kiểm soát.
- Lần deploy sau migration tự động có thể xung đột với thay đổi tay.
Rollback strategy.
- Prisma không auto-rollback migration đã deploy; thực hành chuẩn là roll forward: viết migration mới sửa lại (vd
DROP COLUMN) thay vì undo. - Expand/contract cho thay đổi phá vỡ: (1) expand thêm cột/bảng mới, deploy code dùng cả cũ+mới; (2) backfill dữ liệu; (3) contract xóa cái cũ ở migration sau. Tránh downtime.
- Luôn backup trước migration phá hủy dữ liệu (drop/rename cột).
Sửa migration trước khi áp: --create-only. Prisma chỉ so hai schema nên không biết ý định của bạn. Đổi tên cột title thành name trên schema, Prisma sinh (xem bằng prisma migrate diff --from-schema cũ --to-schema mới --script):
Chạy npx prisma migrate dev --name rename_title --create-only để Prisma chỉ sinh file, chưa áp, rồi sửa tay file SQL:
sau đó npx prisma migrate dev (hoặc migrate deploy ở CI) mới áp. Cùng cách này dùng để thêm thứ Prisma không sinh được (partial index ở mục 4, backfill dữ liệu). Đã chạy trên 9 todo: Prisma 7.10.0 từ chối chạy migrate dev ở môi trường không tương tác khi thấy thay đổi phá dữ liệu (báo "9 rows" và "drop the column title"), nên tôi tạo thư mục migration chứa dòng RENAME COLUMN bằng tay rồi chạy migrate deploy; migrate status ra Database schema is up to date! và cả 9 giá trị vẫn còn. Lệnh --create-only tương tác đúng như trên là code tham chiếu, chưa chạy.
Pitfall. (1) Sửa file migration đã chạy ở prod → checksum lệch, migrate báo lỗi. Đã deploy thì coi như bất biến, thay đổi tiếp bằng migration mới. (2) prisma migrate reset xóa sạch DB — an toàn ở dev, thảm họa nếu lỡ trỏ vào prod. Kiểm tra DATABASE_URL trước mọi lệnh reset.
10. Seeding Data#
Định nghĩa. Seeding là nạp dữ liệu khởi tạo vào DB bằng script lặp lại được: dữ liệu tham chiếu bắt buộc (roles, categories, quốc gia) hoặc dữ liệu mẫu để dev/test.
Tại sao quan trọng. DB mới toanh sau migration là rỗng. Không seed → mỗi dev tự tay tạo user test, không đồng nhất, khó reproduce bug. Seed = "một lệnh có ngay môi trường làm việc".
Cơ chế. Viết script (Prisma: prisma/seed.ts), khai báo ở migrations.seed trong prisma.config.ts, chạy khi cần. Prisma 7 không còn tự seed sau migrate dev. Nên idempotent — chạy nhiều lần không nhân bản dữ liệu (dùng upsert).
Ví dụ.
Pitfall. Seed không idempotent (dùng create thay upsert) → chạy lần 2 lỗi unique hoặc tạo bản trùng. Và đừng seed dữ liệu giả vào prod — seed prod chỉ nên là dữ liệu tham chiếu thật (danh mục, cấu hình), không phải "user John Doe".
11. Connection Pool#
Định nghĩa. Connection pool là tập kết nối DB được tạo sẵn và tái sử dụng. Thay vì mỗi query mở/đóng một kết nối mới, app mượn từ pool rồi trả lại.
Tại sao quan trọng. Mở một kết nối Postgres tốn kém (TCP handshake, auth, cấp process/backend ở server). Postgres cũng giới hạn số kết nối đồng thời (max_connections, mặc định ~100). Không pool → mỗi request một kết nối → chậm và nhanh chóng cạn giới hạn → too many connections.
Cơ chế. Pool giữ sẵn N kết nối mở. Request đến → mượn một cái → chạy query → trả về pool (không đóng). Nếu pool hết, request xếp hàng chờ cho tới khi có kết nối rảnh (hoặc timeout).
Ví dụ.
Pool size bao nhiêu. Quy tắc thô: không vượt max_connections của Postgres chia cho số instance app. Ví dụ Postgres cho 100, chạy 4 instance → mỗi instance ≤ ~20. To hơn không nhanh hơn — CPU/disk mới là giới hạn thật; pool quá lớn còn làm Postgres nghẽn.
Cạn pool ở serverless (case kinh điển). Lambda/Vercel Functions scale ra hàng trăm instance, mỗi instance mở pool riêng → tổng kết nối bùng nổ → Postgres too many connections, sập. Giải pháp:
- Đặt external pooler: PgBouncer hoặc Prisma Accelerate / Supabase pooler đứng giữa, gộp kết nối.
- Serverless set
max: 1ở adapter mỗi function + pooler ngoài gánh.
Pitfall. (1) Tạo new PrismaClient() trong mỗi request handler thay vì một singleton dùng lại → nổ pool ngay. Luôn khởi tạo client một lần ở module scope. (2) Quên trả kết nối (transaction treo, không $disconnect) → rò rỉ kết nối, pool cạn dần.
12. MongoDB (NoSQL document)#
Định nghĩa. MongoDB là document database: lưu document dạng BSON (JSON nhị phân) trong collection (tương đương bảng). Không schema cứng — mỗi document có thể khác cấu trúc.
Tại sao quan trọng. Với FE, document ≈ object JS lồng nhau — trực giác. Hợp khi dữ liệu tự nhiên là cây, schema hay đổi, cần scale ghi ngang. Nhưng đừng chọn chỉ vì "quen JSON" (xem mục 1).
Cơ chế / document model.
Khi nào chọn NoSQL (Mongo).
- Dữ liệu phân cấp, đọc/ghi trọn cả cây một lần (product catalog, CMS content, event log).
- Schema tiến hóa nhanh, chưa ổn định.
- Cần horizontal sharding cho lượng ghi rất lớn.
- Không chọn khi dữ liệu quan hệ nặng, cần ràng buộc toàn vẹn giữa nhiều thực thể (FK, unique chéo bảng), báo cáo JOIN phức tạp → Postgres thắng. Mongo có transaction đa-document (cần replica set, xem GĐ06 mục 7), nhưng đắt hơn và không thay được FK; thiết kế để mỗi thao tác nằm gọn trong một document thì ít khi cần đến nó.
Schema design: Embed vs Reference.
- Embed (nhúng con vào cha): đọc một phát ra hết, nhanh. Dùng khi con thuộc về cha, ít khi query riêng, và có giới hạn (địa chỉ, line items). Rủi ro: unbounded array (nhúng vô hạn comment) → document phình, đụng giới hạn 16MB/document.
- Reference (lưu id trỏ collection khác, giống FK): dùng khi con độc lập, được chia sẻ/nhiều, hoặc lớn/không giới hạn. Đánh đổi: cần nhiều query hoặc
$lookupđể ghép — tức là bạn tự làm việc mà SQL JOIN làm sẵn.
Aggregation pipeline (tóm tắt). Xử lý dữ liệu qua chuỗi stage, output stage này là input stage sau — giống Array.prototype chain của JS:
Pitfall. (1) Nhúng mảng không giới hạn → document phình tới 16MB rồi vỡ. (2) Tưởng Mongo "không cần thiết kế schema" → thực tế thiết kế embed/reference quan trọng hơn SQL vì không có JOIN cứu. (3) Query không có index vẫn COLLSCAN (quét toàn collection) chậm y như Seq Scan — Mongo cũng cần index.
13. Redis#
Định nghĩa. Redis là in-memory data store key-value: dữ liệu nằm trong RAM → đọc/ghi cực nhanh (sub-millisecond). Hỗ trợ nhiều kiểu: string, hash, list, set, sorted set. Thường dùng bên cạnh DB chính, không thay thế.
Tại sao quan trọng. DB đĩa (Postgres) là source of truth nhưng chậm hơn RAM hàng chục–trăm lần. Redis gánh phần "cần nhanh & tạm thời": cache, session, đếm rate-limit, hàng đợi nhẹ — giảm tải cực lớn cho DB chính.
Cơ chế. Toàn bộ dataset trong RAM (có tùy chọn persist ra đĩa để phục hồi). Single-threaded cho command → mỗi lệnh nguyên tử, không cần lock ở phía bạn. Mỗi key có thể gắn TTL để tự hết hạn.
Use cases.
- Cache: lưu kết quả query/tính toán nặng, TTL vài giây–phút.
- Session: lưu session người dùng (nhanh, tự hết hạn khi TTL).
- Rate-limit counter:
INCRmột key theo user+phút, quá ngưỡng thì chặn. - Queue nhẹ:
LPUSH/BRPOPlàm hàng đợi job đơn giản (job nặng dùng BullMQ trên Redis).
TTL (Time To Live). Cho key một tuổi thọ; hết hạn Redis tự xóa. Là công cụ invalidation cơ bản nhất.
Cache-aside pattern (phổ biến nhất).
Đọc: thử cache trước; miss thì đọc DB rồi ghi lại cache. App tự quản cache (không phải Redis tự đọc DB).
Kết quả mong đợi (chưa chạy): trước lần gọi đầu redis-cli TTL user:<id> trả -2 (chưa có key), sau lần gọi đầu trả một số ≤ 300, sau khi update rồi del lại trả -2.
Invalidation. "Có 2 việc khó trong CS: đặt tên và cache invalidation." Khi dữ liệu đổi, cache cũ stale. Chiến lược:
- TTL — chấp nhận stale tối đa T giây, đơn giản nhất.
- Write-through / xóa khi ghi: update DB xong thì
redis.del('user:'+id)→ lần đọc sau tự nạp mới.
Pitfall. (1) Cache dữ liệu đổi liên tục mà không invalidate → user thấy dữ liệu cũ (giá sai, quyền sai). (2) Coi Redis là source of truth — RAM có thể mất; luôn để Postgres giữ dữ liệu thật, Redis chỉ là bản sao tăng tốc. (3) Thundering herd: key nóng hết hạn cùng lúc, hàng loạt request cùng miss cùng đập DB → dùng jitter TTL hoặc lock nạp lại. (4) Không đặt TTL cho cache → RAM đầy dần, Redis evict lung tung hoặc OOM.
Khoá nguyên tử, cấu trúc dữ liệu và eviction#
Cấu trúc dữ liệu: chọn kiểu theo thao tác, không nhét mọi thứ vào chuỗi JSON. Đã chạy trên Redis 8.6.1:
| Kiểu | Hợp với | Ví dụ lệnh | Kết quả |
|---|---|---|---|
| String | cache, bộ đếm, khoá | SET k v EX 60, INCR k | |
| Hash | một đối tượng nhiều trường, sửa từng trường | HSET user:1 name An age 30, HGETALL user:1 | name An, age 30 |
| List | hàng đợi đơn giản | RPUSH q j1 j2, LPOP q | j1 |
| Set | tập không trùng | SADD tags x y x, SCARD tags | 2 |
| Sorted set | bảng xếp hạng, hẹn giờ | ZADD lb 10 a 30 b 20 c, ZREVRANGE lb 0 1 WITHSCORES | b 30, c 20 |
SET key value NX EX n là một lệnh nguyên tử: chỉ ghi khi key chưa có và đặt luôn TTL. Hai tiến trình cùng gọi thì đúng một bên nhận OK, bên kia nhận nil. Dùng cho khoá chống chạy trùng (cron chạy ở nhiều instance) hoặc khoá idempotency. Giá trị là token ngẫu nhiên, và lúc nhả khoá phải so token rồi mới xoá (script Lua chạy nguyên tử), nếu không bạn có thể xoá nhầm khoá của người khác sau khi khoá của mình đã hết TTL:
Đã chạy: khoá A lấy được, khoá B (cùng tên) không lấy được; nhả với token sai trả false, nhả đúng token trả true. Khoá này đủ cho một instance Redis; nó không đảm bảo tuyệt đối khi Redis failover hoặc tiến trình dừng quá TTL (xem GĐ19 mục 7: fencing token).
INCR rồi EXPIRE rời nhau không nguyên tử. Nếu app chết giữa hai lệnh, key đếm tồn tại vĩnh viễn không TTL (đã chạy: INCR rồi TTL ra -1), và user đó bị chặn mãi. Gộp vào một MULTI với EXPIRE ... NX (chỉ đặt TTL khi key chưa có; cần Redis 7.0 trở lên) hoặc một script Lua:
Đã chạy: năm lệnh hit song song ra bộ đếm 1 đến 5 (không trùng, không mất), TTL của key là 60. Gọi EXPIRE ... NX lần hai trên key đã có TTL không làm TTL bị reset (đã chạy: sau lần gọi thứ hai giá trị là 2 và TTL còn 60 hoặc 59; hai lần gọi cách nhau dưới một giây nên số đo này chưa tự phân biệt được "không reset" với "reset", muốn chắc hãy chờ vài giây giữa hai lần gọi rồi đọc TTL).
Eviction: Redis làm gì khi hết RAM. Đặt maxmemory và maxmemory-policy. Đã chạy với maxmemory 3mb, ghi 200 nghìn key:
noeviction: khoảng 10,7 nghìn lệnhSETđầu thành công, phần còn lại nhận lỗiOOM command not allowed when used memory > 'maxmemory'. Lệnh đọc vẫn chạy.allkeys-lru: cả 200 nghìn lệnhOK; Redis xoá khoảng 189,8 nghìn key (evicted_keys), còn khoảng 10,2 nghìn.volatile-lrukhi không key nào có TTL: cư xử nhưnoeviction(cùng lỗi OOM), đúng như tài liệu nói.
Quy tắc chọn: cache thuần thì allkeys-lru (nguồn gốc của dữ liệu là Postgres, mất cache chỉ chậm hơn); Redis chứa queue (BullMQ) hay khoá thì bắt buộc noeviction vì để Redis tự xoá job là mất việc (xem GĐ10 mục 2); và tách cache và queue ra hai instance. Bản cài mặc định ở máy tôi báo maxmemory-policy noeviction và maxmemory 0 (không giới hạn); số này phụ thuộc cách cài, hãy kiểm bằng CONFIG GET.
Persistence: RDB và AOF (theo tài liệu Redis, chưa chạy thử khôi phục sau kill -9):
| RDB | AOF | |
|---|---|---|
| Cơ chế | chụp toàn bộ dữ liệu theo chu kỳ (save 60 1000 = 60 giây nếu có từ 1000 thay đổi) | ghi nối từng lệnh ghi, phát lại khi khởi động |
| Mất dữ liệu khi sập | vài phút cuối | appendfsync everysec (mặc định): tối đa khoảng 1 giây |
| Kích thước, khởi động | gọn, khởi động nhanh | lớn hơn, khởi động chậm hơn |
| Hợp với | backup, cache chịu mất vài phút | dữ liệu ít chịu mất |
Tài liệu khuyên dùng cả hai khi cần độ an toàn gần với Postgres; nếu cả hai bật, lúc khởi động Redis dùng AOF. Với cache thuần, tắt persistence cũng chấp nhận được. Dù chọn gì, Redis vẫn không phải source of truth.
14. Full-text search với Postgres (tsvector)#
Định nghĩa. Full-text search (FTS) là tìm kiếm theo từ và ngữ nghĩa hình thái thay vì so khớp chuỗi thô. Postgres có sẵn: tsvector (tài liệu đã tách từ + chuẩn hoá), tsquery (truy vấn), @@ (toán tử khớp).
Tại sao quan trọng. Ở GĐ23 bạn sẽ học vector search cho tìm kiếm ngữ nghĩa. Nhưng rất nhiều bài toán tìm kiếm không cần embedding — tìm tài liệu theo tiêu đề, lọc sản phẩm, tìm trong ghi chú. Dùng LLM/embedding cho những việc này là chậm hơn, đắt hơn và kém chính xác hơn. Và LIKE '%từ khoá%' thì không dùng được index B-tree → quét toàn bảng.
Cơ chế.
to_tsvector('english', text)tách từ, bỏ stop word ("the", "and"), và stemming (đưa "running"/"ran" → "run"). Nhờ vậy tìm "run" khớp được "running".to_tsquery/plainto_tsquery/websearch_to_tsquerychuyển chuỗi người dùng nhập thành truy vấn.- Index GIN trên
tsvectorlàm việc tìm nhanh như index thường. ts_rankchấm điểm mức liên quan để sắp xếp.
Ví dụ.
Tiếng Việt. Postgres không có cấu hình FTS cho tiếng Việt sẵn. Hai lựa chọn thực dụng:
- Dùng
'simple'(chỉ tách từ theo khoảng trắng, không stemming, không stop word) — hoạt động khá tốt với tiếng Việt vì tiếng Việt không biến hình từ. - Kết hợp thêm
pg_trgm(GINtrên trigram) cho tìm gần đúng, chịu được lỗi gõ thiếu dấu:
Khi nào cần công cụ chuyên dụng. Postgres FTS đủ cho tới khoảng vài triệu tài liệu. Vượt qua đó, hoặc khi cần facet phức tạp / gõ tới đâu gợi ý tới đó / typo-tolerance mạnh → Meilisearch, Typesense, Elasticsearch. Đừng bắt đầu từ đó — thêm một hệ thống phải đồng bộ dữ liệu là một lớp phức tạp lớn.
Pitfall.
- Quên
coalesce()→ một cộtNULLlàm cả biểu thức nối thànhNULL→ tài liệu biến mất khỏi tìm kiếm trong im lặng. - Dùng
to_tsquerythẳng với chuỗi người dùng nhập → cú pháp sai (dấu&,!) làm câu lệnh ném lỗi. Dùngplainto_tsquery/websearch_to_tsquerycho input tự do. - Sai cấu hình ngôn ngữ giữa lúc index và lúc truy vấn (
'english'vs'simple') → không bao giờ khớp, mà không có lỗi nào báo.
Dự án 2#
Mục tiêu: thêm Postgres + Prisma vào Todo API (từ GĐ trước), viết migration, và cố ý tạo rồi fix một query N+1.
Bước làm.
- Chạy Postgres cục bộ (Docker), có volume để dữ liệu sống qua lần xoá container:
docker run -e POSTGRES_PASSWORD=dev -p 5432:5432 -v pgdata:/var/lib/postgresql/data -d postgres:17(lệnh chưa chạy; đường dẫn data này dành cho image 17, image 18 trở lên đổi đường dẫn: chưa xác minh, xem README của image). - Cài Prisma (ghim 7.10, xem hộp phiên bản đầu file):
npm i -D -E prisma@7.10.0 tsx && npm i -E @prisma/client@7.10.0 @prisma/adapter-pg@7.10.0 pg dotenv && npx prisma init, rồi thêmsrc/lib/prisma.tsvàmigrations.seedtrong config như hộp đó. - Thiết kế schema (
schema.prisma) — quan hệ 1-n: mộtUsercó nhiềuTodo.textReady - Migration:
npx prisma migrate dev --name init_todo, rồinpx prisma generate(Prisma 7 không tự generate) → commit thư mụcprisma/migrations/. - Seed:
prisma/seed.tstạo 1 user + vài todo, chạy lại không nhân bản (upsertcho user; todo kiểm tra tồn tại trước khi tạo). Chạy bằngnpx prisma db seed. - Thay in-memory array bằng Prisma trong các route CRUD (
findMany,create,update,delete). - Tạo N+1 rồi fix: endpoint
GET /users-with-todos:- Viết bản N+1:
findManyusers → loopfindManytodos từng user. Bậtlog:['query'], đếm số query. - Fix bằng
include: { todos: true }(hoặc một câu JOIN raw). Đếm lại → còn ~1–2 query.
- Viết bản N+1:
- Xác minh index:
EXPLAIN ANALYZE SELECT * FROM "Todo" WHERE "userId"='<uuid-của-user>';(id là chuỗi UUID nên phải có dấu nháy;= 1báo lỗitext = integer) → thấyIndex ScanhoặcBitmap Index ScantrênTodo_userId_idx, không phảiSeq Scan. Bảng chỉ vài dòng thì planner có thể chọn Seq Scan: chèn vài chục nghìn dòng rồiANALYZE "Todo"trước khi kết luận (đã thử: 20 nghìn dòng →Bitmap Index Scan). - (Tùy chọn) Redis cache-aside cho
GET /users/:id: cache 60s,delkhi update.
Sản phẩm giao: Todo API chạy trên Postgres, thư mục migration versioned, seed script, và một commit "before/after" chứng minh N+1 đã fix (kèm số lượng query đo được).
Lời giải và cách kiểm tra: Dự án 2 — Todo API trên Postgres + Prisma
Code tham chiếu, chưa chạy (chưa dựng Postgres/Prisma cho lời giải này); phần Prisma lấy từ hộp phiên bản đầu file (Prisma 7.10, đã chạy ở đó) và đối chiếu cú pháp. "Kết quả mong đợi" là suy ra từ code và tài liệu, không phải quan sát.
Hướng làm.
- Postgres cục bộ: nếu cổng 5432 đã bận (đã có Postgres khác trên máy), map cổng khác, ví dụ
docker run -e POSTGRES_PASSWORD=dev -p 5433:5432 -v pgdata:/var/lib/postgresql/data -d postgres:17, rồiDATABASE_URL=postgresql://postgres:dev@localhost:5433/postgrestrong.env(không commit; có.env.example). - Cài Prisma và tạo
prisma.config.ts,src/lib/prisma.tsđúng như hộp đầu file; thêmUser,Todovàoschema.prisma(bước 3 của đề). npx prisma migrate dev --name init_todo, rồinpx prisma generate, rồi commitprisma/migrations/.- Viết
prisma/seed.ts, chạynpx prisma db seedhai lần. - Thay
Mapởtodo.service.ts(GĐ04) bằng Prisma; route giữ nguyên. - Thêm
GET /users-with-todosbản N+1, đếm query, sửa bằnginclude. EXPLAIN ANALYZEtrên bảng đủ lớn; tuỳ chọn thêm Redis.
Sơ đồ.
Code tham chiếu.
updateMany/deleteMany với { id, userId } cho phép kiểm quyền và ghi trong một câu SQL; count === 0 nghĩa là "không có hoặc không phải của bạn", cùng một 404. Các thao tác này đều trên một bảng nên không cần $transaction; chỉ cần khi một nghiệp vụ ghi từ hai dòng phụ thuộc nhau trở lên.
Đo query: client có log: ["query"] nên mỗi câu SQL in ra một dòng bắt đầu bằng prisma:query. Chạy npx tsx src/server.ts > server.log 2>&1 &, ghi wc -l < server.log, gọi curl một endpoint, ghi lại wc -l; hiệu số (chỉ tính dòng SELECT) là số query của endpoint đó.
Kết quả mong đợi (suy ra, chưa chạy):
Số "1 + N" chỉ chứng minh được khi bảng User có từ 2 dòng trở lên; thêm vài user vào seed để số liệu before/after rõ.
Lỗi hay gặp. (1) Quên npx prisma generate sau migrate dev (Prisma 7 không tự chạy): lỗi Cannot find module '../generated/prisma/client.js'. (2) Thiếu đuôi .js khi import client sinh ra. (3) EXPLAIN trên bảng 5 dòng ra Seq Scan: không phải lỗi, planner chọn đúng; chèn dữ liệu rồi ANALYZE. (4) where: { id: 1 } vì quen số tự tăng: id là UUID chuỗi. (5) prisma migrate reset trỏ nhầm vào DB không phải dev. (6) Seed dùng create nên lần 2 báo trùng email. (7) new PrismaClient() trong handler: nổ pool (mục 11).
Done khi#
-
Postgres chạy,
DATABASE_URLcấu hình, kết nối OK.Đáp án
psql "$DATABASE_URL" -c 'select 1'ra1, hoặcnpx prisma migrate statuskết nối được và báo trạng thái migration. Nếu báo không kết nối được: kiểm cổng đã map, mật khẩu, và.envđã đượcprisma.config.tsnạp quadotenv/config. Xem hộp phiên bản đầu file và mục 11. Các đáp án ở mục Done khi này là code tham chiếu, chưa chạy; lệnh và kết quả là điều bạn phải thấy khi tự kiểm. -
schema.prismamô tả quan hệ 1-n User–Todo, có@@index([userId])và@uniqueemail. -
prisma migrate devsinh migration, thư mụcprisma/migrations/commit vào git; không sửa DB bằng tay.Đáp án
Có
prisma/migrations/<timestamp>_init_todo/migration.sqltronggit status;npx prisma migrate statusbáoDatabase schema is up to date. Sửa file migration đã áp dụng sẽ làmmigratebáo checksum lệch: thay đổi tiếp bằng migration mới. Sai thường gặp:migrate resettrên DB không phải dev. Xem mục 9. -
Seed script idempotent,
prisma db seedchạy lại được không lỗi/không trùng.Đáp án
Chạy
npx prisma db seedhai lần;SELECT count(*) FROM "User"và"Todo"không tăng ở lần hai. Điều kiện: mọi bản ghi seed dùngupsertvới khoá cố định (email với user, id cố định với todo). Xem mục 10. -
Mọi route CRUD dùng Prisma thay array in-memory; bọc transaction cho thao tác đa-bảng nếu có.
Đáp án
Không còn
Map/array trong service;updateMany/deleteManycówhere: { id, userId },count === 0ra 404. Ghi đa dòng phụ thuộc nhau (chuyển tiền, tồn kho) bọc$transaction; CRUD một bảng thì không cần. Xem mục 5 và lời giải Dự án 2. -
Endpoint N+1 đã fix bằng
include/JOIN; đo được số query giảm rõ (vd 1+N → 2) qua query log.Đáp án
Mở
log: ["query"], đếm dòngSELECTcủa endpoint chậm (1 + số user) rồi endpoint dùnginclude(2SELECTkhi không bậtrelationJoins): số liệu trước và sau đặt vào commit message. Cách đếm ở lời giải Dự án 2. Sai thường gặp: chỉ đo trên một user nên không thấy khác biệt. Xem mục 6. -
EXPLAIN ANALYZEcho truy vấn lọc theouserIdxác nhận dùng index (Index Scan hoặc Bitmap Index Scan), không phải Seq Scan.Đáp án
Chạy với UUID đặt trong dấu nháy trên bảng đã có vài chục nghìn dòng và đã
ANALYZE "Todo": phải thấyIndex ScanhoặcBitmap Index ScantrênTodo_userId_idx.Seq Scantrên bảng vài dòng là bình thường (planner thấy quét rẻ hơn), chưa kết luận được gì. Xem mục 7. -
Giải thích được (bằng lời): 1-n vs n-n, INNER vs LEFT JOIN, khi nào index vô dụng, ACID qua ví dụ chuyển tiền, cache-aside + invalidation.
Đáp án
(a) 1-n: FK ở phía "n"; n-n: bảng trung gian chứa hai FK. (b) INNER chỉ giữ dòng khớp cả hai bên; LEFT giữ hết bảng trái, bên phải thiếu thì
NULL(vàWHERE o.status='paid'biến LEFT thành INNER). (c) Index vô dụng khi bọc cột trong hàm,LIKE '%x', cột ít giá trị phân biệt, hoặc bảng quá nhỏ. (d) ACID: chuyển 100 từ A sang B, lệnh thứ hai lỗi thì lệnh thứ nhất rollback, tiền không bốc hơi. (e) Cache-aside: đọc cache, miss thì đọc DB rồi ghi cache có TTL; ghi DB xong thìdelkey; Redis chỉ là bản sao tăng tốc. Xem mục 2, 3, 4, 5, 13. -
(Nếu làm Redis) cache-aside có TTL và có
delkhi ghi; không coi Redis là source of truth.Đáp án
Tự kiểm: sau lần đọc đầu
redis-cli TTL user:<id>ra số dương ≤ TTL đã đặt; sauPATCHrồiTTLra-2. Xoá toàn bộ Redis thì ứng dụng vẫn trả đúng dữ liệu (chậm hơn lần đầu). Xem mục 13. -
Tái hiện được lost update bằng hai kết nối song song và sửa bằng
FOR UPDATEhoặcUPDATE ... SET x = x + n; nói được khi nào lỗi40001phải thử lại.Đáp án
Mở hai kết nối, mỗi bên
BEGIN,SELECT balance, chờ,UPDATE ... SET balance = <đọc được> + 10,COMMIT: số dư 100 ra 110 thay vì 120. ThêmFOR UPDATEvàoSELECT(hoặc viếtSET balance = balance + 10) thì ra 120.40001(could not serialize access) xuất hiện ở Repeatable Read và Serializable; nó không phải lỗi logic mà là tín hiệu "chạy lại cả transaction", nên bọc vòng thử lại có giới hạn số lần và đừng để hàm trong transaction có tác dụng phụ ngoài DB. Sai thường gặp: tưởng chỉ cầnBEGIN ... COMMITlà đủ ở Read Committed. Xem mục 5. -
Ánh xạ được
P2002sang 409 vàP2025sang 404, và giải thích vì saoupdateManyvới id không tồn tại không ném lỗi.Đáp án
P2002là vi phạm unique (đăng ký trùng email) nên 409;P2025làupdate/deletekhông tìm thấy bản ghi nên 404.updateMany/deleteManykhông nhắm một bản ghi cụ thể: chúng trả{ count: 0 }khi không khớp, nên code phải kiểmcount === 0rồi tự ném 404. Kiểm bằng cách gọiuser.createhai lần cùng email vàtodo.updatevới id không có. Xem mục 8. -
Viết được truy vấn "order lớn nhất của mỗi user" bằng window function, và giải thích vì sao
NOT INvới truy vấn con chứaNULLra rỗng.Đáp án
SELECT * FROM (SELECT user_id, id, total, row_number() OVER (PARTITION BY user_id ORDER BY total DESC NULLS LAST) AS rn FROM orders) s WHERE rn = 1;(lọcrnở truy vấn ngoài vìWHEREchạy trước window).x NOT IN (a, b, NULL)đánh giá thànhNULL(không phảitrue) với mọixkhông nằm tronga,b, nên không dòng nào được giữ: mộtNULLtrong danh sách làm cả điều kiện vô hiệu. DùngNOT EXISTS. Xem mục 3. -
Đổi tên một cột bằng migration giữ nguyên dữ liệu (
--create-onlyrồi sửa tay thànhRENAME COLUMN), và chọn đúngonDeletecho quan hệ User–Todo và User–Invoice.Đáp án
Sinh file migration mà chưa áp, thay
DROP COLUMN+ADD COLUMNbằngALTER TABLE "Todo" RENAME COLUMN "title" TO "name";, rồi áp;SELECT count(name)phải bằng số dòng cũ. Todo thuộc về user nênonDelete: Cascade; hoá đơn không được biến mất khi xoá user nên để mặc định (Restrict, chặn xoá) hoặc dùng xoá mềm. Sai thường gặp: chấp nhận SQL Prisma sinh khi nó cảnh báo mất dữ liệu. Xem mục 2 và mục 9. -
Viết được rate limit bằng Redis không bao giờ để lại key không TTL, và chọn
maxmemory-policycho một Redis cache và một Redis chứa queue.Đáp án
MULTI,INCR key,EXPIRE key 60 NX,EXEC(hoặc một script Lua): sau mỗi lần gọiTTL keyphải ra số dương, không bao giờ-1. Cache:allkeys-lruvớimaxmemoryđặt rõ; queue (BullMQ) và khoá:noeviction, tách instance khỏi cache. Kiểm bằngCONFIG GET maxmemory-policy. Xem mục 13. -
Bảo đảm unique chỉ trên bản ghi chưa xoá mềm (email dùng lại được sau khi xoá mềm) bằng một index Postgres.
Đáp án
CREATE UNIQUE INDEX users_email_live ON users(email) WHERE deleted_at IS NULL;(partial unique index). Chèn email, đặtdeleted_at, chèn lại cùng email thì được; chèn thêm một bản ghi sống cùng email thì lỗi unique. Kiểm bằng ba câuINSERT/UPDATE/INSERTở trên. Xem mục 4.
Câu hỏi mở / cần xác nhận#
-
Dự án 2 dùng framework nào ở tầng API (Express/Nest/Fastify)? Ảnh hưởng cách đặt Prisma singleton & DI.
-
Có triển khai serverless không? Nếu có, cần bàn PgBouncer/Accelerate cho connection pool.
Hướng trả lời hiện tại
(Chưa khẳng định.) lộ trình triển khai bằng container, nên chưa cần bộ gộp kết nối ngoài;
maxcủa pool nhân số instance phải nằm dướimax_connectionscủa Postgres (mục 11). Nếu chuyển sang serverless, dùng pooler ngoài vàmax: 1mỗi function. -
Redis là bắt buộc hay optional trong scope Dự án 2?
Hướng trả lời hiện tại
(Chưa khẳng định.) optional, đúng như bước 9 và Done khi cuối. Mọi tiêu chí còn lại kiểm được không cần Redis.