jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Node.js Production Engineering 04 — Thiết kế Data Layer

Thiết kế data layer đúng khi có concurrency: query shape, index, pool, transaction, pagination, repository, MongoDB và cache contract cho API.

Hai request cùng đọc tồn kho là 1, cả hai cùng trừ và đều trả thành công. Test tuần tự vẫn xanh; production vừa bán một món hàng hai lần. Data layer không chỉ là nơi đặt câu query: nó sở hữu consistency boundary, connection budget, query shape và cách lỗi dữ liệu trở thành API contract.

Bài này dùng Node.js 24 LTS, PostgreSQL, MongoDB và Redis để xây bức tranh toàn cảnh. Sau khi đọc, bạn có thể:

  • chọn driver, query builder hoặc ORM theo constraint thay vì theo xu hướng;
  • thiết kế index từ query shape và xác nhận bằng execution plan;
  • tính connection budget cho nhiều replica và nhận diện pool exhaustion;
  • dùng transaction, isolation và locking để bảo vệ invariant đồng thời;
  • tránh N+1, cursor lỗi, cache stale và abstraction repository bị rò semantics;
  • map conflict/not-found/unavailable thành contract ổn định cho frontend.

Kiến thức cần có: request pipeline và error contract ở Phần 3. Đây là bài bề rộng; Phần 11 đi sâu riêng PostgreSQL.


Chạy database local bằng Docker

Dùng container giúp môi trường local tái tạo được và dọn dễ. Password dưới đây chỉ dành cho máy phát triển; không commit hoặc tái sử dụng trong môi trường dùng chung:

docker run --name pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres:17
docker run --name mongo -p 27017:27017 -d mongo:8
docker run --name redis -p 6379:6379 -d redis:7

Mental model: nguồn sự thật, transaction và dữ liệu dẫn xuất

HTTP / queue / CLI


use case ── xác lập invariant + transaction boundary


repository / query service


connection pool ── connection budget dùng chung


database (source of truth)

       └──── cache / search index / event projection (derived data)

Database là source of truth cho invariant mà nó sở hữu. Cache và read model là dữ liệu dẫn xuất: có thể rebuild và có staleness contract rõ. Repository không được âm thầm mở transaction riêng cho từng method nếu một use case cần atomicity xuyên nhiều write; transaction boundary thuộc use case/unit of work.


Ba tầng: driver, query builder, ORM

Mỗi tầng đổi một phần control lấy productivity:

  • Driver — protocol client + parameterized query; kiểm soát query rõ nhưng tự sở hữu mapping và migration workflow.
  • Query builder — tạo SQL bằng API có type; vẫn nhìn thấy query shape và ít mapping thủ công hơn.
  • ORM/ODM — ánh xạ row/document sang model, quan hệ và lifecycle; nhanh cho CRUD nhưng có thể che query đắt hoặc semantics riêng của database.

Dù dùng ORM, bạn phải biết driver pg thô — mọi tầng đứng trên nó, và cách duy nhất an toàn để viết SQL thô là parameterized:

import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });

// $1/$2 là placeholder cho VALUE; driver gửi value tách khỏi SQL text.
const { rows } = await pool.query(
  'SELECT id, email FROM users WHERE email = $1 AND active = $2',
  [userInput, true]
);

Placeholder bảo vệ value, không thay được table/column/order direction. Identifier động phải map qua allowlist do server sở hữu; không ghép thẳng query param vào SQL dù đang dùng driver.


SQL với một ORM

Sequelize

import { Sequelize, DataTypes } from 'sequelize';

const sequelize = new Sequelize(process.env.DATABASE_URL!, {
  dialect: 'postgres',
  pool: { max: 10, min: 2, idle: 10_000 }, // connection pooling (see 4.4)
  logging: process.env.NODE_ENV !== 'production' ? console.log : false,
});

const User = sequelize.define(
  'User',
  {
    id: {
      type: DataTypes.UUID,
      defaultValue: DataTypes.UUIDV4,
      primaryKey: true,
    },
    email: { type: DataTypes.STRING, unique: true, allowNull: false },
    passwordHash: { type: DataTypes.STRING, allowNull: false },
  },
  { timestamps: true, paranoid: true }
); // paranoid = soft delete (deletedAt)

const Post = sequelize.define('Post', {
  title: DataTypes.STRING,
  content: DataTypes.TEXT,
});

User.hasMany(Post, { foreignKey: 'userId', as: 'posts' });
Post.belongsTo(User, { foreignKey: 'userId', as: 'author' });

