Tại sao tôi từ bỏ Prisma để dùng Raw SQL (và hiệu năng tăng gấp 10 lần)

06 tháng 4, 2026·9 phút đọc

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.

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 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_atupdated_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ấnp50p95p99
Prisma findMany với include45ms120ms310ms
4 truy vấn pg riêng biệt18ms40ms95ms
Một truy vấn JOIN duy nhất6ms14ms28ms

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 $queryRaw cho 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 được tổng hợp và biên soạn bằng AI từ các nguồn tin tức công nghệ. Nội dung mang tính tham khảo. Xem bài gốc ↗