跳到內容

D1 進階:Read Replication 與 Sessions API

查證日期
驗證環境wrangler@4.114.0·workerd@1.20260722.1·compatibility_date: 2026-07-24

D1 可以在六個區域佈署讀取複本,讓全球讀取延遲大幅下降。聽起來只要在 dashboard 按個開關就好。

但最重要的一句話是:不呼叫 withSession(),你的所有查詢仍然會打 primary —— 複本等於沒開。

這篇要處理的三件事:

  1. 它到今天仍然是 beta。 2025-04-10 進 public beta,至今沒有 GA 公告
  2. 開關和程式碼是兩回事。 開了複本但沒改程式碼,你只是多了一堆閒置的複本。
  3. 打錯 constraint 字串會被靜默當成 bookmark。 我實測 "not-a-real-constraint" 不但沒報錯,還被當成 bookmark 用了。

D1 有一個 primary(可寫)和若干個唯讀複本。問題是:複本會落後

你在台北 → 寫入 primary(假設在 ENAM)
你在台北 → 立刻讀取 → 打到 APAC 複本 → 讀到舊值

這就是為什麼不能只是「開複本、讀最近的」。你需要一個機制表達「這次讀取要至少和我剛才那次寫入一樣新」。

那個機制就是 session + bookmark

  • session 是一連串查詢的容器,保證彼此之間是 sequential consistency。
  • bookmark 是一個不透明字串,代表「資料庫的某個時間點」。你把它帶在請求之間,D1 就會找一個至少推進到那個點的複本。
const session = env.DB.withSession(bookmark ?? "first-unconstrained");
const rows = await session.prepare("SELECT ...").bind(...).all();
const nextBookmark = session.getBookmark();

withSession() 的參數有三種形態:

語意延遲新鮮度
"first-unconstrained"(預設)第一個查詢打任何節點(primary 或複本),之後的查詢跟上它最低最弱
"first-primary"第一個查詢打 primary,之後的查詢跟上它最強
<bookmark 字串>從某個已知時間點之後開始,找一個夠新的複本由你控制

型別定義裡的註解說得很清楚:

type D1SessionConstraint =
// Indicates that the first query should go to the primary, and the rest queries
// using the same D1DatabaseSession will go to any replica that is consistent with
// the bookmark maintained by the session (returned by the first query).
'first-primary'
| 'first-unconstrained';

注意兩者的共同點:只有第一個查詢的目標不同,後續查詢都會被綁在同一個 bookmark 上。 所以 session 內部本來就是一致的,你要決定的只是「起點要多新」。

🔴 打錯 constraint 會被靜默當成 bookmark

Section titled “🔴 打錯 constraint 會被靜默當成 bookmark”

withSession() 的參數同時接受 constraint 和 bookmark,而它們都是字串。實測:

Terminal window
$ curl -s localhost:8787/constraints
{
"first-unconstrained": { "ok": "00000000-00000004-00000000-0000...0000" },
"first-primary": { "ok": "00000000-00000005-00000000-0000...0000" },
"not-a-real-constraint": { "ok": "not-a-real-constraint" },
"(empty string)": { "ok": "00000000-00000007-00000000-0000...0000" },
"<bookmark-shaped string>": { "ok": "00000001-00000002-00004ce6-1234567890abcdef" }
}

"not-a-real-constraint" 沒有報錯,而且 getBookmark() 直接把它回傳出來 —— 它被當成 bookmark 了。

所以打錯字("first-unconstrainted""first_primary")不會有任何訊號,你只是得到一個奇怪的 bookmark,而你以為要的 constraint 完全沒生效。

TypeScript 在你直接寫字面量時會擋:

env.DB.withSession("first-primry"); // ❌ 型別錯誤,字面量會被檢查

任何來自設定檔、header、環境變數的字串都會繞過型別檢查

env.DB.withSession(request.headers.get("x-d1-bookmark") ?? env.D1_MODE); // ⚠️ 完全不受保護

實務建議:bookmark 和 constraint 用不同的變數傳,不要共用一個「反正都是字串」的參數。

const CONSTRAINTS = ["first-primary", "first-unconstrained"] as const;
type Constraint = (typeof CONSTRAINTS)[number];
function openSession(db: D1Database, bookmark: string | null, fallback: Constraint) {
// A bookmark is only trusted if it looks like one.
return db.withSession(bookmark && /^[0-9a-f-]{20,}$/.test(bookmark) ? bookmark : fallback);
}
const BOOKMARK_HEADER = "x-d1-bookmark";
// --- Read path ---
const incoming = request.headers.get(BOOKMARK_HEADER);
const session = env.DB.withSession(incoming ?? "first-unconstrained");
const row = await session.prepare("SELECT ... LIMIT 1").bind(...).first();
const res = Response.json({ ... });
const b = session.getBookmark();
if (b) res.headers.set(BOOKMARK_HEADER, b);
return res;

