架構藍圖:多租戶 SaaS
Pages + Worker API + Access JWT + D1(每一列都帶 tenant_id)+ KV + Durable Objects + Web Analytics —— 串成一套安全的多租戶藍圖。
我們要蓋什麼?
多租戶 SaaS(multi-tenant SaaS)是「一套應用程式同時服務很多獨立客戶」的架構,這些客戶稱為「租戶(tenant)」,大家共用同一份程式碼、同一個資料庫。可以想成一棟辦公大樓:一棟樓、一個櫃台,但每間公司都有自己上鎖的樓層。我們的任務,就是確保「租戶 A」永遠看不到「租戶 B」的資料。
把它想成一棟公寓大樓
大家共用大廳、電梯和水管(共用的基礎設施),但每位住戶手上的鑰匙只能打開自己那一戶(tenant_id)。門口的管理員(Access)在大門口查證件;進到裡面後,鑰匙(tenant_id)決定哪一扇門能打開。
下面這張圖把整個系統濃縮在一頁。使用者打開前端(放在 Pages 上),Cloudflare Access 先確認他是誰,並交給 Worker 一個簽章過的權杖(JWT)。Worker 從權杖裡讀出 tenant_id,檢查該租戶的限流額度、載入該租戶的設定,最後執行一個「只查這一個租戶」的資料庫查詢。
每個角色負責什麼?
藍圖裡每個 Cloudflare 服務都只負責一件清楚的事。把職責切乾淨,系統才好理解、也才安全。
Pages —— 前端
把靜態網頁應用程式(HTML/JS/CSS)放在 Cloudflare 全球邊緣節點上。它用 fetch 呼叫 Worker API,再把結果顯示出來。
Worker —— API
系統的大腦。它執行身分/租戶中介層、強制限流,而且是唯一會跟資料庫對話的角色。前端絕不直接碰 D1。
Access —— 身分驗證
門口管理員。它讓使用者登入(Google、GitHub、email PIN),並交給 Worker 一個簽章過的 JWT,裡面帶著 email、tenant_id 等聲明(claims)。
D1 —— SQL 資料庫
一個大家共用的 SQLite 資料庫。每個屬於租戶的資料表都帶一個 tenant_id 欄位,而且每次查詢都用它來過濾。
KV —— 每租戶設定
一個快速的鍵值(key-value)儲存,放小而常讀的資料:每個租戶的功能旗標、方案上限、快取設定,用 tenant_id 當鍵。
Durable Objects —— 限流
每個租戶配一個有狀態的小實例,用 tenant_id 命名。它負責計算請求數,讓某個流量爆量的租戶不會拖慢其他人。
Web Analytics —— 觀測
為前端提供「隱私優先、不用 cookie」的流量統計,讓你觀察使用情況與效能,又不拖慢頁面。
資料隔離實際上怎麼運作
我們用最簡單、也最常見的做法:共用一個資料庫,每一列都標上自己的 tenant_id。不需要為每個客戶開一個獨立資料庫。隔離來自一條鐵律 —— 每一個查詢都加上 WHERE tenant_id = ?,而且這個值是 Worker 從「已驗證的 JWT」填進去的,絕不採用使用者自己輸入的任何內容。
資料模型
三張表就能說明這個模式:tenants(客戶本身)、users(屬於某個租戶的人)、projects(一個範例資源)。注意:除了 tenants 以外,每張表都帶一個 tenant_id 外鍵(foreign key)—— 正是這個欄位讓隔離成為可能。
為什麼 tenant_id 要當外鍵
把 tenant_id 設成指向 tenants(id) 的外鍵,等於讓資料庫本身拒絕建立「指向不存在租戶」的資料列。這是在應用程式邏輯之上、免費又內建的一層安全網。
一次帶身分的請求,逐步拆解
跟著一次 API 呼叫,從瀏覽器一路走到資料庫再回來。關鍵時刻在中間:Worker 從 JWT 解析出 tenant_id,然後把 SQL 查詢限縮到該租戶。
tenant_id 絕不能信任前端
tenant_id 一定要來自 Access 驗證過的簽章 JWT —— 不能來自網址參數、請求內容、或使用者能控制的標頭。如果讓前端自己挑 tenant_id,任何人都能直接索取別的租戶的資料。
動手做:中介層 + 租戶範圍查詢
下面是整套東西的真實、可執行程式碼:SQL 結構、前端、身分/租戶中介層、每租戶限流器、租戶範圍查詢,以及把全部綁在一起的 wrangler 設定。
1. 建立 D1 結構(到處都有 tenant_id)
每張屬於租戶的表都加上一個帶外鍵的 tenant_id 欄位,再建索引,讓資料變多後範圍查詢依然很快。
-- Every tenant-owned table carries tenant_id. Index it for fast, scoped queries. CREATE TABLE tenants ( id TEXT PRIMARY KEY, name TEXT NOT NULL, plan TEXT NOT NULL DEFAULT 'free' ); CREATE TABLE users ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL REFERENCES tenants(id), email TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member' ); CREATE TABLE projects ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL REFERENCES tenants(id), owner_id TEXT NOT NULL REFERENCES users(id), name TEXT NOT NULL ); -- Index tenant_id so WHERE tenant_id = ? stays fast as rows grow. CREATE INDEX idx_projects_tenant ON projects(tenant_id); CREATE INDEX idx_users_tenant ON users(tenant_id);2. 前端(Pages)呼叫 API
頁面只要用 fetch 呼叫 /api/projects。Access 早已驗證過使用者,所以驗證用的 cookie 會自動帶上 —— 前端根本不會看到、也不會送出 tenant_id。
// public/app.js - served by Cloudflare Pages // Access already verified the user; the auth cookie rides along automatically. async function loadProjects() { const res = await fetch('/api/projects', { headers: { 'Accept': 'application/json' }, credentials: 'include' }); if (!res.ok) { document.getElementById('status').textContent = 'Error ' + res.status; return; } const data = await res.json(); document.getElementById('list').innerHTML = data.projects.map(p => `<li>${p.name}</li>`).join(''); } loadProjects();3. 身分 + 租戶中介層
讀取 Access 已經驗證過的 JWT,從中取出 tenant_id 聲明。租戶只在這一個地方被決定 —— 來自權杖,絕不來自使用者輸入。
// src/index.js - Worker API entry export { RateLimiter } from './rate-limiter.js'; // Auth + tenant middleware: read the JWT Access already verified, // then pull the tenant_id claim out of it. function resolveTenant(request) { const jwt = request.headers.get('Cf-Access-Jwt-Assertion'); if (!jwt) return null; const parts = jwt.split('.'); if (parts.length !== 3) return null; const json = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')); const claims = JSON.parse(json); if (!claims.tenant_id) return null; return { tenantId: claims.tenant_id, email: claims.email }; }4. 每租戶限流器(Durable Object)
每個租戶一個 Durable Object 實例(用 tenant_id 命名),各自維護一個獨立計數器。「每分鐘 100 次」的視窗,代表某個忙碌的租戶永遠用不掉別的租戶的額度。
// src/rate-limiter.js - one Durable Object instance per tenant. export class RateLimiter { constructor(state) { this.state = state; } async fetch() { const now = Date.now(); const windowMs = 60000; // 1-minute window const limit = 100; // 100 requests / minute / tenant let w = (await this.state.storage.get('w')) || { start: now, count: 0 }; if (now - w.start > windowMs) w = { start: now, count: 0 }; w.count += 1; await this.state.storage.put('w', w); const ok = w.count <= limit; return new Response(ok ? 'ok' : 'limited', { status: ok ? 200 : 429 }); } }5. 請求流水線 + 租戶範圍查詢
把全部串起來:解析租戶、檢查限流、讀取它的 KV 設定,然後執行帶 WHERE tenant_id = ? 的 D1 查詢,並把 JWT 來的值綁定(bind)進去。這個綁定就是隔離的保證。
// src/index.js (continued) - the request pipeline export default { async fetch(request, env) { // 1) Who is this, and which tenant do they belong to? const tenant = resolveTenant(request); if (!tenant) return new Response('Unauthorized', { status: 401 }); // 2) Per-tenant rate limit: one Durable Object named after the tenant. const stub = env.RATE_LIMITER.get(env.RATE_LIMITER.idFromName(tenant.tenantId)); const rl = await stub.fetch('https://do/check'); if (rl.status === 429) return new Response('Too Many Requests', { status: 429 }); // 3) Per-tenant config from KV (cheap, cached at the edge). const raw = await env.TENANT_KV.get('cfg:' + tenant.tenantId); const cfg = raw ? JSON.parse(raw) : { maxProjects: 100 }; // 4) Tenant-scoped query: EVERY statement filters WHERE tenant_id = ? const { results } = await env.DB .prepare('SELECT id, name FROM projects WHERE tenant_id = ? ORDER BY name') .bind(tenant.tenantId) .all(); return Response.json({ tenant: tenant.tenantId, config: cfg, projects: results }); } };6. 在 wrangler 設定綁定
宣告 D1、KV 和 Durable Object 的綁定,Worker 才能用 env.DB、env.TENANT_KV、env.RATE_LIMITER 存取它們。migration 則用來註冊 Durable Object 類別。
// wrangler.jsonc { "name": "saas-api", "main": "src/index.js", "compatibility_date": "2025-01-01", "d1_databases": [ { "binding": "DB", "database_name": "saas", "database_id": "<your-d1-id>" } ], "kv_namespaces": [ { "binding": "TENANT_KV", "id": "<your-kv-id>" } ], "durable_objects": { "bindings": [{ "name": "RATE_LIMITER", "class_name": "RateLimiter" }] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["RateLimiter"] } ] }
# create the database, the KV namespace, load the schema, then deploy
npx wrangler d1 create saas
npx wrangler kv namespace create TENANT_KV
npx wrangler d1 execute saas --remote --file=./schema.sql
npx wrangler deploy重點概念
多租戶
一個運作中的應用程式、一個資料庫,服務很多客戶(租戶)。比起為每個客戶開一份獨立副本,營運更便宜、更單純。
資料隔離
保證某個租戶永遠無法讀取或寫入另一個租戶的資料。這裡是靠「永遠用 tenant_id 過濾」來達成。
tenant_id
每一列租戶資料上的欄位,標明它屬於哪個租戶。Worker 從 JWT 設定它 —— 絕不取自前端輸入。
JWT 聲明
JWT 是一個簽章過的權杖;裡面的資料(email、tenant_id)就是它的聲明(claims)。因為由 Access 簽章,Worker 才能信任這些值。
每租戶限流
每個租戶透過專屬的 Durable Object 擁有自己的請求額度,所以單一重度租戶不會拖垮其他人的服務品質。
KV 裡的每租戶設定
每個租戶的小型設定(方案、功能旗標)放在 KV,用 tenant_id 當鍵 —— 在邊緣以微秒讀取,不必往返資料庫。
陷阱與計費
讓安全的路成為唯一的路
把存取 D1 的程式碼包成一個小工具函式,強制每次都要傳入 tenantId,並且自動加上 WHERE tenant_id = ?。只要沒有 tenant 就無法查詢,你就不可能不小心漏掉過濾條件。
- 只要有一個查詢漏掉 WHERE tenant_id = ?,就會跨租戶外洩資料 —— 每一條 SQL 都要檢查。
- 一律用 .bind() 把 tenant_id 當參數綁定;絕不要用字串拼接塞進 SQL(那會招來 SQL 注入)。
- 每張租戶表都要替 tenant_id 建索引,否則租戶累積資料後,範圍查詢會變慢。
- tenant_id 只信任來自驗證過的 JWT,不要採用網址、請求內容或前端可控的標頭。
- 每個限流 Durable Object 都用 tenant_id 命名,計數器才不會在租戶之間互相污染。
- D1、KV、Durable Objects、Workers 都有大方的免費額度;規模變大後依請求/讀/寫計費 —— 最新價格請查官方文件。