KV deep-dive: cache toàn cầu, eventual consistency, vs D1

Cloudflare KV là eventually-consistent KV store với cache tại từng PoP. Consistency model thực tế, giới hạn, 5 pattern đúng, 3 anti-pattern phổ biến, và gotcha thực tế.

· 6 phút đọc · Read in English
Cloudflare KV eventually-consistent: write tới central store rồi propagate ra 300+ PoP trong 60 giây, read từ cache edge <10ms, kèm 5 pattern đúng (feature flag, redirect map, session, metadata, rate-limit)

TL;DR

Cloudflare KV là eventually-consistent key-value store. Write đi tới central store, propagate ra 300+ PoP trong 60 giây. Read đọc từ cache edge PoP gần nhất, rẻ và nhanh (< 10ms).

Luận điểm chính:

KV là cache toàn cầu, không phải database. Thiết kế mọi tải KV giả định đọc nhiều, ghi ít, stale OK ≤ 60 giây. Nếu cần strong consistency hoặc ghi nhanh, không phải tải KV.

Bài này đi qua: consistency model thực tế (có diagram), giới hạn quan trọng nhất, 5 pattern đúng (tính năng cờ, redirect map, session ngắn hạn, asset metadata, cấu hình giới hạn tốc độ), 3 anti-pattern (counter, session chính, primary DB), và gotcha từ blog này.


Dành cho ai

  • Dev đã biết cơ bản Workers + binding, chuẩn bị dùng KV lần đầu.
  • Người đang dùng KV sai (counter, primary DB) và gặp throttle hoặc stale.
  • Ai đang chọn giữa KV / D1 / Durable Object cho tải cụ thể.

Nên đọc trước: Part 3 (3-binding mental model) để biết KV nằm ở đâu trong Storage layer.

Sau bài này bạn sẽ:

  • Hiểu consistency model KV và vì sao tốc độ ghi giới hạn ở 1/key/giây.
  • Biết 5 pattern KV sáng nhất.
  • Tránh 3 anti-pattern biến KV thành cái bẫy.
  • Dùng metadata + list() hiệu quả.

Bài này không nói về gì

  • Cache API: primitive khác, chạy ở HTTP layer, có bài riêng.
  • D1: Part 6.
  • Durable Objects: Part 8.
  • Workers KV pricing: Part 19.

KV là gì thực sự

KV store đơn giản: put(key, value), get(key), delete(key), list({ prefix }). Key là chuỗi, value là chuỗi hoặc ArrayBuffer hoặc ReadableStream.

Đằng sau API đơn giản đó là kiến trúc central store + edge cache:

  • Central store: nguồn chân lý duy nhất. Tất cả ghi đi về đây.
  • Edge cache: mỗi PoP có cache riêng. Đọc hit cache → không đi về central.
  • Lan truyền: khi ghi, central đẩy cập nhật ra các PoP. Mất vài giây đến 60s tùy PoP và traffic.

Binding

{
  "kv_namespaces": [
    { "binding": "CACHE", "id": "abc123..." },
    { "binding": "FLAGS", "id": "def456..." }
  ]
}

Trong Worker:

async fetch(request, env) {
  // get
  const cached = await env.CACHE.get("user:123");

  // put với TTL
  await env.CACHE.put("user:123", JSON.stringify(user), {
    expirationTtl: 3600,  // 1 giờ
  });

  // put với metadata (không tính vào value size)
  await env.CACHE.put("post:abc", body, {
    metadata: { author: "khavan", tags: ["cloudflare"] },
  });

  // list theo prefix
  const result = await env.CACHE.list({ prefix: "user:" });
  for (const key of result.keys) {
    console.log(key.name, key.metadata);
  }

  // delete
  await env.CACHE.delete("user:123");
}

Consistency model: quan trọng nhất

KV consistency model: write đi qua central region, propagate ra các PoP edge trong khoảng 60 giây. Read từ cache edge local. Write-then-read trong cùng PoP thấy update ngay; cross-region có thể thấy stale đến 60 giây. Note: cacheTtl mặc định 60s cho đọc.

Đường ghi

