《TypeScript编程实战》8.2 Redis 类型安全封装

本节把裸 Redis 客户端那层 string 边界升级成编译器可检查的类型边界。先拆解键空间声明、codec 与客户端三层封装的职责,再用 TypeScript 泛型与 as const 推导实现 defineCache、类型化 get/set/mget/del 与 remember 缓存旁路助手,并用 Zod codec 在反序列化阶段拦截结构漂移,最后给出管道批量与常见类型漏洞的修复方案。

本节目标:把上一节设计好的字符串键,封装成一个「读出来的就是目标类型」的访问层。读完本节,你能写出 defineCache 这样的泛型工厂,让 get 返回 Product | null 而不是 string | null;能用 Zod 在反序列化时抓住结构漂移;能正确使用管道与批量命令;并知道类型安全封装最容易漏掉的那几个洞。

上一节我们把键设计好了,但 redis.get(key) 的返回类型仍然是 string | null。业务代码里到处是 JSON.parse(raw) as Product,这种 as 就是类型系统的黑洞:写错了没人拦,线上才炸。本节的目标很明确——把 string 边界收敛到一个地方,让其余代码全程有类型。

8.2 Redis 类型安全封装

裸客户端的问题

先看一段真实项目里常见的代码:

import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);

async function getProduct(id: number) {
  const raw = await redis.get(`product:${id}`);
  return raw ? (JSON.parse(raw) as Product) : null;
}

async function getProductPrice(id: number) {
  const raw = await redis.get(`product:${id}`);
  const p = raw ? (JSON.parse(raw) as { price: number }) : null;
  return p?.price ?? 0;
}

这段代码有四个问题,且编译器一个都抓不到:

  1. 键前缀 product: 硬编码在两处,改一处漏一处;
  2. as Product 是单方面断言,Redis 里存的是旧结构也照样通过;
  3. 没有 TTL,键会永久驻留;
  4. 返回类型靠人肉记,getProductPrice 里的 { price: number } 与 Product 没有任何关联。

把这些问题收敛到一处,就是「封装层」的全部意义。

三层封装的职责划分

一个可维护的缓存访问层通常分三层,每层只解决一件事:

层输入 → 输出负责不负责
键空间(keys)业务参数 → string键命名、版本、前缀隔离值怎么存
编解码(codec)T ↔ string序列化、结构校验、压缩键叫什么
客户端(client)键 + codec + TTL → T读写、TTL、批量、错误处理业务语义

三层分开后,「改版本号」「换序列化格式」「加监控」都只需改一层。这也是既有专题 Redis 数据结构详解 中反复强调的分层思路在应用侧的直接映射。

第一层:键空间声明

用 as const 让键构造函数自带字面量返回类型,配合上一节的 CacheKeys:

export const keys = {
  product: {
    detail: (id: number) => `app:v3:product:${id}` as const,
    price: (id: number) => `app:v3:product:${id}:price` as const,
  },
  user: {
    session: (sid: string) => `session:v1:${sid}` as const,
  },
} as const;

as const 让 keys.product.detail(1) 的返回类型是模板字面量类型而不是宽泛的 string,这样后续 codec 与客户端就能按「哪个键对应哪个类型」做映射(见下文 defineCache)。

第二层:codec 与结构校验

codec 负责 T 与 string 之间的双向转换:

export interface Codec<T> {
  encode(value: T): string;
  decode(raw: string): T;
}

export const jsonCodec = <T>(): Codec<T> => ({
  encode: (v) => JSON.stringify(v),
  decode: (s) => JSON.parse(s) as T,
});

但 jsonCodec 的 decode 里仍有 as T——如果 Redis 里躺着一份旧结构,这里会静默通过,直到某个字段 undefined 才在业务深处报错。用 Zod 把校验前移到边界:

import { z } from "zod";

export const ProductSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1),
  price: z.number().nonnegative(),
  updatedAt: z.string().datetime(),
});
export type Product = z.infer<typeof ProductSchema>;

export const productCodec: Codec<Product> = {
  encode: (v) => JSON.stringify(v),
  decode: (s) => ProductSchema.parse(JSON.parse(s)),
};

这样旧结构会在读取时立刻抛出带字段路径的错误,而不是在三个调用层级之后表现为 Cannot read properties of undefined:

ZodError: [
  {
    "code": "invalid_type",
    "expected": "number",
    "received": "string",
    "path": ["price"],
    "message": "Expected number, received string"
  }
]

若想进一步压体积,可以在 codec 里接 JSON.stringify 之外的方案(如压缩或紧凑编码),但务必同时保留一个能识别格式版本的字段,否则换编码时旧值无法识别。这类取舍在 Redis JSON 文档 里有更细的讨论。

第三层:泛型客户端与 defineCache

把三层组装起来,得到一个「键到类型」绑定的工厂:

import type Redis from "ioredis";

export interface CacheOptions {
  /** 秒;不传表示不过期(慎用) */
  ttl?: number;
  /** 抖动比例 0~1,用于打散过期时间(见 8.3) */
  jitter?: number;
}

