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

Thiết kế API tiết kiệm

Bảy luật đúc từ việc tối ưu app thật: request nhanh nhất là request không gửi, cấm select-all, danh sách trả thẻ gọn, timeout cho mọi lệnh gọi ra ngoài.

Cấp 330 phút đọc

Bài này là rulebook — bạn đối chiếu nó mỗi khi thêm hoặc sửa một đường API. Mọi luật ở đây đều đến từ một lần app chậm hoặc chết thật.

Luật 1 — Request nhanh nhất là request không gửi

Trước khi tối ưu một lệnh gọi, hỏi: có cần gọi không?

Ba cách bỏ hẳn lệnh gọi:

  • Gộp vào lượt nạp đầu. App gọi /api/nguoi-dung, /api/cau-hinh, /api/thong-bao, /api/danh-muc lúc mở trang → gộp thành một /api/bootstrap trả cả bốn. Bốn lượt đi–về thành một.
  • Nhớ lại ở phía trình duyệt. Danh mục sản phẩm đổi mỗi tháng một lần thì không cần tải lại mỗi lần mở trang.
  • Đừng gọi khi không ai nhìn. Bỏ mọi kiểu hỏi lặp lại theo chu kỳ (polling) nếu màn hình đang không hiển thị dữ liệu đó. Một app từng gọi /api/trang-thai mỗi 15 giây trên mọi màn hình, kể cả màn hình không dùng tới nó — bỏ đi là giảm 60% tổng số request, không mất tính năng nào.

Luật 2 — Nghiêm cấm select-all

Mọi truy vấn trả về danh sách phải có LIMIT và một con trỏ để lấy trang sau.

// CẤM
"SELECT * FROM don_hang ORDER BY id DESC"

// ĐÚNG
const moiTrang = Math.min(100, +(url.searchParams.get("moi_trang") || 50));
const truoc = url.searchParams.get("truoc");      // con trỏ: id nhỏ hơn
const { results } = await env.DB.prepare(
  `SELECT id, ma_don, ten_khach, tong_tien, trang_thai, tao_luc
     FROM don_hang ${truoc ? "WHERE id < ?" : ""}
    ORDER BY id DESC LIMIT ?`
).bind(...(truoc ? [truoc, moiTrang] : [moiTrang])).all();

return json({
  items: results,
  tiep: results.length === moiTrang ? results[results.length - 1].id : null,
});

Hai chi tiết đáng tiền:

  • Chặn trần moi_trang bằng Math.min. Không chặn thì ai cũng gọi được ?moi_trang=999999 và kéo sập app của bạn.
  • Con trỏ theo id < tốt hơn OFFSET. OFFSET 10000 buộc cơ sở dữ liệu đếm qua 10.000 dòng trước khi trả; con trỏ thì nhảy thẳng.

Luật 3 — Danh sách trả thẻ gọn, chi tiết mới trả đủ

// /api/don-hang        → chỉ các cột VẼ RA MÀN HÌNH danh sách
// /api/don-hang/:id    → đủ mọi cột, kèm chi tiết, kèm lịch sử

Lý do đã nói ở D1: chi phí đi theo byte. Một màn hình danh sách 50 dòng nên nặng khoảng 5–15 KB. Nếu đang là vài trăm KB, bạn đang gửi thứ không ai nhìn.

Cách tự kiểm, 30 giây: mở F12 → tab Network → tải lại trang → nhìn cột Size của lệnh gọi API. Con số đó là bản án.

Luật 4 — Mọi lệnh gọi ra ngoài phải có hạn giờ

Gọi API của bên thứ ba (bản đồ, SMS, Zalo, Google Sheets) mà không đặt hạn giờ: bên kia treo thì request của bạn treo theo, và người dùng ngồi nhìn vòng xoay vô tận.

async function goiNgoai(url, opt = {}, hanMs = 8000) {
  try {
    const r = await fetch(url, { ...opt, signal: AbortSignal.timeout(hanMs) });
    if (!r.ok) throw new Error("HTTP " + r.status);
    return await r.json();
  } catch (e) {
    console.log("Dich vu ngoai loi:", url, e.name);   // log, KHÔNG console.error
    return null;                                       // trả null, app vẫn chạy tiếp
  }
}

Luôn có đường lui. Dịch vụ tra địa chỉ chết thì hiện tọa độ thô, đừng chặn cả việc chấm công. Nguyên tắc: một dịch vụ phụ hỏng không được làm chết chức năng chính.

`console.log` hay `console.error` — có khác biệt thật

