sell整合實戰
apartment

架構藍圖:多租戶 SaaS

Pages + Worker API + Access JWT + D1(每一列都帶 tenant_id)+ KV + Durable Objects + Web Analytics —— 串成一套安全的多租戶藍圖。

1 DB一個資料庫、多個租戶
tenant_id每一列都有
7串接的 Cloudflare 服務
100/min每租戶限流
insights

我們要蓋什麼?

多租戶 SaaS(multi-tenant SaaS)是「一套應用程式同時服務很多獨立客戶」的架構,這些客戶稱為「租戶(tenant)」,大家共用同一份程式碼、同一個資料庫。可以想成一棟辦公大樓:一棟樓、一個櫃台,但每間公司都有自己上鎖的樓層。我們的任務,就是確保「租戶 A」永遠看不到「租戶 B」的資料。

apartment

把它想成一棟公寓大樓

大家共用大廳、電梯和水管(共用的基礎設施),但每位住戶手上的鑰匙只能打開自己那一戶(tenant_id)。門口的管理員(Access)在大門口查證件;進到裡面後,鑰匙(tenant_id)決定哪一扇門能打開。

下面這張圖把整個系統濃縮在一頁。使用者打開前端(放在 Pages 上),Cloudflare Access 先確認他是誰,並交給 Worker 一個簽章過的權杖(JWT)。Worker 從權杖裡讀出 tenant_id,檢查該租戶的限流額度、載入該租戶的設定,最後執行一個「只查這一個租戶」的資料庫查詢。

schema系統架構

瀏覽器 (Pages)

Access:驗證 JWT

Worker API

中介層:解析 tenant_id

Durable Object:每租戶限流

KV:每租戶設定

D1 查詢:WHERE tenant_id = ?

租戶隔離的資料

Web Analytics

account_tree

每個角色負責什麼?

藍圖裡每個 Cloudflare 服務都只負責一件清楚的事。把職責切乾淨,系統才好理解、也才安全。

web

Pages —— 前端

把靜態網頁應用程式(HTML/JS/CSS)放在 Cloudflare 全球邊緣節點上。它用 fetch 呼叫 Worker API,再把結果顯示出來。

dns

Worker —— API

系統的大腦。它執行身分/租戶中介層、強制限流,而且是唯一會跟資料庫對話的角色。前端絕不直接碰 D1。

badge

Access —— 身分驗證

門口管理員。它讓使用者登入(Google、GitHub、email PIN),並交給 Worker 一個簽章過的 JWT,裡面帶著 email、tenant_id 等聲明(claims)。

database

D1 —— SQL 資料庫

一個大家共用的 SQLite 資料庫。每個屬於租戶的資料表都帶一個 tenant_id 欄位,而且每次查詢都用它來過濾。

bolt

KV —— 每租戶設定

一個快速的鍵值(key-value)儲存,放小而常讀的資料:每個租戶的功能旗標、方案上限、快取設定,用 tenant_id 當鍵。

speed

Durable Objects —— 限流

每個租戶配一個有狀態的小實例,用 tenant_id 命名。它負責計算請求數,讓某個流量爆量的租戶不會拖慢其他人。

monitoring

Web Analytics —— 觀測

為前端提供「隱私優先、不用 cookie」的流量統計,讓你觀察使用情況與效能,又不拖慢頁面。

資料隔離實際上怎麼運作

我們用最簡單、也最常見的做法:共用一個資料庫,每一列都標上自己的 tenant_id。不需要為每個客戶開一個獨立資料庫。隔離來自一條鐵律 —— 每一個查詢都加上 WHERE tenant_id = ?,而且這個值是 Worker 從「已驗證的 JWT」填進去的,絕不採用使用者自己輸入的任何內容。

schema共用資料表、隔離資料列

tenant_id = A

tenant_id = B

已過濾

已過濾

租戶 A

共用的 D1 資料表

租戶 B

A 只看到 A 的資料列

B 只看到 B 的資料列

schema

資料模型

三張表就能說明這個模式:tenants(客戶本身)、users(屬於某個租戶的人)、projects(一個範例資源)。注意:除了 tenants 以外,每張表都帶一個 tenant_id 外鍵(foreign key)—— 正是這個欄位讓隔離成為可能。

schema實體關聯圖

擁有

擁有

建立

TENANTS

text

id

PK

text

name

text

plan

USERS

text

id

PK

text

tenant_id

FK

text

email

text

role

PROJECTS

text

id

PK

text

tenant_id

