Redis系列:缓存抽象封装与最佳实践
::: info
本文适合谁:有 Redis 基础,想深入了解缓存架构设计的后端开发者。
前置条件:了解 Redis 基本数据类型,阅读过 Redis 基础 API 解读。
:::
一、为什么需要缓存封装?
1.1 原始 API 调用的问题
当我们直接使用 Redis 客户端的原始 API 时,通常会这样写:
javascript
// 每次都这样写,不累吗?
const userJson = await client.get('user:1001')
if (!userJson) {
const user = await db.query('SELECT * FROM users WHERE id = 1001')
await client.set('user:1001', JSON.stringify(user))
}
const user = JSON.parse(userJson)
问题在哪?
- 重复代码:每次查询都要写 if-null-check-set 逻辑
- 序列化硬编码:JSON.parse/JSON.stringify 散落各处
- 无法统一优化:缓存策略(穿透/击穿/熔断)无法集中管理
- 测试困难:业务代码直接耦合 Redis 客户端
1.2 缓存封装的优势
┌─────────────────────────────────────────────┐
│ 业务代码 │
│ userService.getUser(1001) │
└─────────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 缓存抽象层 │
│ get() / set() / del() + 策略管理 │
└─────────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Redis 客户端 │
└─────────────────────────────────────────────┘
封装后我们获得了什么:
- 统一的序列化/反序列化
- 可插拔的缓存策略
- 集中化的过期管理
- 便捷的 Mock 测试
二、缓存抽象实现
2.1 核心代码
javascript:redis-cache.js
import { RedisClient } from "./redis-client.js";
class RedisCache {
/**
* 获取redis客户端
*/
async getClient() {
return RedisClient.getInstance()
}
/**
* 获取缓存,并自动反序列化 JSON
* @param key 缓存键
* @returns 解析后的对象或 null
*/
async get(key) {
const client = await this.getClient()
const value = await client.get(key);
if (!value) return null;
try {
return JSON.parse(value);
} catch {
return value;
}
}
/**
* 设置缓存,自动序列化对象并支持 TTL
* @param key 缓存键
* @param value 要存储的值
* @param ttl 过期时间(秒),不传则永久
*/
async set(key, value, ttl) {
const client = await this.getClient();
const serialized = JSON.stringify(value);
if (ttl) {
await client.setEx(key, ttl, serialized);
} else {
await client.set(key, serialized);
}
}
/**
* 删除一个或多个缓存键
* @param {...any} keys 要删除的键
*/
async del(...keys) {
const client = await this.getClient()
await client.del(keys)
}
/**
* 检查键是否存在
*/
async exists(key) {
const client = await this.getClient();
const result = await client.exists(key)
return result === 1
}
/**
* 为已存在的键设置过期时间
*/
async expire(key, ttl) {
const client = await this.getClient()
await client.expire(key, ttl)
}
}
export default new RedisCache()
2.2 使用示例
javascript
import cache from './redis-cache.js'
// 基础使用
await cache.set('user:1001', { name: '张三', age: 25 })
const user = await cache.get('user:1001') // 自动反序列化
// 带过期时间(30分钟)
await cache.set('product:2001', product, 1800)
// 删除缓存
await cache.del('user:1001', 'product:2001')
// 检查是否存在
const exists = await cache.exists('user:1001')
三、缓存策略详解
3.1 缓存穿透:布隆过滤器方案
问题:恶意请求或查询不存在的key,每次都穿透到数据库。
解决方案:布隆过滤器 + 缓存空值
javascript
class CacheWithBloomFilter {
constructor(cache, bloomFilter) {
this.cache = cache
this.bloomFilter = bloomFilter
this.nullValueTTL = 60 // 空值缓存60秒
}
async get(key) {
// 1. 先检查布隆过滤器
if (!this.bloomFilter.mightContain(key)) {
return null; // 不存在,直接返回(布隆过滤器有假阳性,可能误判)
}
// 2. 查询缓存
const value = await this.cache.get(key)
if (value !== null) {
// 如果是缓存的空值标记,返回 null
if (value === '__NULL__') return null
return value
}
// 3. 查询数据库
const dbValue = await this.queryFromDB(key)
if (dbValue === null) {
// 数据库也没有,记录空值到布隆过滤器
this.bloomFilter.add(key)
// 缓存空值,防止穿透
await this.cache.set(key, '__NULL__', this.nullValueTTL)
return null // 明确返回 null
} else {
await this.cache.set(key, dbValue)
return dbValue
}
}
}
3.2 缓存击穿:加锁方案
问题:热点 key 过期瞬间,大量请求同时涌入数据库。
解决方案:分布式锁 + 异步重建
javascript
import lock from './redis-lock.js'
class CacheWithLock {
constructor(cache, lock) {
this.cache = cache
this.lock = lock
this.lockTTL = 10 // 锁自动释放10秒
this.cacheTTL = 3600 // 缓存1小时
}
async get(key) {
// 1. 先查缓存
let value = await this.cache.get(key)
if (value !== null) {
return value
}
// 2. 尝试获取锁
const releaseLock = await this.lock.acquireLock(key, this.lockTTL)
try {
// 3. 双重检查(可能其他线程已经重建)
value = await this.cache.get(key)
if (value !== null) {
return value
}
// 4. 查询数据库
value = await this.queryFromDB(key)
// 5. 写入缓存
await this.cache.set(key, value, this.cacheTTL)
return value
} finally {
// 6. 释放锁
releaseLock()
}
}
}
3.3 缓存熔断:快速失败方案
问题:Redis 故障时,业务逻辑不断重试导致雪崩。
解决方案:熔断器模式
javascript
class CircuitBreaker {
constructor(failureThreshold = 5, resetTimeout = 60000) {
this.failureCount = 0
this.failureThreshold = failureThreshold
this.resetTimeout = resetTimeout
this.lastFailureTime = null
this.state = 'CLOSED' // CLOSED, OPEN, HALF_OPEN
}
async execute(fn) {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime > this.resetTimeout) {
this.state = 'HALF_OPEN'
} else {
throw new Error('Circuit is OPEN')
}
}
try {
const result = await fn()
this.onSuccess()
return result
} catch (err) {
this.onFailure()
throw err
}
}
onSuccess() {
this.failureCount = 0
this.state = 'CLOSED'
}
onFailure() {
this.failureCount++
this.lastFailureTime = Date.now()
if (this.failureCount >= this.failureThreshold) {
this.state = 'OPEN'
}
}
}
// 使用
const breaker = new CircuitBreaker()
const user = await breaker.execute(() => cache.get('user:1001'))
四、具体业务场景演示
4.1 用户信息缓存
javascript
// userService.js
import cache from './redis-cache.js'
const USER_CACHE_PREFIX = 'user:'
const USER_CACHE_TTL = 1800 // 30分钟
class UserService {
async getUser(userId) {
const key = `${USER_CACHE_PREFIX}${userId}`
// 查询缓存
const cached = await cache.get(key)
if (cached) {
console.log(`[Cache Hit] user:${userId}`)
return cached
}
// 模拟数据库查询
const user = await this.queryFromDB(userId)
// 写入缓存
await cache.set(key, user, USER_CACHE_TTL)
return user
}
async updateUser(userId, data) {
// 更新数据库
await this.updateInDB(userId, data)
// 删除缓存(而不是更新)
await cache.del(`${USER_CACHE_PREFIX}${userId}`)
}
async queryFromDB(userId) {
// 模拟数据库查询
return { id: userId, name: '张三', age: 25, email: 'zhangsan@example.com' }
}
async updateInDB(userId, data) {
console.log(`[DB Update] user:${userId}`, data)
}
}
4.2 商品详情缓存
javascript
// productService.js
import cache from './redis-cache.js'
import lock from './redis-lock.js'
const PRODUCT_CACHE_PREFIX = 'product:'
const PRODUCT_CACHE_TTL = 3600 // 1小时
const PRODUCT_LOCK_TTL = 10 // 10秒
class ProductService {
async getProduct(productId) {
const key = `${PRODUCT_CACHE_PREFIX}${productId}`
// 查询缓存
const cached = await cache.get(key)
if (cached) {
return cached
}
// 尝试加锁,防止缓存击穿
try {
const releaseLock = await lock.acquireLock(key, PRODUCT_LOCK_TTL)
try {
// 双重检查
const cached = await cache.get(key)
if (cached) return cached
// 查询数据库
const product = await this.queryFromDB(productId)
// 写入缓存
await cache.set(key, product, PRODUCT_CACHE_TTL)
return product
} finally {
releaseLock()
}
} catch (err) {
// 获取锁失败,直接查库(兜底)
// 注意:这里没有再做双重检查,可能返回旧数据或空
console.warn('[Lock Failed] fallback to DB')
return await this.queryFromDB(productId)
}
}
async queryFromDB(productId) {
return {
id: productId,
name: 'iPhone 15 Pro',
price: 8999,
stock: 100
}
}
}
4.3 秒杀活动缓存
javascript
// seckillService.js
import cache from './redis-cache.js'
const STOCK_KEY_PREFIX = 'seckill:stock:'
const ORDER_KEY_PREFIX = 'seckill:order:'
class SeckillService {
async initStock(activityId, stock) {
const key = `${STOCK_KEY_PREFIX}${activityId}`
await cache.set(key, stock)
}
async decrStock(activityId) {
const key = `${STOCK_KEY_PREFIX}${activityId}`
// 原子递减
const client = await RedisClient.getInstance()
const newStock = await client.decr(key)
if (newStock < 0) {
// 恢复库存(回滚)
await client.incr(key)
return false
}
return true
}
async createOrder(userId, activityId, productId) {
const orderKey = `${ORDER_KEY_PREFIX}${activityId}:${userId}`
// 幂等性检查
const exists = await cache.exists(orderKey)
if (exists) {
throw new Error('已参与过秒杀')
}
// 扣减库存
const success = await this.decrStock(activityId)
if (!success) {
throw new Error('库存不足')
}
// 创建订单(缓存标记)
await cache.set(orderKey, { userId, productId, status: 'pending' }, 86400)
return { userId, productId, status: 'pending' }
}
}
五、缓存使用 Checklist
| 检查项 | 说明 |
|---|---|
| 缓存键命名规范 | 建议格式:业务:实体:ID,如 user:1001 |
| TTL 设置合理 | 热点数据短,非热点数据长 |
| 序列化方案 | JSON 最常用,也可选 MessagePack/Protobuf |
| 缓存与数据库一致性 | 优先删除缓存,而不是更新 |
| 容量规划 | 预估数据量,预留足够内存 |
| 监控告警 | 命中率、内存使用、响应时间 |
六、总结与思考
缓存封装让我们从"用 Redis"进化到"用好 Redis":
- 一致性:序列化统一管理,业务代码更清晰
- 策略可插拔:穿透/击穿/熔断按需启用
- 可测试:可以轻松 Mock 缓存层进行单元测试
- 可观测:统一入口方便埋点和监控
::: info 系列导航