D1 Sessions API 與讀取複本:全球低延遲讀取 | Cloudflare 完整教學

2026/08/21
D1 Sessions API 與讀取複本:全球低延遲讀取 | Cloudflare 完整教學

上一篇《D1 Migrations》我們學會了像 Git 一樣安全地演進資料表結構。但當你的應用擴張到全球、讀取流量湧入,新的問題浮現:主要資料庫(Primary)在美國西部,新加坡的使用者每次讀取都要繞半個地球,延遲高得惱人。D1讀取複本(Read Replication) 讓你在全球六個地區部署唯讀副本,使用者就近讀取;而 Sessions API 則是啟用複本的鑰匙,並透過 書籤(Bookmark) 機制,在「低延遲」與「一致性」這對天生矛盾之間取得平衡。這篇我們把 env.DB.withSession()first-primary / first-unconstrained 兩種模式、read-your-writes(讀取自身寫入)、書籤的跨請求傳遞,以及成本與一致性的權衡一次講透。

前言

讀取複本(Read Replication)D1(付費方案)提供的全球擴展機制:把主要資料庫的資料非同步複製到全球六個地區的唯讀副本,讓各地使用者的讀取請求可以由最近的複本服務,而不必每次都繞回主要資料庫。而 Sessions API 是使用讀取複本的必要開關——透過 env.DB.withSession() 建立一個「會話」,並用 書籤(Bookmark) 維持這個會話內的循序一致性(Sequential Consistency)

打個比方:想像一間總部設在美國的圖書館(主要資料庫 Primary),它是所有藏書「唯一能被修改」的正本。過去無論你人在哪裡,要借書都得飛到美國總部——這就是沒有複本的 D1,每次讀取都繞回 primary。現在圖書館在全球六個城市開了分館(讀取複本),分館只能借閱、不能修改,總部每次進了新書就陸續把副本寄到各分館(非同步複製)。你在新加坡的分館就近借書,又快又省事——但要注意:總部剛入庫的新書,可能還沒寄到你這間分館(複本延遲 Replica Lag)。而 書籤 就像一張「我要求至少看到 X 月 X 日之後入庫的書」的字條:你遞給分館,分館就會確保給你的資料不比那個時間點舊。

這篇聚焦 D1 的 Sessions API 與讀取複本,承接上一篇的 schema 演進,把「讀取如何在全球低延遲又不失一致性」這件事講透。讀完你會掌握:

  • 為何需要讀取複本——單一寫入者(Single-Writer)架構下,讀取為何是全球擴展的瓶頸、複本如何非同步降低讀取延遲、什麼是複本延遲(Replica Lag)
  • Sessions API 的兩種模式——env.DB.withSession('first-unconstrained') 換低延遲、first-primary 換最新資料,以及各自的適用場景
  • 書籤與 read-your-writes——getBookmark() 如何標記狀態、跨 HTTP 請求傳遞書籤,確保使用者讀得到自己剛寫入的資料
  • 可觀測性與權衡——用 served_by_region / served_by_primary 看查詢被誰服務,以及低延遲、成本、一致性之間的取捨準則

核心概念

為什麼需要讀取複本?單一寫入者架構的讀取瓶頸

D1 採用 單一寫入者(Single-Writer) 架構:整個資料庫只有一份主要資料庫(Primary)能接受寫入,所有 INSERTUPDATEDELETE 都必須路由到它。這個設計簡化了一致性,但也埋下一個問題——主要資料庫只在一個地理位置

假設你的主要資料庫在美西(WNAM),而使用者遍布全球:

                    ┌──────────────────────────┐
新加坡使用者 ──30ms+──▶│  主要資料庫 Primary(WNAM) │◀──30ms+── 東京使用者
                    └──────────────────────────┘
     每次讀取都要繞回美西 → 高延遲、體驗差

寫入無可避免要繞回 primary(這是單一寫入者的宿命),但讀取其實不必——大多數應用是讀多寫少的(部落格、目錄、內容站),如果能讓讀取就近完成,體驗會大幅改善。這正是讀取複本的用武之地:

                    ┌──────────────────────────┐
                    │  主要資料庫 Primary(WNAM) │
                    └────────────┬─────────────┘
                     非同步複製(Async Replication)
        ┌──────────────┬─────────┼─────────┬──────────────┐
        ▼              ▼         ▼         ▼              ▼
   ENAM 複本      WEUR 複本   APAC 複本  EEUR 複本      OC 複本
   (美東)         (西歐)     (亞太)    (東歐)        (大洋洲)

   新加坡使用者 ──就近讀取──▶ APAC 複本(低延遲!)

