Prisma ORM trong production: schema, migration an toàn, hiệu năng query và những thay đổi của Prisma 7–8
Vận hành Prisma 7 trên production: modeling schema với index và native type, migration zero-downtime, select/include và N+1, transaction, connection pool trong serverless, raw SQL an toàn, client extensions, và Prisma 8 RC có gì.
Prisma thay đổi nhiều trong một năm qua hơn cả ba năm trước đó cộng lại. Prisma 7 (phát hành ngày 19/11/2025) bỏ hẳn query engine viết bằng Rust, bắt buộc driver adapter, chuyển URL kết nối sang prisma.config.ts. Và ngay lúc mình viết bài này, đầu tháng 11/2026, Prisma 8 đang ở giai đoạn release candidate với một mô hình hoàn toàn mới.
Hệ quả thực tế: rất nhiều hướng dẫn trên mạng đã sai. Code copy từ bài viết năm 2024 sẽ không chạy với Prisma 7, và một lệnh npm install prisma vô tư hôm nay có thể kéo về bản 8 RC. Bài này tập trung vào câu hỏi mà team mình phải trả lời: chạy Prisma trên production thế nào cho an toàn, từ schema, migration, hiệu năng, transaction, connection pool đến raw SQL, và nên làm gì với Prisma 8.
Nếu bạn cần một bài build-along từ đầu với Next.js, xem Xây ứng dụng full-stack với Next.js App Router và Prisma. Bài này giả định bạn đã có app chạy được và muốn nó chạy ổn.
TL;DR:
- Prisma 7.x (bản mới nhất là 7.10.1) là lựa chọn cho production lúc này. Pin cả
prismalẫn@prisma/clientvề7, vì dist-taglatestcủa góiprismađang trỏ tới 8.0.0 RC. - Prisma 7: generator
prisma-clientvớioutputbắt buộc, driver adapter bắt buộc, URL nằm trongprisma.config.ts,.envkhông còn tự load,migrate devkhông còn tự chạygeneratevà seed. - Production chỉ chạy
prisma migrate deploy. Đổi tên cột theo expand-and-contract, index lớn tạo bằng SQLCONCURRENTLYtrong migration riêng. - Pool connection giờ do driver quản lý (
pgmặc địnhmax: 10, không timeout). Trong serverless, chỉnh lạimaxvàconnectionTimeoutMillis. $queryRawdạng tagged template là an toàn;$queryRawUnsafevới string nối chuỗi là SQL injection.
Prisma đang ở đâu: 6, 7 và 8 RC
| Dòng | Trạng thái (11/2026) | Điểm chính | Ai nên dùng |
|---|---|---|---|
| Prisma 6.x | Chỉ nhận security patch đến 19/11/2026 | Query engine Rust, url trong schema.prisma |
Dự án MongoDB (Prisma 7 chưa hỗ trợ), dự án chưa kịp migrate |
| Prisma 7.x | Stable, bản mới nhất 7.10.1 | Không còn Rust engine, driver adapter bắt buộc, prisma.config.ts, ESM |
Mọi dự án SQL mới và đang chạy |
| Prisma 8 | Release candidate (8.0.0-rc.x) |
Contract-first, query API mới, không dùng @prisma/client |
Thử nghiệm, side project; chưa nên dùng cho production |
Theo trang release status chính thức, Prisma 7 sẽ tiếp tục nhận bug fix và security update trong 18 tháng sau khi Prisma 8 GA. Nghĩa là bạn không bị ép nâng cấp gấp.
Cái bẫy thực sự nằm ở npm. Tại thời điểm mình kiểm tra, dist-tag latest của gói CLI prisma là 8.0.0-rc.29, trong khi @prisma/client vẫn latest ở 7.10.1. Một dev mới clone repo không có lockfile, hoặc ai đó chạy npm install -D prisma để "cập nhật", sẽ có CLI bản 8 nói chuyện với client bản 7.
npm view prisma dist-tags
npm view @prisma/client dist-tags
# Pin rõ ràng major version
npm install -D prisma@7
npm install @prisma/client@7 @prisma/adapter-pg pg
{
"devDependencies": { "prisma": "^7.10.1" },
"dependencies": { "@prisma/client": "^7.10.1", "@prisma/adapter-pg": "^7.10.1" }
}
Giữ ba gói này cùng version. Renovate hoặc Dependabot nên được cấu hình để không tự mở PR nhảy major lên 8.
Cấu hình Prisma 7 đúng chuẩn
Ba file tạo nên một setup Prisma 7: prisma.config.ts cho CLI, schema.prisma cho model, và một module khởi tạo client với driver adapter.
// prisma.config.ts (ở root project)
import 'dotenv/config'
import { defineConfig, env } from 'prisma/config'
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
seed: 'tsx prisma/seed.ts',
},
datasource: {
// CLI (migrate, db execute, TypedSQL) dùng kết nối trực tiếp, không qua pooler
url: env('DIRECT_URL'),
},
})
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
// src/db.ts
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from './generated/prisma/client'
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL, // URL runtime, có thể đi qua pooler
max: 10,
connectionTimeoutMillis: 5_000,
})
export const prisma = new PrismaClient({ adapter })
Những thay đổi so với Prisma 6 dễ làm hỏng CI nhất:
.envkhông tự load nữa.import 'dotenv/config'ở đầuprisma.config.tslà bắt buộc nếu bạn dựa vào file.env. Hàmenv()củaprisma/confignémPrismaConfigEnvErrorkhi biến không tồn tại, nên mọi môi trường chạy Prisma CLI (kể cả bướcprisma generatelúc build) phải có biến đó.migrate devkhông chạygeneratevà seed. Script của bạn phải gọiprisma generatevàprisma db seedtường minh. Các flag--skip-generate,--skip-seedđã bị xóa.- Client không còn nằm trong
node_modules. Thêm thư mụcoutputvào.gitignorevà chạyprisma generatetrongpostinstallhoặc bước build. $usemiddleware bị xóa. Chuyển sang client extensions (phần cuối bài).- Yêu cầu tối thiểu: Node.js 20.19+, TypeScript 5.4+, và upgrade guide khuyến nghị cấu hình ESM.
Danh sách đầy đủ nằm trong upgrade guide lên Prisma 7. Đọc hết phần SSL: Prisma 7 validate certificate của database, nên một số kết nối từng chạy ở bản 6 sẽ báo P1010 cho tới khi bạn cấu hình CA đúng.
Modeling schema cho production
Schema là nơi rẻ nhất để chặn bug. Đây là phần schema đơn hàng mình dùng làm ví dụ cho cả bài:
enum OrderStatus {
PENDING
PAID
SHIPPED
CANCELED
}
model Customer {
id String @id @default(uuid(7)) @db.Uuid
email String @unique @db.VarChar(320)
firstName String @map("first_name") @db.VarChar(100)
lastName String @map("last_name") @db.VarChar(100)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
orders Order[]
@@map("customers")
}
model Order {
id String @id @default(uuid(7)) @db.Uuid
customerId String @map("customer_id") @db.Uuid
status OrderStatus @default(PENDING)
totalCents Int @map("total_cents")
currency String @default("VND") @db.Char(3)
idempotencyKey String @map("idempotency_key") @db.VarChar(64)
version Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
items OrderItem[]
@@unique([customerId, idempotencyKey])
@@index([customerId, status, createdAt(sort: Desc)])
@@map("orders")
}
model OrderItem {
id String @id @default(uuid(7)) @db.Uuid
orderId String @map("order_id") @db.Uuid
sku String @db.VarChar(64)
quantity Int
unitCents Int @map("unit_cents")
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@index([orderId])
@@map("order_items")
}
Những quyết định đáng giải thích:
@db.Timestamptz(3): với PostgreSQL,DateTimemặc định map sangtimestamp(3)không có timezone. Lưu thời gian không timezone là nguồn bug kinh điển khi server và database khác múi giờ. Dùngtimestamptzngay từ đầu, đổi sau rất đau.@@index([customerId, status, createdAt(sort: Desc)]): index phải khớp với query thật. Thứ tự cột: điều kiện bằng trước, cột sort sau. Phần lý thuyết và cách đọcEXPLAINnằm ở bài Tối ưu SQL query với PostgreSQL.- Index cho foreign key: PostgreSQL không tự tạo index cho cột foreign key.
OrderItem.orderIdcần@@index([orderId]), nếu không mỗi lần xóa một order (cascade) là một seq scan trênorder_items. @@unique([customerId, idempotencyKey]): chặn tạo trùng đơn khi client retry. Insert trùng ném lỗiP2002, bạn bắt lỗi đó và trả lại đơn cũ.@default(uuid(7)): UUID v7 có thứ tự theo thời gian nên index B-tree không bị phân mảnh như UUID v4. Lưu ý giá trị này do Prisma Client sinh ra ở tầng app, không phải default của database; insert bằng raw SQL phải tự truyềnid.- Tiền lưu bằng số nguyên (
totalCents Int) hoặcDecimal @db.Decimal(12, 2), không bao giờ dùngFloat. - Enum: thêm giá trị mới thì dễ, xóa hoặc đổi tên giá trị trong PostgreSQL enum thì phải tạo type mới và migrate dữ liệu. Với trạng thái còn thay đổi nhiều, một cột
StringcóCHECKconstraint (viết trong migration SQL) linh hoạt hơn.
@map và @@map cho phép code dùng camelCase trong khi bảng và cột dùng snake_case. Nó còn là cách đổi tên field trong code mà không chạm database: đổi fullName thành displayName và giữ @map("full_name"), migration sinh ra sẽ rỗng.
Migration an toàn: migrate dev, migrate deploy và expand-and-contract
Quy tắc không có ngoại lệ:
| Lệnh | Dùng ở đâu | Làm gì |
|---|---|---|
prisma migrate dev |
Máy dev | Diff schema với lịch sử migration (dùng shadow database), sinh file SQL mới, apply |
prisma migrate deploy |
CI/CD, production | Chỉ apply những migration chưa chạy, không bao giờ sinh migration mới, không reset |
prisma db push |
Prototype, database tạm | Đẩy schema thẳng vào database, không tạo migration, có thể drop dữ liệu |
prisma migrate reset |
Máy dev | Drop toàn bộ database và chạy lại từ đầu |
db push vào production là cách nhanh nhất để mất dữ liệu: không có file SQL để review, không có lịch sử, và khi Prisma thấy cần drop một cột để khớp schema, nó drop. Migration phải đi qua review như code.
Chạy migration trong CI trước khi deploy code mới:
# .github/workflows/migrate.yml
name: migrate-database
on:
push:
branches: [main]
jobs:
migrate:
runs-on: ubuntu-latest
environment: production
concurrency: migrate-production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx prisma migrate deploy
env:
DIRECT_URL: ${{ secrets.DIRECT_URL }}
concurrency đảm bảo hai lần push liên tiếp không chạy migration song song. DIRECT_URL là kết nối thẳng tới PostgreSQL, không qua PgBouncer, vì migration cần advisory lock và session state.
Expand-and-contract khi đổi tên hoặc đổi kiểu cột
Nếu bạn đổi tên field trong schema mà không dùng @map, migrate dev sẽ sinh DROP COLUMN rồi ADD COLUMN. Kể cả khi bạn sửa tay thành RENAME COLUMN, vẫn có một khoảng thời gian code cũ (đang chạy) đọc tên cũ còn database đã đổi tên mới. Cách làm không downtime là chia thành nhiều lần deploy:
- Expand: thêm cột mới, nullable. Deploy code ghi vào cả hai cột.
- Backfill: copy dữ liệu cũ sang cột mới theo từng batch.
- Switch: deploy code chỉ đọc cột mới (vẫn ghi cả hai).
- Contract: deploy code chỉ ghi cột mới, sau đó migration xóa cột cũ.
# Sinh file migration nhưng chưa apply, để sửa SQL trước
npx prisma migrate dev --name add_customer_display_name --create-only
Backfill không nên nằm trong migration với bảng lớn, vì một UPDATE hàng triệu dòng là một transaction dài giữ lock và chặn autovacuum. Chạy bằng script riêng:
// scripts/backfill-display-name.ts
import { prisma } from '../src/db'
async function main() {
let total = 0
for (;;) {
const updated = await prisma.$executeRaw`
UPDATE customers
SET display_name = first_name || ' ' || last_name
WHERE id IN (
SELECT id FROM customers
WHERE display_name IS NULL
LIMIT 5000
)`
total += updated
if (updated === 0) break
await new Promise((r) => setTimeout(r, 200)) // nhường tài nguyên cho traffic thật
}
console.log(`backfilled ${total} rows`)
}
main().finally(() => prisma.$disconnect())
Với index trên bảng lớn, sửa file migration vừa sinh: thay CREATE INDEX bằng CREATE INDEX CONCURRENTLY và để nó một mình trong một migration. PostgreSQL không cho CONCURRENTLY chạy trong transaction block, và nhiều statement gửi chung một lần sẽ chạy trong implicit transaction.
-- prisma/migrations/20261103090000_orders_customer_status_idx/migration.sql
CREATE INDEX CONCURRENTLY IF NOT EXISTS "orders_customer_id_status_created_at_idx"
ON "orders" ("customer_id", "status", "created_at" DESC);
Một cái bẫy với custom SQL: object nào có trong database mà không mô tả được trong schema.prisma (ví dụ index WHERE deleted_at IS NULL, hoặc trigger) sẽ bị migrate dev coi là drift, và lần sinh migration sau có thể chứa lệnh DROP INDEX để "sửa" nó. Luôn đọc file SQL trước khi commit. Prisma 7.4 thêm preview feature partialIndexes để mô tả partial index ngay trong schema; nếu dùng, kiểm tra cú pháp hiện tại trong docs vì preview feature có thể đổi.
Khi một migration fail giữa chừng trên production, prisma migrate deploy sẽ từ chối chạy tiếp. Sửa tay database về trạng thái nhất quán, rồi đánh dấu bằng npx prisma migrate resolve --rolled-back <tên_migration> (hoặc --applied nếu bạn đã chạy tay phần còn lại). npx prisma migrate status cho biết migration nào đang pending hoặc failed.
Hiệu năng query: select, include, N+1 và relationLoadStrategy
Lỗi hiệu năng phổ biến nhất với Prisma không phải query chậm, mà là lấy quá nhiều dữ liệu. findMany không có select trả về mọi cột, include trả về mọi cột của relation:
// Over-fetch: mọi cột của order + mọi cột của mọi item + toàn bộ customer
const orders = await prisma.order.findMany({
where: { customerId },
include: { items: true, customer: true },
})
// Chỉ lấy thứ màn hình cần
const orderList = await prisma.order.findMany({
where: { customerId, deletedAt: null },
orderBy: { createdAt: 'desc' },
take: 20,
select: {
id: true,
status: true,
totalCents: true,
createdAt: true,
_count: { select: { items: true } },
},
})
Với field nhạy cảm mà hầu như không bao giờ cần trả ra (ví dụ một model User có passwordHash), dùng omit (GA từ Prisma 6.2), ở từng query hoặc ở cấp client: new PrismaClient({ adapter, omit: { user: { passwordHash: true } } }). Bỏ qua ở cấp client nghĩa là quên select cũng không làm lộ field đó.
N+1 xuất hiện khi gọi Prisma trong vòng lặp. Mặc định, include và select lồng nhau dùng chiến lược query: Prisma chạy một query cho bảng cha và một query WHERE ... IN (...) cho mỗi relation, rồi ghép ở tầng ứng dụng. Đó đã là batching, không phải N+1. Muốn một query duy nhất dùng LATERAL JOIN trên PostgreSQL, bật preview feature relationJoins:
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
previewFeatures = ["relationJoins"]
}
const recent = await prisma.customer.findMany({
where: { deletedAt: null },
take: 50,
relationLoadStrategy: 'join', // hoặc 'query'
select: {
id: true,
email: true,
orders: {
where: { status: 'PAID' },
orderBy: { createdAt: 'desc' },
take: 3,
select: { id: true, totalCents: true },
},
},
})
join thường thắng khi latency tới database cao (mỗi round trip đắt), query có thể thắng khi relation trả nhiều dòng vì JOIN nhân bản dữ liệu cha. Đây vẫn là preview, nên đo trên dữ liệu thật trước khi bật toàn cục.
Để biết query nào chậm, log từ Prisma và đối chiếu với pg_stat_statements phía database:
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from './generated/prisma/client'
const SLOW_QUERY_MS = 200
const base = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
log: [
{ emit: 'event', level: 'query' },
{ emit: 'stdout', level: 'warn' },
{ emit: 'stdout', level: 'error' },
],
})
base.$on('query', (e) => {
if (e.duration >= SLOW_QUERY_MS) {
// Không log e.params: có thể chứa email, token, dữ liệu cá nhân
console.warn(JSON.stringify({ msg: 'slow_query', durationMs: e.duration, query: e.query }))
}
})
export const prisma = base
$on chỉ có trên client gốc, không có trên client đã $extends. Đăng ký listener trước, extend sau.
Transaction: interactive, batch và optimistic locking
Prisma có ba cách để nhiều thao tác thành công hoặc thất bại cùng nhau:
- Nested write:
prisma.order.create({ data: { ..., items: { create: [...] } } })tự chạy trong một transaction. Dùng khi mọi thứ là một cây dữ liệu. - Batch transaction:
prisma.$transaction([queryA, queryB])chạy một mảng query độc lập trong một transaction. Không đọc được kết quả của query trước để quyết định query sau. - Interactive transaction:
prisma.$transaction(async (tx) => { ... })cho phép logic tùy ý, nhưng giữ một connection suốt thời gian callback chạy.
import { Prisma } from './generated/prisma/client'
import { prisma } from './db'
export class OutOfStockError extends Error {}
export async function placeOrder(input: {
customerId: string
idempotencyKey: string
items: { sku: string; quantity: number; unitCents: number }[]
}) {
return prisma.$transaction(
async (tx) => {
for (const item of input.items) {
// Điều kiện trong WHERE: trừ kho chỉ khi còn đủ, atomic ở tầng database
const { count } = await tx.inventory.updateMany({
where: { sku: item.sku, available: { gte: item.quantity } },
data: { available: { decrement: item.quantity } },
})
if (count === 0) throw new OutOfStockError(item.sku) // ném lỗi = rollback
}
return tx.order.create({
data: {
customerId: input.customerId,
idempotencyKey: input.idempotencyKey,
totalCents: input.items.reduce((s, i) => s + i.quantity * i.unitCents, 0),
items: { create: input.items },
},
select: { id: true },
})
},
{
maxWait: 2_000, // chờ lấy connection tối đa 2s
timeout: 5_000, // toàn bộ callback tối đa 5s
isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
}
)
}
(Ví dụ giả định có thêm model Inventory với sku và available.)
Mặc định maxWait là 2 giây và timeout là 5 giây. Đừng tăng timeout lên 60 giây để "sửa" lỗi Transaction already closed: nguyên nhân gần như luôn là bạn đang gọi HTTP, gửi email hoặc xử lý file bên trong callback. Mỗi giây callback chạy là một giây một connection bị giữ và các dòng bị lock. Gọi dịch vụ ngoài trước hoặc sau transaction, không phải bên trong.
Với isolation Serializable, PostgreSQL có thể hủy transaction khi phát hiện xung đột, Prisma báo P2034. Code phải retry:
import { Prisma } from './generated/prisma/client'
export async function withSerializableRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
for (let i = 1; ; i++) {
try {
return await fn()
} catch (e) {
const retryable = e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2034'
if (!retryable || i >= attempts) throw e
await new Promise((r) => setTimeout(r, 20 * 2 ** i))
}
}
}
Khi chỉ cần chống "lost update" trên một dòng, optimistic locking rẻ hơn transaction dài: updateMany({ where: { id, version }, data: { status, version: { increment: 1 } } }) rồi kiểm tra count === 1. Nếu 0, có người khác vừa sửa, đọc lại và thử lại.
Connection pool trong serverless: adapter, pooler và con số max
Từ Prisma 7, pool không còn do Prisma quản lý mà do driver. Với @prisma/adapter-pg, đó là pg.Pool, và mặc định của nó khác Prisma 6:
| Hành vi | Prisma 6 (URL param) | Prisma 7 + pg (option của adapter) |
|---|---|---|
| Kích thước pool | connection_limit, mặc định số CPU vật lý × 2 + 1 |
max, mặc định 10 |
| Chờ lấy connection | pool_timeout, mặc định 10s |
connectionTimeoutMillis, mặc định 0 (chờ mãi) |
| Timeout kết nối | connect_timeout, mặc định 5s |
connectionTimeoutMillis, mặc định 0 |
| Connection idle | 300s | idleTimeoutMillis, mặc định 10s |
Hai điều rút ra. Một, các URL param kiểu ?connection_limit=5 không còn là chỗ để cấu hình; đặt option trên adapter. Hai, timeout mặc định bằng 0 nghĩa là khi pool cạn, request treo cho đến khi function hết maxDuration. Luôn đặt connectionTimeoutMillis.
Phép tính quan trọng trong serverless: tổng connection = số instance đang chạy × max. 40 instance × max: 10 là 400 connection, trong khi PostgreSQL mặc định max_connections = 100. Có ba cách xử lý, thường kết hợp:
- Pooler phía database (PgBouncer, Supavisor của Supabase, endpoint pooled của Neon) ở transaction mode. Runtime dùng URL pooled, CLI dùng URL direct trong
prisma.config.ts. Theo docs của Prisma, PgBouncer từ 1.21.0 không cầnpgbouncer=truenữa, nhưng phải đặtmax_prepared_statementslớn hơn 0 vì Prisma dùng prepared statement. Thấy lỗiprepared statement "s0" already existskhi migrate là dấu hiệu CLI đang đi qua pooler. - Pool nhỏ trong mỗi instance:
maxtừ 1 đến 5 tùy nền tảng. Trên Vercel với Fluid compute, một instance xử lý nhiều request đồng thời, nên pool nhỏ vẫn có ý nghĩa, vàattachDatabasePool()từ@vercel/functionsgiúp đóng connection idle trước khi instance bị suspend. Chi tiết ở bài Serverless functions trên Vercel và Netlify. - Serverless driver qua HTTP/WebSocket: ví dụ
@prisma/adapter-neoncho Neon, phù hợp môi trường không giữ TCP lâu được.
import { Pool } from 'pg'
import { attachDatabasePool } from '@vercel/functions'
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from './generated/prisma/client'
const pool = new Pool({
connectionString: process.env.DATABASE_URL, // URL pooled
max: 5,
connectionTimeoutMillis: 5_000,
idleTimeoutMillis: 10_000,
})
attachDatabasePool(pool)
export const prisma = new PrismaClient({ adapter: new PrismaPg(pool) })
PrismaPg nhận cả object cấu hình lẫn một pg.Pool có sẵn. Truyền pool có sẵn khi bạn cần dùng chung pool đó cho thư viện khác hoặc cần gắn hook của nền tảng như trên.
Một lưu ý cho ai đang dùng Accelerate: upgrade guide của Prisma 7 ghi rằng Accelerate độc lập và kết nối hosted dạng prisma+postgres://accelerate.prisma-data.net sẽ ngừng vào ngày 1/12/2026, và không được truyền URL Accelerate vào driver adapter. Nếu dự án của bạn còn dùng, lên kế hoạch chuyển ngay.
Raw SQL an toàn và TypedSQL
Khi query builder không đủ (CTE, window function, ON CONFLICT phức tạp), Prisma có raw SQL. Ranh giới an toàn rất rõ:
import { Prisma } from './generated/prisma/client'
import { prisma } from './db'
// An toàn: tagged template, giá trị thành tham số $1, $2
export async function revenueByDay(customerId: string, since: Date) {
return prisma.$queryRaw<{ day: Date; revenue_cents: bigint }[]>`
SELECT date_trunc('day', created_at) AS day, sum(total_cents) AS revenue_cents
FROM orders
WHERE customer_id = ${customerId}::uuid AND created_at >= ${since} AND status = 'PAID'
GROUP BY 1
ORDER BY 1`
}
// An toàn: ghép điều kiện động bằng Prisma.sql / Prisma.join
export async function ordersByStatuses(statuses: string[]) {
const filter = statuses.length
? Prisma.sql`WHERE status::text IN (${Prisma.join(statuses)})`
: Prisma.empty
return prisma.$queryRaw`SELECT id, status FROM orders ${filter} LIMIT 100`
}
// NGUY HIỂM: nối chuỗi input vào SQL = SQL injection
export async function searchUnsafe(term: string) {
return prisma.$queryRawUnsafe(`SELECT id FROM customers WHERE email LIKE '%${term}%'`)
}
$queryRawUnsafe chỉ an toàn khi bạn truyền giá trị qua tham số vị trí ($queryRawUnsafe('... WHERE id = $1', id)) và phần chuỗi SQL hoàn toàn do code của bạn kiểm soát. Tên cột hoặc hướng sort động phải được so với whitelist rồi mới dùng Prisma.raw. Một chi tiết hay gây lỗi: sum() và count() trong PostgreSQL trả về bigint, Prisma map thành BigInt của JavaScript, và JSON.stringify sẽ ném lỗi với BigInt.
TypedSQL (preview feature typedSql) giải quyết chuyện kiểu dữ liệu: bạn viết file .sql, Prisma kết nối database lúc generate để suy ra kiểu tham số và kết quả.
-- prisma/sql/topCustomers.sql
-- @param {DateTime} $1:since
-- @param {Int} $2:limit
SELECT c.id, c.email, sum(o.total_cents) AS revenue_cents
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'PAID' AND o.created_at >= $1
GROUP BY c.id, c.email
ORDER BY revenue_cents DESC
LIMIT $2
import { prisma } from './db'
import { topCustomers } from './generated/prisma/sql'
const since = new Date(Date.now() - 30 * 24 * 3600 * 1000)
const rows = await prisma.$queryRawTyped(topCustomers(since, 10))
// rows[0].revenue_cents có kiểu suy ra từ database
Bật previewFeatures = ["typedSql"] trong generator và chạy prisma generate --sql. Vì generate cần kết nối database (qua datasource.url trong prisma.config.ts), bước này trong CI phải có database đã migrate. Mình chạy nó trong cùng job với integration test, dùng PostgreSQL service container.
Client extensions: soft delete và computed field
Từ Prisma 7, $use đã bị xóa; mọi logic cắt ngang đi qua $extends. Ví dụ soft delete và một computed field:
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from './generated/prisma/client'
const base = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
})
export const prisma = base.$extends({
query: {
order: {
// Đặt deletedAt: null TRƯỚC spread để caller vẫn có thể ghi đè có chủ đích
async findMany({ args, query }) {
args.where = { deletedAt: null, ...args.where }
return query(args)
},
async findFirst({ args, query }) {
args.where = { deletedAt: null, ...args.where }
return query(args)
},
async count({ args, query }) {
args.where = { deletedAt: null, ...args.where }
return query(args)
},
},
},
model: {
order: {
async softDelete(id: string) {
return base.order.update({ where: { id }, data: { deletedAt: new Date() } })
},
},
},
result: {
customer: {
fullName: {
needs: { firstName: true, lastName: true },
compute(c) {
return `${c.lastName} ${c.firstName}`.trim()
},
},
},
},
})
// Sử dụng
await prisma.order.softDelete('0193f0c2-7c1e-7d4a-9a51-3c2f6f0b2a10')
const customers = await prisma.customer.findMany({ select: { id: true, fullName: true } })
Giới hạn cần biết của soft delete bằng extension: nó chỉ chặn các operation bạn liệt kê. findUnique, aggregate, groupBy, và đặc biệt là relation được include từ model khác (customer.findMany({ include: { orders: true } })) không đi qua hook order.findMany. Với dữ liệu mà rò rỉ bản ghi đã xóa là sự cố (dữ liệu cá nhân, compliance), lớp bảo vệ nên nằm ở database: view chỉ chứa dòng chưa xóa, hoặc Row Level Security.
result extension tính toán ở tầng app sau khi query xong, không chạy trong SQL. Bạn không thể where hay orderBy theo fullName. Nếu cần lọc, đó là việc của generated column trong PostgreSQL.
Prisma, Drizzle hay Kysely
| Tiêu chí | Prisma 7 | Drizzle ORM | Kysely |
|---|---|---|---|
| Định nghĩa schema | File .prisma (DSL riêng) |
TypeScript (pgTable(...)) |
Không có; type viết tay hoặc sinh từ database bằng công cụ bên ngoài |
| Migration | Prisma Migrate: sinh SQL từ diff, có shadow database | drizzle-kit: sinh SQL từ diff schema TS | Migrator chạy file migration bạn tự viết |
| Bước codegen | Bắt buộc prisma generate |
Không cần | Tùy chọn |
| Phong cách query | Object API (findMany, include) |
Gần SQL, kèm relational query API | Query builder sát SQL, type-safe từng mệnh đề |
| Raw SQL | $queryRaw, TypedSQL (preview) |
Template sql |
Template sql |
| Serverless / edge | Driver adapter, không còn binary Rust | Nhẹ, dùng driver trực tiếp | Nhẹ, dùng dialect |
| Hợp với | Team muốn schema là nguồn sự thật, migration có sẵn, API dễ đọc | Team muốn schema bằng TS và query gần SQL | Team viết SQL giỏi, muốn type-safety mà không muốn ORM |
Drizzle hiện đang trong giai đoạn RC cho bản 1.0 (bản latest trên npm vẫn thuộc dòng 0.45 khi mình kiểm tra), Kysely ở dòng 0.29. Không có lựa chọn nào sai; câu hỏi là team bạn muốn suy nghĩ bằng model hay bằng SQL.
Prisma 8 RC: có gì và có nên chờ
Prisma 8 không phải bản nâng cấp tăng dần. Theo docs hiện tại của bản RC: model nằm trong một file contract thay cho schema.prisma, lệnh prisma contract emit thay prisma generate, code không import @prisma/client nữa mà import một db được sinh ra, query có hình dạng mới, và migration dùng các lệnh mới. Middleware kiểu mới thay cả $use lẫn $extends. PostgreSQL được hỗ trợ, MongoDB ở early access, SQLite thử nghiệm; MySQL, SQL Server và CockroachDB chưa có trong bản 8 tại thời điểm viết.
Lộ trình chính thức cho dự án đang chạy Prisma 7 là chạy song song hai phiên bản trên cùng database và chuyển từng phần. Điều đó nói lên quy mô thay đổi: đây là một migration thực sự, không phải đổi version trong package.json.
Nhận định của mình:
- Áp dụng ngay: Prisma 7 cho mọi dự án SQL. Nếu còn ở 6, nâng lên 7 trước khi bản 6 hết security patch ngày 19/11/2026.
- Thử nghiệm: Prisma 8 trên một service nhỏ hoặc side project mới dùng PostgreSQL, để team quen với contract và query API.
- Chờ: chuyển production lên 8 cho tới khi có bản GA, codemod hoặc migration guide ổn định, và các thư viện xung quanh (auth adapter, testing helper) hỗ trợ.
Những lỗi thường gặp
Cài prisma không pin version.
# Sai: có thể kéo về CLI 8 RC trong khi client là 7
npm install -D prisma
# Đúng
npm install -D prisma@7 && npm install @prisma/client@7 @prisma/adapter-pg
db push lên production. Không có migration file, không review được, có thể drop cột. Production chỉ dùng migrate deploy.
Tạo new PrismaClient() trong mỗi request. Mỗi instance là một pool riêng. Khởi tạo một lần ở module scope và tái sử dụng.
Interactive transaction gọi dịch vụ ngoài.
// Sai: giữ connection và lock trong khi chờ payment gateway
await prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data })
await chargeCard(order.id) // 3 giây
})
// Đúng: transaction ngắn, gọi ngoài sau khi commit, trạng thái chuyển bằng update riêng
const order = await prisma.order.create({ data: { ...data, status: 'PENDING' } })
await chargeCard(order.id)
await prisma.order.update({ where: { id: order.id }, data: { status: 'PAID' } })
Không đặt timeout cho pool pg. Mặc định chờ vô hạn, request treo tới khi function bị kill. Đặt connectionTimeoutMillis.
CLI đi qua pooler. prisma.config.ts trỏ vào URL pooled khiến migrate lỗi prepared statement hoặc advisory lock. CLI dùng URL direct.
Tin rằng soft delete extension bảo vệ mọi đường đọc. Relation include và findUnique không đi qua hook findMany. Kiểm tra bằng test, hoặc đưa ràng buộc xuống database.
Đổi tên field không dùng @map. Migration sinh DROP COLUMN và mất dữ liệu. Đổi tên trong code thì giữ @map tên cũ; đổi tên trong database thì làm expand-and-contract.
Checklist
- Pin
prisma,@prisma/clientvà driver adapter cùng major 7, chặn bot tự nhảy lên 8. -
prisma.config.tscóimport 'dotenv/config'vàdatasource.urltrỏ vào URL direct. - Script build gọi
prisma generatetường minh; thư mụcoutputnằm trong.gitignore. - Mọi cột thời gian dùng
@db.Timestamptz, mọi foreign key có index. - Production chỉ chạy
prisma migrate deploytrong CI, cóconcurrencyđể không chạy song song. - Đổi tên/đổi kiểu cột theo expand-and-contract; index lớn tạo bằng
CONCURRENTLYtrong migration riêng. - Đặt
max,connectionTimeoutMillischo adapter; tính tổng connection theo số instance. - Log query chậm hơn ngưỡng (không log params) và đối chiếu với
pg_stat_statements. - Tìm và loại bỏ mọi
$queryRawUnsafecó nối chuỗi input. - Chuyển
$usesang$extends, viết test cho các đường đọc mà soft delete phải chặn.