Worker (SIN PoP) → put("user:123", v)
                 → POST tới central store
                 → central persist + broadcast
                 → lan truyền ra 300+ PoP trong ≤ 60s

Ghi không xác nhận khi tất cả PoP đã nhận. put() trả về ngay khi central ghi xong (thường < 100ms).

Đường đọc

Worker (LAX PoP) → get("user:123")
                → kiểm tra cache edge local
                → hit: trả ngay (< 10ms)
                → miss: lấy từ central (~50-100ms cross-region)

Tỉ lệ hit đọc thường > 90% trong production. Nhưng cache staleness là vấn đề: PoP LAX có thể cache bản cũ từ 59s trước, trong khi PoP SIN đã có bản mới.

Hệ quả

  • Write-your-read cùng PoP: thường thấy ngay (cache local vô hiệu hóa sau ghi).
  • Write-then-read cross-PoP: có thể stale tới 60s.
  • Cùng user, hai request, hai PoP khác nhau: không nhất quán.

Điều này làm KV không hợp với:

  • Counter tăng dần (race condition + giới hạn tốc độ ghi).
  • Session cần strong consistency (user login ở Sydney, request 2 giây sau ở Tokyo thấy chưa login).
  • Primary database (stale read = logic nghiệp vụ hỏng).

cacheTtl: controlled staleness

Muốn đánh đổi consistency lấy hiệu năng:

// Cache 5 phút trong edge, ngay cả khi key đã hết hạn ở central
const cached = await env.CACHE.get("heavy-config", { cacheTtl: 300 });

cacheTtl > 60s: đọc siêu nhanh, chấp nhận stale lâu hơn. cacheTtl: 0: luôn đi về central (chậm, đắt, mất lợi thế KV).

Blog này dùng cacheTtl: 60 cho tính năng cờ. Nếu đổi cờ, tối đa 1 phút để edge phản ánh.


Giới hạn cần thuộc

KV limits: key size 512 byte, value size 25MB, metadata 1KB per key, list cursor 1000 item per page, rate limit 1 write per second per key, 1000 write per second per namespace, propagation delay tối đa 60 giây.

Tốc độ ghi per key: 1/giây

Quan trọng nhất. Thử:

for (let i = 0; i < 10; i++) {
  await env.CACHE.put("counter", String(i));
}

Lần ghi đầu thành công. 9 lần ghi sau bị throttle, một số có thể bị drop. KV không phải counter.

Counter thực tế dùng Durable Object (Part 15) hoặc D1 (UPDATE ... SET count = count + 1).

Kích thước value: 25 MB

Blob lớn hơn phải chia hoặc dùng R2. Với 25MB thực tế đã quá lớn cho KV vì chi phí phụ cache edge.

Kinh nghiệm: value KV nên < 1MB. Càng nhỏ càng nhanh.

Metadata: 1 KB

Metadata là trường phụ đi kèm key, lấy được mà không cần lấy value. Rất hữu ích cho list():

await env.POSTS.put(slug, body, {
  metadata: { published: true, tags: ["cf"], reading_time: 5 },
});

const result = await env.POSTS.list({ prefix: "2026/" });
const publishedPosts = result.keys.filter(k => k.metadata?.published);

Không cần gọi get() cho mỗi key. Lọc ngay tại list().

List: 1000 item per page

let cursor: string | undefined;
const all: KVNamespaceListKey<any>[] = [];

do {
  const page = await env.KV.list({ prefix: "user:", cursor, limit: 1000 });
  all.push(...page.keys);
  cursor = page.list_complete ? undefined : page.cursor;
} while (cursor);

Nếu namespace có 100k key, list() hết qua 100 trang. Mỗi trang 1 subrequest.


5 pattern đúng

① Tính năng cờ

// Cấu hình đổi ít, đọc nhiều
async function isFeatureEnabled(env: Env, feature: string, userId: string) {
  const config = await env.FLAGS.get("features", { type: "json", cacheTtl: 60 });
  const rule = config?.[feature];
  if (!rule) return false;
  if (rule.enabled === true) return true;
  if (rule.enabled === false) return false;
  if (rule.allowlist?.includes(userId)) return true;
  if (rule.rollout && hashToPercent(userId) < rule.rollout) return true;
  return false;
}