D1 的讀取複本部署在全球六個地區:ENAM(美東)、WNAM(美西)、WEUR(西歐)、EEUR(東歐)、APAC(亞太)、OC(大洋洲)。主要資料庫寫入後,會非同步把變更複製到所有複本。複本本身不額外計費(不多算儲存或運算費用),讀取由複本服務時,延遲從跨洲的數十毫秒降到就近的個位數毫秒。

天生的權衡:複本延遲(Replica Lag)與一致性

複本的低延遲不是免費午餐,代價是 複本延遲(Replica Lag):因為複製是非同步的,主要資料庫寫入後,變更需要一段時間(通常在 100ms 到 2s 之間)才會傳播到各複本。這段空窗期內,複本上的資料是舊的

這帶來一個經典難題:

  • 使用者 A 在主要資料庫更新了個人資料(寫入走 primary)。
  • 使用者 A 立刻重新整理頁面(讀取),請求被路由到一個還沒複製到最新的複本。
  • 使用者 A 看到「改了又變回舊的」——這就是違反了 讀取自身寫入(Read-Your-Writes) 一致性。

要在「讀取要快(用複本)」和「讀取要正確(不讀到過期資料)」之間取得平衡,就需要 Sessions API 提供的一致性保證。

Sessions API:讀取複本的必要開關與一致性保證

這是最關鍵的一點:Sessions API 是使用讀取複本的必要條件。 若你不透過 Sessions API、而是直接呼叫 env.DB.prepare(...),所有查詢(含讀取)都只會路由到主要資料庫,複本形同虛設。只有透過 env.DB.withSession() 建立的 會話(Session),D1 才會把讀取路由到最近的複本。

Sessions API 透過**書籤(Bookmark)**機制,在一個 session 內提供 循序一致性(Sequential Consistency),具體保證三件事:

一致性保證意義
讀取自身寫入(Read-Your-Writes)在同一 session 內,寫入之後的讀取一定看得到自己剛才的寫入
單調讀取(Monotonic Reads)後續讀取看到的資料不會少於先前讀取(不會「時光倒流」讀到更舊的狀態)
單調寫入(Monotonic Writes)寫入的順序在所有複本上保持一致

換句話說,session 就是把「一連串本該有先後關係的查詢」綁在一起,確保它們在分散的複本世界裡,看起來仍像在單一資料庫上依序執行。

withSession() 的兩種模式與書籤

env.DB.withSession() 接受一個參數,決定這個 session 的一致性起點(也就是「第一個查詢」路由到哪裡):

傳入值第一個查詢路由到特性適用場景
'first-unconstrained'(預設)任意最近的實例(通常是複本)最低延遲,但可能讀到有 lag 的複本部落格、商品目錄、公開內容等最終一致性可接受的讀取
'first-primary'主要資料庫(Primary)確保從最新狀態出發,延遲較高即時庫存、餘額查詢,或後續要據此寫入的場景
書籤字串(bookmark)保證至少看到書籤代表的狀態跨請求維持一致性,實現 read-your-writes使用者要讀到自己剛寫入的資料

前兩者(first-unconstrained / first-primary)只影響單一 session 的起點;而書籤是跨越多個 HTTP 請求維持一致性的關鍵——這是三者最大的差異,也是 Sessions API 最強大的地方。

關鍵術語速覽

術語一句話定義
Read Replication(讀取複本)把主要資料庫非同步複製到全球六地的唯讀副本,讓讀取就近完成
Primary(主要資料庫)唯一能接受寫入的正本,所有寫入都路由到它
Replica Lag(複本延遲)非同步複製造成的資料落差,通常 100ms–2s
Session(會話)withSession() 建立,把一連串查詢綁起來維持一致性
Bookmark(書籤)代表資料庫某時間點狀態的可排序字串,用來要求一致性下限
Read-Your-Writes讀取自身寫入,保證讀得到自己剛寫的資料

實作範例

我們用一組可執行的 TypeScript,把 Sessions API 從讀取、寫入到跨請求書籤傳遞走一遍。假設你已按前面文章建好 D1、綁定為 env.DB,並在 Dashboard 或 API 啟用了讀取複本。

1. 啟用讀取複本(前置設定)

讀取複本需在資料庫層級啟用,可透過 Dashboard(D1 → 選資料庫 → Settings → 啟用 Read Replication),或用 REST API:

