D1 trong production: primary-replica, batch, và 7 gotcha

D1 là SQLite ở edge với primary region và read replica: kiến trúc, 5 query method, Sessions API cho read replica, prepared statement cache, migration, và 7 gotcha production.

· 6 phút đọc · Read in English
D1 trong production: kiến trúc SQLite primary-replica ở edge, 5 query method (first/all/run/batch/exec), Sessions API cho read replica, prepared statement cache và migration an toàn

TL;DR

D1 là SQLite chạy trên Cloudflare edge với kiến trúc primary-replica. Write đi về primary (1 region), read có thể đi replica gần Worker hơn nếu bật Sessions API.

Luận điểm chính:

D1 không phải SQLite chạy trong Worker. Là SQLite managed với chi phí phụ mạng (mỗi query = 1 subrequest ~5-50ms) và consistency model khác hẳn local SQLite. Thiết kế truy cập đúng từ đầu tránh cháy subrequest budget và stale read.

Bài này đi qua: kiến trúc primary-replica, 5 query method và subrequest cost, Sessions API cho read-your-write, prepared statement, migration pattern an toàn, 7 gotcha production từ build blog này.

Bài này là reference platform-level. Nếu muốn retrospective về schema design + FTS + index pattern thực tế, đọc thêm Năm lưu ý về schema D1.


Dành cho ai

  • Dev đã đọc Part 5 (KV), giờ chuyển sang relational data.
  • Người dùng Postgres/MySQL, đang đánh giá D1 cho tải edge.
  • Ai bị cháy subrequest budget hoặc gặp stale read với D1.

Nên đọc trước: Part 2 (subrequest limit), Part 3 (Storage layer).

Sau bài này bạn sẽ:

  • Hiểu kiến trúc primary-replica và khi nào gặp replication lag.
  • Chọn đúng query method (first vs all vs batch) để tối ưu subrequest.
  • Dùng Sessions API cho read-your-write.
  • Viết migration an toàn (không rollback auto).
  • Tránh 7 gotcha production phổ biến.

Bài này không nói về gì

  • Schema design, index, FTS, sharding: đã có d1-schema-tips.
  • ORM (Drizzle, Prisma): Part 10.
  • Backup / disaster recovery: Part 19.

Kiến trúc: primary + replica

D1 architecture: một primary database ở 1 region (ENAM hoặc WEUR), các read replica ở các region khác đồng bộ bất đồng bộ. Write từ Worker đi về primary; read có thể đi replica gần hơn khi dùng Sessions API. Mỗi query là một subrequest trong Worker.

Primary

Mỗi D1 database có một primary ở một region bạn chọn khi tạo (thường ENAM, WEUR, hoặc APAC). Primary là source of truth. Mọi write đi qua primary, strong ordering.

Read replica

Cloudflare tự động tạo replica ở các region khác. Replica sync bất đồng bộ từ primary, replication lag thường < 1 giây nhưng có thể vọt lên vài giây khi write rate cao.

Request routing

Mặc định Worker gọi env.DB.prepare(...) đi về primary. Nếu bật Sessions API, read sẽ:

  1. Thử replica gần Worker nhất.
  2. Check commit token để đảm bảo replica đã catch up tới ít nhất transaction mình đã gửi.
  3. Phương án dự phòng sang primary nếu replica lag quá hoặc chưa catch up.

Kết quả: read gần hơn (~10ms thay vì 50-100ms cross-region), nhưng vẫn “read your own write”.

Kích thước

  • Tối đa 10 GB per database (free tier 5 GB).
  • Không giới hạn số database per account.
  • Muốn > 10 GB: shard qua nhiều database hoặc chuyển sang dịch vụ khác.

Binding

{
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "my-app-prod",
      "database_id": "01234567-89ab-cdef-0123-456789abcdef",
      "migrations_dir": "migrations"
    }
  ]
}

Type trong Worker:

interface Env {
  DB: D1Database;
}

5 query method

D1 query method comparison: first lấy 1 row, all lấy tất cả row, run cho write statement, batch cho transaction atomic nhiều statement, exec cho raw SQL migration. Mỗi method là 1 subrequest. exec cảnh báo SQL injection risk, chỉ dùng migration.

