Tại sao tôi từ bỏ Prisma để dùng Raw SQL (và hiệu năng tăng gấp 10 lần)
Prisma là một công cụ tuyệt vời với cú pháp rõ ràng và hỗ trợ TypeScript tốt, nhưng hiệu năng truy vấn thực tế lại là một vấn đề lớn khi ứng dụng phát triển. Bài viết này chia sẻ hành trình chuyển đổi từ Prisma sang Raw SQL để giải quyết vấn đề truy vấn chậm và N+1, giúp cải thiện hiệu suất truy vấn lên tới 10 lần mà không cần viết lại toàn bộ ứng dụng.

Prisma thực sự là một phần mềm tốt. Ngôn ngữ định nghĩa schema (DSL) của nó gọn gàng, tính năng tạo type hoạt động hiệu quả, và với một dự án mới, nó giúp bạn thiết lập lớp dữ liệu khả dụng chỉ trong một giờ. Tôi đã sử dụng nó khoảng một năm trước khi bắt đầu nhận ra một số vấn đề.
Dấu hiệu đầu tiên là một truy vấn lẽ ra chỉ mất 5ms lại tốn tới 80ms. Dấu hiệu thứ hai là vấn đề N+1 mà về mặt kỹ thuật tôi đã giải quyết bằng include nhưng nó vẫn tạo ra 15 câu lệnh SQL. Dấu hiệu thứ ba là việc phải mở prisma.$queryRaw lần thứ ba trong tuần vì query builder không thể diễn đạt những gì tôi cần.
Đến lúc đó, tôi ngừng chiến đấu với sự trừu tượng hóa và bắt đầu viết SQL trực tiếp.
Prisma thực sự làm gì với truy vấn của bạn
Dưới đây là một truy vấn đơn giản với bộ lọc và phân trang:
const logs = await prisma.logEntry.findMany({
where: {
organizationId: orgId,
timestamp: {
gte: from,
lt: to,
},
level: { in: ["error", "fatal"] },
},
orderBy: { timestamp: "desc" },
take: 50,
skip: page * 50,
});
Đây là SQL mà Prisma tạo ra:
SELECT
"public"."log_entries"."id",
"public"."log_entries"."timestamp",
"public"."log_entries"."service",
"public"."log_entries"."level",
"public"."log_entries"."message",
"public"."log_entries"."metadata",
"public"."log_entries"."organization_id",
"public"."log_entries"."created_at",
"public"."log_entries"."updated_at"
FROM "public"."log_entries"
WHERE (
"public"."log_entries"."organization_id" = $1
AND "public"."log_entries"."timestamp" >= $2
AND "public"."log_entries"."timestamp" < $3
AND "public"."log_entries"."level" IN ($4, $5)
)
ORDER BY "public"."log_entries"."timestamp" DESC
LIMIT $6 OFFSET $7
Đây là một câu lệnh SQL ổn. Tuy nhiên, hãy lưu ý: nó chọn mọi cột (bao gồm cả created_at và updated_at mà giao diện người dùng của tôi không cần), nó sử dụng phân trang OFFSET (chậm trên các bảng lớn), và tôi không thể kiểm soát bất kỳ điều nào nếu không phải thoát ra dùng $queryRaw.
Truy vấn raw tương đương sẽ như sau:
const logs = await pool.query(
`SELECT id, timestamp, service, level, message, metadata
FROM log_entries
WHERE organization_id = $1
AND timestamp >= $2
AND timestamp < $3
AND level = ANY($4)
AND (timestamp, id) < ($5, $6)
ORDER BY timestamp DESC, id DESC
LIMIT $7`,
[orgId, from, to, ["error", "fatal"], cursorTs, cursorId, 50]
);
Sử dụng phân trang keyset thay vì OFFSET, chỉ chọn các cột tôi cần, và truy vấn chính xác là những gì tôi muốn cơ sở dữ liệu thực thi.
Vấn đề N+1 mà Prisma không giải quyết triệt để
Tính năng include của Prisma giải quyết vấn đề N+1 bằng cách sử dụng các mệnh đề IN thay vì các truy vấn trên từng hàng. Nhưng "không có N+1" không có nghĩa là "chỉ có một truy vấn":
const projects = await prisma.project.findMany({
where: { organizationId: orgId },
include: {
members: true,
apiKeys: { where: { active: true } },
_count: { select: { logEntries: true } },
},
});
Prisma thực thi điều này thành 4 truy vấn riêng biệt: một cho dự án, một cho thành viên, một cho apiKeys, và một cho việc đếm. Sau đó, nó lắp ráp kết quả trong JavaScript.
Phiên bản raw chỉ cần một truy vấn duy nhất. Cách tiếp cận ngây thơ là nối chuỗi nhiều LEFT JOIN trên các bảng one-to-many và dựa vào GROUP BY - nhưng điều đó sẽ tạo ra sự phân tạp Descartes (Cartesian fan-out): nếu một dự án có 10 thành viên, 5 API keys và 100 bản ghi log, cơ sở dữ liệu sẽ tạo ra 10x5x100 = 5.000 dòng trung gian cho mỗi dự án trước khi thu gọn chúng lại. COUNT(DISTINCT ...) có thể che giấu lỗi trong kết quả, nhưng hiệu năng sẽ sụp đổ khi các bảng tăng trưởng.
Phiên bản đúng sẽ tổng hợp trước từng mối quan hệ bằng CTE trước khi join:
WITH member_stats AS (
SELECT
project_id,
COUNT(user_id) AS member_count,
jsonb_agg(jsonb_build_object('id', user_id, 'role', role)) AS members
FROM project_members
GROUP BY project_id
),
key_stats AS (
SELECT project_id, COUNT(id) AS active_key_count
FROM api_keys
WHERE active = true
GROUP BY project_id
),
log_stats AS (
SELECT project_id, COUNT(id) AS log_entry_count
FROM log_entries
WHERE timestamp > NOW() - INTERVAL '24 hours'
GROUP BY project_id
)
SELECT
p.id,
p.name,
p.created_at,
COALESCE(ms.member_count, 0) AS member_count,
COALESCE(ks.active_key_count, 0) AS active_key_count,
COALESCE(ls.log_entry_count, 0) AS log_entry_count,
COALESCE(ms.members, '[]'::jsonb) AS members
FROM projects p
LEFT JOIN member_stats ms ON ms.project_id = p.id
LEFT JOIN key_stats ks ON ks.project_id = p.id
LEFT JOIN log_stats ls ON ls.project_id = p.id
WHERE p.organization_id = $1
ORDER BY p.created_at DESC
Mỗi CTE quét và tổng hợp bảng của nó một cách độc lập. Lần join cuối cùng hoạt động trên các dòng đã được thu gọn - không có sự phân tán, không có dòng trung gian lãng phí. Một lần truy xuất (round trip), và thực tế nhanh hơn 4 truy vấn của Prisma ở quy mô lớn.
Prisma không thể tạo ra truy vấn này. $queryRaw có thể chạy nó, nhưng sau đó bạn sẽ mất đi sự an toàn về kiểu dữ liệu (type safety) - vốn là điểm chính khi sử dụng Prisma.
Các con số hiệu năng
Cùng một endpoint, cùng dữ liệu, cùng cấu hình chỉ mục. 50.000 dòng trong bảng.
| Loại truy vấn | p50 | p95 | p99 |
|---|---|---|---|
Prisma findMany với include | 45ms | 120ms | 310ms |
4 truy vấn pg riêng biệt | 18ms | 40ms | 95ms |
| Một truy vấn JOIN duy nhất | 6ms | 14ms | 28ms |
Con số 10 lần trong tiêu đề đến từ so sánh p99. Ở mức p50, nó gần hơn là 7 lần. Cả hai đều là những con số thực tế.
Các con số của Prisma không hề tệ về mặt tuyệt đối đối với hầu hết các ứng dụng. Chúng trở thành vấn đề khi bạn thực hiện việc này trên mọi yêu cầu, ở quy mô lớn, với áp lực lên pool kết nối từ các yêu cầu đồng thời.
Di chuyển mà không cần viết lại mọi thứ
Bạn không cần phải thay thế Prisma ở mọi nơi cùng một lúc. Con đường thực tế là như sau:
Bước 1: Giữ Prisma cho các thao tác ghi và đọc đơn giản
Prisma thực sự tốt cho các lệnh chèn (insert), cập nhật (update), và tra cứu bản ghi đơn theo khóa chính. Việc tạo truy vấn cho các trường hợp này là tối ưu và tính năng an toàn kiểu dữ liệu rất hữu ích.
// Giữ lại phần này trong Prisma - nó vẫn ổn
await prisma.user.create({ data: { email, name, organizationId } });
await prisma.project.update({ where: { id }, data: { name } });
const user = await prisma.user.findUnique({ where: { id } });
Bước 2: Thay thế các truy vấn danh sách và bất kỳ thứ gì có join
Đây là nơi mà chi phí vận hành (overhead) cộng hưởng. Thêm một pool pg song song với Prisma:
import { Pool } from "pg";
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
Bước 3: Viết một lớp truy vấn mỏng
Điều tôi nhớ nhất từ Prisma là kết quả có kiểu dữ liệu (typed results). TypeScript với raw SQL mặc định là any. Hãy khắc phục nó:
async function queryLogs(params: LogQuery): Promise<LogEntry[]> {
const result = await pool.query<{
id: string;
timestamp: Date;
service: string;
level: string;
message: string;
metadata: Record<string, unknown>;
}>(
`SELECT id, timestamp, service, level, message, metadata
FROM log_entries
WHERE organization_id = $1
AND timestamp >= $2
AND timestamp < $3
ORDER BY timestamp DESC
LIMIT $4`,
[params.orgId, params.from, params.to, params.limit]
);
return result.rows;
}
Tham số generic trên pool.query<T> sẽ định kiểu cho các dòng. Nó không tiện dụng bằng các type được tạo tự động của Prisma, nhưng nó đủ để bắt hầu hết các lỗi tại thời điểm biên dịch.
Nếu bạn muốn kiểm soát ở mức SQL với sự an toàn kiểu dữ liệu như Prisma, hãy thử nghiệm Kysely hoặc Drizzle ORM. Cả hai đều cho phép bạn viết các truy vấn gần giống SQL trong khi suy ra đầy đủ các kiểu TypeScript từ schema của bạn - mà không cần "ma thuật" của ORM khiến việc tối ưu hóa truy vấn trở nên khó khăn.
Những gì bạn thực sự đánh đổi
Điều quan trọng là phải nói rõ ràng: có những thứ thực sự bạn phải hy sinh.
Di chuyển Schema. Prisma Migrate rất tốt. Khi bạn loại bỏ Prisma khỏi lớp truy vấn, bạn vẫn muốn có một công cụ di chuyển. Tôi sử dụng node-pg-migrate, người khác dùng db-migrate hoặc chỉ là các tệp SQL thô trong thư mục migrations với một trình chạy đơn giản. Không cái nào bóng bẩy bằng Prisma Migrate.
Schema là nguồn sự thật duy nhất. Tệp schema của Prisma giúp dễ dàng nhìn thấy mô hình dữ liệu của bạn tại một glance và tạo ra các type từ nó. Với raw SQL, bạn đang duy trì các type thủ công hoặc tạo chúng từ schema cơ sở dữ liệu bằng một thứ như pgtyped hoặc zapatos.
Prisma Studio. Một điều nhỏ nhưng đáng để nhắc đến - có một giao diện để duyệt dữ liệu của bạn rất hữu ích trong quá trình phát triển.
Tốc độ hòa nhập (Onboarding). Các nhà phát triển mới tham gia một dự án với raw SQL cần biết SQL. Đây không phải là điều xấu, nhưng đó là một chi phí thực sự.
Khi nào nên giữ Prisma
Prisma là lựa chọn đúng khi:
- Đội ngũ của bạn không thoải mái với SQL
- Bạn đang xây dựng ứng dụng CRUD nơi trình tạo truy vấn của Prisma đáp ứng hơn 90% nhu cầu
- Bạn đang ở giai đoạn đầu và hiệu năng truy vấn chưa phải là nút thắt cổ chai
- Lợi ích năng suất từ trải nghiệm nhà phát triển (DX) lớn hơn chi phí hiệu năng
Nó ngừng là lựa chọn đúng khi:
- Các truy vấn quan trọng nhất của bạn không thể diễn đạt qua trình tạo truy vấn
- Bạn thường xuyên phải thoát ra dùng
$queryRawcho những việc phức tạp hơn tra cứu đơn giản - Thời gian truy vấn chiếm một phần đáng kể trong ngân sách độ trễ (latency budget)
- Bạn cần kiểm soát chi tiết các chỉ mục, gợi ý (hints), hoặc kế hoạch truy vấn
Câu trả lời cho hầu hết các hệ thống sản xuất đã chạy hơn một năm là: sử dụng cả hai. Prisma cho những việc đơn giản, raw SQL cho các truy vấn quan trọng.
Bạn đang sử dụng ORM hoặc cách tiếp cận truy vấn nào trong môi trường sản xuất? Có điều gì đã làm thay đổi suy nghĩ của bạn theo hướng này hay hướng khác không? Hãy chia sẻ trong phần bình luận.
Bài viết liên quan

Phần mềm
Anthropic ra mắt Claude Opus 4.7: Nâng cấp mạnh mẽ cho lập trình nhưng vẫn thua Mythos Preview
16 tháng 4, 2026

Công nghệ
Qwen3.6-35B-A3B: Quyền năng Lập trình Agentic, Nay Đã Mở Cửa Cho Tất Cả
16 tháng 4, 2026

Công nghệ
Spotify thắng kiện 322 triệu USD từ nhóm pirate Anna's Archive nhưng đối mặt với bài toán thu hồi
16 tháng 4, 2026