Ghi qua dashboard hoặc API admin hiếm khi, đọc ở mỗi request. Stale 60s chấp nhận được. Cổ điển KV.

② Redirect map / URL alias

// /go/twitter → https://twitter.com/khavan
async fetch(request, env) {
  const url = new URL(request.url);
  if (url.pathname.startsWith("/go/")) {
    const slug = url.pathname.slice(4);
    const target = await env.REDIRECTS.get(slug);
    if (target) return Response.redirect(target, 302);
  }
}

Ghi thỉnh thoảng (thêm slug mới), đọc ở click. KV hợp hoàn hảo.

③ Session token ngắn hạn

// Session 24h, token → userId
async function createSession(env: Env, userId: string) {
  const token = crypto.randomUUID();
  await env.SESSIONS.put(token, userId, { expirationTtl: 86400 });
  return token;
}

async function verifySession(env: Env, token: string) {
  return await env.SESSIONS.get(token);
}

KV tự hết hạn bằng TTL, không cần cron dọn dẹp. Ghi 1 lần khi login, đọc ở mỗi request đã xác thực.

Lưu ý: Session cần strong consistency (user logout → không vào được ngay) thì không dùng KV. Dùng D1 hoặc Durable Object. Session “kéo dài session” mà logout chậm 60s OK thì KV ổn.

④ Asset metadata / content lookup

// Blog này: OIDC creds temp cache
async function getCachedAwsCreds(env: Env, arn: string) {
  const cached = await env.OIDC_CREDS_CACHE.get(`creds:${arn}`, { type: "json" });
  if (cached && cached.expiresAt > Date.now() + 300_000) {
    return cached;
  }
  const fresh = await assumeRoleWithWebIdentity(env, arn);
  await env.OIDC_CREDS_CACHE.put(`creds:${arn}`, JSON.stringify(fresh), {
    expirationTtl: Math.floor((fresh.expiresAt - Date.now()) / 1000) - 60,
  });
  return fresh;
}

Credentials hợp lệ 15-60 phút. Cache giúp tránh gọi STS mỗi request. Hết hạn tự động.

⑤ Cấu hình giới hạn tốc độ

// Config rate limit per endpoint, đọc ở mỗi request
async function getRateLimit(env: Env, endpoint: string) {
  const config = await env.RATE_LIMITS.get("limits", { type: "json", cacheTtl: 300 });
  return config?.[endpoint] ?? { rpm: 60, burst: 100 };
}

Counter thực tế (số request / phút) vẫn cần Durable Object. KV chỉ giữ ngưỡng.


3 anti-pattern

① Counter / metric tăng dần

// SAI — sẽ throttle, sẽ mất update
async function incrementPageView(env: Env, slug: string) {
  const current = Number(await env.VIEWS.get(slug)) ?? 0;
  await env.VIEWS.put(slug, String(current + 1));
}

2 vấn đề:

  • Race condition: đọc-sửa-ghi không nguyên tử. 2 request song song = mất update.
  • Tốc độ ghi: trang phổ biến = hàng trăm view/giây = throttle ngay.

Đúng: dùng Durable Object với storage.transaction() hoặc D1 với UPDATE ... SET count = count + 1 (D1 primary là SQLite, nguyên tử).

② Primary database cho user / post / order

// SAI — stale, không nhất quán
async function getUser(env: Env, id: string) {
  return await env.USERS.get(id, { type: "json" });
}

async function updateUser(env: Env, id: string, user: User) {
  await env.USERS.put(id, JSON.stringify(user));
}

Tưởng tượng: user cập nhật profile ở PoP SIN, ngay sau đó tải profile ở PoP LAX → thấy bản cũ 30s. User bối rối, ticket hỗ trợ tăng.

Đúng: D1 cho relational, nguồn chân lý. KV chỉ cache dẫn xuất.

③ Session với logout cần hiệu lực ngay

