本节目标:把上一节设计好的字符串键,封装成一个「读出来的就是目标类型」的访问层。读完本节,你能写出
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;
}
这段代码有四个问题,且编译器一个都抓不到:
- 键前缀
product:硬编码在两处,改一处漏一处; as Product是单方面断言,Redis 里存的是旧结构也照样通过;- 没有 TTL,键会永久驻留;
- 返回类型靠人肉记,
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 穿透·击穿·雪崩防护 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。