Vibe Code
Giáo trình/Phần 3 — App có dự liệu

D1 — cơ sở dữ liệu SQL cho app của bạn

Tạo bảng, truy vấn an toàn, migration, và quy luật quan trọng nhất: D1 chậm theo số byte trả về, không theo số dòng.

Cấp 350 phút làm

D1 là cơ sở dữ liệu SQL (SQLite) của Cloudflare, gắn thẳng vào Worker. Đây là nơi dữ liệu app của bạn thật sự sống: đơn hàng, nhân viên, báo cáo, lượt chấm công.

Tạo và nối vào Worker

npx wrangler d1 create don-hang-db

Nó in ra một khối cấu hình — dán vào wrangler.toml:

[[d1_databases]]
binding = "DB"
database_name = "don-hang-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

Từ đó trong mã, env.DB là cơ sở dữ liệu của bạn.

Binding phải nằm trong wrangler.toml, không phải trên dashboard

Thêm binding bằng tay trên dashboard thì lần deploy sau nó biến mất — vì wrangler deploy áp cấu hình từ repo lên. App vẫn chạy nhưng env.DB thành undefined, và lỗi hiện ra là "Cannot read properties of undefined", chẳng nhắc gì tới binding.

Luật: wrangler.toml là nguồn sự thật duy nhất. Dashboard chỉ để xem.

Tạo bảng bằng migration, không bằng tay

Đừng gõ CREATE TABLE trực tiếp vào cơ sở dữ liệu thật. Viết thành file, lưu trong Git — để một năm sau bạn còn biết bảng của mình hình thành thế nào.

Tạo migrations/0001_khoi_tao.sql:

CREATE TABLE IF NOT EXISTS don_hang (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  ma_don      TEXT    NOT NULL UNIQUE,
  ten_khach   TEXT    NOT NULL,
  sdt         TEXT,
  tong_tien   INTEGER NOT NULL DEFAULT 0,
  trang_thai  TEXT    NOT NULL DEFAULT 'moi',
  ghi_chu     TEXT,
  tao_luc     TEXT    NOT NULL DEFAULT (datetime('now')),
  tao_boi     TEXT
);

CREATE INDEX IF NOT EXISTS idx_don_hang_tao_luc ON don_hang(tao_luc DESC);
CREATE INDEX IF NOT EXISTS idx_don_hang_trang_thai ON don_hang(trang_thai);

Năm quyết định trong 15 dòng này, mỗi cái có lý do:

1. Tiền lưu bằng INTEGER, đơn vị đồng. Không dùng số thực cho tiền — 0.1 + 0.2 trong máy tính không bằng 0.3, và sai số đó tích lại thành lệch sổ. Giá 25.000 đ lưu là 25000.

2. Thời gian lưu dạng chuỗi UTC (datetime('now') cho ra 2026-10-08 14:30:00). Quy đổi sang giờ Việt Nam ở chỗ hiển thị, không lưu giờ địa phương. Xem cảnh báo múi giờ ở dưới.

3. Luôn có tao_luc và tao_boi. Khi có tranh chấp ("ai nhập sai đơn này?"), hai cột này là bằng chứng. Thêm sau thì không có dữ liệu cũ.

4. UNIQUE trên ma_don. Đây là chốt chặn chống trùng ở tầng cơ sở dữ liệu — bấm đúp nút Lưu thì lần thứ hai bị từ chối. Kiểm trong mã thôi là không đủ: hai request chạy song song đều kiểm thấy "chưa có", rồi cả hai cùng ghi.

5. Index cho cột hay lọc/sắp xếp. Không có index, mỗi lần xem danh sách là quét cả bảng. Ở 500 dòng không thấy gì; ở 50.000 dòng thì app đứng.

Chạy migration:

npx wrangler d1 migrations apply don-hang-db --local    # máy mình
npx wrangler d1 migrations apply don-hang-db --remote   # thật

Truy vấn an toàn — một luật duy nhất

// ĐÚNG: dữ liệu đi qua dấu ?, không bao giờ nối vào chuỗi SQL
const { results } = await env.DB
  .prepare("SELECT * FROM don_hang WHERE trang_thai = ? ORDER BY tao_luc DESC LIMIT ?")
  .bind(trangThai, 50)
  .all();

// SAI — lỗ hổng SQL injection
const sql = `SELECT * FROM don_hang WHERE trang_thai = '${trangThai}'`;

Vì sao dòng SAI là nguy hiểm: nếu trangThai là ' OR 1=1 --, câu lệnh trả về toàn bộ bảng. Nếu là '; DROP TABLE don_hang; -- thì mất bảng. Và người tấn công không cần biết gì về app của bạn — có bot dò tự động.

