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 latest của gói prisma trên npm đang trỏ tới RC của Prisma 8 (8.0.0-rc.19) trong khi @prisma/client latest là 7.10.0, nên npm i -D prisma có thể kéo CLI và client lệch major.

bashReady
npm i -D -E prisma@7.10.0 tsxnpm i -E @prisma/client@7.10.0 @prisma/adapter-pg@7.10.0 pg dotenvnpx prisma init     # tạo prisma/schema.prisma, .env và file config

Khác Prisma 6 (những chỗ làm ví dụ cũ hỏng):

  • generator client dùng provider = "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-pg cho 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 trong schema.prisma. Ở 7.10, prisma init đặt tên file là prisma7.config.ts; Prisma nhận cả hai tên (đã chạy prisma validate với từng tên), đổi thành prisma.config.ts cho gọn. init còn ghi thêm thư mục skills (.claude/, .agents/, .windsurf/): xoá nếu không dùng.
  • prisma migrate dev không tự chạy generate và không tự seed: gọi npx prisma generate và npx prisma db seed (đã chạy: sau migrate dev thư 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.
textReady
// prisma/schema.prisma — đầu file (các `model` ở dưới đều ghép vào sau đoạn này)generator client {  provider = "prisma-client"  output   = "../src/generated/prisma"}datasource db {  provider = "postgresql"}
typescriptReady
// prisma.config.tsimport "dotenv/config";import { defineConfig } from "prisma/config";export default defineConfig({  schema: "prisma/schema.prisma",  migrations: {    path: "prisma/migrations",    seed: "tsx prisma/seed.ts",   // chạy bằng `npx prisma db seed`  },  datasource: { url: process.env["DATABASE_URL"] },});
typescriptReady
// src/lib/prisma.ts — một instance dùng chung; các ví dụ bên dưới gọi nó là `prisma`import { PrismaPg } from "@prisma/adapter-pg";import { PrismaClient } from "../generated/prisma/client.js"; // đuôi .js theo module NodeNext của GĐ02const adapter = new PrismaPg({  connectionString: process.env.DATABASE_URL,  max: 10,                    // pool size, tuỳ chọn của driver `pg`});export const prisma = new PrismaClient({ adapter, log: ["query"] }); // log: xem N+1 ở mục 6

prisma validate và prisma generate khô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ó SQLSTATE 40001, app phải thử lại cả transaction) và Redis: Key eviction (danh sách chính sách, noeviction trả lỗi khi ghi, volatile-* hoạt động như noeviction nếu không key nào có TTL) và Redis: Persistence (RDB, AOF, appendfsync everysec mặ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ỗi P2002/P2025 trê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ụ.

textReady
-- Khai báo "cái gì", không phải "làm thế nào"SELECT name, email FROM users WHERE age >= 18 ORDER BY created_at DESC LIMIT 10;

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 + UNIQUE constraint (hoặc dùng chung primary key).

Ví dụ (SQL — n-n).

textReady
CREATE TABLE students (id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT);CREATE TABLE courses  (id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title TEXT);CREATE TABLE enrollments (  student_id INT REFERENCES students(id),  course_id  INT REFERENCES courses(id),  enrolled_at TIMESTAMPTZ DEFAULT now(),  PRIMARY KEY (student_id, course_id)   -- ngăn ghi trùng cặp);

Ví dụ (Prisma — n-n).

textReady
model Student {  id      Int          @id @default(autoincrement())  courses Enrollment[]}model Course {  id       Int          @id @default(autoincrement())  students Enrollment[]}model Enrollment {  student   Student @relation(fields: [studentId], references: [id])  studentId Int  course    Course  @relation(fields: [courseId], references: [id])  courseId  Int  @@id([studentId, courseId])}

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_count thay 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áoSQL sinh raXoá User còn Todo
quan hệ bắt buộc, không ghi onDeleteON DELETE RESTRICTbáo lỗi, không xoá
onDelete: CascadeON DELETE CASCADExoá luôn các Todo
quan hệ tuỳ chọn (userId String?), không ghi onDeleteON DELETE SET NULLTodo còn lại, userId thành NULL
textReady
model Todo {  id     String @id @default(uuid())  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)  userId String}

Đã 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ụ.