TypeORM (dựa decorator, TypeScript-first)

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  OneToMany,
  Index,
  CreateDateColumn,
} from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn('uuid') id!: string;

  @Index({ unique: true })
  @Column()
  email!: string;

  @Column() passwordHash!: string;

  @OneToMany(() => Post, (post) => post.user) posts!: Post[];

  @CreateDateColumn() createdAt!: Date;
}

const userRepo = AppDataSource.getRepository(User);
const user = await userRepo.findOne({
  where: { id: userId },
  relations: { posts: true },
});

Không có ORM mặc định cho mọi team. Hãy so sánh migration control, type model, escape hatch sang SQL, observability, transaction API và mức độ phù hợp với query thật. Phần 12 dùng Prisma làm một deep dive, không phải tuyên bố mọi project phải chọn nó.


Index — đòn bẩy lớn nhất

Index là cấu trúc truy cập phụ; B-tree là mặc định phổ biến cho equality/range/order. Thiết kế index từ query shape thường xuyên và quan trọng, không phải thêm riêng lẻ vào mọi cột từng xuất hiện trong WHERE.

CREATE INDEX idx_posts_user_id ON posts (user_id);          -- speeds up the join/filter
CREATE INDEX idx_posts_user_created ON posts (user_id, created_at DESC); -- composite
CREATE UNIQUE INDEX idx_users_email ON users (email);
CREATE INDEX idx_active_users ON users (email) WHERE active; -- partial: smaller, faster

Các trade-off cần kiểm tra:

  • thứ tự cột quan trọng — index (user_id, created_at) giúp lọc theo user_id, nhưng không giúp lọc chỉ theo created_at (quy tắc prefix trái nhất).
  • index tốn write, disk và memory; index thừa cũng làm planner/vacuum thêm việc.
  • xác minh bằng EXPLAIN (ANALYZE, BUFFERS) trên dữ liệu có phân bố gần production; câu lệnh thực thi query thật nên tránh trên write nguy hiểm.

Connection pooling — budget dùng chung

Mở kết nối DB tốn kém. Mở một cái mỗi request đè bẹp database khi tải. Một pool giữ tập kết nối tái dùng cố định mà request mượn rồi trả.

Req A ─┐
Req B ─┤──▶ ┌──────────────────┐
Req C ─┤    │ Pool (10 conns)  │──▶ Database
Req D ─┘    │ borrow ↔ return  │
            └──────────────────┘

max không phải “càng lớn càng tốt”. Capacity budget tối thiểu phải thỏa số instance tối đa × pool.max + migration/admin/worker reserve ≤ giới hạn kết nối DB. Autoscaling từ 4 lên 20 replica có thể nhân pool năm lần mà không đổi một dòng application code.

Đặt timeout khi chờ pool, đo số connection active/idle/waiting, và luôn release client trong finally. Pool exhaustionidle in transaction có triệu chứng giống API chậm nhưng cách sửa khác nhau. Môi trường serverless hoặc số replica lớn thường cần connection proxy/pooler như PgBouncer; mode transaction pooling cũng có giới hạn với session state và prepared statement nên phải kiểm thử driver/ORM tương ứng.


Transaction & mức cô lập

Một số write phải thành công cùng nhau hoặc không cái nào (chuyển tiền).

// Sequelize managed transaction — auto commit on success, rollback on throw
await sequelize.transaction(async (t) => {
  const from = await Account.findByPk(fromId, {
    transaction: t,
    lock: t.LOCK.UPDATE,
  });
  if (!from || from.get('balance') < amount)
    throw new AppError('Insufficient funds', 400);
  await Account.decrement('balance', {
    by: amount,
    where: { id: fromId },
    transaction: t,
  });
  await Account.increment('balance', {
    by: amount,
    where: { id: toId },
    transaction: t,
  });
}); // throwing anywhere rolls everything back

Mental model: transaction tạo một ranh giới atomicity trong cùng database. Nó không tự làm email, cache hoặc API bên thứ ba rollback; side effect vượt ranh giới đó cần outbox/idempotency và được đào sâu ở Phần 16.

Mức cô lập và anomaly

Mức cô lập điều khiển các transaction đồng thời thấy gì của nhau:

Mức PostgreSQLSnapshot nhìn thấyĐiều vẫn phải xử lý
Read Committed (mặc định)mỗi statement thấy dữ liệu đã commit trước statement đónon-repeatable read, lost update nếu read-modify-write sai
Repeatable Readtransaction dùng snapshot ổn địnhserialization anomaly có thể làm transaction bị abort; cần retry
Serializablehành vi tương đương một thứ tự chạy tuần tự hợp lệtransaction có thể bị serialization failure; retry toàn transaction