Luật tuyệt đối: mọi giá trị từ người dùng đi qua ? và .bind(). Không có ngoại lệ, không có "chỗ này an toàn vì chỉ admin dùng".

Bốn cách lấy kết quả:

await stmt.all();     // { results: [...] }  — nhiều dòng
await stmt.first();   // một dòng, hoặc null  — dùng cho tra một bản ghi
await stmt.run();     // cho INSERT/UPDATE/DELETE, trả meta.changes
await env.DB.batch([ stmt1, stmt2 ]);   // nhiều câu trong một lượt gọi

batch quan trọng hơn vẻ ngoài của nó: mỗi lượt gọi D1 có chi phí đi–về cố định. Ba câu lệnh gọi riêng tốn gấp ba; gộp vào batch chỉ một lượt. Đây là cách tối ưu rẻ nhất mà hiệu quả nhất.

Quy luật quan trọng nhất về D1

D1 chậm theo số BYTE trả về, không theo số DÒNG.

Đo được từ app thật: tốc độ trả dữ liệu khoảng 100 KB mỗi giây qua binding. Nghĩa là:

Truy vấn Dữ liệu Thời gian
300 dòng, mỗi dòng 200 byte 60 KB ~0,6 giây
300 dòng, mỗi dòng có một ảnh base64 300 KB 90 MB treo

Case thật: một app lưu ảnh dạng base64 trong cột TEXT. Bảng 101 MB. Mỗi lần mở trang danh sách mất 26–90 giây, dù chỉ 818 dòng. Chữa bằng ba việc, kết quả xuống 1–3 giây:

  1. Chuyển ảnh sang R2, trong D1 chỉ giữ đường dẫn (xem KV & R2).
  2. Danh sách trả THẺ GỌN, không trả đủ mọi cột:
// Danh sách: chỉ cột cần để vẽ một dòng
"SELECT id, ma_don, ten_khach, tong_tien, trang_thai, tao_luc FROM don_hang ..."

// Chi tiết (khi bấm vào một dòng): mới trả đủ
"SELECT * FROM don_hang WHERE id = ?"
  1. Giới hạn và phân trang. Không bao giờ SELECT * không có LIMIT:
const trang = Math.max(1, parseInt(url.searchParams.get("trang") || "1"));
const moiTrang = 50;
const { results } = await env.DB
  .prepare("SELECT ... FROM don_hang ORDER BY id DESC LIMIT ? OFFSET ?")
  .bind(moiTrang, (trang - 1) * moiTrang)
  .all();
"App chậm" hầu như luôn là "trả về quá nhiều"chuyên gia insight & case thực tế

Khi ai nhờ tôi xem app chậm, tôi hỏi đúng một câu trước mọi câu khác: "Một lần mở trang này tải về bao nhiêu KB?" Mở tab Network, nhìn cột Size. Trong 9/10 lần, câu trả lời giải thích hết: 4 MB JSON cho một màn hình hiện 20 dòng.

Người ta hay đi tìm nguyên nhân ở chỗ sang trọng hơn — thiếu index, cần cache, cần nâng gói. Nhưng cái phải sửa trước là: đừng gửi thứ không hiện ra màn hình. Một màn hình 20 dòng cần khoảng 5 KB. Nếu đang là 4 MB, đó là 800 lần dư, và không có cache nào chữa được chuyện đó cho tử tế.

Múi giờ — cái bẫy đắt nhất của app Việt Nam

D1 lưu datetime('now') theo UTC. Việt Nam là UTC+7.

Hậu quả nếu nhầm: mọi hoạt động từ 17:00 đến 24:00 giờ Việt Nam bị ghi sang ngày hôm sau theo UTC. Báo cáo "hôm nay" thiếu cả buổi tối. Lỗi này tồn tại được hàng tuần vì mọi thứ trông như chạy đúng.

Hai cách xử lý, chọn một và ghi vào README:

-- Cách 1: lưu UTC, quy đổi khi truy vấn (gọn, chuẩn)
SELECT datetime(tao_luc, '+7 hours') AS gio_vn FROM don_hang;

-- Lọc "hôm nay theo giờ Việt Nam"
SELECT * FROM don_hang
WHERE date(tao_luc, '+7 hours') = date('now', '+7 hours');
// Cách 2: tự tính chuỗi ngày giờ Việt Nam trong mã rồi lưu
function ngayVN(d = new Date()) {
  return new Date(d.getTime() + 7 * 3600 * 1000).toISOString().slice(0, 10);
}

Đừng trộn hai cách. Trộn là có bảng vừa UTC vừa giờ Việt Nam, và không ai gỡ được nữa.

