feat(cache): 实现 RedisCacheStore(多实例 + tag 索引 + fail-open)
This commit is contained in:
76
src/shared/lib/cache/redis-store.test.ts
vendored
Normal file
76
src/shared/lib/cache/redis-store.test.ts
vendored
Normal 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
107
src/shared/lib/cache/redis-store.ts
vendored
Normal 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}
|
||||||
|
* - value:JSON.stringify
|
||||||
|
* - tag 反查索引:next-edu:cache:tag:{tag} → Redis Set 存放 keys
|
||||||
|
* - TTL:set 时通过 expire 原子设置
|
||||||
|
* - invalidateTags:smembers → 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>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user