Redis缓存读/写操作流程
1. Redis读操作和写操作
1.1 读操作
Redis读操作通常遵循以下流程:
应用程序 -> 检查缓存 -> 缓存命中 -> 返回数据
|
v
缓存未命中 -> 查询数据库 -> 更新缓存 -> 返回数据
读操作的关键点:
- 缓存命中时直接返回数据,性能高
- 缓存未命中时需要访问数据库,性能较低
- 需要考虑
缓存击穿、穿透、雪崩等问题
1.2 写操作
Redis写操作有多种策略,主要包括:
1.2 写操作策略对比
策略 | 流程 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
先更新DB,再更新缓存 | DB更新 → 缓存更新 | 数据一致性较好 | 并发更新可能脏数据 | 读多写少,一致性要求高 |
先更新DB,再删除缓存 | DB更新 → 缓存删除 | 简单,避免并发更新问题 | 可能短暂不一致 | 推荐:通用场景 |
先删除缓存,再更新DB | 缓存删除 → DB更新 | 避免脏读 | 可能缓存击穿 | 写多读少,强一致性 |
每种策略都有其适用场景和潜在问题。
2. Redis内存淘汰机制
2.1 淘汰淘汰策略分类
Redis作为内存数据库,当内存使用达到上限时,需要根据淘汰策略来释放空间。Redis提供了多种内存淘汰策略:
2.1 淘汰策略分类
策略 | 描述 |
---|---|
noeviction | 默认策略,不淘汰数据,内存满时写操作返回错误 |
allkeys-lru | 从所有key中淘汰最近最少使用的key |
allkeys-lfu | 从所有key中淘汰最不经常使用的key |
allkeys-random | 从所有key中随机淘汰 |
volatile-lru | 从设置了过期时间的key中淘汰最近最少使用的key |
volatile-lfu | 从设置了过期时间的key中淘汰最不经常使用的key |
volatile-random | 从设置了过期时间的key中随机淘汰 |
volatile-ttl | 从设置了过期时间的key中淘汰剩余时间最短的key |
2.2 策略选择建议
- allkeys-lru:适用于大部分场景,通用性好
- allkeys-lfu:适用于访问模式相对固定的场景
- volatile-ttl:适用于明确知道key过期时间的场景
- noeviction:适用于缓存数据量可控且不希望数据被淘汰的场景
3. 业务场景中缓存更新策略是什么?
这是缓存更新策略中的经典问题,需要根据业务场景和数据一致性要求来选择。

核心概念
- 缓存:Redis 等内存数据库,用于快速读取数据。
- 数据库:MySQL 等持久化存储,是数据的"真相源"。
- 线程1 和 线程2:两个并发执行的请求线程。
3.1 推荐方案:先更新数据库,再删除缓存
步骤顺序如下:
- 线程1 查询缓存未命中 -> 查询数据库(获取到 v=10)
- 线程2 更新数据库(v = 20)
- 线程2 删除缓存
- 线程1 写入缓存(把 v=10 写回缓存)
分析:
这种情况确实会发生,但概率较低。
因为通常步骤 1(读数据库)和步骤 4(写缓存)之间的时间间隔很短,在线程2更新数据库并删缓存的操作(步骤2、3)正好发生在它们之间的概率不高。
而且这种不一致会在下次更新或缓存过期时修复。
3.2 不推荐方案:先删除缓存,再更新数据库

步骤顺序如下:
线程1 删除缓存
线程2 查询缓存未命中 → 查数据库(旧值 20)
线程2 写入缓存(旧值 20)
线程1 更新数据库(新值 30)
结果:缓存中是旧值 20,数据库是新值 30,出现不一致。
高级优化建议(生产环境常用):
-
双删策略:
- 先删缓存
- 再更新数据库
- 延迟一段时间后再次删除缓存(防止其他线程在这期间写入旧值)
-
使用消息队列异步更新缓存:
- 数据库更新后发消息给 Redis 更新服务,保证最终一致性
-
设置缓存过期时间:
- 即使偶尔出现不一致,也能通过 TTL 自动恢复
总结一句话:
不要先改数据库再删缓存,否则可能让缓存写入旧数据,造成"脏读"。正确的做法是:先删缓存,再改数据库。
这正是图中右边被标记为"胜出"的原因 ------ 它是错误的,应该被淘汰!
4. 高一致性要求场景的解决方案
对于对数据一致性要求极高的场景,可以考虑以下方案:
41 延迟双删
java
public void updateProductWithDelayDelete(Product product) {
// 1. 删除缓存
String cacheKey = "product:" + product.getId();
redisCache.deleteProduct(cacheKey);
// 2. 更新数据库
productService.updateProductInDatabase(product);
// 3. 延迟删除缓存(防止其他请求将旧数据写入缓存)
Thread.sleep(100); // 短暂延迟
redisCache.deleteProduct(cacheKey);
}
4.2 异步更新缓存
java
public void updateProductWithAsyncCache(Product product) {
// 1. 更新数据库
productService.updateProductInDatabase(product);
// 2. 删除缓存
String cacheKey = "product:" + product.getId();
redisCache.deleteProduct(cacheKey);
// 3. 异步更新缓存
executorService.submit(() -> {
// 延迟一段时间后更新缓存,确保数据库事务已提交
Thread.sleep(1000);
Product updatedProduct = productService.getProductFromDatabase(product.getId());
redisCache.setProduct(cacheKey, updatedProduct, 300); // 5分钟过期
});
}
5. 实际应用思考
5.1 选择合适的缓存更新策略
- 读多写少:可以容忍短暂不一致,使用"先更新数据库,再删除缓存"
- 强一致性要求:使用延迟双删或异步更新缓存
- 写多读少:考虑是否真的需要缓存,或者使用较短的过期时间
5.2 监控和报警
- 缓存命中率监控:确保缓存有效
- 数据库查询次数监控:避免缓存失效导致数据库压力增大
- 缓存更新失败监控:及时发现和处理异常情况
5.3 缓存设计原则
- 合理设置过期时间:根据业务特点设置合适的过期时间
- 缓存预热:系统启动时预加载热点数据
- 缓存穿透防护:对空值也进行缓存
- 缓存雪崩防护:设置随机过期时间
- 缓存击穿防护:使用互斥锁或逻辑过期
6. 线程安全问题
在高并发场景下,缓存操作的线程安全是一个重要考虑因素。当多个线程同时访问缓存时,可能会出现以下线程安全问题:
5.1 缓存击穿与线程安全
缓存击穿是指热点数据在缓存中过期时,大量请求同时访问数据库的情况。这不仅会造成数据库压力,还可能导致线程安全问题。
1) 使用同步锁解决
最简单的解决方案是使用synchronized
关键字:
java
public Product getProductWithSynchronized(Long productId) {
String cacheKey = "product:" + productId;
// 1. 先从缓存中获取
Product product = redisCache.getProduct(cacheKey);
if (product != null) {
return product;
}
// 2. 缓存中没有,需要从数据库获取,使用同步锁保证只有一个线程去查询数据库
synchronized (this) {
// 双重检查,可能其他线程已经查询并放入缓存
product = redisCache.getProduct(cacheKey);
if (product != null) {
return product;
}
// 从数据库获取
product = productService.getProductFromDatabase(productId);
// 如果数据库中有数据,放入缓存
if (product != null) {
redisCache.setProduct(cacheKey, product, 5); // 设置5秒过期
}
}
return product;
}
这种方法在单机环境下有效,但在分布式环境下无法跨节点生效。
2)使用分布式锁解决
在分布式系统中,需要使用分布式锁来保证线程安全:
java
public Product getProductWithDistributedLock(Long productId) {
String cacheKey = "product:" + productId;
String lockKey = "lock:product:" + productId;
// 1. 先从缓存中获取
Product product = redisCache.getProduct(cacheKey);
if (product != null) {
return product;
}
// 2. 获取分布式锁
Jedis jedis = jedisPool.getResource();
try {
// 尝试获取锁,超时时间10秒,过期时间30秒
String lockValue = UUID.randomUUID().toString();
boolean lockAcquired = jedis.setnx(lockKey, lockValue) == 1;
if (lockAcquired) {
jedis.expire(lockKey, 30);
try {
// 双重检查
product = redisCache.getProduct(cacheKey);
if (product != null) {
return product;
}
// 从数据库获取
product = productService.getProductFromDatabase(productId);
// 如果数据库中有数据,放入缓存
if (product != null) {
redisCache.setProduct(cacheKey, product, 5);
}
} finally {
// 释放锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(script, 1, lockKey, lockValue);
}
} else {
// 获取锁失败,短暂等待后重试
Thread.sleep(100);
return getProductWithDistributedLock(productId); // 递归重试
}
} finally {
jedis.close();
}
return product;
}
5.2 缓存更新的线程安全
在缓存更新时,也需要考虑线程安全问题,特别是在高并发写操作场景下。
逻辑过期避免并发更新
逻辑过期是一种有效的线程安全方案,它避免了物理过期时的并发问题:
java
public class LogicalExpireWrapper {
private Product product;
private long expireTime; // 逻辑过期时间戳
// 构造函数、getter、setter省略
}
public Product getProductWithLogicalExpire(Long productId) {
String cacheKey = "product_logical:" + productId;
try (Jedis jedis = jedisPool.getResource()) {
String cachedValue = jedis.get(cacheKey);
if (cachedValue != null) {
// 解析缓存值和逻辑过期时间
LogicalExpireWrapper wrapper = objectMapper.readValue(cachedValue, LogicalExpireWrapper.class);
// 检查是否逻辑过期
if (System.currentTimeMillis() < wrapper.getExpireTime()) {
return wrapper.getProduct();
}
}
// 缓存不存在或已逻辑过期,需要查询数据库
// 获取该商品的锁
ReentrantLock lock = lockMap.computeIfAbsent(productId, k -> new ReentrantLock());
lock.lock();
try {
// 双重检查
String cachedValueAgain = jedis.get(cacheKey);
if (cachedValueAgain != null) {
LogicalExpireWrapper wrapper = objectMapper.readValue(cachedValueAgain, LogicalExpireWrapper.class);
if (System.currentTimeMillis() < wrapper.getExpireTime()) {
return wrapper.getProduct();
}
}
// 查询数据库
Product product = productService.getProductFromDatabase(productId);
if (product != null) {
// 设置逻辑过期时间为5秒后
LogicalExpireWrapper wrapper = new LogicalExpireWrapper(
product,
System.currentTimeMillis() + 5000);
String wrapperJson = objectMapper.writeValueAsString(wrapper);
jedis.set(cacheKey, wrapperJson);
}
return product;
} finally {
lock.unlock();
}
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
6. 总结
Redis缓存更新策略的选择需要综合考虑业务场景、数据一致性要求、系统复杂度等因素。
对于大多数业务场景,采用「先更新数据库,再删除缓存」策略,配合合适的过期时间和监控告警,即可满足性能和数据一致性要求。对于特殊的高并发、强一致性场景,再考虑使用更复杂的方案。