zudo-cloudflare-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

KV(キーバリュー)

Cloudflare KV ネームスペースの使用パターン

概要

KV はグローバルで低レイテンシのキーバリューストア。結果整合性 -- 書き込みは約60秒以内にグローバルに伝播するが、その間は古いデータを返す可能性がある。

セットアップ

ネームスペースの作成

npx wrangler kv namespace create "MY_KV"

出力されたネームスペース ID を wrangler.toml に追加:

[[kv_namespaces]]
binding = "MY_KV"
id = "abc123def456ghi789"

関数での使用

interface Env {
  MY_KV: KVNamespace;
}

// 読み取り
const value = await env.MY_KV.get("key");
const json = await env.MY_KV.get("key", { type: "json" });

// 書き込み
await env.MY_KV.put("key", "value");
await env.MY_KV.put("key", JSON.stringify(data));

// 有効期限付き書き込み(TTL は秒)
await env.MY_KV.put("key", "value", { expirationTtl: 3600 });

// 削除
await env.MY_KV.delete("key");

// キーの一覧
const list = await env.MY_KV.list({ prefix: "logs:" });

実践パターン: キーワードログ

zpaper project から -- 検索キーワードを KV に記録する:

interface Env {
  KEYWORD_LOGS: KVNamespace;
}

export const onRequestGet: PagesFunction<Env> = async (context) => {
  const url = new URL(context.request.url);
  const query = url.searchParams.get("q")?.trim();

  if (query) {
    // Log the keyword asynchronously (don't block the response)
    const key = `search:${Date.now()}:${crypto.randomUUID()}`;
    context.waitUntil(
      context.env.KEYWORD_LOGS.put(key, JSON.stringify({
        query,
        timestamp: new Date().toISOString(),
      }), { expirationTtl: 86400 * 30 }) // 30 days
    );
  }

  // ... return search results
};

重要でない書き込みには waitUntil を使用

context.waitUntil() はレスポンス送信後に非同期処理を実行できる。ロギング、アナリティクス、その他の重要でない書き込みに使用する。

パターン: 短命な会話状態

TTL ベースの自動クリーンアップ付きで、スレッドごとの小さな bot、セッション、キャッシュ状態を 保存する:

KV をデフォルトの永続チャット DB にしない

KV は短命な TTL 付きの bot、セッション、キャッシュ状態には向いている。永続的なチャット履歴、 認可を考慮した読み取り、監査性には、通常 D1 のほうがよいデフォルトになる。 チャットストレージ全体の設計は チャットメモリと RAG を参照する。

const CONVERSATION_TTL = 86400; // 24 時間

interface ConversationHistory {
  messages: Array<{ role: string; content: string }>;
}

// Load with typed JSON deserialization
const key = `conv:${threadId}`;
const stored = await env.KV.get<ConversationHistory>(key, "json");
const history = stored ?? { messages: [] };

// Append new message
history.messages.push({ role: "user", content: userMessage });

// Save with TTL -- old conversations auto-expire
await env.KV.put(key, JSON.stringify(history), {
  expirationTtl: CONVERSATION_TTL,
});

設計のポイント:

  • conv:{threadId} のような構造化キーで識別しやすくする

  • get()"json" 型パラメータでデシリアライズを自動化

  • TTL により、このパターンを永続履歴ではなく短命な状態に限定する

パターン: レート制限

TTL 有効期限付き KV カウンターによるユーザーごとのレート制限:

const RATE_LIMIT = 30;
const RATE_WINDOW = 86400; // 24 時間

async function checkRateLimit(
  env: Env,
  userId: string,
): Promise<boolean> {
  const key = `rate:${userId}`;
  const count = await env.KV.get<number>(key, "json") ?? 0;

  if (count >= RATE_LIMIT) return false;

  await env.KV.put(key, JSON.stringify(count + 1), {
    expirationTtl: RATE_WINDOW,
  });
  return true;
}

KV のレート制限は近似的

KV は結果整合性のため、高い同時実行数では数件の余分なリクエストが通過する可能性がある。ボットや API のレート制限としては問題ないが、正確なカウントが必要な場合は Durable Objects を使用する。

パターン: 新着順フィード

「最近のアクティビティ」や「最新の投稿」のようなフィードを KV 上に構築するには、2つの問題を解決する必要がある:順序を正しくすることと、それを描画するために無制限のファンアウトコストを支払わないことだ。

