旁路缓存(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;
}
读操作的关键步骤:
- 先从 Redis 读取数据
- 缓存命中则直接返回
- 缓存未命中则查询数据库
- 将查询结果写入缓存并设置过期时间
写操作流程
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);
}
总结
旁路缓存是提升系统读取性能的利器,但在实际应用中需要注意:
- 合理设计:根据业务特点选择合适的缓存策略
- 一致性保障:根据一致性要求选择延迟双删或 Binlog 方案
- 容错机制:做好缓存穿透、击穿、雪崩的防护
- 监控告警:实时监控缓存命中率、内存使用等关键指标
- 预热与降级:系统启动时预热缓存,异常时做好降级
正确使用旁路缓存,能够将系统性能提升一个数量级,是后端开发必备的核心技能。