缓存策略不是加上Redis就完事
在MySQL前面加一层Redis缓存是提升查询性能的标准方案,但缓存引入的数据一致性问题往往比性能问题更棘手。缓存穿透、缓存雪崩、数据不一致是生产环境中的高频故障类型,每个都需要针对性的解决方案。
本文从缓存读写策略、一致性保障、异常防护三个维度,构建完整的Redis+MySQL缓存方案。
缓存读写策略选型
三种主流读写策略各有适用场景:
Cache Aside(旁路缓存):最常用的策略。读请求先查缓存,miss时查数据库并回写缓存;写请求先更新数据库再删除缓存。
// Cache Aside模式实现
@Service
public class ProductService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductMapper productMapper;
private static final long CACHE_TTL = 3600; // 1小时
public Product getProduct(Long id) {
String key = "product:" + id;
// 1. 查缓存
Product product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
// 2. 查数据库
product = productMapper.selectById(id);
if (product == null) {
// 防穿透:空值缓存,短TTL
redisTemplate.opsForValue().set(key, "NULL", 300, TimeUnit.SECONDS);
return null;
}
// 3. 回写缓存
redisTemplate.opsForValue().set(key, product, CACHE_TTL, TimeUnit.SECONDS);
return product;
}
public void updateProduct(Product product) {
// 1. 更新数据库
productMapper.updateById(product);
// 2. 删除缓存
redisTemplate.delete("product:" + product.getId());
}
}
为什么是删除缓存而不是更新缓存?并发写场景下,更新缓存可能出现后更新的请求先写入缓存、先更新的请求后写入缓存,导致缓存数据是旧值。删除缓存让下次读请求从数据库加载最新数据,避免了并发写的顺序问题。
Read/Write Through:缓存层代理读写操作,应用层只与缓存交互。适合读多写少且数据结构简单的场景。
Write Behind(异步回写):写请求只写缓存,异步批量刷入数据库。适合写密集且允许短暂不一致的场景,如浏览量、点赞数。
缓存穿透防护:布隆过滤器 + 空值缓存
穿透指大量请求查询数据库中不存在的数据,缓存永远miss,请求全部打到数据库。两种防护手段配合使用:
空值缓存:缓存查不到的key,设置短TTL(5分钟),防止同一不存在的key反复击穿。上文代码中的NULL标记就是空值缓存。
布隆过滤器:在Redis中维护一个布隆过滤器,包含所有合法key。请求到达时先过布隆过滤器,不存在的key直接拒绝:
@Service
public class BloomFilterService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String BLOOM_KEY = "product:bloom";
public boolean mightExist(Long id) {
long[] offsets = getHashOffsets(id);
for (long offset : offsets) {
Boolean bit = redisTemplate.opsForValue().getBit(BLOOM_KEY, offset);
if (bit == null || !bit) {
return false; // 一定不存在
}
}
return true; // 可能存在
}
public void add(Long id) {
long[] offsets = getHashOffsets(id);
for (long offset : offsets) {
redisTemplate.opsForValue().setBit(BLOOM_KEY, offset, true);
}
}
private long[] getHashOffsets(Long id) {
// 使用5个hash函数,误判率约1%
long[] offsets = new long[5];
long hash1 = hash1(id);
long hash2 = hash2(id);
for (int i = 0; i < 5; i++) {
offsets[i] = Math.abs(hash1 + i * hash2) % 100000000L;
}
return offsets;
}
}
布隆过滤器的限制:存在误判率(可能将不存在的key判断为存在),但不会漏判(存在的key一定通过)。误判率通过hash函数数量和bitmap大小控制。1亿条数据、1%误判率约需要100MB内存。
缓存雪崩防护:随机TTL + 多级缓存
雪崩指大量key同时过期,请求瞬间涌入数据库。两个解决思路:
1. 给TTL加随机偏移:TTL = base_ttl + random(0, 300s),避免同一批key同时过期
2. 多级缓存:本地缓存(Caffeine)+ Redis分布式缓存,Redis大面积失效时本地缓存兜底
@Service
public class MultiLevelCacheService {
private final Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats()
.build();
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public Object get(String key) {
// L1: 本地缓存
Object value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
// L2: Redis
value = redisTemplate.opsForValue().get(key);
if (value != null) {
localCache.put(key, value);
return value;
}
// L3: 数据库
value = loadFromDB(key);
if (value != null) {
long randomTtl = 3600 + ThreadLocalRandom.current().nextLong(300);
redisTemplate.opsForValue().set(key, value, randomTtl, TimeUnit.SECONDS);
localCache.put(key, value);
}
return value;
}
}
本地缓存的TTL应短于Redis缓存,这样Redis失效时本地缓存还能兜底一小段时间,给数据库恢复争取缓冲期。
数据一致性保障:延迟双删 + 消息队列
先更新数据库再删除缓存的模式存在一个时间窗口:数据库更新完成后、缓存删除之前的这段时间,读请求可能从缓存中读到旧数据。对于一致性要求高的场景,需要延迟双删:
public void updateWithDoubleDelete(Product product) {
// 1. 删除缓存(第一次)
redisTemplate.delete("product:" + product.getId());
// 2. 更新数据库
productMapper.updateById(product);
// 3. 延迟500ms后再删一次(第二次)
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.schedule(() -> {
redisTemplate.delete("product:" + product.getId());
}, 500, TimeUnit.MILLISECONDS);
}
500ms的延迟需要大于一次读请求的耗时,确保在数据库更新和第二次删除之间的读请求完成回写后,缓存被再次清理。
更高一致性要求下,使用消息队列确保缓存删除的可靠性:更新数据库后发送缓存删除消息到队列,消费者处理删除操作。消息队列保证至少被消费一次,如果消费失败会自动重试。
分布式锁:Redisson实现与注意事项
缓存重建场景中,只允许一个请求查数据库并回写缓存,其他请求等待。Redisson提供的分布式锁比setnx更可靠:
@Service
public class CacheRebuildService {
@Autowired
private RedissonClient redissonClient;
public Product getProductWithLock(Long id) {
String key = "product:" + id;
Product product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
String lockKey = "lock:product:" + id;
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
// Double check
product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(key, product, 3600, TimeUnit.SECONDS);
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
return product;
}
}
Redisson的看门狗(Watchdog)机制会自动续期锁,避免业务未执行完锁就过期。但lock.isHeldByCurrentThread()检查不能省——如果锁已超时被其他线程持有,unlock会释放别人的锁。
缓存方案配置清单
1. 写操作采用先更新数据库再删除缓存,不使用更新缓存
2. 空值缓存TTL设5分钟,防止穿透
3. 高价值数据用布隆过滤器前置拦截,误判率控制在1%以内
4. TTL加随机偏移(0-300s),防止雪崩
5. 本地缓存TTL短于Redis缓存,形成多级兜底
6. 一致性要求高的场景使用延迟双删,延迟时间大于读请求耗时
7. 关键业务用消息队列保障缓存删除的可靠性
8. 分布式锁使用Redisson,检查isHeldByCurrentThread再释放
9. 锁的自动释放时间必须大于业务执行时间,避免误释放
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-ce-lyue-yu-mysql-shu-ju-yi-zhi-xing-shi-zhan/