# 用 REST API 啟用讀取複本(mode: auto 讓 D1 自動管理複本)
curl -X PUT \
  "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/d1/database/${DATABASE_ID}" \
  -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"read_replication": {"mode": "auto"}}'

啟用後,接下來的重點全在 Worker 程式碼裡——因為複本只有透過 Sessions API 才會被使用。

2. 最基本的讀取 session:換取低延遲

純讀取、且能容忍些微延遲的場景(例如列出公開使用者),用 first-unconstrained(預設)讓查詢就近落在複本上:

import type { D1Database } from "@cloudflare/workers-types";

interface Env {
  DB: D1Database;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // 建立一個「不受約束」的 session:第一個查詢路由到最近的實例(通常是複本)
    const session = env.DB.withSession("first-unconstrained");

    const { results, meta } = await session
      .prepare("SELECT id, name, email FROM users WHERE active = 1 ORDER BY name")
      .all();

    // meta 會告訴你這個查詢實際上是被誰服務的(見後面的可觀測性小節)
    return Response.json(
      {
        users: results,
        _meta: {
          region: meta.served_by_region,     // 例如 "APAC"——由哪個地區服務
          isPrimary: meta.served_by_primary, // false = 由複本服務(成功就近讀!)
        },
      },
    );
  },
};

如果這個查詢的 served_by_primaryfalse,恭喜——你的讀取確實由複本就近完成了。

3. 寫入 + 讀取自身寫入:同一 session 保證看到剛寫的資料

寫入必須走主要資料庫,所以用 first-primary。關鍵在於:寫入後的讀取用同一個 session 物件,就能保證 read-your-writes——一定讀得到剛才的寫入:

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const { name, email } = (await request.json()) as {
      name: string;
      email: string;
    };

    // 寫入場景:用 first-primary,從最新且能寫入的主要資料庫出發
    const session = env.DB.withSession("first-primary");

    // (1) 寫入 → 路由到主要資料庫
    const insert = await session
      .prepare("INSERT INTO users (name, email) VALUES (?, ?)")
      .bind(name, email)
      .run();

    // (2) 立刻讀回剛寫入的資料 → 用「同一個 session」保證讀得到(read-your-writes)
    const newUser = await session
      .prepare("SELECT * FROM users WHERE id = ?")
      .bind(insert.meta.last_row_id)
      .first();

    // (3) 取得這個 session 目前的書籤,回傳給客戶端供下次請求使用
    const bookmark = session.getBookmark();

    return Response.json(newUser, {
      status: 201,
      headers: bookmark ? { "X-D1-Bookmark": bookmark } : {},
    });
  },
};

注意第 (2) 步:即使讀取本來可能被路由到複本,但因為是同一個 session,D1 保證它看得到第 (1) 步的寫入。第 (3) 步我們把書籤透過 X-D1-Bookmark 標頭回傳——這是下一個範例跨請求一致性的基礎。

4. 跨請求書籤傳遞:read-your-writes 的完整模式

這是 Sessions API 最重要的實戰模式。使用者的「寫入」和「後續讀取」往往是兩個獨立的 HTTP 請求(而且 Worker 是無狀態的),要讓第二個請求讀得到第一個請求的寫入,就得把書籤傳給客戶端、下次帶回:

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // (1) 從請求標頭(或 Cookie)取回上一個請求留下的書籤;
    //     若沒有,退回 "first-unconstrained"(第一次造訪,尚無一致性需求)
    const incomingBookmark =
      request.headers.get("X-D1-Bookmark") ??
      request.headers.get("Cookie")?.match(/d1_bookmark=([^;]+)/)?.[1] ??
      "first-unconstrained";

    // (2) 用書籤初始化 session:D1 保證讀取「至少」看到書籤代表的狀態
    const session = env.DB.withSession(incomingBookmark);

    let responseData: unknown;

    if (request.method === "POST") {
      const body = (await request.json()) as { userId: number; name: string };

      // 寫入(此 session 已帶入一致性起點)
      await session
        .prepare("UPDATE users SET name = ?, updated_at = datetime('now') WHERE id = ?")
        .bind(body.name, body.userId)
        .run();

      // 讀取自身寫入:同一 session 保證看到剛才的 UPDATE
      responseData = await session
        .prepare("SELECT * FROM users WHERE id = ?")
        .bind(body.userId)
        .first();
    } else {
      const userId = new URL(request.url).searchParams.get("userId");
      // 讀取:因為帶了書籤,不會讀到比上次寫入更舊的複本
      responseData = await session
        .prepare("SELECT * FROM users WHERE id = ?")
        .bind(userId)
        .first();
    }

    // (3) 取得這次 session 結束後的最新書籤
    const outgoingBookmark = session.getBookmark();

    const headers = new Headers({ "Content-Type": "application/json" });
    if (outgoingBookmark) {
      // (4) 把書籤回傳給客戶端:下次請求帶回來,就能延續一致性
      headers.set("X-D1-Bookmark", outgoingBookmark);
      headers.set(
        "Set-Cookie",
        `d1_bookmark=${outgoingBookmark}; Path=/; HttpOnly; SameSite=Strict`,
      );
    }

    return new Response(JSON.stringify(responseData), { headers });
  },
};

