# 微服务

# Nacos 的服务注册表结构是怎样的

# 参考话术

Nacos 采用了数据的分级存储模型,最外层是 Namespace,用来隔离环境。然后是 Group,用来对服务分组。接下来就是服务(Service)了,一个服务包含多个实例,但是可能处于不同机房,因此 Service 下有多个集群(Cluster),Cluster 下是不同的实例(Instance)。

对应到 Java 代码中,Nacos 采用了一个多层的 Map 来表示。结构为 Map<String, Map<String, Service>>,其中最外层 Map 的 key 就是 namespaceId,值是一个 Map。内层 Map 的 key 是 group 拼接 serviceName,值是 Service 对象。Service 对象内部又是一个 Map,key 是集群名称,值是 Cluster 对象。而 Cluster 对象内部维护了 Instance 的集合。

image-20210925215305446

# Nacos 如何支撑内部数十万注册压力

# 参考话术

Nacos 内部接收到注册的请求时,不会立即写数据,而是将服务注册的任务放入一个阻塞队列就立即相应给客户端,然后利用线程池读取阻塞队列中的任务,异步来完成实例更新,从而提高并发写能力

# Nacos 如何避免读写冲突

# 参考话术

Nacos 在更新实例列表时,会采用 CopyOnWrite 技术,首先将实例列表拷贝一份,然后更新拷贝的实例列表,再用更新后的列表来覆盖旧的列表

这样在更新过程中,就不会对实例列表的请求产生影响,也不会出现脏读问题了

# Nacos 与 Eureka 的区别有哪些

# 参考话术

Nacos 与 Eureka 有相同点,也有不同之处

  • 接口方式:Nacos 与 Eureka 都对外暴露了 Rest 风格的 API 接口,用来实现服务注册、发现等功能
  • 实例类型:Nacos 的实例有永久和临时之分,而 Eureka 只支持临时实例
  • 健康检测:Nacos 对临时实例采用心跳模式检测,对永久实例采用主动请求来检测,Eureka 只支持心跳模式
  • 服务发现:Nacos 支持临时拉取和订阅推送服务两种模式,Eureka 只支持定时拉取模式

# Sentinel 的限流与 Gateway 的限流有什么差别

# 参考话术

限流算法创建有三种实现:滑动时间窗口、令牌桶算法、漏桶算法,Gateway 则采用了基于 Redis 实现的令牌桶算法

  • 默认限流是基于滑动时间窗口算法
  • 排队等待的限流模式则基于漏桶算法
  • 热点参数限流则是基于令牌桶算法

# Sentinel 的线程隔离与 Hystix 的线程隔离有什么差别

# 参考话术

Hystix 默认是基于线程池实现的线程隔离,每一个被隔离的业务都要创建一个独立的线程池,线程过多会带来额外的 CPU 开销,性能一般,但是隔离性更强

Sentinel 是基于信号量(计数器)实现的线程隔离,不用创建线程池,性能较好,但是隔离性一般

# RabbitMQ

# 为什么选择 RabbitMQ 而不选择其它 MQ

image-20210925220034702

# 参考话术

kafka 是以吞吐量高而闻名,不过其数据稳定性一般,而且无法保证信息有序性,我们公司的日志收集也有使用,业务模块中则使用 RabbitMQ

阿里巴巴的 RocketMQ 基于 Kakfa 的原理,弥补了 Kakfa 的缺点,继承了其高吞吐的优势,其客户端目前以 Java 为主,但是我们担心阿里巴巴开源产品的稳定性,所以就没有使用

RabbitMQ 基于面向并发的语言 Erlang 开发,吞吐量不如 Kafka,但是对于普通需求够用了,而且消息可靠性较好,并且消息延迟极低,集群搭建比较方便,支持多种协议,并且有各种语言客户端,比较灵活,Spring 对 RabbitMQ 的支持比较好,使用起来比较方便

# RabbitMQ 如何保证消息不丢失

# 参考话术

  • 生产者发送消息时可能因为网络问题导致消息没有到达交换机:
    • RabbitMQ 提供了 publisher confirm 机制
      • 生产者发送消息后,可以编写 ConfirmCallback 函数
      • 消息成功到达交换机后,RabbitMQ 会调用 ConfirmCallback 通知消息的发送者,返回 ACK
      • 消息如果未到达交换机,RabbitMQ 也会调用 ConfirmCallback 通知消息的发送者,返回 NACK
      • 消息超时未发送成功也会抛出异常
  • 消息到达交换机后,如果未能到达队列,也会导致消息丢失:
    • RabbitMQ 提供了 publisher return 机制
      • 生产者可以定义 ReturnCallback 函数
      • 消息到达交换机,未到达队列,RabbitMQ 会调用 ReturnCallback 通知发送者,告知失败原因
  • 消息到达队列后,MQ 宕机也可能导致丢失消息:
    • RabbitMQ 提供了持久化功能,集群的主从备份功能
      • 消息持久化,RabbitMQ 会将交换机、队列、消息持久化到磁盘,宕机重启可以恢复消息
      • 镜像集群,仲裁队列,都可以提供主从备份功能,主节点宕机,从节点会自动切换为主,数据依然在
  • 消息投递给消费者后,如果消费者处理不当,也可能导致消息丢失
    • SpringAMQP 基于 RabbitMQ 提供了消费者确认机制、消费者重试机制,消费者失败处理策略:
      • 消费者的确认机制:
        • 消费者处理消息成功,未出现异常时,Spring 返回 ACK 给 RabbitMQ,消息才被移除
        • 消费者处理消息失败,抛出异常,宕机,Spring 返回 NACK 或者不返回结果,消息不被异常
      • 消费者重试机制:
        • 默认情况下,消费者处理失败时,消息会再次回到 MQ 队列,然后投递给其它消费者。Spring 提供的消费者重试机制,则是在处理失败后不返回 NACK,而是直接在消费者本地重试。多次重试都失败后,则按照消费者失败处理策略来处理消息。避免了消息频繁入队带来的额外压力。
      • 消费者失败策略:
        • 当消费者多次本地重试失败时,消息默认会丢弃。
        • Spring 提供了 Republish 策略,在多次重试都失败,耗尽重试次数后,将消息重新投递给指定的异常交换机,并且会携带上异常栈信息,帮助定位问题。

# RabbitMQ 如何避免消息堆积

# 参考话术

# 因消息发送过快产生消息堆积解决方案

  • 提高消费者处理速度
  • 增加更多消费者
  • 增加队列消息存储上限

# 提高消费者处理速度

消费者处理速度是由业务代码决定的,所以我们能做的事情包括:

  • 尽可能优化业务代码,提高业务性能
  • 接收到消息后,开启线程池,并发处理多个消息

优点:成本低,改改代码即可

缺点:开启线程池会带来额外的性能开销,对于高频、低时延的任务不合适。推荐任务执行周期较长的业务

# 增加更多消费者

一个队列绑定多个消费者,共同争抢任务,自然可以提供消息处理的速度

优点:能用钱解决的问题都不是问题。实现简单粗暴

缺点:问题是没有钱。成本太高

# 增加队列消息存储上限

在 RabbitMQ 的 1.8 版本后,加入了新的队列模式:Lazy Queue

这种队列不会将消息保存在内存中,而是在收到消息后直接写入磁盘中,理论上没有存储上限。可以解决消息堆积问题

优点:磁盘存储更安全;存储无上限;避免内存存储带来的 Page Out 问题,性能更稳定

缺点:磁盘存储受到 IO 性能的限制,消息时效性不如内存模式,但影响不大

# RabbitMQ 如何保证信息有序性

# 参考话术

其实 RabbitMQ 是队列存储,天然具备先进先出的特点,只要消息的发送是有序的,那么理论上接收也是有序的。不过当一个队列绑定了多个消费者时,可能出现消息轮询投递给消费者的情况,而消费者的处理顺序就无法保证了

# 保证有序性方法

  • 保证消息发送的有序性
  • 保证一组有序的消息都发送到同一个队列
  • 保证一个队列只包含一个消费者

# 如何防止 MQ 消息被重复消费

# 参考话术

消息重复消费的原因多种多样,不可避免。所以只能从消费者端入手,只要能保证消息处理的幂等性就可以确保消息不被重复消费

  • 给每一条消息都添加一个唯一 id,在本地记录消息表及消息状态,处理消息时基于数据库表的 id 唯一性做判断
  • 同样是记录消息表,利用消息状态字段实现基于乐观锁的判断,保证幂等
  • 基于业务本身的幂等性。比如根据 id 的删除、查询业务天生幂等;新增、修改等业务可以考虑基于数据库 id 唯一性、或者乐观锁机制确保幂等。本质与消息表方案类似

# 如何保证 RabbitMQ 高可用

# 参考话术

  • 做好交换机、队列、消息的持久化
  • 搭建 RabbitMQ 的镜像集群,做好主从备份。当然也可以使用仲裁队列代替镜像集群

# 使用 MQ 可以解决哪些问题

# 参考话术

  • 解耦合:将几个业务关联的微服务调用修改为基于 MQ 的异步通知,可以解除微服务之间的业务耦合。同时还提高了业务性能。
  • 流量削峰:将突发的业务请求放入 MQ 中,作为缓冲区。后端的业务根据自己的处理能力从 MQ 中获取消息,逐个处理任务。流量曲线变的平滑很多
  • 延迟队列:基于 RabbitMQ 的死信队列或者 DelayExchange 插件,可以实现消息发送后,延迟接收的效果

# Redis

# Redis 与 Memcache 区别

# 参考话术

  • redis支持更丰富的数据类型 (支持更复杂的应用场景):Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。memcache 支持简单的数据类型,String。
  • Redis支持数据的持久化 ,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memecache 把数据全部存在内存之中。
  • 集群模式 :memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 redis 目前是原生支持 cluster 模式的.
  • Redis使用单线程 :Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。

1574821356723

# Redis 单线程问题

# 参考话术

Redis 采用单线程如何保证高并发?

  1. 完全基于内存
  2. 数据结构简单,对数据操作也简单
  3. 使用多路 I/O 复用模型,充分利用 CPU 资源

这样做有什么好处?

  • 代码更清晰,处理逻辑更简单
  • 不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为锁而导致的性能消耗
  • 不存在多进程或者多线程导致的 CPU 切换,充分利用 CPU 资源

# Redis 的持久化方案有哪些

# RDB 持久化

# 流程

RDB 持久化可以使用 save 或 bgsave,为了不阻塞主进程业务,一般都使用 bgsave,流程:

  • Redis 进程会 fork 出一个子进程(与父进程内存数据一致)。
  • 父进程继续处理客户端请求命令
  • 由子进程将内存中的所有数据写入到一个临时的 RDB 文件中。
  • 完成写入操作之后,旧的 RDB 文件会被新的 RDB 文件替换掉。

# RDB 持久化配置

  • save 60 10000 :如果在 60 秒内有 10000 个 key 发生改变,那就执行 RDB 持久化。
  • stop-writes-on-bgsave-error yes :如果 Redis 执行 RDB 持久化失败(常见于操作系统内存不足),那么 Redis 将不再接受 client 写入数据的请求。
  • rdbcompression yes :当生成 RDB 文件时,同时进行压缩。
  • dbfilename dump.rdb :将 RDB 文件命名为 dump.rdb。
  • dir /var/lib/redis :将 RDB 文件保存在 /var/lib/redis 目录下。

当然在实践中,我们通常会将 stop-writes-on-bgsave-error 设置为 false ,同时让监控系统在 Redis 执行 RDB 持久化失败时发送告警,以便人工介入解决,而不是粗暴地拒绝 client 的写入请求。

# RDB 持久化的优点

  • RDB 持久化文件小,Redis 数据恢复时速度快
  • 子进程不影响父进程,父进程可以持续处理客户端命令
  • 子进程 fork 时采用 copy-on-write 方式,大多数情况下,没有太多的内存消耗,效率比较好

# RDB 持久化缺点

  • 子进程 fork 时采用 copy-on-write 方式,如果 Redis 此时写操作较多,可能导致额外的内存占用,甚至内存溢出
  • RDB 文件压缩会减小文件体积,但通过时会对 CPU 有额外的消耗
  • 如果业务场景很看重数据的持久性 (durability),那么不应该采用 RDB 持久化。譬如说,如果 Redis 每 5 分钟执行一次 RDB 持久化,要是 Redis 意外奔溃了,那么最多会丢失 5 分钟的数据

# AOF 持久化

# 介绍

可以使用 appendonly yes 配置项来开启 AOF 持久化。Redis 执行 AOF 持久化时,会将接收到的写命令追加到 AOF 文件的末尾,因此 Redis 只要对 AOF 文件中的命令进行回放,就可以将数据库还原到原先的状态。
  与 RDB 持久化相比,AOF 持久化的一个明显优势就是,它可以提高数据的持久性 (durability)。因为在 AOF 模式下,Redis 每次接收到 client 的写命令,就会将命令 write() 到 AOF 文件末尾。
  然而,在 Linux 中,将数据 write() 到文件后,数据并不会立即刷新到磁盘,而会先暂存在 OS 的文件系统缓冲区。在合适的时机,OS 才会将缓冲区的数据刷新到磁盘(如果需要将文件内容刷新到磁盘,可以调用 fsync()fdatasync() )。
  通过 appendfsync 配置项,可以控制 Redis 将命令同步到磁盘的频率:

  • always :每次 Redis 将命令 write() 到 AOF 文件时,都会调用 fsync() ,将命令刷新到磁盘。这可以保证最好的数据持久性,但却会给系统带来极大的开销。
  • no :Redis 只将命令 write() 到 AOF 文件。这会让 OS 决定何时将命令刷新到磁盘。
  • everysec :除了将命令 write() 到 AOF 文件,Redis 还会每秒执行一次 fsync() 。在实践中,推荐使用这种设置,一定程度上可以保证数据持久性,又不会明显降低 Redis 性能。

然而,AOF 持久化并不是没有缺点的:Redis 会不断将接收到的写命令追加到 AOF 文件中,导致 AOF 文件越来越大。过大的 AOF 文件会消耗磁盘空间,并且导致 Redis 重启时更加缓慢。为了解决这个问题,在适当情况下,Redis 会对 AOF 文件进行重写,去除文件中冗余的命令,以减小 AOF 文件的体积。在重写 AOF 文件期间, Redis 会启动一个子进程,由子进程负责对 AOF 文件进行重写。
  可以通过下面两个配置项,控制 Redis 重写 AOF 文件的频率:

  • auto-aof-rewrite-min-size 64mb
  • auto-aof-rewrite-percentage 100

上面两个配置的作用:当 AOF 文件的体积大于 64MB,并且 AOF 文件的体积比上一次重写之后的体积大了至少一倍,那么 Redis 就会执行 AOF 重写

# 优缺点

  • 优点:
    • 持久化频率高,数据可靠性高
    • 没有额外的内存或 CPU 消耗
  • 缺点:
    • 文件体积大
    • 文件大导致服务数据恢复时效率较低

# 参考话术

Redis 提供了两种数据持久化的方式,一种是 RDB,另一种是 AOF。默认情况下,Redis 使用的是 RDB 持久化。

RDB 持久化文件体积较小,但是保存数据的频率一般较低,可靠性差,容易丢失数据。另外 RDB 写数据时会采用 Fork 函数拷贝主进程,可能有额外的内存消耗,文件压缩也会有额外的 CPU 消耗

AOF 持久化可以做到每秒钟持久化一次,可靠性高。但是持久化文件体积较大,导致数据恢复时读取文件时间较长,效率略低

# Redis 的集群方式有哪些

# 参考话术

Redis 集群可以分为主从集群分片集群

主从集群一般一主多从,主库用来写数据,从库用来读数据。结合哨兵,可以再主库宕机时从新选主,目的是保证 Redis 的高可用

分片集群是数据分片,我们会让多个 Redis 节点组成集群,并将 16383 个插槽分到不同的节点上。存储数据时利用对 key 做 hash 运算,得到插槽值后存储到对应的节点即可。因为存储数据面向的是插槽而非节点本身,因此可以做到集群动态伸缩。目的是让 Redis 能存储更多数据

# 主从集群

主从集群,也是读写分离集群。一般都是一主多从方式。

Redis 的复制(replication)功能允许用户根据一个 Redis 服务器来创建任意多个该服务器的复制品,其中被复制的服务器为主服务器(master),而通过复制创建出来的服务器复制品则为从服务器(slave)

只要主从服务器之间的网络连接正常,主从服务器两者会具有相同的数据,主服务器就会一直将发生在自己身上的数据更新同步 给从服务器,从而一直保证主从服务器的数据相同。

  • 写数据时只能通过主节点完成
  • 读数据可以从任何节点完成
  • 如果配置了 哨兵节点 ,当 master 宕机时,哨兵会从 salve 节点选出一个新的主

主从集群分两种:

1574821993599 1574822026037

带有哨兵的集群:

1574822077190

# 分片集群

主从集群中,每个节点都要保存所有信息,容易形成木桶效应。并且当数据量较大时,单个机器无法满足需求。此时我们就要使用分片集群了。

1574822184467

集群特征:

  • 每个节点都保存不同数据

  • 所有的 redis 节点彼此互联 (PING-PONG 机制), 内部使用二进制协议优化传输速度和带宽.

  • 节点的 fail 是通过集群中超过半数的节点检测失效时才生效.

  • 客户端与 redis 节点直连,不需要中间 proxy 层连接集群中任何一个可用节点都可以访问到数据

  • redis-cluster 把所有的物理节点映射到 [0-16383] slot(插槽)上,实现动态伸缩

为了保证 Redis 中每个节点的高可用,我们还可以给每个节点创建 replication(slave 节点),如图:

1574822584357

出现故障时,主从可以及时切换:

1574822602109

# Redis 常用数据类型有哪些

  • string:最基本的数据类型,二进制安全的字符串,最大 512M。
  • list:按照添加顺序保持顺序的字符串列表。
  • set:无序的字符串集合,不存在重复的元素。
  • sorted set:已排序的字符串集合。
  • hash:key-value 对格式

# Redis 的事务机制

# 介绍

Redis 事务功能是通过 MULTI、EXEC、DISCARD 和 WATCH 四个原语实现的。Redis 会将一个事务中的所有命令序列化,然后按顺序执行。但是 Redis 事务不支持回滚操作,命令运行出错后,正确的命令会继续执行。

  • MULTI : 用于开启一个事务,它总是返回 OK。 MULTI 执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个待执行命令队列
  • EXEC :按顺序执行命令队列内的所有命令。返回所有命令的返回值。事务执行过程中,Redis 不会执行其它事务的命令。
  • DISCARD :清空命令队列,并放弃执行事务, 并且客户端会从事务状态中退出
  • WATCH :Redis 的乐观锁机制,利用 compare-and-set(CAS)原理,可以监控一个或多个键,一旦其中有一个键被修改,之后的事务就不会执行

# 使用事务时会遇上的两种错误

  • 执行 EXEC 之前,入队的命令可能会出错。比如说,命令可能会产生语法错误(参数数量错误,参数名错误,等等),或者其他更严重的错误,比如内存不足(如果服务器使用 maxmemory 设置了最大内存限制的话)。
    • Redis 2.6.5 开始,服务器会对命令入队失败的情况进行记录,并在客户端调用 EXEC 命令时,拒绝执行并自动放弃这个事务。
  • 命令可能在 EXEC 调用之后失败。举个例子,事务中的命令可能处理了错误类型的键,比如将列表命令用在了字符串键上面,诸如此类。
    • 即使事务中有某个 / 某些命令在执行时产生了错误, 事务中的其他命令仍然会继续执行,不会回滚。

# 为什么 Redis 不支持回滚

  • Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面:这也就是说,从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中
  • 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速

鉴于没有任何机制能避免程序员自己造成的错误, 并且这类错误通常不会在生产环境中出现, 所以 Redis 选择了更简单、更快速的无回滚方式来处理事务

# 参考话术

Redis 事务其实是把一系列 Redis 命令放入队列,然后批量执行,执行过程中不会有其它事务来打断。不过与关系型数据库的事务不同,Redis 事务不支持回滚操作,事务中某个命令执行失败,其它命令依然会执行

为了弥补不能回滚的问题,Redis 会在事务入队时就检查命令,如果命令异常则会放弃整个事务

因此,只要程序员编程是正确的,理论上说 Redis 会正确执行所有事务,无需回滚

# 事务执行一半 Redis 宕机怎么办

Redis 有持久化机制,因为可靠性问题,我们一般使用 AOF 持久化。事务的所有命令也会写入 AOF 文件,但是如果在执行 EXEC 命令之前,Redis 已经宕机,则 AOF 文件中事务不完整。使用 redis-check-aof 程序可以移除 AOF 文件中不完整事务的信息,确保服务器可以顺利启动

# Redis 的 Key 过期策略

# 为什么需要内存回收?

  • 在 Redis 中,set 指令可以指定 key 的过期时间,当过期时间到达以后,key 就失效了
  • Redis 是基于内存操作的,所有的数据都是保存在内存中,一台机器的内存是有限且很宝贵的

# 过期删除策略

# 定时删除

对于每一个设置了过期时间的 key 都会创建一个定时器,一旦到达过期时间就立即删除。该策略可以立即清除过期的数据,对内存较友好,但是缺点是占用了大量的 CPU 资源去处理过期的数据,会影响 Redis 的吞吐量和响应时间

# 惰性删除

当访问一个 key 时,才判断该 key 是否过期,过期则删除。该策略能最大限度地节省 CPU 资源,但是对内存却十分不友好。有一种极端的情况是可能出现大量的过期 key 没有被再次访问,因此不会被清除,导致占用了大量的内存

在计算机科学中,懒惰删除(英文:lazy deletion)指的是从一个散列表(也称哈希表)中删除元素的一种方法。在这个方法中,删除仅仅是指标记一个元素被删除,而不是整个清除它。被删除的位点在插入时被当作空元素,在搜索之时被当作已占据

# 定期删除

每隔一段时间,扫描 Redis 中过期 key 字典,并清除部分过期的 key。该策略是前两者的一个折中方案,还可以通过调整定时扫描的时间间隔和每次扫描的限定耗时,在不同情况下使得 CPU 和内存资源达到最优的平衡效果

在 Redis 中, 同时使用了定期删除和惰性删除 。不过 Redis 定期删除采用的是随机抽取的方式删除部分 Key,因此不能保证过期 key 100% 的删除

# 内存淘汰策略

Redis 的内存淘汰策略,是指内存达到 maxmemory 极限时,使用某种算法来决定清理掉哪些数据,以保证新数据的存入

  • noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间( server.db[i].dict )中,移除最近最少使用的 key(这个是最常用的)
  • allkeys-random:当内存不足以容纳新写入数据时,在键空间( server.db[i].dict )中,随机移除某个 ke
  • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间( server.db[i].expires )中,移除最近最少使用的 key
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间( server.db[i].expires )中,随机移除某个 key
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间( server.db[i].expires )中,有更早过期时间的 key 优先移

在配置文件中,通过 maxmemory-policy 可以配置要使用哪一个淘汰机制

# 什么时候会进行淘汰

Redis 会在每一次处理命令的时候(processCommand 函数调用 freeMemoryIfNeeded)判断当前 redis 是否达到了内存的最大限制,如果达到限制,则使用对应的算法去处理需要删除的 key

在淘汰 key 时,Redis 默认最常用的是 LRU 算法(Latest Recently Used)。Redis 通过在每一个 redisObject 保存 lru 属性来保存 key 最近的访问时间,在实现 LRU 算法时直接读取 key 的 lru 属性

具体实现时,Redis 遍历每一个 db,从每一个 db 中随机抽取一批样本 key,默认是 3 个 key,再从这 3 个 key 中,删除最近最少使用的 key

# 参考话术

Redis 过期策略包含定期删除和惰性删除两部分。定期删除是在 Redis 内部有一个定时任务,会定期删除一些过期的 key。惰性删除是当用户查询某个 Key 时,会检查这个 Key 是否已经过期,如果没过期则返回用户,如果过期则删除

但是这两个策略都无法保证过期 key 一定删除,漏网之鱼越来越多,还可能导致内存溢出。当发生内存不足问题时,Redis 还会做内存回收。内存回收采用 LRU 策略,就是最近最少使用。其原理就是记录每个 Key 的最近使用时间,内存回收时,随机抽取一些 Key,比较其使用时间,把最老的几个删除

Redis 的逻辑是:最近使用过的,很可能再次被使用

# Redis 在项目中哪些地方有用到

# 共享 Session

在分布式系统下,服务会部署在不同的 tomcat,因此多个 tomcat 的 session 无法共享,以前存储在 session 中的数据无法实现共享,可以用 redis 代替 session,解决分布式系统间数据共享问题

# 数据缓存

Redis 采用内存存储,读写效率较高。我们可以把数据库的访问频率高的热点数据存储到 redis 中,这样用户请求时优先从 redis 中读取,减少数据库压力,提高并发能力

# 异步队列

Reids 在内存存储引擎领域的一大优点是提供 list 和 set 操作,这使得 Redis 能作为一个很好的消息队列平台来使用。而且 Redis 中还有 pub/sub 这样的专用结构,用于 1 对 N 的消息通信模式

# 分布式锁

Redis 中的乐观锁机制,可以帮助我们实现分布式锁的效果,用于解决分布式系统下的多线程安全问题

# Redis 的缓存击穿、缓存雪崩、缓存穿透

# 缓存穿透

# 缓存穿透介绍

正常情况下,我们去查询数据都是存在。那么请求去查询一条压根儿数据库中根本就不存在的数据,也就是缓存和数据库都查询不到这条数据,但是请求每次都会打到数据库上面去。这种查询不存在数据的现象我们称为缓存穿透

# 穿透带来的问题

试想一下,如果有黑客会对你的系统进行攻击,拿一个不存在的 id 去查询数据,会产生大量的请求到数据库去查询。可能会导致你的数据库由于压力过大而宕掉

# 解决方案

  • 缓存空值:之所以会发生穿透,就是因为缓存中没有存储这些空数据的 key。从而导致每次查询都到数据库去了。那么我们就可以为这些 key 对应的值设置为 null 丢到缓存里面去。后面再出现查询这个 key 的请求的时候,直接返回 null 。这样,就不用在到数据库中去走一圈了,但是别忘了设置过期时间
  • BloomFilter(布隆过滤):将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被 这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力。在缓存之前在加一层 BloomFilter ,在查询的时候先去 BloomFilter 去查询 key 是否存在,如果不存在就直接返回,存在再走查缓存 -> 查 DB

# 参考话术

缓存穿透有两种解决方案:其一是把不存在的 key 设置 null 值到缓存中。其二是使用布隆过滤器,在查询缓存前先通过布隆过滤器判断 key 是否存在,存在再去查询缓存

设置 null 值可能被恶意针对,攻击者使用大量不存在的不重复 key ,那么方案一就会缓存大量不存在 key 数据。此时我们还可以对 Key 规定格式模板,然后对不存在的 key 做正则规范匹配,如果完全不符合就不用存 null 值到 redis,而是直接返回错误

# 缓存击穿

# 缓存击穿介绍

key 可能会在某些时间点被超高并发地访问,是一种非常 “热点” 的数据。这个时候,需要考虑一个问题:缓存被 “击穿” 的问题

当这个 key 在失效的瞬间,redis 查询失败,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞

# 解决方案

  • 使用互斥锁 (mutex key):mutex,就是互斥。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去 load db,而是先使用 Redis 的 SETNX 去 set 一个互斥 key,当操作返回成功时,再进行 load db 的操作并回设缓存;否则,就重试整个 get 缓存的方法。SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现互斥的效果
  • 软过期:也就是逻辑过期,不使用 redis 提供的过期时间,而是业务层在数据中存储过期时间信息。查询时由业务程序判断是否过期,如果数据即将过期时,将缓存的时效延长,程序可以派遣一个线程去数据库中获取最新的数据,其他线程这时看到延长了的过期时间,就会继续使用旧数据,等派遣的线程获取最新数据后再更新缓存

# 参考话术

缓存击穿主要担心的是某个 Key 过期,更新缓存时引起对数据库的突发高并发访问。因此我们可以在更新缓存时采用互斥锁控制,只允许一个线程去更新缓存,其它线程等待并重新读取缓存

# 缓存雪崩

# 缓存雪崩介绍

缓存雪崩,是指在某一个时间段,缓存集中过期失效。对这批数据的访问查询,都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰

# 解决方案

  • 数据分类分批处理:采取不同分类数据,缓存不同周期
  • 相同分类数据:采用固定时长加随机数方式设置缓存
  • 热点数据缓存时间长一些,冷门数据缓存时间短一些
  • 避免 redis 节点宕机引起雪崩,搭建主从集群,保证高可用

# 参考话术

解决缓存雪崩问题的关键是让缓存 Key 的过期时间分散。因此我们可以把数据按照业务分类,然后设置不同过期时间。相同业务类型的 key,设置固定时长加随机数。尽可能保证每个 Key 的过期时间都不相同

另外,Redis 宕机也可能导致缓存雪崩,因此我们还要搭建 Redis 主从集群及哨兵监控,保证 Redis 的高可用

# 缓存冷热数据分离

# 介绍

Redis 使用的是内存存储,当需要海量数据存储时,成本非常高

经过调研发现,当前主流 DDR3 内存和主流 SATA SSD 的单位成本价格差距大概在 20 倍左右,为了优化 redis 机器综合成本,我们考虑实现基于热度统计 的数据分级存储及数据在 RAM/FLASH 之间的动态交换,从而大幅度降低成本,达到性能与成本的高平衡

基本思路:基于 key 访问次数 (LFU) 的热度统计算法识别出热点数据,并将热点数据保留在 redis 中,对于无访问 / 访问次数少的数据则转存到 SSD 上,如果 SSD 上的 key 再次变热,则重新将其加载到 redis 内存中

# SSDB

需要在应用程序与缓存服务之间引入代理,实现 Redis 和 SSD 之间的切换

image-20200521115702956

# Redis 实现分布式锁

# 分布式锁满足条件

  • 多进程互斥:同一时刻,只有一个进程可以获取锁
  • 保证锁可以释放:任务结束或出现异常,锁一定要释放,避免死锁
  • 阻塞锁(可选):获取锁失败时可否重试
  • 重入锁(可选):获取锁的代码递归调用时,依然可以获取锁

# 最基本的分布式锁

利用 Redis 的 setnx 命令,这个命令的特征时如果多次执行,只有第一次执行会成功,可以实现 互斥 的效果。但是为了保证服务宕机时也可以释放锁,需要利用 expire 命令给锁设置一个有效期

setnx lock thread-01 # 尝试获取锁
expire lock 10 # 设置有效期

# 如果 expire 之前服务宕机怎么办?

set key value [NX] [EX time]

需要添加 nx 和 ex 的选项:

  • NX:与 setnx 一致,第一次执行成功
  • EX:设置过期时间

# 释放锁的时候,如果自己的锁已经过期了,此时会出现安全漏洞,如何解决?

在锁中存储当前进程和线程标识,释放锁时对锁的标识判断,如果是自己的则删除,不是则放弃操作

但是这两步操作要保证原子性,需要通过 Lua 脚本来实现

if redis.call("get",KEYS[1]) == ARGV[1] then
    redis.call("del",KEYS[1])
end

# 可重入分布式锁

流程

1574824172228

步骤

  • 判断 lock 是否存在 EXISTS lock
    • 存在,说明有人获取锁了,下面判断是不是自己的锁
      • 判断当前线程 id 作为 hashKey 是否存在: HEXISTS lock threadId
        • 不存在,说明锁已经有了,且不是自己获取的,锁获取失败,end
        • 存在,说明是自己获取的锁,重入次数 + 1: HINCRBY lock threadId 1 ,去到步骤 3
    • 不存在,说明可以获取锁, HSET key threadId 1
    • 设置锁自动释放时间, EXPIRE lock 20

释放锁的步骤:

  • 判断当前线程 id 作为 hashKey 是否存在: HEXISTS lock threadId
    • 不存在,说明锁已经失效,不用管了
    • 存在,说明锁还在,重入次数减 1: HINCRBY lock threadId -1 ,获取新的重入次数
  • 判断重入次数是否为 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;

# 高可用的锁

redis 分布式锁依赖与 redis,如果 redis 宕机则锁失效。如何解决

在 Redis 的分布式环境中,我们假设有 N 个 Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。之前我们已经描述了在 Redis 单实例下怎么安全地获取和释放锁。我们确保将在每(N) 个实例上使用此方法获取和释放锁。在这个样例中,我们假设有 5 个 Redis master 节点,这是一个比较合理的设置,所以我们需要在 5 台机器上面或者 5 台虚拟机上面运行这些实例,这样保证他们不会同时都宕掉。

为了取到锁,客户端应该执行以下操作:

  1. 获取当前 Unix 时间,以毫秒为单位
  2. 依次尝试从 N 个实例,使用相同的 key 和随机值获取锁。在步骤 2,当向 Redis 设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以避免服务器端 Redis 已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个 Redis 实例
  3. 客户端使用当前时间减去开始获取锁时间(步骤 1 记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是 3 个节点)的 Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功
  4. 如果取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤 3 计算的结果)
  5. 如果因为某些原因,获取锁失败(没有在至少 N/2+1 个 Redis 实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功)

# 如何实现数据库与缓存数据一致

  • 本地缓存同步:当前微服务的数据库数据与缓存数据同步,可以直接在数据库修改时加入对 Redis 的修改逻辑,保证一致。
  • 跨服务缓存同步:服务 A 调用了服务 B,并对查询结果缓存。服务 B 数据库修改,可以通过 MQ 通知服务 A,服务 A 修改 Redis 缓存数据
  • 通用方案:使用 Canal 框架,伪装成 MySQL 的 salve 节点,监听 MySQL 的 binLog 变化,然后修改 Redis 缓存数据
更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

Baozi 微信支付

微信支付

Baozi 支付宝

支付宝

Baozi 微信

微信