跳到主要内容

《林一的 Cloudflare 通关记》第 8 篇-给后端加个"备忘录"——KV 键值存储

· 阅读需 25 分钟

《林一的 Cloudflare 通关记》第 8 篇

林一最近心情不错。星火AI 的图片生成功能跑通了,R2 存储也安排上了,用户体验提升了一大截。

但周三早上,小王在群里甩了一张截图:

故事引入

"林一你看,用户反馈说每次打开页面都要等两三秒才出内容。而且同一个用户每次请求 AI,都要重新加载一遍偏好设置——语言偏好、模型选择、温度参数……这些配置每次都从 R2 拿,慢得要命。"

林一打开后台看了看日志,果然——每个请求都要从 R2 拉一遍用户配置文件,再读一遍系统提示词模板。R2 是存文件的,用它来存这些碎小的配置数据,就像用卡车运一封信,大材小用且效率低下。

"我在 Worker 里搞个全局变量缓存一下?"林一想了想,在代码里加了个 const configCache = {},把首次读取的配置存起来,后续请求直接从内存里拿。

部署上去一测——根本没生效。每次请求 configCache 都是空的。

"张哥,这缓存怎么不工作啊?"

老张凑过来看了一眼代码,笑了:"林一,你忘了 Worker 是无状态的?每次请求都是一个全新实例,你的全局变量在请求结束就没了。你缓存了个寂寞。"

"啊……"林一想起了第5篇讲过的 Workers 执行模型——请求隔离,实例不共享。

"那怎么办?总不能每次都从 R2 拿吧?"

"你需要一个'备忘录',"老张在白板上写下三个字母,"Cloudflare KV——全球分布式的键值存储。读得快,存得简单,专门对付这种场景。"


技术讲解

键值数据库:最简单的数据库模型

"在讲 Cloudflare KV 之前,先搞清楚什么是键值数据库。"老张在白板上画了一棵树:

数据库四大类型:

1. 键值数据库(Key-Value Store)
├── 代表:Redis、Cloudflare KV、DynamoDB
├── 数据模型:{ key: value },就像 JavaScript 对象
└── 场景:缓存、配置存储、会话管理

2. 文档数据库(Document Store)
├── 代表:MongoDB、Firestore
├── 数据模型:{ key: { JSON文档 } },值是结构化文档
└── 场景:内容管理、用户画像、CMS

3. 关系型数据库(Relational Database)
├── 代表:MySQL、PostgreSQL、Cloudflare D1
├── 数据模型:表、行、列,SQL 查询
└── 场景:事务处理、复杂查询、数据分析

4. 图数据库(Graph Database)
├── 代表:Neo4j
├── 数据模型:节点 + 边,关系查询
└── 场景:社交网络、推荐系统

数据库四大类型对比

"键值数据库是最简单的一种——简单到什么程度?就像一个 JavaScript 对象:"

// 键值数据库的本质
const kv = {
"user:1001:theme": "dark",
"user:1001:lang": "zh-CN",
"config:system_prompt": "你是一个有用的AI助手...",
"feature:new_ui": "true"
};

// 读
kv["user:1001:theme"]; // → "dark"
// 写
kv["user:1001:theme"] = "light";
// 删
delete kv["user:1001:theme"];

"就这么简单。没有表结构,没有 SQL,没有 JOIN。你给它一个 key,它给你一个 value。"

"打个比方:关系型数据库像一个 Excel 表格——有行有列,能筛选、排序、做关联查询。键值数据库像一个字典——你查一个词(key),得到一个释义(value),不能做复杂查询,但查得贼快。"

KV vs 关系型数据库 vs Redis:三者对比

"你提到了 Redis,"林一说,"KV 跟 Redis 是一回事吗?"

"不是。虽然都属于键值存储的范畴,但定位完全不同。"老张整理了一张对比表:

