个人博客不上 Redis:用 Postgres 做一层 Cache 抽象

个人博客不上 Redis:用 Postgres 做一层 Cache 抽象

2026-03-13 11:52:0056 浏览993作者:dreamk项目实践

最近在撸自己的博客系统,缓存这块绕不过去:文章阅读量要原子自增、几个接口想临时记点热数据、还有个简单的 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」捆成一步。
  • RedisINCRBYEXPIRE 是两条命令。以后写 Redis 适配器,得上 Lua 脚本合成一次原子操作,不然并发一高,TTL 丢了你都不知道怎么丢的。

管理台分页别绑死 SQL 习惯

  • Postgres 用 OFFSET / LIMIT + LIKE 做分页很轻松。
  • Redis 没有正经的「第几页」;常见是 SCAN 游标扫。硬要在 Redis 里做精确的 pageNo + pageSize,成本很难看。

所以 CacheStoreAdmin 做成可选:别让「SQL 好做的事」变成 Redis 也必须有的契约。

小结

个人项目里,一上来就上 Redis 容易折腾过度;业务里到处裸写缓存表,后面换存储又会哭。

中间夹这几十行接口和工厂之后:

  • 现在本地和生产都可以零额外组件,用 Postgres 扛缓存;
  • 以后要上 Redis,写一个 RedisAppCache 实现 CacheStore,工厂里切一下分支就行,业务代码尽量不用动。

够用就行,别为了「看起来很架构」多挖坑。

评论区

0 条评论

还没有评论,欢迎成为第一个留言的人。