Redis 旁路缓存深度解析

旁路缓存(Cache-Aside Pattern)是 Redis 最常用的缓存策略,通过"先查缓存,后查数据库"的读写模式,显著提升系统读取性能

为什么需要缓存

在互联网应用中,数据库通常是系统性能的瓶颈。当面对高并发读取请求时,直接查询数据库会造成:

  • 数据库压力过大:大量并发查询会导致数据库连接耗尽
  • 响应延迟高:复杂查询可能需要数十甚至数百毫秒
  • 扩展困难:通过增加数据库实例来提升性能成本高昂

缓存的出现正是为了解决这一问题。通过将热点数据存储在内存中,缓存能够提供微秒级的访问速度。

旁路缓存核心原理

旁路缓存(Cache-Aside Pattern)是最经典的缓存应用模式,其核心思想是:应用层主动管理缓存,数据库为主,缓存为从

读操作流程

java 复制代码
public Product getProduct(Long id) {
    // 1. 先查缓存
    Product cachedProduct = redis.get("product:" + id);
    if (cachedProduct != null) {
        // 缓存命中,直接返回
        return cachedProduct;
    }
    
    // 2. 缓存未命中,查询数据库
    Product product = productMapper.findById(id);
    
    if (product != null) {
        // 3. 将数据写入缓存,设置过期时间
        redis.set("product:" + id, product, 3600);
    }
    
    return product;
}

读操作的关键步骤:

  1. 先从 Redis 读取数据
  2. 缓存命中则直接返回
  3. 缓存未命中则查询数据库
  4. 将查询结果写入缓存并设置过期时间

写操作流程

java 复制代码
public void updateProduct(Product product) {
    // 1. 先更新数据库
    productMapper.update(product);
    
    // 2. 再删除缓存
    redis.del("product:" + product.getId());
}

写操作采用先更新数据库,后删除缓存的策略,原因如下:

  • 保证数据一致性:即使缓存删除失败,数据库数据仍是最新的
  • 避免并发问题:相比更新缓存,删除缓存更能保证数据一致性
  • 简化流程:不需要维护缓存与数据库的精确同步

缓存三大经典问题

1. 缓存穿透

问题描述:查询一个不存在的数据,由于缓存和数据库都没有,每次请求都会打到数据库。

危害:恶意用户可能利用此漏洞,大量请求不存在的数据导致数据库崩溃。

解决方案

java 复制代码
public Product getProduct(Long id) {
    // 参数校验
    if (id == null || id <= 0) {
        return null;
    }
    
    // 1. 先查缓存
    Product cachedProduct = redis.get("product:" + id);
    if (cachedProduct != null) {
        return cachedProduct;
    }
    
    // 2. 缓存未命中,查询数据库
    Product product = productMapper.findById(id);
    
    if (product == null) {
        // 3. 缓存空值,防止穿透
        redis.set("product:" + id, null, 60); // 短过期时间
        return null;
    }
    
    // 4. 写入缓存
    redis.set("product:" + id, product, 3600);
    return product;
}

额外防护措施

java 复制代码
// 使用布隆过滤器
private BloomFilter<Long> bloomFilter;

public Product getProduct(Long id) {
    // 布隆过滤器检查
    if (!bloomFilter.mightContain(id)) {
        return null; // 一定不存在
    }
    
    // 正常查询流程
    Product product = getProductFromCacheOrDB(id);
    return product;
}

2. 缓存击穿

问题描述:某个热点数据过期的瞬间,大量并发请求同时发现缓存失效,全部涌入数据库查询。

解决方案

方案一:互斥锁
java 复制代码
private boolean lock = false;

public Product getProduct(Long id) {
    Product product = redis.get("product:" + id);
    if (product != null) {
        return product;
    }
    
    // 获取锁
    if (tryLock("lock:product:" + id)) {
        try {
            // Double Check
            product = redis.get("product:" + id);
            if (product != null) {
                return product;
            }
            
            // 查询数据库
            product = productMapper.findById(id);
            redis.set("product:" + id, product, 3600);
        } finally {
            unlock("lock:product:" + id);
        }
    } else {
        // 等待后重试
        Thread.sleep(100);
        return getProduct(id);
    }
    
    return product;
}
方案二:逻辑过期
java 复制代码
public Product getProduct(Long id) {
    // 1. 查缓存
    Product product = redis.get("product:" + id);
    
    if (product == null) {
        // 缓存为空,尝试获取锁重建缓存
        if (tryLock("lock:product:" + id)) {
            Product newProduct = productMapper.findById(id);
            redis.set("product:" + id, newProduct, 3600);
            unlock("lock:product:" + id);
            return newProduct;
        }
        // 等待后重试
        Thread.sleep(100);
        return getProduct(id);
    }
    
    // 2. 检查是否逻辑过期
    if (isLogicalExpired(product)) {
        // 异步重建缓存,不阻塞请求
        if (tryLock("lock:product:" + id)) {
            threadPool.execute(() -> {
                Product newProduct = productMapper.findById(id);
                redis.set("product:" + id, newProduct, 3600);
                unlock("lock:product:" + id);
            });
        }
    }
    
    return product;
}

3. 缓存雪崩

问题描述:大量缓存数据在同一时间过期,导致大量请求同时打到数据库。

解决方案

方案一:随机过期时间
java 复制代码
// 设置过期时间添加随机值
int baseExpire = 3600;
int randomExpire = ThreadLocalRandom.current().nextInt(300);
redis.set("product:" + id, product, baseExpire + randomExpire);
方案二:多级缓存
java 复制代码
public Product getProduct(Long id) {
    // 1. 先查本地缓存
    Product product = localCache.get(id);
    if (product != null) {
        return product;
    }
    
    // 2. 查 Redis
    product = redis.get("product:" + id);
    if (product != null) {
        // 回填本地缓存
        localCache.put(id, product, 300);
        return product;
    }
    
    // 3. 查数据库
    product = productMapper.findById(id);
    redis.set("product:" + id, product, 3600);
    return product;
}
方案三:服务降级
java 复制代码
public Product getProduct(Long id) {
    try {
        // 1. 查缓存
        Product product = redis.get("product:" + id);
        if (product != null) {
            return product;
        }
        
        // 2. 缓存未命中,降级处理
        return getProductFromBackup(id);
    } catch (Exception e) {
        // Redis 异常,降级到数据库
        log.error("Redis error, fallback to DB", e);
        return productMapper.findById(id);
    }
}

数据一致性方案

延迟双删

java 复制代码
public void updateProduct(Product product) {
    // 1. 删除缓存
    redis.del("product:" + product.getId());
    
    // 2. 更新数据库
    productMapper.update(product);
    
    // 3. 延迟删除缓存
    threadPool.execute(() -> {
        try {
            Thread.sleep(1000);
            redis.del("product:" + product.getId());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
}

延迟双删的适用场景:写操作非常频繁,且对一致性要求较高。

订阅 Binlog + Canal

java 复制代码
// Canal 配置
@CanalMessageListener(topic = "product_db.product")
public void onMessage(CanalEntry.Entry entry) {
    CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
    
    for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
        if (rowChange.getEventType() == CanalEntry.EventType.UPDATE) {
            // 更新操作
            for (Column column : rowData.getBeforeColumnsList()) {
                if ("id".equals(column.getName())) {
                    Long id = Long.parseLong(column.getValue());
                    redis.del("product:" + id);
                }
            }
        }
    }
}

优势

  • 异步更新,不影响主流程
  • 保证最终一致性
  • 适用于大型分布式系统

缓存策略最佳实践

缓存 key 设计

java 复制代码
// 好的设计
String key = "product:info:" + categoryId + ":" + productId;
String key = "user:profile:" + userId;
String key = "order:summary:" + dateStr;

// 避免的设计
String key = "product_" + productId;           // 缺少命名空间
String key = getComplexKey(product);            // 复杂计算
String key = "temp:" + System.currentTimeMillis(); // 时效性数据

缓存 Value 设计

java 复制代码
// 序列化配置
@Cacheable(value = "products", key = "#id", unless = "#result == null")
public Product getProduct(Long id) {
    return productMapper.findById(id);
}

// JSON 序列化
@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        
        Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        mapper.activateDefaultTyping(mapper.getPolymorphicTypeValidator());
        serializer.setObjectMapper(mapper);
        
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);
        
        return template;
    }
}

过期时间策略

数据类型 过期时间 原因
热点商品 24 小时 数据相对稳定
用户会话 30 分钟 安全性考虑
排行榜数据 5 分钟 更新频繁
配置信息 1 小时 变更不频繁
计数器 不过期 需要持久化

容量规划

java 复制代码
// 预估缓存容量
// 假设每秒 10000 次查询,缓存 10000 条数据
// 每条数据 1KB
// 需要的内存 = 10000 * 1KB = 10MB

// 实际规划需要预留 20-30% 冗余
// 还需要考虑 Redis 本身的内存开销

监控告警

yaml 复制代码
# 监控指标
- alert: RedisHighMemoryUsage
  expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.8
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "Redis 内存使用率过高"
    
- alert: RedisHighHitMissRatio
  expr: redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total) < 0.8
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "Redis 缓存命中率过低"

