跳到主要内容

ZenWeb Cache Module

基于 Redis 的对象缓存工具

功能如下:

  • 对象缓存
  • 大对象自动压缩
  • JSON 序列直接输出
  • 防止缓存击穿
  • 单例执行

安装

npm install @zenweb/cache
src/index.ts
import { create } from 'zenweb';
import modCache from '@zenweb/cache';

const app = create();

app.setup(modCache());

app.start();

Redis 配置

默认通过环境变量配置 Redis 连接,支持以下环境变量:

环境变量默认值说明
REDIS_HOST127.0.0.1Redis 服务器地址
REDIS_PORT6379Redis 服务器端口
REDIS_PASSWORD(空)Redis 连接密码
REDIS_DB0Redis 数据库编号

也可以在代码中显式传入 Redis 配置:

src/index.ts
import modCache from '@zenweb/cache';

app.setup(modCache({
redis: {
host: '10.0.0.1',
port: 6379,
password: 'your-password',
db: 1,
},
}));

基本使用

import { Get, Context } from "zenweb";
import { $cache, cached } from "@zenweb/cache";

export class CacheController {
/**
* 一般使用
*/
@Get()
async index() {
const result = await $cache.lockGet('TEST', function() {
return {
cacheAt: new Date(),
};
});
return result;
}

/**
* 大对象压缩存储
*/
@Get()
async big() {
const result = await $cache.lockGet('TEST-GZ', function() {
return longData;
});
return result;
}

/**
* 大对象直接输出 - 1
*/
@Get()
async big_direct_out(ctx: Context) {
const result = await $cache.lockGet('TEST-GZ', function() {
return longData;
}, { parse: false, decompress: false });
if (result.compressed) {
ctx.set('Content-Encoding', 'gzip');
}
ctx.type = 'json';
ctx.body = result.data;
}

/**
* 使用缓存中间件
* - 自动处理是否需要解压缩对象
*/
@Mapping({
middleware: cached('TEST-middleware'),
})
async cached_middleware() {
return longData;
}
}

lockGet 防缓存击穿

在高并发场景下,当缓存过期时会有大量请求同时穿透到数据库,造成数据库压力骤增(缓存击穿)。lockGet 通过分布式锁机制保证同一个缓存 key 只有一个请求会执行数据获取(fetch),其余请求等待结果返回。

基本原理

  1. 先尝试从 Redis 获取缓存数据
  2. 如果缓存不存在,通过 Redis 分布式锁竞争
  3. 获得锁的请求执行 fetch 函数获取数据并写入缓存
  4. 未获得锁的请求在重试超时内等待并重试获取缓存
  5. 如果 fetch 返回 undefined,不会写入缓存

完整示例

import { Get } from "zenweb";
import { $cache } from "@zenweb/cache";

export class UserController {
@Get('/user/:id')
async userInfo(ctx: Context) {
const userId = ctx.params.id;

const userInfo = await $cache.lockGet(
`user:info:${userId}`,
async () => {
// 缓存不存在时,从数据库获取数据
const user = await ctx.mysql.query(
'SELECT id, name, email FROM users WHERE id = ?',
[userId]
);
if (!user.length) return undefined; // 返回 undefined 不缓存
return user[0];
},
{
ttl: 300, // 缓存有效期 300 秒
lockTimeout: 10000, // 锁超时 10 秒
retryTimeout: 5000, // 等待重试超时 5 秒
retryDelay: 500, // 重试间隔 500 毫秒
}
);

return userInfo;
}
}

preRefresh 预刷新选项

当缓存即将过期但还未过期时,可以在后台提前刷新数据,用户始终获取到的是有效的缓存数据,不会感知到刷新延迟。

preRefresh 的值为剩余秒数阈值,当缓存的剩余 TTL 小于该值时,会在后台异步执行 fetch 刷新数据:

await $cache.lockGet('product:list', fetchProductList, {
ttl: 300, // 缓存有效期 5 分钟
preRefresh: 60, // 剩余不到 1 分钟时触发后台刷新
});

上例中,缓存在创建后的前 4 分钟内正常返回缓存数据;在第 4 分钟时,某个请求发现剩余 TTL 小于 60 秒,该请求会在后台启动 fetch 刷新缓存,同时仍然返回当前有效的缓存数据给调用方。

localStore 本地存储选项

在一次 HTTP 请求的生命周期内,可能会多次调用相同缓存 key 的 lockGetlocalStore 允许将已获取的数据缓存在本地对象中,避免同一次请求中重复从 Redis 读取:

import { Get, Context } from "zenweb";
import { $cache } from "@zenweb/cache";

export class OrderController {
@Get('/order/detail/:id')
async detail(ctx: Context) {
// 在上下文中创建本地存储对象
const localStore: Record<string, any> = {};

// 获取订单信息(首次从 Redis 获取)
const order = await $cache.lockGet(
`order:${ctx.params.id}`,
() => fetchOrder(ctx.params.id),
{ localStore }
);

// 另一个方法中也获取相同数据,直接从 localStore 读取,不再访问 Redis
const orderAgain = await $cache.lockGet(
`order:${ctx.params.id}`,
() => fetchOrder(ctx.params.id),
{ localStore }
);

return order;
}
}

noWait 不等待选项

当缓存不存在时,直接返回 undefined,不等待 fetch 执行完成。适用于数据不参与关键业务逻辑且 fetch 耗时较长的场景:

const stats = await $cache.lockGet('dashboard:stats', fetchDashboardStats, {
noWait: true, // 缓存不存在时直接返回 undefined,后台异步更新
});
// stats 可能为 undefined,前端自行处理空状态
return stats ?? { loading: true };

singleRunner 单例执行器

singleRunner 提供基于 Redis 的分布式互斥锁,确保在多实例部署环境下,同一个 key 对应的任务同一时刻只有一个实例在执行。适用于定时任务去重、资源竞争控制等场景。

工作原理

  1. 通过 Redis SET NX PX 命令获取分布式锁
  2. 获取成功则执行任务,执行完毕后释放锁
  3. 获取失败时根据配置决定是否重试等待
  4. 锁具有超时机制,防止死锁

使用示例

import { Get } from "zenweb";
import { $cache } from "@zenweb/cache";

export class ReportController {
@Get('/report/generate')
async generate(ctx: Context) {
// 确保同一时刻只有一个报告生成任务在执行
const result = await $cache.singleRunner(
'report:generate:lock',
async () => {
// 执行耗时的报告生成逻辑
const reportData = await generateAnnualReport();
await saveReport(reportData);
return { success: true, reportId: reportData.id };
},
{
lockTimeout: 30000, // 锁超时 30 秒
retryTimeout: 0, // 不重试,获取不到锁直接抛出异常
}
);

return result;
}
}

lockGet 不同,singleRunner 默认 retryTimeout 为 0,即获取锁失败时立即抛出异常,不会等待重试。

$cache$cacheHelper 全局快捷方法 [v4.0+]

@zenweb/cache 提供了全局快捷方法,无需通过 ctx.core.cache2 获取缓存实例。

$cache

$cache 是一个 Proxy 对象,可以直接调用 Cache 类上的所有方法:

import { $cache } from "@zenweb/cache";

// 设置缓存
await $cache.set('key', { foo: 'bar' }, 60);

// 获取缓存
const data = await $cache.get<{ foo: string }>('key');

// 删除缓存
await $cache.del('key');

// 获取剩余有效期
const ttl = await $cache.ttl('key');

// lockGet
const result = await $cache.lockGet('expensive:key', async () => {
return await fetchExpensiveData();
});

// singleRunner
await $cache.singleRunner('unique:task', async () => {
await doSomething();
});

$cacheHelper

$cacheHelper 用于创建带有参数化 key 的缓存助手实例,适合在 Service 层封装缓存逻辑:

import { $cacheHelper } from "@zenweb/cache";

// 定义缓存助手,key 根据参数动态生成
const userCache = $cacheHelper<[number], UserInfo>(
(userId) => `user:info:${userId}`,
async (userId) => {
// fetch 函数:缓存不存在时从数据库获取
const rows = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
return rows[0];
},
{ ttl: 300 }
);

// 使用
const user = await userCache.get(123); // 获取缓存
await userCache.del(123); // 删除缓存

自动压缩

当缓存数据较大时,系统会自动使用 gzip 压缩以节省 Redis 内存。

压缩机制

  1. 数据序列化后,检查长度是否超过 compressMinLength(默认 1024 字节)
  2. 如果超过阈值,执行 gzip 压缩
  3. 比较压缩后的数据大小与原始数据大小乘以 compressStoreRatio(默认 0.8)
  4. 只有压缩后的数据明显更小(至少节省 20%)时才存储压缩版本
  5. 读取时自动检测 gzip 头部标记并解压

配置压缩参数

app.setup(modCache({
set: {
compressMinLength: 2048, // 超过 2KB 才压缩(默认 1024)
compressStoreRatio: 0.7, // 压缩后至少节省 30% 才存储(默认 0.8)
},
}));

禁用压缩

设置 compressMinLength 为 0 即可禁用压缩:

app.setup(modCache({
set: {
compressMinLength: 0,
},
}));

@cached() 缓存中间件装饰器

@cached() 是一个中间件装饰器,可以快速将接口返回结果缓存到 Redis。它封装了 lockGet 逻辑,仅缓存成功结果ctx.success() 的数据),失败结果不会被缓存。

基本用法

import { Get } from "zenweb";
import { cached } from "@zenweb/cache";

export class ProductController {
/**
* 使用默认 key(基于请求路径自动生成)
*/
@Mapping({
middleware: cached(),
})
async list() {
// 返回结果会自动缓存
return await getProductList();
}
}

自定义缓存 key

export class ProductController {
/**
* 使用固定 key
*/
@Mapping({
middleware: cached('product:featured'),
})
async featured() {
return await getFeaturedProducts();
}

/**
* 使用函数动态生成 key
*/
@Mapping({
path: '/product/:id',
middleware: cached((ctx) => `product:detail:${ctx.params.id}`),
})
async detail(ctx: Context) {
return await getProduct(ctx.params.id);
}

/**
* key 为 null 时跳过缓存
* - 适用于某些条件下才需要缓存的场景
*/
@Mapping({
path: '/search',
middleware: cached((ctx) => {
// 只有管理员请求才缓存
return ctx.user?.isAdmin ? `search:${ctx.query.q}` : null;
}),
})
async search(ctx: Context) {
return await searchProducts(ctx.query.q);
}
}

带选项的缓存

export class ReportController {
@Mapping({
path: '/report/daily',
middleware: cached('report:daily', {
ttl: 3600, // 缓存 1 小时
preRefresh: 300, // 提前 5 分钟后台刷新
lockTimeout: 15000, // 锁超时 15 秒
}),
})
async daily() {
return await generateDailyReport();
}
}