export function defineCache<T, Args extends unknown[]>(
  redis: Redis,
  codec: Codec<T>,
  keyOf: (...args: Args) => string,
) {
  const ttlWithJitter = (ttl: number, jitter = 0.1) =>
    Math.max(1, Math.floor(ttl * (1 + (Math.random() * 2 - 1) * jitter)));

  return {
    async get(...args: Args): Promise<T | null> {
      const raw = await redis.get(keyOf(...args));
      return raw === null ? null : codec.decode(raw);
    },

    async set(value: T, opts: CacheOptions, ...args: Args): Promise<void> {
      const raw = codec.encode(value);
      if (opts.ttl) {
        await redis.set(keyOf(...args), raw, "EX", ttlWithJitter(opts.ttl, opts.jitter));
      } else {
        await redis.set(keyOf(...args), raw);
      }
    },

    async del(...args: Args): Promise<void> {
      await redis.del(keyOf(...args));
    },

    /** 缓存旁路:命中即返回,未命中则回源并写回 */
    async remember(ttl: number, loader: () => Promise<T>, ...args: Args): Promise<T> {
      const hit = await this.get(...args);
      if (hit !== null) return hit;
      const value = await loader();
      await this.set(value, { ttl }, ...args);
      return value;
    },
  };
}

Args 从键函数推断为参数元组,例如 [id: number];不能写成 never[],否则调用方连合法的数字 ID 都无法传入。下面示例沿用 redis 与 db 依赖:

const productCache = defineCache(redis, productCodec, keys.product.detail);

const p = await productCache.get(10086);
//    ^? const p: Product | null

const p2 = await productCache.remember(
  300,
  () => db.product.findUniqueOrThrow({ where: { id: 10086 } }),
  10086,
);
//    ^? const p2: Product   —— 注意 remember 返回非空 T

remember 的返回类型是 T 而不是 T | null,因为它必然回源——这个小细节能省掉调用方一堆 if (x === null) 分支。

批量读取:mget 与类型对齐

单个 get 解决不了 N+1 问题。批量读要用 mget,但要注意 mget 返回的是 (string | null)[],长度与键数组一一对应:

// 添加到 defineCache 的 return 对象中;Args 与 T 沿用工厂泛型
const batchMethods = {
async mget(...keyArgs: Args[]): Promise<(T | null)[]> {
  if (keyArgs.length === 0) return [];
  const rawKeys = keyArgs.map((a) => keyOf(...a));
  const raws = await redis.mget(...rawKeys);
  return raws.map((r) => (r === null ? null : codec.decode(r)));
}
};

一个真实坑:mget 在集群模式下如果键落在不同哈希槽会直接报错。要么把键设计成同槽(用 hash tag {...}),要么拆成多次调用。批量命令的取舍可参考 Redis 管道与批量优化 。

命名空间隔离与环境前缀

多个服务、多个环境共用一个 Redis 实例是常态,也是事故高发区。把环境前缀做进键构造函数,而不是靠「运维记得用不同的库」:

const ENV = process.env.APP_ENV ?? "dev"; // dev | staging | prod
const ns = `app:${ENV}` as const;

export const keys = {
  product: {
    detail: (id: number) => `${ns}:v3:product:${id}` as const,
  },
} as const;

// dev 环境:app:dev:v3:product:10086
// prod 环境:app:prod:v3:product:10086

APP_ENV 未设置时默认 dev,是一个刻意的选择:开发环境可默认 dev,但生产必须显式校验环境变量并在缺失时终止启动,防止生产数据进入开发命名空间。若使用 Redis 的 SELECT 切库,请注意集群模式只支持 db 0,SELECT 会直接失败——这是把环境前缀写进键而不是切库的另一个理由。

写路径与失效

类型安全封装同样要把失效做成一等公民。推荐把「删除」和「版本自增」都暴露出来:

export async function invalidateProduct(redis: Redis, id: number) {
  await productCache.del(id);
  // 列表键基数未知,整体作废(见 8.1 的版本号技巧)
  await redis.incr("app:version:product:list");
}

如果缓存里存的是 Hash 或 Sorted Set 这类结构,也可以用 hgetall + codec 做同样封装;但要注意 hgetall 的字段名不会经过类型检查,仍需 codec 兜底。这类结构的选择在 Redis 进阶实战 里有完整对照。

常见坑

现象根因修法
缓存里读到旧字段codec 只有 as T,没有结构校验用 Zod codec 在边界校验
mget 集群报 CROSSSLOT键跨哈希槽用 {} hash tag 或拆批
类型对了但值是错的键空间与 codec 不匹配(串了)用 defineCache 把两者绑死
内存只涨忘了 TTL客户端写入策略要求 ttl 参数
反序列化偶发失败一半值是新格式一半是旧格式版本号入键,新旧并存到期

最后一条尤其常见:做结构迁移时,不要原地改格式,而是把 app:v3: 换成 app:v4:,让旧键自然过期。

衔接下一节

到这里,我们已经有了类型安全、可版本化、带 TTL 的访问层。但它还有一个致命弱点:remember 在缓存未命中时会让所有并发请求同时回源——这就是击穿。下一节 8.3 穿透·击穿·雪崩防护 会在本节的 remember 基础上加单飞、空值缓存与熔断,把它变成一个真正抗压的读路径。

小结

本节把缓存访问收敛成三层:

  • 键空间:as const 声明,集中管理前缀与版本;
  • codec:encode/decode 双向转换,用 Zod 在边界做结构校验,把「类型对但值错」的坑前移;
  • 泛型客户端:defineCache 把键与类型绑定,提供类型化的 get/set/del/mget/remember。

记住两个关键返回值差异:get 返回 T | null,而 remember 返回 T(必然回源)。以及一条迁移铁律:改结构就换版本号,不要原地改格式。

下一节我们给这个访问层加上防护:布隆过滤器挡穿透、单飞挡击穿、TTL 抖动与熔断挡雪崩。

阅读导航:上一节:8.1 缓存层次与键设计 · 下一节:8.3 穿透·击穿·雪崩防护 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes