feat(cache): 实现 MemoryCacheStore(LRU + TTL + tag 索引)

This commit is contained in:
SpecialX
2026-07-05 17:49:51 +08:00
parent 4174cd61d7
commit 0058b0b311
2 changed files with 184 additions and 0 deletions

View File

@@ -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")
})
})

111
src/shared/lib/cache/memory-store.ts vendored Normal file
View File

@@ -0,0 +1,111 @@
import "server-only"
import type { CacheStore } from "./types"
interface CacheEntry {
value: unknown
expireAt: number // 0 表示永久
tags: ReadonlySet<string>
}
export interface MemoryCacheStoreOptions {
/** 最大条目数,超出 LRU 淘汰。默认 500。 */
readonly maxEntries?: number
}
/**
* 内存 LRU 缓存实现。
*
* - 使用 Map 维护插入顺序FIFO访问时删除再插入实现 LRU
* - TTL 过期惰性删除getOrSet 时检查)
* - tag 索引Map<tag, Set<key>> 反查表
* - 适用于单实例 dev/test
*/
export class MemoryCacheStore implements CacheStore {
private readonly entries = new Map<string, CacheEntry>()
private readonly tagIndex = new Map<string, Set<string>>()
private readonly maxEntries: number
constructor(options: MemoryCacheStoreOptions = {}) {
this.maxEntries = options.maxEntries ?? 500
}
async getOrSet<T>(
key: string,
producer: () => Promise<T>,
options: { tags: readonly string[]; ttl?: number },
): Promise<T> {
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<void> {
const keysToDelete = new Set<string>()
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)
}
}
}
}
}