seansie's blog

超越 ORM 的邊界:從 Prisma 邁向高階 Raw SQL 與效能調校實戰

在現代後端開發中,Prisma 等現代物件關聯對應工具(ORM)憑藉著強大的型別安全、直觀的資料建模(Schema-driven)與自動生成關聯查詢的能力,大幅提升了團隊的開發吞吐量。然而,任何抽象層都有其效能天花板與表達邊界。當資料規模突破數百萬級距、業務邏輯涉及多維聚合、複雜報表或高頻並發寫入時,過度依賴 ORM 的高階抽象往往會引發嚴重的「物件關聯阻抗不匹配(Object-Relational Impedance Mismatch)」以及不可預期的資料庫效能損耗。

務實的架構思維並非全盤否定 ORM,而是清晰界定其能力邊界。理解何時該果斷退回 Raw SQL,並具備精準調校 SQL 與執行計畫的能力,是每位後端工程師邁向進階的必經之路。


一、 何時該跨出 ORM 的舒適圈?

Prisma 提供了優雅的 Client API(如 findManyinclude),但在以下四種情境中,手寫 Raw SQL 通常是更好的技術決策:

1. ORM 抽象導致的 N+1 與關聯載入放大

Prisma 在處理跨表關聯時,預設透過多個平行或批次查詢(Separate Queries)在應用程式記憶體中進行組裝,或是生成包含大量子查詢的龐大 SQL。當需要深度關聯查詢(如 4 層以上的樹狀或關聯資料)時,應用層與資料庫之間的網路來回(Round-trip Time, RTT)與記憶體解構成本會急遽上升。

2. 複雜的多維度聚合與視窗函式(Window Functions)

Prisma 的 groupByaggregate 僅支援基本的 COUNTSUMAVGMINMAX。一旦需要涉及時序分析、排名運算(如 ROW_NUMBER()DENSE_RANK())、滑動視窗累加(OVER (PARTITION BY ... ORDER BY ...))或樞紐轉換,ORM API 往往無能為力。

3. 大規模批次更新與資料清洗(Bulk Upsert / Conditional Updates)

Prisma 的 updateMany 不支援依據每筆記錄的動態值進行條件更新。若透過迴圈搭配 prisma.user.update(),會產生數千次資料庫連線請求;改用手寫 SQL 搭配暫存表、UNNESTINSERT ... ON CONFLICT DO UPDATE(PostgreSQL),可在單一 Transaction 內完成萬級資料更新。

4. 資料庫特有原生功能(Database-Specific Features)

當需要充分利用資料庫的底層硬體特性或專屬延伸模組時,例如 PostgreSQL 的全文檢索(tsvector、GIN 索引)、地理空間運算(PostGIS)、遞迴查詢(Recursive CTE)或複雜的 JSONB 路徑運算(jsonb_path_query),Raw SQL 是唯一的選擇。


二、 在 Prisma 中撰寫 Raw SQL 的核心技巧

在 Prisma 專案中編寫 Raw SQL,必須同時兼顧防範 SQL Injection保留 TypeScript 型別安全動態語句拼接的彈性

1. 嚴格區分 $queryRaw$queryRawUnsafe

  • $queryRaw(推薦): 採用 ES6 標籤樣板字串(Tagged Template Literals),Prisma 會自動將插值轉換為參數化查詢(Parameterized Query / Prepared Statement),徹底防範 SQL Injection。
  • $queryRawUnsafe(謹慎): 接受純字串,僅適用於表名、欄位名動態代入,或語句結構本身完全由程式碼控制且無使用者輸入的情境。
import { PrismaClient, Prisma } from '@prisma/client';

const prisma = new PrismaClient();

// 正確做法:自動參數化查詢
async function getActiveUsers(minScore: number, search: string) {
  const users = await prisma.$queryRaw<Array<{ id: string; email: string }>>`
    SELECT id, email 
    FROM "User" 
    WHERE status = 'ACTIVE' 
      AND score >= ${minScore}
      AND email ILIKE ${`%${search}%`}
  `;
  return users;
}

