Cloudflare D1 — Kho Lưu Trữ Dữ Liệu (Database) 🗄️¶
D1 Rốt Cuộc Là Cái Gì?¶
D1 thực chất là SQLite nhưng được mang lên mây (chạy tại mạng lưới toàn cầu Edge của Cloudflare). Thông thường, SQLite chỉ là một file dữ liệu nhỏ gọn nằm chết một chỗ trên máy tính, nhưng Cloudflare đã "độ" nó thành một hệ thống xịn sò với:
- Bản sao đọc (Read replicas): Dữ liệu được nhân bản ra nhiều nơi trên thế giới, ai ở gần đâu thì lấy dữ liệu ở đó cho nhanh.
- Tự động sao lưu (Automatic backup): Không lo mất dữ liệu.
- Bắt tay mượt mà với Workers: Kết nối trực tiếp với "Não bộ" mã code của bạn mà không cần cài đặt lằng nhằng.
Tại sao lại chọn SQLite?
SQLite là hệ thống cơ sở dữ liệu phổ biến nhất hành tinh (nó nằm ngầm trong mọi chiếc điện thoại Android, iOS mà bạn đang dùng). Nó cực kỳ đơn giản, không đòi hỏi cấu hình mạng phức tạp, và đủ sức gánh vác phần lớn các ứng dụng vừa và nhỏ.
So Kèo: D1 (SQLite) vs PostgreSQL ⚔️¶
| Tiêu chí | D1 (SQLite) | PostgreSQL |
|---|---|---|
| Cài đặt ban đầu | Dùng luôn, không cần cài cắm gì (Zero config) | Rất nhiều cấu hình (Connection string, pool...) |
| Chi phí | Xài chùa được rất nhiều (Free tier rộng) | Tốn tiền ngay từ ngày đầu |
| Sức mạnh | Ngon lành cho web < 1 triệu lượt truy cập/ngày | Siêu mạnh khi hệ thống khổng lồ |
| Tính năng | Các lệnh SQL cơ bản + Xử lý JSON | Full phép thuật (Stored procedures, extensions...) |
| Nhiều người ghi cùng lúc | Hơi đuối | Rất tốt |
| Tính toàn vẹn (Transactions) | ✅ Có hỗ trợ | ✅ Có hỗ trợ |
Khi nào thì chọn D1? Khi làm dự án cá nhân, làm bản thử nghiệm (MVP), web có lượng truy cập vừa phải (< 1 triệu request/tháng), hoặc team chỉ có vài người.
Khi nào phải lên đời PostgreSQL? Khi bạn cần các câu lệnh truy vấn phức tạp, tìm kiếm toàn văn bản, hoặc có hàng ngàn người thi nhau bấm nút "Lưu" cùng một phần nghìn giây.
Cách Lấy Dữ Liệu Từ D1 (Query) 🔍¶
"Chuẩn Bị Sẵn Lời Thoại" (Prepared Statements) — Cứu Tinh Của Bạn¶
// ✅ CHUẨN BÀI — Dùng dấu "?" để đặt chỗ, cực kỳ an toàn
const user = await c.env.DB.prepare(
'SELECT * FROM users WHERE email = ?'
).bind(email).first();
// ❌ NGUY HIỂM CHẾT NGƯỜI — Nối chuỗi kiểu này là mời hacker xơi dữ liệu!
const user = await c.env.DB.prepare(
`SELECT * FROM users WHERE email = '${email}'`
).first();
Cảnh báo đỏ: SQL Injection!
KHÔNG BAO GIỜ được nhét thẳng chữ người dùng gõ vào trong câu lệnh SQL. Phải luôn dùng dấu ? làm người đóng thế và truyền dữ liệu thật qua hàm .bind(). Nếu không, hacker có thể xóa sạch cơ sở dữ liệu của bạn chỉ bằng cách gõ vài ký tự lạ vào ô đăng nhập.
Các "Tuyệt Chiêu" Lấy Dữ Liệu (API Reference)¶
// Lấy đúng 1 dòng (Không có thì nó trả về null)
const user = await env.DB.prepare(
'SELECT * FROM users WHERE id = ?'
).bind(userId).first<User>();
// Lấy 1 rổ dữ liệu (Nhiều dòng)
const { results } = await env.DB.prepare(
'SELECT * FROM transactions WHERE group_id = ? ORDER BY date DESC LIMIT ?'
).bind(groupId, 50).all<Transaction>();
// Thực hiện hành động (Thêm/Sửa/Xóa)
const result = await env.DB.prepare(
'INSERT INTO users (id, email, name) VALUES (?, ?, ?)'
).bind(nanoid(), email, name).run();
// result.success sẽ là true/false
// result.meta.changes cho biết có bao nhiêu dòng vừa bị thay đổi
// Gom lại làm một cục (Batch execution) - Giống như lời thề: Hoặc cùng sống, hoặc cùng chết!
await env.DB.batch([
env.DB.prepare('INSERT INTO groups (id, name) VALUES (?, ?)').bind(groupId, name),
env.DB.prepare('INSERT INTO group_members (group_id, user_id) VALUES (?, ?)').bind(groupId, userId),
]);
Nhật Ký Xây Nhà (Migration System) 🏗️¶
Cấu trúc các bảng trong Database được quản lý qua các file migration. Hãy coi mỗi file như một trang nhật ký ghi lại các bước bạn "xây" hoặc "sửa" ngôi nhà dữ liệu của mình theo thời gian.
Cấu trúc file¶
api/migrations/
0001_initial_schema.sql # Lần 1: Xây móng, tạo các bảng cơ bản
0002_contributions_transaction_link.sql # Lần 2: Mở đường nối đóng quỹ và giao dịch
0003_contribution_nullable_member.sql # Lần 3: Cho phép người ngoài đóng quỹ
0004_group_bank_info.sql # Lần 4: Thêm 4 cột thông tin ngân hàng
0005_user_soft_delete.sql # Lần 5: Thêm tính năng "Xóa giả vờ"
0006_category_type.sql # Lần 6: Phân biệt loại thu/chi
0007_manage_fund_system_categories.sql # Lần 7: Bật tắt chế độ quản lý quỹ
Luật Thép Bất Di Bất Dịch: Một khi bạn đã chạy file migration nào lên mạng thật (Production), bạn tuyệt đối không được mở file đó ra sửa lại. Muốn thay đổi? Hãy tạo một file migration mới tinh (Ví dụ: 0008_...).
Ví dụ về trang nhật ký¶
-- 0001_initial_schema.sql (Xây móng nhà)
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- 0005_user_soft_delete.sql (Mua thêm đồ đạc sau này)
ALTER TABLE users ADD COLUMN deleted_at TEXT;
-- Nhớ nha: Không được lùi về file 0001 để thêm dòng này, phải viết file mới!
Cách áp dụng bản vẽ vào thực tế¶
# Áp dụng cho cái Database nháp ở máy bạn
wrangler d1 migrations apply group-accounting-db --local
# Áp dụng lên Database thật trên mạng
wrangler d1 migrations apply group-accounting-db
Đừng lo lắng, Wrangler rất thông minh. Nó tự nhớ xem file nào đã chạy rồi. Mỗi lần gõ lệnh, nó chỉ chạy những file MỚI mà thôi.
Bí Quyết Thiết Kế (Schema Design) — Tại Sao Lại Làm Thế Này? 🧠¶
1. Dùng mã ngẫu nhiên (TEXT nanoid) thay vì số thứ tự (1, 2, 3...)¶
-- ✅ Nên dùng: Mã ngẫu nhiên không đụng hàng
id TEXT PRIMARY KEY -- Ví dụ: "V8kJd2mNpXwQ3rY1" (21 ký tự)
-- ❌ Hạn chế dùng: Số tự tăng
id INTEGER PRIMARY KEY AUTOINCREMENT -- 1, 2, 3, ...
Lý do:
- Số tự tăng làm lộ bí mật kinh doanh: Khách hàng số 1000 biết ngay ứng dụng của bạn mới có 1000 người dùng.
- Gộp dữ liệu dễ bị đánh nhau (conflict).
- Mã
nanoidkhó đoán, mang tính toàn cầu, ai cũng tự tạo được mà không sợ trùng.
2. Xóa giả vờ (Soft Delete) thay vì Xóa sổ (Hard Delete)¶
-- Thêm một cột để đánh dấu ngày bị xóa
ALTER TABLE users ADD COLUMN deleted_at TEXT;
-- Lúc "xóa", thực ra chỉ ghi chú ngày giờ vào cột đó:
UPDATE users SET deleted_at = datetime('now') WHERE id = ?
-- Và nhớ khi tìm kiếm, phải chừa mặt những người có đánh dấu đó ra:
SELECT * FROM users WHERE deleted_at IS NULL
Lý do: Đây là app về tiền bạc! Bạn cần có sổ sách đối chiếu (audit trail). Nếu bạn xóa mất người dùng, lịch sử giao dịch của người đó sẽ bốc hơi theo, lúc đó tra kê toán bằng niềm tin à?
3. Tiền bạc cứ quy ra số nguyên (INTEGER)¶
Tại sao không lưu số thập phân (REAL/DECIMAL)?
Trong thế giới máy tính, số thập phân hay bị "lú". Ví dụ: 0.1 + 0.2 máy tính sẽ tính ra 0.30000000000000004 (Lỗi kinh điển!). Với số tiền lớn, điều này là thảm họa.
Hãy lưu mọi thứ bằng đơn vị nhỏ nhất (số nguyên). Lúc đem ra web hiển thị thì tùy ý mà chia ra.
4. Hiệu ứng Domino (Foreign Keys với CASCADE)¶
CREATE TABLE group_members (
id TEXT PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member', -- Chức vụ
UNIQUE(group_id, user_id) -- Chống spam: 1 người chỉ vào 1 nhóm được 1 lần
);
Chữ ON DELETE CASCADE giống như hiệu ứng domino: Nếu bạn xóa sập cái group (nhóm), tất cả các thành viên group_members râu ria liên quan sẽ tự động biến mất theo. Đỡ mất công dọn rác bằng tay.
Về 3 cấp bậc chức vụ trong nhóm:
admin(Trùm): Sửa mọi thứ, đá người khác, xóa giao dịch của bất kỳ ai.sub_admin(Lớp phó): Thêm/sửa giao dịch giùm người khác, nhưng không được phép lên mặt thăng chức cho ai.member(Dân thường): Tiền ai nấy quản, chỉ xem và sửa giao dịch của chính mình.
5. Lưu thời gian bằng Chữ (TEXT - ISO 8601)¶
Vì SQLite lười nên nó không có kiểu dữ liệu DATETIME xịn sò như người ta. Mình sẽ dùng kiểu Chữ (TEXT) với chuẩn ISO 8601. Yên tâm là kiểu này vẫn dùng để sắp xếp cũ mới (sort) cực kỳ chuẩn xác nhé.
Trọn Bộ Bản Vẽ Ngôi Nhà Dữ Liệu (Sau 7 lần nâng cấp) 🗺️¶
Đừng sợ đoạn code dưới đây, nó chỉ là tổng hợp lại thành quả sau 7 file migration thôi. Dành để tra cứu lúc cần.
-- ─── Bảng Người Dùng (Users) ──────────────────────────────────────
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, -- Mật khẩu đã băm nát: "pbkdf2:saltHex:hashHex"
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', -- Chức vụ hệ thống: 'super_admin' hoặc 'user'
deleted_at TEXT, -- Ngày bị "xóa giả vờ" (Lần nâng cấp 5)
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── Bảng Quỹ Nhóm (Groups) ───────────────────────────────────────
CREATE TABLE groups (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE, -- Đoạn link đẹp mắt: "nhom-gia-dinh-abc123"
name TEXT NOT NULL,
description TEXT,
invite_code TEXT NOT NULL UNIQUE, -- Mã mời nhập hội (8 chữ cái viết hoa)
currency TEXT NOT NULL DEFAULT 'VND',
-- Thông tin tài khoản ngân hàng (Lần nâng cấp 4)
bank_name TEXT, -- Tên NH: "Vietcombank"
bank_code TEXT, -- Mã NH: "VCB"
bank_account_no TEXT, -- Số tài khoản
bank_account_name TEXT, -- Tên người đứng thẻ
-- Nút bật/tắt chế độ quản lý quỹ (Lần nâng cấp 7)
manage_fund INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── Bảng Thành Viên Nhóm (Group Membership) ───────────────────────
CREATE TABLE group_members (
id TEXT PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member', -- Quyền hạn trong nhóm
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(group_id, user_id) -- Đảm bảo 1 user không vào 1 nhóm 2 lần
);
-- ─── Bảng Danh Mục Thu/Chi (Categories) ───────────────────────────
CREATE TABLE categories (
id TEXT PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6366f1', -- Màu sắc hiển thị
icon TEXT NOT NULL DEFAULT '💰', -- Icon dễ thương
-- Phân biệt rạch ròi Thu hay Chi (Lần nâng cấp 6)
type TEXT NOT NULL DEFAULT 'expense' CHECK(type IN ('income', 'expense')),
-- Hạng mục mặc định của hệ thống, cấm user xóa (Lần nâng cấp 7)
is_system INTEGER NOT NULL DEFAULT 0
);
-- ─── Bảng Giao Dịch (Transactions) ────────────────────────────────
CREATE TABLE transactions (
id TEXT PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
member_id TEXT NOT NULL REFERENCES group_members(id),
category_id TEXT REFERENCES categories(id),
type TEXT NOT NULL, -- Là tiền vào 'income' hay tiền ra 'expense'
amount INTEGER NOT NULL, -- Số tiền (Lưu bằng số nguyên)
description TEXT NOT NULL,
note TEXT,
date TEXT NOT NULL, -- Ngày xảy ra sự việc "2024-07-25"
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── Bảng Đóng Quỹ (Contributions) ────────────────────────────────
CREATE TABLE contributions (
id TEXT PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
member_id TEXT REFERENCES group_members(id), -- Có thể trống nếu là người ngoài (Lần 3)
contributor_name TEXT, -- Gõ tay tên người ngoài vào đây (Lần 3)
amount INTEGER NOT NULL,
note TEXT,
date TEXT NOT NULL,
transaction_id TEXT REFERENCES transactions(id), -- Gắn liền với giao dịch bên bảng transactions (Lần 2)
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── Mục Lục Tra Cứu Nhanh (Indexes) ──────────────────────────────
-- Giúp tìm kiếm dữ liệu nhanh hơn gấp bội phần
CREATE INDEX idx_transactions_group ON transactions(group_id);
CREATE INDEX idx_transactions_date ON transactions(date);
CREATE INDEX idx_contributions_group ON contributions(group_id);
CREATE INDEX idx_group_members_group ON group_members(group_id);
CREATE INDEX idx_group_members_user ON group_members(user_id);
Mẹo nhỏ đọc bản vẽ
Bản vẽ trên là kết quả cuối cùng sau 7 lần đập đi xây lại. Nếu bạn thắc mắc "Ủa tự nhiên có cái cột này làm gì?", hãy kéo lên tìm file migration có số thứ tự tương ứng. Trong đó sẽ ghi lại "lịch sử tiến hóa" và lý do tại sao nó lại được sinh ra đấy!