.first() — lấy 1 row

interface Post { id: string; title: string; body: string; }

const post = await env.DB
  .prepare("SELECT * FROM posts WHERE slug = ?")
  .bind(slug)
  .first<Post>();

if (!post) return new Response("Not found", { status: 404 });

Generic <Post> cho type safety. Trả null nếu không có row.

.all() — lấy tất cả

const { results, meta } = await env.DB
  .prepare("SELECT * FROM posts WHERE author = ? ORDER BY published_at DESC")
  .bind(authorId)
  .all<Post>();

console.log(`Fetched ${results.length} posts in ${meta.duration}ms`);
console.log(`Rows scanned: ${meta.rows_read}`);

meta cho biết duration, rows_read, rows_written. Dùng để phát hiện slow query.

.run() — write statement

const result = await env.DB
  .prepare("INSERT INTO subscribers (email, token, confirmed) VALUES (?, ?, false)")
  .bind(email, token)
  .run();

console.log(`Inserted, rows affected: ${result.meta.changes}`);

Dùng cho INSERT, UPDATE, DELETE. Không return rows.

.batch([stmts]) — transaction atomic

const stmts = [
  env.DB.prepare("INSERT INTO posts (id, title) VALUES (?, ?)").bind(id, title),
  env.DB.prepare("INSERT INTO post_tags (post_id, tag) VALUES (?, ?)").bind(id, "cf"),
  env.DB.prepare("UPDATE stats SET post_count = post_count + 1").bind(),
];

const results = await env.DB.batch(stmts);
// results[0], results[1], results[2] đều chạy trong 1 transaction.
// Fail ở giữa = rollback tất cả.

Chỉ tốn 1 subrequest cho cả batch, không phải N subrequest. Dùng khi cần atomic hoặc để tiết kiệm subrequest.

.exec(raw) — raw SQL, không bind

const schema = `
  CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT);
  CREATE INDEX idx_users_email ON users(email);
`;
await env.DB.exec(schema);

CẢNH BÁO: exec() không hỗ trợ .bind(). Nếu nối string từ đầu vào người dùng → SQL injection. Chỉ dùng cho migration hoặc thiết lập schema, không dùng cho user query.


Sessions API: read-your-write

Vấn đề cổ điển với replica: write vào primary, read ngay sau từ replica có thể thấy chưa có.

Sessions API giải:

async fetch(request, env) {
  // Tạo session, lấy token commit từ header nếu client đã có
  const bookmark = request.headers.get("x-d1-bookmark") ?? "first-unconstrained";
  const session = env.DB.withSession(bookmark);

  // Write
  await session.prepare("INSERT INTO posts (id, title) VALUES (?, ?)")
    .bind(id, title)
    .run();

  // Read — session guarantee thấy được write trên
  const post = await session.prepare("SELECT * FROM posts WHERE id = ?")
    .bind(id)
    .first();

  // Trả bookmark cho client để request sau tiếp tục session
  return Response.json({ post }, {
    headers: { "x-d1-bookmark": session.getBookmark() }
  });
}
  • "first-unconstrained": session bắt đầu, chưa ràng buộc replica nào.
  • "first-primary": bắt đầu từ primary luôn (strong, nhưng chậm hơn).
  • <bookmark>: client cung cấp bookmark từ request trước → session tiếp tục từ điểm đó.

Khi nào cần:

  • Form submit → ngay sau hiển thị kết quả.
  • POST API → GET follow-up trong cùng quy trình.

Khi không cần:

  • Dashboard chỉ đọc (stale < 1s fine).
  • Analytics query, rollup (dữ liệu cũ vài giây OK).

Prepared statement

// KHÔNG tốt: prepare trong loop, waste
for (const id of ids) {
  const row = await env.DB
    .prepare("SELECT * FROM posts WHERE id = ?")
    .bind(id)
    .first();
}

// Tốt hơn: prepare một lần, bind nhiều
const stmt = env.DB.prepare("SELECT * FROM posts WHERE id = ?");
const rows = await Promise.all(ids.map(id => stmt.bind(id).first()));