// SAI nếu logout phải vô hiệu hóa cứng
async function logout(env: Env, token: string) {
  await env.SESSIONS.delete(token);
}

async function verifySession(env: Env, token: string) {
  // Có thể thấy session cũ đến 60s sau khi xóa
  return await env.SESSIONS.get(token);
}

User click logout → vẫn dùng được token 30s sau. Vấn đề an ninh.

Đúng: Durable Object cho session hoặc D1 với đọc mạnh. Hoặc thiết kế session TTL ngắn (15 phút) để dù stale cũng hết hạn nhanh.


Gotcha từ blog này

① Mất update OIDC creds khi đồng thời

Ban đầu:

// SAI
async function getCachedCreds(env: Env) {
  const cached = await env.KV.get("aws-creds");
  if (cached && !expired(cached)) return cached;

  const fresh = await assumeRole();
  await env.KV.put("aws-creds", fresh);
  return fresh;
}

Vấn đề: 2 request song song cùng miss cache, cả 2 gọi STS, cả 2 ghi KV. AWS STS tính phí 2 lần, không sao, nhưng tốc độ ghi đạt nhẹ.

Sửa: chấp nhận lấy 2 lần (giờ vẫn vậy, không quan trọng), hoặc dùng Durable Object làm singleton locker (quá phức tạp cho value chỉ mất $0.001).

② Metadata 1KB bị cắt

// SAI — metadata > 1KB bị cắt âm thầm
await env.KV.put(key, value, {
  metadata: {
    tags: longArray,  // 2KB → cắt
    description: longText,
  },
});

Metadata >1KB không lỗi, bị cắt. Kiểm tra kích thước trước khi put.

③ list() prefix phân biệt hoa thường

await env.KV.put("User:alice", "...");
await env.KV.put("user:bob", "...");

const result = await env.KV.list({ prefix: "user:" });
// Chỉ trả "user:bob", không trả "User:alice"

Chọn quy ước (chữ thường ưu tiên) và giữ vững.

④ Binary value qua JSON serialize

// SAI
const data = new Uint8Array([1, 2, 3]);
await env.KV.put("key", JSON.stringify(data));  // "{}" không phải binary

// ĐÚNG
await env.KV.put("key", data);  // KV tự xử lý ArrayBuffer
const back = await env.KV.get("key", { type: "arrayBuffer" });

Production checklist

  • Mọi get() có TTL ngầm phù hợp với staleness chấp nhận được.
  • Không dùng KV cho counter / tăng metric.
  • Không dùng KV làm primary DB cho user / post / order.
  • Kích thước value < 1MB cho hot key; > 1MB cân nhắc R2.
  • Metadata < 1KB sau JSON serialize.
  • Session quan trọng (logout cần hiệu lực) không dùng KV.
  • list() có phân trang cursor nếu namespace > 1000 key.
  • Tốc độ ghi per key < 1/giây (dùng DO/D1 cho ghi thường xuyên).
  • Quy ước hoa thường cho key (thường chữ thường).

Khi nào KHÔNG dùng KV

Quyết định đơn giản:

  • Cần strong consistency → D1 hoặc Durable Object.
  • Cần ghi nhanh liên tục / key → Durable Object.
  • Value binary / media > 1MB → R2.
  • Dữ liệu quan hệ với JOIN → D1.
  • Queue / pub-sub → Queues / Durable Object.

KV hợp nhất cho: metadata, cấu hình, cache, token ngắn hạn — đọc nhiều, ghi ít, stale OK.


Kết

KV là cache toàn cầu, không phải database. Mô hình tư duy: central store + edge cache + lan truyền ≤ 60s. Tốc độ ghi giới hạn ở 1/key/giây vì kiến trúc này. Đọc rẻ vì hit cache PoP local.

5 pattern đúng: tính năng cờ, redirect map, session ngắn hạn, asset metadata, cấu hình giới hạn tốc độ. 3 anti-pattern chính: counter, primary DB, session cần vô hiệu hóa cứng.

Part 6 tới đi vào D1: relational database thực sự trên edge, SQL, transaction, FTS, migration pattern.


Tham khảo