对比项关系型数据库(MySQL/D1)RedisCloudflare KV
数据模型表、行、列,支持复杂关系键值对,值可以是字符串、列表、集合等键值对,值为字符串/二进制
查询能力SQL,支持 JOIN/聚合/子查询按键查找,支持模式匹配(SCAN)按键查找,支持前缀列表(list)
一致性强一致性强一致性最终一致性
存储位置单机或主从集群内存为主(可持久化)Cloudflare 全球边缘网络
读写速度毫秒级亚毫秒级(内存操作)边缘缓存命中时毫秒级
数据量TB~PB 级GB 级(受内存限制)理论上无限
持久性磁盘持久化可配置(RDB/AOF)中央存储 + 边缘缓存
适用场景事务、复杂查询、结构化数据实时排行榜、计数器、消息队列配置存储、会话缓存、API 缓存
费用按实例/存储付费按实例规格付费免费额度 + 按量付费

KV vs Redis vs 关系型数据库

"重点说三个区别:"

第一,查询能力不同。 Redis 支持丰富的数据结构——字符串、列表、集合、有序集合、哈希表,能在服务端做排序、排名、交集等操作。Cloudflare KV 只支持按键存取和前缀列表,不能在服务端做任何计算。简单说,Redis 是一个"聪明的"数据库,KV 是一个"笨但快"的存储。

第二,一致性模型不同。 Redis 是强一致性的——写入后立刻对所有客户端可见。Cloudflare KV 是最终一致性的——写入后最多需要 60 秒才能在全球所有节点可见。这个区别很关键,后面详细讲。

第三,部署架构不同。 Redis 通常部署在单个机房或主从集群,你需要自己维护实例。Cloudflare KV 是 Serverless 的——不需要管实例,数据存在中央存储,读取时自动缓存到全球 300+ 城市的边缘节点。

"再打个比方:Redis 像你办公桌上的便签本——随手就能翻到,速度极快,但容量有限,而且只有你自己能看到。Cloudflare KV 像一个全球连锁的快递柜——你存进去的东西,任何城市的柜子都能取到,但需要等快递把东西送到各个城市的柜子里。"

Cloudflare KV 的核心特点

"Cloudflare KV 有三个核心特点,理解了这三个,就知道它适合什么场景了。"

1. 全球分布式,低延迟读取

"KV 的数据存在少数几个中央数据中心,但读取时会自动缓存到 Cloudflare 全球 300+ 城市的边缘节点。什么意思?用户在上海访问,数据缓存在上海节点;用户在纽约访问,数据缓存在纽约节点。第二次读取时,直接从本地边缘节点返回,延迟个位数毫秒级。"

用户请求(上海)→ 上海边缘节点(缓存命中)→ 直接返回
↗ 缓存未命中 → 中央存储 → 返回并缓存

用户请求(纽约)→ 纽约边缘节点(缓存命中)→ 直接返回
↗ 缓存未命中 → 中央存储 → 返回并缓存

KV 全球边缘缓存读取流程

"这跟 Redis 完全不同。Redis 如果你部署在北京机房,上海用户访问要跨地域网络,延迟几十毫秒。而 KV 的边缘缓存让每个地方的用户都能享受'本地读取'的速度。"

2. 读多写少的最优设计

"KV 是为读密集型工作负载设计的。官方文档原话:'KV is optimized for high-read applications'。读取操作走边缘缓存,速度极快;写入操作要写回中央存储,相对慢一些。"

"具体来说,同一 key 的写入频率限制为每秒 1 次——超过会返回 429 错误。读取没有这个限制,免费版每天 10 万次,付费版每月 1000 万次免费。"

"如果你的场景是'写少读多'——比如用户配置(偶尔改,频繁读)、系统参数(几乎不改,每次请求都读)——KV 就是完美的。如果是'频繁写'——比如实时计数器、消息队列——KV 不适合,应该用 Durable Objects。"

3. 最终一致性模型

"这是 KV 最重要的特性,也是最容易踩坑的地方。"老张在白板上写下大大的"最终一致性",准备详细讲解。

最终一致性 vs 强一致性:为什么 KV 选择"不够新"

"先解释两个概念。"

强一致性(Strong Consistency):写入操作完成后,所有后续读取都能看到最新值。你写了 theme = "dark",下一秒不管从哪里读,都是 "dark"。关系型数据库和 Redis 都是强一致性的。

最终一致性(Eventual Consistency):写入操作完成后,不保证所有后续读取立刻看到最新值。但最终——经过一段时间——所有读取都会看到最新值。

"Cloudflare KV 是最终一致性的。具体来说:"

  • 写入操作在当前边缘节点通常立即可见(但不保证,不建议依赖这个行为)
  • 其他边缘节点,最多需要 60 秒(或 cacheTtl 设定的值)才能看到最新值
  • 这 60 秒内,其他节点读到的可能还是旧值

"为什么?因为 KV 的读取走边缘缓存。你在北京节点写入了一个新值,但上海节点的缓存里还是旧值,要等缓存过期(默认 60 秒)后重新从中央存储拉取,才能看到新值。"

"打个比方:强一致性就像公司内部的共享文档——你改了,所有人立刻看到。最终一致性就像公司走廊的公告栏——你贴了新通知,但其他楼层的人要等保洁阿姨换报纸时才能看到新通知。虽然慢一点,但阿姨最终会换的——所以叫'最终'一致。"

最终一致性与强一致性对比

"那 KV 为什么不做成强一致性的?因为强一致性和低延迟不可兼得。"

老张画了一张图:

强一致性方案(如 Redis):
上海用户写入 → 同步到主节点 → 确认所有副本同步 → 返回成功
↑ 这个过程需要跨地域网络通信,延迟高

最终一致性方案(如 Cloudflare KV):
上海用户写入 → 写到中央存储 → 立即返回成功
↓ 异步刷新到各边缘节点(最多60秒)
↑ 写入快,读取走本地缓存也快,但数据有短暂不一致窗口

"如果要强一致性,上海用户写入时,要等纽约、伦敦的节点都确认收到,才能返回成功——光速绕地球一圈要 134 毫秒,加上网络处理,动辄几百毫秒。KV 选择了最终一致性,写入只写中央存储就返回,读取走本地缓存——两边都快,代价是有最多 60 秒的不一致窗口。"

"什么时候要注意?"

场景是否适合 KV原因
用户配置(主题、语言)适合改了配置等 60 秒生效,用户无感
特征开关(Feature Flag)适合开关切换稍有延迟,完全可以接受
API 响应缓存适合缓存本就是"旧数据",最终一致没问题
会话 Token适合创建后等 60 秒生效可接受,删除后等 60 秒失效也还行
库存扣减不适合两个人同时买最后一件商品,60 秒窗口内可能超卖
计数器不适合需要原子操作,KV 不支持事务
资金转账不适合必须强一致性,一分钱都不能错

"一句话总结:如果你的场景能容忍'最多 60 秒的旧数据',KV 就够用。如果不能,用 Durable Objects(强一致性)或等下一篇的 D1 数据库。"

Cloudflare KV 的限制与定价

"先看限制,知道边界在哪。"

限制项免费版付费版
读取10 万次/天无限
写入(不同 key)1000 次/天无限
写入(同一 key)1 次/秒1 次/秒
每次 Worker 调用的操作数10001000
Namespace 数量10001000
存储1 GB无限
Key 大小512 字节512 字节
Value 大小25 MiB25 MiB
Metadata 大小1024 字节1024 字节
cacheTtl 最小值30 秒30 秒

"再看定价。免费版的额度对实习项目来说绑绑有余。"

项目免费版付费版
读取10 万次/天1000 万次/月,超出 $0.50/百万次
写入1000 次/天100 万次/月,超出 $5.00/百万次
删除1000 次/天100 万次/月,超出 $5.00/百万次
List 请求1000 次/天100 万次/月,超出 $5.00/百万次
存储1 GB1 GB,超出 $0.50/GB-月
出口流量免费免费

"注意一个细节:读取不存在的 key 也算一次读取操作——返回 null 也消耗配额。所以别拿 KV 当探测器用。"


实操指导

第一步:创建 KV Namespace

"理论够了,开始动手。"老张打开终端。