核心心法:書籤是「一致性接力棒」。客戶端每次請求把它帶回、伺服器每次回應把最新的書籤交出去,如此一來,即使中間的讀取被分散到不同複本,使用者也永遠不會看到「時光倒流」的舊資料。

5. getBookmark() 的行為細節

getBookmark() 回傳一個 string | null——若這個 session 尚未執行任何查詢,會回傳 null:

const session = env.DB.withSession("first-unconstrained");

// 尚未查詢時,書籤是 null
console.log(session.getBookmark()); // null

// 執行查詢後,書籤代表「此 session 已看到的最新狀態」
await session.prepare("SELECT * FROM users").all();
const bookmark = session.getBookmark();
console.log(bookmark);
// 例如:"00000002-0000024e-00004c1e-xxxxxxxxxxxxxxxx"(可排序的狀態識別符)

因此在回傳書籤前,務必判斷是否為 null(如前面範例的 bookmark ? {...} : {}),避免把空值寫進標頭。

6. Session 也支援 batch()

Session 物件同樣提供 batch(),整批查詢由同一 session 保障一致性:

const session = env.DB.withSession("first-primary");

const [userResult, postsResult] = await session.batch([
  session.prepare("SELECT * FROM users WHERE id = ?").bind(userId),
  session.prepare("SELECT * FROM posts WHERE author_id = ?").bind(userId),
]);

// 整個 batch 由同一 session 維持循序一致性
const bookmark = session.getBookmark();

7. 可觀測性:用 meta 看查詢被誰服務

要驗證複本真的生效、或除錯一致性問題,D1Resultmeta 物件提供路由資訊:

const session = env.DB.withSession("first-unconstrained");
const result = await session
  .prepare("SELECT * FROM users WHERE active = 1")
  .all();

console.log({
  region: result.meta.served_by_region,     // 服務此查詢的地區,例如 "WEUR"
  isPrimary: result.meta.served_by_primary,  // true = 主要資料庫;false = 複本
  duration: result.meta.duration,            // 查詢耗時(毫秒)
  rowsRead: result.meta.rows_read,           // 讀取列數(計費依據)
});
// 若 isPrimary 為 false 且 region 是使用者所在地 → 就近讀取成功!

served_by_regionserved_by_primary 是你觀察複本行為最直接的兩個訊號:前者看查詢落在哪個地區,後者確認是走複本還是主要資料庫。

常見錯誤與最佳實踐

坑一:啟用了讀取複本,卻還是直接查 env.DB,複本完全沒生效。

最常見的誤解:以為在 Dashboard 打開 Read Replication 就會自動就近讀。事實是——沒透過 Sessions API 的查詢一律走主要資料庫。你可能還在納悶「怎麼延遲沒改善」,問題就出在少了 withSession():

// ❌ 錯誤:直接查 env.DB,即使啟用了複本,也只會打到主要資料庫
const users = await env.DB.prepare("SELECT * FROM users").all();

// ✅ 正確:透過 session,讀取才會被路由到最近的複本
const session = env.DB.withSession("first-unconstrained");
const users = await session.prepare("SELECT * FROM users").all();

served_by_primary 驗證:若它一直是 true,代表你根本沒用到複本。

坑二:寫後立即讀,卻讀到過期資料(違反 read-your-writes)。

使用者更新完資料、馬上重整頁面,卻看到舊值——因為那次讀取被路由到還沒複製到最新的複本。這通常發生在「寫入和讀取是兩個獨立請求、卻沒傳書籤」的情況:

// ❌ 錯誤:第二個請求的讀取用全新的 unconstrained session,可能落在有 lag 的複本
// 請求 A(POST):寫入 → 沒把書籤回傳
// 請求 B(GET):env.DB.withSession("first-unconstrained") → 讀到舊資料!

