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
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ẽ:
- Thử replica gần Worker nhất.
- Check commit token để đảm bảo replica đã catch up tới ít nhất transaction mình đã gửi.
- 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
① .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:
- Áp dụng migration vào D1 local với dữ liệu seed production-like.
- Chạy test suite.
- Áp dụng migration vào môi trường preview (D1 riêng).
- Chạy smoke test.
- 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
- D1 documentation
- D1 Sessions API
- D1 Worker API
- SQLite syntax (D1 tuân theo SQLite)