KV 的存储单元叫 Namespace(命名空间),你可以理解为一个独立的"键值仓库"。一个账号最多可以建 1000 个 Namespace。

# 创建一个名为 SPARK_CONFIG 的 Namespace
npx wrangler kv namespace create SPARK_CONFIG

输出类似:

🌀 Creating namespace with title "SPARK_CONFIG"
✨ Success!

Add the following to your configuration file in your kv_namespaces array:
{
"kv_namespaces": [
{
"binding": "SPARK_CONFIG",
"id": "abcdef1234567890abcdef1234567890"
}
]
}

"记住那个 id,接下来要用。"

第二步:在 Workers 中绑定 KV

"跟 R2 一样,KV 也是通过 Binding 接入 Workers。"老张修改 wrangler.jsonc

{
"name": "spark-api",
"main": "src/index.js",
"compatibility_date": "2025-01-01",
"ai": {
"binding": "AI"
},
"r2_buckets": [
{
"binding": "IMAGES_BUCKET",
"bucket_name": "spark-images"
}
],
"kv_namespaces": [
{
"binding": "SPARK_CONFIG",
"id": "abcdef1234567890abcdef1234567890"
}
]
}

"binding 是代码里访问它的变量名,id 是刚才创建 Namespace 时返回的 ID。配置好后,代码里就能通过 env.SPARK_CONFIG 操作 KV 了。"

第三步:在 Workers 中读写 KV

"KV 的 API 非常简洁——就四个核心方法:putgetlistdelete。"

KV 四大 API 方法

写入数据:put()

// 基本写入
await env.SPARK_CONFIG.put("user:1001:theme", "dark");

// 写入 JSON
await env.SPARK_CONFIG.put(
"user:1001:preferences",
JSON.stringify({ theme: "dark", lang: "zh-CN", model: "flux-1" })
);

// 设置 TTL(60秒后自动过期,最小值 60)
await env.SPARK_CONFIG.put("session:abc123", "valid", {
expirationTtl: 3600 // 1小时后过期
});

// 设置绝对过期时间(Unix 时间戳)
await env.SPARK_CONFIG.put("promo:summer2025", "active", {
expiration: 1756684800 // 2025-09-01 00:00:00 UTC
});

// 附加 metadata(最大 1024 字节)
await env.SPARK_CONFIG.put("user:1001:theme", "dark", {
metadata: { updatedAt: Date.now(), source: "settings_page" }
});

"几个要点:key 最大 512 字节,value 最大 25 MiB。expirationTtl 最小 60 秒——不能设比 60 秒更短的 TTL。metadata 是可选的附加信息,最大 1024 字节,可以通过 getWithMetadata() 一起读取。"

读取数据:get()

// 基本读取(返回 string 或 null)
const theme = await env.SPARK_CONFIG.get("user:1001:theme");
// → "dark" 或 null

// 读取并解析 JSON
const prefs = await env.SPARK_CONFIG.get("user:1001:preferences", "json");
// → { theme: "dark", lang: "zh-CN", model: "flux-1" }

// 读取并指定返回类型
const text = await env.SPARK_CONFIG.get("some-key", "text"); // string(默认)
const json = await env.SPARK_CONFIG.get("some-key", "json"); // 解析后的对象
const buf = await env.SPARK_CONFIG.get("some-key", "arrayBuffer"); // ArrayBuffer
const stream = await env.SPARK_CONFIG.get("some-key", "stream"); // ReadableStream

// 读取时设置 cacheTtl(最小 30 秒,默认 60 秒)
const value = await env.SPARK_CONFIG.get("config:system_prompt", {
cacheTtl: 300 // 缓存 5 分钟
});

// 读取值 + metadata
const result = await env.SPARK_CONFIG.getWithMetadata("user:1001:theme");
// → { value: "dark", metadata: { updatedAt: 1718000000000, source: "settings_page" } }

"get() 返回 null 表示 key 不存在。注意区分 null(不存在)和空字符串 ""(存在但值为空)。"

"cacheTtl 控制边缘缓存的过期时间。默认 60 秒——也就是说写入后最多 60 秒,其他节点的缓存就会过期并拉取最新值。如果你的数据很少变,可以设大一些(比如 300 秒),减少回源次数。"

列出 key:list()

// 列出所有 key(最多返回 1000 条)
const result = await env.SPARK_CONFIG.list();
// → { keys: [{ name: "user:1001:theme", expiration: ..., metadata: ... }], list_complete: true, cursor: "..." }

// 按前缀过滤
const userKeys = await env.SPARK_CONFIG.list({ prefix: "user:1001:" });
// → 列出所有以 "user:1001:" 开头的 key

// 分页
let cursor = null;
let allKeys = [];
do {
const page = await env.SPARK_CONFIG.list({
prefix: "user:",
cursor: cursor,
limit: 1000
});
allKeys = allKeys.concat(page.keys);
cursor = page.list_complete ? null : page.cursor;
} while (cursor);

"list() 返回的 key 按字典序排列。list_completetrue 表示没有更多数据了。注意 list() 也消耗配额(算一次 list 请求),不要频繁调用。"

"一个性能优化技巧:如果你的 value 很小(不超过 1024 字节),可以把 value 存在 metadata 里,list() 时直接拿到,省去后续的 get() 调用:"

// 写入时把值放进 metadata
await env.SPARK_CONFIG.put("flag:new_feature", "", {
metadata: { enabled: true, rollout: 50 }
});

// list 时直接拿到 metadata,不需要再 get
const flags = await env.SPARK_CONFIG.list({ prefix: "flag:" });
flags.keys.forEach(key => {
console.log(key.name, key.metadata);
// → "flag:new_feature" { enabled: true, rollout: 50 }
});

删除数据:delete()

// 删除单个 key
await env.SPARK_CONFIG.delete("user:1001:theme");

// delete 不存在的 key 也是成功的,不会报错
await env.SPARK_CONFIG.delete("not-exist-key"); // 正常返回,无错误

"删除后,其他边缘节点的缓存最多 60 秒后才会清除——最终一致性在这里同样适用。"

第四步:实战——给星火AI 加上配置缓存

"理论和方法都讲完了,来实战。我们给星火AI 加一个配置缓存层。"老张修改 src/index.js

const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};

// ============ KV 工具函数 ============

/**
* 从 KV 读取 JSON 配置,带缓存
* @param {Env} env - Worker 环境
* @param {string} key - KV 键名
* @param {number} cacheTtl - 缓存时间(秒)
*/
async function getConfig(env, key, cacheTtl = 60) {
const value = await env.SPARK_CONFIG.get(key, {
cacheTtl: cacheTtl,
type: "json",
});
return value;
}

/**
* 写入配置到 KV
*/
async function setConfig(env, key, value, options = {}) {
await env.SPARK_CONFIG.put(key, JSON.stringify(value), options);
}

/**
* 带缓存的读取:先查 KV,没有则回源并写入 KV
* @param {Env} env
* @param {string cacheKey - KV 缓存键
* @param {Function} fetchFn - 回源函数(KV 未命中时调用)
* @param {number} ttl - 缓存 TTL(秒)
*/
async function cachedRead(env, cacheKey, fetchFn, ttl = 300) {
// 1. 先查 KV 缓存
const cached = await env.SPARK_CONFIG.get(cacheKey, { cacheTtl: ttl });
if (cached !== null) {
return JSON.parse(cached);
}

// 2. 缓存未命中,回源获取
const fresh = await fetchFn();

// 3. 写入 KV 缓存(设置 TTL,到期自动失效)
await env.SPARK_CONFIG.put(cacheKey, JSON.stringify(fresh), {
expirationTtl: ttl,
});

return fresh;
}

// ============ 路由处理 ============

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

if (method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}

// 获取用户配置(KV 缓存)
if (path.startsWith("/api/user/config") && method === "GET") {
const userId = url.searchParams.get("uid") || "default";

// 直接从 KV 读取,cacheTtl 设 60 秒
const config = await getConfig(env, `user:${userId}:config`, 60);

if (!config) {
// 首次访问,返回默认配置
const defaultConfig = {
theme: "light",
lang: "zh-CN",
model: "flux-1-schnell",
temperature: 0.7,
};
// 写入 KV
await setConfig(env, `user:${userId}:config`, defaultConfig);
return jsonResponse({ config: defaultConfig, source: "default" });
}

return jsonResponse({ config, source: "kv_cache" });
}

// 更新用户配置
if (path.startsWith("/api/user/config") && method === "POST") {
const userId = url.searchParams.get("uid") || "default";
const newConfig = await request.json();

// 写入 KV(下次读取时自动更新缓存)
await setConfig(env, `user:${userId}:config`, newConfig);

return jsonResponse({ success: true, message: "配置已更新" });
}

// 缓存 AI 响应(相同 prompt 60 分钟内不重复调用 AI)
if (path === "/api/image/generate" && method === "POST") {
const { prompt } = await request.json();

// 用 prompt 的哈希作为缓存 key
const cacheKey = `cache:image:${await sha256(prompt)}`;

// 先查缓存,命中则直接返回
const cached = await env.SPARK_CONFIG.get(cacheKey);
if (cached) {
return jsonResponse({
success: true,
source: "kv_cache",
imageUrl: cached,
prompt,
});
}

// 未命中,调用 AI 生成
const imageResponse = await env.AI.run(
"@cf/black-forest-labs/flux-1-schnell",
{ prompt }
);

const imageId = crypto.randomUUID();
const key = `generated/${imageId}.png`;

// 存到 R2
await env.IMAGES_BUCKET.put(key, imageResponse, {
httpMetadata: { contentType: "image/png" },
});

const imageUrl = `/api/image/${imageId}`;

// 缓存到 KV,TTL 1 小时
await env.SPARK_CONFIG.put(cacheKey, imageUrl, {
expirationTtl: 3600,
});

return jsonResponse({
success: true,
source: "ai_generate",
imageUrl,
prompt,
});
}

// 特征开关:控制功能上线
if (path === "/api/feature-flags" && method === "GET") {
// 从 KV 读取所有特征开关
const flagsList = await env.SPARK_CONFIG.list({ prefix: "flag:" });

const flags = {};
for (const item of flagsList.keys) {
const flagName = item.name.replace("flag:", "");
const metadata = await env.SPARK_CONFIG.getWithMetadata(item.name);
flags[flagName] = metadata.metadata || { enabled: false };
}

return jsonResponse({ flags });
}

return jsonResponse({ error: "Not Found" }, 404);
},
};

// ============ 辅助函数 ============

function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}

async function sha256(text) {
const data = new TextEncoder().encode(text);
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

"这段代码实现了三个典型场景:"

  1. 用户配置存储/api/user/config 读取时从 KV 拿,cacheTtl 设 60 秒——同一用户一分钟内的多次请求都走缓存。更新配置时直接写入 KV,最多 60 秒后全局生效。

  2. AI 响应缓存:相同的 prompt 在 1 小时内不重复调用 AI——用 prompt 的 SHA-256 哈希做 key,存图片 URL。expirationTtl: 3600 让缓存自动过期。这既省 AI 调用额度,又加快响应速度。

  3. 特征开关:用 KV 存功能开关,flag:new_ui{ enabled: true, rollout: 50 }。线上修改开关后,最多 60 秒全球生效——不用重新部署代码。

缓存策略设计:TTL、缓存失效与防击穿

"既然用 KV 做缓存,就要面对缓存设计的三大经典问题。"老张在白板上写下:

1. TTL 设置策略

"TTL(Time To Live)是缓存的'保质期'。设长了数据旧,设短了缓存命中率低。"

数据类型推荐 TTL理由
用户配置60~300 秒改了配置等几分钟生效可接受
系统提示词300~600 秒很少改,可以长一点
AI 响应缓存3600~86400 秒相同 prompt 的回答短期内不变
特征开关60 秒需要较快生效
会话 Token3600~86400 秒跟 Token 本身有效期一致

"KV 的 cacheTtl 最小值是 30 秒,expirationTtl 最小值是 60 秒。设更短也没用——KV 会自动向上取整。"

2. 缓存失效

"缓存失效有两种方式:被动失效(等 TTL 过期)和主动失效(主动删除)。"

// 被动失效:设置 TTL,到期自动删除
await env.SPARK_CONFIG.put("cache:hot_data", value, {
expirationTtl: 300 // 5 分钟后自动删除
});

// 主动失效:数据更新时删除缓存
async function updateUserConfig(env, userId, newConfig) {
// 1. 更新数据源
await setConfig(env, `user:${userId}:config`, newConfig);
// 2. 删除缓存(如果用了单独的缓存 key)
await env.SPARK_CONFIG.delete(`cache:user_config:${userId}`);
}

"在星火AI 的例子里,用户配置直接存在 KV 里——更新即写入,没有单独的缓存层。这种模式叫 Cache-Aside(旁路缓存):读取时查 KV,没有就回源;写入时直接更新 KV。简单可靠。"

3. 缓存击穿与雪崩

"缓存击穿:一个热点 key 过期的瞬间,大量请求同时回源,打垮后端。"

"缓存雪崩:大量 key 同时过期,所有请求都回源。"

"在 KV 的语境下,这两个问题的解法跟传统缓存不太一样——因为 KV 本身就是存储层,不是传统意义上的缓存层。但如果你用 KV 缓存 AI 响应,还是可能遇到:"

// 防止缓存击穿:用 KV 原子写入做"锁"
// 注意:KV 不支持真正的原子操作,这是近似方案

async function safeCachedRead(env, cacheKey, fetchFn, ttl = 300) {
const cached = await env.SPARK_CONFIG.get(cacheKey);
if (cached) return JSON.parse(cached);

// 检查是否已有其他请求在回源(简单的互斥标记)
const lockKey = `lock:${cacheKey}`;
const lock = await env.SPARK_CONFIG.get(lockKey);
if (lock) {
// 等待 200ms 后重试读取缓存
await new Promise((r) => setTimeout(r, 200));
const retry = await env.SPARK_CONFIG.get(cacheKey);
if (retry) return JSON.parse(retry);
}

// 设置锁(TTL 10 秒,防止死锁)
await env.SPARK_CONFIG.put(lockKey, "1", { expirationTtl: 10 });

// 回源
const fresh = await fetchFn();
await env.SPARK_CONFIG.put(cacheKey, JSON.stringify(fresh), {
expirationTtl: ttl,
});

// 释放锁
await env.SPARK_CONFIG.delete(lockKey);

return fresh;
}

// 防止缓存雪崩:给 TTL 加随机抖动
async function setCacheWithJitter(env, key, value, baseTtl = 300) {
// 在基础 TTL 上加 0~60 秒的随机量,避免同时过期
const jitter = Math.floor(Math.random() * 60);
await env.SPARK_CONFIG.put(key, JSON.stringify(value), {
expirationTtl: baseTtl + jitter,
});
}

"注意:因为 KV 是最终一致性的,上面的'锁'方案不是真正的互斥锁——其他节点可能看不到这个锁。但在同一边缘节点内,大部分情况下能起到收敛回源请求的效果。如果需要严格的互斥,用 Durable Objects。"

第五步:部署与测试

# 本地开发(默认使用本地模拟 KV)
npx wrangler dev

# 测试用户配置 API
# 读取(首次返回默认配置)
curl "http://localhost:8787/api/user/config?uid=1001"

# 更新配置
curl -X POST "http://localhost:8787/api/user/config?uid=1001" \
-H "Content-Type: application/json" \
-d '{"theme":"dark","lang":"zh-CN","model":"flux-1-schnell","temperature":0.8}'

# 再次读取(从 KV 缓存返回)
curl "http://localhost:8787/api/user/config?uid=1001"

# 部署上线
npx wrangler deploy

"有个开发注意点:wrangler dev 本地开发时,KV 默认使用本地模拟——数据不会写到线上。如果需要连线上 KV,在 wrangler.jsonc 的 KV 绑定中加 "remote": true。但一般不推荐这么做——本地开发用本地数据更安全。"


小结预告

本篇知识点回顾

知识点核心内容
键值数据库最简单的数据库模型,{ key: value } 结构,按键存取,无 SQL
KV vs 关系型 vs RedisKV 读写快但查询弱;关系型适合复杂查询;Redis 强一致性但需自维护
Cloudflare KV 特点全球分布式、边缘缓存、低延迟读取、读多写少设计
最终一致性写入后最多 60 秒才在全球所有节点可见;KV 为低延迟牺牲了强一致性
强一致性 vs 最终一致性强一致=写入后立刻可见;最终一致=最终可见但有延迟窗口
最终一致性适用场景配置存储、缓存、特征开关——能容忍 60 秒旧数据的场景
最终一致性不适用场景库存扣减、计数器、资金转账——需要原子操作或强一致性
创建 Namespacenpx wrangler kv namespace create <BINDING_NAME>
绑定 KVwrangler.jsonc 中配置 kv_namespaces,代码中 env.BINDING_NAME 访问
put()env.KV.put(key, value, {expirationTtl, expiration, metadata})
get()env.KV.get(key, {cacheTtl, type}),返回 string/JSON/ArrayBuffer/Stream/null
list()env.KV.list({prefix, limit, cursor}),按字典序返回,支持分页
delete()env.KV.delete(key),删除不存在的 key 不会报错
TTL 设置expirationTtl 最小 60 秒;cacheTtl 最小 30 秒,默认 60 秒
免费额度10 万次读取/天,1000 次写入/天,1GB 存储
同 key 写入限制每秒 1 次,超出返回 429 错误
缓存策略Cache-Aside 模式、TTL 随机抖动防雪崩、锁近似防击穿
Metadata 优化小数据存 metadata,list 时直接获取,省去 get 调用

动手挑战

  1. 基础挑战:创建一个 KV Namespace,在 Workers 中绑定它,实现 GET /api/config/:key 读取和 POST /api/config/:key 写入的功能——一个通用的键值存储 API

  2. 进阶挑战:给星火AI 的 AI 图片生成接口加上响应缓存——相同 prompt 在 1 小时内直接返回缓存结果,不再调用 AI。用 prompt 的 SHA-256 哈希做缓存 key,思考一下 TTL 设多少合适

  3. 折腾挑战:实现一个特征开关系统——管理员通过 API 开关功能,用户请求时从 KV 读取开关状态。尝试用 metadata 存储 { enabled, rollout },通过 list({ prefix: "flag:" }) 一次性获取所有开关。再思考:如果要做灰度发布(只对 50% 用户开放),该怎么做?

提示:KV 的 list() 方法按字典序返回 key,可以用这个特性做简单的"前缀路由"——比如 user:1001:configuser:1001:preferencesuser:1001:history 都用 user:1001: 前缀,一次 list 就能拿到这个用户的所有数据。

下回预告

配置缓存搞定了,用户偏好和特征开关都存进了 KV,响应速度立竿见影——小王测了一下,首屏加载从 3 秒降到了 800 毫秒。

"不过张哥,"林一翻了翻代码,"KV 只能按 key 存取,不能做条件查询。比如我想查'所有注册超过 30 天的用户',或者'本月生成图片最多的前 10 名用户',KV 根本做不到。"

"而且用户数据越来越多——用户信息、对话记录、图片元数据——全塞在 KV 里也不合适,key 管理起来太混乱了。"

老张点点头:"KV 是'备忘录',适合简单的键值存取。但你说的这些需求,需要查询、过滤、排序、聚合——这是关系型数据库的活儿。"

"关系型数据库?那不是得买数据库实例吗?"

"在 Cloudflare 上不用。有一个基于 SQLite 的 Serverless 数据库——D1。免费额度够用,SQL 查询,跟 Workers 无缝集成。"

"SQLite?那个嵌入式数据库?能扛住生产环境吗?"

老张笑了笑:"你小看它了。下一篇文章你就知道了。"

下篇预告:《缓存搞定了,但用户数据总不能存在 KV 里吧?——D1 数据库》

wp