Lỗi của dịch vụ ngoài đã có đường lui thì ghi bằng console.log. Dùng console.error là nó vào bảng thống kê lỗi, làm tỉ lệ lỗi phồng lên, và che mất lỗi thật của bạn khi nhìn bảng quan sát.

Để dành console.error cho thứ thật sự hỏng và cần bạn xử lý.

Luật 5 — Thử lại có kỷ luật, không thử lại bừa

Chỉ thử lại với lỗi thoáng qua (mạng chập, 429, 503, cơ sở dữ liệu quá tải) và chỉ với thao tác đọc hoặc thao tác có khóa chống trùng.

async function thuLai(viec, lan = 3) {
  for (let i = 0; i < lan; i++) {
    try { return await viec(); }
    catch (e) {
      if (i === lan - 1) throw e;
      const cho = 200 * 2 ** i + Math.random() * 150;   // lùi dần + nhiễu ngẫu nhiên
      await new Promise(r => setTimeout(r, cho));
    }
  }
}

Phần Math.random() không phải trang trí: nếu 100 người cùng gặp lỗi và cùng thử lại sau đúng 400 ms, họ tạo ra một đợt sóng thứ hai đánh gục dịch vụ vừa hồi.

Tuyệt đối không thử lại cho thao tác ghi không có khóa chống trùng — bạn sẽ tạo hai đơn hàng giống nhau. Muốn an toàn thì để người gọi gửi kèm một mã duy nhất, và ràng buộc UNIQUE ở cơ sở dữ liệu.

Tôn trọng 429. Khi bên kia trả Retry-After, chờ đúng chừng đó, đừng tự quyết.

Luật 6 — Kiểm dữ liệu vào, trả lỗi đúng mã

function kiemDon(d) {
  const loi = [];
  if (!d.ten_khach || d.ten_khach.trim().length < 2) loi.push("Thieu ten khach");
  if (d.sdt && !/^0\d{9}$/.test(d.sdt.replace(/\s/g, ""))) loi.push("So dien thoai khong dung");
  if (!Number.isInteger(d.tong_tien) || d.tong_tien < 0) loi.push("Tong tien khong hop le");
  if (d.ghi_chu && d.ghi_chu.length > 1000) loi.push("Ghi chu qua dai");
  return loi;
}

const loi = kiemDon(body);
if (loi.length) return json({ loi: loi.join("; ") }, 400);

Bảng mã trả về — dùng đúng thì phía trình duyệt xử lý được tự động:

Mã Nghĩa Phía trình duyệt nên làm
400 Dữ liệu gửi lên sai Hiện lỗi ngay tại ô nhập
401 Chưa đăng nhập Về màn hình đăng nhập
403 Đã đăng nhập, không có quyền Hiện "không có quyền", đừng đá ra đăng nhập
404 Không có Hiện trạng thái rỗng
409 Trùng, xung đột "Đơn này đã tồn tại"
429 Quá nhiều lượt Chờ rồi thử lại
500 Lỗi của bạn Thông báo chung + ghi log

Lỗi hay gặp: trả 500 cho dữ liệu sai. Nó làm bạn tưởng app hỏng, và che mất lỗi thật trong bảng quan sát.

Luật 7 — Việc nặng không nằm trong request nóng

Đây là luật đã trả giá bằng một buổi chiều app chết.

// RẤT NGUY HIỂM
ctx.waitUntil(donDepHangLoat(env));    // 40 lượt xóa trên D1, chạy sau khi trả response

ctx.waitUntil chạy sau khi người dùng đã nhận kết quả, nên trông có vẻ miễn phí. Nhưng:

  • Nó có hạn thời gian; quá hạn Cloudflare hủy giữa chừng, để lại việc làm dở.
  • Trong lúc chạy, nó giữ D1 dùng chung — mà D1 xử lý tuần tự cả cơ sở dữ liệu. Mọi request khác xếp hàng chờ. App treo, và nhìn vào thì không thấy đường nào chậm cả.

Luật: waitUntil chỉ cho việc nhẹ và chấp nhận mất được — ghi một dòng log, gửi một thông báo. Việc nặng hoặc định kỳ thì chuyển sang Cron Trigger hoặc Queue, hoặc một đường /admin/... bạn tự bấm.

Bảy luật này đổi được bằng tiền thậtchuyên gia insight & case thực tế

App chấm công của một công ty 300 người, trước và sau khi áp đủ bảy luật:

Trước Sau
Thời gian mở app 26–90 giây 1–3 giây
Dữ liệu tải về mỗi lần mở ~4 MB ~80 KB
Số lệnh gọi lúc mở 7 1
Khiếu nại "app chậm" mỗi tuần 10–15 0

Không đổi khung, không nâng gói, không thuê thêm ai. Chỉ là: gộp lệnh gọi lúc mở, cắt cột không hiển thị, phân trang, và bỏ việc nặng ra khỏi request nóng.

Điều tôi muốn bạn nhớ: hiệu năng không phải việc làm sau cùng. Nó là hệ quả của bảy quyết định nhỏ lúc viết từng đường API. Sửa sau thì phải sửa cả giao diện lẫn máy chủ.

Bảng kiểm trước khi thêm một đường API

Dán vào README, đối chiếu mỗi lần:

[ ] Có thật sự cần đường này, hay gộp được vào đường có sẵn?
[ ] Trả danh sách? → có LIMIT + con trỏ + trần moi_trang
[ ] Chỉ trả cột được vẽ ra màn hình?
[ ] Có kiểm quyền ở máy chủ? Có ràng buộc theo dữ liệu của người đó?
[ ] Dữ liệu vào đã kiểm? Trả 400 chứ không phải 500 khi sai?
[ ] Gọi ra ngoài? → có hạn giờ + đường lui
[ ] Thao tác ghi? → có khóa chống trùng khi bấm hai lần
[ ] Việc nặng? → KHÔNG nằm trong waitUntil
[ ] Thông báo lỗi nói đúng nguyên nhân và có đường thoát?
Bắt AI tự rà API theo bảy luậtchuyên gia thực thi
Rà toàn bộ các đường /api/ trong project này theo 7 luật dưới đây.
Với mỗi vi phạm: ghi đường API, luật bị vi phạm, hậu quả cụ thể,
và cách sửa nhỏ nhất. Luật nào không vi phạm thì không cần nhắc.

1 Có đường nào gọi được gộp lại không?
2 Truy vấn danh sách nào thiếu LIMIT hoặc thiếu trần số dòng?
3 Danh sách nào đang SELECT * thay vì chỉ cột cần vẽ?
4 fetch ra ngoài nào thiếu timeout hoặc thiếu đường lui?
5 Chỗ nào thử lại cho thao tác GHI không có khóa chống trùng?
6 Chỗ nào trả 500 cho lỗi dữ liệu đáng lẽ là 400?
7 Có việc nặng nào nằm trong ctx.waitUntil không?

Chỉ đọc mã, đừng sửa gì. Sắp xếp theo mức độ hại, nặng nhất trước.
Luật tôi thấy khó tin nhất, hóa ra lại đúng nhấtngười dùng bình thường góp ý

Luật 3 — "đừng gửi cột mà màn hình không hiện". Tôi nghĩ vài cột thừa thì đáng bao nhiêu.

Rồi tôi làm đúng phép kiểm 30 giây: F12 → Network → nhìn cột Size. 3,8 MB cho một màn hình hiện 20 dòng. Thủ phạm là một cột ghi chú dài và một cột ảnh.

Từ đó tôi có thói quen: mỗi lần làm xong một màn hình, tôi liếc cột Size một cái. Mất 5 giây, và nó bắt lỗi tốt hơn mọi thứ khác tôi từng làm.

Chốt review bài nàychuyên gia review
  • Đủ: bảy luật, mã mẫu cho từng luật, bảng mã lỗi, bảng kiểm, đề rà tự động.
  • Đã trả giá thật: luật 7 (waitUntil giữ D1 → treo app) và luật 2 (thiếu LIMIT) đều từ sự cố production.
  • Rủi ro còn lại: bảng kiểm chỉ có tác dụng nếu bạn thật sự mở ra đối chiếu. Cách làm nó thành thói quen: để ngay trong README, và bắt AI đối chiếu trong mỗi lần review Pull Request.
Bài tập chốt
  1. Chạy đề rà tự động cho app của bạn, sửa mọi vi phạm luật 2 và luật 7 trước.
  2. Đo trước và sau bằng số: cỡ dữ liệu trả về cho màn hình chính (F12 → Network → Size) và số lệnh gọi lúc mở app. Chép bảng trước/sau vào README.
  3. Gộp các lệnh gọi lúc mở app thành một /api/bootstrap. Đo lại.

Nếu màn hình chính của bạn đang trên 200 KB, bạn sẽ thấy con số rơi rất nhanh — và đó là lần đầu bạn tối ưu một cách có bằng chứng.