textReady
-- INNER: chỉ user CÓ order mới hiệnSELECT u.name, o.totalFROM users uINNER JOIN orders o ON o.user_id = u.id;-- LEFT: MỌI user hiện; user chưa mua → o.total = NULLSELECT u.name, o.totalFROM users uLEFT JOIN orders o ON o.user_id = u.id;-- Đếm order mỗi user (user 0 order vẫn hiện nhờ LEFT + COUNT trên cột phải)SELECT u.name, COUNT(o.id) AS order_countFROM users uLEFT JOIN orders o ON o.user_id = u.idGROUP BY u.id, u.name;

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(*) vs COUNT(o.id) với LEFT JOIN: COUNT(*) đếm cả dòng NULL → user 0 order ra 1 (SAI); COUNT(o.id) bỏ qua NULL → ra 0 (ĐÚ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).

textReady
-- Window function: giữ mọi dòng, thêm cột tính trên "cửa sổ" cùng user-- Order lớn nhất của mỗi user (lọc rn = 1 ở truy vấn ngoài, vì WHERE chạy trước window)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;-- Tổng luỹ kế theo userSELECT id, user_id, total,       sum(total) OVER (PARTITION BY user_id ORDER BY id) AS runningFROM orders;-- CTE: đặt tên cho bước trung gian, đọc từ trên xuốngWITH spent AS (  SELECT user_id, sum(total) AS s FROM orders WHERE status = 'paid' GROUP BY user_id)SELECT u.name, coalesce(s.s, 0) AS spentFROM users2 u LEFT JOIN spent s ON s.user_id = u.id;-- CTE đệ quy: duyệt cây quản lý (An -> Binh -> Dung)WITH RECURSIVE tree AS (  SELECT id, name, 0 AS depth FROM users2 WHERE manager_id IS NULL  UNION ALL  SELECT u.id, u.name, t.depth + 1 FROM users2 u JOIN tree t ON u.manager_id = t.id)SELECT * FROM tree ORDER BY depth, id;-- EXISTS: "có ít nhất một", dừng ở dòng đầu tiên tìm thấySELECT name FROM users2 u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);SELECT name FROM users2 u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

Đã 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 = NULL ra NULL (không phải true), nên WHERE x = NULL không bao giờ khớp. Dùng IS NULL, hoặc IS DISTINCT FROM khi muốn so sánh coi NULL là một giá trị.
  • WHERE total <> 50 bỏ luôn dòng có total là NULL (4 dòng); WHERE total IS DISTINCT FROM 50 giữ chúng (5 dòng).
  • COUNT(total) bỏ qua NULL, COUNT(*) thì không; SUM/AVG cũng bỏ qua NULL (6 dòng nhưng COUNT(total) ra 5).
  • NOT IN với danh sách chứa NULL ra rỗng. Thêm một order có user_id là NULL rồi chạy SELECT 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ùng NOT EXISTS (như trên) thay cho NOT IN vớ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ụ.

textReady
CREATE INDEX idx_orders_user ON orders(user_id);           -- tăng tốc JOIN & WHERE user_id=CREATE INDEX idx_orders_user_status ON orders(user_id, status); -- compositeCREATE UNIQUE INDEX idx_users_email ON users(email);       -- vừa tăng tốc vừa ép unique
textReady
model Order {  id     String @id @default(uuid())   // mỗi model cần ít nhất một khoá duy nhất  userId String  status String  @@index([userId, status])   // Prisma tạo composite index}

Đá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ùng idx(email) → cần functional index CREATE INDEX ... ON users(lower(email)).
  • LIKE '%abc' (wildcard đầu) — không dùng B-tree được.
  • Cột low cardinality (vd is_active chỉ 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 text mà 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ạiDùng khiVí dụKết quả đã đo
Partialchỉ 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 đủ
Expressionlọc qua hàmCREATE 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ảngCREATE INDEX ON big(created_at) INCLUDE (status)Index Only Scan (sau VACUUM, vì cần visibility map)
GINjsonb, 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
BRINbả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:

textReady
CREATE UNIQUE INDEX acc_email_live ON acc(email) WHERE deleted_at IS NULL;-- email của tài khoản đã xoá mềm dùng lại được; hai tài khoản "sống" cùng email thì lỗi:-- ERROR: duplicate key value violates unique constraint "acc_email_live"

Đã 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).

textReady
BEGIN;UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- trừ AUPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- cộng BCOMMIT;-- Nếu lệnh 2 lỗi → ROLLBACK → lệnh 1 cũng bị hủy. Tiền không bao giờ "bốc hơi".
typescriptReady
// Prisma: interactive transaction — throw ở giữa => tự rollback toàn bộawait prisma.$transaction(async (tx) => {  await tx.account.update({ where: { id: 1 }, data: { balance: { decrement: 100 } } });  await tx.account.update({ where: { id: 2 }, data: { balance: { increment: 100 } } });});

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áchCơ 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 ghikết nối thứ hai chờ tới khi kết nối đầu COMMIT, rồi đọc giá trị mới120
UPDATE ... SET balance = balance + 10phép cộng nằm trong một câu, DB tự khoá dòng120
khoá lạc quan: cột versionUPDATE ... WHERE id = $1 AND version = $2; ai ghi sau trên bản cũ nhận rowCount 0mộ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:

typescriptReady
// Cần cột: version Int @default(0) trên model Todoexport async function renameTodo(id: string, userId: string, version: number, title: string) {  const { count } = await prisma.todo.updateMany({    where: { id, userId, version },    data: { title, version: { increment: 1 } },  });  if (count === 0) throw new AppError(409, "Todo changed, reload and retry");}

Đã 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.

typescriptReady
export async function transfer(from: number, to: number, amount: number) {  await prisma.$transaction(async (tx) => {    const ids = [from, to].sort((a, b) => a - b);    await tx.$queryRaw`SELECT id FROM "Account" WHERE id IN (${ids[0]}, ${ids[1]}) ORDER BY id FOR UPDATE`;    const src = await tx.account.findUniqueOrThrow({ where: { id: from } });    if (src.balance < amount) throw new Error("INSUFFICIENT_FUNDS");    await tx.account.update({ where: { id: from }, data: { balance: { decrement: amount } } });    await tx.account.update({ where: { id: to }, data: { balance: { increment: amount } } });  });}

Đã 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.
typescriptReady
import { Prisma } from "../generated/prisma/client.js";// Với adapter-pg của Prisma 7, xung đột tuần tự hoá là DriverAdapterError có// cause.originalCode "40001", KHÔNG phải P2034 như engine cũ; kiểm cả hai.function isSerializationFailure(e: unknown): boolean {  if (e instanceof Prisma.PrismaClientKnownRequestError) return e.code === "P2034";  const cause = (e as { cause?: { originalCode?: string } } | null)?.cause;  return cause?.originalCode === "40001";}export async function serializable<T>(fn: (tx: Prisma.TransactionClient) => Promise<T>, max = 3): Promise<T> {  for (let attempt = 1; ; attempt++) {    try {      return await prisma.$transaction(fn, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });    } catch (e) {      if (!isSerializationFailure(e) || attempt >= max) throw e;    }  }}

Đã 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:

typescriptReady
const users = await prisma.user.findMany();          // 1 queryconst out = [];for (const u of users) {  const todos = await prisma.todo.findMany({ where: { userId: u.id } }); // N query!  out.push({ ...u, todos });}

Code trông rất "bình thường" với FE nhưng ẩn N round-trip.

textReady
N+1 (3 user)                         include / JOINapp --SELECT users------> DB         app --SELECT users + todos--> DBapp --SELECT todos u1---> DB         (2 round-trip, không phụ thuộc N)app --SELECT todos u2---> DBapp --SELECT todos u3---> DB         Số query: 1 + N  so với  1 (hoặc 2)

Đã 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.

typescriptReady
// 1) Prisma include (eager load) — engine gom thành ~2 query hiệu quảconst users = await prisma.user.findMany({ include: { todos: true } });// 2) JOIN thẳng bằng SQL — 1 query// SELECT u.*, t.* FROM "User" u LEFT JOIN "Todo" t ON t."userId" = u.id;// 3) Tự gom bằng IN rồi ghép lại — 2 query; đây chính là việc DataLoader (thường ở GraphQL)//    làm tự động: gom các id của cùng một tick thành một query IN(...)const list = await prisma.user.findMany();const todos = await prisma.todo.findMany({ where: { userId: { in: list.map((u) => u.id) } } });const byUser = new Map<string, typeof todos>();for (const t of todos) byUser.set(t.userId, [...(byUser.get(t.userId) ?? []), t]);const result = list.map((u) => ({ ...u, todos: byUser.get(u.id) ?? [] }));

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ụ.

textReady
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;-- Chưa index:--   Seq Scan on orders  (actual time=0.2..85.3 rows=12) -> quét cả triệu dòng-- Sau CREATE INDEX idx_orders_user ON orders(user_id):--   Index Scan using idx_orders_user  (actual time=0.03..0.09 rows=12) -> nhanh ~1000x

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íPrismaTypeORM
Type-safetyXuất sắc (client sinh từ schema, TS types tự động)Khá, dựa decorator, dễ lệch runtime
SchemaFile schema.prisma (khai báo, một nguồn)Decorator trên class entity
Migrationprisma migrate tự sinh từ diff schemaSinh/viết tay, dễ rối
PatternData Mapper (client tách khỏi model)Active Record hoặc Data Mapper
Hợp với TSRất hợp, DX hiện đạiCũ 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ụ.

typescriptReady
// Prisma (ORM) — type-safe, gọnconst u = await prisma.user.findUnique({ where: { email }, include: { orders: true } });// Raw khi cần SQL đặc thù (Prisma vẫn tham số hóa an toàn qua tagged template)const rows = await prisma.$queryRaw`  SELECT user_id, SUM(total) AS spent  FROM orders GROUP BY user_id HAVING SUM(total) > ${1000}`;

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 ($queryRaw tagged 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àoTrả về
P2002vi phạm unique (đăng ký trùng email, ghi trùng khoá)409 Conflict
P2025update/delete (không phải updateMany/deleteMany) trên bản ghi không tồn tại404 Not Found
typescriptReady
import { Prisma } from "../generated/prisma/client.js";export function mapPrismaError(err: unknown): AppError | null {  if (err instanceof Prisma.PrismaClientKnownRequestError) {    if (err.code === "P2002") return new AppError(409, "Already exists");    if (err.code === "P2025") return new AppError(404, "Not found");  }  return null; // lỗi khác: để error handler trả 500}

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ụ.

textReady
// Sửa schema.prisma: thêm cộtmodel User {  id    String  @id @default(uuid())  name  String  phone String?              // cột mới}
bashReady
npx prisma migrate dev --name add_user_phone   # sinh + áp migration ở devnpx prisma generate                             # Prisma 7: migrate dev KHÔNG tự generate clientnpx prisma migrate deploy                       # áp ở prod (trong CI/CD)
textReady
-- File migration sinh ra (đọc được, review được, commit được):ALTER TABLE "User" ADD COLUMN "phone" TEXT;

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):

textReady
ALTER TABLE "Todo" DROP COLUMN "title",ADD COLUMN     "name" TEXT NOT NULL;   -- mất toàn bộ dữ liệu cột cũ

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:

textReady
ALTER TABLE "Todo" RENAME COLUMN "title" TO "name";   -- giữ nguyên dữ liệu

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ụ.

typescriptReady
// prisma/seed.ts — dùng chung instance (có adapter) từ lib/prisma.tsimport { prisma } from '../src/lib/prisma.js';async function main() {  await prisma.user.upsert({                     // upsert => chạy lại không trùng    where: { email: 'dev@example.com' },    update: {},    create: { email: 'dev@example.com' },  });}main().finally(() => prisma.$disconnect());
bashReady
npx prisma db seed

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ụ.

typescriptReady
// Prisma 7: pool do driver `pg` quản lý, chỉnh ở adapter (không còn ?connection_limit= trong URL)const adapter = new PrismaPg({  connectionString: process.env.DATABASE_URL,  max: 10,                        // pool size = kết nối tối đa giữ mở  connectionTimeoutMillis: 20_000, // chờ tối đa bao lâu khi pool cạn trước khi báo lỗi});

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.

typescriptReady
// Một document đơn có thể EMBED dữ liệu con{  _id: ObjectId("..."),  name: "An",  addresses: [ { city: "HCM", zip: "70000" } ],   // nhúng mảng con  createdAt: ISODate("...")}

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:

typescriptReady
db.orders.aggregate([  { $match: { status: "paid" } },                         // ~ WHERE / filter  { $group: { _id: "$userId", total: { $sum: "$amount" } } }, // ~ GROUP BY / reduce  { $sort:  { total: -1 } },                              // ~ ORDER BY  { $limit: 10 },]);// $lookup = JOIN thủ công giữa 2 collection

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: INCR một key theo user+phút, quá ngưỡng thì chặn.
  • Queue nhẹ: LPUSH/BRPOP là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.

textReady
SET session:abc "{...}" EX 3600      # tự xóa sau 1 giờ# Đếm rate-limit: KHÔNG viết INCR rồi EXPIRE thành hai lệnh rời nhau;# cách nguyên tử nằm ở phần "Khoá nguyên tử, cấu trúc dữ liệu và eviction" bên dưới

Cache-aside pattern (phổ biến nhất).

typescriptReady
async function getUser(id: string) {  const cached = await redis.get(`user:${id}`);  if (cached) return JSON.parse(cached);            // cache HIT  const user = await prisma.user.findUnique({ where: { id } }); // MISS -> DB  await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 300); // ghi cache, TTL 5'  return user;}

Đọ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).

textReady
getUser(id)  |  +--> redis GET user:id --hit--------------------------> trả về          |         miss          |          +--> Postgres SELECT --> redis SET user:id EX 300 --> trả vềupdate: Postgres UPDATE --> redis DEL user:id (lần đọc sau miss, nạp mới)

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.
typescriptReady
await prisma.user.update({ where: { id }, data });await redis.del(`user:${id}`);   // ép reload từ DB lần sau

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ểuHợp vớiVí dụ lệnhKết quả
Stringcache, bộ đếm, khoáSET k v EX 60, INCR k
Hashmột đối tượng nhiều trường, sửa từng trườngHSET user:1 name An age 30, HGETALL user:1name An, age 30
Listhàng đợi đơn giảnRPUSH q j1 j2, LPOP qj1
Settập không trùngSADD tags x y x, SCARD tags2
Sorted setbảng xếp hạng, hẹn giờZADD lb 10 a 30 b 20 c, ZREVRANGE lb 0 1 WITHSCORESb 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:

typescriptReady
import { randomUUID } from "node:crypto";import { Redis } from "ioredis";const redis = new Redis(process.env.REDIS_URL!);export async function acquireLock(name: string, ttlSec: number): Promise<string | null> {  const token = randomUUID();  const ok = await redis.set(`lock:${name}`, token, "EX", ttlSec, "NX");  return ok === "OK" ? token : null;}const RELEASE = `if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end`;export async function releaseLock(name: string, token: string): Promise<boolean> {  return (await redis.eval(RELEASE, 1, `lock:${name}`, token)) === 1;}

Đã 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:

typescriptReady
export async function hit(userId: string, windowSec: number): Promise<number> {  const key = `rl:${userId}:${Math.floor(Date.now() / 1000 / windowSec)}`;  const res = await redis.multi().incr(key).expire(key, windowSec, "NX").exec();  return Number(res?.[0]?.[1]);}

Đã 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ệnh SET đầu thành công, phần còn lại nhận lỗi OOM command not allowed when used memory > 'maxmemory'. Lệnh đọc vẫn chạy.
  • allkeys-lru: cả 200 nghìn lệnh OK; Redis xoá khoảng 189,8 nghìn key (evicted_keys), còn khoảng 10,2 nghìn.
  • volatile-lru khi 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):

RDBAOF
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ậpvài phút cuốiappendfsync everysec (mặc định): tối đa khoảng 1 giây
Kích thước, khởi độnggọn, khởi động nhanhlớn hơn, khởi động chậm hơn
Hợp vớibackup, cache chịu mất vài phútdữ 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_tsquery chuyển chuỗi người dùng nhập thành truy vấn.
  • Index GIN trên tsvector làm việc tìm nhanh như index thường.
  • ts_rank chấm điểm mức liên quan để sắp xếp.

Ví dụ.

textReady
-- Cột sinh tự động (generated column): luôn đồng bộ, không thể quên cập nhậtALTER TABLE documents ADD COLUMN search_vector tsvector  GENERATED ALWAYS AS (    -- setweight: tiêu đề quan trọng hơn nội dung ('A' > 'B' khi xếp hạng)    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||    setweight(to_tsvector('english', coalesce(body,  '')), 'B')  ) STORED;CREATE INDEX idx_documents_search ON documents USING GIN (search_vector);
textReady
-- websearch_to_tsquery hiểu cú pháp quen thuộc: "cụm chính xác", -loại_trừ, ORSELECT id, title, ts_rank(search_vector, q) AS rankFROM documents, websearch_to_tsquery('english', $1) qWHERE search_vector @@ q  AND tenant_id = $2          -- lọc tenant LUÔN đi kèmORDER BY rank DESCLIMIT 20;
typescriptReady
// Prisma chưa hỗ trợ tsvector đầy đủ → dùng raw query, vẫn tham số hoá an toànconst rows = await prisma.$queryRaw<Row[]>`  SELECT id, title, ts_rank(search_vector, q) AS rank  FROM documents, websearch_to_tsquery('english', ${term}) q  WHERE search_vector @@ q AND tenant_id = ${tenantId}  ORDER BY rank DESC LIMIT 20`;

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 (GIN trên trigram) cho tìm gần đúng, chịu được lỗi gõ thiếu dấu:
textReady
CREATE EXTENSION pg_trgm;CREATE INDEX ON documents USING GIN (title gin_trgm_ops);SELECT * FROM documents WHERE title % 'nguyen van' ORDER BY similarity(title, 'nguyen van') DESC;

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ột NULL làm cả biểu thức nối thành NULL → tài liệu biến mất khỏi tìm kiếm trong im lặng.
  • Dùng to_tsquery thẳ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ùng plainto_tsquery/websearch_to_tsquery cho 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.

  1. 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).
  2. 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êm src/lib/prisma.ts và migrations.seed trong config như hộp đó.
  3. Thiết kế schema (schema.prisma) — quan hệ 1-n: một User có nhiều Todo.
    textReady
    model User {  id    String @id @default(uuid())   // UUID chuỗi, thống nhất với GĐ04/GĐ07  email String @unique  todos Todo[]}model Todo {  id     String  @id @default(uuid())  title  String  done   Boolean @default(false)  user   User    @relation(fields: [userId], references: [id])  userId String  @@index([userId])          // index FK cho JOIN/lọc theo user}
  4. Migration: npx prisma migrate dev --name init_todo, rồi npx prisma generate (Prisma 7 không tự generate) → commit thư mục prisma/migrations/.
  5. Seed: prisma/seed.ts tạo 1 user + vài todo, chạy lại không nhân bản (upsert cho user; todo kiểm tra tồn tại trước khi tạo). Chạy bằng npx prisma db seed.
  6. Thay in-memory array bằng Prisma trong các route CRUD (findMany, create, update, delete).
  7. Tạo N+1 rồi fix: endpoint GET /users-with-todos:
    • Viết bản N+1: findMany users → loop findMany todos từng user. Bật log:['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.
  8. 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; = 1 báo lỗi text = integer) → thấy Index Scan hoặc Bitmap Index Scan trên Todo_userId_idx, không phải Seq 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ồi ANALYZE "Todo" trước khi kết luận (đã thử: 20 nghìn dòng → Bitmap Index Scan).
  9. (Tùy chọn) Redis cache-aside cho GET /users/:id: cache 60s, del khi 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.

  1. 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ồi DATABASE_URL=postgresql://postgres:dev@localhost:5433/postgres trong .env (không commit; có .env.example).
  2. Cài Prisma và tạo prisma.config.ts, src/lib/prisma.ts đúng như hộp đầu file; thêm User, Todo vào schema.prisma (bước 3 của đề).
  3. npx prisma migrate dev --name init_todo, rồi npx prisma generate, rồi commit prisma/migrations/.
  4. Viết prisma/seed.ts, chạy npx prisma db seed hai lần.
  5. Thay Map ở todo.service.ts (GĐ04) bằng Prisma; route giữ nguyên.
  6. Thêm GET /users-with-todos bản N+1, đếm query, sửa bằng include.
  7. EXPLAIN ANALYZE trên bảng đủ lớn; tuỳ chọn thêm Redis.

Sơ đồ.

textReady
Express route -> todo.service -> prisma (PrismaClient + adapter-pg, pool max 10)                                      |                     |                                   Postgres 17        (tuỳ chọn) Redis cache                                   User 1--n Todo

Code tham chiếu.

typescriptReady
// prisma/seed.ts: chạy lại không nhân bản (id cố định cho todo mẫu)import { prisma } from "../src/lib/prisma.js";const SEED_TODOS = [  { id: "00000000-0000-4000-8000-000000000001", title: "hoc Prisma" },  { id: "00000000-0000-4000-8000-000000000002", title: "do N+1" },];async function main() {  const user = await prisma.user.upsert({    where: { email: "dev@example.com" },    update: {},    create: { email: "dev@example.com" },  });  for (const t of SEED_TODOS) {    await prisma.todo.upsert({      where: { id: t.id },      update: {},      create: { ...t, userId: user.id },    });  }}main().finally(() => prisma.$disconnect());
typescriptReady
// src/modules/todos/todo.service.ts: giữ nguyên hợp đồng của GĐ04 (404 cho todo của người khác)export const todoService = {  create: (userId: string, data: { title: string; done: boolean }) =>    prisma.todo.create({ data: { ...data, userId } }),  async getOwned(id: string, userId: string) {    const todo = await prisma.todo.findFirst({ where: { id, userId } }); // lọc userId ngay trong query    if (!todo) throw new AppError(404, "Not found");    return todo;  },  async update(id: string, userId: string, patch: { title?: string; done?: boolean }) {    const { count } = await prisma.todo.updateMany({ where: { id, userId }, data: patch });    if (count === 0) throw new AppError(404, "Not found");    return this.getOwned(id, userId);  },  async remove(id: string, userId: string) {    const { count } = await prisma.todo.deleteMany({ where: { id, userId } });    if (count === 0) throw new AppError(404, "Not found");  },  async list(userId: string, page: number, limit: number) {    const [data, total] = await Promise.all([      prisma.todo.findMany({ where: { userId }, skip: (page - 1) * limit, take: limit, orderBy: { id: "asc" } }),      prisma.todo.count({ where: { userId } }), // count cũng lọc userId    ]);    return { data, meta: { page, limit, total, totalPages: Math.ceil(total / limit) } };  },};

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.

typescriptReady
// GET /users-with-todos: bản N+1 và bản sửarouter.get("/users-with-todos/slow", async (_req, res) => {  const users = await prisma.user.findMany();                       // 1 query  const out = [];  for (const u of users) {    out.push({ ...u, todos: await prisma.todo.findMany({ where: { userId: u.id } }) }); // N query  }  res.json(out);});router.get("/users-with-todos", async (_req, res) => {  res.json(await prisma.user.findMany({ include: { todos: true } })); // 2 query (User, rồi Todo IN)});

Đ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 đó.

typescriptReady
// Redis cache-aside (tuỳ chọn), ioredis; Redis cho cache khác Redis dành cho queue (GĐ10)async function getUserCached(id: string) {  const key = `user:${id}`;  const hit = await redis.get(key);  if (hit) return JSON.parse(hit);  const user = await prisma.user.findUnique({ where: { id } });  if (user) await redis.set(key, JSON.stringify(user), "EX", 60);  return user;}export async function updateUser(id: string, data: { email: string }) {  const user = await prisma.user.update({ where: { id }, data });  await redis.del(`user:${id}`); // ghi DB xong mới xoá cache  return user;}

Kết quả mong đợi (suy ra, chưa chạy):

textReady
$ npx prisma migrate dev --name init_todo  # có thư mục prisma/migrations/<ts>_init_todo/migration.sql$ npx prisma migrate status                   # "Database schema is up to date!"$ npx prisma db seed                          # chạy lần 2: không lỗi$ psql "$DATABASE_URL" -c 'SELECT count(*) FROM "Todo"'  # cùng một số sau lần 1 và lần 2$ curl localhost:3000/users-with-todos/slow  # N+1: số dòng SELECT = 1 + số user$ curl localhost:3000/users-with-todos  # 2 dòng SELECT (không bật relationJoins)EXPLAIN ANALYZE SELECT * FROM "Todo" WHERE "userId" = '<uuid>';  # bảng vài chục nghìn dòng sau ANALYZE "Todo":  # Index Scan hoặc Bitmap Index Scan trên Todo_userId_idx

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_URL cấu hình, kết nối OK.

    Đáp án

    psql "$DATABASE_URL" -c 'select 1' ra 1, hoặc npx prisma migrate status kế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 đã được prisma.config.ts nạp qua dotenv/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.prisma mô tả quan hệ 1-n User–Todo, có @@index([userId]) và @unique email.

    Đáp án

    npx prisma validate ra valid. Sau migrate, psql -c '\d "Todo"' phải liệt kê index Todo_userId_idx và foreign key Todo_userId_fkey; \d "User" có User_email_key (unique). FK ở phía "n" (Todo.userId). Xem mục 2 và mục 4.

  • prisma migrate dev sinh migration, thư mục prisma/migrations/ commit vào git; không sửa DB bằng tay.

    Đáp án

    Có prisma/migrations/<timestamp>_init_todo/migration.sql trong git status; npx prisma migrate status báo Database schema is up to date. Sửa file migration đã áp dụng sẽ làm migrate báo checksum lệch: thay đổi tiếp bằng migration mới. Sai thường gặp: migrate reset trên DB không phải dev. Xem mục 9.

  • Seed script idempotent, prisma db seed chạy lại được không lỗi/không trùng.

    Đáp án

    Chạy npx prisma db seed hai 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ùng upsert vớ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/deleteMany có where: { id, userId }, count === 0 ra 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òng SELECT của endpoint chậm (1 + số user) rồi endpoint dùng include (2 SELECT khi không bật relationJoins): 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 ANALYZE cho truy vấn lọc theo userId xá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ấy Index Scan hoặc Bitmap Index Scan trên Todo_userId_idx. Seq Scan trê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ì del key; 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ó del khi 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; sau PATCH rồi TTL ra -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 UPDATE hoặc UPDATE ... SET x = x + n; nói được khi nào lỗi 40001 phả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êm FOR UPDATE vào SELECT (hoặc viết SET 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ần BEGIN ... COMMIT là đủ ở Read Committed. Xem mục 5.

  • Ánh xạ được P2002 sang 409 và P2025 sang 404, và giải thích vì sao updateMany với id không tồn tại không ném lỗi.

    Đáp án

    P2002 là vi phạm unique (đăng ký trùng email) nên 409; P2025 là update/delete không tìm thấy bản ghi nên 404. updateMany/deleteMany khô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ểm count === 0 rồi tự ném 404. Kiểm bằng cách gọi user.create hai lần cùng email và todo.update vớ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 IN với truy vấn con chứa NULL ra 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ọc rn ở truy vấn ngoài vì WHERE chạy trước window). x NOT IN (a, b, NULL) đánh giá thành NULL (không phải true) với mọi x không nằm trong a, b, nên không dòng nào được giữ: một NULL trong danh sách làm cả điều kiện vô hiệu. Dùng NOT EXISTS. Xem mục 3.

  • Đổi tên một cột bằng migration giữ nguyên dữ liệu (--create-only rồi sửa tay thành RENAME COLUMN), và chọn đúng onDelete cho quan hệ User–Todo và User–Invoice.

    Đáp án

    Sinh file migration mà chưa áp, thay DROP COLUMN + ADD COLUMN bằng ALTER 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ên onDelete: 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-policy cho 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ọi TTL key phải ra số dương, không bao giờ -1. Cache: allkeys-lru với maxmemory đặt rõ; queue (BullMQ) và khoá: noeviction, tách instance khỏi cache. Kiểm bằng CONFIG 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, đặt deleted_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âu INSERT/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.

    Hướng trả lời hiện tại

    (Chưa khẳng định.) Express, nối tiếp Dự án 1 (GĐ04); Prisma là singleton ở src/lib/prisma.ts. Sang NestJS (GĐ07) thì đưa client vào provider để DI quản lý vòng đời.

  • 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; max của pool nhân số instance phải nằm dưới max_connections của Postgres (mục 11). Nếu chuyển sang serverless, dùng pooler ngoài và max: 1 mỗ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.