Năm lưu ý về schema D1 mình rút ra từ những bài học đau thương

Composite primary key, khi nào vẫn cần FTS, vì sao không nên index theo cảm tính, và vì sao row count ở edge quan trọng hơn bạn nghĩ.

· 11 phút đọc · Read in English
5 bài học thiết kế schema D1: composite primary key, full-text search FTS5, index theo dữ liệu thực thay vì cảm tính, và áp lực row count khi SQLite chạy ở edge

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ơn id TEXT PRIMARY KEY khi 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ínhseverity độ chọn lọc thấp; đặt nó sau landing_zone_id, status, account_id trong composite index.
  • Tìm kiếm tự do dùng SQLite fts5 thay LIKE '%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ảng finding_summary_daily denormalized.
  • Time travel ≠ backup: chạy wrangler d1 export --remote hà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ợpCách xử lý
Cột xuất hiện thường xuyên trong WHERECâ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ấpKhông vội index đơn lẻ
Cột chỉ hiển thị, không lọcKhông index
Cột ít dùng, query không phải đường quan trọngKhô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.


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ệuNơi lưu
Trường dùng để lọc/queryD1
Raw JSON reportR2
HTML reportR2
File screenshot/bằng chứngR2
Debug logR2
Metadata pointerD1

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ệuNơi lưu
Finding đang hoạt độngD1 hot table
Finding trong 90 ngàyD1 hoặc bảng tổng hợp
Finding cũ hơn 90 ngàyR2 lưu trữ
Dữ liệu lịch sử rawR2 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ấtRetention
Hằng ngày30 ngày
Hằng tuần12 tuần
Hằng tháng12 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 sauChỉ dùng surrogate ID khi không có key nghiệp vụ ổn định
Mẫu query quyết định indexKhô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ữ artifactTránh phình row không cần thiết
FTS cho tìm kiếm tự doKhông dùng LIKE '%keyword%' cho bảng lớn
Bảng tổng hợp cho dashboard aggregateKhông aggregate trực tiếp hot table quá nhiều
Lưu trữ dữ liệu lạnhHot table càng nhỏ càng tốt
Backup độc lập với time travelDR 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:

  1. Composite primary key thường tốt hơn surrogate ID nếu natural key ổn định.
  2. Index phải bám sát mẫu query, đặc biệt là các bộ lọc chính của dashboard.
  3. FTS nên được dùng cho tìm kiếm tự do thay vì kéo dài với LIKE.
  4. Sharding là phương án cuối cùng; hãy column pruning, archive và denormalize trước.
  5. 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.