feat(cache): 实现 MemoryCacheStore(LRU + TTL + tag 索引)
This commit is contained in:
73
src/shared/lib/cache/memory-store.test.ts
vendored
Normal file
73
src/shared/lib/cache/memory-store.test.ts
vendored
Normal 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
111
src/shared/lib/cache/memory-store.ts
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user