性能优化技巧

批量操作

java 复制代码
// 批量查询
public Map<Long, Product> getProducts(List<Long> ids) {
    List<String> keys = ids.stream()
        .map(id -> "product:" + id)
        .collect(Collectors.toList());
    
    List<Product> products = redis.mGet(keys);
    
    Map<Long, Product> result = new HashMap<>();
    for (int i = 0; i < ids.size(); i++) {
        if (products.get(i) != null) {
            result.put(ids.get(i), products.get(i));
        }
    }
    
    // 批量回补缓存
    result.forEach((id, product) -> 
        redis.set("product:" + id, product, 3600)
    );
    
    return result;
}

Pipeline 批量写入

java 复制代码
public void batchWriteProducts(List<Product> products) {
    redis.executePipelined((RedisCallback<Object>) connection -> {
        for (Product product : products) {
            String key = "product:" + product.getId();
            byte[] value = serializationUtils.serialize(product);
            connection.setEx(key.getBytes(), 3600, value);
        }
        return null;
    });
}

缓存预热

java 复制代码
@PostConstruct
public void warmupCache() {
    // 系统启动时预加载热点数据
    log.info("Start cache warmup...");
    
    List<Long> hotProductIds = productService.getHotProductIds();
    
    for (Long id : hotProductIds) {
        Product product = productMapper.findById(id);
        redis.set("product:" + id, product, 3600);
    }
    
    log.info("Cache warmup completed, {} products loaded", hotProductIds.size());
}

常见错误与规避

错误一:缓存与数据库双写不一致

java 复制代码
// 错误写法:先更新缓存,再更新数据库
public void updateProduct(Product product) {
    redis.set("product:" + product.getId(), product);  // 先更新缓存
    productMapper.update(product);                       // 后更新数据库
    // 并发时可能缓存是旧数据
}

// 正确写法:先删缓存,再更新数据库
public void updateProduct(Product product) {
    redis.del("product:" + product.getId());  // 先删缓存
    productMapper.update(product);             // 后更新数据库
}

错误二:缓存频繁更新

java 复制代码
// 错误写法:每次访问都更新缓存
public Product getProduct(Long id) {
    Product product = redis.get("product:" + id);
    if (product == null) {
        product = productMapper.findById(id);
    }
    // 每次都更新,浪费资源
    redis.set("product:" + id, product, 3600);
    return product;
}

// 正确写法:只在缓存不存在时更新
public Product getProduct(Long id) {
    Product product = redis.get("product:" + id);
    if (product == null) {
        product = productMapper.findById(id);
        if (product != null) {
            redis.set("product:" + id, product, 3600);
        }
    }
    return product;
}

错误三:大对象缓存

java 复制代码
// 错误写法:缓存整个列表
public List<Product> getAllProducts() {
    List<Product> products = redis.get("all_products");
    if (products == null) {
        products = productMapper.findAll();
        redis.set("all_products", products, 300);
    }
    return products;
}

// 正确写法:分页缓存或使用压缩
public List<Product> getProducts(int page, int size) {
    String key = "products:" + page + ":" + size;
    return redis.get(key);
}

总结

旁路缓存是提升系统读取性能的利器,但在实际应用中需要注意:

  1. 合理设计:根据业务特点选择合适的缓存策略
  2. 一致性保障:根据一致性要求选择延迟双删或 Binlog 方案
  3. 容错机制:做好缓存穿透、击穿、雪崩的防护
  4. 监控告警:实时监控缓存命中率、内存使用等关键指标
  5. 预热与降级:系统启动时预热缓存,异常时做好降级

正确使用旁路缓存,能够将系统性能提升一个数量级,是后端开发必备的核心技能。

相关推荐
你这个代码我看不懂2 小时前
Redis TTL
数据库·redis·缓存
We་ct3 小时前
LeetCode 146. LRU缓存:题解+代码详解
前端·算法·leetcode·链表·缓存·typescript
青衫码上行3 小时前
Redis持久化 (快速入门)
数据库·redis·缓存
敲上瘾4 小时前
从虚拟地址到物理页框:Linux 页表与内存管理全解析
linux·运维·服务器·缓存
青春:一叶知秋5 小时前
【Redis存储】Redis客户端
java·数据库·redis
独泪了无痕5 小时前
通过Homebrew安装Redis指南
数据库·redis·缓存
dinga1985102617 小时前
linux上redis升级
linux·运维·redis
c***032318 小时前
linux centos8 安装redis 卸载redis
linux·运维·redis
kiss strong19 小时前
同一无线网下两台笔记本,一台访问另一台虚拟机中服务(redis为例)
数据库·redis·缓存