FK

text

owner_id

FK

text

name

key

為什麼 tenant_id 要當外鍵

把 tenant_id 設成指向 tenants(id) 的外鍵,等於讓資料庫本身拒絕建立「指向不存在租戶」的資料列。這是在應用程式邏輯之上、免費又內建的一層安全網。

swap_vert

一次帶身分的請求,逐步拆解

跟著一次 API 呼叫,從瀏覽器一路走到資料庫再回來。關鍵時刻在中間:Worker 從 JWT 解析出 tenant_id,然後把 SQL 查詢限縮到該租戶。

schema帶身分的請求流程
"D1""KV""Durable Object""Worker API""Access""瀏覽器""D1""KV""Durable Object""Worker API""Access""瀏覽器"請求 + JWT已驗證的 JWT解析 tenant_id檢查租戶限流放行載入租戶設定設定SELECT WHERE tenant_id = ?只回該租戶的資料列JSON
gpp_maybe

tenant_id 絕不能信任前端

tenant_id 一定要來自 Access 驗證過的簽章 JWT —— 不能來自網址參數、請求內容、或使用者能控制的標頭。如果讓前端自己挑 tenant_id,任何人都能直接索取別的租戶的資料。

construction

動手做:中介層 + 租戶範圍查詢

下面是整套東西的真實、可執行程式碼:SQL 結構、前端、身分/租戶中介層、每租戶限流器、租戶範圍查詢,以及把全部綁在一起的 wrangler 設定。

  1. 1. 建立 D1 結構(到處都有 tenant_id)

    每張屬於租戶的表都加上一個帶外鍵的 tenant_id 欄位,再建索引,讓資料變多後範圍查詢依然很快。

    sql
    -- 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. 2. 前端(Pages)呼叫 API

    頁面只要用 fetch 呼叫 /api/projects。Access 早已驗證過使用者,所以驗證用的 cookie 會自動帶上 —— 前端根本不會看到、也不會送出 tenant_id。

    js
    // 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. 3. 身分 + 租戶中介層

    讀取 Access 已經驗證過的 JWT,從中取出 tenant_id 聲明。租戶只在這一個地方被決定 —— 來自權杖,絕不來自使用者輸入。

    js
    // 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. 4. 每租戶限流器(Durable Object)

    每個租戶一個 Durable Object 實例(用 tenant_id 命名),各自維護一個獨立計數器。「每分鐘 100 次」的視窗,代表某個忙碌的租戶永遠用不掉別的租戶的額度。

    js
    // 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. 5. 請求流水線 + 租戶範圍查詢

    把全部串起來:解析租戶、檢查限流、讀取它的 KV 設定,然後執行帶 WHERE tenant_id = ? 的 D1 查詢,並把 JWT 來的值綁定(bind)進去。這個綁定就是隔離的保證。

    js
    // 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. 6. 在 wrangler 設定綁定

    宣告 D1、KV 和 Durable Object 的綁定,Worker 才能用 env.DB、env.TENANT_KV、env.RATE_LIMITER 存取它們。migration 則用來註冊 Durable Object 類別。

    jsonc
    // 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"] }
      ]
    }
bash建立資源並部署
# 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
school

重點概念

groups

多租戶

一個運作中的應用程式、一個資料庫,服務很多客戶(租戶)。比起為每個客戶開一份獨立副本,營運更便宜、更單純。

shield_lock

資料隔離

保證某個租戶永遠無法讀取或寫入另一個租戶的資料。這裡是靠「永遠用 tenant_id 過濾」來達成。

fingerprint

tenant_id

每一列租戶資料上的欄位,標明它屬於哪個租戶。Worker 從 JWT 設定它 —— 絕不取自前端輸入。

verified_user

JWT 聲明

JWT 是一個簽章過的權杖;裡面的資料(email、tenant_id)就是它的聲明(claims)。因為由 Access 簽章,Worker 才能信任這些值。

speed

每租戶限流

每個租戶透過專屬的 Durable Object 擁有自己的請求額度,所以單一重度租戶不會拖垮其他人的服務品質。

bolt

KV 裡的每租戶設定

每個租戶的小型設定(方案、功能旗標)放在 KV,用 tenant_id 當鍵 —— 在邊緣以微秒讀取,不必往返資料庫。

tips_and_updates

陷阱與計費

checklist

讓安全的路成為唯一的路

把存取 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 都有大方的免費額度;規模變大後依請求/讀/寫計費 —— 最新價格請查官方文件。