
最近在撸自己的博客系统,缓存这块绕不过去:文章阅读量要原子自增、几个接口想临时记点热数据、还有个简单的 API 限流。
第一反应当然是 Redis, 但是这就是个个人博客,访问量离「高并发」差得远。为了这几个读写专门起一个 Redis,本地开发、部署、CI 都得跟着多折腾一圈,有点不值。
博客本身已经用了 PostgreSQL + Prisma,那为什么不直接建一张 system_cache 表,先让数据库扛着?
但是如果直接业务代码里到处写 prisma.systemCache.findUnique。就是说万一流量真上来了,或者手痒想换成 Redis,全站搜一遍再改,绝对要命。
所以我中间设计了一层cache抽象类:现在默认走 Postgres,以后切 Redis 只改驱动,业务侧尽量不动。下面是接口、工厂,以及落地时踩过的坑。
接口怎么拆
应用层我只暴露两套东西:日常读写一套,后台运维一套。
业务接口只管高频操作;列表、按前缀清、清过期这种,单独放到可选的管理接口里。不是为了堆设计模式,是怕以后 Redis 那套「不好扫全库」的特性和 SQL 绑死。
export type CacheSetOptions = {
/** Time-to-live from now; omit for no expiry */
ttlMs?: number;
};
/**
* 应用层键值缓存(高频基础操作)
* Postgres / Redis 都实现这一套,具体用哪个由工厂决定
*/
export interface CacheStore {
get(key: string): Promise<string | null>;
set(key: string, value: string, options?: CacheSetOptions): Promise<void>;
delete(key: string): Promise<void>;
/**
* 原子自增:不存在或已过期则从 1 开始并设 TTL;否则在剩余 TTL 内 +1
* @returns 自增后的计数值
*/
incrementWithTtl(key: string, ttlMs: number): Promise<number>;
}
/** 管理台列表一行(跟具体存储无关) */
export type CacheEntryListItem = {
key: string;
value: string;
expiresAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
/**
* 可选:后台能看、能清
* SQL 好做列表和前缀删除;驱动没这能力就返回 null
*/
export interface CacheStoreAdmin {
listEntries(params: {
pageNo: number;
pageSize: number;
keyword?: string;
}): Promise<{
list: CacheEntryListItem[];
total: number;
}>;
deleteEntry(key: string): Promise<{ deleted: 0 | 1 }>;
deleteByPrefix(prefix: string): Promise<{ deleted: number }>;
purgeExpiredEntries(): Promise<{ deleted: number }>;
}业务侧只依赖 CacheStore,接口很干净。管理后台要看缓存列表,再去拿 CacheStoreAdmin;拿不到就当没有这功能,页面降级就行,别把整个站拖死。
工厂和单例
接口定好了,用环境变量选驱动。Redis 我先没写,选了就直接抛错,免得默默跑出一个半成品。
开发环境还有个坑:热更新一跑,模块顶层变量经常被重置,你以为的单例其实白建了。我把实例挂到 globalThis 上,热更新时还能保住:
import { env } from "@/config/env";
import { prisma } from "@/lib/prisma";
import type { CacheStore, CacheStoreAdmin } from "./cache-store";
import { PrismaAppCache } from "./prisma-app-cache";
export type CacheDriver = "postgres" | "redis";
function resolveDriver(): CacheDriver {
return env.cache.driver;
}
/** 按环境变量创建具体实现 */
export function createCacheStore(): CacheStore {
const driver = resolveDriver();
if (driver === "redis") {
throw new Error(
"CACHE_DRIVER=redis 尚未实现。请使用 postgres(默认)或实现 Redis 适配器后接入。",
);
}
return new PrismaAppCache(prisma);
}
// 开发模式热更新会重置顶层变量,单例挂 globalThis
const globalForCache = globalThis as unknown as {
cacheStoreInstance?: CacheStore;
};
export function getCacheStore(): CacheStore {
if (!globalForCache.cacheStoreInstance) {
globalForCache.cacheStoreInstance = createCacheStore();
}
return globalForCache.cacheStoreInstance;
}
/**
* 管理台接口:用能力探测,不写死「当前一定是 Postgres」
*/
export function getCacheStoreAdmin(): CacheStoreAdmin | null {
const store = getCacheStore();
if (
"listEntries" in store &&
typeof (store as CacheStoreAdmin).listEntries === "function"
) {
return store as unknown as CacheStoreAdmin;
}
return null;
}落地时的两个坑
接口写着爽,落到 Postgres / Redis 上差别就出来了。
incrementWithTtl 必须原子
阅读量这种东西,自增和设 TTL 不能拆开。
- Postgres:用事务,或者
INSERT ... ON CONFLICT,把「没有就初始化并设过期、有就 +1」捆成一步。 - Redis:
INCRBY和EXPIRE是两条命令。以后写 Redis 适配器,得上 Lua 脚本合成一次原子操作,不然并发一高,TTL 丢了你都不知道怎么丢的。
管理台分页别绑死 SQL 习惯
- Postgres 用
OFFSET/LIMIT+LIKE做分页很轻松。 - Redis 没有正经的「第几页」;常见是
SCAN游标扫。硬要在 Redis 里做精确的pageNo+pageSize,成本很难看。
所以 CacheStoreAdmin 做成可选:别让「SQL 好做的事」变成 Redis 也必须有的契约。
小结
个人项目里,一上来就上 Redis 容易折腾过度;业务里到处裸写缓存表,后面换存储又会哭。
中间夹这几十行接口和工厂之后:
- 现在本地和生产都可以零额外组件,用 Postgres 扛缓存;
- 以后要上 Redis,写一个
RedisAppCache实现CacheStore,工厂里切一下分支就行,业务代码尽量不用动。
够用就行,别为了「看起来很架构」多挖坑。
评论区
0 条评论还没有评论,欢迎成为第一个留言的人。