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 变成强一致数据库。
评论