ソート可能なキー

各フィードアイテムのキーに ISO-8601 タイムスタンプを含め、辞書順のキー順序が時系列順序と一致するようにする:

const key = `feed:${new Date().toISOString()}:${crypto.randomUUID()}`;
await env.FEED.put(key, JSON.stringify(item));

toISOString() は固定長でゼロ埋めされている(2026-08-12T03:15:22.123Z)ため、文字列比較と時刻比較が一致する -- ゼロ埋めのない Date.now() やランダムな UUID では時系列順にソートされないのとは対照的だ。

これで時系列順にはなるが、時系列順にはならない。list() はプレフィックス範囲の先頭から前方にしか進めない -- 「末尾から開始する」カーソルは存在しない。そのまま昇順の ISO-8601 にしておくと、最新のアイテムは肥大化し続けるプレフィックスの末尾にあり、そこに到達するには履歴全体をページングする必要がある。代わりにタイムスタンプを反転させ、最新の書き込みが最小のキー、つまり素の list() の最初の結果になるようにする:

// Newest-first: invert the timestamp so ascending list() order is descending time order.
const REVERSE_EPOCH_MS = 9999999999999; // safely past any real Date.now()
function feedKey(id: string, when = new Date()): string {
  const reversed = String(REVERSE_EPOCH_MS - when.getTime()).padStart(13, "0");
  return `feed:${reversed}:${id}`;
}

ファンアウトのコスト

list() はキー名とメタデータのみを返し、値は返さない。N 件のフィードアイテムを描画するには N 回の追加の get() 呼び出しが必要になる -- 本当のコストは list 自体ではなく、このファンアウトにある。

フィード量の急増や、フィルタで除外されるアイテムの連続が、無制限の get() バーストに変わらないよう、境界を3段階に分けて設ける:

interface FeedItem {
  hidden: boolean;
  // ...other fields
}

async function getFeed(env: Env, displayLimit = 6): Promise<FeedItem[]> {
  // 1. List a candidate pool, oversized to absorb items filtered out in step 3.
  const { keys } = await env.FEED.list({ prefix: "feed:", limit: 40 });

  // 2. Bound the fan-out: fetch at most 20 bodies, regardless of how many
  //    candidates step 1 returned.
  const candidates = keys.slice(0, 20);
  const items = await Promise.all(
    candidates.map((k) => env.FEED.get<FeedItem>(k.name, "json")),
  );

  // 3. Bound what actually reaches the UI.
  return items
    .filter((item): item is FeedItem => item !== null && !item.hidden)
    .slice(0, displayLimit);
}
  • 40 -- list() の呼び出し。手順3で除外されるアイテム(非表示、不正、削除済み)の分だけ余裕を持たせたサイズ

  • 20 -- get() のファンアウトに対するハードな上限で、手順1が何件のキーを返したかに関わらず適用される。これはワーストケースのサブリクエスト数と CPU 時間を決める数値なので、意図的に選ぶ

  • 6 -- 実際にフィードに表示される件数。ファンアウトの上限とは切り離してあるので、UI 変更(6件ではなく10件表示する等)がフェッチコストの予算に影響することはない

各レイヤーは、その下のレイヤーで発生する損失 -- フィルタで除外される list 結果、取得後に非表示と判明するアイテム -- を吸収し、その損失が KV 操作の回数を静かに膨張させないようにする。

注意点

  • 結果整合性: 書き込み直後の読み取りは古いデータを返す可能性がある

  • CAS(Compare-And-Swap)がなく、否定的なルックアップがキャッシュされる: put() は常に無条件で上書きする -- アトミックな check-and-set が存在しないため、2つの書き込み側がそれぞれキーを「存在しない」と観測し、衝突の合図なしに両方とも書き込んでしまうことがある。さらに get() のミスはエッジでキャッシュされうるため、別の書き込み側がその key に対して put() を完了した後もしばらく「見つからない」を返し続けることがある。重複排除、ロック、クレーム処理のロジックを KV の上に構築してはならない -- それは D1 のアトミックな重複排除とクレーム が担うべきで、そこにはこれに必要なプリミティブ(ON CONFLICTchanges())がある。

  • 512バイトのキー制限: キーは512バイトを超えられない

  • 25 MiB の値制限: 値は25 MiB を超えられない

  • リストのページネーション: list() は1回の呼び出しで最大1000キーを返す

Revision History

作成更新