【Redis】缓存读/写操作流程
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 延迟双删
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 异步更新缓存
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
关键字:
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)使用分布式锁解决
在分布式系统中,需要使用分布式锁来保证线程安全:
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 缓存更新的线程安全
在缓存更新时,也需要考虑线程安全问题,特别是在高并发写操作场景下。
逻辑过期避免并发更新
逻辑过期是一种有效的线程安全方案,它避免了物理过期时的并发问题:
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缓存更新策略的选择需要综合考虑业务场景、数据一致性要求、系统复杂度等因素。
对于大多数业务场景,采用「先更新数据库,再删除缓存」策略,配合合适的过期时间和监控告警,即可满足性能和数据一致性要求。对于特殊的高并发、强一致性场景,再考虑使用更复杂的方案。