Skip to content

HARRY-5 — GĐ4 — Database: SQL/Postgres, ORM, Migration, Redis (Dự án 2)

GĐ4 — 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.


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

sql
-- 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).

sql
CREATE TABLE students (id SERIAL PRIMARY KEY, name TEXT);
CREATE TABLE courses  (id SERIAL 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).

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


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

sql
-- INNER: chỉ user CÓ order mới hiện
SELECT u.name, o.total
FROM users u
INNER JOIN orders o ON o.user_id = u.id;

-- LEFT: MỌI user hiện; user chưa mua → o.total = NULL
SELECT u.name, o.total
FROM users u
LEFT 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_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP 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'.


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 được cho query lọc a, hoặc a AND b, nhưng KHÔNG dùng được 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 vô dụng).

Ví dụ.

sql
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); -- composite
CREATE UNIQUE INDEX idx_users_email ON users(email);       -- vừa tăng tốc vừa ép unique
prisma
model Order {
  userId Int
  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.


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

sql
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- trừ A
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- cộng B
COMMIT;
-- 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".
ts
// 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 } } });
});

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


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:

ts
const users = await prisma.user.findMany();          // 1 query
for (const u of users) {
  u.orders = await prisma.order.findMany({ where: { userId: u.id } }); // N query!
}

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

Cách fix.

ts
// 1) Prisma include (eager load) — engine gom thành ~2 query hiệu quả
const users = await prisma.user.findMany({ include: { orders: true } });

// 2) JOIN thẳng bằng SQL — 1 query
// SELECT u.*, o.* FROM users u LEFT JOIN orders o ON o.user_id = u.id;

// 3) DataLoader (thường ở GraphQL) — gom nhiều id trong 1 tick thành 1 query IN(...)
const orders = await orderLoader.loadMany(users.map(u => u.id));

Pitfall. ORM giấu N+1 rất kín. Truy cập lazy relation trong vòng lặp (user.orders mà không include) là dính. Cách phát hiện chắc chắn: bật query logging (new PrismaClient({ log: ['query'] })) 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ậmindex 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ụ.

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

ts
// Prisma (ORM) — type-safe, gọn
const 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.


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

prisma
// Sửa schema.prisma: thêm cột
model User { id Int @id @default(autoincrement()); name String; phone String? }
bash
npx prisma migrate dev --name add_user_phone   # sinh + áp migration ở dev
npx prisma migrate deploy                       # áp ở prod (trong CI/CD)
sql
-- 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).

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 trong package.json, chạy khi cần. Nên idempotent — chạy nhiều lần không nhân bản dữ liệu (dùng upsert).

Ví dụ.

ts
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
  await prisma.role.upsert({                     // upsert => chạy lại không trùng
    where: { name: 'admin' },
    update: {},
    create: { name: 'admin' },
  });
}
main().finally(() => prisma.$disconnect());
bash
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ụ.

ts
// Prisma quản lý pool nội bộ; chỉnh qua connection string
// postgresql://user:pass@host:5432/db?connection_limit=10&pool_timeout=20
pool size = kết nối tối đa giữ mở
pool_timeout = 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 connection_limit=1 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.

js
// 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 transaction đa-thực-thể, báo cáo JOIN phức tạp → Postgres thắng.

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:

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

SET session:abc "{...}" EX 3600      # tự xóa sau 1 giờ
INCR ratelimit:user42:1690000000     # đếm request
EXPIRE ratelimit:user42:1690000000 60

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

ts
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).

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


14. Full-text search với Postgres (tsvector)

Định nghĩa. Full-text search (FTS) là tìm kiếm theo từ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Đ20 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ụ.

sql
-- Cột sinh tự động (generated column): luôn đồng bộ, không thể quên cập nhật
ALTER 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);
sql
-- websearch_to_tsquery hiểu cú pháp quen thuộc: "cụm chính xác", -loại_trừ, OR
SELECT id, title, ts_rank(search_vector, q) AS rank
FROM documents, websearch_to_tsquery('english', $1) q
WHERE search_vector @@ q
  AND tenant_id = $2          -- lọc tenant LUÔN đi kèm
ORDER BY rank DESC
LIMIT 20;
ts
// Prisma chưa hỗ trợ tsvector đầy đủ → dùng raw query, vẫn tham số hoá an toàn
const 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:
sql
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): docker run -e POSTGRES_PASSWORD=dev -p 5432:5432 -d postgres.
  2. Cài Prisma: npm i -D prisma && npm i @prisma/client && npx prisma init.
  3. Thiết kế schema (schema.prisma) — quan hệ 1-n: một User có nhiều Todo.
    prisma
    model User { id Int @id @default(autoincrement()); email String @unique; todos Todo[] }
    model Todo {
      id     Int    @id @default(autoincrement())
      title  String
      done   Boolean @default(false)
      user   User   @relation(fields: [userId], references: [id])
      userId Int
      @@index([userId])          // index FK cho JOIN/lọc theo user
    }
  4. Migration: npx prisma migrate dev --name init_todo → commit thư mục prisma/migrations/.
  5. Seed: prisma/seed.ts tạo 1 user + vài todo bằng upsert (idempotent).
  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"=1; → thấy Index Scan chứ không Seq 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).

Done khi

  • [ ] Postgres chạy, DATABASE_URL cấu hình, kết nối OK.
  • [ ] schema.prisma mô tả quan hệ 1-n User–Todo, có @@index([userId])@unique email.
  • [ ] prisma migrate dev sinh migration, thư mục prisma/migrations/ commit vào git; không sửa DB bằng tay.
  • [ ] Seed script idempotent, prisma db seed chạy lại được không lỗi/không trùng.
  • [ ] Mọi route CRUD dùng Prisma thay array in-memory; bọc transaction cho thao tác đa-bảng nếu có.
  • [ ] Endpoint N+1 đã fix bằng include/JOIN; đo được số query giảm rõ (vd 1+N → 2) qua query log.
  • [ ] EXPLAIN ANALYZE cho truy vấn lọc theo userId xác nhận dùng Index Scan.
  • [ ] 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.
  • [ ] (Nếu làm Redis) cache-aside có TTL và có del khi ghi; không coi Redis là source of truth.

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.
  • Redis là bắt buộc hay optional trong scope Dự án 2?

Học bằng cách build. Chứng minh, đừng tin.