โหมดมืด
บทที่ 4 — Database (TypeORM + Prisma)
⚠️ บทนี้ทดสอบกับ TypeORM 0.3.x / Prisma 6.x ณ มิ.ย. 2026 — Prisma 6 เป็น major version ปัจจุบัน เริ่มใช้ "rust-free query engine" (query engine ที่ไม่พึ่ง Rust binary แยกต่างหากอีกต่อไป) เป็น default มากขึ้น และ generator command บางตัวเปลี่ยนชื่อจาก v5 ถ้าโปรเจกต์เก่ายังอยู่ v5 ให้ตรวจ Prisma upgrade guide ก่อน upgrade
ส่วน TypeORM 0.3.x ตอนนี้อยู่ในโหมด maintenance (ออก release ช้า) — ฝั่ง Drizzle ORM กำลังมาแรงในสาย TS-native + performance ถ้าเริ่มโปรเจกต์ใหม่อยากได้ speed สูงสุดลองพิจารณาดู
NestJS ไม่มี ORM (Object-Relational Mapping — ตัวแปลงระหว่าง object ในโค้ดกับตารางในฐานข้อมูล ให้เราเขียนโค้ดแทนการเขียน SQL เอง) ในตัว — รู้แค่วิธี "ใส่ ORM ของ ecosystem (ระบบนิเวศ/กลุ่ม library + เครื่องมือรอบ ๆ) Node เข้าใน DI (Dependency Injection — กลไกฉีด dependency เข้า class ของ Nest, ทบทวนบทที่ 1)" บทนี้สอนสองตัวที่นิยมที่สุด: TypeORM และ Prisma พร้อมเปรียบเทียบ
1. เลือก ORM ตัวไหน — Decision Matrix
💡 มือใหม่ข้ามตารางนี้ได้ในรอบแรก — ถ้ายังไม่เคยเห็นโค้ด TypeORM/Prisma จริงมาก่อน แนะนำข้ามไปลองโค้ดใน "2. TypeORM — Setup" ก่อน แล้วค่อยย้อนกลับมาอ่านตารางเปรียบเทียบนี้ทีหลัง จะเข้าใจง่ายกว่าเห็นตัวอย่างจริงแล้ว
| แง่มุม | TypeORM | Prisma | Drizzle | MikroORM |
|---|---|---|---|---|
| Schema definition | TS class + decorator | DSL (schema.prisma) | TS code | TS class + decorator |
| Migration | CLI สร้างจาก entity diff | CLI สร้างจาก schema diff | drizzle-kit | CLI |
| Type safety | กลาง | สูงมาก (generate client) | สูง (TS native) | สูง |
| Active Record + Repository | ทั้งสอง | Repository only | Query builder | Identity Map (Unit of Work) |
| Performance | กลาง | กลาง-สูง | สูงสุด | กลาง |
| สำหรับ relations ซับซ้อน | ดี | ดี | ต้องเขียน join เอง | ดีมาก (Unit of Work) |
| Community | ใหญ่ที่สุด | ใหญ่ โตเร็ว | กำลังโต | กลาง |
ศัพท์ในตาราง:
- DX (Developer Experience) = ประสบการณ์ของนักพัฒนา — เครื่องมือใช้ง่าย/error อ่านรู้เรื่อง/autocomplete ดีแค่ไหน
- DSL (Domain-Specific Language) = ภาษาเฉพาะงาน — Prisma มี
schema.prismaเป็นไฟล์ของตัวเอง (ไม่ใช่ TS)- Active Record = pattern ที่ entity object มี method
.save()/.delete()ในตัว — เรียกจาก instance ได้เลย- Repository = pattern ที่แยก "ตัวจัดการ DB" (repository) ออกจาก entity — เรียก
repo.save(entity)แทน- Identity Map = ORM จำว่าโหลด entity ตัวไหนมาแล้ว → ไม่ดึงซ้ำ
- Unit of Work = รูปแบบที่ ORM จดทุกการเปลี่ยนแปลงไว้ก่อน แล้วค่อยบันทึกลง DB ทีเดียวตอน
flush/commit(แทนที่จะยิงทันทีทุกบรรทัด)
คำแนะนำ:
- โปรเจกต์ใหม่ + ทีมไม่มาก → Prisma (DX ดีสุด, error message ชัด)
- ของเดิมเป็น TypeORM แล้ว → อยู่กับ TypeORM
- ต้องการ performance สูงสุด + เขียน SQL ใกล้ ๆ → Drizzle
- ใช้ Unit of Work / มาจากสาย JPA/Hibernate (JPA = Java Persistence API, Hibernate = ORM ยอดนิยมฝั่ง Java) → MikroORM
บทนี้จะลงลึกที่ TypeORM และ Prisma เพราะเป็นสองตัวที่เจอบ่อยที่สุด
2. TypeORM — Setup
bash
npm i @nestjs/typeorm typeorm pg # postgres driverConnection ใน root
typescript
// app.module.ts
import { TypeOrmModule } from "@nestjs/typeorm";
@Module({
imports: [
TypeOrmModule.forRootAsync({
useFactory: (cfg: ConfigService) => ({
type: "postgres",
host: cfg.get("DB_HOST"),
port: cfg.get<number>("DB_PORT"),
username: cfg.get("DB_USER"),
password: cfg.get("DB_PASS"),
database: cfg.get("DB_NAME"),
autoLoadEntities: true,
// synchronize: true → DROP/CREATE schema ตาม entity ใน dev เท่านั้น
synchronize: false,
logging: ["error", "warn", "schema"],
}),
inject: [ConfigService],
}),
UsersModule,
],
})
export class AppModule {}⚠️ Pitfall ที่ทำให้ data หาย: เปิด
synchronize: trueใน production — TypeORM จะ DROP column ที่ entity ไม่มี เปิดเฉพาะตอนทดลอง dev เท่านั้น ทุกอย่างที่จริงจังต้องใช้ migration
Entity
typescript
// users/entities/user.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
@Entity("users") // ชื่อ table
export class User {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({ length: 100 })
name: string;
@Column({ unique: true })
email: string;
@Column({ select: false }) // ไม่ select กลับ default (กัน leak password)
password: string;
@CreateDateColumn() // Nest/TypeORM ใส่วันเวลาให้อัตโนมัติตอน insert แถวใหม่ ไม่ต้องเขียนเอง
createdAt: Date;
@UpdateDateColumn() // อัปเดตวันเวลาให้อัตโนมัติทุกครั้งที่แถวนี้ถูกแก้ไข
updatedAt: Date;
}Module + Repository
typescript
// users/users.module.ts
@Module({
imports: [TypeOrmModule.forFeature([User])], // register entity ให้ scope module นี้
providers: [UsersService],
controllers: [UsersController],
exports: [UsersService],
})
export class UsersModule {}
// users/users.service.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { hash } from "bcrypt"; // npm i bcrypt @types/bcrypt — async API ที่ไม่บล็อก event loop
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User) private readonly repo: Repository<User>,
) {}
findAll() { return this.repo.find(); }
findOne(id: string) { return this.repo.findOneBy({ id }); }
async create(dto: CreateUserDto) {
// ตัวอย่างนี้ใช้ bcrypt เพื่อความเรียบง่าย — production แนะนำ argon2 (ดูบท 5: Authentication)
// (import { hash } from "bcrypt" อยู่บนสุดของไฟล์แล้ว ตามที่เห็นด้านบน)
// ⚠️ ลำดับ key สำคัญ: ต้องวาง `password: await hash(...)` ไว้ "หลัง" ...dto เสมอ
// เพื่อให้ค่า hash แล้วทับ password ดิบจาก dto — ถ้าสลับลำดับ หรือ dto มี field อื่นชื่อชนกันทีหลัง จะรั่วค่าดิบแบบเงียบ ๆ
const u = this.repo.create({ ...dto, password: await hash(dto.password, 12) });
return this.repo.save(u);
}
update(id: string, dto: UpdateUserDto) {
// หมายเหตุ: repo.update() ข้าม optimistic-lock check ของ @VersionColumn แบบเงียบ ๆ (ดู §4.2)
return this.repo.update(id, dto);
}
remove(id: string) { return this.repo.delete(id); }
}Relations
typescript
@Entity("posts")
export class Post {
@PrimaryGeneratedColumn("uuid") id: string;
@Column() title: string;
@ManyToOne(() => User, (u) => u.posts, { onDelete: "CASCADE" })
@JoinColumn({ name: "author_id" })
author: User;
}
@Entity("users")
export class User {
// ...
@OneToMany(() => Post, (p) => p.author)
posts: Post[];
}Query relation:
typescript
// ระบุชัดเจนว่าจะโหลด posts มาด้วย (เทียบกับ Prisma คือ `include: { posts: true }`)
this.repo.find({ relations: { posts: true } });
// หรือเขียนแบบ array string ก็ได้
this.repo.find({ relations: ["posts"] });
// หรือ QueryBuilder ที่ flexible กว่า
this.repo.createQueryBuilder("u")
.leftJoinAndSelect("u.posts", "p")
.where("u.id = :id", { id })
.getOne();⚠️ Pitfall (จุดพลาดที่เจอบ่อย):
eager: true(โหลดข้อมูลที่เชื่อมโยงมาด้วยทุกครั้งอัตโนมัติ) ใน relation = N+1 ทันที (N+1 = ดึงรายการ N ตัวแล้ววนยิง query ย่อยอีก N ครั้ง รวม 1+N query — หนัก DB โดยไม่จำเป็น, §6 อธิบายเต็ม) ทุก find จะ join (เชื่อมตาราง) ตลอด → ปิดeagerไว้แล้วระบุrelations: ['posts'](หรือ{ posts: true }) ตรงจุดที่ใช้จริงเท่านั้น — Prisma ก็เป็นแบบเดียวกัน ต้องใส่include: { posts: true }ชัด ๆ ที่ query⚠️ ระวัง
cascade: trueบน relation ด้วย — ทำให้repo.save(user)ลบ/บันทึก posts ทับโดยอัตโนมัติ; ผสมกับonDelete: 'CASCADE'จะลบ posts ลูกตามไปเมื่อ user ถูกลบ ตั้งใจเมื่อต้องการเท่านั้น
Migrations
💡 TypeORM CLI 0.3.x ต้อง register
ts-nodeก่อน (เช่น"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js"ใน package.json) ไม่งั้น CLI โหลด.tsของ data-source / entity ไม่ได้
bash
# สร้าง migration จาก diff (ของ entity vs DB จริง)
npm run typeorm migration:generate -- ./src/migrations/CreateUsers -d ./data-source.ts
# run
npm run typeorm migration:run -- -d ./data-source.ts
# revert
npm run typeorm migration:revert -- -d ./data-source.tsdata-source.ts:
typescript
import { DataSource } from "typeorm";
import { User } from "./src/users/entities/user.entity";
import { Post } from "./src/posts/entities/post.entity";
export default new DataSource({
type: "postgres",
url: process.env.DATABASE_URL,
// 👍 แนะนำ: import class ตรง ๆ (type-safe + boot เร็วกว่า glob)
entities: [User, Post],
migrations: ["src/migrations/*.ts", "dist/migrations/*.js"],
});⚠️ Pitfall ที่กัด junior บ่อย: ถ้าจะใช้ glob แทน — ต้องครอบทั้ง
.ts(dev) และ.js(หลัง build →dist/) เช่นentities: ['src/**/*.entity.ts', 'dist/**/*.entity.js']; ใส่แต่.tsอย่างเดียว → dev ทำงาน แต่ production (รันnode dist/main.js) หา entity ไม่เจอ → TypeORM throw error ดังกล่องด้านล่าง🇹🇭
No metadata for "User" was found= "หา metadata ของ entityUserไม่เจอ" — TypeORM ไม่ได้โหลด classUserเข้า DataSource (ไม่อยู่ในentities, path glob ไม่ match, หรือ build แล้วลืม.js); ทางแก้ที่ปลอดภัยที่สุดคือ import class ตรง ๆ อย่างในตัวอย่างข้างบน — TS compiler จะฟ้องตั้งแต่ build ถ้าลืม
Transactions
typescript
import { DataSource } from "typeorm";
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
@Injectable()
export class TransferService {
constructor(private readonly ds: DataSource) {}
async transfer(fromId: string, toId: string, amount: number) {
// ⚠️ ใช้ SERIALIZABLE + pessimistic_write lock — กัน race condition (สถานการณ์ที่ 2 transaction
// แย่งกันแก้แถวเดียวกันพร้อมกัน → ผลลัพธ์เพี้ยน) ใน financial transaction
// 📖 รายละเอียดของ SERIALIZABLE / pessimistic_write อยู่ใน §4 "Transaction Isolation + Concurrency Control"
// มือใหม่ข้ามรายละเอียดตรงนี้ไปก่อนได้ — ตอนนี้แค่จำว่า "ล็อกเพื่อกัน 2 คนโอนพร้อมกัน" ก็พอ แล้วค่อยกลับมาอ่าน §4 ทีหลัง
return this.ds.transaction("SERIALIZABLE", async (mgr) => {
const from = await mgr.findOne(Account, {
where: { id: fromId },
lock: { mode: "pessimistic_write" }, // SELECT ... FOR UPDATE
});
const to = await mgr.findOne(Account, {
where: { id: toId },
lock: { mode: "pessimistic_write" },
});
if (!from || !to) throw new NotFoundException();
if (from.balance < amount) throw new BadRequestException("insufficient");
from.balance -= amount;
to.balance += amount;
await mgr.save([from, to]);
});
}
}DataSource.transaction รัน callback ใน transaction — error = rollback อัตโนมัติ
⚠️ ตัวอย่างนี้แสดงเวอร์ชัน "ถูกต้องสำหรับ production" ถ้าตัดทั้ง
"SERIALIZABLE"และlock: pessimistic_writeออก โค้ดจะยังคอมไพล์และรันได้ปกติ — แต่ผลลัพธ์จะผิดในงานจริงเหตุผล: ถ้า 2 คำสั่ง transfer วิ่งพร้อมกันบนบัญชีเดียวกัน ทั้งคู่จะอ่านยอดเดิมพร้อมกัน (เพราะ Read Committed คือ isolation level default ของ Postgres) แล้วต่างคนต่างเขียนทับ ผลคือเกิด lost update (การแก้ไขของอีกฝ่ายหายไปเงียบ ๆ) → ยอดเงินอาจติดลบได้โดยไม่มี error เตือน ดูหัวข้อ Transaction Isolation + Concurrency Control ด้านล่างสำหรับเหตุผลเต็มและทางเลือก optimistic locking
💡 หมายเหตุ trade-off: ใส่ทั้ง
SERIALIZABLE+pessimistic_writeคือ "belt-and-suspenders" (กันสองชั้น) — เลือกอย่างใดอย่างหนึ่งก็พอ:
SERIALIZABLEอย่างเดียว + retry on serialization failure (เหมาะถ้า conflict น้อย)READ COMMITTED+pessimistic_writelock (เหมาะถ้า hot row contention สูง, ไม่อยาก retry) ใส่ทั้งคู่เพิ่ม overhead และโอกาส deadlock — บทนี้ใส่ทั้งคู่เพื่อสอน 2 concept พร้อมกัน
3. Prisma — Setup
bash
npm i -D prisma # CLI (dev)
npm i @prisma/client # client runtime
npx prisma init # สร้าง prisma/schema.prisma + .env (Prisma 6.x)Schema
prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
name String
email String @unique
password String
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(uuid())
title String
body String
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
}Generate + Migrate
bash
npx prisma migrate dev --name init # สร้าง migration + apply + regen client
npx prisma generate # regen หลังแก้ schemaModule + Service
Prisma ไม่มี official @nestjs/prisma — มี 2 ทางเลือก:
nestjs-prisma(community package) — pre-built module, ไม่ต้องเขียน wrapper เอง เหมาะกับโปรเจกต์ที่อยาก get-started เร็ว- เขียน wrapper เอง (วิธีที่บทนี้สอน) — control เต็ม + ลด dependency บุคคลที่สาม + เข้าใจ lifecycle ของ Prisma client ดีกว่า
บทนี้เลือกวิธีที่ 2 เพื่อให้เห็นกลไก connect/disconnect และ DI registration ชัดเจน:
typescript
// prisma/prisma.service.ts
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
}
// prisma/prisma.module.ts
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}ใช้งาน
typescript
import { Injectable } from "@nestjs/common";
import { hashSync } from "bcrypt";
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
findAll() {
return this.prisma.user.findMany({
orderBy: { createdAt: "desc" },
});
}
findOne(id: string) {
return this.prisma.user.findUnique({
where: { id },
include: { posts: true },
});
}
create(dto: CreateUserDto) {
// ต้อง import sync version: `import { hashSync } from "bcrypt";`
// ⚠️ hashSync บล็อก event loop ทั้งช่วงที่ hash ทำงาน (cost factor 12 ใช้เวลาพอควร)
// ตัวอย่างนี้ใช้ sync เพื่อให้สั้น — production ควรใช้ async `await hash(...)` แบบตัวอย่าง TypeORM ด้านบน กับ argon2 ดูบทที่ 5
return this.prisma.user.create({
data: { ...dto, password: hashSync(dto.password, 12) },
});
}
update(id: string, dto: UpdateUserDto) {
return this.prisma.user.update({ where: { id }, data: dto });
}
remove(id: string) {
return this.prisma.user.delete({ where: { id } });
}
}Type safety สุดยอด — autocomplete ทุก field, ทุก relation Prisma รู้หมด
Transactions ใน Prisma
Interactive transaction (เหมือน TypeORM):
typescript
import { Prisma } from "@prisma/client"; // namespace `Prisma` มี enum / type ที่ generate มา
await this.prisma.$transaction(
async (tx) => {
await tx.account.update({ where: { id: fromId }, data: { balance: { decrement: amount } } });
await tx.account.update({ where: { id: toId }, data: { balance: { increment: amount } } });
},
{
// กัน lost update — ดูหัวข้อ "Transaction Isolation + Concurrency Control"
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
timeout: 5000, // ยกเลิกถ้า lock ค้างเกิน 5 วินาที
},
);🚀 โซนขั้นสูง — ข้ามได้ (สำหรับมือใหม่)
⚠️ ถ้าใช้ PgBouncer ใน transaction pooling mode ดู §5.1 ก่อน สรุปเป็นข้อ ๆ:
- Prisma interactive transaction (callback
$transaction) ต้องพึ่ง "session-level state" — พูดง่าย ๆ คือค่าที่ผูกติดกับการเชื่อมต่อฐานข้อมูลครั้งเดียวตลอดทั้งช่วง ไม่ใช่แค่ query เดียว ๆ- pooler บางโหมด "ไม่รองรับ" การผูกค่าแบบนี้ → connection พังกลางคันได้
- ถ้าใช้ PgBouncer transaction mode (ปล่อย connection คืน pool ทุกครั้งหลัง commit) ต้องเพิ่ม
?pgbouncer=trueต่อท้าย DATABASE_URL และอ่านข้อจำกัดของ Prisma ให้ครบก่อนใช้จริง🇹🇭 PgBouncer = connection pooler ภายนอกที่นั่งกลางระหว่าง app กับ Postgres ช่วยรีไซเคิล connection ให้ app เปิดได้เยอะกว่า
max_connectionsของ DB · Prisma Accelerate = pooler+cache ของ Prisma เอง เหมาะกับ serverless แทนการตั้ง PgBouncer ของตัวเอง
💡 Prisma ไม่มี explicit pessimistic lock — ต้องใช้
$queryRawกับSELECT ... FOR UPDATEตรง ๆ หรือพึ่ง Serializable isolation + retry on conflict (P2034) ดูตัวอย่างใน section ถัดไป
Batch transaction (atomic, แต่ไม่มี logic ระหว่างกัน):
typescript
// ⚠️ ตัวอย่างนี้ใช้ prisma.log เพื่อสื่อไอเดีย "หลาย operation ใน transaction เดียว" เท่านั้น
// ถ้าจะรันจริงต้องเพิ่ม model Log ใน schema.prisma ก่อน (ในบทนี้มีแค่ User กับ Post)
await this.prisma.$transaction([
this.prisma.user.create({ data: dto }),
this.prisma.post.create({ data: { title: "welcome", body: "...", authorId: dto.id } }),
]);Soft Delete + Middleware
Prisma มี $extends API:
typescript
const prisma = new PrismaClient().$extends({
query: {
user: {
async findMany({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
},
},
});⚠️ Caveat:
$extendsquery hook ไม่ทำงานใน$transactioncallback โดย default (Prisma รัน raw client ภายใน) — soft delete อาจถูก bypass ในระหว่าง transaction → ต้องตรวจwhereเองหรือใช้ DB trigger เป็นชั้นป้องกันสุดท้าย
Raw query
typescript
// ✅ ปลอดภัย — Prisma แปลง ${email} เป็น parameter placeholder ($1) อัตโนมัติ
this.prisma.$queryRaw<User[]>`SELECT * FROM users WHERE email = ${email}`;
// execute (UPDATE/DELETE)
this.prisma.$executeRaw`UPDATE users SET active = false WHERE id = ${id}`;⚠️ อันตราย — SQL injection: ถ้าใช้
$queryRawUnsafe/$executeRawUnsafeหรือquery()ของ TypeORM แล้วต่อ string ตรง ๆ → injection เต็ม ๆtypescript// ❌ ห้ามทำ this.prisma.$queryRawUnsafe(`SELECT * FROM users WHERE email = '${email}'`); dataSource.query(`SELECT * FROM users WHERE id = ${id}`); // TypeORM ก็เช่นกัน // ✅ ถูก — TypeORM ส่ง parameter array dataSource.query("SELECT * FROM users WHERE id = $1", [id]); // หรือ QueryBuilder ที่ binding param repo.createQueryBuilder("u").where("u.id = :id").setParameters({ id }).getOne();
4. Transaction Isolation + Concurrency Control
🚀 โซนขั้นสูง — ข้ามได้
ส่วนนี้เกี่ยวกับ DB internal ขั้นสูง (isolation level, lost update, phantom read, deadlock) — มือใหม่ที่เพิ่งเริ่ม CRUD ไม่จำเป็นต้องเข้าใจหมดในรอบแรก เข้าใจแค่ "ใช้
prisma.$transaction(async ...)แล้ว rollback อัตโนมัติ" ก็พอ ค่อยกลับมาอ่านตอนทำงาน financial / ระบบนับสต็อก🇹🇭 ศัพท์ที่จะเจอ:
- Isolation level = ระดับความเข้มงวดของ transaction ในการ "ไม่ให้คนอื่นเห็นการเปลี่ยนแปลงที่ยังไม่ commit"
- Read Committed = อ่านได้เฉพาะข้อมูลที่ commit แล้ว (default Postgres) — เร็วแต่ไม่กัน lost update
- Repeatable Read = ภายใน transaction เดียว อ่านซ้ำเห็นเหมือนเดิม
- Serializable = เข้มสุด เสมือนรันทีละ transaction ไม่พร้อมกัน — ถ้าชนกัน DB จะ abort แล้วให้ retry
- Lost update = เคสที่ 2 transaction อ่านยอดเดิม แล้วต่างคนต่างเขียนทับ → การแก้ของคนแรกหายไป
- Phantom read = อ่านชุดผลลัพธ์ครั้งที่ 2 ในทรานแซกชันเดียวเจอแถวใหม่โผล่มา
ปัญหาที่ default isolation แก้ไม่ได้ — ตัวอย่าง transfer ใน §2 ถ้าใช้ default Read Committed:
text
เวลา Tx A (โอน 100) Tx B (โอน 80)
---- ------------------ ------------------
T1 SELECT balance FROM acc (เห็น 100)
T2 SELECT balance FROM acc (เห็น 100)
T3 UPDATE balance = 0 (100 - 100)
T4 UPDATE balance = 20 (100 - 80)
COMMIT COMMIT
ผล: ยอดเหลือ 20 แทนที่ควรเหลือ -80 (รวมโอน 180 จาก 100) → lost updateทางแก้มี 2 แนว:
4.1 Pessimistic Lock (แนะนำสำหรับ hot row เช่นบัญชีเงิน)
ใช้ SELECT ... FOR UPDATE — ตัวที่ล็อกก่อน ตัวที่มาทีหลังต้องรอ → serialize เอง
TypeORM:
typescript
const from = await mgr.findOne(Account, {
where: { id: fromId },
lock: { mode: "pessimistic_write" },
});Prisma (raw):
typescript
const [from] = await tx.$queryRaw<Account[]>`
SELECT * FROM accounts WHERE id = ${fromId} FOR UPDATE
`;✅ เข้าใจง่าย ผลคาดเดาได้ · ❌ deadlock ถ้าล็อกหลายแถวสลับลำดับ → ล็อกแถวเรียงตาม id เสมอ
4.2 Optimistic Concurrency (เหมาะกับงานที่ conflict น้อย)
ใส่ version column — update เช็คว่า version ยังเดิม → ถ้าใครชน รีทรายงานเอง
typescript
import { Entity, PrimaryColumn, Column, VersionColumn } from "typeorm";
import { OptimisticLockVersionMismatchError } from "typeorm";
@Entity()
export class Account {
@PrimaryColumn() id: string;
@Column() balance: number;
@VersionColumn() version: number; // TypeORM auto-increment ตอน save
}
// ใช้ผ่าน save() ของ entity ที่ load มาก่อน เท่านั้น
async function transfer(id: string, delta: number) {
const repo = ds.getRepository(Account);
const acc = await repo.findOneByOrFail({ id }); // โหลด → version ติดมาด้วย
acc.balance += delta;
try {
await repo.save(acc); // TypeORM ใส่ WHERE version = ? ให้อัตโนมัติ
} catch (e) {
if (e instanceof OptimisticLockVersionMismatchError) {
// ใครก็แก้แทรกไป → reload + retry หรือคืน 409 Conflict
}
throw e;
}
}⚠️ ข้อจำกัดสำคัญของ
@VersionColumn:
- ทำงานเฉพาะกับ
repository.save(entity)ที่ entity load มาแล้ว เท่านั้นrepository.update(id, dto),QueryBuilder.update(), bulk update — bypass version check เงียบ ๆ ไม่ throw error- ตัวอย่างใน §2 ที่ใช้
this.repo.update(id, dto)จะไม่มี optimistic lock แม้จะใส่@VersionColumnไว้- ต้อง wrap
save()ใน try/catch จับOptimisticLockVersionMismatchErrorเสมอ- ห้ามผสม
save()ที่มี version กับupdate()partial บน entity เดียวกัน เพราะupdate()จะไม่ bump version → save ครั้งหน้าเห็น version ตรง แต่ข้อมูลเปลี่ยนแล้ว
- ตัวอย่างให้เห็นภาพ: entity เริ่มที่
version = 1→ มีคนเรียกrepo.update(id, { balance: 500 })(ข้อมูลเปลี่ยนจริง แต่ version ยังเป็น 1 เพราะupdate()ไม่ bump ให้) → คนถัดไปsave()entity ที่ตัวเองโหลดมาตอน version ยังเป็น 1 → ระบบเห็นว่า version ตรง (ยัง 1) เลย "ยอมให้ save" ทั้งที่ balance จริงถูกคนแรกเปลี่ยนไปแล้ว → ทับข้อมูลกันแบบไม่มี error เตือน
หรือใน Prisma + isolation Serializable — DB จะ throw P2034 (serialization failure) → catch + retry:
typescript
async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
for (let i = 0; i < max; i++) {
try { return await fn(); }
catch (e: any) {
if (e.code === "P2034" && i < max - 1) {
// ⚠️ exponential backoff + jitter — กัน "thundering herd" ที่ทุก client retry พร้อมกันแล้วชนซ้ำ
const base = 50 * 2 ** i; // 50ms, 100ms, 200ms, ...
const jitter = Math.random() * base; // สุ่มเพิ่ม 0–base ms
await new Promise((r) => setTimeout(r, base + jitter));
continue; // serialization conflict → retry
}
throw e;
}
}
throw new Error("unreachable");
}4.3 Isolation Level — สรุปสั้น
| Level | Postgres default? | กัน lost update? | กัน phantom read? | overhead |
|---|---|---|---|---|
| Read Committed | ✅ | ❌ | ❌ | ต่ำ |
| Repeatable Read | ✅ (Postgres) | บางส่วน | กลาง | |
| Serializable | ✅ | ✅ | สูง (serialization failure ต้อง retry) |
แนวทางเลือกแบบสรุป:
- งาน financial / inventory ที่ห้ามผิด → Serializable + retry หรือ Read Committed + pessimistic lock
- งานทั่วไป (CRUD ไม่มี contention) → Read Committed พอ
- รายงาน analytic ที่ต้องการ snapshot consistent → Repeatable Read
💡 เทียบกับ MySQL: default ของ MySQL InnoDB คือ Repeatable Read (ไม่ใช่ Read Committed เหมือน Postgres) — และมี gap lock เพิ่มเติม pitfall ไม่เหมือนกัน ตรวจ DB ที่ใช้ก่อนเลือก isolation
5. Connection Pooling — สำคัญในทุก ORM
Database มี connection limit (Postgres default = 100) — ถ้า app spawn connection ไม่จำกัด → DB ตาย
TypeORM:
typescript
TypeOrmModule.forRoot({
// ...
extra: { max: 20 }, // ส่งต่อ pg pool option
})Prisma:
bash
# ตั้งใน DATABASE_URL
DATABASE_URL="postgresql://user:pass@localhost/db?connection_limit=20"Pool sizing — เริ่มจากค่ากลาง แล้ว tune จากของจริง:
จุดเริ่มที่แนะนำ: ~10 connection ต่อ app instance สำหรับ I/O-bound API ทั่วไป แล้ว monitor + ปรับ — เป็น mental anchor ไม่ใช่กฎตายตัว
🚀 โซนขั้นสูง — ข้ามได้: ย่อหน้าถัดไปพูดถึง container CPU limit,
num_physical_cpus, สูตร Prisma — รายละเอียดสำหรับ dev ที่ tune production บน Kubernetes/Docker; มือใหม่จำแค่ "เริ่มที่ ~10 connection ต่อ instance แล้ว monitor" ก็พอ ข้ามช่วงนี้ไปได้เลย💡 Prisma default ใช้สูตร
num_physical_cpus * 2 + 1(Prisma docs) ซึ่งบน container ที่ CPU limit เพี้ยน หรือ workload เป็น I/O bound แท้ ๆ ก็อาจไม่เหมาะ → ตัวเลข 10 ด้านบนเป็นจุดเริ่มแบบ I/O-wait mental model แล้วค่อยขยับจาก metric จริง (ไม่ใช่ตัวเลข official)⚠️ ตัวเลขในย่อหน้านี้เป็นค่ากลางที่ใช้ได้ในกรณีทั่วไป (rule of thumb ของผู้เขียน) ไม่ใช่ benchmark ที่มี source — ใช้เป็นจุดเริ่มเท่านั้น
🇹🇭
num_physical_cpus= จำนวน CPU core จริงของเครื่อง (ไม่นับ hyperthread) · container CPU limit = ค่าจำกัด CPU ที่ตั้งใน Docker/K8s ซึ่งมักไม่เท่ากับ core ของ host → สูตรอัตโนมัติเลยเพี้ยน
sql
-- ดูว่าใช้ไปกี่ตัว + มี idle in transaction (ค้างนาน) ไหม
SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = 'mydb'
GROUP BY state;- ถ้า
idleเยอะ → pool ใหญ่เกิน → ลด - ถ้าเห็น
wait_event = ClientReadแล้ว latency พุ่ง + app เห็น "pool exhausted" → ใหญ่ไม่พอ → เพิ่ม แต่อย่าลืมว่ารวมทุก replica ห้ามเกินmax_connections - ถ้า
idle in transactionเยอะ → app มี transaction leak ไม่ใช่ pool ปัญหา
🇹🇭
wait_event = ClientRead= backend ของ Postgres กำลังรอ "อ่านคำสั่งจาก client" — ถ้าตัวเลขนี้สูงพร้อมกับ "pool exhausted" แปลว่า connection ถูกจองค้างไว้ทั้งที่ไม่มีคำสั่งใหม่ → ขยาย pool หรือไปแก้ leak ·max_connections= ค่ากำหนดสูงสุดของ Postgres ทั้งคลัสเตอร์ (default 100)
💡 สูตรเก่า
(CPU cores * 2) + spindle_countมาจาก PostgreSQL wiki ยุค HDD (spindle = ก้านอ่านดิสก์ในฮาร์ดดิสก์แบบจานหมุน สมัยนั้นดิสก์อ่านพร้อมกันได้จำกัดตามจำนวนหัวอ่าน ยิ่งมี spindle เยอะยิ่งรองรับ concurrent I/O ได้มาก) — SSD/NVMe ไม่มี spindle แบบนี้แล้ว, container ที่ CPU limit ไม่เท่า host cores ก็ทำสูตรเพี้ยน → ใช้แค่เป็น mental anchor ไม่ใช่กฎ
🚀 ถ้าคุณ deploy บน serverless (ข้ามได้ถ้ารันบน VM/container ปกติ)
⚠️ Pitfall: Prisma + serverless (Vercel / AWS Lambda) → ทุก cold start (= function ตื่นจากศูนย์เมื่อนานๆ ถูกเรียก) สร้าง connection ใหม่ → DB เต็มเร็ว
- ทางเลือก 1: ใช้ Prisma Accelerate (managed pooler + edge cache ของ Prisma เอง) — ตั้งค่าใน
DATABASE_URLที่ Prisma ออกให้ ง่ายสุดสำหรับ serverless- ทางเลือก 2: ตั้ง PgBouncer ใน transaction mode + ใส่
?pgbouncer=trueในของ DATABASE_URL- ทางเลือก 3: ใช้ direct connection แต่จำกัด
connection_limit=1-3ต่อ instance + พึ่ง Postgresmax_connectionsสูง (ไม่แนะนำเมื่อ scale)
5.1 statement_timeout — กัน query ค้างฆ่า DB
ตั้ง timeout ระดับ session ทุกครั้งที่ต่อ DB — query ที่ค้างเกินจะถูก kill อัตโนมัติ ไม่กิน connection ตลอดกาล:
TypeORM:
typescript
TypeOrmModule.forRoot({
// ...
extra: {
max: 10,
statement_timeout: 5000, // 5s ต่อ statement
idle_in_transaction_session_timeout: 10000, // 10s ใน idle tx
},
})Prisma: ตั้งใน DB-level (Postgres):
sql
ALTER ROLE app_user SET statement_timeout = '5s';5.2 Read Replica Routing (write/read split)
อ่านมากกว่าเขียน 10:1 ขึ้นไป → split read query ไปอีก instance:
TypeORM:
typescript
TypeOrmModule.forRoot({
type: "postgres",
// 📌 หมายเหตุคำศัพท์: TypeORM 0.3.x ยังใช้ key `master`/`slaves` ใน config (เป็น public API)
// แม้คำเหล่านี้จะ deprecated ในภาษาสากลแล้ว (industry นิยม primary/replica) — รอ TypeORM rename ในรุ่นถัดไป
replication: {
master: { host: "primary.db", username: "...", password: "...", database: "..." },
slaves: [
{ host: "replica1.db", username: "...", password: "...", database: "..." },
{ host: "replica2.db", username: "...", password: "...", database: "..." },
],
},
})
// findOne/find → slave อัตโนมัติ; save/update → master
// บังคับ master: queryRunner.connect().then(qr => qr.query(..., [], qr.master))Prisma: ใช้ extension @prisma/extension-read-replicas:
typescript
import { PrismaClient } from "@prisma/client";
import { readReplicas } from "@prisma/extension-read-replicas";
const prisma = new PrismaClient().$extends(
readReplicas({ url: process.env.DATABASE_REPLICA_URL! }),
);⚠️ Replication lag (ความหน่วงของการ replicate) — replica ตามหลัง master อยู่ราว 10–200 ms เพราะการคัดลอกข้อมูลข้ามเครื่องไม่ใช่ทันที → ถ้า app เขียนลง master เสร็จแล้วอ่านจาก replica ทันที อาจ "ไม่เห็น" data ที่เพิ่งบันทึก (write-then-read race)
- ทางแก้: บังคับ query นั้น ๆ ให้ใช้ master/primary
- หรือใช้ Postgres
synchronous_commit = remote_apply(รอจน replica apply เสร็จก่อนยืนยัน commit — ช้าลงแต่ consistent)
6. N+1 Query Problem
โค้ดที่ดูปกติ:
typescript
const users = await prisma.user.findMany();
for (const u of users) {
const posts = await prisma.post.findMany({ where: { authorId: u.id } });
console.log(u.name, posts.length);
}
// → 1 + N queries (1 user query + N post queries)ทางแก้ — eager load:
typescript
const users = await prisma.user.findMany({ include: { posts: true } });
// → 2 queries (Prisma optimize ให้)TypeORM:
typescript
this.userRepo.find({ relations: { posts: true } });หรือใช้ DataLoader สำหรับ GraphQL (บทที่ 7)
7. Indexing + Query Performance
ออกแบบ index จาก query pattern จริง:
prisma
model User {
id String @id
email String @unique
status String
createdAt DateTime @default(now())
@@index([status, createdAt(sort: Desc)]) // composite index — sort: Desc เสถียรตั้งแต่ Prisma 5 แล้ว ไม่ต้องเปิด previewFeatures
@@index([email]) // ซ้ำกับ @unique แต่ระบุ explicit ก็ได้
}TypeORM:
typescript
import { Entity, Column, Index } from "typeorm";
@Entity()
@Index(["status", "createdAt"])
export class User {
@Index({ unique: true })
@Column()
email: string;
}ดู query plan:
sql
EXPLAIN ANALYZE SELECT * FROM users WHERE status = 'active' ORDER BY created_at DESC LIMIT 20;💡 ดูบท Database ของหนังสือนี้ (database/04) สำหรับ indexing เชิงลึก
8. Repository Pattern (เลือกใช้)
Service ที่ inject PrismaService/Repository<User> ตรง ๆ มี coupling สูง — บางคนแยก:
typescript
// users/users.repository.ts
export interface UsersRepository {
findById(id: string): Promise<User | null>;
create(data: CreateUserDto): Promise<User>;
}
export const USERS_REPO = Symbol("USERS_REPO");
@Injectable()
export class PrismaUsersRepository implements UsersRepository {
constructor(private readonly prisma: PrismaService) {}
findById(id: string) { return this.prisma.user.findUnique({ where: { id } }); }
create(data: CreateUserDto) { return this.prisma.user.create({ data }); }
}
// module
{ provide: USERS_REPO, useClass: PrismaUsersRepository }
// service
constructor(@Inject(USERS_REPO) private repo: UsersRepository) {}ประโยชน์: เปลี่ยน ORM ได้ไม่กระทบ service / mock ง่ายในเทส ข้อเสีย: ขั้นตอนเพิ่ม
ใช้เมื่อไหร่: ทีมใหญ่ที่อยากแยก domain logic (ตรรกะธุรกิจหลัก) ออกจาก persistence (ส่วนเก็บข้อมูลลง DB) ชัดเจน — แนวคิดสาย DDD (Domain-Driven Design = วิธีออกแบบที่ยึดเรื่องราวธุรกิจจริงเป็นศูนย์กลาง) ข้ามได้เมื่อไหร่: โปรเจกต์เล็ก, prototype (ตัวต้นแบบที่จะเขียนทิ้ง), CRUD ตรง ๆ ไม่มี logic ซับซ้อน
9. Migration Workflow ใน Production
Prisma:
bash
# dev: สร้าง migration จาก schema diff
npx prisma migrate dev --name add_user_status
# CI/production: apply migration ที่ commit แล้ว ไม่ generate ใหม่
npx prisma migrate deployTypeORM:
bash
# dev
npm run typeorm migration:generate -- ./src/migrations/AddUserStatus
# production
npm run typeorm migration:run⚠️ ห้าม run migration ใน startup hook ของ Nest โดยตรง — ทำให้ rolling deploy เสี่ยง (pod เก่ายังรันโค้ดเดิม, pod ใหม่ migrate schema → โค้ดเก่าใช้ schema ใหม่ไม่ได้) แยก migration job ออกเป็นขั้นตอนก่อน rollout
💡 Expand-contract pattern (สำหรับ schema change ที่ต้อง backward-compatible ระหว่าง rolling deploy):
- Expand — เพิ่ม column ใหม่/ตารางใหม่ ที่ code เดิมยัง ignore ได้ (เช่น nullable)
- Migrate code — deploy code ใหม่ที่เขียน/อ่านทั้ง column เก่าและใหม่
- Contract — เมื่อ code เก่าถูกลบหมดแล้ว ค่อยลบ column เก่าใน migration ถัดไป ลำดับนี้ทำให้ทั้ง pod เก่าและใหม่ทำงานบน schema เดียวกันได้ตลอด rollout
10. Multi-tenancy ใน ORM
3 strategies:
A) Schema per tenant — PG schema แยก:
typescript
// ⚠️ ตัวอย่างต่อไปนี้ทำผิด 2 จุด — อย่า copy ตรง ๆ:
// 1. `tenant_schema` ใน scope จริงต้องมาจาก request context (เช่น req.tenant)
// 2. `$queryRaw` template tag จะ parameterize ${...} เป็น $1 — แต่ SET search_path
// ไม่รับ parameter (identifier ไม่ใช่ value) → คำสั่งจะพังหรือถูก quote ผิด
// ทางที่ถูก: validate ชื่อ schema เองให้เป็น identifier ที่ปลอดภัย แล้วใช้ $executeRawUnsafe
const tenantSchema = req.tenant; // ต้อง validate ก่อนว่าเป็น [a-z_][a-z0-9_]+
if (!/^[a-z_][a-z0-9_]*$/.test(tenantSchema)) throw new BadRequestException("invalid tenant");
await prisma.$executeRawUnsafe(`SET search_path TO "${tenantSchema}"`);B) Row per tenant — column tenantId:
prisma
model Post {
// ...
tenantId String
@@index([tenantId])
}ใช้ Prisma extension แทรก where: { tenantId } อัตโนมัติ
C) Database per tenant — แยก DB ทั้งหมด (heavyweight, ใช้กับ enterprise)
เลือกตามสเกล: row = scale ดี, schema = ดี-กลาง, db = ดีสุดในแง่ isolation แต่จัดการยาก
11. Audit / Soft Delete / Timestamp
ระบบจริงมักไม่อยากลบข้อมูลทิ้งถาวร และอยากรู้ว่าแถวไหนสร้าง/แก้เมื่อไหร่ — soft delete (มาร์ค deletedAt แทนการลบจริง) และ timestamp อัตโนมัติ (createdAt/updatedAt) ตอบโจทย์นี้ ส่วนนี้แสดงวิธีทำทั้งใน Prisma และ TypeORM:
Soft delete (Prisma extension):
typescript
const prisma = new PrismaClient().$extends({
query: {
user: {
// ✅ ใช้ client ที่ Prisma ส่งให้ (Prisma.getExtensionContext / arg ที่ destructure ได้)
// เลี่ยงเรียก `prisma.user.update` ตัวด้านนอก → จะ recursion ลงไปใน extension ซ้ำ
async delete({ args, model, operation, query }) {
// เรียก update บน model เดียวกัน โดยไม่กระโดดกลับเข้า extension wrapper
// 📖 pattern นี้อิง Prisma Client Extensions "query" component (ดู https://www.prisma.io/docs/orm/prisma-client/client-extensions/query)
// ตรวจสอบ syntax ล่าสุดกับเอกสารทางการก่อนใช้จริง เพราะ API นี้ปรับรายละเอียดตามเวอร์ชัน
return (this as any).user.update({
where: args.where,
data: { deletedAt: new Date() },
});
},
async findMany({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
},
},
});Audit (บันทึกประวัติ insert/update/delete):
- ใช้ trigger (โค้ดที่ DB เรียกเองอัตโนมัติเมื่อข้อมูลเปลี่ยน) ที่ DB เป็น authoritative source (แหล่งความจริงที่เชื่อถือได้ที่สุด) (ดีสุด)
- หรือ Prisma
$on("query")+ outbox pattern (เขียน event ลงตาราง outbox พร้อมกับข้อมูลจริงในทรานแซกชันเดียว แล้วค่อยส่งทีหลัง — กัน event หายตอนระบบล่ม)
12. Testing — In-memory DB
สำหรับ unit test ของ service — mock Repository/PrismaService
สำหรับ integration test — ใช้ Testcontainers (docker postgres ตัวจริง):
bash
npm i -D @testcontainers/postgresqltypescript
import { execSync } from "node:child_process";
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
let container: StartedPostgreSqlContainer;
beforeAll(async () => {
container = await new PostgreSqlContainer().start();
process.env.DATABASE_URL = container.getConnectionUri();
// run migration
execSync("npx prisma migrate deploy");
});
afterAll(() => container.stop());แม่นยำกว่า in-memory mock มาก เพราะ test ใช้ DB engine จริง
🛠️ Checkpoint 4 — ลงมือทำ
ใช้ Prisma + Postgres:
docker run -d --name pg -e POSTGRES_PASSWORD=pass -p 5432:5432 postgres:16- ติดตั้ง Prisma + setup
schema.prismaที่มีUserกับPost(1-N) npx prisma migrate dev --name init- สร้าง
UsersModule+PostsModuleที่ injectPrismaService - เพิ่ม endpoint
GET /users/:idที่ include posts - ทำ transaction:
POST /users/:id/posts/bulkรับ array posts → create ทั้งหมดใน 1 transaction (rollback ถ้าตัวใดผิด) - เปิด
prisma.$on("query")ใน dev → log SQL ที่ Prisma generate → ดูว่า include 3 posts ใช้ queries กี่ครั้ง
สรุปบทที่ 4
- ORM เลือกตามทีมและสไตล์ — Prisma DX ดีสุดสำหรับโปรเจกต์ใหม่
- ห้าม
synchronize: trueใน production — ใช้ migration - Connection pool ต้องตั้งให้พอดี (โดยเฉพาะ serverless ที่ต้องมี pooler)
- N+1 = ระวังทุก ORM, ใช้
include/relations - Index ตาม query pattern + ดู
EXPLAIN ANALYZE - Migration ใน production = job แยก ไม่ใช่ startup hook
- Test ด้วย Testcontainers แม่นกว่า mock
บทต่อไปจะใส่ auth ที่ปลอดภัยจริง
Glossary: ../glossary.md · Style guide: ../CONTRIBUTING.md last_verified: 2026-06-03 · review report: ../REVIEW-2026-06-03.md