實測 round-trip:

Terminal window
$ curl -sD- localhost:8787/write | grep -i x-d1-bookmark
x-d1-bookmark: 00000000-00000009-00000000-00000000000000000000000000000000
$ curl -s localhost:8787/read
{"value":1,"usedBookmark":null}
$ curl -s -H "x-d1-bookmark: 00000000-00000009-..." localhost:8787/read
{"value":1,"usedBookmark":"00000000-00000009-..."}

bookmark 是單調遞增的(第二次寫入拿到 0000000c)。

這一點很容易漏。如果寫入沒有經過 session,你就拿不到寫入之後的 bookmark,後續的讀取就沒有東西可以「跟上」:

// ❌ Bookmark never advances past the write.
await env.DB.prepare("UPDATE ...").run();
const session = env.DB.withSession("first-unconstrained"); // starts from nowhere
// ✅
const session = env.DB.withSession("first-primary");
await session.prepare("UPDATE ...").run();
const b = session.getBookmark(); // now this reflects the write

寫入用 "first-primary"(本來就一定要打 primary),然後把 bookmark 傳給客戶端。

三種常見做法:

存放位置適合注意
Response header + 客戶端回傳SPA / 行動 App要記得在 fetch wrapper 裡自動帶上
Cookie傳統網頁大小有限,且會跟著每個請求送
不存,用 first-primary寫入後立刻讀的關鍵路徑放棄複本的延遲優勢,換確定性

實務上最常見的是混合:大部分讀取用 first-unconstrained(最快),只有「剛寫完那一個畫面」帶 bookmark。

觀測:怎麼知道複本真的有在用

Section titled “觀測:怎麼知道複本真的有在用”

Production 的 meta 會多出三個欄位(本機沒有):

欄位意義
served_by_region服務這次查詢的區域(ENAM / WNAM / WEUR / EEUR / APAC / OC)
served_by_primary是不是 primary 服務的
served_by_colo三碼機場代碼

驗收方式很直接:部署後看 served_by_primary 的比例。 如果幾乎都是 true,代表你的 session 用法有問題 —— 複本沒被用到。

把它記進第 39 篇的結構化 log:

console.log(JSON.stringify({
event: "d1_read",
region: r.meta.served_by_region,
primary: r.meta.served_by_primary,
rows_read: r.meta.rows_read,
}));

第 10 篇的兩條版本線在這裡又分岔了:

0.45.21.0.0-rc.4
drizzle(session)❌ 型別不接受 D1DatabaseSession✅ 接受

1.0 把 AnyD1Database 擴充成包含 D1DatabaseSession,所以:

// 1.0 line — type-checks
const session = env.DB.withSession(bookmark ?? "first-unconstrained");
const db = drizzle(session, { relations });
const bookmarkOut = session.getBookmark();

0.45.2 上同樣的程式碼跑得起來(因為 D1DatabaseSession 結構上有 prepare()batch()),但型別不過,需要 cast —— 那是未受支援的用法。

兩條線都沒有 drizzle 層級的 bookmark API。你得自己抓著原始的 session 物件讀 getBookmark()

這是「第 10 篇建議把資料存取包成 repository 函式」的另一個理由:session 的傳遞只需要在一個地方處理。

  • 在 dashboard 的資料庫 Settings → Enable Read Replication,或 REST API 設 "read_replication": { "mode": "auto" }
  • 複本自動佈署到六個區域:ENAM、WNAM、WEUR、EEUR、APAC、OC。你不能選。
  • 關閉最多要 24 小時生效。
  • Sessions API 只能透過 Worker binding 使用,REST API 不支援。
  • 複本不額外收費。 官方原文:「Read replication does not charge extra for read replicas.」費用仍然按 rows_read 計(第 9 篇)。

換句話說:開複本沒有成本,只有「你有沒有正確使用」的問題。

Terminal window
$ curl -s localhost:8787/session
{
"sessionProto": ["constructor","_updateBookmark","prepare","batch","getBookmark", ...],
"bookmarkBeforeAnyQuery": null,
"bookmarkAfterQuery": "00000000-00000003-00000000-0000...0000",
"meta": { "served_by": "miniflare.db", "rows_read": 1, ... }
}
本機production
withSession() / getBookmark()✅ 可用
bookmark 單調遞增
constraint 字串驗證行為✅(包括那個陷阱)
實際的複本路由❌ 只有一顆資料庫
served_by_region / served_by_primary❌ 不存在
讀到舊資料的情境❌ 重現不了

能在本機做的事是「把程式碼寫對」,不能做的是「驗證它真的有效」。 驗證要靠部署後的 served_by_primary 比例。

