TL;DR
- Bài học từ chạy D1 với ~50 triệu row trong 6 tháng — SQLite ở edge chịu áp lực rất khác SQLite trên laptop.
- Composite primary key kiểu
(account_id, region, resource_id, control_id)tốt hơnid TEXT PRIMARY KEYkhi natural key ổn định — tiết kiệm storage và bỏ unique index phụ.- Index phải bám mẫu query, không bám cảm tính —
severityđộ chọn lọc thấp; đặt nó saulanding_zone_id,status,account_idtrong composite index.- Tìm kiếm tự do dùng SQLite
fts5thayLIKE '%keyword%'— wildcard ở đầu khiến index thông thường vô dụng khi bảng vài triệu row; tiếng Việt cần normalize Unicode + bỏ dấu.- Sharding là phương án cuối — trước đó: column-prune (đẩy raw JSON/HTML/evidence ra R2), archive dữ liệu
> 90 ngày, dùng bảngfinding_summary_dailydenormalized.- Time travel ≠ backup: chạy
wrangler d1 export --remotehàng ngày sang R2 với retention 30d/12w/12mo, và phải test restore — backup chưa từng restore thành công chỉ là cảm giác an toàn.
Cloudflare D1 rất dễ bắt đầu.
Bạn tạo database, viết vài câu SQL, triển khai Worker, và mọi thứ chạy khá mượt. Vì D1 dựa trên SQLite, cảm giác ban đầu thường là: “cũng giống SQLite thôi”.
Nhưng khi đưa D1 vào production, đặc biệt với khối lượng công việc có hàng chục triệu row, bạn sẽ nhanh chóng nhận ra một điều:
D1 là SQLite, nhưng SQLite chạy ở edge có những áp lực rất khác SQLite chạy trên laptop hoặc trong một VM backend truyền thống.
Bài viết này tổng hợp năm bài học mình rút ra sau khoảng 6 tháng vận hành D1 với khoảng 50 triệu row. Một số bài học đến từ benchmark. Một số khác đến từ những lần dashboard chậm, query scan quá nhiều row, hoặc chuyển đổi tưởng đơn giản nhưng làm hệ thống mất ổn định.
Bối cảnh
Mình dùng D1 cho một số khối lượng công việc dạng metadata index:
- Finding từ scanner CSPM.
- Trạng thái scan.
- Inventory tài nguyên.
- Ánh xạ giữa account, landing zone, người phụ trách và framework.
- Bộ lọc dashboard cho Security Operations.
- Một phần audit trail nhẹ.
Đây không phải tải OLTP nặng kiểu banking core, cũng không phải analytics warehouse. Nhưng dữ liệu tăng đều theo thời gian, mẫu query lặp lại nhiều, và dashboard cần phản hồi nhanh.
Ban đầu, schema được thiết kế khá giống cách nhiều người quen dùng với Postgres hoặc MySQL:
CREATE TABLE findings (
id TEXT PRIMARY KEY,
account_id TEXT,
finding_id TEXT,
region TEXT,
severity TEXT,
status TEXT,
created_at TEXT
);
Nhìn qua thì ổn. Nhưng khi dữ liệu tăng lên hàng triệu rồi hàng chục triệu row, các quyết định schema nhỏ bắt đầu tạo ra khác biệt rất lớn.
1. Composite primary key thường tốt hơn surrogate ID
Sai lầm đầu tiên của mình là dùng surrogate ID cho gần như mọi bảng.
Ví dụ:
id TEXT PRIMARY KEY
Sau đó mới thêm các cột thật sự định danh bản ghi:
account_id TEXT
finding_id TEXT
region TEXT
resource_id TEXT
control_id TEXT
Cách này quen thuộc, dễ viết code, dễ debug. Nhưng với D1, đặc biệt khi bảng lớn dần, nó tạo ra hai vấn đề.
Thứ nhất, bạn phải lưu thêm một cột id không mang nhiều ý nghĩa nghiệp vụ.
Thứ hai, natural key thật sự vẫn cần unique index riêng để chống duplicate.
Ví dụ:
CREATE UNIQUE INDEX idx_findings_unique
ON findings(account_id, region, resource_id, control_id);
Như vậy, cùng một logic định danh nhưng database phải duy trì cả primary key surrogate và unique index phụ.
Sau một vài lần đo storage và mẫu query, mình chuyển sang dùng composite primary key cho các bảng có natural key ổn định.
Ví dụ:
CREATE TABLE findings (
account_id TEXT NOT NULL,
region TEXT NOT NULL,
resource_id TEXT NOT NULL,
control_id TEXT NOT NULL,
landing_zone_id TEXT NOT NULL,
service TEXT,
severity TEXT,
status TEXT,
risk_score INTEGER,
first_seen_at TEXT,
last_seen_at TEXT,
PRIMARY KEY (account_id, region, resource_id, control_id)
);
Với các bảng kiểu finding CSPM, natural key thường rất rõ:
account_id + region + resource_id + control_id
Hoặc trong một số trường hợp:
landing_zone_id + account_id + region + finding_id
Khi natural key ổn định, dùng nó làm primary key giúp:
- Giảm storage do không cần thêm surrogate ID.
- Tránh phải tạo unique index phụ.
- Giữ query theo prefix key hiệu quả hơn.
- Giảm rủi ro trùng finding.
- Làm schema phản ánh đúng mô hình domain hơn.
Mình chỉ dùng surrogate ID khi dữ liệu không có natural key ổn định, ví dụ:
- Nội dung do người dùng tạo.
- Log sự kiện có thể trùng nội dung.
- Record cần định danh độc lập với key nghiệp vụ.
- Thực thể có key nghiệp vụ có thể thay đổi.
Bài học ở đây khá đơn giản:
Nếu dữ liệu đã có natural key rõ ràng và ổn định, đừng vội tạo thêm surrogate ID chỉ vì thói quen.
2. Chỉ index những cột thật sự dùng trong WHERE, JOIN, hoặc ORDER BY
Khi dashboard bắt đầu chậm, phản xạ rất tự nhiên là thêm index.
Query chậm theo severity? Thêm index.
Query chậm theo status? Thêm index.
Query chậm theo region? Thêm index.
Một thời gian sau, schema có rất nhiều index đơn lẻ:
CREATE INDEX idx_findings_severity ON findings(severity);
CREATE INDEX idx_findings_status ON findings(status);
CREATE INDEX idx_findings_region ON findings(region);
CREATE INDEX idx_findings_account ON findings(account_id);
CREATE INDEX idx_findings_framework ON findings(framework);
Nhìn có vẻ an toàn, nhưng thực tế không tối ưu.
Mỗi index đều có chi phí:
- Tăng storage.
- Làm chậm insert/update.
- Làm migration nặng hơn.
- Có thể không được bộ lập kế hoạch query dùng nếu độ chọn lọc thấp.
- Không giải quyết tốt query nhiều điều kiện.
Ví dụ dashboard thường không query kiểu này:
SELECT *
FROM findings
WHERE severity = 'CRITICAL';
Thực tế query thường là:
SELECT *
FROM findings
WHERE landing_zone_id = ?
AND account_id = ?
AND severity IN ('HIGH', 'CRITICAL')
AND status = 'FAIL'
ORDER BY risk_score DESC, last_seen_at DESC
LIMIT 100;
Với query như trên, nhiều single-column index không hiệu quả bằng một composite index đúng mẫu.
Ví dụ:
CREATE INDEX idx_findings_dashboard
ON findings (
landing_zone_id,
account_id,
status,
severity,
risk_score,
last_seen_at
);
Hoặc nếu dashboard thường lọc theo landing zone trước:
CREATE INDEX idx_findings_lz_status_severity
ON findings (
landing_zone_id,
status,
severity,
last_seen_at
);
Nguyên tắc mình áp dụng:
| Trường hợp | Cách xử lý |
|---|---|
Cột xuất hiện thường xuyên trong WHERE | Cân nhắc index |
| Query dùng nhiều điều kiện cố định | Ưu tiên composite index |
| Cột có độ đa dạng thấp | Không vội index đơn lẻ |
| Cột chỉ hiển thị, không lọc | Không index |
| Cột ít dùng, query không phải đường quan trọng | Không index ngay |
| Query phục vụ dashboard chính | Đo và index có chủ đích |
Một ví dụ cột không nên vội index là severity.
Severity thường chỉ có vài giá trị:
CRITICAL, HIGH, MEDIUM, LOW, INFORMATIONAL
Nếu chỉ index riêng severity, database vẫn có thể phải scan rất nhiều row vì độ chọn lọc thấp. Nhưng nếu severity nằm sau landing_zone_id, status, account_id trong composite index, hiệu quả thực tế tốt hơn nhiều.
Bài học:
Index phải đi theo mẫu query, không đi theo cảm giác.
3. FTS vẫn rất đáng dùng cho free-text search
Dashboard nào rồi cũng sẽ có ô tìm kiếm.
Ban đầu, cách dễ nhất là dùng LIKE:
SELECT *
FROM findings
WHERE title LIKE '%public bucket%'
OR resource_id LIKE '%public bucket%'
OR remediation LIKE '%public bucket%';
Cách này ổn khi dữ liệu nhỏ. Nhưng khi bảng tăng lên vài trăm nghìn hoặc vài triệu row, query dạng %keyword% bắt đầu chậm rõ rệt.
Lý do là wildcard ở đầu chuỗi khiến database rất khó tận dụng index thông thường.
Với các trường hợp tìm kiếm tự do, mình chuyển sang dùng FTS.
Ví dụ tạo bảng FTS:
CREATE VIRTUAL TABLE findings_fts
USING fts5(
finding_pk,
title,
resource_id,
remediation,
content
);
Khi ingest finding, ghi thêm dữ liệu vào bảng FTS:
INSERT INTO findings_fts (
finding_pk,
title,
resource_id,
remediation,
content
)
VALUES (?, ?, ?, ?, ?);
Khi tìm kiếm:
SELECT finding_pk
FROM findings_fts
WHERE findings_fts MATCH ?
LIMIT 50;
Sau đó join ngược về bảng chính:
SELECT f.*
FROM findings f
JOIN findings_fts s
ON s.finding_pk = f.finding_pk
WHERE findings_fts MATCH ?
LIMIT 50;
FTS phù hợp cho:
- Tìm theo tiêu đề.
- Tìm theo tên tài nguyên.
- Tìm theo văn bản khắc phục.
- Tìm theo mô tả control.
- Tìm theo người phụ trách hoặc metadata dạng văn bản.
- Tìm qua nhiều trường trong dashboard.
Tuy nhiên, với tiếng Việt cần lưu ý thêm. SQLite FTS không có stemmer tiếng Việt chính thức giống một số ngôn ngữ khác. Nếu dữ liệu có nhiều tiếng Việt, nên cân nhắc tiền xử lý trước khi ghi vào bảng FTS:
- Chuẩn hóa Unicode.
- Chuyển về chữ thường.
- Loại bỏ hoặc chuẩn hóa dấu nếu trường hợp sử dụng cần.
- Chuẩn hóa khoảng trắng.
- Tách token nhất quán.
- Lưu thêm một trường đã chuẩn hóa tìm kiếm nếu cần.
Ví dụ đơn giản:
"Máy chủ cấu hình sai Security Group"
có thể normalize thành:
"may chu cau hinh sai security group"
Tùy trường hợp sử dụng, bạn có thể lưu cả bản gốc và bản normalized để vừa hiển thị đúng, vừa tìm kiếm dễ hơn.
Bài học:
Với tìm kiếm tự do, đừng cố kéo dài tuổi thọ của
LIKE '%keyword%'. FTS nên được thiết kế từ đầu nếu tìm kiếm là tính năng chính.
4. Sharding là phương án cuối cùng, không phải phản xạ đầu tiên
Khi bảng lớn nhanh, phản xạ thường là nghĩ đến sharding.
Ví dụ:
findings_2026_01
findings_2026_02
findings_2026_03
Hoặc:
findings_lz_a
findings_lz_b
findings_lz_c
Sharding có thể cần thiết ở một thời điểm nào đó, nhưng với mình, đó là phương án cuối cùng.
Trước khi shard, có ba việc nên làm trước.
4.1. Column pruning
Không phải dữ liệu nào cũng nên nằm trong D1.
Ban đầu, mình có xu hướng lưu mọi thứ vào bảng finding:
raw_json TEXT,
html_report TEXT,
evidence TEXT,
debug_log TEXT
Việc này làm row phình rất nhanh.
Sau đó mình tách rõ:
| Dữ liệu | Nơi lưu |
|---|---|
| Trường dùng để lọc/query | D1 |
| Raw JSON report | R2 |
| HTML report | R2 |
| File screenshot/bằng chứng | R2 |
| Debug log | R2 |
| Metadata pointer | D1 |
Ví dụ D1 chỉ giữ pointer:
artifact_url TEXT,
raw_json_url TEXT,
evidence_url TEXT
Cách này giúp D1 nhỏ hơn, query nhanh hơn, và storage hợp lý hơn.
Nguyên tắc:
D1 nên giữ metadata và index. Artifact lớn nên đưa ra ngoài.
4.2. Archive mạnh tay
Không phải dữ liệu cũ nào cũng cần nằm trong hot table.
Với dashboard vận hành hằng ngày, đa số query tập trung vào:
- Finding hiện tại.
- Finding mới phát hiện.
- Finding chưa giải quyết.
- Finding trong 7, 30 hoặc 90 ngày gần nhất.
Dữ liệu cũ hơn có thể chuyển sang lưu trữ.
Ví dụ:
| Loại dữ liệu | Nơi lưu |
|---|---|
| Finding đang hoạt động | D1 hot table |
| Finding trong 90 ngày | D1 hoặc bảng tổng hợp |
| Finding cũ hơn 90 ngày | R2 lưu trữ |
| Dữ liệu lịch sử raw | R2 Parquet / JSONL |
Có thể thiết kế vòng đời như sau:
0 - 90 ngày:
D1 hot table
> 90 ngày:
Xuất sang R2
Xóa khỏi hot table hoặc chuyển sang bảng lưu trữ
> 1 năm:
Giữ aggregate + artifact cần thiết cho kiểm toán
Nếu cần analytics dài hạn, R2 với Parquet sẽ hợp lý hơn D1.
4.3. Denormalize bằng summary table
Dashboard không nên lúc nào cũng query bảng finding gốc.
Ví dụ để hiển thị số lượng finding theo severity:
SELECT severity, COUNT(*)
FROM findings
WHERE landing_zone_id = ?
GROUP BY severity;
Query này ổn khi dữ liệu nhỏ. Nhưng nếu dashboard nào cũng chạy aggregate trực tiếp trên bảng lớn, chi phí sẽ tăng nhanh.
Thay vào đó, có thể tính trước bảng tổng hợp:
CREATE TABLE finding_summary_daily (
date TEXT NOT NULL,
landing_zone_id TEXT NOT NULL,
account_id TEXT,
region TEXT,
severity TEXT NOT NULL,
status TEXT NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY (
date,
landing_zone_id,
account_id,
region,
severity,
status
)
);
Dashboard tổng quan có thể đọc bảng tổng hợp thay vì scan bảng finding.
Ví dụ:
SELECT severity, SUM(count) AS total
FROM finding_summary_daily
WHERE landing_zone_id = ?
AND date >= ?
GROUP BY severity;
Cách này đặc biệt hữu ích cho:
- Xu hướng theo ngày.
- Đếm theo severity.
- Đếm theo framework.
- Đếm theo account.
- Dashboard SLA.
- Tóm tắt cho lãnh đạo.
Bài học:
Trước khi shard, hãy giảm kích thước row, lưu trữ dữ liệu lạnh, và tính trước aggregate.
5. Backup là trách nhiệm của bạn
D1 có time travel, và tính năng này rất hữu ích cho một số tình huống vận hành:
- Chạy nhầm
UPDATE. - Xóa nhầm một nhóm record.
- Cần khôi phục trạng thái gần đây.
- Truy nguyên dữ liệu trước/sau một chuyển đổi.
Nhưng time travel không nên được xem là chiến lược khôi phục thảm họa hoàn chỉnh.
Một chiến lược backup thực tế vẫn nên có xuất định kỳ ra lưu trữ độc lập.
Ví dụ đơn giản:
wrangler d1 export <DATABASE_NAME> --remote --output backup.sql
Sau đó tải file backup lên R2:
wrangler r2 object put cspm-d1-backups/$(date +%F)/backup.sql --file backup.sql
Một lịch backup tối thiểu có thể là:
| Tần suất | Retention |
|---|---|
| Hằng ngày | 30 ngày |
| Hằng tuần | 12 tuần |
| Hằng tháng | 12 tháng |
Với môi trường production, nên bổ sung thêm:
- Backup trước mỗi chuyển đổi lớn.
- Backup trước batch update/delete.
- Kiểm thử khôi phục định kỳ.
- Checksum hoặc xác minh kích thước sau backup.
- Tách quyền ghi database và quyền xóa backup.
- Lưu backup ở bucket có vòng đời và chính sách truy cập riêng.
Điểm quan trọng nhất là phải kiểm thử khôi phục.
Backup chưa từng khôi phục thành công thì chỉ là cảm giác an toàn.
Ví dụ quy trình kiểm thử khôi phục:
1. Xuất production D1.
2. Tải backup lên R2.
3. Tạo database D1 staging mới.
4. Nhập backup vào staging.
5. Chạy sanity check:
- row count
- sample query
- smoke test dashboard
6. Ghi log kết quả kiểm thử khôi phục.
Bài học:
Time travel giúp xử lý lỗi gần. Backup giúp sống sót khi có sự cố lớn. Hai thứ này không thay thế cho nhau.
Schema tham khảo sau khi tối ưu
Sau nhiều lần điều chỉnh, schema của mình đi theo hướng tách rõ hot metadata, artifact pointer và summary.
Ví dụ bảng finding:
CREATE TABLE findings (
landing_zone_id TEXT NOT NULL,
account_id TEXT NOT NULL,
region TEXT NOT NULL,
resource_id TEXT NOT NULL,
control_id TEXT NOT NULL,
account_name TEXT,
service TEXT,
resource_type TEXT,
title TEXT,
severity TEXT NOT NULL,
status TEXT NOT NULL,
risk_score INTEGER,
owner_team TEXT,
framework TEXT,
artifact_url TEXT,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
resolved_at TEXT,
PRIMARY KEY (
landing_zone_id,
account_id,
region,
resource_id,
control_id
)
);
Index phục vụ dashboard chính:
CREATE INDEX idx_findings_lz_status_severity
ON findings (
landing_zone_id,
status,
severity,
last_seen_at
);
Index phục vụ view người phụ trách:
CREATE INDEX idx_findings_owner_status
ON findings (
owner_team,
status,
risk_score,
last_seen_at
);
FTS cho tìm kiếm:
CREATE VIRTUAL TABLE findings_fts
USING fts5(
finding_key,
title,
resource_id,
service,
framework,
content
);
Summary table:
CREATE TABLE finding_summary_daily (
date TEXT NOT NULL,
landing_zone_id TEXT NOT NULL,
account_id TEXT,
region TEXT,
severity TEXT NOT NULL,
status TEXT NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY (
date,
landing_zone_id,
account_id,
region,
severity,
status
)
);
Những nguyên tắc mình đang dùng
Sau một thời gian vận hành, mình chốt lại một số nguyên tắc thiết kế schema D1 như sau:
| Nguyên tắc | Ý nghĩa |
|---|---|
| Natural key trước, surrogate ID sau | Chỉ dùng surrogate ID khi không có key nghiệp vụ ổn định |
| Mẫu query quyết định index | Không index theo cảm tính |
| Composite index tốt hơn nhiều index rời | Đặc biệt với dashboard lọc nhiều điều kiện |
| D1 giữ metadata, R2 giữ artifact | Tránh phình row không cần thiết |
| FTS cho tìm kiếm tự do | Không dùng LIKE '%keyword%' cho bảng lớn |
| Bảng tổng hợp cho dashboard aggregate | Không aggregate trực tiếp hot table quá nhiều |
| Lưu trữ dữ liệu lạnh | Hot table càng nhỏ càng tốt |
| Backup độc lập với time travel | DR phải có xuất và kiểm thử khôi phục |
Điều rút ra
D1 rất mạnh nếu dùng đúng vai trò.
Nó phù hợp cho metadata, index, query dashboard, tải transactional nhẹ và ứng dụng chạy gần Cloudflare Workers. Nhưng nếu xem D1 như một database truyền thống rồi nhét mọi loại dữ liệu vào một bảng lớn, bạn sẽ sớm gặp giới hạn về row scan, storage, index và chuyển đổi.
Năm điều mình rút ra rõ nhất:
- Composite primary key thường tốt hơn surrogate ID nếu natural key ổn định.
- Index phải bám sát mẫu query, đặc biệt là các bộ lọc chính của dashboard.
- FTS nên được dùng cho tìm kiếm tự do thay vì kéo dài với
LIKE. - Sharding là phương án cuối cùng; hãy column pruning, archive và denormalize trước.
- Backup là trách nhiệm vận hành bắt buộc, không nên phụ thuộc hoàn toàn vào time travel.
Kết
Nếu bạn mới bắt đầu với D1, không nhất thiết phải tối ưu mọi thứ ngay ngày đầu tiên.
Một số quyết định có thể đợi đến khi dữ liệu đủ lớn để đo đạc. Nhưng có hai việc nên làm sớm:
- Thiết kế primary key và index theo mẫu query thật.
- Thiết lập backup ra R2 ngay từ đầu.
Schema tốt không làm hệ thống tự động mở rộng vô hạn, nhưng nó giúp bạn tránh được rất nhiều lần đau không cần thiết khi dữ liệu bắt đầu tăng nhanh.
Với D1, bài học quan trọng nhất của mình là:
Đừng thiết kế schema như thể dữ liệu sẽ mãi nhỏ. Nhưng cũng đừng tối ưu như thể bạn đã có vấn đề của 1 tỷ row. Đo trước, tối ưu sau, và luôn có đường khôi phục.