Workers KV 适合低频写入、允许短暂误差的轻量状态,但不能直接当作强一致数据库。本文实现软限流、路径浏览量和匿名 QLOG,并明确哪些故障可以放行,哪些操作必须 fail-closed。

先划清 KV 的适用边界

很多个人站点只需要几个很小的后端能力:限制表单提交频率、记录页面浏览趋势,以及收集读者想继续了解的问题。为这些需求单独维护数据库和常驻服务,运维成本可能高于业务本身。

Workers KV 的优势是接入简单、读取方便,并且能为键设置过期时间;限制则是写入并非强一致,同一键上的“读取—修改—写回”也不是原子操作。因此,本文把三个功能定义为:

功能数据模型可接受的误差故障策略
软限流匿名标识 + 分钟桶并发时可能少计fail-closed
路径浏览量路径对应计数器允许丢失增量fail-open
QLOG每个问题一个独立键不接受假成功fail-closed

这里的 QLOG 是“问题日志”:保存读者主动提交的问题,用于整理后续文章或 AI 应用的评测主题。它不是访问日志,也不需要记录原始 IP、完整 User-Agent 或跨天稳定的用户标识。

浏览量只用于观察相对趋势,不能作为计费、结算或严格 UV;软限流用于降低误操作和普通脚本的影响,不能代替专业的防滥用产品。边界明确后,KV 的弱一致性才不会悄悄变成业务缺陷。

一份可运行的 Worker 实现

先创建 KV 命名空间,将实际 ID 写入 wrangler.toml

name = "kv-light-backend"
main = "src/index.js"
compatibility_date = "2025-01-01"

[[kv_namespaces]]
binding = "APP_KV"
id = "替换为你的 KV namespace id"

再执行 npx wrangler secret put HASH_SALT 设置随机盐。下面的 src/index.js 可直接部署:

const encoder = new TextEncoder();

function json(data, status = 200) {
  return new Response(JSON.stringify(data), {
    status,
    headers: { "content-type": "application/json; charset=utf-8" }
  });
}

async function sha256(text) {
  const bytes = await crypto.subtle.digest("SHA-256", encoder.encode(text));
  return [...new Uint8Array(bytes)]
    .map((n) => n.toString(16).padStart(2, "0"))
    .join("");
}

function dayKey(now = new Date()) {
  return now.toISOString().slice(0, 10);
}

function minuteKey(now = new Date()) {
  return now.toISOString().slice(0, 16);
}

function normalizePath(value) {
  if (typeof value !== "string" || !value.startsWith("/")) return null;
  const path = new URL(value, "https://local.invalid").pathname;
  return path.length <= 200 ? path : null;
}

async function readBody(request) {
  try {
    return await request.json();
  } catch {
    return null;
  }
}

async function anonymousId(request, env) {
  const ip = request.headers.get("CF-Connecting-IP") || "unknown";
  return sha256(`${dayKey()}:${ip}:${env.HASH_SALT}`);
}

async function allowQuestion(request, env, limit = 10) {
  const actor = await anonymousId(request, env);
  const key = `rate:qlog:${minuteKey()}:${actor}`;
  const current = Number(await env.APP_KV.get(key)) || 0;
  if (current >= limit) return { allowed: false, actor };
  await env.APP_KV.put(key, String(current + 1), { expirationTtl: 120 });
  return { allowed: true, actor };
}

async function incrementView(env, path) {
  const key = `views:${path}`;
  const current = Number(await env.APP_KV.get(key)) || 0;
  await env.APP_KV.put(key, String(current + 1));
}

async function handleView(request, env, ctx) {
  const body = await readBody(request);
  const path = normalizePath(body?.path);
  if (!path) return json({ error: "invalid_path" }, 400);

  ctx.waitUntil(
    incrementView(env, path).catch((error) => {
      console.error("view increment failed", error);
    })
  );
  return json({ accepted: true }, 202);
}

async function handleViewCount(url, env) {
  const path = normalizePath(url.searchParams.get("path"));
  if (!path) return json({ error: "invalid_path" }, 400);

  try {
    const count = Number(await env.APP_KV.get(`views:${path}`)) || 0;
    return json({ path, count });
  } catch (error) {
    console.error("view read failed", error);
    return json({ error: "temporarily_unavailable" }, 503);
  }
}