// ✅ 正確:請求 A 回傳書籤,請求 B 帶回書籤,保證讀到不比寫入更舊的狀態
// 請求 A:const bm = session.getBookmark(); 回傳給客戶端
// 請求 B:env.DB.withSession(clientProvidedBookmark) → read-your-writes ✓

坑三:模式選錯——該快的用了 first-primary,該準的用了 first-unconstrained

把公開內容頁(能容忍些微延遲)寫成 first-primary,等於白白放棄複本、每次都繞回主要資料庫,延遲不減反增;反過來,把「必須讀到最新」的即時庫存查詢寫成 first-unconstrained,又可能讀到過期庫存導致超賣。判斷準則:

  • 讀取能容忍延遲first-unconstrained,換取低延遲(部落格、目錄、公開頁)。
  • 必須讀到最新first-primary(即時庫存、餘額、後續要據此寫入)。
  • 要讀到使用者自己剛寫的 → 傳 書籤(個人資料、購物車、貼文編輯)。

坑四:把書籤當敏感資訊或永久狀態存錯地方。

書籤只是「一致性接力棒」,它會過期(隨資料演進失效),也不該被當作長期身分憑證。正確做法是把它放在當次會話的傳遞管道(自訂標頭或短效 Cookie),請求間接力傳遞即可;不要塞進資料庫長期保存、也不要跨使用者共用。若客戶端傳回一個過舊或無效的書籤,穩健的退路是退回 first-primary(從最新狀態重新開始),而非直接失敗。

坑五:忘了成本與一致性的整體權衡。

讀取複本本身不額外收費,但它改變的是一致性語意:用了複本,你就得接受最終一致性、並用書籤補回需要的一致性。而 first-primary 雖然總是讀到最新,卻放棄了複本的延遲優勢。最務實的架構是混合使用——大量的公開讀取走 first-unconstrained 享受低延遲,少數「必須最新」或「read-your-writes」的關鍵路徑才用 first-primary 或書籤。別為了追求「處處強一致」而讓每個查詢都繞回主要資料庫,那等於沒開複本。

最佳實踐小結:把握 Sessions API 的心法——要用複本,一律走 withSession()(直接查 env.DB 沒有複本);讀取能容忍延遲用 first-unconstrained、要最新用 first-primary、要讀到自己剛寫的用書籤;跨請求的 read-your-writes 靠書籤接力(回傳 + 帶回);served_by_region / served_by_primary 觀測路由驗證效果;大量讀取走複本、關鍵路徑才強一致,混合使用取得最佳平衡。守住這幾條,你的全球讀取就能又快又不失一致。

小結

上一篇《D1 Migrations》,我們學會像 Git 一樣安全地演進資料表結構;這一篇,我們補上讓全球讀取又快又對的關鍵機制——D1 的 Sessions API 與讀取複本:

  • 為何需要讀取複本——單一寫入者架構下,寫入必回主要資料庫,但讀多寫少的讀取可由全球六地的非同步複本就近服務,代價是 100ms–2s 的複本延遲
  • Sessions API 是必要開關——不透過 env.DB.withSession() 的查詢一律走主要資料庫,複本形同虛設;session 用書籤提供循序一致性(read-your-writes、單調讀取、單調寫入)。
  • 兩種模式 + 書籤——first-unconstrained 換低延遲、first-primary 換最新資料;書籤(Bookmark) 則跨 HTTP 請求接力,確保使用者讀得到自己剛寫的資料。
  • 權衡與觀測——用 served_by_region / served_by_primary 驗證路由;大量讀取走複本、關鍵路徑才強一致,混合使用取得延遲、成本、一致性的最佳平衡。

到這裡,你的 D1 已經能在全球低延遲地讀取、又不失一致性了。但資料庫難免出錯——誤刪一張表、跑錯一個 UPDATE、被壞掉的遷移搞砸 schema,這些「時光倒流」的需求怎麼辦?下一篇《D1 Time Travel 與備份》,我們就來看 D1 內建、永遠啟用又免費的時間點還原(Point-in-Time Recovery):如何用書籤或時間戳把資料庫還原到過去任一時刻,以及結合 R2 做超過 30 天的長期備份。

想先查閱官方對 D1 讀取複本與 Sessions API 的完整說明,可以隨時參考 Cloudflare D1 Read Replication 官方文件。全球低延遲讀取這一課已就緒,我們下一篇《D1 Time Travel 與備份》見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →