《林一的 Cloudflare 通关记》第 8 篇-给后端加个"备忘录"——KV 键值存储
《林一的 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) | Redis | Cloudflare KV |
|---|---|---|---|
| 数据模型 | 表、行、列,支持复杂关系 | 键值对,值可以是字符串、列表、集合等 | 键值对,值为字符串/二进制 |
| 查询能力 | SQL,支持 JOIN/聚合/子查询 | 按键查找,支持模式匹配(SCAN) | 按键查找,支持前缀列表(list) |
| 一致性 | 强一致性 | 强一致性 | 最终一致性 |
| 存储位置 | 单机或主从集群 | 内存为主(可持久化) | Cloudflare 全球边缘网络 |
| 读写速度 | 毫秒级 | 亚毫秒级(内存操作) | 边缘缓存命中时毫秒级 |
| 数据量 | TB~PB 级 | GB 级(受内存限制) | 理论上无限 |
| 持久性 | 磁盘持久化 | 可配置(RDB/AOF) | 中央存储 + 边缘缓存 |
| 适用场景 | 事务、复杂查询、结构化数据 | 实时排行榜、计数器、消息队列 | 配置存储、会话缓存、API 缓存 |
| 费用 | 按实例/存储付费 | 按实例规格付费 | 免费额度 + 按量付费 |

"重点说三个区别:"
第一,查询能力不同。 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+ 城市的边缘节点。什么意思?用户在上海访问,数据缓存在上海节点;用户在纽约访问,数据缓存在纽约节点。第二次读取时,直接从本地边缘节点返回,延迟个位数毫秒级。"
用户请求(上海)→ 上海边缘节点(缓存命中)→ 直接返回
↗ 缓存未命中 → 中央存储 → 返回并缓存
用户请求(纽约)→ 纽约边缘节点(缓存命中)→ 直接返回
↗ 缓存未命中 → 中央存储 → 返回并缓存

"这跟 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 调用的操作数 | 1000 | 1000 |
| Namespace 数量 | 1000 | 1000 |
| 存储 | 1 GB | 无限 |
| Key 大小 | 512 字节 | 512 字节 |
| Value 大小 | 25 MiB | 25 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 GB | 1 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 非常简洁——就四个核心方法:put、get、list、delete。"

写入数据: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_complete 为 true 表示没有更多数据了。注意 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("");
}
"这段代码实现了三个典型场景:"
-
用户配置存储:
/api/user/config读取时从 KV 拿,cacheTtl设 60 秒——同一用户一分钟内的多次请求都走缓存。更新配置时直接写入 KV,最多 60 秒后全局生效。 -
AI 响应缓存:相同的 prompt 在 1 小时内不重复调用 AI——用 prompt 的 SHA-256 哈希做 key,存图片 URL。
expirationTtl: 3600让缓存自动过期。这既省 AI 调用额度,又加快响应速度。 -
特征开关:用 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 秒 | 需要较快生效 |
| 会话 Token | 3600~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 Redis | KV 读写快但查询弱;关系型适合复杂查询;Redis 强一致性但需自维护 |
| Cloudflare KV 特点 | 全球分布式、边缘缓存、低延迟读取、读多写少设计 |
| 最终一致性 | 写入后最多 60 秒才在全球所有节点可见;KV 为低延迟牺牲了强一致性 |
| 强一致性 vs 最终一致性 | 强一致=写入后立刻可见;最终一致=最终可见但有延迟窗口 |
| 最终一致性适用场景 | 配置存储、缓存、特征开关——能容忍 60 秒旧数据的场景 |
| 最终一致性不适用场景 | 库存扣减、计数器、资金转账——需要原子操作或强一致性 |
| 创建 Namespace | npx wrangler kv namespace create <BINDING_NAME> |
| 绑定 KV | wrangler.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 调用 |
动手挑战
-
基础挑战:创建一个 KV Namespace,在 Workers 中绑定它,实现
GET /api/config/:key读取和POST /api/config/:key写入的功能——一个通用的键值存储 API -
进阶挑战:给星火AI 的 AI 图片生成接口加上响应缓存——相同 prompt 在 1 小时内直接返回缓存结果,不再调用 AI。用 prompt 的 SHA-256 哈希做缓存 key,思考一下 TTL 设多少合适
-
折腾挑战:实现一个特征开关系统——管理员通过 API 开关功能,用户请求时从 KV 读取开关状态。尝试用 metadata 存储
{ enabled, rollout },通过list({ prefix: "flag:" })一次性获取所有开关。再思考:如果要做灰度发布(只对 50% 用户开放),该怎么做?
提示:KV 的
list()方法按字典序返回 key,可以用这个特性做简单的"前缀路由"——比如user:1001:config、user:1001:preferences、user: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 数据库》
