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

KV và R2 — cấu hình và ảnh

Chọn đúng kho cho đúng loại dữ liệu, đưa ảnh ra khỏi cơ sở dữ liệu, và case thật: D1 từ 101 MB xuống 18,9 MB, trang từ 90 giây xuống 2 giây.

Cấp 340 phút làm

Lớp 3 của sơ đồ có ba kho, mỗi kho cho một loại dữ liệu. Dùng sai kho là nguyên nhân chậm số một của app Vibe Code.

Chọn kho trong 10 giây

Dữ liệu Kho Vì sao
Đơn hàng, nhân viên, báo cáo — thứ cần lọc, sắp xếp, cộng D1 Có SQL
Cấu hình, cờ bật/tắt, phiên đăng nhập, bộ đệm — tra theo một khóa KV Đọc rất nhanh, không cần SQL
Ảnh, PDF, video, file Excel xuất ra R2 Tính theo dung lượng, không mất phí truyền ra
Trạng thái thời gian thực, đếm chính xác, phòng chat Durable Objects Bài riêng

Câu hỏi phân loại nhanh: "Tôi có bao giờ cần hỏi những cái nào thỏa điều kiện X không?" Có → D1. Không, chỉ tra đúng một khóa → KV. Là file → R2.

KV — tra theo khóa, đọc nhanh

npx wrangler kv namespace create CAU_HINH

Dán khối kết quả vào wrangler.toml:

[[kv_namespaces]]
binding = "CAU_HINH"
id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Dùng:

// ghi
await env.CAU_HINH.put("bang-gia", JSON.stringify({ banh_cuon: 25000, cha: 15000 }));

// đọc (type:"json" để khỏi tự JSON.parse)
const gia = await env.CAU_HINH.get("bang-gia", { type: "json" });

// đọc có bộ đệm ở biên — nhanh hơn nhiều cho dữ liệu ít đổi
const gia2 = await env.CAU_HINH.get("bang-gia", { type: "json", cacheTtl: 300 });

// tự hết hạn sau 1 giờ — dùng cho phiên đăng nhập, mã OTP
await env.CAU_HINH.put("phien:" + maPhien, JSON.stringify(nguoiDung), { expirationTtl: 3600 });
KV nhất quán sau cùng — ghi xong đọc lại có thể ra bản cũ

Đây là đặc tính quan trọng nhất của KV và nó làm hỏng app theo cách rất khó hiểu: sau khi put, một lượt get ở điểm mạng khác có thể còn trả giá trị cũ trong khoảng tới một phút.

Hậu quả thật: admin sửa bảng giá, bấm lưu, trang tải lại vẫn hiện giá cũ → admin bấm lưu lại ba lần nữa.

Luật: KV cho thứ đọc nhiều, ghi ít, và lệch vài chục giây thì không chết ai — bảng giá, cờ bật/tắt, nội dung trang tĩnh, phiên đăng nhập. Đừng dùng KV cho: tồn kho, số dư, bộ đếm, đặt chỗ — mọi thứ mà hai người ghi cùng lúc thì một người phải thua. Những thứ đó thuộc D1 hoặc Durable Objects.

R2 — kho tệp, và vì sao nó quan trọng với app Việt Nam

R2 lưu file. Điểm khác biệt lớn nhất so với các dịch vụ tương đương: không tính phí truyền dữ liệu ra. Bạn chỉ trả tiền dung lượng lưu và số thao tác. Với app nhiều ảnh — chấm công, biên bản, catalogue sản phẩm — đây là khác biệt giữa vài chục nghìn và vài triệu mỗi tháng.

npx wrangler r2 bucket create app-anh
[[r2_buckets]]
binding = "ANH"
bucket_name = "app-anh"

Nhận ảnh tải lên và trả ảnh về:

// ---- Nhận ảnh ----
async function luuAnh(request, env) {
  const form = await request.formData();
  const file = form.get("anh");
  if (!file || typeof file === "string") return json({ loi: "Thieu anh" }, 400);

  // 1. Chặn cỡ — luôn luôn, trước mọi việc khác
  if (file.size > 5 * 1024 * 1024) return json({ loi: "Anh qua 5MB" }, 413);

  // 2. Chỉ nhận đúng loại mình phục vụ được
  const loaiOK = ["image/jpeg", "image/png", "image/webp"];
  if (!loaiOK.includes(file.type)) return json({ loi: "Chi nhan JPG, PNG, WEBP" }, 415);

  // 3. TỰ đặt tên — không bao giờ dùng tên file người dùng gửi lên
  const duoi = file.type === "image/png" ? "png" : file.type === "image/webp" ? "webp" : "jpg";
  const key = `don/${new Date().toISOString().slice(0, 10)}/${crypto.randomUUID()}.${duoi}`;

  await env.ANH.put(key, file.stream(), {
    httpMetadata: { contentType: file.type, cacheControl: "public, max-age=31536000, immutable" },
  });

  // 4. Trong D1 chỉ lưu ĐƯỜNG DẪN, không lưu ảnh
  await env.DB.prepare("UPDATE don_hang SET anh_key = ? WHERE id = ?").bind(key, donId).run();
  return json({ ok: true, key });
}

// ---- Trả ảnh ----
async function docAnh(key, env) {
  const obj = await env.ANH.get(key);
  if (!obj) return new Response("Khong co anh", { status: 404 });
  const h = new Headers();
  obj.writeHttpMetadata(h);
  h.set("etag", obj.httpEtag);
  return new Response(obj.body, { headers: h });
}

Bốn chỗ đánh số là bốn chỗ người mới hay bỏ, và mỗi chỗ là một lỗ hổng:

  1. Không chặn cỡ → một người tải lên video 2 GB là hết hạn mức của bạn.
  2. Không lọc loại file → có người tải lên file HTML, bạn phục vụ lại, thành trang lừa đảo nằm trên tên miền của bạn.
  3. Dùng tên file gốc → tên kiểu ../../index.html hoặc tên tiếng Việt có dấu gây lỗi; và hai người cùng tải anh.jpg thì đè nhau.
  4. Lưu ảnh vào D1 → xem case dưới đây.

Case thật: 101 MB xuống 18,9 MB

Một app chấm công lưu ảnh dạng base64 trong cột TEXT của D1. Lý do ban đầu hợp lý: "lưu chung một chỗ cho gọn, khỏi quản lý hai kho."

Sau 8 tháng:

Trước Sau khi chuyển ảnh sang R2
Cỡ D1 101 MB 18,9 MB
Mở trang danh sách 26–90 giây 1–3 giây
Xuất báo cáo thường hết giờ, lỗi xong trong 4 giây

Nguyên nhân đúng như quy luật ở bài D1: D1 chậm theo số byte trả về. Một truy vấn SELECT * trên bảng có cột base64 kéo về hàng chục MB, dù màn hình chỉ hiện 20 dòng.

Thêm hai hậu quả ít ai nghĩ tới:

  • Base64 phình 33%. Ảnh 300 KB thành 400 KB chữ trong cơ sở dữ liệu.
  • Ảnh trong D1 không được bộ đệm. Ảnh trong R2 phục vụ qua CDN với immutable, người dùng mở lần hai là tức thì.

Luật rút ra, áp dụng cho mọi app: cơ sở dữ liệu giữ sự thật về dữ liệu; kho tệp giữ tệp. Trong D1 chỉ nên có đường dẫn anh_key, không bao giờ có ảnh.

Script chuyển ảnh cũ sang R2 — làm từng mẻ, có thể dừngchuyên gia thực thi

Nếu app của bạn đã lỡ nhét ảnh vào D1, đừng chuyển một lần. Viết script chạy từng mẻ:

Viết script Node chuyển ảnh base64 trong cột `anh_base64` của bảng
don_hang sang R2, theo đúng các ràng buộc sau:

- Mỗi mẻ 50 dòng, dừng được giữa chừng và chạy lại tiếp được
  (chỉ lấy dòng có anh_base64 khác rỗng VÀ anh_key đang rỗng).
- Với mỗi dòng: giải mã base64 → put lên R2 với key theo ngày + uuid
  → cập nhật anh_key → CHỈ KHI cập nhật thành công mới xóa anh_base64.
- In tiến độ từng mẻ: đã xong bao nhiêu / còn bao nhiêu.
- Có cờ --thu để chạy thử KHÔNG ghi gì, chỉ in ra sẽ làm gì.

Chạy --thu trước, cho tôi xem kết quả, rồi chờ tôi đồng ý mới chạy thật.

Ba ràng buộc sống còn: chạy lại được (mạng đứt giữa chừng là chuyện thường), xóa sau khi xác nhận ghi xong (ngược lại là mất ảnh), và chế độ chạy thử. Và trước khi chạy thật: wrangler d1 export một bản sao lưu.

Ảnh: nén trước khi tải lên, ngay trên máy người dùng

Nhân viên chụp bằng điện thoại thì ảnh 4–8 MB. Tải nguyên lên là chậm, tốn hạn mức, và hại người dùng đang dùng 4G. Nén ngay trong trình duyệt trước khi gửi:

async function nenAnh(file, canhToiDa = 1600, chatLuong = 0.82) {
  const bm = await createImageBitmap(file);
  const ti = Math.min(1, canhToiDa / Math.max(bm.width, bm.height));
  const c = new OffscreenCanvas(Math.round(bm.width * ti), Math.round(bm.height * ti));
  c.getContext("2d").drawImage(bm, 0, 0, c.width, c.height);
  return await c.convertToBlob({ type: "image/jpeg", quality: chatLuong });
}

Thực tế đo được: ảnh 6,2 MB xuống còn ~380 KB, mắt thường không phân biệt trên điện thoại. Thời gian gửi trên 4G từ ~40 giây xuống ~3 giây.

