跳轉到

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 });
}

完整流程(現代檔案上傳的標準三步驟):

  1. 瀏覽器呼叫你的 Worker:GET /presign?name=cover.jpg
  2. Worker 驗證身分後回傳 presigned URL(含有效期)
  3. 瀏覽器 PUT 該 URL 直傳 R2,之後用 https://media.example.com/cover.jpg 讀取

小結與練習#

KV 是「全球只讀副本」的最終一致鍵值倉,適合組態與快取,不適合衝突性寫入;R2 是 S3 相容、zero egress 的物件儲存,搭配預署 URL 讓大檔案直傳直下。兩者合用可以覆蓋「小資料快取 + 大媒體」的大部分現代網站需求。

練習:

  1. 把留言板改成 D1 版本(5.2 節的 comments 表),比較兩者在「同時留言」的行為差異,並寫下你的結論。
  2. 為 KV 快取實作「快取穿透」防護:top:posts 不存在時,從 D1 讀取並回填 KV(TTL 60 秒),畫出流程圖。
  3. 實作 GET /files/:key 的下載端點,並對 .jpg 設定 Cache-Control: public, max-age=31536000, immutable。
  4. 進階:查閱 R2 文件,研究「presigned URL 的最長有效期」與「自訂網域掛載 R2」兩個主題。