5.3 KV 與 R2:鍵值快取與物件儲存#
本節目標#
- 理解 KV 的最終一致模型與 TTL 語意,學會 cache TTL 設定
- 用 KV 實作一個留言板(讀多寫少場景)
- 理解 R2 的 S3 相容性與 zero egress 收費模式
- 實作 R2 的 putObject 與預署 URL(presigned URL)檔案下載
KV:邊緣上的「快取等級」鍵值儲存#
Cloudflare KV 的原理很單純:把整個 namespace 最終複製到全球所有資料中心。任一邊緣節點的寫入先送到中心協調者,再逐步傳播;讀取永遠是「就近讀本地副本」,因此讀取延遲極低(常是個位數毫秒),但寫入要到最長 60 秒才會全球可見。
對照 5.1 節的光譜,KV 是極端的 AP 系統:永遠可以讀(本地有副本就回),但犧牲即時一致。這帶來一條鐵律:
KV 適合「寫一次、讀很多次」的資料;不適合需要讀回自己剛寫入內容(read-after-write)的場景。
歷史脈絡:KV 的設計精神承襲 Amazon Dynamo(2007 論文)——去中心化、最終一致、以可用性優先。而 KV 的「全球只讀副本」策略則更像 CDN 的行為,這不是巧合:KV 本來就是為「組態、特性開關、快取索引」這類 CDN 周邊資料設計的。
TTL 設定:兩種時間語意#
KV 支援兩種 TTL 寫法,語意不同,容易混淆:
| 寫法 | 意義 | 典型用途 |
|---|---|---|
expirationTtl: seconds | 從寫入時刻起,多久後過期(相對時間) | session token、短期快取 |
expiration: unixTimestamp | 在哪個絕對時刻過期 | 「今天 23:59 前」的活動資料 |
// 相對 vs 絕對 TTL
await env.CACHE.put("top:posts", json, { expirationTtl: 60 });
await env.CACHE.put("flag:newyear", "on", {
expiration: Math.floor(new Date("2026-01-01").getTime() / 1000),
});
注意:KV 過期不是精準的——到期後值可能延遲至多一小時才真正「消失」,因此不要把 KV 拿來做精確的鎖或倒數。
實作一:KV 留言板#
留言板是 KV 的教科書場景嗎?其實不是——兩人同時留言時,LWW 會吃掉一筆。但若留言板是「寫入後不要求他人立刻看到、可接受偶爾覆蓋」的場景,KV 是最便宜、最快的選擇。我們用「留言追加清單(存 JSON)」示範,並在結尾指出它的極限。
# wrangler.toml 片段
kv_namespaces = [
{ binding = "MESSAGES", id = "xxxx", preview_id = "yyyy" }
]
// src/kv-board.ts
const MAX = 100; // 保留最新 100 筆
interface Msg { user: string; text: string; at: number }
async function read(env: Env): Promise<Msg[]> {
const raw = await env.MESSAGES.get("board");
return raw ? JSON.parse(raw) as Msg[] : [];
}
export async function getMessages(env: Env): Promise<Response> {
return Response.json(await read(env));
}
export async function postMessage(env: Env, req: Request): Promise<Response> {
const { user, text } = await req.json<{ user: string; text: string }>();
if (!user || !text) return new Response("bad request", { status: 400 });
const list = await read(env);
list.push({ user, text, at: Date.now() });
// 最後寫入者覆蓋整個清單:兩人同時留言時,較慢的那次寫入勝出
await env.MESSAGES.put(
"board",
JSON.stringify(list.slice(-MAX)),
{ expirationTtl: 86400 * 7 } // 七天後整板清空
);
return new Response(null, { status: 201 });
}
架構上的限制要點明:這種「讀─改─寫整個清單」的模式在兩個邊緣節點同進行時會遺失留言。若要求留言永不遺失或即時顯示,請改用 D1(交易)或 Durable Objects(單一寫入者),KV 留給「公告欄、全站快取」這類不衝突資料。
R2:S3 相容的物件儲存,zero egress#
R2 的定位是物件儲存(BLOB:圖片、影片、備份、模型檔),與 KV 的差異:
| 面向 | KV | R2 |
|---|---|---|
| 物件大小 | ≤ 25 MB | 最大 5 TB |
| 介面 | 自有 REST(binding) | AWS S3 API 相容(binding 或 SDK) |
| 收費 | 讀寫次數 | 存儲 + 操作;egress 頻寬免費 |
zero egress 是 R2 的殺手鐧:傳統雲端把檔案「讀出去」要收頻寬費,使用者下載影片、跨雲搬資料都會被收。R2 宣告不收 egress,這對「影片站、軟體發布站」的成本結構是顛覆性的。歷史上 AWS S3(2006)訂立的 API 已成為事實標準,R2 選擇相容而非另起爐灶,大幅降低遷移成本。
實作二:R2 上傳與檔案下載#
綁定 R2 到 Worker(wrangler.toml):
r2_buckets = [
{ binding = "BUCKET", bucket_name = "blog-media", preview_bucket_name = "blog-media-dev" }
]
// src/r2-files.ts
export async function upload(env: Env, req: Request): Promise<Response> {
const file = (await req.formData()).get("file");
if (!(file instanceof File)) return new Response("no file", { status: 400 });
await env.BUCKET.put(file.name, file, {
httpMetadata: { contentType: file.type },
});
return Response.json({ key: file.name, size: file.size }, { status: 201 });
}
export async function download(env: Env, key: string): Promise<Response> {
const obj = await env.BUCKET.get(key);
if (!obj) return new Response("Not found", { status: 404 });
const headers = new Headers();
obj.writeHttpMetadata(headers); // 還原 content-type 等
headers.set("etag", obj.httpEtag);
return new Response(obj.body, { headers }); // obj.body 是 ReadableStream
}
預署 URL(Presigned URL):讓瀏覽器直傳/直下載#
預署 URL 是「把有時效、有權限範圍的暫時性簽章附在 URL 上」,讓沒有金鑰的瀏覽器可在期限內對特定物件執行指定動作,而不經由你的伺服器轉發檔案——直接轉發大檔案會吃掉 Worker 的請求時間與 CPU。
// 用 aws4fetch SDK 產生 presigned PUT,讓瀏覽器直傳
import { AwsClient } from "aws4fetch";
export async function presignUpload(env: Env, key: string): Promise<Response> {
const client = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
});
const url = new URL(
`https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/blog-media/${key}`
);
const signed = await client.sign(
new Request(url, { method: "PUT" }),
{ aws: { signQuery: true } } // 把簽章放進 query string
);
return Response.json({ uploadUrl: signed.url });
}
完整流程(現代檔案上傳的標準三步驟):
- 瀏覽器呼叫你的 Worker:
GET /presign?name=cover.jpg - Worker 驗證身分後回傳 presigned URL(含有效期)
- 瀏覽器
PUT該 URL 直傳 R2,之後用https://media.example.com/cover.jpg讀取
小結與練習#
KV 是「全球只讀副本」的最終一致鍵值倉,適合組態與快取,不適合衝突性寫入;R2 是 S3 相容、zero egress 的物件儲存,搭配預署 URL 讓大檔案直傳直下。兩者合用可以覆蓋「小資料快取 + 大媒體」的大部分現代網站需求。
練習:
- 把留言板改成 D1 版本(5.2 節的
comments表),比較兩者在「同時留言」的行為差異,並寫下你的結論。 - 為 KV 快取實作「快取穿透」防護:
top:posts不存在時,從 D1 讀取並回填 KV(TTL 60 秒),畫出流程圖。 - 實作
GET /files/:key的下載端點,並對.jpg設定Cache-Control: public, max-age=31536000, immutable。 - 進階:查閱 R2 文件,研究「presigned URL 的最長有效期」與「自訂網域掛載 R2」兩個主題。