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)能接受寫入,所有 INSERT、UPDATE、DELETE 都必須路由到它。這個設計簡化了一致性,但也埋下一個問題——主要資料庫只在一個地理位置。
假設你的主要資料庫在美西(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_primary 是 false,恭喜——你的讀取確實由複本就近完成了。
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 看查詢被誰服務
要驗證複本真的生效、或除錯一致性問題,D1Result 的 meta 物件提供路由資訊:
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_region 與 served_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 與備份》見。