beio Logobeio

【Cloudflare Workers 全端架構師之路 04】數據篇:D1 資料庫設計模式與效能優化

發布於

01. 前言:為什麼 Edge 需要 SQLite?

在傳統 Serverless (AWS Lambda) 開發中,連接關聯式資料庫 (MySQL/PostgreSQL) 一直是個惡夢。 因為 Lambda 是無狀態且會隨流量暴增 (Scale Out) 的,當 1000 個請求同時進來,會瞬間建立 1000 個資料庫連線,直接把 RDS 打掛。你必須額外花錢買 RDS Proxy 來解決這個問題。

Cloudflare D1 的出現解決了這個痛點。

D1 是建立在 SQLite 之上的 Serverless 資料庫。 它沒有傳統的 TCP 連線問題,因為它是透過 HTTP/RPC 協議與 Workers 通訊的。它天生就是為了高併發、短壽命的 Workers 環境設計的。

架構師觀點: 不要小看 SQLite。它不僅是手機裡的資料庫,在 D1 的架構下,它具備了自動備份時間點恢復 (PITR) 以及讀取複寫 (Read Replication) 的企業級能力。

02. 架構比較:D1 vs. AWS RDS

特性Cloudflare D1AWS RDS (PostgreSQL/MySQL)
底層引擎SQLitePostgreSQL / MySQL
連線管理無須管理 (HTTP/RPC)需管理 Connection Pool (或使用 RDS Proxy)
部署位置全球邊緣 (Primary 在特定區域,Replicas 自動散佈)指定區域 (VPC 內)
冷啟動延遲極低需等待 TCP Handshake 與驗證
計費模式讀寫列數 (Row reads/writes) + 儲存量實例小時費 (Instance hour) + 流量費

03. 環境建置:D1 + Drizzle ORM

為什麼選擇 Drizzle ORM? 相比於 Prisma (引擎肥大、冷啟動慢),Drizzle 幾乎是 Zero-runtime overhead,且產生的 SQL 非常乾淨。它是目前 Serverless 環境的標準配備。

步驟 1: 建立資料庫

npx wrangler d1 create my-blog-db

將輸出的 ID 填入 wrangler.toml

[[d1_databases]]
binding = "DB"
database_name = "my-blog-db"
database_id = "你的_DATABASE_ID"

步驟 2: 安裝套件

npm install drizzle-orm
npm install -D drizzle-kit

04. 實作:關聯式 Schema 設計

我們來設計一個典型的部落格系統:使用者 (Users)文章 (Posts)評論 (Comments)

建立 src/schema.ts

import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { relations } from 'drizzle-orm';

// 1. Users Table
export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
});

// 2. Posts Table
export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  authorId: integer('author_id').notNull().references(() => users.id), // Foreign Key
  title: text('title').notNull(),
  content: text('content').notNull(),
  published: integer('published', { mode: 'boolean' }).default(false),
});

// 3. 定義關聯 (Application Level)
// 這讓 Drizzle 知道如何執行 query.users.findMany({ with: { posts: true } })
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
}));

步驟 3: 執行 Migration

建立 drizzle.config.ts 並執行:

npx drizzle-kit generate
npx wrangler d1 migrations apply my-blog-db --local # 本地測試

05. 深度實作:JOIN 查詢與 Transaction

在 API 開發中,最考驗效能的就是 N+1 問題與資料一致性。讓我們看看如何在 Worker 中優雅地處理。

修改 src/index.ts

import { Hono } from 'hono'
import { drizzle } from 'drizzle-orm/d1'
import { users, posts } from './schema'
import * as schema from './schema'
import { eq } from 'drizzle-orm'

type Bindings = { DB: D1Database }
const app = new Hono<{ Bindings: Bindings }>()

// GET: 取得使用者及其所有文章 (解決 N+1 問題)
app.get('/users/:id/posts', async (c) => {
  const db = drizzle(c.env.DB, { schema }); // 注入 schema 以啟用關聯查詢
  const id = Number(c.req.param('id'));

  // Drizzle 的 query builder 會自動優化成單條 SQL 或高效的 JOIN
  const result = await db.query.users.findFirst({
    where: eq(users.id, id),
    with: {
      posts: {
        // 只撈出已發布的文章
        where: eq(posts.published, true),
        columns: { content: false } // 優化:不撈取大欄位
      }
    }
  });

  if (!result) return c.json({ error: 'User not found' }, 404);
  return c.json(result);
})

// POST: 批量發布文章 (Transaction 範例)
app.post('/posts/batch', async (c) => {
  const db = drizzle(c.env.DB);
  const body = await c.req.json();
  const { authorId, articles } = body;

  try {
    // 交易保證:要嘛全成功,要嘛全失敗
    const result = await db.transaction(async (tx) => {
      const insertedPosts = [];
      
      for (const article of articles) {
        const res = await tx.insert(posts).values({
          authorId,
          title: article.title,
          content: article.content,
          published: true
        }).returning({ id: posts.id });
        
        insertedPosts.push(res[0]);
      }
      
      return insertedPosts;
    });

    return c.json({ success: true, data: result });
  } catch (e) {
    return c.json({ error: 'Transaction failed', details: String(e) }, 500);
  }
})

export default app

06. 效能優化與注意事項

  1. 索引 (Indexes): D1 是 SQLite,所以索引至關重要。記得在 schema.ts 中對常查詢的欄位 (如 email, author_id) 加上索引。

    import { index } from 'drizzle-orm/sqlite-core';
    // 在 table 定義最後加上
    // (example) }, (table) => ({ emailIdx: index('email_idx').on(table.email) }));
    
  2. 併發寫入限制: D1 雖然解決了連線問題,但 SQLite 本質上是 單一寫入者 (Single Writer)

    • 讀取 (Read):可以無限水平擴展 (透過 Read Replicas)。
    • 寫入 (Write):會被序列化 (Serialized)。
    • 對策:如果你的應用是「超高頻寫入」(如 IoT Sensor log),D1 可能會遇到瓶頸。這時候應該改用 Workers Analytics Engine 或透過 Queues 進行批次寫入 (Batch Write)。

07. 小結與下一步

我們成功構建了一個具備 關聯查詢交易保護 的 Edge 資料庫層。 D1 讓我們不再需要煩惱 VPC、Connection Pool 或是昂貴的 RDS 帳單,同時保持了 SQL 的強大功能。

現在資料有了,但如果使用者要上傳頭像或文章配圖呢?把圖片存進 D1 是絕對錯誤的做法 (BLOB 會拖慢資料庫)。

在下一篇 Part 5: 儲存篇,我們將引入 R2 Storage。 這不僅僅是 S3 的替代品,重點在於它的 零出口流量費 (Zero Egress Fee)。我們將教你如何省下大筆 AWS 帳單,並實作安全的 Presigned URL 上傳機制。


Ken Huang

關於作者

Ken Huang

熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。

最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。

這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。

Android APP DevelopmentiOS APP DevelopmentBePTT CreatorFull Stack Learner