From 0058b0b311867dfd0bf577697b90108858e62e02 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:49:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(cache):=20=E5=AE=9E=E7=8E=B0=20MemoryCache?= =?UTF-8?q?Store=EF=BC=88LRU=20+=20TTL=20+=20tag=20=E7=B4=A2=E5=BC=95?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/lib/cache/memory-store.test.ts | 73 ++++++++++++++ src/shared/lib/cache/memory-store.ts | 111 ++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 src/shared/lib/cache/memory-store.test.ts create mode 100644 src/shared/lib/cache/memory-store.ts diff --git a/src/shared/lib/cache/memory-store.test.ts b/src/shared/lib/cache/memory-store.test.ts new file mode 100644 index 0000000..ffba6d5 --- /dev/null +++ b/src/shared/lib/cache/memory-store.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { MemoryCacheStore } from "./memory-store" + +describe("MemoryCacheStore", () => { + let store: MemoryCacheStore + + beforeEach(() => { + store = new MemoryCacheStore({ maxEntries: 3 }) + }) + + it("未命中时调用 producer 并缓存结果", async () => { + const producer = vi.fn().mockResolvedValue({ count: 42 }) + const result = await store.getOrSet("k1", producer, { tags: ["users"] }) + expect(result).toEqual({ count: 42 }) + expect(producer).toHaveBeenCalledTimes(1) + }) + + it("命中时不调用 producer", async () => { + const producer = vi.fn().mockResolvedValue("v1") + await store.getOrSet("k1", producer, { tags: ["users"] }) + const result = await store.getOrSet("k1", producer, { tags: ["users"] }) + expect(result).toBe("v1") + expect(producer).toHaveBeenCalledTimes(1) + }) + + it("TTL 过期后重新调用 producer", async () => { + vi.useFakeTimers() + const producer = vi.fn().mockResolvedValue("v1") + await store.getOrSet("k1", producer, { tags: ["users"], ttl: 60 }) + vi.advanceTimersByTime(61_000) + producer.mockResolvedValue("v2") + const result = await store.getOrSet("k1", producer, { tags: ["users"], ttl: 60 }) + expect(result).toBe("v2") + expect(producer).toHaveBeenCalledTimes(2) + vi.useRealTimers() + }) + + it("invalidateTags 删除关联 key", async () => { + const producer = vi.fn() + producer.mockResolvedValueOnce("v1").mockResolvedValueOnce("v2") + await store.getOrSet("k1", producer, { tags: ["users"] }) + await store.invalidateTags(["users"]) + const result = await store.getOrSet("k1", producer, { tags: ["users"] }) + expect(result).toBe("v2") + expect(producer).toHaveBeenCalledTimes(2) + }) + + it("LRU 淘汰最久未访问的 key", async () => { + const producer = vi.fn() + producer.mockResolvedValue("v") + await store.getOrSet("k1", producer, { tags: ["a"] }) + await store.getOrSet("k2", producer, { tags: ["b"] }) + // 访问 k1,使 k2 成为 LRU + await store.getOrSet("k1", producer, { tags: ["a"] }) + await store.getOrSet("k3", producer, { tags: ["c"] }) + // k4 触发淘汰,k2 应被淘汰 + await store.getOrSet("k4", producer, { tags: ["d"] }) + producer.mockResolvedValue("new-v2") + const result = await store.getOrSet("k2", producer, { tags: ["b"] }) + expect(result).toBe("new-v2") + expect(producer).toHaveBeenCalledTimes(5) // 4 次初始 + 1 次 k2 重新调用 + }) + + it("producer 抛错时不写入缓存", async () => { + const producer = vi.fn().mockRejectedValueOnce(new Error("DB down")) + await expect( + store.getOrSet("k1", producer, { tags: ["users"] }), + ).rejects.toThrow("DB down") + producer.mockResolvedValueOnce("v1") + const result = await store.getOrSet("k1", producer, { tags: ["users"] }) + expect(result).toBe("v1") + }) +}) diff --git a/src/shared/lib/cache/memory-store.ts b/src/shared/lib/cache/memory-store.ts new file mode 100644 index 0000000..bab6c5a --- /dev/null +++ b/src/shared/lib/cache/memory-store.ts @@ -0,0 +1,111 @@ +import "server-only" + +import type { CacheStore } from "./types" + +interface CacheEntry { + value: unknown + expireAt: number // 0 表示永久 + tags: ReadonlySet +} + +export interface MemoryCacheStoreOptions { + /** 最大条目数,超出 LRU 淘汰。默认 500。 */ + readonly maxEntries?: number +} + +/** + * 内存 LRU 缓存实现。 + * + * - 使用 Map 维护插入顺序(FIFO),访问时删除再插入实现 LRU + * - TTL 过期:惰性删除(getOrSet 时检查) + * - tag 索引:Map> 反查表 + * - 适用于单实例 dev/test + */ +export class MemoryCacheStore implements CacheStore { + private readonly entries = new Map() + private readonly tagIndex = new Map>() + private readonly maxEntries: number + + constructor(options: MemoryCacheStoreOptions = {}) { + this.maxEntries = options.maxEntries ?? 500 + } + + async getOrSet( + key: string, + producer: () => Promise, + options: { tags: readonly string[]; ttl?: number }, + ): Promise { + const now = Date.now() + const existing = this.entries.get(key) + + // 命中且未过期 + if (existing && (existing.expireAt === 0 || existing.expireAt > now)) { + // LRU:删除再插入,使其成为最新访问 + this.entries.delete(key) + this.entries.set(key, existing) + return existing.value as T + } + + // 未命中或过期:调用 producer + if (existing) { + this.removeEntry(key) + } + + const value = await producer() + const expireAt = options.ttl ? now + options.ttl * 1000 : 0 + const tagSet = new Set(options.tags) + + this.entries.set(key, { value, expireAt, tags: tagSet }) + for (const tag of options.tags) { + let keys = this.tagIndex.get(tag) + if (!keys) { + keys = new Set() + this.tagIndex.set(tag, keys) + } + keys.add(key) + } + + this.evictIfNeeded() + return value + } + + async invalidateTags(tags: readonly string[]): Promise { + const keysToDelete = new Set() + for (const tag of tags) { + const keys = this.tagIndex.get(tag) + if (keys) { + for (const key of keys) { + keysToDelete.add(key) + } + this.tagIndex.delete(tag) + } + } + for (const key of keysToDelete) { + this.removeEntry(key) + } + } + + /** LRU 淘汰:超出 maxEntries 时删除最旧的 */ + private evictIfNeeded(): void { + while (this.entries.size > this.maxEntries) { + const oldestKey = this.entries.keys().next().value + if (oldestKey === undefined) break + this.removeEntry(oldestKey) + } + } + + private removeEntry(key: string): void { + const entry = this.entries.get(key) + if (!entry) return + this.entries.delete(key) + for (const tag of entry.tags) { + const keys = this.tagIndex.get(tag) + if (keys) { + keys.delete(key) + if (keys.size === 0) { + this.tagIndex.delete(tag) + } + } + } + } +}