Redis系列:缓存抽象封装与最佳实践

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 系列导航

相关推荐
cen__y8 小时前
Linux13(数据库)
linux·服务器·c语言·开发语言·数据库
happyprince8 小时前
05-Hugging Face Transformers 缓存系统深度分析
java·spring·缓存
__zRainy__8 小时前
Redis系列:核心数据类型与基础 API 解读
数据库·redis·缓存
雨辰AI9 小时前
人大金仓慢 SQL 根治方法论:问题定位 - 分析 - 优化全流程
数据库·后端·sql·mysql·政务
guslegend9 小时前
2.Redis核心数据结构
数据结构·数据库·redis
Daydream.V9 小时前
Redis 零基础入门到实战:数据结构 + 常用命令 + 场景全覆盖
数据结构·数据库·redis
小a彤9 小时前
atvoss:Vector 算子子程序模板库,让 Ascend C 开发效率提升 5 倍
android·c语言·数据库
不爱洗脚的小滕9 小时前
【向量数据库】Milvus 稠密与稀疏向量核心解析
数据库·人工智能·milvus
AI周红伟9 小时前
Windows 支持 Hermes Agent 吗:原生 Windows 安装 + WSL2 路径完整指南
数据库·人工智能·windows·阿里云·职场和发展·计算机外设