// Tốt nhất: IN clause, 1 subrequest
const placeholders = ids.map(() => "?").join(",");
const { results } = await env.DB
  .prepare(`SELECT * FROM posts WHERE id IN (${placeholders})`)
  .bind(...ids)
  .all();

D1 tự cache compiled statement. Nhưng mỗi .bind().run() vẫn là 1 subrequest. Dùng IN (?) với mảng để gộp.

Subrequest budget (Part 2): Free 50, Paid 1000 per request. Lặp 100 phần tử với 1 query mỗi cái = cháy free tier.


Migration pattern

Tạo migration

wrangler d1 migrations create my-app add-subscribers-table

Tạo migrations/0001_add-subscribers-table.sql:

CREATE TABLE subscribers (
  email TEXT PRIMARY KEY,
  token TEXT NOT NULL,
  confirmed INTEGER NOT NULL DEFAULT 0,
  created_at INTEGER NOT NULL,
  confirmed_at INTEGER
);

CREATE INDEX idx_subscribers_token ON subscribers(token);
CREATE INDEX idx_subscribers_confirmed ON subscribers(confirmed);

Áp dụng

# Local dev trước
wrangler d1 migrations apply my-app --local

# Production
wrangler d1 migrations apply my-app --remote

Additive-only pattern

D1 không hỗ trợ đầy đủ ALTER TABLE. Không DROP COLUMN, không đổi type. Pattern an toàn:

Thêm column mới: OK, ALTER TABLE posts ADD COLUMN reading_time INTEGER chạy được.

Đổi type column: KHÔNG được. Workaround:

-- 0002_rename-posts-schema.sql
CREATE TABLE posts_new (
  id TEXT PRIMARY KEY,
  reading_time REAL,  -- đổi từ INTEGER sang REAL
  -- các cột khác giống cũ
  title TEXT,
  body TEXT
);

INSERT INTO posts_new SELECT id, CAST(reading_time AS REAL), title, body FROM posts;

DROP TABLE posts;
ALTER TABLE posts_new RENAME TO posts;

-- Đừng quên tạo lại index!
CREATE INDEX idx_posts_... ON posts(...);

Đổi tên column: Cloudflare mới hỗ trợ ALTER TABLE ... RENAME COLUMN (kiểm tra compatibility date). Nếu không, dùng pattern create-copy-drop như trên.

Lùi phiên bản

D1 không tự động lùi phiên bản. Viết migration đảo:

-- 0002_undo-add-reading-time.sql
CREATE TABLE posts_old AS SELECT id, title, body FROM posts;
DROP TABLE posts;
ALTER TABLE posts_old RENAME TO posts;

Áp dụng tay khi cần.

Test migration trước production

Luôn:

  1. Áp dụng migration vào D1 local với dữ liệu seed production-like.
  2. Chạy test suite.
  3. Áp dụng migration vào môi trường preview (D1 riêng).
  4. Chạy smoke test.
  5. Rồi mới áp dụng production.

Blog này có 12 migration, 3 lần phải viết reverse migration sau khi triển khai production (không phải hoàn hảo).


7 gotcha production

① Timestamp nên lưu INTEGER (ms từ epoch)

-- SAI: TEXT dễ sort nhầm
created_at TEXT  -- "2026-05-04T10:30:00Z"

-- ĐÚNG: INTEGER unix ms
created_at INTEGER NOT NULL  -- 1746354600000

TEXT ISO string sort đúng nếu format nhất quán, nhưng dễ lỗi timezone. INTEGER đơn giản, compare O(1), index nhanh.

JavaScript:

const now = Date.now();  // 1746354600000
await env.DB.prepare("INSERT INTO posts (id, created_at) VALUES (?, ?)")
  .bind(id, now)
  .run();

② Boolean không native

SQLite không có BOOLEAN. D1 cũng vậy. Dùng INTEGER 0/1:

confirmed INTEGER NOT NULL DEFAULT 0 CHECK (confirmed IN (0, 1))

JavaScript:

const user = await env.DB.prepare("SELECT confirmed FROM users WHERE id = ?").bind(id).first<{confirmed: number}>();
const isConfirmed = user?.confirmed === 1;  // không `=== true`

null vs missing column

// SAI
await env.DB.prepare("INSERT INTO posts (id, title) VALUES (?, ?)")
  .bind(id, undefined)  // D1 coerce thành null hay throw?
  .run();

