闲的无聊,发个库存。。。。。
1.SpringBoot面试题
1.1.SpringBoot的作用是什么?
答:采用默认配置,帮助我们快速的构建和运行Spring项目:
- 用来简化spring初始搭建和开发过程使用特定的方式进行配置(properties或者yml文件)
- 创建独立的spring引用程序main方法运行
- 嵌入Tomcat无需部署war包,直接打成jar包nohup java -jar – & 启动就好
- 简化了maven的配置
- 自动配置spring添加对应的starter自动化配置
1.2.SpringBoot的自动配置原理?
SpringBoot项目的启动类都会有@SpringBootApplication注解,而这个注解的二级注解时@EnableAutoConfiguration注解。而@EnableAutoConfiguration注解通过@Import注解来导入各种在SpringBoot的jar包中或starter中写好的各种@Configuration声明的类。具体流程如下:
- @EnableAutoConfiguration通过@Import来导入配置
- 导入过程中,会利用classLoader读取META-INF/spring.factories文件(key-value格式)中的数据
- 读取文件中以EnableAutoConfiguration为key的值,值就是Spring提供的,或我们引入的starter中的加了@Configuration的类,包括对spring、第三方库的各种配置。例如redis、elasticsearch、springmvc、mybatis等
- 对加载到的@Configuration类做过滤,分三步过滤:
-
- 滤重
- 去除用户通过exclude排除的配置
- 去除不满足@ConditionalOnXX这样条件的配置
- 将剩余的配置类实例化,完成自动配置加载
这个过程中,我们可以通过自定义@Bean的方式,覆盖默认配置中已经完成的Bean。或者我们可以通过编写application.properties或者application.yml文件来覆盖默认配置中的属性值。
1.3.有没有自定义过SpringBoot的stater?
有,项目中某些中间件的客户端(如Redis、ElasticSearch)会进行二次封装,并通过starter方式提供jar包,供大家使用。
一般定义starter包括下面几个子工程:
- xxx-spring-boot-starter:pom格式,管理当前starter中需要的各种依赖
- xxx-spring-boot-autoconfigure:jar格式,编写@Configuration配置类,读取application.yml文件,实现默认配置
2.SpringCloud相关
2.0.微服务的优缺点
微服务架构是一种架构模式或者说是一种架构风格,它提倡将单一应用程序划分为一组小服务,每个服务运行在自己的独立进程中,服务间通信采用轻量级通信机制(通常是基于 HTTP 的 RESTful API)。每个服务都围绕着具体业务进行构建,并且能够被独立地部署到生产环境、类生产环境等。另外,应该尽量避免统一的、集中式的服务管理机制,对具体的一个服务而言,应根据业务上下文,选择合适的语言、工具对其进行构建,可以有一个非常轻量级的集中式管理来协调这些服务,可以使用不同的语言来编写服务,也可以使用不同的数据存储技术。
- 优点:
-
- 易于开发和维护:一个微服务只会关注一个特定的业务功能,所以它业务清晰,代码量较少
- 单个微服务启动较快:单个微服务代码量较少,所以启动会比较快
- 业务之间松耦合,无论是在开发阶段或者部署阶段,不同的服务都是互相独立的
- 局部修改容易部署:单体应用只要有修改,就得重新部署整个应用,微服务解决了这样的问题
- 技术栈不受限:在微服务架构中,可以结合项目业务及团队的特点,合理地选择技术栈
- 按需伸缩:可根据需求,实现细粒度的扩展
- 只有业务逻辑的代码,不会和 HTML、CSS 或者其他前端页面耦合,目前有两种开发模式:前后端分离、全栈开发
- 缺点:
-
- 运维要求高:更多的服务意味着更多的运维投入
- 技术开发难度高:涉及到网络通信延迟、服务容错、数据一致性、系统集成测试、系统部署依赖、性能监控等
- 分布式系统固有的复杂性:使用微服务架构是分布式系统,对于一个分布式系统,系统容错,网络延迟,分布式事务等都会带来巨大的挑战
- 接口调整成本高:微服务之间通过接口进行通信。如果修改某一个微服务的 API,可能所有使用了该接口的微服务都需要做调整
- 重复劳动:很多服务可能都会使用到相同的功能,而这个功能并没有达到分解为一个微服务的程度,这个时候,可能各个服务都会开发这一功能,从而导致代码重复
2.1.SpringCloud和Dubbo的区别
SpringCloud:Spring公司开源的微服务框架,SpirngCloud 定位为微服务架构下的一站式解决方案(微服务生态)
Dubbo:阿里巴巴开源的RPC框架,Dubbo 是 SOA 时代的产物,它的关注点主要在于服务的调用,流量分发、流量监控和熔断
功能 | Dubbo | SpringCloud |
服务注册中心 | Zookeeper、Redis | Spring Cloud Netfix Eureka |
服务调用方式 | RPC | REST API |
服务监控 | Dubbo-Monitor | Spring Boot Admin |
熔断器 | 不完善 | Spring Cloud Netflix Hystrix |
服务网关 | 无 | Spring Cloud Netflix Zuul |
分布式配置 | 无 | Spring Cloud Config |
服务跟踪 | 无 | Spring Cloud Sleuth |
数据流 | 无 | Spring Cloud Stream |
批量任务 | 无 | Spring Cloud Task |
信息总线 | 无 | Spring Cloud Bus |
Spring Cloud 的功能很明显比 Dubbo 更加强大,涵盖面更广,而且作为 Spring 的旗舰项目,它也能够与 Spring Framework、Spring Boot、Spring Data、Spring Batch 等其他 Spring 项目完美融合,这些对于微服务而言是至关重要的。
使用 Dubbo 构建的微服务架构就像组装电脑,各环节选择自由度很高,但是最终结果很有可能因为一条内存质量不行就点不亮了,总是让人不怎么放心,但是如果使用者是一名高手,那这些都不是问题。
而 Spring Cloud 就像品牌机,在 Spring Source 的整合下,做了大量的兼容性测试,保证了机器拥有更高的稳定性,但是如果要在使用非原装组件外的东西,就需要对其基础原理有足够的了解。
2.2.RPC与Rest的区别(dubbo协议和Feign远程调用的差异)
Dubbo协议默认采用的时RPC框架实现远程调用,而SpringCloud中使用的时基于Rest风格的调用方式。包括下面区别:
1)Rest风格
REST是一种架构风格,指的是一组架构约束条件和原则。满足这些约束条件和原则的应用程序或设计就是 RESTful。
Rest的风格可以完全通过HTTP协议实现,使用 HTTP 协议处理数据通信。REST架构对资源的操作包括获取、创建、修改和删除资源的操作正好对应HTTP协议提供的GET、POST、PUT和DELETE方法。
因此请求和想要过程只要遵循http协议即可,更加灵活
SpringCloud中的Feign就是Rest风格的调用方式。
2)RPC
Remote Procedure Call,远程过程调用,就是像调用本地方法一样调用远程方法。RPC架构图:
RPC一般要确定下面几件事情:
- 数据传输方式:多数RPC框架选择TCP作为传输协议,性能比较好,也有部分框架选择http协议。
- 数据传输内容:请求方需要告知需要调用的函数的名称、参数、等信息。
- 序列化方式:客户端和服务端交互时将参数或结果转化为字节流在网络中传输,那么数据转化为字节流的或者将字节流转换成能读取的固定格式时就需要进行序列化和反序列化
因为有序列化和反序列化的需求,因此对数据传输格式有严格要求,不如Http灵活
Dubbo协议就是RPC的典型代表。
我们看看Dubbo协议和Rest的调用区别:
Dubbo | Rest(Http调用) | |
传输协议 | TCP | TCP |
开发语言 | java | 不限 |
性能 | 好 | 一般 |
灵活性 | 一般 | 好 |
2.3.Eureka和Zookeeper注册中心的区别
答:
先看下CAP原则:C-数据一致性;A-服务可用性;P-服务对网络分区故障的容错性,这三个特性在任何分布式系统中不能同时满足,最多同时满足两个
- Eureka满足AP,Zookeeper满足CP
-
- Zookeeper满足一致性、容错性。数据要在各个服务间同步完成后才返回用户结果,而且如果服务出现网络波动,会立即从服务列表中剔除,服务不可使用
- Eureka满足AP,可用性,容错性。当因网络故障时,Eureka的自我保护机制不会立即剔除服务,虽然用户获取到的服务不一定时可用的,但是至少能够获取到服务列表。用户访问服务列表时还可以利用重试机制,找到正确的服务。更符合分布式服务的高可用需求
- Eureka集群各节点平等,Zookeeper中有主从之分
-
- 如果Zookeeper集群中部分宕机,可能会导致整个集群因为选主而阻塞,服务不可用
- eureka集群宕机部分,不会对其它机器产生影响
- Eureka的服务发现需要主动去拉取,Zookeeper服务发现是监听机制
-
- eureka中获取服务列表后会缓存起来,每隔30秒重新拉取服务列表
- zookeeper则是监听节点信息变化,当服务节点信息变化时,客户端立即就得到通知
2.4.SpringCloud中的常用组件有哪些?
常见组件:
- SpringCloudNetflix:
-
- Eureka:注册中心,可以用Zookeeper和Consul代替
- Ribbon:负载均衡器
- Hystrix:断路器
- Zuul:网关,可以用SpringCloudGateway代替
- SpringCloudBus:消息总线,默认基于RabbitMQ和Kafka实现
- SpringCloudConfig:统一配置中心,分为server端和client端
- SpringCloudStrean:数据流处理,默认基于RabbitMQ和Kafka
- SpringCloudSleuth:结合SpringCloudZipkin实现链路追踪
3.RabbitMQ常见面试题
3.1.你们公司为什么选择了RabbitMQ产品,而不是RocketMQ和Kafka(问区别)?
这个问题其实时问3种MQ的差别,先看一张图:
特性 | RabbitMQ | RocketMQ | kafka |
开发语言 | erlang | java | scala |
单机吞吐量 | 万级 | 10万级 | 10万级 |
时效性 | us级 | ms级 | ms级以内 |
可用性 | 高(主从架构) | 非常高(分布式架构) | 非常高(分布式架构) |
功能特性 | 基于erlang开发,所以并发能力很强,性能极其好,延时很低;管理界面较丰富 | MQ功能比较完备,扩展性佳 | 只支持主要的MQ功能,像一些消息查询,消息回溯等功能没有提供,毕竟是为大数据准备的,在大数据领域应用广。 |
中小型软件公司,建议选RabbitMQ,原因:
- erlang语言天生具备高并发的特性,而且他的管理界面用起来十分方便。
- RabbitMQ的社区十分活跃,可以解决开发过程中遇到的bug,这点对于中小型公司来说十分重要。
- 不考虑rocketmq和kafka的原因是,一方面中小型软件公司不如互联网公司,数据量没那么大,选消息中间件,应首选功能比较完备的,所以kafka排除。
- 不考虑rocketmq的原因是,rocketmq是阿里出品,如果阿里放弃维护rocketmq,中小型公司一般抽不出人来进行rocketmq的定制化开发,因此不推荐。
RabbitMQ的缺点:
- 虽然RabbitMQ是开源的,然而国内有几个能定制化开发erlang的程序员呢
如果有大数据的需求,例如日志记录、程序运行链路追踪的记录,可以使用Kafka来做MQ,毕竟吞吐量比较大。
3.2.在项目中哪些地方使用了MQ,解决了什么问题?
这个问题主要问的时MQ的作用,包括下列几点:
- 解耦合
- 流量削峰
- 异步执行
1)解耦合
例如项目中 商品微服务对商品完成了增删改,需要对索引库数据、商品的静态页做处理。但是不能在商品微服务嵌入代码,这样会出现耦合。
此时,可以利用MQ来解耦,让商品微服务发送消息通知,而相关的其它系统监听MQ即可:
2)流量削峰
数据库的并发能力有限,往往称为业务执行的性能瓶颈。
例如我们的服务只能支持500的并发,然而又每秒1000甚至更高的服务流量涌入,服务肯定会崩溃的。
此时,利用MQ来作为缓冲,就像大坝一样,高并发流量涌入,先放到MQ中缓存起来,后续系统再慢慢取出并处理即可:
3)异步调用
如果一个业务执行中,需要调用多个其它服务,业务链路很长,同步调用的用时就时多个服务执行的总耗时,如图:
但是,我们如果再B系统执行完成后,利用MQ通知系统C和系统D去完成,直接返回结果给用户,就可以减少业务耗时。这样就把同步阻塞调用,变成了异步调用:
3.2.如何保证MQ的高可用
RabbitMQ底层时基于Erlang语言,对分布式支持较好。并且官方也给出了搭建镜像机器的方式,可以把队列及其中的数据同步到镜像节点中,当队列所在节点故障时,镜像队列可以继续提供服务。
另外,MQ数据可以持久化,当节点恢复时,可以恢复数据。
而Kafka和RocketMQ是通过主从集群方案来实现高可用的:
以rcoketMQ为例,他的集群就有多master 模式、多master多slave异步复制模式、多 master多slave同步双写模式。RocketMQ多master多slave模式部署架构图
kafka:
3.3.如何保证MQ的消息可靠性,防止消息丢失?
1)RabbitMQ
消息丢失的几种情况:
- 生产者发送消息时丢失:
-
- 利用RabbitMQ提供的publisher confirm机制
- MQ丢失消息:
-
- 消息持久化
- 镜像集群备份
- 消费者丢失消息:rabbitmq中消息消费后自动删除,不会永久保留
-
- 消费者的确认机制,在处理消息结束后,手动Acknowledge
2)kafka
这里先引一张kafka Replication的数据流向图
Producer在发布消息到某个Partition时,先通过ZooKeeper找到该Partition的Leader,然后无论该Topic的Replication Factor为多少(也即该Partition有多少个Replica),Producer只将该消息发送到该Partition的Leader。Leader会将该消息写入其本地Log。每个Follower都从Leader中pull数据。 针对上述情况,得出如下分析
- 生产者丢数据 在kafka生产中,基本都有一个leader和多个follwer。follwer会去同步leader的信息。因此,为了避免生产者丢数据,做如下两点配置
-
- 第一个配置要在producer端设置acks=all。这个配置保证了,follwer同步完成后,才认为消息发送成功。
- 在producer端设置retries=MAX,一旦写入失败,这无限重试
- 消息队列丢数据 针对消息队列丢数据的情况,无外乎就是,数据还没同步,leader就挂了,这时zookpeer会将其他的follwer切换为leader,那数据就丢失了。针对这种情况,应该做两个配置。
-
- replication.factor参数,这个值必须大于1,即要求每个partition必须有至少2个副本
- min.insync.replicas参数,这个值必须大于1,这个是要求一个leader至少感知到有至少一个follower还跟自己保持联系
- 这两个配置加上上面生产者的配置联合起来用,基本可确保kafka不丢数据
- 消费者丢数据 这种情况一般是自动提交了offset,然后你处理程序过程中挂了。kafka以为你处理好了
-
- offset:指的是kafka的topic中的每个消费组消费的下标。简单的来说就是一条消息对应一个offset下标,每次消费数据的时候如果提交offset,那么下次消费就会从提交的offset加一那里开始消费。 比如一个topic中有100条数据,我消费了50条并且提交了,那么此时的kafka服务端记录提交的offset就是49(offset从0开始),那么下次消费的时候offset就从50开始消费。
- 解决方案也很简单,改成手动提交即可。
3.4.如何防止MQ消息的重复消费?
消息重复消费产生的原因:
- 因为网络故障,导致生产者确认机制失败,生产者重发消息
- 因为网络故障,导致生产者确认机制失败,MQ重新投递消息
解决思路:保证处理消息接口的幂等性:
- 某些接口天生幂等,例如查询请求,无需处理
- 某些接口天生不幂等,比如新增,还有某些接口的修改功能
-
- 能根据具体的业务或状态来确定的,在消费端通过业务判断是否执行过,例如新增订单,看看订单ID是否已经存在
- 对于无法通过业务判断的,我们可以为每一条消息设置全局唯一id,保存到数据库或其它地方。消息处理前对ID进行判断即可
3.5.如何解决MQ的消息堆积问题?
通过同一个队列多消费者监听,实现消息的争抢,加快消息消费速度。
3.6.如何保证MQ消息的有序性?
某个业务发出了3条消息,要求这3条消息按照发送时的顺序执行。
- 业务同时对并发要求不高:
-
- 保证消息发送时有序同步发送
- 保证消息发送被同一个队列接收
- 保证一个队列只有一个消费者
- 业务同时对并发要求较高:
-
- 满足上述第一个场景的条件
- 可以有多个队列
- 有时序要求的一组消息,通过hash方式分派到一个固定队列
4.Redis相关问题
4.1.Redis与Memcache的区别?
- redis支持更丰富的数据类型(支持更复杂的应用场景):Redis不仅仅支持简单的k/v类型的数据,同时还提供list,set,zset,hash等数据结构的存储。memcache支持简单的数据类型,String。
- Redis支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而Memecache把数据全部存在内存之中。
- 集群模式:memcached没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 redis 目前是原生支持 cluster 模式的.
- Redis使用单线程:Memcached是多线程,非阻塞IO复用的网络模型;Redis使用单线程的多路 IO 复用模型。
4.2.Redis的持久化方案由哪些?
Redis主要提供了两种持久化机制,RDB和AOF:
1)RDB:
默认开启,满足条件时将内存中的数据快照到磁盘中,创建一个dump.rdb文件,Redis启动时再恢复到内存中。
Redis会单独创建fork()一个子进程,将当前父进程的数据库数据复制到子进程的内存中,然后由子进程写入到临时文件中,持久化的过程结束了,再用这个临时文件替换上次的快照文件,然后子进程退出,内存释放。
需要注意的是,每次快照持久化都会将主进程的数据库数据复制一遍,导致内存开销加倍,若此时内存不足,则会阻塞服务器运行,直到复制结束释放内存;都会将内存数据完整写入磁盘一次,所以如果数据量大的话,而且写操作频繁,必然会引起大量的磁盘I/O操作,严重影响性能,并且最后一次持久化后的数据可能会丢失;
RDB策略配置:
save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
2)AOF:
与快照持久化相比,AOF持久化 的实时性更好,因此已成为主流的持久化方案。默认情况下Redis没有开启AOF(append only file)方式的持久化,可以通过appendonly参数开启:
appendonly yes
开启AOF持久化后每执行一条会更改Redis中的数据的命令,Redis就会将该命令写入硬盘中的AOF文件。AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的,默认的文件名是appendonly.aof。
在Redis的配置文件中存在三种不同的 AOF 持久化方式,它们分别是:
appendfsync always #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
appendfsync everysec #每秒钟同步一次,显示地将多个写命令同步到硬盘
appendfsync no #让操作系统决定何时进行同步
为了兼顾数据和写入性能,用户可以考虑 appendfsync everysec选项 ,让Redis每秒同步一次AOF文件,Redis性能几乎没受到任何影响。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。
AOF是以日志的形式来记录每个写操作,只允许追加文件,但不允许改写文件,redis的设置中默认AOF的文件是64MB,当文件大小超过设定的阈值的时候,会触发重写(rewrite),重写会将多条写命令合成一条,或者合并为批量写命令,超时的数据也不会再写入aof文件,所以重写后aof文件会变小。
4.3.Redis的集群方式有哪些?
1)主从集群
主从集群,也是读写分离集群。一般都是一主多从方式。
Redis 的复制(replication)功能允许用户根据一个 Redis 服务器来创建任意多个该服务器的复制品,其中被复制的服务器为主服务器(master),而通过复制创建出来的服务器复制品则为从服务器(slave)。
只要主从服务器之间的网络连接正常,主从服务器两者会具有相同的数据,主服务器就会一直将发生在自己身上的数据更新同步 给从服务器,从而一直保证主从服务器的数据相同。
- 写数据时只能通过主节点完成
- 读数据可以从任何节点完成
- 如果配置了哨兵节点,当master宕机时,哨兵会从salve节点选出一个新的主。
主从集群分两种:
2)分片集群
主从集群中,每个节点都要保存所有信息,容易形成木桶效应。并且当数据量较大时,单个机器无法满足需求。此时我们就要使用分片集群了
集群特征:
- 每个节点都保存不同数据
- 所有的redis节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽.
- 节点的fail是通过集群中超过半数的节点检测失效时才生效.
- 客户端与redis节点直连,不需要中间proxy层连接集群中任何一个可用节点都可以访问到数据
- redis-cluster把所有的物理节点映射到[0-16383]slot(插槽)上,实现动态伸缩
为了保证Redis中每个节点的高可用,我们还可以给每个节点创建replication(slave节点),如图:
出现故障时,主从可以及时切换:
4.4.Redis的分片集群如何做到集群动态伸缩(hash槽原理)
创建集群时,Redis会将16384个插槽分配到不同的节点上。
redis集群存储数据时,会根据key计算插槽值,利用key的有效部分使用CRC16算法计算出哈希值,再将哈希值对16384取余,得到插槽值。
插槽在哪个节点,数据就跟随到哪个几点,因此数据时跟插槽绑定,而不是具体的机器。
当集群中有新的机器加入,我们可以重新分配插槽,Redis会自动把对应插槽的数据同步到新的节点。
4.5.Redis的常用数据类型有哪些?
支持多种类型的数据结构,主要区别是value存储的数据格式不同:
- string:最基本的数据类型,二进制安全的字符串,最大512M。
- list:按照添加顺序保持顺序的字符串列表。
- set:无序的字符串集合,不存在重复的元素。
- sorted set:已排序的字符串集合。
- hash:key-value对格式
4.6.Redis是单线程,为什么并发能力这么强?
答:
单线程可以带来下列好处:
1)绝大部分请求是纯粹的内存操作(非常快速)
2)采用单线程,避免了不必要的上下文切换和竞争条件
3)采用非阻塞IO,利用了IO多路复用的特性。
简述IO多路复用的模型:
首先,Redis线程模型包括下面几个概念:
- 套接字(Socket):用户请求套接字对象
- I/O 多路复用程序:监听客户端Socket,并未客户端绑定事件处理器
- 文件事件分派器(dispatcher):根据客户端目前状态不同,分发不同的事件处理器处理
- 事件处理器:处理用户具体操作事件,包括:接收套接字(accept)、读取(read)、写入(write)、关闭(close)等操作的处理器
如图:
4.7.聊一下Redis事务机制
Redis事务功能是通过MULTI、EXEC、DISCARD和WATCH 四个原语实现的。Redis会将一个事务中的所有命令序列化,然后按顺序执行。但是Redis事务不支持回滚操作,命令运行出错后,正确的命令会继续执行。
- MULTI: 用于开启一个事务,它总是返回OK。 MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中
- EXEC:执行所有事务块内的命令。返回事务块内所有命令的返回值,按命令执行的先后顺序排列。 当操作被打断时,返回空值 nil
- DISCARD:清空事务队列,并放弃执行事务, 并且客户端会从事务状态中退出
- WATCH:Redis的乐观锁机制,利用compare-and-set(CAS)原理,可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行
4.8.Redis的Key过期策略
Redis是内存存储,如果key永不过期,就会导致内存占用越来越多,因此会采用:定期删除、惰性删除结合内存的淘汰机制来清理内存。
- 定期删除:redis默认每隔100ms检查,是否有过期的key,有过期key则删除。需要说明的是,redis不是每个100ms将所有的key检查一次,而是随机抽取进行检查,因为如果对全部key检查,会占用过多资源。因此,没有抽查到的key就不会删除,这样内存占用依然会越来越多
- 惰性删除:当获取某个key的时候,redis会检查一下key是否有过期时间,是否已经过期,如果过期就会删除
但是依据上面的两种策略,依然会有漏网之鱼(比如过期了,一直没有被抽查到,也没有人访问的key),这样内存也会耗尽。
因此当redis内存不足时,会采用淘汰机制,删除一些Key,包括下面的策略:
- volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
- volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
- allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
- no-enviction:禁止驱逐数据,新写入操作会报错
注意:如果没有设置 过期时间的key, 并且淘汰机制是volatile-lru, volatile-random 和 volatile-ttl 中的一个,那么就无法实现淘汰,与no-eviction基本上一致。
4.9.Redis在项目中的哪些地方有用到,解决什么问题?
(1)共享session
在分布式系统下,服务会部署在不同的tomcat,因此多个tomcat的session无法共享,以前存储在session中的数据无法实现共享,可以用redis代替session,解决分布式系统间数据共享问题。
(2)数据缓存
Redis采用内存存储,读写效率较高。我们可以把数据库的访问频率高的热点数据存储到redis中,这样用户请求时优先从redis中读取,减少数据库压力,提高并发能力。
(3)异步队列
Reids在内存存储引擎领域的一大优点是提供 list 和 set 操作,这使得Redis能作为一个很好的消息队列平台来使用。而且Redis中还有pub/sub这样的专用结构,用于1对N的消息通信模式。
(4)排行榜/计数器
Redis在内存中对数字进行递增或递减的操作实现的非常好。集合(Set)和有序集合(Sorted Set)也使得我们在执行这些操作的时候变的非常简单,Redis只是正好提供了这两种数据结构。
(5)分布式锁
Redis中的乐观锁机制,可以帮助我们实现分布式锁的效果,用于解决分布式系统下的多线程安全问题
4.10.Redis的缓存击穿(热点Key问题)、缓存雪崩、缓存穿透问题及解决方案
1)缓存穿透
一般的缓存系统,都是按照key去缓存查询,如果不存在对应的value,就应该去后端系统查找(比如DB)。一些恶意的请求会故意查询不存在的key,请求量很大,就会对后端系统造成很大的压力。这就叫做缓存穿透。
如何避免?
- 对查询结果为空的情况也进行缓存,缓存时间设置短一点,或者该key对应的数据insert了之后重置缓存。
- 利用布隆过滤器,对不存在的key进行判断过滤。
2)缓存雪崩
缓存雪崩,是指在某一个时间段,缓存集中过期失效。对这批数据的访问查询,都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰。
解决方案:
- 数据分类分批处理:采取不同分类数据,缓存不同周期
- 热点数据缓存时间长一些,冷门数据缓存时间短一些
- 避免redis节点宕机引起雪崩,搭建主从集群,保证高可用
3)缓存击穿
缓存击穿,是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。
解决思路:热点的key永不过期即可。
4.11.Redis实现分布式锁
分布式锁要满足的条件:
- 多进程互斥:同一时刻,只有一个进程可以获取锁
- 保证锁可以释放:任务结束或出现异常,锁一定要释放,避免死锁
- 阻塞锁(可选):获取锁失败时可否重试
- 重入锁(可选):获取锁的代码递归调用时,依然可以获取锁
1)最基本的分布式锁:
利用Redis的setnx命令,这个命令的特征时如果多次执行,只有第一次执行会成功,可以实现互斥的效果。但是为了保证服务宕机时也可以释放锁,需要利用expire命令给锁设置一个有效期
setnx lock thread-01 # 尝试获取锁 expire lock 10 # 设置有效期
面试官问题1:如果expire之前服务宕机怎么办?
要保证setnx和expire命令的原子性。redis的set命令可以满足:
set key value [NX] [EX time]
需要添加nx和ex的选项:
- NX:与setnx一致,第一次执行成功
- EX:设置过期时间
面试官问题2:释放锁的时候,如果自己的锁已经过期了,此时会出现安全漏洞,如何解决?
在锁中存储当前进程和线程标识,释放锁时对锁的标识判断,如果是自己的则删除,不是则放弃操作。
但是这两步操作要保证原子性,需要通过Lua脚本来实现。
if redis.call("get",KEYS[1]) == ARGV[1] then redis.call("del",KEYS[1]) end
2)可重入分布式锁
如果有重入的需求,则除了在锁中记录进程标识,还要记录重试次数,流程如下:
下面我们假设锁的key为“lock”,hashKey是当前线程的id:“threadId”,锁自动释放时间假设为20
获取锁的步骤:
- 1、判断lock是否存在 EXISTS lock
-
- 存在,说明有人获取锁了,下面判断是不是自己的锁
-
- 判断当前线程id作为hashKey是否存在:HEXISTS lock threadId
-
- 不存在,说明锁已经有了,且不是自己获取的,锁获取失败,end
- 存在,说明是自己获取的锁,重入次数+1:HINCRBY lock threadId 1,去到步骤3
- 2、不存在,说明可以获取锁,HSET key threadId 1
- 3、设置锁自动释放时间,EXPIRE lock 20
释放锁的步骤:
- 1、判断当前线程id作为hashKey是否存在:HEXISTS lock threadId
-
- 不存在,说明锁已经失效,不用管了
- 存在,说明锁还在,重入次数减1:HINCRBY lock threadId -1,获取新的重入次数
- 2、判断重入次数是否为0:
-
- 为0,说明锁全部释放,删除key:DEL lock
- 大于0,说明锁还在使用,重置有效时间:EXPIRE lock 20
对应的Lua脚本如下:
首先是获取锁:
local key = KEYS[1]; -- 锁的key local threadId = ARGV[1]; -- 线程唯一标识 local releaseTime = ARGV[2]; -- 锁的自动释放时间 if(redis.call('exists', key) == 0) then -- 判断是否存在 redis.call('hset', key, threadId, '1'); -- 不存在, 获取锁 redis.call('expire', key, releaseTime); -- 设置有效期 return 1; -- 返回结果 end; if(redis.call('hexists', key, threadId) == 1) then -- 锁已经存在,判断threadId是否是自己 redis.call('hincrby', key, threadId, '1'); -- 不存在, 获取锁,重入次数+1 redis.call('expire', key, releaseTime); -- 设置有效期 return 1; -- 返回结果 end; return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败
然后是释放锁:
local key = KEYS[1]; -- 锁的key local threadId = ARGV[1]; -- 线程唯一标识 local releaseTime = ARGV[2]; -- 锁的自动释放时间 if (redis.call('HEXISTS', key, threadId) == 0) then -- 判断当前锁是否还是被自己持有 return nil; -- 如果已经不是自己,则直接返回 end; local count = redis.call('HINCRBY', key, threadId, -1); -- 是自己的锁,则重入次数-1 if (count > 0) then -- 判断是否重入次数是否已经为0 redis.call('EXPIRE', key, releaseTime); -- 大于0说明不能释放锁,重置有效期然后返回 return nil; else redis.call('DEL', key); -- 等于0说明可以释放锁,直接删除 return nil; end;
3)高可用的锁
面试官问题:redis分布式锁依赖与redis,如果redis宕机则锁失效。如何解决?
此时大多数同学会回答说:搭建主从集群,做数据备份。
这样就进入了陷阱,因为面试官的下一个问题就来了:
面试官问题:如果搭建主从集群做数据备份时,进程A获取锁,master还没有把数据备份到slave,master宕机,slave升级为master,此时原来锁失效,其它进程也可以获取锁,出现安全问题。如何解决?
关于这个问题,Redis官网给出了解决方案,使用RedLock思路可以解决:
在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。之前我们已经描述了在Redis单实例下怎么安全地获取和释放锁。我们确保将在每(N)个实例上使用此方法获取和释放锁。在这个样例中,我们假设有5个Redis master节点,这是一个比较合理的设置,所以我们需要在5台机器上面或者5台虚拟机上面运行这些实例,这样保证他们不会同时都宕掉。为了取到锁,客户端应该执行以下操作:获取当前Unix时间,以毫秒为单位。依次尝试从N个实例,使用相同的key和随机值获取锁。在步骤2,当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)。
5.秒杀相关
5.1.锁,减库存
1)悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
以减库存为例来说:
减库存先要查询库存,判断库存是否充足,然后再减库存。
如果我们查询库存后,判断库存是充足的,此时有人修改了库存,则我们的判断就不准确了,此时写数据就会又库存超卖的风险。
必须保证从查询开始就锁定数据,保证其它人无法操作,可以通过下列方式实现:
- 同步方法或Synchronized:适用于单点项目,分布式下会失效。
- 分布式锁:把整个减库存方法通过分布式锁锁定,不允许他人执行减库存逻辑
- 数据库锁:在执行查询语句时,在语句后面跟上 for update,则查询即会对数据加锁,其它人就无法操作了。
2)乐观锁
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的AtomicInteger就是使用了乐观锁的一种实现方式CAS实现的。
以减库存为例来说:
减库存先要查询库存,判断库存是否充足,然后再减库存。
如果我们查询库存后,判断库存是充足的,此时有人修改了库存,则我们的判断就不准确了,此时写数据就会又库存超卖的风险。
我们假设自己减库存时没有其它人在操作,不过执行sql时对库存检查即可。
方式1:版本号
在库存表添加version字段,每次修改数据都对version执行+1操做。
减库存步骤:
- 查询version值,例如此时version是20
- 执行减库存,在where条件中判断 version值是否等于查询到的version值:
-
- UPDATE tb_stock SET stock = stock - 1, version = 21 WHERE id = 101 AND version = 20
方式2:判断库存
因为库存本身就是数值,可以用库存来做检查,代替版本号:
- 查询库存,例如值是20,需要减库存值为 2
- 减库存:UPDATE tb_stock SET stock = 18 WHERE id = 101 AND stock = 20
这种方式可能有安全漏洞,即CAS这ABA问题,比如我查询的时候是20,有人购买了一个商品,变成了19,然后又有人退货,库存恢复为20。我们认为库存没变,其实此时已经有人修改了数据了。
方式3:无符号数
库存是数字,如果我们把库存变成无符号数字,则数据库默认不能为负,如果减库存传入的值为负数,数据库直接报错,因此减库存时无需做特殊判断,直接减库存即可。
3)使用场景
乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
5.2.秒杀思路
秒杀问题的难点:
- 高并发,服务端tomcat并发能力有限
- 减库存多线程执行容易超卖
- 下单业务流程、业务链路较长,耗时较久,服务QPS低
- 秒杀页面请求量大
- 秒杀机器人防范
具体实现:
5.2.1.前端优化
前端是直接与用户交互的地方,并发最高,一般有下面手段去处理:
1)页面静态化
将秒杀商品页面静态化处理,少量动态数据通过ajax异步加载,访问商品页面无需去数据库查询商品信息,大大提高页面加载的速度。
2)CDN服务
静态化可以让页面响应速度增加,但是如果我们的静态资源服务器压力过大,也可以考虑购买CDN服务,将静态资源部署到CDN服务,一方面提高响应速度,另一方面减轻对服务端压力
3)秒杀限流
秒杀按钮点击时,不立即向服务端发送请求,而是要求回答验证问题答案,回答结束才发送秒杀请求。好处有2点:
- 用户回答问题耗时不同,把用户发送请求分散到不同事件段
- 限制秒杀机器人或爬虫的恶意访问
4)动态秒杀按钮
为了避免秒杀开始前有人提前获取秒杀地址并编写秒杀机器人,我们可以把秒杀按钮利用JS绑定,秒杀开始前对应的JS文件内容设置为点击后禁止发送请求。
秒杀开始时,我们再修改对应的JS文件内容,填写真实发送请求地址,这样开始前不会有人知道秒杀的地址信息。
5)避免重复连续点击
点击秒杀后,按钮禁用,一定时间后开启使用
5.2.2.网关
如果采用Nginx作为网关,则可以再Nginx中对用户请求限流,只放行部分用户请求到达微服务群。
5.2.3.微服务
1)限流
RateLimiter是guava提供的基于令牌桶算法的限流实现类,通过调整生成token的速率来限制用户频繁访问秒杀页面,从而达到防止超大流量冲垮系统。(令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
2)预减库存
请求到达服务端,并发依然很高,数据库直接处理肯定难以接受。
我们可以把秒杀的商品库存存入Redis,利用Redis中的库存判断秒杀商品是否充足,再Redis中完成抢购资格判断、减库存行为。
但是,尽管redis单线程运行,执行Redis的Java代码依然有线程安全风险,所以为了保证redis中减库存判断的安全性,这里推荐使用Lua脚本编写相关逻辑,保证代码执行的原子性。
3)流量削峰,异步写数据
经过Redis的判断处理,单个商品放行的请求数量基本就是库存剩余量,请求大大减少,但是如果参与秒杀商品较多,用户并发依然很高,数据库可能依然难以处理,所以还需要把下的业务异步执行,实现流量削峰。
用户在redis中获取下单资格后,不要去执行下单逻辑,而是把用户及资格信息发送到MQ中,然后就返回用户抢购成功的结果。
此时服务端下单的业务监听RabbitMQ,逐个处理MQ中的下单消息,利用MQ来缓存高并发的流量。变同步写数据为异步写数据,大大缩短业务链路,提高并发。
流程图:
监听到MQ后的处理逻辑,关键时如何防止库存超卖,这一点我们在上面的5.1中已经讲过,不再赘述。