缓存
数据交换的缓冲区,俗称的缓存是缓冲区内的数据,一般从数据库中获取,
例1:Static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>(); 本地用于高并发例2:static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build(); 用于redis等缓存例3:Static final Map<K,V> map = new HashMap(); 本地缓存
-
ConcurrentHashMap
:
线程安全的哈希表,支持高并发读写。适用于本地内存缓存,无需序列化,直接操作 Java 对象。但无法持久化或分布式共享。 -
CacheBuilder
(Guava Cache):
功能更丰富的本地缓存,支持过期策略、最大容量、弱引用等。通常用于本地二级缓存,配合 Redis 等远程缓存使用,减少远程访问压力。 -
HashMap
:
非线程安全的哈希表,直接用于缓存会有并发问题(如数据不一致、死循环)。不推荐在高并发场景使用,除非通过外部同步(如Collections.synchronizedMap
)。
使用缓存的目的
速度快,提高读写效率,降低响应时间
缓存数据存储在内存中,而内存读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力(降低后端负载)
- 数据一致性成本:
若后端数据更新(如商品价格修改),缓存未及时同步,会出现 “缓存与源数据不一致” 的问题。需设计 缓存失效策略(如超时、主动更新),但这会增加代码复杂度和异常处理成本。 - 代码维护成本:
引入缓存后,代码需新增 “缓存读写、失效、回源(缓存未命中时查后端)” 等逻辑,还需处理缓存穿透、击穿、雪崩等异常场景,导致代码更复杂,维护难度提升。 - 运维成本:
缓存系统(如 Redis、Memcached)需独立部署、监控(内存、命中率、连接数)、扩容(集群化)、故障恢复,增加运维人力和资源投入。
如何使用缓存:
构建多级缓存,例如本地缓存和redis缓存并发使用
浏览器缓存:保存在浏览器端的缓存
应用层缓存:分为tomcat本地缓存,如使用map或redis
数据库缓存:数据库中有一个缓存池,增改查数据都会先加载到mysql缓存中
CPU缓存:CPU的L1、L2、L3级缓存
添加商户缓存
在查询商户信息时,先到缓存中查询
这里添加redis缓存
查询时先访问Redis,若没有命中再访问数据库,同时写缓存到Redis
String key = "cache:shop:" + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) { Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop);
}
Shop shop = getById(id);
if (shop == null) { return Result.fail("店铺不存在哦");
}
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
return Result.ok(shop);
先从redis查询店铺数据
如果存在,将JSOn格式的字符串通过JSONUtil反序列化成Shop类对象的实例
如果不存在,去数据库中查找,将返回值写入redis,这里同样将Shop类对象的实例转化成String类型
缓存更新策略
若缓存中数据过多,redis会对部分数据进行更新或淘汰
内存淘汰
当内存达到设定的最大值时,自动淘汰一些不重要的数据
超时剔除
设置过期时间,redis会将超时的数据进行删除
主动更新
手动调用方法删除缓存,通常用于解决缓存和数据库不一致的问题
数据库缓存不一致解决方案
由于缓存数据来源于数据库,而数据库中的数据是会发生变化的,若数据库数据发生变化,而缓存未同步,就会出现一致性问题
后果是用户可能使用缓存中过时数据,从而产生类似多线程数据安全问题
有如下解决方案:
人工编码:内存调用者在更新完数据库后更新缓存
读写穿透模式:系统作为中间层,同时管理缓存与数据库的读写操作
写回缓存模式:应用层仅操作缓存,数据库更新由异步线程批量处理,调用者写入缓存后直接返回,由异步线程定期将缓存数据批量写入数据库
综合推荐使用方案一,
在操作数据库时,我们可以将缓存删除,待查询时再从缓存中加载数据
为保证数据库操作同时成功或失败:
采用单体系统的情况,则将缓存与数据库放在一个事务中
采用分布式系统,则利用TCC等分布式事务方案
具体操作缓存和数据库时,应该采用先操作数据库,后删除缓存的操作
若先删除缓存,再操作数据库:
当有两个线程并发查询的时候,假设线程1先查询,删除缓存后此时线程2发现没有缓存数据,从数据库中读取旧数据写入到缓存中,此时线程1再进行更新数据库的操作,那么缓存就是旧数据
@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();
}
具体到代码在执行更新操作是,先更新数据库,再删除缓存
缓存穿透问题的解决思路
缓存穿透:客户端请求的数据在缓存中和数据库总都不存在,都有缓存永远不会生效,这些请求都会打到数据库。
解决方案:
将空对象缓存:哪怕数据在数据库中不存在也存入redis中,这样就不会访问数据库
实现简单,但会造成额外的内存消耗
布隆过滤:通过一个庞大的二进制数据,走哈希的思想判断这个数据是否存在,若存在才会放行
内存占用少,但实现复杂并有误判可能
缓存雪崩及解决思路
缓存雪崩是指同一时间大量缓存key同时失效导致Redis服务宕机,导致大量请求到达数据库,从而造成巨大的压力
解决方案:
给不同Key的TTL添加随机值
使用Redis集群
给缓存业务进行降级限流
给业务添加多级缓存
缓存击穿及解决思路
也叫热点key,就是一个高并发且缓存重建业务比较复杂(重建时间长)的key突然失效,无数请求的访问会瞬间给数据库带来巨大的冲击
解决方案:
互斥锁
将并行查询改为串行,一次只能一个线程访问数据库,使用tryLock和double check解决问题
逻辑过期
不设置过期时间,将过期时间设置在redis的value中,当线程1查询缓存时,发现数据已经过期了,他会开启一个新的线程去进行重构数据的逻辑,而线程1直接返回过期数据,假设线程3过来访问,由于线程2持有锁,线程3无法获得锁,它也直接返回过期数据
特点是在完成缓存重建之前,所有线程返回的都是脏数据
对比:
互斥锁:简单,保证数据一致,可能存在死锁风险且性能低
逻辑过期:读取不需要等待,性能好,在重构之前都是脏数据,实现复杂
使用互斥锁解决缓存击穿问题
public Shop queryWithMutex(Long id) {String key = CACHE_SHOP_KEY + id;// 1、从redis中查询商铺缓存String shopJson = stringRedisTemplate.opsForValue().get("key");// 2、判断是否存在if (StrUtil.isNotBlank(shopJson)) {// 存在,直接返回return JSONUtil.toBean(shopJson, Shop.class);}//判断命中的值是否是空值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){//将空值写入redisstringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);//返回错误信息return null;}//6.写入redisstringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_NULL_TTL,TimeUnit.MINUTES);}catch (Exception e){throw new RuntimeException(e);}finally {//7.释放互斥锁unlock(lockKey);}return shop;}
先进行获取锁,未获取到迭代继续获取,知道拿到后查询数据库,如果数据库没有,将空对象写入redis并返回null
如果有就写入redis,然后释放锁,最后返回数据库的结果
使用逻辑过期解决缓存击穿问题
在查询redis时,先判断是否命中,如果没有命中直接返回空数据,不查询数据库,一旦命中将value取出,判断value的过期时间,如果没过期直接返回数据,过期则开启独立线程后返回之前的数据,独立线程单独重构数据,重构完成后释放互斥锁
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire( Long id ) {String key = CACHE_SHOP_KEY + 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);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){CACHE_REBUILD_EXECUTOR.submit( ()->{try{//重建缓存this.saveShop2Redis(id,20L);}catch (Exception e){throw new RuntimeException(e);}finally {unlock(lockKey);}});}// 6.4.返回过期的商铺信息return shop;
}
- 线程池的运用:
这里创建了一个固定大小为 10 的线程池,目的是管控缓存重建任务。借助线程池,可以避免因大量创建线程而导致系统资源被过度占用。private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
- 异步任务的提交:
当商铺缓存过期后,会向线程池提交一个重建缓存的任务,这样可以让主线程继续执行后续操作,不用等待缓存重建完成。CACHE_REBUILD_EXECUTOR.submit( ()->{// 任务内容 });
- 缓存重建的流程:
try{// 重建缓存this.saveShop2Redis(id, 20L); } catch (Exception e) {throw new RuntimeException(e); }
saveShop2Redis(id, 20L)
方法会从数据库获取最新的商铺数据,然后把这些数据存入 Redis,同时设置 20 秒的逻辑过期时间。- 对可能出现的异常进行捕获,将其封装成运行时异常后重新抛出。
- 锁的释放操作:
不管缓存重建成功与否,最终都会执行finally {unlock(lockKey); }
unlock(lockKey)
方法来释放锁,防止出现死锁的情况。
封装redis工具类
基于StringRedisTemplate封装一个缓存工具类
方法1:将任意Java对象序列化为Json并储存在string类型的key中,可设置TTL过期时间
方法2:将任意Java对象序列化为Json并储存在String类型的key中,可以设置逻辑过期时间,用于处理缓存击穿问题
方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透的问题
根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
Shop shop = cacheClient.queryWithPassThrough( CACHE_SHOP_KEY, // 缓存键前缀
id, // 商铺ID
Shop.class, // 返回类型
this::getById, // 数据库查询回调
CACHE_SHOP_TTL, // 缓存时间
TimeUnit.MINUTES // 时间单位 );
public <R, ID> R queryWithPassThrough( String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) { // ... R r = dbFallback.apply(id); // 调用传入的函数 // ... }
这里的 Function<ID, R>
是一个函数式接口,表示接受一个 ID 类型的参数,返回一个 R 类型的结果。
等价于id->getById(id),表示传入一个id参数,调用当前对象的getById方法处理它,并返回结果
总结
-
缓存的核心作用是什么?
缓存通过将高频访问数据存储在内存中,显著提升读写效率、降低响应时间,同时减少后端数据库的访问压力,缓解高并发场景下的服务器负载。 -
常见的本地缓存实现有哪些?核心区别是什么?
常见实现包括ConcurrentHashMap
(线程安全,适用于高并发本地缓存,无持久化)、Guava Cache
(功能丰富,支持过期策略、容量控制等,适合本地二级缓存)、HashMap
(非线程安全,高并发下易出问题,不推荐直接使用)。核心区别在于线程安全性、功能丰富度及适用场景。 -
如何解决缓存与数据库的数据一致性问题?
推荐 “先更新数据库,后删除缓存” 的策略:更新操作时,先保证数据库数据正确,再删除对应缓存,避免旧数据残留。单体系统中可通过事务保证操作原子性,分布式系统需结合 TCC 等分布式事务方案。 -
什么是缓存穿透?如何解决?
缓存穿透指请求数据在缓存和数据库中均不存在,导致请求直接穿透缓存冲击数据库。解决方式包括:①缓存空对象(将不存在的数据以空值存入缓存,避免重复穿透);②布隆过滤(通过哈希判断数据是否存在,提前拦截无效请求)。 -
缓存击穿的解决方式有哪些?各有什么特点?
缓存击穿指高并发下热点 Key 突然失效,大量请求瞬间冲击数据库。解决方式包括:①互斥锁(串行化请求,保证缓存重建时仅一个线程访问数据库,数据一致但性能略低);②逻辑过期(不设置物理过期,通过 value 中的逻辑时间判断,过期时异步重建缓存,性能高但可能返回脏数据)。 -
缓存雪崩的成因及预防措施是什么?
缓存雪崩指大量缓存 Key 同时失效,导致 Redis 压力骤降、请求集中冲击数据库。预防措施包括:①给 Key 的 TTL 添加随机值,避免集中过期;②使用 Redis 集群提高可用性;③对缓存业务降级限流;④引入多级缓存减少单一层级依赖。