8. 高并发问题
Redis做缓存虽减轻了DBMS的压力,减小了RT(Response Time),但在高并发情况下也是可能会出现各种问题的。
8.1 缓存穿透
当用户访问的数据既不在数据库中也不在缓存中,如id为“-1”的数据或id为特别大不存在的数据, 这时的用户很可能是攻击者,攻击会导致数据库压力过大。就会导致每个用户查询都会“穿透”缓存“直抵”数据库。这种情况就称为缓存穿透。
当高度发的访问请求到达时,缓存穿透不仅增加了响应时间,而且还会引发对DBMS的高并发查询,这种高并发查询很可能会导致DBMS的崩溃(对DBMS做的负载均衡暂且不提)。
缓存穿透产生的主要原因有两个:
- 一是在数据库中没有相应的查询结果,
- 二是查询结果为空时,不对查询结果进行缓存。
所以,针对以上两点,解决方案也有两个:
-
对非法请求进行限制,例如限制查询的范围
a. 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
b. 使用布隆过滤器,需要安装redis组件
c. 使用布谷鸟滤器,布谷鸟过滤器是布隆过滤器的升级版,需要安装redis组件 -
对数据库中查询结果也为空的查询给出默认值, 并且把这个键值对的缓存有效时间可以设置短一些,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击
8.2 缓存击穿
关键词:定点打击
试想如果所有请求对着一个 key 照死里搞,这是不是就是一种定点打击呢?
怎么理解呢?举个极端的例子:比如某某明星爆出一个惊天狠料,海量吃瓜群众同时访问微博去查看该八卦新闻,而微博 Redis 集群中数据在此刻正好过期了,那么无数的请求则直接打到了微博系统的物理 DB 上,DB 瞬间挂了。
缓存击穿指的就是某key长期有大量请求,但某一瞬间却过期了,那么程序在redis找不到数据,就会去数据库里查询,数据库处理大量的请求的同时导致压力瞬间增大,甚至导致崩溃.
这种情况称为缓存击穿,而该缓存数据称为热点数据。
解决方案:
- 设置key值永不过期
- 将key的过期时间设为随机
- 使用布隆过滤器或者布谷鸟过滤器
- 使用分布式锁,当多个key过期时,同一时间只有一个查询请求下发到数据库,其他的key等待一个个地轮流查,就可以避免数据库压力过大的问题;代码如下:
// 分布式锁,为了可读性高用 ReentrantLock 代替分布式锁static Lock lock = new ReentrantLock();public String getData(String key ) throws InterruptedException {try {// 从redis获取值String data = getRedisData(key);// 如果key不存在,从数据库查询if(null == data){// 尝试获取锁if(!lock.tryLock()){// 获取锁失败 ,100ms后在次尝试TimeUnit.MILLISECONDS.sleep(100);data = getData(key);}// 走到这里表示成功获取锁// 从myqsl中获取锁data = getMysqlData(key);// 将数据更新到redissetDataToRedis(key,value);}return data;} catch (Exception e){e.printStackTrace();throw e;} finally {// 解锁lock.unlock();}}
- 双重检测锁机制
8.3.1 穿透和击穿的区别
关于穿透和击穿的区别上面已经介绍的很清楚了,这里在做个总结
- 穿透 :大量请求了缓存和数据库中都没有的数据,每次都查询数据库,导致数据库压力过大
- 击穿 : 热点key在同一时间过期,导致所有请求都达到数据库,导致数据库压力过大
8.3 缓存雪崩
关键词:Redis 崩了,没有数据了
这里的 Redis 崩了指的并不是 Redis 集群宕机了。而是说在某个时刻 Redis 集群中的热点 key 都失效了。
如果集群中的热点 key 在某一时刻同时失效了的话,试想海量的请求都将直接打到 DB 上,DB 可能在瞬间就被打爆了,一旦DB崩了,它所带来的连锁反应是可怕的,数据库不可用的情况下你的服务器也无法使用;这就是雪崩效应。
对于缓存雪崩没有很直接的解决方案,最好的解决方案就是预防,即提前规划好缓存的过期时间。要么就是让缓存永久有效,当 DB 中数据发生变化时清除相应的缓存。
如果 DBMS采用的是分布式部署,则将热点数据均匀分布在不同数据库节点中,将可能到来的访问负载均衡开来。
8.4 数据库缓存双写不一致
以上三种情况都是针对高并发读场景中可能会出现的问题,
而在高并发写场景下 , 则可能出现数据库缓存双写不一致的问题
对于数据库缓存双写不一致问题,又分为两种
8.4.1 “修改 DB 并更新缓存”场景
若多个请求要对 DBMS 中同一个数据进行修改,修改后还需要更新缓存中相关数据,
那么程序的异步执行可能会导致缓存与数据库中数据不一致的情况
8.4.2 “修改 DB 并删除缓存”场景
若两个请求对 DBMS 中同一个数据的操作既包含写也包含读,
且修改后还要删除缓存中相关数据,那么程序的异步执行就可能导致缓存与数据库中数据不一致的情况。
在很多系统中是没有缓存预热 warmup 功能的,为了保持缓存与数据库数据的一致性,一般都是在对数据库执行了写操作后,就会删除相应缓存。
8.4.3 解决方案
8.4.3.1 延迟双删
延迟双删方案是专门针对于“修改 DB 并删除缓存”场景的解决方案。但该方案并不能彻底解决数据不一致的状况,其只可能降低发生数据不一致的概率。
延迟双删方案是指,在写操作完毕后会立即执行一次缓存的删除操作,然后再停上一段时间(一般为几秒)后再进行一次删除。而两次删除中间的间隔时长,要大于一次缓存写操作
8.4.3.2 队列
以上两种场景中,只所以会出现数据库与缓存中数据不一致,主要是因为对请求的处理出现了并行。
只要将请求写入到一个统一的队列,只有处理完一个请求后才可处理下一个请求,即让系统对用户请求的处理串行化,就可以完全解决数据不一致的问题。
例如使用ZooKeeper或分布式消息队列MQ
8.4.3.3 分布式锁
使用队列的串行化虽然可以解决数据库与缓存中数据不一致,但系统失去了并发性,降低了性能。
使用分布式锁可以在不影响并发性的前提下,协调各处理线程间的关系,使数据库与缓存中的数据达成一致性。
只需要对数据库中的这个共享数据的访问通过分布式锁来协调对其的操作访问即可
9. 分布式锁
在分布式环境下, 分布式锁大部分是由Lua实现的
9.1 分布式锁的工作原理
当有多个线程要访问某一个共享资源(DBMS 中的数据或 Redis 中的数据,或共享文件等)时,为了达到协调多个线程的同步访问,此时就需要使用分布式锁了。
为了达到同步访问的目的,规定,让这些线程在访问共享资源之前先要获取到一个令牌token,只有具有令牌的线程才可以访问共享资源。这个令牌就是通过各种技术实现的分布式锁。而这个分布锁是一种“互斥资源”,即只有一个。只要有线程抢到了锁,那么其它线程只能等待,直到锁被释放或等待超时。
9.2 问题引入
某电商平台要对商品 sk:0008 进行秒杀销售。假设参与秒杀的商品数量amount 为 1000 台,每个账户只允许抢购一台,即每个请求只会减少一台库存
9.2.1 SB实现
9.2.1.1 准备
- 添加spring-boot-starter-redis/web依赖
- 编写配置文件设置Redis的主机地址和端口号
总之,在过去一年中,虽然我在各方面都取得了一些进步,但是离一名优秀共产党员的标准和要求还有一定差距,还存在一些缺点需要克服,主要体现在工作的主动性还不够、服务一线员工的意识还有待加强、思想认识还有待提高等。我相信,在以后的工作学习中,我一定会在党组织的关怀下,在各位党员及同事的帮助日下,通过自己的努力、采取有效措施克服缺点,不断积累经验,提高自身素质、增强工作能力,使自己真正成为一名能经受任何考验的共产党员。
以上是自己一年来基本情况的小结,不妥之处,恳请党组织批评指正,作为一名预备党员,我渴望按期转为中共正式党员,请党组织考虑我的申请,我将虚心接受党组织对我的审查和考验!如果党组织批准我成为正式党员,我一定在党组织和广大群众的监督之下,牢记入党誓言,勤奋工作、刻苦学习,处处以党员标准严格要求自己,做一名合格的共产党员,如果党组织没有批准我成为正式党员,我也不会泄气,继续努力,争取早日成为一名中国共产党正式党员。
9.2.1.1 有问题的示例
这里仅编写一个controller
@RestController
public class Seckillcontroller {@AutowiredStringRedisTemplate srt;@GetMapping("/sk")public string seckillHandler(){String stock =srt.opsForValue().get("sk:0008");int amount =stock == null ?0 : Integer.parseInt(stock);if(amount>0){srt.opsForValue().set("sk:0008",String.value0f(--amount));return "库存剩余"+ amount +"台";}return"抱歉,您没抢到";
}
上述代码是有问题的。既然是秒杀,那么一定是高并发场景,且生产环境下,该应用一定是部署在一个集群中的。如果参与秒杀的用户数量特别巨大,那么一定会存在很多用户同时读取 Redis 缓存中的 sk:0008 这个 key,那么大家读取到的 value 很可能是相同的,均大于零,均可购买。此时就会出现“超卖”。
即,以上代码存在并发问题。
9.2.1.2 SETNX修改
为了解决上述代码中的并发问题,可以使用 Redis 实现的分布式锁。
该实现方式主要是通过 SETNX 命令完成的。其基本原理是,SETNX 只有在指定 key 不存在时才能执行成功,分布式系统中的哪个节点抢先成功执行了 SETNX ,谁就抢到了锁,谁就拥有了对共享资源的操作权限。
与此同时,其它节点只能等待锁的释放。一旦拥有锁的节点对共享资源操作完毕,其就可以主动删除该 key,即释放锁。然后其它节点就可重新使用 SETNX 命令抢注该 key,即抢注锁
新建一个SeckillController类:
@RestController
public class SeckillController {// 分布式锁的keypublic static final String REDIS_LOCK = "redis_lock";@Autowiredprivate stringRedisTemplate srt;@Value("${server.port}")private String serverPort;@GetMapping(©~"/sk2")public string seckillHandler2(){String result ="抱歉,您没抢到";//仅当try {//setIfAbsent实质就是SETNX, 仅当原键不存在时才能设置Boolean lockOK = srt.opsForValve().setIfAbsent(REDIS_LOCK, "I'm a Lock");if(!lockOK){return "没抢到锁哟";}String stock =srt.opsForValue().get("sk:0008");//如果stock为null, 即缓存中没获取到, 就将amount设为0, 宣告购买失败int amount = stock == null ? 0 : Integer.parseInt(stock);//因为每个人只买一件, 如果库存大于0, 则肯定能买到, 故将amount-1后写回缓存 if (amount >0){srt.opsForValue().set("sk:0008",String.value0f(--amount));result = "库存剩余"+ amount +"台";System.out.println(result);}
} finally {srt.delete(REDIS_LOCK);}return result +"。server is "+ serverPort;
}
10. 缓存预热warmup
缓存预热指的是提前将热点数据加载到缓存中,这样当用户或系统开始请求这些数据时,它们已经可用,无需等待数据从慢速存储(如数据库)中检索。这有助于避免冷启动问题,提高系统的响应速度和吞吐量。
对于具有缓存 warmup 功能的系统,DBMS 中常用数据的变更,都会引发缓存中相关数据的更新。
Redis缓存预热的场景:
- 系统重启或部署: 重新部署应用程序后,缓存可能会被清空,预热可以迅速恢复缓存状态。
- 数据更新: 当缓存中的数据定期更新时,预热可以确保最新数据的快速可用性。
- 流量高峰: 在预期流量高峰之前预热缓存,可以帮助系统更好地应对负载。
10.1 实现方案
在 Spring Boot 启动之后,可以通过以下四种方案实现缓存预热:
- 使用启动监听事件实现缓存预热。
- 使用 @PostConstruct 注解实现缓存预热。
- 使用 CommandLineRunner 或 ApplicationRunner 实现缓存预热。
- 通过实现 InitializingBean 接口,并重写 afterPropertiesSet 方法实现缓存预热。
10.1.1 使用启动监听事件实现缓存预热
① 启动监听事件
可以使用 ApplicationListener 监听 ContextRefreshedEvent 或 ApplicationReadyEvent 等应用上下文初始化完成事件,在这些事件触发后执行数据加载到缓存的操作,具体实现如下:
@Component
public class CacheWarmer implements ApplicationListener<ContextRefreshedEvent> {@Overridepublic void onApplicationEvent(ContextRefreshedEvent event) {// 执行缓存预热业务...cacheManager.put("key", dataList);}
}
或监听 ApplicationReadyEvent 事件,如下代码所示:
@Component
public class CacheWarmer implements ApplicationListener<ApplicationReadyEvent> {@Overridepublic void onApplicationEvent(ApplicationReadyEvent event) {// 执行缓存预热业务...cacheManager.put("key", dataList);}
}
② @PostConstruct 注解
在需要进行缓存预热的类上添加 @Component 注解,并在其方法中添加 @PostConstruct 注解和缓存预热的业务逻辑,具体实现代码如下:
@Component
public class CachePreloader {@Autowiredprivate YourCacheManager cacheManager;@PostConstructpublic void preloadCache() {// 执行缓存预热业务...cacheManager.put("key", dataList);}
}
③ CommandLineRunner或ApplicationRunner
CommandLineRunner 和 ApplicationRunner 都是 Spring Boot 应用程序启动后要执行的接口,它们都允许我们在应用启动后执行一些自定义的初始化逻辑,例如缓存预热。
CommandLineRunner 实现示例如下:
@Component
public class MyCommandLineRunner implements CommandLineRunner {@Overridepublic void run(String... args) throws Exception {// 执行缓存预热业务...cacheManager.put("key", dataList);}
}
ApplicationRunner 实现示例如下:
@Component
public class MyApplicationRunner implements ApplicationRunner {@Overridepublic void run(ApplicationArguments args) throws Exception {// 执行缓存预热业务...cacheManager.put("key", dataList);}
}
CommandLineRunner 和 ApplicationRunner 区别如下:
方法签名不同: CommandLineRunner 接口有一个 run(String... args) 方法,它接收命令行参数作为可变长度字符串数组。ApplicationRunner 接口则提供了一个 run(ApplicationArguments args) 方法,它接收一个 ApplicationArguments 对象作为参数,这个对象提供了对传入的所有命令行参数(包括选项和非选项参数)的访问。
参数解析方式不同: CommandLineRunner 接口更简单直接,适合处理简单的命令行参数。ApplicationRunner 接口提供了一种更强大的参数解析能力,可以通过 ApplicationArguments 获取详细的参数信息,比如获取选项参数及其值、非选项参数列表以及查询是否存在特定参数等。
使用场景不同: 当只需要处理一组简单的命令行参数时,可以使用 CommandLineRunner。对于需要精细控制和解析命令行参数的复杂场景,推荐使用 ApplicationRunner。
④ 实现InitializingBean接口
实现 InitializingBean 接口并重写 afterPropertiesSet 方法,可以在 Spring Bean 初始化完成后执行缓存预热,具体实现代码如下:
@Component
public class CachePreloader implements InitializingBean {@Autowiredprivate YourCacheManager cacheManager;@Overridepublic void afterPropertiesSet() throws Exception {// 执行缓存预热业务...cacheManager.put("key", dataList);}
}
小结
缓存预热是指在 Spring Boot 项目启动时,预先将数据加载到缓存系统(如 Redis)中的一种机制。它可以通过监听 ContextRefreshedEvent 或 ApplicationReadyEvent 启动事件,或使用 @PostConstruct 注解,或实现 CommandLineRunner 接口、ApplicationRunner 接口,和 InitializingBean 接口的方式来完成。