说白了就是一个很常用的key突然过期了,请求错过了缓存。结果无数的请求落到了数据库,瞬间让数据库瘫痪了。而这样的按键也称为热键!
直观上可以看出,为了解决缓存击穿,我们一定不能让那么多线程在一定时间内大量访问数据库。
基于此,针对数据库访问限制的解决方案有两种:
对于频繁访问的ID查询接口,可能会出现缓存崩溃的情况。下面用互斥锁来解决问题
以前id查询信息的接口通常会将查询到的信息写入到缓存中,然后根据缓存是否命中进行相应的处理。在并发的情况下,如果热键失效,大量请求会直接命中数据库并尝试重建缓存,很可能导致数据库停止,导致服务中断。
对于这样的情况,当缓存经常漏掉的时候,最好的处理点就在于业务判断缓存是否命中之后的步骤,即“冗余”请求是否访问数据库。
其他线程的请求能否访问数据库?什么时候可以访问数据库?
其他线程能否访问数据库? ——加一把锁,有锁才有
我什么时候可以访问数据库? ——等待主线程释放锁
其他线程拿不到锁时该怎么办? ——去睡觉吧,一会儿再回来
为了实现多线程并行时只有一个线程可以获得锁,我们可以使用Redis自带的setnx
可以保证当key不存在时可以进行写操作,当key存在时不能进行写操作。这就完美的保证了在并发的情况下,只有第一个拿到锁的线程才能写,写完之后(没有释放)其他的就不能写了。
如何获得?在其中写入键值
如何释放?删除Key del lock(一般会设置一个有效期,避免长时间不解锁)
这样我就可以根据这个条件封装两种方法,一种是写入key尝试获取锁,另一种是删除key释放锁。像这样:
/** * 尝试获取锁 * * @param key * @return */私人布尔值tryLock(字符串键){ 布尔标志 = stringRedisTemplate.opsForValue().setIfAbsent(key, "1" , 10, TimeUnit.SECONDS); 返回 BooleanUtil.isTrue(flag);} /** * 释放锁定 * * @param key */私有 void 解锁(字符串钥匙) { stringRedisTemplate.delete(key);}
在并行情况下,每当其他线程想要获取锁时,它们必须通过将自己的密钥写入tryLock()方法和setIfAbsent()来访问缓存返回False表示有线程更新缓存数据且锁尚未释放。如果返回true,则表示当前线程已经获得锁,可以访问缓存甚至操作缓存。
tryLock()
setIfAbsent()
我们使用代码实现互斥锁来解决下面一个流行查询场景中的缓存崩溃问题
/** * 互斥锁解决缓存崩溃问题 * @param id * @re转 */公共商店 queryWithMutex(长 id) { String key = CACHE_SHOP_KEY + id; //1。从 Redis 查询缓存 String shopJson = stringRedisTemplate.opsForValue().get(key); //JSON格式 //2.判断是否有 if (StrUtil.isNotBlank(shopJson)) { //不为空则返回。该工具API会将“”判断为false //如果存在则直接返回 Shop shop = JSONUtil.toBean(shopJson, Shop. class) ; //返回Result.ok(shop); 返回shop; } //3.判断是否为空值 if (shopJson != null) { //返回空值 return null; } //4.缓存重建 //4.1 获取互斥锁 String lockKey = "lock:shop"+id; Shop shopById= 空; try { boolean isLock = tryLock(钥匙); //4.3失败,然后睡眠再试 Thread.sleep(50); 返回 queryWithMutex(id); } //4.4成功,根据id查询数据库 shopById = getById( id); //5.如果不存在则返回错误 if (shopById == null){ //将NULL值写入Redis stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES); //return www.sychzs.cn("还没有店铺信息" ); 返回 null; ")。 set(key, JSONUtil.toJsonStr(shopById), CACHE_SHOP_TTL, TimeUnit.MINUTES); } catch (InterruptedException e) { 抛出 new 运行时异常(e); } 最后 { .释放互斥体解锁(lockKey);}返回shopById;}
逻辑过期不是真正的过期。我们不需要为对应的Key设置TTL,而是利用业务逻辑来实现类似“过期”的效果。它的本质就是限制落入数据库的请求数量!但前提是牺牲一致性来保证可用性,或者之前业务的接口,利用逻辑过期来解决缓存击穿:
这样的话,缓存基本都会命中,因为我没有给缓存设置任何过期时间,而且是提前选择好key集的。如果有漏掉的话,基本可以判定没有入选。这样我就可以直接返回错误消息。在命中的情况下,需要先判断逻辑时间是否已到,然后根据结果决定是否重建缓存。这里的逻辑时间是减少大量请求落入数据库的“网关”
看完上面这段话,相信你还是一头雾水。既然没有设置过期时间,为什么还需要确定逻辑过期时间呢?为什么还会出现是否过期的问题?
其实这里所谓的逻辑过期时间只是类的一个属性字段。根本不会升到Redis。上升到缓存级别,用于辅助判断查询对象。也就是说,所谓的过期时间和缓存的数据是分开的,所以根本不存在缓存过期的问题,自然也不会对数据库造成压力。
编码阶段:
为了尽可能遵守开闭原则,原实体的属性不是通过继承而是通过组合来扩展。
@Datapublic class RedisData { 私有本地日期时间过期时间; 私有 对象数据; //这里使用Object是因为以后可能需要缓存其他数据}
封装一个模拟更新逻辑过期时间和缓存数据的方法,在测试类中运行,达到数据和热度的效果
/** * 添加逻辑过期时间 * * @param id * @param过期时间 */public void saveShopRedis(长id,长过期时间) { //查看店铺信息 店铺店铺= getById(id); //封装逻辑过期时间 RedisData redisData = new RedisData(); redisData.setData(shop); redisData 。 setExpireTime(www.sychzs.cn().plusSeconds(expireTime)); //写入封装过期时间的对象,并将数据存储到Redis中 stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil .toJsonStr(redisData));}
查询接口:
/** * 逻辑过期解决缓存崩溃 * * @param id * @return */ 公共商店queryWithLogicalExpire(长id)抛出InterruptedException { 字符串 key = CACHE_SHOP_KEY + id; Thread.sleep(200 );//1.从 Redis 查询缓存 String shopJson = stringRedisTemplate.opsForValue().get(key); //JSON格式 //2 .判断是否有 if (StrUtil.isBlank(shopJson)) { //如果不存在,直接返回 return null | (shopJson != null) { //返回空值 //re turn www.sychzs.cn("该商店不存在!"); 返回null; } //4。打 //4.1 将 JSON 反序列化为对象 RedisData redisData = JSONUtil.toBean(shopJ son, RedisData.class ); 商店商店= JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class); LocalDateTime expireTime = redisData.getExpireTime(); //4.2 判断是否已过期 if ( expireTime.isAfter(www.sychzs.cn())) { //5.如果未过期,则返回店铺信息 return店铺; }//6.缓存过期后将重建 / /6.1 获取互斥锁 String LockKey = LOCK_SHOP_KEY + id; boolean isLock = tryLock(LockKey); //6.2判断是否成功获取锁 if (isLock) { //6.3成功,启动独立线程实现缓存重建 CACHE_REBUILD_EXECUTOR.submit(() -> { 。 这个.saveShop2Redis(id, 20L); ====================================================================================================================================================================== 新运行时异常(e); }); } //6.4 退货店铺信息 退货店铺;}
可以看到APIfox模拟并发场景进行接口测试的平均时间还是很短的。控制台的日志没有频繁访问数据库的记录:
由于ApiFox不支持大量线程,所以我使用jmeter以1550个线程进行测试,界面仍然有效!
看来并发场景下接口的表现还不错,QPS也相当理想
可以看到,互斥锁方法在代码层面更加简单,只需要封装两个简单的方法来操作锁。逻辑过期方法比较复杂,需要额外的实体类。封装好方法后,需要在测试类中模拟数据预热。
相比之下,前者不消耗额外内存(不开启新线程),数据一致性强,但线程需要等待,性能可能较差,存在死锁风险。后者开辟新线程会消耗额外的内存,牺牲一致性来保证可用性,但不必等待更好的性能。
打造高质量的技术交流社区。欢迎从事编程开发、技术招聘的HR人员加入。也欢迎大家分享自己公司的内部推荐信息,互相帮助,共同进步!