Với undefined, behavior không rõ ràng. Luôn coerce tường minh:

await env.DB.prepare("INSERT INTO posts (id, title) VALUES (?, ?)")
  .bind(id, title ?? null)
  .run();

④ Không có RETURNING clause truyền thống

SQLite 3.35+ hỗ trợ RETURNING. D1 có, nhưng không phải tất cả version:

-- Có thể chạy:
INSERT INTO posts (id, title) VALUES (?, ?) RETURNING *;

Kiểm tra compatibility date. Nếu cần migration, dùng last_insert_rowid():

const result = await env.DB.prepare("INSERT INTO posts (title) VALUES (?)")
  .bind(title)
  .run();
const newId = result.meta.last_row_id;

⑤ Query text size limit

D1 query text < 100KB. Lớn nghĩa là INSERT ... VALUES (?, ?, ?), (?, ?, ?), ... với hàng trăm row. Dùng .batch() thay vì multi-VALUES:

// SAI: 1000 row trong 1 INSERT → query text ~200KB, fail
const sql = `INSERT INTO events VALUES ${rows.map(() => "(?, ?, ?)").join(",")}`;
await env.DB.prepare(sql).bind(...allParams).run();

// ĐÚNG: batch, 1 subrequest, không giới hạn text per statement
const stmts = rows.map(r =>
  env.DB.prepare("INSERT INTO events VALUES (?, ?, ?)").bind(r.a, r.b, r.c)
);
await env.DB.batch(stmts);

.all() load hết vào memory

// Cẩn thận: 100k row sẽ load 100k object vào isolate
const { results } = await env.DB.prepare("SELECT * FROM events").all();

Isolate memory cap 128MB (Part 2). 100k row nặng có thể OOM. Dùng LIMIT + pagination:

const page = 1;
const size = 100;
const { results } = await env.DB.prepare("SELECT * FROM events LIMIT ? OFFSET ?")
  .bind(size, (page - 1) * size)
  .all();

⑦ Index không được dùng nếu không match

CREATE INDEX idx_posts_author_published ON posts(author_id, published_at);

-- Hit index:
SELECT * FROM posts WHERE author_id = ? ORDER BY published_at DESC;

-- Hit index (prefix):
SELECT * FROM posts WHERE author_id = ?;

-- KHÔNG hit index:
SELECT * FROM posts WHERE published_at > ?;  -- bỏ qua cột đầu
SELECT * FROM posts ORDER BY published_at;   -- không có WHERE author_id

Composite index hoạt động theo left-prefix. Check bằng EXPLAIN QUERY PLAN:

const { results } = await env.DB.prepare("EXPLAIN QUERY PLAN SELECT ... ").all();
console.log(results);  // SCAN vs SEARCH

SCAN = full scan bảng. SEARCH dùng index.


Production checklist

  • Primary region match với nơi có nhiều write nhất.
  • Sessions API bật cho endpoint cần read-your-write.
  • Timestamp lưu INTEGER (ms since epoch), không TEXT.
  • Boolean dùng INTEGER 0/1 với CHECK constraint.
  • Bind parameter, không nối string.
  • Multi-statement write dùng batch(), không loop.
  • Query với WHERE match index (check EXPLAIN QUERY PLAN).
  • LIMIT + pagination cho query trả nhiều row.
  • Migration test local trước, có reverse migration sẵn.
  • meta.rows_read được monitor để detect slow query.

Kết

D1 là SQLite edge-managed với primary-replica, không phải “SQLite in your Worker”. Mỗi query là 1 subrequest, chi phí phụ mạng thật. Thiết kế truy cập hiểu điều này giảm cháy budget và stale read.

5 query method phủ 99% trường hợp sử dụng. Sessions API giải read-your-write khi cần. Migration là additive-only, reverse phải viết tay. 7 gotcha ở trên là những cái mình đã trả giá khi build blog.

Tham chiếu thêm: d1-schema-tips cho retrospective về schema design thực tế.

Part 7 tới: R2 object storage — S3-compat, egress free, presigned URL, và khi nào R2 thay S3 làm nghi ngờ hợp lý.


Tham khảo