Ba loại ảnh đừng nén
  1. Ảnh làm bằng chứng — biên bản, hóa đơn, chữ ký. Nén quá tay là không đọc được chữ. Giữ cạnh dài 2000–2400px.
  2. Ảnh chân dung người thật. Đó là thứ người ta soi kỹ nhất, không đáng đổi lấy vài trăm KB.
  3. Ảnh cần phóng to xem chi tiết — sản phẩm, tổn thương da, lỗi kỹ thuật.

Cách xử lý đúng: lưu hai bản — bản gốc trên R2 để tải về khi cần, bản nén ~800px để hiện trong danh sách. Danh sách tải bản nén, bấm vào mới tải bản gốc.

Phục vụ ảnh có kiểm quyền

Ảnh trong R2 không công khai trừ khi bạn mở. Với dữ liệu nội bộ, đừng mở bucket ra công khai — hãy cho ảnh đi qua Worker để kiểm quyền:

if (p.startsWith("/anh/")) {
  const u = await nguoiDungTuPhien(request, env);
  if (!u) return new Response("Chua dang nhap", { status: 401 });
  const key = decodeURIComponent(p.slice(5));
  // kiểm key này có thuộc dữ liệu mà người này được xem không
  if (!(await duocXemAnh(u, key, env))) return new Response("Khong co quyen", { status: 403 });
  return docAnh(key, env);
}

Dòng kiểm duocXemAnh là dòng hay bị bỏ nhất: nhiều app kiểm "đã đăng nhập" rồi cho xem mọi ảnh. Nhân viên A đổi một chữ số trong đường dẫn là xem được ảnh của nhân viên B.

Cái khách nhớ là ảnh mở nhanh, không phải tính năngchuyên gia insight & case thực tế

Trong các app chấm công / biên bản tôi làm, phản hồi của người dùng gần như luôn xoay quanh ảnh: "gửi ảnh lâu quá", "mở cái ảnh chờ mãi". Không ai khen cấu trúc dữ liệu.

Ba việc có tác động lớn nhất, theo thứ tự: 1. Nén phía máy người dùng trước khi gửi — cải thiện rõ nhất, và là chỗ ai cũng bỏ qua. 2. Hiện phần trăm và số giây khi đang gửi. Không làm nhanh hơn, nhưng người dùng hết bấm lại nhiều lần — mà bấm lại chính là thứ gây trùng dữ liệu. 3. Ảnh thu nhỏ cho danh sách. Trang danh sách tải 20 ảnh 800px thay vì 20 ảnh gốc là khác nhau một trời một vực trên 4G.

Tôi không hiểu vì sao phải có ba cái khongười dùng bình thường góp ý

Ba cái tên, ba cách dùng, tôi loạn. Người kèm cho tôi một ví dụ tôi nhớ tới giờ:

D1 là cuốn sổ kế toán — có dòng, có cột, cộng trừ được, tìm được "tháng 9 bán bao nhiêu". KV là tờ giấy dán tủ lạnh — ghi vài thứ, nhìn phát thấy ngay, nhưng không ai cộng sổ trên tờ giấy đó. R2 là cái tủ hồ sơ — cất giấy tờ, ảnh. Muốn lấy thì phải biết ngăn nào, nên trong sổ kế toán ghi "hồ sơ ở ngăn B12".

Từ lúc nghe ví dụ này tôi chưa chọn sai kho lần nào. Và câu "trong sổ ghi số ngăn" chính là luật trong D1 chỉ lưu đường dẫn ảnh.

Chốt review bài nàychuyên gia review
  • Đủ: phân loại ba kho, KV kèm cảnh báo nhất quán sau cùng, R2 với 4 chốt chặn khi nhận file, case 101 MB → 18,9 MB, nén phía máy người dùng, phục vụ ảnh có kiểm quyền.
  • Đã kiểm thật: con số của case chuyển ảnh và mức nén 6,2 MB → 380 KB đều từ app đang chạy.
  • Chưa nói: R2 presigned URL, bucket công khai kèm tên miền riêng, Cloudflare Images. Chúng hữu ích khi lưu lượng ảnh lớn, nhưng thêm một lớp phải hiểu.
  • Rủi ro còn lại: crypto.randomUUID() cho tên ảnh là khó đoán, không phải bí mật. Dữ liệu nhạy cảm vẫn phải kiểm quyền ở Worker như mục trên, đừng dựa vào việc đường dẫn khó đoán.
Bài tập chốt
  1. Tạo R2 bucket, thêm đường tải ảnh có đủ 4 chốt chặn (cỡ, loại, tự đặt tên, chỉ lưu đường dẫn vào D1).
  2. Tự tấn công app của mình: thử tải lên một file .html đổi tên thành .jpg, một file 20 MB, và một file tên ../../hack.jpg. Cả ba phải bị từ chối với thông báo rõ ràng.
  3. Thêm nén phía trình duyệt, đo bằng số: cỡ file trước và sau, chép vào README.
  4. Đăng nhập bằng một tài khoản khác rồi thử mở ảnh của người thứ nhất. Nếu xem được, bạn vừa tìm ra lỗ hổng thật trong app của mình — sửa trước khi đi tiếp.