feat(cache): 实现 RedisCacheStore(多实例 + tag 索引 + fail-open)

This commit is contained in:
SpecialX
2026-07-05 17:45:02 +08:00
parent 44f997bad7
commit 4174cd61d7
2 changed files with 183 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
// Mock 共享 Redis 客户端
const mockRedis = {
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
sadd: vi.fn(),
smembers: vi.fn(),
expire: vi.fn(),
}
const getRedisClientMock = vi.fn().mockResolvedValue(mockRedis)
vi.mock("@/shared/lib/redis-client", () => ({
getRedisClient: () => getRedisClientMock(),
}))
import { RedisCacheStore } from "./redis-store"
describe("RedisCacheStore", () => {
let store: RedisCacheStore
beforeEach(() => {
vi.clearAllMocks()
getRedisClientMock.mockResolvedValue(mockRedis)
store = new RedisCacheStore()
})
it("未命中时调用 producer 并缓存set + sadd 索引)", async () => {
mockRedis.get.mockResolvedValue(null)
const producer = vi.fn().mockResolvedValue({ count: 42 })
const result = await store.getOrSet("k1", producer, {
tags: ["users"],
ttl: 60,
})
expect(result).toEqual({ count: 42 })
expect(mockRedis.set).toHaveBeenCalledTimes(1)
expect(mockRedis.sadd).toHaveBeenCalledWith("next-edu:cache:tag:users", "next-edu:cache:k1")
expect(mockRedis.expire).toHaveBeenCalledWith("next-edu:cache:tag:users", 60)
})
it("命中时直接返回缓存值,不调用 producer", async () => {
mockRedis.get.mockResolvedValue(JSON.stringify({ count: 99 }))
const producer = vi.fn()
const result = await store.getOrSet("k1", producer, { tags: ["users"] })
expect(result).toEqual({ count: 99 })
expect(producer).not.toHaveBeenCalled()
})
it("invalidateTags 通过 smembers+del 批量删除", async () => {
mockRedis.smembers
.mockResolvedValueOnce(["next-edu:cache:k1", "next-edu:cache:k2"]) // tag users
.mockResolvedValueOnce(["next-edu:cache:k3"]) // tag classes
await store.invalidateTags(["users", "classes"])
expect(mockRedis.smembers).toHaveBeenCalledWith("next-edu:cache:tag:users")
expect(mockRedis.smembers).toHaveBeenCalledWith("next-edu:cache:tag:classes")
expect(mockRedis.del).toHaveBeenCalledWith("next-edu:cache:k1", "next-edu:cache:k2", "next-edu:cache:k3")
expect(mockRedis.del).toHaveBeenCalledWith("next-edu:cache:tag:users", "next-edu:cache:tag:classes")
})
it("Redis 不可用时降级直查 producer", async () => {
getRedisClientMock.mockResolvedValueOnce(null)
const producer = vi.fn().mockResolvedValue("v1")
const result = await store.getOrSet("k1", producer, { tags: ["users"] })
expect(result).toBe("v1")
expect(producer).toHaveBeenCalledTimes(1)
})
it("Redis 调用抛错时降级直查 producer", async () => {
mockRedis.get.mockRejectedValue(new Error("Redis down"))
const producer = vi.fn().mockResolvedValue("v1")
const result = await store.getOrSet("k1", producer, { tags: ["users"] })
expect(result).toBe("v1")
expect(producer).toHaveBeenCalledTimes(1)
})
})

107
src/shared/lib/cache/redis-store.ts vendored Normal file
View File

@@ -0,0 +1,107 @@
import "server-only"
import { getRedisClient } from "@/shared/lib/redis-client"
import type { CacheStore } from "./types"
const KEY_PREFIX = "next-edu:cache:"
const TAG_PREFIX = `${KEY_PREFIX}tag:`
/**
* Redis 多实例缓存实现。
*
* - key 格式next-edu:cache:{key}
* - valueJSON.stringify
* - tag 反查索引next-edu:cache:tag:{tag} → Redis Set 存放 keys
* - TTLset 时通过 expire 原子设置
* - invalidateTagssmembers → del 批量
*
* 故障降级Redis 不可用或调用抛错时,透传 producer不阻断主流程。
*/
export class RedisCacheStore implements CacheStore {
async getOrSet<T>(
key: string,
producer: () => Promise<T>,
options: { tags: readonly string[]; ttl?: number },
): Promise<T> {
const redis = (await getRedisClient()) as RedisLike | null
const redisKey = `${KEY_PREFIX}${key}`
if (redis) {
try {
const cached = await redis.get(redisKey)
if (cached) {
return JSON.parse(cached) as T
}
} catch (error) {
console.error(
"[cache] Redis get failure, falling back to producer:",
error instanceof Error ? error.message : String(error),
)
}
}
// 未命中或 Redis 不可用:调用 producer
const value = await producer()
if (redis) {
try {
await redis.set(redisKey, JSON.stringify(value))
if (options.ttl) {
await redis.expire(redisKey, options.ttl)
}
// 维护 tag 索引
for (const tag of options.tags) {
const tagKey = `${TAG_PREFIX}${tag}`
await redis.sadd(tagKey, redisKey)
if (options.ttl) {
await redis.expire(tagKey, options.ttl)
}
}
} catch (error) {
console.error(
"[cache] Redis set failure, ignoring:",
error instanceof Error ? error.message : String(error),
)
}
}
return value
}
async invalidateTags(tags: readonly string[]): Promise<void> {
const redis = (await getRedisClient()) as RedisLike | null
if (!redis) return
try {
const keysToDelete: string[] = []
const tagKeysToDelete: string[] = []
for (const tag of tags) {
const tagKey = `${TAG_PREFIX}${tag}`
const keys = await redis.smembers(tagKey)
keysToDelete.push(...keys)
tagKeysToDelete.push(tagKey)
}
if (keysToDelete.length > 0) {
await redis.del(...keysToDelete)
}
if (tagKeysToDelete.length > 0) {
await redis.del(...tagKeysToDelete)
}
} catch (error) {
console.error(
"[cache] Redis invalidateTags failure, ignoring:",
error instanceof Error ? error.message : String(error),
)
}
}
}
/** @upstash/redis 的最小可用接口 */
interface RedisLike {
get(key: string): Promise<string | null>
set(key: string, value: string): Promise<unknown>
del(...keys: string[]): Promise<number>
sadd(key: string, ...members: string[]): Promise<number>
smembers(key: string): Promise<string[]>
expire(key: string, seconds: number): Promise<number>
}