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