目录
[2.1. 缓存主动更新策略](#2.1. 缓存主动更新策略)
[2.1.1. Cache Aside模式(主流)](#2.1.1. Cache Aside模式(主流))
[2.1.2. Read/Write Through模式](#2.1.2. Read/Write Through模式)
[2.1.3. Write Behind模式](#2.1.3. Write Behind模式)
[2.1.4. 总结](#2.1.4. 总结)
[5.1. 互斥锁实现](#5.1. 互斥锁实现)
[5.1.1. 实现流程图](#5.1.1. 实现流程图)
[编辑5.1.2. 主要实现代码](#编辑5.1.2. 主要实现代码)
[5.2. 逻辑过期实现](#5.2. 逻辑过期实现)
本文中的图片内容部分来源于黑马程序员教程案例
一、什么是缓存
**缓存是一种用于临时存储数据的技术或机制,旨在提高数据访问速度和系统性能。** 缓存通常位于计算机系统内部或附近,可以是硬件、软件或两者的结合体。例如,Web浏览器可以将最常访问的网页内容缓存在本地,以便下次访问时无需从远程服务器重新下载。
缓存的作用和原理在于利用程序局部性原理,将频繁访问的数据存储在高速存储器中,如SRAM或DRAM,以便快速访问。当硬件需要读取数据时,首先在缓存中查找,如果找到则直接执行,否则从内存中查找。由于缓存的运行速度比内存快得多,因此缓存的作用是帮助硬件更快地运行。
缓存可以根据不同的分类标准进行分类。例如,根据存储位置和用途的不同,可以分为CPU缓存、硬盘缓存、内存缓存等。CPU缓存包括L1、L2和L3缓存,硬盘缓存用于提高数据传输速度。此外,还有HTTP缓存、浏览器缓存等。
虽然缓存可以提高系统性能,但也会引入一定的数据一致性问题。因为缓存中的数据可能会与后端数据源(如数据库)存在不一致的情况,所以在使用缓存时需要考虑数据的更新和缓存的过期策略,以保证数据的一致性。
二、缓存更新策略
2.1. 缓存主动更新策略
指在更新数据库中的数据时,如何同步更新缓存中的数据,以保证数据的一致性。常见的缓存更新策略包括Cache Aside模式、Read/Write Through模式和Write Behind模式。
2.1.1. **Cache Aside模式(主流)**
Cache Aside模式是最常用的缓存更新策略,其中又分为两种:
-
先删除缓存,再操作数据库
-
先操作数据库,再删除缓存(主流),其操作步骤如下:
-
先更新数据库中的数据。
-
然后删除缓存中的数据,或者让缓存失效。
-
当下次查询时,如果缓存失效,则重新从数据库中读取数据并更新缓存。
注:由于操作缓存和数据库的话存在线程安全性问题,相较于先删除缓存再操作数据库而言,先操作数据库再删除缓存对于产生线程安全性的概率较低,因为写缓存的速度比更新数据库要快很多:线程1在查询缓存未命中后继续查询数据库时,线程2进来更新数据库,绝大部分情况下线程2更新数据库期间,线程1已经完成了缓存的写入。
2.1.2. Read/Write Through模式
Read/Write Through模式将缓存和数据库整合为一个服务,由服务来维护一致性。操作步骤如下:
- 查询操作直接从缓存中读取数据。
- 如果缓存中不存在数据,则直接从数据库中读取并返回给用户,同时将数据写入缓存。
2.1.3. Write Behind模式
Write Behind模式由其他线程异步地将缓存数据持久化到数据库,保证最终一致性。操作步骤如下:
- 更新操作只更新缓存。
- 由其他线程异步地将缓存数据写入数据库。
2.1.4. 总结
不同策略的优缺点
Cache Aside模式的优点是简单易实现,但存在数据不一致的风险。Read/Write Through模式的优点是数据一致性高,但性能较低。Write Behind模式的优点是最终一致性,但需要额外的线程管理。
不同场景下的适用性
Cache Aside模式适用于读多写少的场景,简单高效。Read/Write Through模式适用于对数据一致性要求高的场景。Write Behind模式适用于写操作较少,可以容忍最终一致性的场景。
缓存更新策略的最佳实践方案
-
低一致性需求:使用Redis自带的内存淘汰机制
-
高一致性需求:主动更新,并以超时剔除作为兜底方案
读操作:
- 缓存命中则直接返回
- 缓存未命中则查询数据库,并写入缓存,设定超时时间
写操作:
- 先写数据库,然后再删除缓存
- 要确保数据库与缓存操作的原子性
整个写操作的逻辑,我们要确保事务性,如在单体的Spring的JavaWeb项目业务代码的Service实现层添加@Transactional注解,分布式的项目中可以通过TTC模式来控制,当比如删除Redis缓存出现异常,直接回滚数据库的写操作。
三、缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会到数据库。
常见的解决方案有两种:
3.1. 缓存空对象(项目中主流用法):
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗以及可能造成短期的不一致
2. 布隆过滤器
- 优点:内存占用较少,没有多余key
- 缺点:实现复杂、存在误判可能
3. 参数校验过滤不合法请求
通过对用户输入的参数进行校验,例如检查ID的格式是否合法,如果不合法则直接拒绝请求。这种方法可以过滤掉一部分恶意伪造的用户请求,减少对数据库的压力。
4. 使用分布式锁
在缓存未命中时,通过分布式锁控制只有一个请求去查询数据库,其他请求等待锁释放。这样可以防止多个请求同时查询数据库,减轻数据库的压力。
5. 服务降级和限流
在面对大量的恶意请求时,可以通过服务降级或限流的方式来保护后端服务。限流可以限制到达数据库的请求数量,避免数据库压力过大。
四、缓存雪崩
**缓存雪崩是指由于缓存中大量数据同时失效或缓存服务器故障,导致大量请求直接打到数据库上,引发数据库压力激增,可能导致整个系统崩溃的现象。** 缓存雪崩通常由于缓存的过期策略不当或缓存服务器故障导致。例如,如果大量的缓存数据设置为在同一时间点过期,或者缓存服务器出现问题无法提供服务,所有的请求将直接访问后端存储系统,导致后端系统瞬时承受巨大的负载压力
解决方案:
- 给不同的key的TTL失效时间设置随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
五、缓存击穿
**缓存击穿是指当一个缓存中的热点数据过期或被删除后,大量并发请求同时到达,导致这些请求直接穿透缓存访问数据库,从而增加数据库的负载。** 这种情况通常发生在热点数据过期或删除的瞬间,导致短时间内大量请求无法通过缓存获取数据,直接访问数据库,造成数据库负载急剧增加。
缓存击穿的具体场景包括:
- 热点数据过期:当缓存中的热点数据过期时,大量请求会同时查询后端数据库,导致数据库负载增加。
- 第一次请求:对于一个之前从未被请求过的数据,当它第一次被请求时,缓存中没有该数据,导致请求穿透到后端存储。
解决缓存击穿的方法包括:
- 设置热点数据永不过期:将热点数据的缓存过期时间设置为较长的时间,甚至是永不过期。这种方法可以避免缓存击穿,但可能导致数据不够及时和准确。
- 使用互斥锁:在数据失效时,通过设置互斥锁来保护数据库访问过程。如果某个请求已经获取到了锁,其他请求需要等待,直到获取到锁为止。这种方法可以避免大量并发请求同时访问数据库,但可能导致并发性能下降和请求等待时间增加
注意:这两种方案没有孰优孰劣,在实际项目中,我们要针对业务场景和需求,权衡自身是更注重可用性还是一致性来做选择。
5.1. 互斥锁实现
5.1.1. 实现流程图
互斥锁的实现主要基于Redis的命令setnx再附加一个失效时间,key不存在时可以往Redis中写入(返回值1),否则写入失败(返回值0),同一时期如果有多人写入同一个key,只有一人能成功,以此达到互斥锁的效果:
5.1.2. 主要实现代码
java
package com.hmdp.service;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.baomidou.mybatisplus.extension.service.IService;
public interface IShopService extends IService<Shop> {
Result queryById(Long id);
Result update(Shop shop);
}
java
package com.hmdp.service.impl;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.hmdp.utils.CacheClient;
import com.hmdp.utils.SystemConstants;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.GeoResult;
import org.springframework.data.geo.GeoResults;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.domain.geo.GeoReference;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.*;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.*;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if (shop == null) {
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("id不能为空");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3. 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断命中的是否为null空值,如果是除了null以外的""和" \t\n"这类空串,则直接返回错误信息
if (shopJson != null) {
// 返回一个错误信息
return null;
}
// 4. 实现缓存重建
// 4.1. 获取互斥锁
String lockKey = "lock:shop" + id;
Shop shop = null;
try {
boolean isLock = trylock(lockKey);
// 4.2. 判断是否获取成功
if (!isLock) {
// 4.3. 失败,则休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 4.4 获取成功,则根据id查询数据库
shop = getById(id);
// 5. 不存在,则返回错误
if (shop == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6. 存在,写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException();
} finally {
// 释放互斥锁
unlock(lockKey);
}
// 7. 返回
return shop;
}
private boolean trylock(String key) {
// 不要直接返回flag,因这个值可能会为空,直接拆箱会可能报空指针
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}
5.2. 逻辑过期实现
缓存击穿往往发生在高并发访问的热点数据上,因此实际项目中,在我们的预期里,缓存击穿的热点数据是能够命中,即存在的。对于未命中的key,我们认定为非热点数据,因此直接返回空,不做逻辑过期处理。
java
package com.hmdp.utils;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 作为封装的Redis缓存对象
*/
@Data
public class RedisData {
/**
* 逻辑过期时间
*/
private LocalDateTime expireTime;
/**
* 缓存的业务实体Bean,如用户、商家等
*/
private Object data;
}
java
package com.hmdp.service;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.baomidou.mybatisplus.extension.service.IService;
public interface IShopService extends IService<Shop> {
Result queryById(Long id);
Result update(Shop shop);
}
java
package com.hmdp.service.impl;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.hmdp.utils.RedisData;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_KEY;
import static com.hmdp.utils.RedisConstants.LOCK_SHOP_KEY;
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 逻辑过期
Shop shop = queryWithLogicalExpire(id);
if (shop == null) {
return Result.fail("店铺不存在!");
}
// 7.返回
return Result.ok(shop);
}
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("id不能为空");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
/**
* 逻辑过期代码实现
* @param id
* @return
*/
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断缓存是否命中,未命中表明不是我们的热点数据,直接返回空(实际项目中,在我们的预期里,缓存击穿的热点数据是能够命中的)
if (StrUtil.isBlank(shopJson)) {
// 3. 未命中,直接返回空
return null;
}
// 4. 命中,需要把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5. 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1. 未过期则直接返回店铺信息
return shop;
}
// 5.2. 已过期需要缓存重建
// 6. 缓存重建
// 6.1. 获取互斥锁
String lockKey = LOCK_SHOP_KEY +id;
boolean isLock = trylock(lockKey);
// 6.2. 判断是否获取锁成功
if (isLock) {
// 6.3. 成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
}
// 6.4. 返回过期的商铺信息
return shop;
}
private void saveShop2Redis(Long id, Long expireSeconds) {
// 1. 查询店铺数据
Shop shop = getById(id);
// 2. 封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
// 当前时间加上传入的指定秒数(即多少秒后过期)
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. 写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
private boolean trylock(String key) {
// 不要直接返回flag,因这个值可能会为空,直接拆箱会可能报空指针
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}
六、缓存穿透/击穿封装工具类
java
package com.hmdp.utils;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import static com.hmdp.utils.RedisConstants.CACHE_NULL_TTL;
import static com.hmdp.utils.RedisConstants.LOCK_SHOP_KEY;
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 缓存穿透
* @param keyPrefix
* @param id
* @param type
* @param dbFallback
* @param time
* @param unit
* @param <R>
* @param <ID>
* @return
*/
public <R,ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,直接返回
return JSONUtil.toBean(json, type);
}
// 判断命中的是否是空值
if (json != null) {
// 返回一个错误信息
return null;
}
// 4.不存在,根据id查询数据库
R r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
return r;
}
/**
* 逻辑过期解决缓存击穿
* @param keyPrefix
* @param id
* @param type
* @param dbFallback
* @param time
* @param unit
* @param <R>
* @param <ID>
* @return
*/
public <R, ID> R queryWithLogicalExpire(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return r;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
// 6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R newR = dbFallback.apply(id);
// 重建缓存
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return r;
}
/**
* 互斥锁解决缓存击穿
* @param keyPrefix
* @param id
* @param type
* @param dbFallback
* @param time
* @param unit
* @param <R>
* @param <ID>
* @return
*/
public <R, ID> R queryWithMutex(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, type);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return null;
}
// 4.实现缓存重建
// 4.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2.判断是否获取成功
if (!isLock) {
// 4.3.获取锁失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
// 4.4.获取锁成功,根据id查询数据库
r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 7.释放锁
unlock(lockKey);
}
// 8.返回
return r;
}
/**
* 加锁
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 解锁
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}