getBookmark() 在任何查詢之前回傳 null —— 這個要處理,不然你會把 "null" 字串送給客戶端。


完整程式碼:examples/ch11-d1-replication/

Terminal window
cd examples/ch11-d1-replication && npm install
npx wrangler d1 migrations apply linkforge-demo
npm run dev
Terminal window
B=localhost:8787
curl -s "$B/session" # session 的形狀、bookmark 格式
curl -s "$B/constraints" # 打錯的 constraint 被當成 bookmark
curl -sD- "$B/write" # 寫入並取得 bookmark
curl -s "$B/read" # 不帶 bookmark
curl -s -H "x-d1-bookmark: <上面那個>" "$B/read"

LinkForge 的讀取路徑分成三類,各用不同策略。

路徑策略理由
redirect 熱路徑完全不碰 D1走 Workers Cache → KV(第 6、8 篇)
dashboard 一般讀取(列表、統計)first-unconstrained慢個幾秒完全無所謂,要的是延遲
寫入後的即時回饋(剛建好的連結)帶 bookmark使用者剛按下建立,看不到自己的東西會以為壞了
packages/db/src/session.ts
const CONSTRAINTS = ["first-primary", "first-unconstrained"] as const;
type Constraint = (typeof CONSTRAINTS)[number];
const looksLikeBookmark = (s: string) => /^[0-9a-f]{8}-[0-9a-f]{8}-[0-9a-f]{8}-[0-9a-f]{32}$/.test(s);
export function openSession(
d1: D1Database,
bookmark: string | null,
fallback: Constraint = "first-unconstrained",
): D1DatabaseSession {
// Never pass an unvalidated string here — an invalid constraint is
// silently treated as a bookmark (chapter 11).
return d1.withSession(bookmark && looksLikeBookmark(bookmark) ? bookmark : fallback);
}
// apps/api middleware — one place, applied to every route
export const d1Session = createMiddleware<AppEnv>(async (c, next) => {
const incoming = c.req.header("x-d1-bookmark") ?? null;
const isWrite = c.req.method !== "GET" && c.req.method !== "HEAD";
const session = openSession(c.env.DB, incoming, isWrite ? "first-primary" : "first-unconstrained");
c.set("dbSession", session);
await next();
const b = session.getBookmark();
if (b) c.header("x-d1-bookmark", b);
});

三個決策:

① 寫入自動用 first-primary 根據 HTTP method 判斷,不靠開發者記得。

② bookmark 的產生與回傳都在 middleware。 路由處理器完全不需要知道 session 的存在,只從 c.get("dbSession") 拿。

③ 前端的 fetch wrapper 自動帶 bookmark。 第 26 篇會在 TanStack Query 的層級處理 —— 存在記憶體、每次請求帶上、每次回應更新。不存 localStorage,因為過期的 bookmark 會讓 D1 找不到夠新的複本而退回 primary,反而更慢。

部署後在第 39 篇的 Query Builder 上盯一個數字:

非寫入請求裡 served_by_primary = true 的比例

如果超過 20%,代表 bookmark 傳得太積極(該用 first-unconstrained 的地方用了 bookmark),或者 session 中介層沒套上。

本篇交付物packages/db/src/session.tsapps/apid1Session middleware、bookmark 格式驗證、以及一條 served_by_primary 比例的告警規則。


① 開了複本卻沒改程式碼

沒有 withSession(),全部查詢照樣打 primary。複本只是閒置。

② 打錯 constraint 字串

不會報錯,會被當成 bookmark。字面量以外的來源要自己驗。

③ 寫入沒走 session

bookmark 不會推進到寫入之後,後續讀取無從跟上。

④ 沒處理 getBookmark() 回傳 null

第一個查詢之前是 null,直接送出去會變成字串 "null"

⑤ 把 bookmark 存進 localStorage

過期的 bookmark 會讓 D1 找不到夠新的複本,退回 primary,比不帶還慢。

⑥ 以為 REST API 也能用 Sessions

只有 Worker binding 支援。

⑦ 在本機驗證複本行為

本機只有一顆資料庫。要看 production 的 served_by_primary

⑧ 說 read replication 是 GA

到 2026-07-28 仍標示 Beta,沒有 GA 公告。

⑨ 0.45.2 的 Drizzle 直接傳 session

型別不接受,要 cast(未受支援)。1.0 線才正式支援。


  1. **不呼叫 withSession(),複本等於沒開。**開關和程式碼是兩件事。
  2. **打錯的 constraint 會被靜默當成 bookmark。**任何非字面量來源都要自己驗格式。
  3. **驗收看 served_by_primary 的比例。**本機驗不了這件事。


下一篇12. R2:零 egress 費用的物件儲存 —— 最貴的陷阱是 ListObjects 屬於 Class A,是 GetObject 的 12.5 倍價格。