2. 使用 Prisma.sql 實現安全的動態條件拼接

在複雜的過濾條件篩選器(Filter Query)中,字串串接容易造成安全漏洞或語法破裂。使用 Prisma.sqlPrisma.join 可以動態組合子語句,同時保持參數化特性。

async function searchOrders(filters: {
  userId?: string;
  status?: string;
  minAmount?: number;
}) {
  const conditions: Prisma.Sql[] = [];

  if (filters.userId) {
    conditions.push(Prisma.sql`"userId" = ${filters.userId}`);
  }
  if (filters.status) {
    conditions.push(Prisma.sql`"status" = ${filters.status}`);
  }
  if (filters.minAmount !== undefined) {
    conditions.push(Prisma.sql`"totalAmount" >= ${filters.minAmount}`);
  }

  const whereClause = conditions.length > 0
    ? Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}`
    : Prisma.empty;

  return await prisma.$queryRaw<Array<{ id: string; totalAmount: number }>>`
    SELECT id, "totalAmount"
    FROM "Order"
    ${whereClause}
    ORDER BY "createdAt" DESC
    LIMIT 20
  `;
}

3. 處理 Prisma 型別轉換與 BigInt 序列化

$queryRaw 返回的結果中,PostgreSQL 的 BIGINT 會映射為 JavaScript 原生 BigInt 型別,這會導致 JSON.stringify() 拋出 TypeError: Do not know how to serialize a BigInt

解決方案:

  • 在 SQL 層進行顯式轉型:SELECT count(*)::INT AS total ...
  • 或在應用層全域定義序列化規則:
(BigInt.prototype as any).toJSON = function () {
  return Number(this);
};

三、 常見 SQL 效能瓶頸與優化範例

範例 1:深度分頁效能優化(Keyset Pagination vs. Offset/Limit)

  • 問題背景: ORM 常見的 skip: 50000, take: 20OFFSET 50000 LIMIT 20)會導致資料庫讀取前 50,020 筆資料後丟棄前 50,000 筆,引發嚴重的 Disk I/O 與 CPU 消耗。
  • 優化方案: 改用「尋標分頁(Keyset / Cursor Pagination)」。
-- 原始 ORM 常見模式 (低效)
SELECT id, "createdAt", title 
FROM "Post" 
ORDER BY "createdAt" DESC 
OFFSET 50000 LIMIT 20;

-- 優化後的 Raw SQL (高效:直接利用索引走 Range Scan)
SELECT id, "createdAt", title 
FROM "Post" 
WHERE "createdAt" < '2026-08-01T10:00:00Z' -- 上一頁最後一筆記錄的時間戳
ORDER BY "createdAt" DESC 
LIMIT 20;

範例 2:分組取最新 N 筆記錄(Top-N Per Group)

  • 問題背景: 取得「每個分類下最新發布的 3 篇文章」。使用 ORM 往往需要先查詢所有分類,再針對每個分類發送查詢(引發 $N$ 次查詢),或拉取全量資料在 Node.js 記憶體中排序過濾。
  • 優化方案: 使用視窗函式(Window Function)在資料庫核心內完成運算。
WITH RankedPosts AS (
  SELECT 
    p.id, 
    p.title, 
    p."categoryId", 
    p."createdAt",
    ROW_NUMBER() OVER (
      PARTITION BY p."categoryId" 
      ORDER BY p."createdAt" DESC
    ) AS rank
  FROM "Post" p
  WHERE p."isPublished" = true
)
SELECT id, title, "categoryId", "createdAt"
FROM RankedPosts
WHERE rank <= 3;

範例 3:大批量寫入與衝突處理(Bulk Upsert)

  • 問題背景: 同步 5,000 筆商品庫存。ORM 的 upsert API 只能單筆執行,造成 5,000 次網路往返。
  • 優化方案: 透過 PostgreSQL 的 UNNEST 搭配 INSERT ... ON CONFLICT,一次網路來回完成。
async function bulkUpsertProducts(products: Array<{ id: string; price: number; stock: number }>) {
  const ids = products.map(p => p.id);
  const prices = products.map(p => p.price);
  const stocks = products.map(p => p.stock);

  await prisma.$executeRaw`
    INSERT INTO "Product" (id, price, stock, "updatedAt")
    SELECT 
      u.id, 
      u.price, 
      u.stock, 
      NOW()
    FROM UNNEST(
      ${ids}::text[], 
      ${prices}::numeric[], 
      ${stocks}::int[]
    ) AS u(id, price, stock)
    ON CONFLICT (id) DO UPDATE SET
      price = EXCLUDED.price,
      stock = EXCLUDED.stock,
      "updatedAt" = NOW();
  `;
}

四、 效能分析與觀測實務(Observability & Profiling)

撰寫 Raw SQL 時,不能僅憑直覺評估效能,必須以資料庫底層的指標與執行計畫為依據。

1. 掌握 EXPLAIN (ANALYZE, BUFFERS)

在 PostgreSQL 中,評估 SQL 效能最權威的工具是 EXPLAIN (ANALYZE, BUFFERS)

EXPLAIN (ANALYZE, BUFFERS, VERBOSE, COSTS)
SELECT p.id, p.title 
FROM "Post" p
JOIN "User" u ON p."authorId" = u.id
WHERE u.email = 'engineer@example.com';

解讀執行計畫的核心重點:

  • Scan 模式:

  • Seq Scan(全表掃描):大表應盡量避免,通常代表缺乏有效索引或查詢條件未命中索引。

  • Index Scan / Bitmap Index Scan:透過索引檢索,效率較高。

  • Index Only Scan:最高效,查詢所需的欄位均包含在索引內(Covering Index),無需回表(Heap Fetch)。

  • Buffers 統計:

  • shared hit:從記憶體緩衝區(Buffer Cache)命中的資料頁。

  • shared read:從實體磁碟讀取的資料頁。若此數值過高,代表需要調優索引或擴增 Shared Buffers。

  • Cost 與 Time 偏差: 比較 actual timecost,若規劃器預估的列數(rows)與實際返回差距巨大,代表資料庫統計資訊過期,需執行 ANALYZE <table>


2. 啟用 Prisma Query Logging

在開發與測試環境中,應啟用 Prisma 的底層查詢監聽,捕捉 ORM 實際生成的 SQL 語句與執行耗時:

const prisma = new PrismaClient({
  log: [
    { emit: 'event', level: 'query' },
    { emit: 'stdout', level: 'warn' },
    { emit: 'stdout', level: 'error' },
  ],
});

prisma.$on('query', (e) => {
  if (e.duration >= 100) { // 標記超過 100ms 的慢查詢
    console.warn(`[Slow Query] Duration: ${e.duration}ms | Query: ${e.query} | Params: ${e.params}`);
  }
});

結語:架構選擇的務實平衡

在軟體工程中,沒有單一的銀彈。完全摒棄 ORM 走回純手寫 SQL 會犧牲開發效率與維護性;但過度依賴 ORM 又容易在系統擴展時埋下效能隱患。

最佳實踐架構策略:

  1. 80% 的常規 CRUD: 繼續使用 Prisma Client,享受型別安全、自動遷移與簡潔的語法。
  2. 20% 的高效能核心、複雜報表與批次作業: 透過 $queryRaw 與適當的 SQL 技巧,精確控制執行計畫與資源消耗。

掌握 ORM 封裝的便利,同時保留直面底層資料庫的能力,才能在兼顧交付速度的同時,建構出具備彈性與高負載韌性的後端架構。