Bug đồng thời kinh điển là read-modify-write. Có thể sửa bằng một atomic UPDATE ... WHERE, khóa bi quan (SELECT ... FOR UPDATE), hoặc optimistic concurrency (cột version phải khớp). Deadlock và serialization failure là kết quả hợp lệ của concurrency control; retry toàn transaction với giới hạn và jitter, không retry riêng statement cuối.


Vấn đề N+1 và batching theo request

N+1 là query amplification: một query lấy danh sách kéo theo một query cho mỗi phần tử.

// ❌ N+1: 1 query for users + N queries for each user's posts = N+1 round-trips
const users = await User.findAll();
for (const user of users) {
  user.posts = await Post.findAll({ where: { userId: user.id } });
}

// ✅ Eager loading: ONE query with a join
const users = await User.findAll({ include: [{ model: Post, as: 'posts' }] });

Khi không eager load được (vd resolver GraphQL gọi từng item), DataLoader gộp các lookup riêng lẻ trong một tick thành một query WHERE id IN (...), và cache trong request:

import DataLoader from 'dataloader';

function createUserLoader() {
  return new DataLoader(async (ids: readonly string[]) => {
    const users = await User.findAll({ where: { id: ids as string[] } });
    const byId = new Map(users.map((u) => [u.id, u]));
    return ids.map((id) => byId.get(id) ?? null); // phải cùng length/thứ tự với ids
  });
}

// Tạo loader trong request context, không dùng singleton xuyên user/request.
const userLoader = createUserLoader();
const [a, b] = await Promise.all([userLoader.load('1'), userLoader.load('2')]);

Cách bắt N+1: đếm query theo request/trace và kiểm query shape lặp theo số item. DataLoader cache mặc định nằm trong lifetime của loader; singleton có thể giữ dữ liệu stale hoặc làm rò dữ liệu theo authorization context, nên thường tạo một instance cho mỗi request.


Mẫu repository — tách khỏi ORM

Không rải lời gọi ORM vào controller. Repository hữu ích khi nó diễn đạt operation theo domain, cô lập transaction/query policy hoặc tạo test seam có giá trị:

export interface UserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  create(data: NewUser): Promise<User>;
}

// Adapter hiện tại dùng Sequelize; interface không để lộ model Sequelize.
export class SequelizeUserRepository implements UserRepository {
  findById(id: string) {
    return UserModel.findByPk(id);
  }
  findByEmail(email: string) {
    return UserModel.findOne({ where: { email } });
  }
  create(data: NewUser) {
    return UserModel.create(data);
  }
}

Repository không bảo đảm đổi PostgreSQL sang MongoDB “chỉ thay adapter”: transaction, consistency, pagination và query capability khác nhau có thể làm đổi use case. Đừng tạo generic repository findAll/create/update chỉ để bọc lại ORM; interface nên phản ánh nhu cầu domain như reserveInventory hoặc findActiveUserByEmail.


Phân trang đúng — keyset thay vì offset

OFFSET ... LIMIT khiến DB quét rồi bỏ rất nhiều hàng — càng vào sâu càng chậm. Phân trang keyset (cursor) dùng khóa sắp xếp cuối cùng đã thấy, nhanh ở mọi độ sâu:

import { Op } from 'sequelize';

// page 1: no cursor; later pages pass the last item's (created_at, id)
const rows = await Post.findAll({
  where: cursor
    ? {
        [Op.or]: [
          { createdAt: { [Op.lt]: cursor.createdAt } },
          { createdAt: cursor.createdAt, id: { [Op.lt]: cursor.id } },
        ],
      }
    : {},
  order: [
    ['createdAt', 'DESC'],
    ['id', 'DESC'],
  ],
  limit: 20,
});

Cursor phải chứa đủ tuple sort (createdAt, id), được encode/validate phía server và query phải dùng cùng ordering. Chỉ so createdAt sẽ bỏ sót hoặc lặp row khi nhiều bản ghi có cùng timestamp. Keyset ổn định cho next/previous navigation; offset vẫn hữu ích khi UI cần nhảy tới page số cụ thể và dataset nhỏ.


NoSQL (MongoDB) với Mongoose

Mongoose là ODM chuẩn cho MongoDB — validation schema trên database vốn không schema:

import mongoose, { Schema, model } from 'mongoose';

interface IUser {
  email: string;
  passwordHash: string;
  profile?: { firstName?: string };
}