Truy vấn nhanh từ dòng lệnh

npx wrangler d1 execute don-hang-db --remote --command "SELECT COUNT(*) FROM don_hang"
npx wrangler d1 execute don-hang-db --remote --command "SELECT * FROM don_hang ORDER BY id DESC LIMIT 5"
Chữ tiếng Việt trong câu lệnh D1 trên Windows

Gõ --command "INSERT ... VALUES ('Nguyễn Văn A')" trên PowerShell là chữ bị đổi bảng mã: ễ thành ?, ă rụng dấu. Bạn sẽ tưởng D1 làm hỏng dữ liệu.

Cách đúng: ghi câu lệnh ra file rồi nạp file:

npx wrangler d1 execute don-hang-db --remote --file=./them-du-lieu.sql

Dấu hiệu nhận ra bệnh này: trong cùng một câu, chữ một dấu thì rụng dấu, chữ nhiều dấu thì thành ?.

Sao lưu — làm trước khi cần

npx wrangler d1 export don-hang-db --remote --output=backup-2026-10-08.sql

D1 có tính năng khôi phục theo thời điểm, nhưng đừng để nó là lớp duy nhất. Luật tối thiểu: xuất một bản trước mọi migration sửa cấu trúc bảng, và giữ file đó ngoài repo.

Đề thêm bảng mới đúng cáchchuyên gia thực thi
Thêm bảng "mon_an" vào app, qua migration mới trong migrations/.

Yêu cầu bắt buộc:
- File migration mới, KHÔNG sửa file migration cũ đã chạy.
- Có id, ten, gia (INTEGER, đơn vị đồng), dang_ban (0/1),
  tao_luc (UTC), tao_boi.
- Index cho cột hay lọc.
- Mọi truy vấn dùng .bind(), không nối chuỗi SQL.
- Đường danh sách chỉ trả các cột cần vẽ, có LIMIT và phân trang,
  mặc định 50 dòng/trang.

Rồi chạy migration ở --local, chèn 3 dòng mẫu, và tự kiểm bằng
wrangler d1 execute. Báo cho tôi kết quả thật của câu SELECT.

Câu "KHÔNG sửa file migration cũ" là câu phải có. Sửa file cũ thì cơ sở dữ liệu của bạn và của người khác (và bản thật) lệch nhau mà không ai biết.

Tôi quen Excel, nên tôi mắc đúng một lỗingười dùng bình thường góp ý

Tôi coi bảng cơ sở dữ liệu như một sheet Excel: cứ thêm cột cho đủ thứ. Đến khi bảng đơn hàng của tôi có 40 cột, trong đó 12 cột là ghi_chu_1 tới ghi_chu_12.

Người kèm chỉ cho tôi đúng một câu: "Cái gì có nhiều, tách thành bảng riêng." Một đơn hàng có nhiều món → bảng don_hang và bảng chi_tiet_don nối với nhau bằng don_hang_id. Nghe đơn giản mà tôi phải làm sai một lần mới hiểu.

Mẹo nhỏ của tôi: trước khi thêm cột, tự hỏi "cột này có thể cần tới cái thứ hai, thứ ba không?" Có thì nó là một bảng, không phải một cột.

Chốt review bài nàychuyên gia review
  • Đủ: tạo và nối D1, migration trong Git, truy vấn an toàn, batch, quy luật byte, danh sách gọn + phân trang, múi giờ, sao lưu, hai bẫy Windows/binding.
  • Đã kiểm thật: quy luật ~100 KB/giây và case 101 MB → 18,9 MB đều đo trên app đang chạy.
  • Chưa nói: giao dịch nhiều bước, D1 Sessions, bản sao đọc. Chưa cần ở Cấp 3.
  • Rủi ro còn lại: D1 xử lý tuần tự trên cả cơ sở dữ liệu. Hai app dùng chung một D1 thì app này nặng là app kia khựng — xem Nhiều app một tài khoản.
Bài tập chốt
  1. Tạo D1, viết migration cho bảng việc thật của bạn, chạy cả --local và --remote.
  2. Viết hai đường API: danh sách (có LIMIT + phân trang, chỉ cột cần) và chi tiết (đủ cột).
  3. Phép thử múi giờ: chèn một dòng vào lúc sau 17:00 giờ Việt Nam, rồi chạy truy vấn "hôm nay" cả hai kiểu (có và không có +7 hours). Thấy kết quả khác nhau — đó là bài học đắt nhất bài này, và bạn vừa học miễn phí.
  4. Xuất một bản sao lưu, mở file ra xem có dữ liệu thật trong đó.