async function handleQlog(request, env) {
  const body = await readBody(request);
  const question = typeof body?.question === "string"
    ? body.question.trim()
    : "";

  if (!question || question.length > 1000) {
    return json({ error: "question_must_be_1_to_1000_chars" }, 400);
  }

  try {
    const result = await allowQuestion(request, env);
    if (!result.allowed) return json({ error: "rate_limited" }, 429);

    const now = new Date();
    const id = crypto.randomUUID();
    const record = {
      id,
      question,
      actor: result.actor,
      createdAt: now.toISOString()
    };

    await env.APP_KV.put(
      `qlog:${dayKey(now)}:${id}`,
      JSON.stringify(record),
      { expirationTtl: 60 * 60 * 24 * 30 }
    );
    return json({ accepted: true, id }, 201);
  } catch (error) {
    console.error("qlog failed", error);
    return json({ error: "temporarily_unavailable" }, 503);
  }
}

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);

    if (request.method === "POST" && url.pathname === "/api/view") {
      return handleView(request, env, ctx);
    }
    if (request.method === "GET" && url.pathname === "/api/views") {
      return handleViewCount(url, env);
    }
    if (request.method === "POST" && url.pathname === "/api/qlog") {
      return handleQlog(request, env);
    }
    return json({ error: "not_found" }, 404);
  }
};

本地可用 npx wrangler dev 启动,然后向 /api/view 提交 {"path":"/posts/kv"},或向 /api/qlog 提交 {"question":"KV 的一致性会怎样影响计数?"}

一致性:为什么只能叫软限流和趋势浏览量

限流与浏览量都使用了读取旧值、加一、写回的流程。假设两个请求同时读到 8,它们都可能写入 9,最终少记一次。不同边缘位置还可能在短时间内看到不同值,所以一分钟十次的限制并不是精确闸门。

分钟桶避免了持续续期的复杂度,键在两分钟后自动清理;代价是时间边界附近可以连续提交更多请求。对于防止按钮连点和普通脚本,这通常可以接受。如果需求是严格配额、余额扣减或库存控制,应改用具备串行化能力的 Durable Objects,或使用专门的限流服务,而不是继续给 KV 计数器打补丁。

浏览量写入通过 waitUntil 延长后台任务生命周期,响应无需等待计数完成。写入失败只记录错误,不影响页面主流程,这正是 fail-open。即使请求成功,计数仍可能因并发覆盖而偏低,因此前端最好展示“约 1.2k 次浏览”或仅在后台看趋势。

此外,公开的 /api/view 可以被机器人调用。校验 Referer 只能提高门槛,不能提供可靠身份认证。若浏览量具有业务价值,应在真正返回页面的 Worker 内部计数,并叠加 Bot Management、Turnstile 或更严格的数据管道。

成本、隐私与 fail-closed 的取舍

KV 成本不只取决于记录大小,也取决于操作次数。一次计数通常包含一次读和一次写;热门路径会集中写同一个键,并放大弱一致性问题。可以按小时分片为 views:路径:小时,之后再汇总,代价是查询和清理逻辑更复杂。低流量博客没必要过早引入这层设计。

QLOG 采用“一条问题一个键”,避免多个提交者竞争同一数组。键带日期便于按前缀列举,30 天 TTL 则建立了默认删除期限。生产环境还应限制问题长度、控制读取后台权限,并在表单旁提醒用户不要填写姓名、邮箱、密钥或客户数据。

示例没有保存原始 IP,而是使用“日期 + IP + 私有盐”的 SHA-256 结果。它能支持当天限流,又降低跨天关联能力。不过哈希后的 IP 仍可能属于可识别信息,不能简单宣称为完全匿名;盐需要作为 Secret 管理,也不应把该标识导出到公开分析系统。

QLOG 选择 fail-closed:只要限流读取、计数写入或日志写入失败,就返回 503,不能先告诉用户“提交成功”再异步写入。这样可能暂时少收问题,但不会制造已经保存的假象。相反,浏览量是附属指标,KV 故障时放弃本次增量更合理。故障策略应按数据语义决定,而不是全站统一采用一种模式。

总结

  • Workers KV 适合允许短暂不一致的轻量状态,不适合严格配额、交易和库存。
  • 分钟桶限流实现简单,但并发覆盖和边界突发决定了它只能是软限流。
  • 浏览量应作为趋势指标,并采用后台写入和 fail-open,避免拖慢主请求。
  • QLOG 使用独立键、有限保留期和每日伪匿名标识,减少竞争与隐私暴露。
  • 限流及问题落库失败时返回明确错误,避免“前端成功、数据丢失”的假成功。
  • 当准确性、防滥用或查询需求上升时,应及时迁移到 Durable Objects、数据库或专业分析系统,而不是强行把 KV 变成强一致数据库。