const userSchema = new Schema<IUser>(
  {
    email: {
      type: String,
      unique: true,
      required: true,
      lowercase: true,
      trim: true,
    },
    passwordHash: { type: String, required: true, select: false },
    profile: { firstName: String },
  },
  { timestamps: true }
);

export const User = model<IUser>('User', userSchema);
await mongoose.connect(process.env.MONGO_URL!);

Hash password ở authentication use case trước khi truyền passwordHash vào repository; persistence hook dễ bị bỏ qua bởi updateOne/bulk operation và che một security side effect quan trọng. unique: true yêu cầu MongoDB tạo unique index, nhưng duplicate vẫn phải được bắt từ database error vì validation trước write luôn có race.

Quyết định mô hình cốt lõi là nhúng hay tham chiếu: nhúng dữ liệu được đọc/cập nhật cùng nhau và có kích thước hữu hạn; tham chiếu khi lifecycle độc lập, được chia sẻ hoặc collection con tăng không giới hạn. Single-document write là atomic. Multi-document transaction tồn tại trên replica set/sharded cluster nhưng có chi phí; nó không thay thế schema design tốt.


SQL injection — và cách ORM bảo vệ bạn

// ✅ Good — ORM/driver parameterizes; the value can never become SQL
await User.findOne({ where: { email: userInput } });
await pool.query('SELECT * FROM users WHERE email = $1', [userInput]);

// ❌ Bad — never build queries by string concatenation with user input
// await pool.query(`SELECT * FROM users WHERE email = '${userInput}'`);

Invariant: không nối input người dùng vào SQL text. Với identifier hoặc sort direction không parameterize được, map từ enum/allowlist nội bộ sang fragment cố định.


Tầng cache Redis

Cho dữ liệu nóng, đọc nhiều, cache trong Redis (cache-aside; đầy đủ ở Phase 8):

import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);

interface UserDTO {
  id: string;
  email: string;
  updatedAt: string;
}

async function getUser(id: string): Promise<UserDTO | null> {
  const cached = await redis.get(`user:${id}`);
  if (cached) return JSON.parse(cached) as UserDTO;

  const model = await User.findByPk(id);
  if (!model) return null;
  const user: UserDTO = {
    id: model.id,
    email: model.email,
    updatedAt: model.updatedAt.toISOString(),
  };
  await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 300);
  return user;
}

async function updateUser(
  id: string,
  data: Partial<NewUser>
): Promise<UserDTO | null> {
  await User.update(data, { where: { id } });
  await redis.del(`user:${id}`);
  // Đọc trực tiếp DB nếu response sau write cần read-your-writes.
  const model = await User.findByPk(id);
  return model
    ? {
        id: model.id,
        email: model.email,
        updatedAt: model.updatedAt.toISOString(),
      }
    : null;
}

TTL giới hạn thời gian stale nhưng không giải consistency. Pattern “write DB rồi delete cache” vẫn có race: một reader có thể đọc dữ liệu cũ trước commit và ghi lại cache sau lần delete. Với dữ liệu yêu cầu correctness mạnh, cân nhắc versioned key, write-through có transaction/outbox phù hợp, hoặc không cache. Với key nóng, coalesce request/lock ngắn để tránh cache stampede; luôn theo dõi hit rate, latency và eviction thay vì coi cache là mặc định.


SQL vs NoSQL — chọn có cân nhắc

Khía cạnhPostgreSQLMongoDB
Mô hìnhquan hệ, constraint và joindocument, embedding và reference
Atomicity tự nhiêntransaction trên nhiều row/tablemột document; multi-document transaction khi cần
Integrityforeign key, unique/check constraintschema validation + unique/index; integrity liên collection thường ở app/transaction
Query evolutionSQL và planner mạnh cho quan hệ/ad-hocaggregation mạnh, nhưng schema nên bám access pattern
Trade-offmigration và normalization cần kỷ luậtduplication/staleness và document growth cần quản lý

Khuyến nghị mặc định có điều kiện: PostgreSQL thường phù hợp khi domain có quan hệ, invariant và báo cáo chưa biết hết từ đầu. Chọn MongoDB khi aggregate document và access pattern rõ ràng đem lại lợi ích cụ thể. “Schema linh hoạt” không có nghĩa là không cần schema; cả hai lựa chọn đều cần migration và validation strategy.


Failure modes và contract với API

Failure modeTriệu chứngBoundary chịu trách nhiệmCách kiểm chứng
Pool exhaustionAPI chậm dù query riêng nhanhdeployment + pool configmetric waiting count, acquire timeout, load test nhiều replica
Lost update/oversellhai response thành công nhưng state saiuse case + database constraint/lockconcurrent integration test bằng barrier
N+1latency/query count tăng theo số itemquery service/resolverquery counter theo request và dataset tăng dần
Cursor thiếu tie-breakeritem lặp hoặc biến mất giữa pageAPI pagination contractnhiều row cùng timestamp, insert xen giữa hai lần đọc
Cache staleGET sau update trả dữ liệu cũcache policyrace test reader/writer và TTL metric
Migration khóa bảngdeploy timeout hàng loạtmigration/release processchạy trên bản sao dữ liệu gần production, quan sát lock

Map lỗi database thành HTTP

  • Unique constraint/domain conflict → 409 Conflict với code ổn định như EMAIL_ALREADY_EXISTS.
  • Optimistic version mismatch → 409 hoặc 412 Precondition Failed nếu dùng If-Match/ETag.
  • Validation representation → 422; database constraint vẫn là lưới an toàn cuối, không thay input validation.
  • Pool acquire timeout/database unavailable → 503 Service Unavailable; không trả raw driver error.
  • Query deadline vượt quá → 504 chỉ khi service này đóng vai gateway tới upstream; với DB nội bộ thường trả 503/500 theo contract và ghi cause chính xác.

Frontend cần biết pagination là snapshot ổn định hay eventual; cursor có hết hạn không; mutation có hỗ trợ retry/idempotency không; và optimistic UI rollback theo error code nào. Không để UI suy luận consistency từ việc “request trả 200”.

Migration như một thay đổi phân tán

App cũ và app mới có thể chạy đồng thời trong rolling deploy. Dùng expand → migrate/backfill → contract: thêm schema tương thích ngược, deploy code đọc/ghi được cả hai, backfill có checkpoint, chuyển traffic/đo, rồi mới xóa cột cũ ở release sau. Migration destructive trong cùng deploy làm rollback application trở nên vô nghĩa.


Dự án thực hành

  1. Thiết kế & cài schema SQL: mô hình User, Post, Comment với quan hệ và index đúng, trên Postgres Docker.

  2. Tầng repository: giấu mọi lời gọi ORM sau interface repository và dùng từ service.

  3. Migration: sinh và áp migration thêm cột slug có index; xác nhận version trong git.

  4. Transaction an toàn đồng thời: cài chuyển tiền với khóa, tái hiện oversell khi không khóa và chứng minh khóa sửa được.

  5. Diệt N+1: tái hiện N+1, sửa bằng eager loading, rồi cài bản DataLoader.

  6. Chiến lược caching: thêm cache-aside, đo độ trễ, invalidate khi update; thêm TTL và lập luận về stampede.

Bài tập thêm: đổi offset sang keyset tuple và benchmark; demo SQL injection trong môi trường local cô lập rồi sửa bằng parameter; mô hình orders nhúng line item trong MongoDB và giải thích giới hạn kích thước/lifecycle.

Tiêu chí hoàn thành: test chạy ít nhất 20 transfer đồng thời và chứng minh tổng balance được bảo toàn; query list không vượt query budget cố định; cursor không mất item có cùng timestamp; pool acquire có timeout; migration có đường rollback/forward-fix; cache test ghi rõ mức staleness chấp nhận được.


Checklist trước khi ship

  • Mọi cột lọc/join/sort có index; composite đúng thứ tự.
  • Cỡ pool hợp lý; không rò kết nối.
  • Write nhiều bước có transaction; read-modify-write có khóa.
  • Không N+1 ở hot path.
  • HTTP không gọi ORM trực tiếp; transaction/query boundary được đặt theo use case.
  • Input parameterized; cache có TTL và invalidate khi write.
  • Constraint quan trọng tồn tại ở database, không chỉ trong validation code.
  • Migration tương thích rolling deploy và đã thử trên dataset gần production.

Nếu chỉ nhớ năm điều

  • Data model bắt đầu từ invariant và access pattern, không bắt đầu từ tên ORM.
  • Index phải phục vụ query shape đã đo và luôn có write/storage cost.
  • Pool là budget nhân với số replica; queue trong pool cũng là latency.
  • Transaction chỉ atomic trong boundary của database và vẫn có thể cần retry.
  • Cache, cursor và error mapping đều là một phần API contract với frontend.

Phần tiếp theo

Data layer giờ có boundary để bảo vệ concurrency, capacity và contract. Ở Phần 5, ta đặt authentication và authorization lên trên boundary đó mà không biến token thành nguồn sự thật duy nhất.

Đọc tiếp: Authentication và API Security.

Tài liệu chính thức