java大厂技术面试第五课 MQ/Mysql/Redis

564 篇文章 141 订阅

第15讲:MQ 有什么作用?你都用过哪些 MQ 中间件?

在第 10 课时中讲过“手写消息队列”,当时粗略的讲了 Java API 中使用 Queue 实现自定义消息队列,以及使用 Delayed 实现延迟队列的示例;同时还讲了 RabbitMQ 中的一些基础概念。本课时我们将会更加深入的讲解 MQ(Message Queue,消息队列)中间件,以及这些热门中间件的具体使用。

我们本课时的面试题是,MQ 常见的使用场景有哪些?你都用过哪些 MQ 中间件?

典型回答

在介绍 MQ 的使用场景之前,先来回忆一下 MQ 的作用。MQ 可以用来实现削峰填谷,也就是使用它可以解决短时间内爆发式的请求任务,在不使用 MQ 的情况下会导致服务处理不过来,出现应用程序假死的情况,而使用了 MQ 之后可以把这些请求先暂存到消息队列中,然后进行排队执行,那么就不会出现应用程序假死的情况了,所以它的第一个应用就是商品秒杀以及产品抢购等使用场景,如下图所示:

图片1.png

使用 MQ 实现消息通讯

使用 MQ 可以作为消息通讯的实现手段,利用它可以实现点对点的通讯或者多对多的聊天室功能。

点对点的消息通讯如下图所示:

图片2.png

多对多的消息通讯如下图所示:

图片3.png

使用 MQ 实现日志系统

可使用 MQ 实现对日志的采集和转发,比如有多个日志写入到程序中,然后把日志添加到 MQ,紧接着由日志处理系统订阅 MQ,最后 MQ 将消息接收并转发给日志处理系统,这样就完成了日志的分析和保存功能,如下图所示:

图片4.png

常用的 MQ 中间件有 RabbitMQ、Kafka 和 Redis 等,其中 Redis 属于轻量级的消息队列,而 RabbitMQ、Kafka 属于比较成熟且比较稳定和高效的 MQ 中间件。

考点分析

MQ 属于中高级或优秀的程序员必备的技能,对于 MQ 中间件掌握的数量则是你技术广度和编程经验的直接体现信息之一。值得庆幸的是,关于 MQ 中间件的实现原理和使用方式都比较类似,因此如果开发者掌握一项 MQ 中间件再去熟悉其他 MQ 中间件时,会非常的容易。

MQ 相关的面试题还有这些:

  • MQ 的特点是什么?引入 MQ 中间件会带来哪些问题?
  • 常见的 MQ 中间件的优缺点分析。

知识扩展

MQ 的特点及注意事项

MQ 具有以下 5 个特点。

  • 先进先出:消息队列的顺序一般在入列时就基本确定了,最先到达消息队列的信息,一般情况下也会先转发给订阅的消费者,我们把这种实现了先进先出的数据结构称之为队列。
  • 发布、订阅工作模式生产者也就是消息的创建者,负责创建和推送数据到消息服务器;消费者也就是消息的接收方,用于处理数据和确认消息的消费;消息队列也是 MQ 服务器中最重要的组成元素之一,它负责消息的存储,这三者是 MQ 中的三个重要角色。而它们之间的消息传递与转发都是通过发布以及订阅的工作模式来进行的,即生产者把消息推送到消息队列,消费者订阅到相关的消息后进行消费,在消息非阻塞的情况下,此模式基本可以实现同步操作的效果。并且此种工作模式会把请求的压力转移给 MQ 服务器,以减少了应用服务器本身的并发压力。
  • 持久化:持久化是把消息从内存存储到磁盘的过程,并且在服务器重启或者发生宕机的情况下,重新启动服务器之后是保证数据不会丢失的一种手段,也是目前主流 MQ 中间件都会提供的重要功能。
  • 分布式:MQ 的一个主要特性就是要应对大流量、大数据的高并发环境,一个单体的 MQ 服务器是很难应对这种高并发的压力的,所以 MQ 服务器都会支持分布式应用的部署,以分摊和降低高并发对 MQ 系统的冲击。
  • 消息确认:消息消费确认是程序稳定性和安全性的一个重要考核指标,假如消费者在拿到消息之后突然宕机了,那么 MQ 服务器会误认为此消息已经被消费者消费了,从而造成消息丢失的问题,而目前市面上的主流 MQ 都实现了消息确认的功能,保证了消息不会丢失,从而保证了系统的稳定性。
引入 MQ 系统会带来的问题

任何系统的引入都是有两面性的,MQ 也不例外,在引入 MQ 之后,可能会带来以下两个问题。

  • 增加了系统的运行风险:引入 MQ 系统,则意味着新增了一套系统,并且其他的业务系统会对 MQ 系统进行深度依赖,系统部署的越多则意味着发生故障的可能性就越大,如果 MQ 系统挂掉的话可能会导致整个业务系统瘫痪。
  • 增加了系统的复杂度:引入 MQ 系统后,需要考虑消息丢失、消息重复消费、消息的顺序消费等问题,同时还需要引入新的客户端来处理 MQ 的业务,增加了编程的运维门槛,增加了系统的复杂性。

使用 MQ 需要注意的问题,不要过度依赖 MQ,比如发送短信验证码或邮件等功能,这种低频但有可能比较耗时的功能可以使用多线程异步处理即可,不用任何的功能都依赖 MQ 中间件来完成,但像秒杀抢购可能会导致超卖(也就是把货卖多了,库存变成负数了)等短时间内高并发的请求,此时建议使用 MQ 中间件。

常用的 MQ 中间件

常用的 MQ 中间件有 Redis、RabbitMQ、Kafka,下来我们分别来看看各自的作用。

Redis 轻量级的消息中间件

Redis 是一个高效的内存性数据库中间件,但使用 Redis 也可以实现消息队列的功能。

早期的 Redis(Redis 5.0 之前)是不支持消息确认的,那时候我们可以通过 List 数据类型的 lpush 和 rpop 方法来实现队列消息的存入和读取功能,或者使用 Redis 提供的发布订阅(pub/sub)功能来实现消息队列,但这种模式不支持持久化,List 虽然支持持久化但不能设置复杂的路由规则来匹配多个消息,并且他们二者都不支持消息消费确认。

于是在 Redis 5.0 之后提供了新的数据类型 Stream 解决了消息确认的问题,但它同样不能提供复杂的路由匹配规则,因此在业务不复杂的场景下可以尝试性的使用 Redis 提供的消息队列。

RabbitMQ

在第 10 课时中,我们对 RabbitMQ 有过初步的讲解,它是一个实现了标准的高级消息队列协议(AMQP,Advanced Message Queuing Protocol)的老牌开源消息中间件,最初起源于金融系统,后来被普遍应用在了其他分布式系统中,它支持集群部署,和多种客户端调用。

之前主要介绍了 RabbitMQ 的基础功能,本课时我们重点来看 RabbitMQ 集群相关的内容。

RabbitMQ 集群是由多个节点组成,但默认情况下每个节点并不是存储所有队列的完整拷贝,这是出于存储空间和性能的考虑,因为如果存储了队列的完整拷贝,那么就会有很多冗余的重复数据,并且在新增节点的情况下,不但没有新增存储空间,反而需要更大的空间来存储旧的数据;同样的道理,如果每个节点都保存了所有队列的完整信息,那么非查询操作的性能就会很慢,就会需要更多的网络带宽和磁盘负载来存储这些数据。

为了能兼顾性能和稳定性,RabbitMQ 集群的节点分为两种类型,即磁盘节点和内存节点,对于磁盘节点来说显然它的优势就是稳定,可以把相关数据保存下来,若 RabbitMQ 因为意外情况宕机,重启之后保证了数据不丢失;而内存节点的优势是快,因为是在内存中进行数据交换和操作,因此性能比磁盘节点要高出很多倍。

如果是单个 RabbitMQ 那么就必须要求是磁盘节点,否则当 RabbitMQ 服务器重启之后所有的数据都会丢失,这样显然是不能接受的。在 RabbitMQ 的集群中,至少需要一个磁盘节点,这样至少能保证集群数据的相对可靠性。

如果集群中的某一个磁盘节点崩溃了,此时整个 RabbitMQ 服务也不会处于崩溃的状态,不过部分操作会受影响,比如不能创建队列、交换器、也不能添加用户及修改用户权限,更不能添加和删除集群的节点等功能。

小贴士:对于 RabbitMQ 集群来说,我们启动集群节点的顺序应该是先启动磁盘节点再启动内存节点,而关闭的顺序正好和启动的顺序相反,不然可能会导致 RabbitMQ 集群启动失败或者是数据丢失等异常问题。

Kafka

Kafka 是 LinkedIn 公司开发的基于 ZooKeeper 的多分区、多副本的分布式消息系统,它于 2010 年贡献给了 Apache 基金会,并且成为了 Apache 的顶级开源项目。其中 ZooKeeper 的作用是用来为 Kafka 提供集群元数据管理以及节点的选举和发现等功能。

与 RabbitMQ 比较类似,一个典型的 Kafka 是由多个 Broker、多个生产者和消费者,以及 ZooKeeper 集群组成的,其中 Broker 可以理解为一个代理,Kafka 集群中的一台服务器称之为一个 Broker,其组成框架图如下所示:

image.png

Kafka VS RabbitMQ

Kafka(2.0.0)和 RabbitMQ(3.6.10)的区别主要体现在以下几点:

  • Kafka 支持消息回溯,它可以根据 Offset(消息偏移量)、TimeStamp(时间戳)等维度进行消息回溯,而 RabbitMQ 并不支持消息回溯;
  • Kafka 的消息消费是基于拉取数据的模式,也就是消费者主动向服务器端发送拉取消息请求,而 RabbitMQ 支持拉取数据模式和主动推送数据的模式,也就说 RabbitMQ 服务器会主动把消息推送给订阅的消费者;
  • 在相同配置下,Kafka 的吞吐量通常会比 RabbitMQ 高一到两个级别,比如在单机模式下,RabbitMQ 的吞吐量大概是万级别的处理能力,而 Kafka 则可以到达十万甚至是百万的吞吐级别;
  • Kafka 从 0.11 版本就开始支持幂等性了,当然所谓的幂等性指的是对单个生产者在单分区上的单会话的幂等操作,但对于全局幂等性则还需要结合业务来处理,比如,消费者在消费完一条消息之后没有来得及确认就发生异常了,等到恢复之后又得重新消费原来消费过的消息,类似这种情况,是无法在消息中间件层面来保证的,这个时候则需要引入更多的外部资源来保证全局幂等性,比如唯一的订单号、消费之前先做去重判断等;而 RabbitMQ 是没有幂等性功能支持的;
  • RabbitMQ 支持多租户的功能,也就是常说的 Virtual Host(vhost),每一个 vhost 相当于一个独立的小型 RabbitMQ 服务器,它们拥有自己独立的交换器、消息队列及绑定关系等,并且拥有自己独立权限,而且多个 vhost 之间是绝对隔离的,但 Kafka 并不支持多租户的功能。

Kafka 和 RabbitMQ 都支持分布式集群部署,并且都支持数据持久化和消息消费确认等 MQ 的核心功能,对于 MQ 的选型要结合自己团队本身的情况,从性能、稳定性及二次开发的难易程度等维度来进行综合的考量并选择。

小结

本课时我们讲了 MQ 的常见使用场景,以及常见的 MQ 中间件(Redis、RabbitMQ、Kafka)及其优缺点分析;同时还了解了 MQ 的五大特点:先进先出、发布和订阅的模式、持久化、分布式和消息确认等;接着讲了 MQ 引入对系统可能带来的风险;最后讲了 MQ 在使用时需要注意的问题。希望本课时对你整体了解 MQ 系统有所帮助。


第16讲:MySQL 的运行机制是什么?它有哪些引擎?

数据库是 Java 程序员面试必问的知识点之一,它和 Java 的核心面试点共同组成了一个完整的技术面试。而数据库一般泛指的就是 MySQL,因为 MySQL 几乎占据了数据库的半壁江山,即使有些公司没有使用 MySQL 数据库,如果你对 MySQL 足够精通的话,也是会被他们录取的。因为数据库的核心与原理基本是相通的,所以有了 MySQL 的基础之后,再去熟悉其他数据库也是非常快的,那么接下来的几个课时就让我们好好的学习一下 MySQL。

我们本课时的面试题是,MySQL 是如何运行的?说一下它有哪些引擎?

典型回答

MySQL 的执行流程是这样的,首先客户端先要发送用户信息去服务器端进行授权认证。如果使用的是命令行工具,通常需要输入如下信息:

mysql -h 主机名(IP) -u 用户名 -P 端口 -p

其中:

  • -h 表示要连接的数据库服务器的主机名或者 IP 信息;
  • -u 表示数据库的用户名称;
  • -P 表示数据库服务器的端口号,
  • 小写的 -p 表示需要输入数据库的密码。

具体使用示例,如下图所示:

image.png

当输入正确密码之后可以连接到数据库了,如果密码输入错误,则会提示“Access denied for user 'xxx'@'xxx' (using password: YES)”密码错误信息,如下图所示:

image (1).png

当连接服务器端成功之后就可以正常的执行 SQL 命令了,MySQL 服务器拿到 SQL 命令之后,会使用 MySQL 的分析器解析 SQL 指令,同时会根据语法分析器验证 SQL 指令,查询 SQL 指令是否满足 MySQL 的语法规则。如果不支持此语法,则会提示“SQL syntax”语法错误信息。

当分析器验证并解析 SQL 命令之后,会进入优化器阶段,执行生成计划,并设置相应的索引;当上面的这些步骤都执行完之后,就进入了执行器阶段,并开始正式执行 SQL 命令。同样在执行命令之前,它会先对你的执行命令进行权限查询,看看是否有操作某个表的权限,如果有相应的权限,执行器就去调用 MySQL 数据库引擎提供的接口,执行相应的命令;如果是非查询操作会记录对应的操作日志,再命令执行完成之后返回结果给客户端,这就是整个 MySQL 操作的完整流程。

需要注意的是,如果执行的是 select 语句并且是 MySQL 8.0 之前的版本的话,则会去 MySQL 的查询缓存中查看之前是否有执行过这条 SQL;如果缓存中可以查到,则会直接返回查询结果,这样查询性能就会提升很高。

整个 SQL 的执行流程,如下图所示:

Java面试 16.png

我们可以使用 SHOW ENGINES 命令来查看 MySQL 数据库使用的存储引擎,如下图所示:

image (3).png

常用的数据库引擎有 InnoDB、MyISAM、MEMORY 等,其中 InnoDB 支持事务功能,而 MyISAM 不支持事务,但 MyISAM 拥有较高的插入和查询的速度。而 MEMORY 是内存型的数据库引擎,它会将表中的数据存储到内存中,因为它是内存级的数据引擎,因此具备最快速的查询效率,但它的缺点是,重启数据库之后,所有数据都会丢失,因为这些数据是存放在内存中的。

考点分析

此面试题考察的是面试者对 MySQL 基础知识的掌握程度,以及对于 MySQL 引擎的了解程度,这些都是属于 MySQL 最核心的原理之一,也是面试中常见的面试问题,它一般作为数据库面试题的开始题目,和此面试题相关的面试点还有以下几个:

  • 查询缓存在什么问题?
  • 如何选择数据库的引擎?
  • InnoDB 自增索引的持久化问题。

知识扩展

1.查询缓存的利弊

MySQL 8.0 之前可以正常的使用查询缓存的功能,可通过“SHOW GLOBAL VARIABLES LIKE 'query_cache_type'”命令查询数据库是否开启了查询缓存的功能,它的结果值有以下三项:

  • OFF,关闭了查询缓存功能;
  • ON,开启了查询缓存功能;
  • DEMAND,在 sql 语句中指定 sql_cache 关键字才会有查询缓存,也就是说必须使用 sql_cache 才可以把该 select 语句的查询结果缓存起来,比如“select sql_cache name from token where tid=1010”语句。

开启和关闭查询缓存可以通过修改 MySQL 的配置文件 my.cnf 进行修改,它的配置项如下:

query_cache_type = ON

注意:配置被更改之后需要重启 MySQL 服务才能生效。

查询缓存的功能要根据实际的情况进行使用,建议设置为按需缓存(DEMAND)模式,因为查询缓存的功能并不是那么好用。比如我们设置了 query_cache_type = ON,当我们好不容易缓存了很多查询语句之后,任何一条对此表的更新操作都会把和这个表关联的所有查询缓存全部清空,那么在更新频率相对较高的业务中,查询缓存功能完全是一个鸡肋。因此,在 MySQL 8.0 的版本中已经完全移除了此功能,也就是说在 MySQL 8.0 之后就完全没有查询缓存这个概念和功能了。

2.如何选择数据库引擎

选择数据库引擎要从实际的业务情况入手,比如是否需要支持事务?是否需要支持外键?是否需要支持持久化?以及是否支持地理位置存储以及索引等方面进行综合考量。

我们最常用的数据库引擎是 InnoDB,它是 MySQL 5.5.5 之后的默认引擎,其优点是支持事务,且支持 4 种隔离级别。

  • 读未提交:也就是一个事务还没有提交时,它做的变更就能被其他事务看到。
  • 读已提交:指的是一个事务只有提交了之后,其他事务才能看得到它的变更。
  • 可重复读:此方式为默认的隔离级别,它是指一个事务在执行过程中(从开始到结束)看到的数据都是一致的,在这个过程中未提交的变更对其他事务也是不可见的。
  • 串行化:是指对同一行记录的读、写都会添加读锁和写锁,后面访问的事务必须等前一个事务执行完成之后才能继续执行,所以这种事务的执行效率很低。

InnoDB 还支持外键、崩溃后的快速恢复、支持全文检索(需要 5.6.4+ 版本)、集群索引,以及地理位置类型的存储和索引等功能。

MyISAM 引擎是 MySQL 原生的引擎,但它并不支持事务功能,这也是后来被 InnoDB 替代为默认引擎的主要原因。MyISAM 有独立的索引文件,因此在读取数据方面的性能很高,它也支持全文索引、地理位置存储和索引等功能,但不支持外键。

InnoDB 和 MyISAM 都支持持久化,但 MEMORY 引擎是将数据直接存储在内存中了,因此在重启服务之后数据就会丢失,但它带来的优点是执行速度很快,可以作为临时表来使用。

我们可以根据实际的情况设置相关的数据库引擎,还可以针对不同的表设置不同的数据引擎,只需要在创建表的时候指定 engine=引擎名称即可,SQL 代码如下:

create table student(
   id int primary key auto_increment,
   uname varchar(60),
   age int
) engine=Memory;
3.InnoDB 自增主键

在面试的过程中我们经常看到这样一道面试题:

在一个自增表里面一共有 5 条数据,id 从 1 到 5,删除了最后两条数据,也就是 id 为 4 和 5 的数据,之后重启的 MySQL 服务器,又新增了一条数据,请问新增的数据 id 为几?

我们通常的答案是如果表为 MyISAM 引擎,那么 id 就是 6,如果是 InnoDB 那么 id 就是 4。

但是这个情况在高版本的 InnoDB 中,也就是 MySQL 8.0 之后就不准确了,它的 id 就不是 4 了,而是 6 了。因为在 MySQL 8.0 之后 InnoDB 会把索引持久化到日志中,重启服务之后自增索引是不会丢失的,因此答案是 6,这个需要面试者注意一下。

小结

本课时我们讲了 MySQL 数据库运行流程的几个阶段,先从连接器授权,再到分析器进行语法分析。如果是 MySQL 8.0 之前的 select 语句可能会先查询缓存,如果有缓存则会直接返回结果给客户端,否则会从分析器进入优化器生成 SQL 的执行计划,然后交给执行器调用操作引擎执行相关的 SQL,再把结果返回给客户端。我们还讲了最常见的三种数据库引擎 InnoDB、MyISAM、MEMORY,以及它们的利弊分析。最后讲了 InnoDB 在高版本(8.0)之后可以持久化自增主键的小特性,希望可以帮助到你。


第17讲:MySQL 的优化方案有哪些?

性能优化(Optimize)指的是在保证系统正确性的前提下,能够更快速响应请求的一种手段。而且有些性能问题,比如慢查询等,如果积累到一定的程度或者是遇到急速上升的并发请求之后,会导致严重的后果,轻则造成服务繁忙,重则导致应用不可用。它对我们来说就像一颗即将被引爆的定时炸弹一样,时刻威胁着我们。因此在上线项目之前需要严格的把关,以确保 MySQL 能够以最优的状态进行运行。同时,在实际工作中还有面试中关于 MySQL 优化的知识点,都是面试官考察的重点内容。

我们本课时的面试题是,MySQL 的优化方案有哪些?

典型回答

MySQL 数据库常见的优化手段分为三个层面:SQL 和索引优化、数据库结构优化、系统硬件优化等,然而每个大的方向中又包含多个小的优化点,下面我们具体来看看。

1.SQL 和索引优化

此优化方案指的是通过优化 SQL 语句以及索引来提高 MySQL 数据库的运行效率,具体内容如下。

① 使用正确的索引

索引是数据库中最重要的概念之一,也是提高数据库性能最有效的手段之一,它的诞生本身就是为了提高数据查询效率的,就像字典的目录一样,通过目录可以很快找到相关的内容,如下图所示:

1.png
1.1.png

假如我们没有添加索引,那么在查询时就会触发全表扫描,因此查询的数据就会很多,并且查询效率会很低,为了提高查询的性能,我们就需要给最常使用的查询字段上,添加相应的索引,这样才能提高查询的性能。

小贴士:我们应该尽可能的使用主键查询,而非其他索引查询,因为主键查询不会触发回表查询,因此节省了一部分时间,变相的提高了查询的性能。

在 MySQL 5.0 之前的版本要尽量避免使用 or 查询,可以使用 union 或者子查询来替代,因为早期的 MySQL 版本使用 or 查询可能会导致索引失效,在 MySQL 5.0 之后的版本中引入了索引合并,简单来说就是把多条件查询,比如 or 或 and 查询的结果集进行合并交集或并集的功能,因此就不会导致索引失效的问题了。

避免在 where 查询条件中使用 != 或者 <> 操作符,因为这些操作符会导致查询引擎放弃索引而进行全表扫描。

适当使用前缀索引,MySQL 是支持前缀索引的,也就是说我们可以定义字符串的一部分来作为索引。我们知道索引越长占用的磁盘空间就越大,那么在相同数据页中能放下的索引值也就越少,这就意味着搜索索引需要的查询时间也就越长,进而查询的效率就会降低,所以我们可以适当的选择使用前缀索引,以减少空间的占用和提高查询效率。比如,邮箱的后缀都是固定的“@xxx.com”,那么类似这种后面几位为固定值的字段就非常适合定义为前缀索引。

② 查询具体的字段而非全部字段

要尽量避免使用 select *,而是查询需要的字段,这样可以提升速度,以及减少网络传输的带宽压力。

③ 优化子查询

尽量使用 Join 语句来替代子查询,因为子查询是嵌套查询,而嵌套查询会新创建一张临时表,而临时表的创建与销毁会占用一定的系统资源以及花费一定的时间,但 Join 语句并不会创建临时表,因此性能会更高。

④ 注意查询结果集

我们要尽量使用小表驱动大表的方式进行查询,也就是如果 B 表的数据小于 A 表的数据,那执行的顺序就是先查 B 表再查 A 表,具体查询语句如下:

select name from A where id in (select id from B);

⑤ 不要在列上进行运算操作

不要在列字段上进行算术运算或其他表达式运算,否则可能会导致查询引擎无法正确使用索引,从而影响了查询的效率。

⑥ 适当增加冗余字段

增加冗余字段可以减少大量的连表查询,因为多张表的连表查询性能很低,所有可以适当的增加冗余字段,以减少多张表的关联查询,这是以空间换时间的优化策略

2.数据库结构优化

① 最小数据长度

一般说来数据库的表越小,那么它的查询速度就越快,因此为了提高表的效率,应该将表的字段设置的尽可能小,比如身份证号,可以设置为 char(18) 就不要设置为 varchar(18)。

② 使用最简单数据类型

能使用 int 类型就不要使用 varchar 类型,因为 int 类型比 varchar 类型的查询效率更高。

③ 尽量少定义 text 类型

text 类型的查询效率很低,如果必须要使用 text 定义字段,可以把此字段分离成子表,需要查询此字段时使用联合查询,这样可以提高主表的查询效率。

④ 适当分表、分库策略

分表和分库方案也是我们经常说的垂直分隔(分表)和水平分隔(分库)。

分表是指当一张表中的字段更多时,可以尝试将一张大表拆分为多张子表,把使用比较高频的主信息放入主表中,其他的放入子表,这样我们大部分查询只需要查询字段更少的主表就可以完成了,从而有效的提高了查询的效率。

分库是指将一个数据库分为多个数据库。比如我们把一个数据库拆分为了多个数据库,一个主数据库用于写入和修改数据,其他的用于同步主数据并提供给客户端查询,这样就把一个库的读和写的压力,分摊给了多个库,从而提高了数据库整体的运行效率。

3.硬件优化

MySQL 对硬件的要求主要体现在三个方面:磁盘、网络和内存。

① 磁盘

磁盘应该尽量使用有高性能读写能力的磁盘,比如固态硬盘,这样就可以减少 I/O 运行的时间,从而提高了 MySQL 整体的运行效率。

磁盘也可以尽量使用多个小磁盘而不是一个大磁盘,因为磁盘的转速是固定的,有多个小磁盘就相当于拥有多个并行运行的磁盘一样。

② 网络

保证网络带宽的通畅(低延迟)以及够大的网络带宽是 MySQL 正常运行的基本条件,如果条件允许的话也可以设置多个网卡,以提高网络高峰期 MySQL 服务器的运行效率。

③ 内存

MySQL 服务器的内存越大,那么存储和缓存的信息也就越多,而内存的性能是非常高的,从而提高了整个 MySQL 的运行效率。

考点分析

MySQL 性能优化的方案很多,因此它可以全面考察的一个程序员的经验是否丰富。当然这个问题的回答也是可深可浅,不同的岗位对此问题的答案要求也是不同的,这个问题也可以引申出更多的面试问题,比如:

  • 联合索引需要注意什么问题?
  • 如何排查慢查询?

知识扩展

正确使用联合索引

使用了 B+ 树的 MySQL 数据库引擎,比如 InnoDB 引擎,在每次查询复合字段时是从左往右匹配数据的,因此在创建联合索引的时候需要注意索引创建的顺序。例如,我们创建了一个联合索引是 idx(name,age,sex),那么当我们使用,姓名+年龄+性别、姓名+年龄、姓名等这种最左前缀查询条件时,就会触发联合索引进行查询;然而如果非最左匹配的查询条件,例如,性别+姓名这种查询条件就不会触发联合索引。

当然,当我们已经有了(name,age)这个联合索引之后,一般情况下就不需要在 name 字段单独创建索引了,这样就可以少维护一个索引。

慢查询

慢查询通常的排查手段是先使用慢查询日志功能,查询出比较慢的 SQL 语句,然后再通过 explain 来查询 SQL 语句的执行计划,最后分析并定位出问题的根源,再进行处理。

慢查询日志指的是在 MySQL 中可以通过配置来开启慢查询日志的记录功能,超过 long_query_time 值的 SQL 将会被记录在日志中。我们可以通过设置“slow_query_log=1”来开启慢查询,它的开启方式有两种:

  • 通过 MySQL 命令行的模式进行开启,只需要执行“set global slow_query_log=1”即可,然而这种配置模式再重启 MySQL 服务之后就会失效;
  • 另一种方式可通过修改 MySQL 配置文件的方式进行开启,我们需要配置 my.cnf 中的“slow_query_log=1”即可,并且可以通过设置“slow_query_log_file=/tmp/mysql_slow.log”来配置慢查询日志的存储目录,但这种方式配置完成之后需要重启 MySQL 服务器才可生效。

需要注意的是,在开启慢日志功能之后,会对 MySQL 的性能造成一定的影响,因此在生产环境中要慎用此功能。

explain 执行计划的使用示例 SQL 如下:

explain select * from person where uname = 'Java';

它的执行结果如下图所示:

image (8).png

摘要说明如下表所示:

2.png

以上字段中最重要的就是 type 字段,它的所有值如下所示:

3.png

当 type 为 all 时,则表示全表扫描,因此效率会比较低,此时需要查看一下为什么会造成此种原因,是没有创建索引还是索引创建的有问题?以此来优化整个 MySQL 运行的速度。

小结

本课时我们从三个维度讲了 MySQL 的优化手段:SQL 和索引优化、数据库结构优化以及系统硬件优化等;同时深入到每个维度中,详细地介绍了 MySQL 具体的优化细节;最后我们讲了联合索引的最左匹配原则,以及慢查询的具体解决方案。


第18讲:关系型数据和文档型数据库有什么区别?

关系数据库(Relational Database)是建立在关系模型基础上的数据库,借助于几何代数等数学概念和方法来处理数据库中的数据。所谓关系模型是一对一、一对多或者多对多等关系,常见的关系型数据库有 Oracle、SQL Server、DB2、MySQL 等。

而文档型数据库是一种非关系型数据库,非关系型数据库(Not Only SQL,NoSQL)正好与关系型数据库相反,它不是建立在“关系模型”上的数据库。文档型数据库的典型代表是 MongoDB。

我们本课时的面试题是,关系型数据库和文档型数据库有什么区别?

典型回答

关系型数据库属于早期的传统型数据库,它有着标准化的数据模型,以及事务和持久化的支持、例如,关系型数据库都会支持的 ACID 特性,也就是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),具体含义如下。

  • 原子性(Atomicity):是指一个事务中的所有操作,要么全部完成、要么全部不完成,不会存在中间的状态。也就是说事务在正常的情况下会执行完成;异常的情况下,比如在执行的过程中如果出现问题,会回滚成最初的状态,而非中间状态。
  • 一致性(Consistency):是指事务从开始执行到结束执行之间的中间状态不会被其他事务看到。
  • 隔离性(Isolation):是指数据库允许多个事务同时对数据进行读写或修改的能力,并且整个过程对各个事务来说是相互隔离的。
  • 持久性(Durability):是指每次事务提交之后都不会丢失。

关系型数据库一般遵循三范式设计思想,具体内容如下。

第一范式(The First Normal Form,1NF):要求对属性的原子性,也就是说要求数据库中的字段需要具备原子性,不能再被拆分。

比如,用户表中有字段:用户 ID、用户名、电话;而其中电话又可以分为:家庭电话和移动电话等。因此,此表不符合第一范式,如下图所示:

image (9).png

第二范式(The Second Normal Form,2NF):例如订单详情表有这些字段:订单 ID、产品 ID、产品名称、产品单价、折扣。其中,订单 ID 和产品 ID 为联合主键,但这个表中的产品名称和产品单价两个字段只依赖产品 ID,和订单 ID 就没有任何关系了,因此这个表也不符合第二范式。

我们可以把原来的订单表拆分为订单表和产品表,其中订单表包含:订单 ID、产品 ID、折扣等字段;而产品表包含:产品 ID、产品名称、产品单价等字段。这样就消除了产品名称和产品单价多次重复出现的情况了,从而避免了冗余数据的产生。

image (10).png

第三范式(The Third Normal Form,3NF):想要满足第三范式必须先满足第二范式,第三范式要求所有的非主键字段必须直接依赖主键,且不存在传递依赖的情况。

例如,有一个学生表中包含了:学生 ID、姓名、所在学院 ID、学院电话、学院地址等字段。这个表的所有字段(除去主键字段)都完全依赖唯一的主键字段(学生 ID),所以符合第二范式。但它存在一个问题,学院电话、学院地址依赖非主键字段学院 ID,而不是直接依赖于主键,它是通过传递才依赖于主键,所以不符合第三范式。

我们可以把学生表分为两张表,一张是学生表包含了:学生 ID、姓名、所在学院 ID 等字段;另一张为学院表包含了:学院 ID、学院电话、学院地址等字段,这样就满足第三范式的要求了。

image (11).png

可以看出,使用三范式可以避免数据的冗余,而且在更新表操作时,只需要更新单张表就可以了。

但随着互联网应用的快速发展,我们需要应对日益复杂且快速迭代的数据库,以应对互联网快速发展的趋势,于是诞生了以 MongoDB 为代表的文档型数据库。它提供了更高效的读/写性能以及可自动容灾的数据库集群,还有灵活的数据库结构,从而给系统的数据库存储带来了更多可能 性。

当然 MongoDB 的诞生并不是为了替代关系型数据库,而是为系统的快速开发提供一种可能性,它和关系型数据库是一种互补的关系,可供开发者在不同的业务场景下选择相对应的数据库类型。

考点分析

本课时的面试题考察的是面试者对数据库整体概念的理解与区分,这个问题看似简单,但包含着众多小的知识点,面试者需要真正的理解关系型数据库和非关系型数据库以及文档型数据库之间的区别才能灵活应对。与之相关的面试题还有:

  • 非关系型数据库和文档型数据库有什么区别?
  • MongoDB 支持事务吗?

知识扩展

非关系型数据库 VS 文档型数据库

非关系型数据和文档型数据库属于包含关系,非关系型数据包含了文档型数据库,文档型数据库属于非关系型数据。

非关系型数据通常包含 3 种数据库类型:文档型数据库、键值型数据库和全文搜索型数据库,下面分别来看每种类型的具体用途。

1. 文档型数据库

文档型数据库以 MongoDB 和 Apache CouchDB 为代表,文档型数据库通常以 JSON 或者 XML 为格式进行数据存储。

以 MongoDB 为例,它是由 C++ 编写的一种面向文档的数据库管理系统,在 2007 年 10 月 由 10gen 团队所开发,并在 2009 年 2 月首度推出。MongoDB 是以二进制 JSON 格式存储数据的,MongoDB 对 JSON 做了一些优化,它支持了更多的数据类型,这种二进制存储的 JSON 我们也可以称之为 BSON(Binary JSON)

BSON 具备三个特点:轻量、可遍历以及高效,它的缺点是空间利用率不是很理想。MongoDB 使用 BSON 进行存储的另一个重要原因是 BSON 具备可遍历性。

MongoDB 存储结构示例如下:

{"_id":ObjectId(“57ce2d4cce8685a6fd9df3a3"),"name":"老王","email":['java@qq.com','java@163.com']}

其中,“_id”为 MongoDB 默认的主键字段,它会为我们生成一起全局唯一的 id 值,并且这个值在做数据分片时非常有用。

文档型数据库的使用场景如下。

  • 敏捷开发,因为 MongoDB 拥有比关系型数据库更快的开发速度,因此很多敏捷开发组织,包括纽约时报等都采用了 MongoDB 数据库。使用它可以有效地避免在增加和修改数据库带来的沟通成本,以及维护和创建数据库模型成本,使用 MongoDB 只需要在程序层面严格把关就行,程序提交的数据结构可以直接更新到数据库中,并不需要繁杂的设计数据库模型再生成修改语句等过程。
  • 日志系统,使用 MongoDB 数据库非常适合存储日志,日志对应到数据库中就是很多个文件,而 MongoDB 更擅长存储和查询文档,它提供了更简单的存储和更方便的查询功能。
  • 社交系统,使用 MongoDB 可以很方便的存储用户的位置信息,可以方便的实现查询附近的人以及附近的地点等功能。

2. 键值型数据库

键值数据库也就是 Key-Value 数据库,它的典型代表数据库是 RedisMemcached,而它们通常被当做非持久化的内存型数据库缓存来使用。当然 Redis 数据库是具备可持久化得能力的,但是开启持久化会降低系统的运行效率,因此在使用时需要根据实际的情况,选择开启或者关闭持久化的功能。

键值型数据库以极高的性能著称,且除了 Key-Value 字符串类型之外,还包含一些其他的数据类型。以 Redis 为例,它提供了字符串类型(String)、列表类型(List)、哈希表类型(Hash)、集合类型(Set)、有序集合类型(ZSet)等五种最常用的基础数据类型,还有管道类型(Pipeline)、地理位置类型(GEO)、基数统计类型(HyperLogLog)和流类型(Stream),并且还提供了消息队列的功能。

此数据库的优点是性能比较高,缺点是对事务的支持不是很好。

3. 全文搜索型数据库

传统的关系型数据库主要是依赖索引来实现快速查询功能的,而在全文搜索的业务下,索引很难满足查询的需求。因为全文搜索需要支持模糊匹配的,当数据量比较大的情况下,传递的关系型数据库的查询效率是非常低的;另一个原因是全文搜索需要支持多条件随意组合排序,如果要通过索引来实现的话,则需要创建大量的索引,而传统型数据库也很难实现,因此需要专门全文搜索引擎和相关的数据库才能实现此功能。

全文搜索型数据库以 ElasticSearch 和 Solr 为代表,它们的出现解决了关系型数据库全文搜索功能较弱的问题。

MongoDB 事务

MongoDB 在 4.0 之前是不支持事务的,不支持的原因也很简单,因为文档型数据库和传统的关系型数据库不一样,不需要满足三范式。文档型数据库之所以性能比较高的另一个主要原因,就是使用文档型数据库不用进行多表关联性查询,因为文档型数据库会把相关的信息存放到一张表中。因此,无需关联多表查询的 MongoDB,在这种情况下的查询性能是比较高的。

把所有相关的数据都放入一个表中,这也是 MongoDB 之前很长一段时间内不支持事务的原因,它可以保证单表操作的原子性,一条记录要么成功插入,要么插入失败,不会存在插入了一半的数据。因此,在这种设计思路下,MongoDB 官方认为“事务功能”的实现没有那么紧迫。

但在 MongoDB 4.0 之中正式添加了事务的功能,并且在 MongoDB 4.2 中实现了分布式事务的功能,至此 MongoDB 开启了支持事务之旅。

小结

本课时我们首先讲了关系型数据库的 ACID 特性以及设计时需要遵循的三范式设计思想;然后介绍了以 MongoDB 为代表的文档型数据库与关系型数据库的不同;最后还讲了 MongoDB 的事务功能,以及文档性数据库与非关系型数据库的关系,希望本课时的内容对你有帮助。


第19讲:Redis 的过期策略和内存淘汰机制有什么区别?

Redis 和 MySQL 是面试绕不过的两座大山,他们一个是关系型数据库的代表(MySQL),一个是键值数据库以及缓存中间件的一哥。尤其 Redis 几乎是所有互联网公司都在用的技术,比如国内的 BATJ、新浪、360、小米等公司;国外的微软、Twitter、Stack Overflow、GitHub、暴雪等公司。我从业了十几年,就职过 4、5 家公司,有的公司用 MySQL、有的用 SQL Server、甚至还有的用 Oracle 和 DB2,但缓存无一例外使用的都是 Redis,从某种程度上来讲 Redis 是普及率最高的技术,没有之一。

我们本课时的面试题是,Redis 是如何处理过期数据的?当内存不够用时 Redis 又是如何处理的?

典型回答

我们在新增 Redis 缓存时可以设置缓存的过期时间,该时间保证了数据在规定的时间内失效,可以借助这个特性来实现很多功能。比如,存储一定天数的用户(登录)会话信息,这样在一定范围内用户不用重复登录了,但为了安全性,需要在一定时间之后重新验证用户的信息。因此,我们可以使用 Redis 设置过期时间来存储用户的会话信息。

对于已经过期的数据,Redis 将使用两种策略来删除这些过期键,它们分别是惰性删除定期删除

惰性删除是指 Redis 服务器不主动删除过期的键值,而是当访问键值时,再检查当前的键值是否过期,如果过期则执行删除并返回 null 给客户端;如果没过期则正常返回值信息给客户端。

它的优点是不会浪费太多的系统资源,只是在每次访问时才检查键值是否过期。缺点是删除过期键不及时,造成了一定的空间浪费。

惰性删除的源码位于 src/db.c 文件的 expireIfNeeded 方法中,如下所示:

int expireIfNeeded(redisDb *db, robj *key) {
    // 判断键是否过期
    if (!keyIsExpired(db,key)) return 0;
    if (server.masterhost != NULL) return 1;
    /* 删除过期键 */
    // 增加过期键个数
    server.stat_expiredkeys++;
    // 传播键过期的消息
    propagateExpire(db,key,server.lazyfree_lazy_expire);
    notifyKeyspaceEvent(NOTIFY_EXPIRED,
        "expired",key,db->id);
    // server.lazyfree_lazy_expire 为 1 表示异步删除,否则则为同步删除
    return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
                                         dbSyncDelete(db,key);
}
// 判断键是否过期
int keyIsExpired(redisDb *db, robj *key) {
    mstime_t when = getExpire(db,key);
    if (when < 0) return 0; 
    if (server.loading) return 0;
    mstime_t now = server.lua_caller ? server.lua_time_start : mstime();
    return now > when;
}
// 获取键的过期时间
long long getExpire(redisDb *db, robj *key) {
    dictEntry *de;
    if (dictSize(db->expires) == 0 ||
       (de = dictFind(db->expires,key->ptr)) == NULL) return -1;
    serverAssertWithInfo(NULL,key,dictFind(db->dict,key->ptr) != NULL);
    return dictGetSignedIntegerVal(de);
}

惰性删除的执行流程如下图所示:

图片1.png

除了惰性删除之外,Redis 还提供了定期删除功能以弥补惰性删除的不足。

定期删除是指 Redis 服务器每隔一段时间会检查一下数据库,看看是否有过期键可以被清除。

默认情况下 Redis 定期检查的频率是每秒扫描 10 次,用于定期清除过期键。当然此值还可以通过配置文件进行设置,在 redis.conf 中修改配置“hz”即可,默认的值为“hz 10”。

小贴士:定期删除的扫描并不是遍历所有的键值对,这样的话比较费时且太消耗系统资源。Redis 服务器采用的是随机抽取形式,每次从过期字典中,取出 20 个键进行过期检测,过期字典中存储的是所有设置了过期时间的键值对。如果这批随机检查的数据中有 25% 的比例过期,那么会再抽取 20 个随机键值进行检测和删除,并且会循环执行这个流程,直到抽取的这批数据中过期键值小于 25%,此次检测才算完成。

定期删除的源码在 expire.c 文件的 activeExpireCycle 方法中,如下所示:

void activeExpireCycle(int type) {
    static unsigned int current_db = 0; /* 上次定期删除遍历到的数据库ID */
    static int timelimit_exit = 0;      
    static long long last_fast_cycle = 0; /* 上次执行定期删除的时间点 */
    int j, iteration = 0;
    int dbs_per_call = CRON_DBS_PER_CALL; // 需要遍历数据库的数量
    long long start = ustime(), timelimit, elapsed;
    if (clientsArePaused()) return;
    if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
        if (!timelimit_exit) return;
        // ACTIVE_EXPIRE_CYCLE_FAST_DURATION 快速定期删除的执行时长
        if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return;
        last_fast_cycle = start;
    }
    if (dbs_per_call > server.dbnum || timelimit_exit)
        dbs_per_call = server.dbnum;
    // 慢速定期删除的执行时长
    timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
    timelimit_exit = 0;
    if (timelimit <= 0) timelimit = 1;
    if (type == ACTIVE_EXPIRE_CYCLE_FAST)
        timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /* 删除操作花费的时间 */
    long total_sampled = 0;
    long total_expired = 0;
    for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
        int expired;
        redisDb *db = server.db+(current_db % server.dbnum);
        current_db++;
        do {
            // .......
            expired = 0;
            ttl_sum = 0;
            ttl_samples = 0;
            // 每个数据库中检查的键的数量
            if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
                num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;
            // 从数据库中随机选取 num 个键进行检查
            while (num--) {
                dictEntry *de;
                long long ttl;
                if ((de = dictGetRandomKey(db->expires)) == NULL) break;
                ttl = dictGetSignedInteger
                // 过期检查,并对过期键进行删除
                if (activeExpireCycleTryExpire(db,de,now)) expired++;
                if (ttl > 0) {
                    ttl_sum += ttl;
                    ttl_samples++;
                }
                total_sampled++;
            }
            total_expired += expired;
            if (ttl_samples) {
                long long avg_ttl = ttl_sum/ttl_samples;
                if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
                db->avg_ttl = (db->avg_ttl/50)*49 + (avg_ttl/50);
            }
            if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */
                elapsed = ustime()-start;
                if (elapsed > timelimit) {
                    timelimit_exit = 1;
                    server.stat_expired_time_cap_reached_count++;
                    break;
                }
            }
            /* 判断过期键删除数量是否超过 25% */
        } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
    }
    // .......
}

定期删除的执行流程,如下图所示:

图片2.png

小贴士:Redis 服务器为了保证过期删除策略不会导致线程卡死,会给过期扫描增加了最大执行时间为 25ms。

以上是 Redis 服务器对待过期键的处理方案,当 Redis 的内存超过最大允许的内存之后,Redis 会触发内存淘汰策略,这和过期策略是完全不同的两个概念,经常有人把二者搞混,这两者一个是在正常情况下清除过期键,一个是在非正常情况下为了保证 Redis 顺利运行的保护策略。

当 Redis 内存不够用时,Redis 服务器会根据服务器设置的淘汰策略,删除一些不常用的数据,以保证 Redis 服务器的顺利运行。

考点分析

本课时的面试题并非 Redis 的入门级面试题,需要面试者对 Redis 有一定的了解才能对答如流,并且 Redis 的过期淘汰策略和内存淘汰策略的概念比较类似,都是用于淘汰数据的。因此很多人会把二者当成一回事,但其实并不是,这个面试者特别注意一下,和此知识点相关的面试题还有以下这些:

  • Redis 内存淘汰策略有哪些?
  • Redis 有哪些内存淘汰算法?

知识扩展

Redis 内存淘汰策略

我们可以使用 config get maxmemory-policy 命令,来查看当前 Redis 的内存淘汰策略,示例代码如下:

127.0.0.1:6379> config get maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"

从上面的结果可以看出,当前 Redis 服务器设置的是“noeviction”类型的内存淘汰策略,那么这表示什么含义呢?Redis 又有几种内存淘汰策略呢?

在 4.0 版本之前 Redis 的内存淘汰策略有以下 6 种。

  • noeviction:不淘汰任何数据,当内存不足时,执行缓存新增操作会报错,它是 Redis 默认内存淘汰策略。
  • allkeys-lru:淘汰整个键值中最久未使用的键值。
  • allkeys-random:随机淘汰任意键值。
  • volatile-lru:淘汰所有设置了过期时间的键值中最久未使用的键值。
  • volatile-random:随机淘汰设置了过期时间的任意键值。
  • volatile-ttl:优先淘汰更早过期的键值。

可以看出我们上面示例使用的是 Redis 默认的内存淘汰策略“noeviction”。

而在 Redis 4.0 版本中又新增了 2 种淘汰策略:

  • volatile-lfu,淘汰所有设置了过期时间的键值中最少使用的键值;
  • allkeys-lfu,淘汰整个键值中最少使用的键值。

小贴士:从以上内存淘汰策略中可以看出,allkeys-xxx 表示从所有的键值中淘汰数据,而 volatile-xxx 表示从设置了过期键的键值中淘汰数据。

这个内存淘汰策略我们可以通过配置文件来修改,redis.conf 对应的配置项是“maxmemory-policy noeviction”,只需要把它修改成我们需要设置的类型即可。

需要注意的是,如果使用修改 redis.conf 的方式,当设置完成之后需要重启 Redis 服务器才能生效。

还有另一种简单的修改内存淘汰策略的方式,我们可以使用命令行工具输入“config set maxmemory-policy noeviction”来修改内存淘汰的策略,这种修改方式的好处是执行成功之后就会生效,无需重启 Redis 服务器。但它的坏处是不能持久化内存淘汰策略,每次重启 Redis 服务器之后设置的内存淘汰策略就会丢失。

Redis 内存淘汰算法

内存淘汰算法主要包含两种:LRU 淘汰算法和 LFU 淘汰算法。

LRU( Least Recently Used,最近最少使用)淘汰算法:是一种常用的页面置换算法,也就是说最久没有使用的缓存将会被淘汰。

LRU 是基于链表结构实现的,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要进行内存淘汰时,只需要删除链表尾部的元素即可。

Redis 使用的是一种近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是给现有的数据结构添加一个额外的字段,用于记录此键值的最后一次访问时间。Redis 内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值 (此值可配置) ,然后淘汰最久没有使用的数据。

LFU(Least Frequently Used,最不常用的)淘汰算法:最不常用的算法是根据总访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。

LFU 相对来说比 LRU 更“智能”,因为它解决了使用频率很低的缓存,只是最近被访问了一次就不会被删除的问题。如果是使用 LRU 类似这种情况数据是不会被删除的,而使用 LFU 的话,这个数据就会被删除。

Redis 内存淘汰策略使用了 LFU 和近 LRU 的淘汰算法,具体使用哪种淘汰算法,要看服务器是如何设置内存淘汰策略的,也就是要看“maxmemory-policy”的值是如何设置的。

小结

本课时我们讲了 Redis 的过期删除策略:惰性删除 + 定期删除;还讲了 Redis 的内存淘汰策略,它和过期策略是完全不同的两个概念,内存淘汰策略是当内存不够用时才会触发的一种机制,它在 Redis 4.0 之后提供了 8 种内存淘汰策略,这些淘汰策略主要使用了近 LRU 淘汰算法和 LFU 淘汰算法。


第20讲:Redis 怎样实现的分布式锁?

“锁”是我们实际工作和面试中无法避开的话题之一,正确使用锁可以保证高并发环境下程序的正确执行,也就是说只有使用锁才能保证多人同时访问时程序不会出现问题。

我们本课时的面试题是,什么是分布式锁?如何实现分布式锁?

典型回答

第 06 课时讲了单机锁的一些知识,包括悲观锁、乐观锁、可重入锁、共享锁和独占锁等内容,但它们都属于单机锁也就是程序级别的锁,如果在分布式环境下使用就会出现锁不生效的问题,因此我们需要使用分布式锁来解决这个问题。

分布式锁是控制分布式系统之间同步访问共享资源的一种方式。是为了解决分布式系统中,不同的系统或是同一个系统的不同主机共享同一个资源的问题,它通常会采用互斥来保证程序的一致性,这就是分布式锁的用途以及执行原理。

分布式锁示意图,如下图所示:

image.png

分布式锁的常见实现方式有四种:

  • 基于 MySQL 的悲观锁来实现分布式锁,这种方式使用的最少,因为这种实现方式的性能不好,且容易造成死锁;
  • 基于 Memcached 实现分布式锁,可使用 add 方法来实现,如果添加成功了则表示分布式锁创建成功;
  • 基于 Redis 实现分布式锁,这也是本课时要介绍的重点,可以使用 setnx 方法来实现;
  • 基于 ZooKeeper 实现分布式锁,利用 ZooKeeper 顺序临时节点来实现。

由于 MySQL 的执行效率问题和死锁问题,所以这种实现方式会被我们先排除掉,而 Memcached 和 Redis 的实现方式比较类似,但因为 Redis 技术比较普及,所以会优先使用 Redis 来实现分布式锁,而 ZooKeeper 确实可以很好的实现分布式锁。但此技术在中小型公司的普及率不高,尤其是非 Java 技术栈的公司使用的较少,如果只是为了实现分布式锁而重新搭建一套 ZooKeeper 集群,显然实现成本和维护成本太高,所以综合以上因素,我们本文会采用 Redis 来实现分布式锁。

之所以可以使用以上四种方式来实现分布式锁,是因为以上四种方式都属于程序调用的“外部系统”,而分布式的程序是需要共享“外部系统”的,这就是分布式锁得以实现的基本前提

考点分析

分布式锁的问题看似简单,但却有很多细节需要注意,比如,需要考虑分布式锁的超时问题,如果不设置超时时间的话,可能会导致死锁的产生,所以在对待这个“锁”的问题上,一定不能马虎。和此知识点相关的面试还有以下这些:

  • 单机锁有哪些?它为什么不能在分布式环境下使用?
  • Redis 是如何实现分布式锁的?可能会遇到什么问题?
  • 分布式锁超时的话会有什么问题?如何解决?

知识扩展

单机锁

程序中使用的锁叫单机锁,我们日常中所说的“锁”都泛指单机锁,其分类有很多,大体可分为以下几类:

  • 悲观锁,是数据对外界的修改采取保守策略,它认为线程很容易把数据修改掉,因此在整个数据被修改的过程中都会采取锁定状态,直到一个线程使用完,其他线程才可以继续使用,典型应用是 synchronized;
  • 乐观锁,和悲观锁的概念恰好相反,乐观锁认为一般情况下数据在修改时不会出现冲突,所以在数据访问之前不会加锁,只是在数据提交更改时,才会对数据进行检测,典型应用是 ReadWriteLock 读写锁;
  • 可重入锁,也叫递归锁,指的是同一个线程在外面的函数获取了锁之后,那么内层的函数也可以继续获得此锁,在 Java 语言中 ReentrantLock 和 synchronized 都是可重入锁;
  • 独占锁和共享锁,只能被单线程持有的锁叫做独占锁,可以被多线程持有的锁叫共享锁,独占锁指的是在任何时候最多只能有一个线程持有该锁,比如 ReentrantLock 就是独占锁;而 ReadWriteLock 读写锁允许同一时间内有多个线程进行读操作,它就属于共享锁。

单机锁之所以不能应用在分布式系统中是因为,在分布式系统中,每次请求可能会被分配在不同的服务器上,而单机锁是在单台服务器上生效的。如果是多台服务器就会导致请求分发到不同的服务器,从而导致锁代码不能生效,因此会造成很多异常的问题,那么单机锁就不能应用在分布式系统中了。

使用 Redis 实现分布式锁

使用 Redis 实现分布式锁主要需要使用 setnx 方法,也就是 set if not exists(不存在则创建),具体的实现代码如下:

127.0.0.1:6379> setnx lock true
(integer) 1 #创建锁成功
#逻辑业务处理...
127.0.0.1:6379> del lock
(integer) 1 #释放锁

当执行 setnx 命令之后返回值为 1 的话,则表示创建锁成功,否则就是失败。释放锁使用 del 删除即可,当其他程序 setnx 失败时,则表示此锁正在使用中,这样就可以实现简单的分布式锁了。

但是以上代码有一个问题,就是没有设置锁的超时时间,因此如果出现异常情况,会导致锁未被释放,而其他线程又在排队等待此锁就会导致程序不可用。

有人可能会想到使用 expire 来设置键值的过期时间来解决这个问题,例如以下代码:

127.0.0.1:6379> setnx lock true
(integer) 1 #创建锁成功
127.0.0.1:6379> expire lock 30 #设置锁的(过期)超时时间为 30s
(integer) 1 
#逻辑业务处理...
127.0.0.1:6379> del lock
(integer) 1 #释放锁

但这样执行仍然会有问题,因为 setnx lock true 和 expire lock 30 命令是非原子的,也就是一个执行完另一个才能执行。但如果在 setnx 命令执行完之后,发生了异常情况,那么就会导致 expire 命令不会执行,因此依然没有解决死锁的问题。

这个问题在 Redis 2.6.12 之前一直没有得到有效的处理,当时的解决方案是在客户端进行原子合并操作,于是就诞生了很多客户端类库来解决此原子问题,不过这样就增加了使用的成本。因为你不但要添加 Redis 的客户端,还要为了解决锁的超时问题,需额外的增加新的类库,这样就增加了使用成本,但这个问题在 Redis 2.6.12 版本中得到了有效的处理。

在 Redis 2.6.12 中我们可以使用一条 set 命令来执行键值存储,并且可以判断键是否存在以及设置超时时间了,如下代码所示:

127.0.0.1:6379> set lock true ex 30 nx
OK #创建锁成功

其中,ex 是用来设置超时时间的,而 nx 是 not exists 的意思,用来判断键是否存在。如果返回的结果为“OK”则表示创建锁成功,否则表示此锁有人在使用。

锁超时

从上面的内容可以看出,使用 set 命令之后好像一切问题都解决了,但在这里我要告诉你,其实并没有。例如,我们给锁设置了超时时间为 10s,但程序的执行需要使用 15s,那么在第 10s 时此锁因为超时就会被释放,这时候线程二在执行 set 命令时正常获取到了锁,于是在很短的时间内 2s 之后删除了此锁,这就造成了锁被误删的情况,如下图所示:

image (1).png

锁被误删的解决方案是在使用 set 命令创建锁时,给 value 值设置一个归属标识。例如,在 value 中插入一个 UUID,每次在删除之前先要判断 UUID 是不是属于当前的线程,如果属于再删除,这样就避免了锁被误删的问题。

注意:在锁的归属判断和删除的过程中,不能先判断锁再删除锁,如下代码所示:

if(uuid.equals(uuid)){ // 判断是否是自己的锁
	del(luck); // 删除锁
}

应该把判断和删除放到一个原子单元中去执行,因此需要借助 Lua 脚本来执行,在 Redis 中执行 Lua 脚本可以保证这批命令的原子性,它的实现代码如下:

/**
 * 释放分布式锁
 * @param jedis Redis客户端
 * @param lockKey 锁的 key
 * @param flagId 锁归属标识
 * @return 是否释放成功
 */
public static boolean unLock(Jedis jedis, String lockKey, String flagId) {
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(flagId));
    if ("1L".equals(result)) { // 判断执行结果
        return true;
    }
    return false;
}

其中,Collections.singletonList() 方法是将 String 转成 List,因为 jedis.eval() 最后两个参数要求必须是 List 类型。

锁超时可以通过两种方案来解决:

  • 把执行耗时的方法从锁中剔除,减少锁中代码的执行时间,保证锁在超时之前,代码一定可以执行完;
  • 把锁的超时时间设置的长一些,正常情况下我们在使用完锁之后,会调用删除的方法手动删除锁,因此可以把超时时间设置的稍微长一些。

小结

本课时我们讲了分布式锁的四种实现方式,即 MySQL、Memcached、Redis 和 ZooKeeper,因为 Redis 的普及率比较高,因此对于很多公司来说使用 Redis 实现分布式锁是最优的选择。本课时我们还讲了使用 Redis 实现分布式锁的具体步骤以及实现代码,还讲了在实现过程中可能会遇到的一些问题以及解决方案。


第21讲:Redis 中如何实现的消息队列?实现的方式有几种?

细心的你可能发现了,本系列课程中竟然出现了三个课时都是在说消息队列,第 10 课时讲了程序级别的消息队列以及延迟消息队列的实现,而第 15 课时讲了常见的消息队列中间件 RabbitMQ、Kafka 等,由此可见消息队列在整个 Java 技术体系中的重要程度。本课时我们将重点来看一下 Redis 是如何实现消息队列的。

我们本课时的面试题是,在 Redis 中实现消息队列的方式有几种?

典型回答

早在 Redis 2.0 版本之前使用 Redis 实现消息队列的方式有两种:

  • 使用 List 类型实现
  • 使用 ZSet 类型实现

其中使用** List 类型实现的方式最为简单和直接**,它主要是通过 lpush、rpop 存入和读取实现消息队列的,如下图所示:

image.png

lpush 可以把最新的消息存储到消息队列(List 集合)的首部,而 rpop 可以读取消息队列的尾部,这样就实现了先进先出,如下图所示:

image (1).png

命令行的实现命令如下:

127.0.0.1:6379> lpush mq "java" #推送消息 java
(integer) 1
127.0.0.1:6379> lpush mq "msg" #推送消息 msg
(integer) 2
127.0.0.1:6379> rpop mq #接收到消息 java
"java"
127.0.0.1:6379> rpop mq #接收到消息 msg
"mq"

其中,mq 相当于消息队列的名称,而 lpush 用于生产并添加消息,而 rpop 用于拉取并消费消息。
使用 List 实现消息队列的优点是消息可以被持久化,List 可以借助 Redis 本身的持久化功能,AOF 或者是 RDB 或混合持久化的方式,用于把数据保存至磁盘,这样当 Redis 重启之后,消息不会丢失。

但使用 List 同样存在一定的问题,比如消息不支持重复消费、没有按照主题订阅的功能、不支持消费消息确认等。

ZSet 实现消息队列的方式和 List 类似,它是利用 zadd 和 zrangebyscore 来实现存入和读取消息的,这里就不重复叙述了。但 ZSet 的实现方式更为复杂一些,因为 ZSet 多了一个分值(score)属性,我们可以使用它来实现更多的功能,比如用它来存储时间戳,以此来实现延迟消息队列等。

ZSet 同样具备持久化的功能,List 存在的问题它也同样存在,不但如此,使用 ZSet 还不能存储相同元素的值。因为它是有序集合,有序集合的存储元素值是不能重复的,但分值可以重复,也就是说当消息值重复时,只能存储一条信息在 ZSet 中。

在 Redis 2.0 之后 Redis 就新增了专门的发布和订阅的类型,Publisher(发布者)和 Subscriber(订阅者)来实现消息队列了,它们对应的执行命令如下:

  • 发布消息,publish channel "message"
  • 订阅消息,subscribe channel

使用发布和订阅的类型,我们可以实现主题订阅的功能,也就是 Pattern Subscribe 的功能。因此我们可以使用一个消费者“queue_*”来订阅所有以“queue_”开头的消息队列,如下图所示:

image (2).png

发布订阅模式的优点很明显,但同样存在以下 3 个问题:

  • 无法持久化保存消息,如果 Redis 服务器宕机或重启,那么所有的消息将会丢失;
  • 发布订阅模式是“发后既忘”的工作模式,如果有订阅者离线重连之后就不能消费之前的历史消息;
  • 不支持消费者确认机制,稳定性不能得到保证,例如当消费者获取到消息之后,还没来得及执行就宕机了。因为没有消费者确认机制,Redis 就会误以为消费者已经执行了,因此就不会重复发送未被正常消费的消息了,这样整体的 Redis 稳定性就被没有办法得到保障了。

然而在 Redis 5.0 之后新增了 Stream 类型,我们就可以使用 Stream 的 xadd 和 xrange 来实现消息的存入和读取了,并且 Stream 提供了 xack 手动确认消息消费的命令,用它我们就可以实现消费者确认的功能了,使用命令如下:

127.0.0.1:6379> xack mq group1 1580959593553-0
(integer) 1

相关语法如下:

xack key group-key ID [ID ...] 

消费确认增加了消息的可靠性,一般在业务处理完成之后,需要执行 ack 确认消息已经被消费完成,整个流程的执行如下图所示:

image (3).png

其中“Group”为群组,消费者也就是接收者需要订阅到群组才能正常获取到消息。

以上就 Redis 实现消息队列的四种方式,他们分别是:

  • 使用 List 实现消息队列;
  • 使用 ZSet 实现消息队列;
  • 使用发布订阅者模式实现消息队列;
  • 使用 Stream 实现消息队列。

考点分析

本课时的题目比较全面的考察了面试者对于 Redis 整体知识框架和新版本特性的理解和领悟。早期版本中比较常用的实现消息队列的方式是 List、ZSet 和发布订阅者模式,使用 Stream 来实现消息队列是近两年才流行起来的方案,并且很多企业也没有使用到 Redis 5.0 这么新的版本。因此只需回答出前三种就算及格了,而 Stream 方式实现消息队列属于附加题,如果面试中能回答上来的话就更好了,它体现了你对新技术的敏感度与对技术的热爱程度,属于面试中的加分项。

和此知识点相关的面试题还有以下几个:

  • 在 Java 代码中使用 List 实现消息队列会有什么问题?应该如何解决?
  • 在程序中如何使用 Stream 来实现消息队列?

知识扩展

使用 List 实现消息队列

在 Java 程序中我们需要使用 Redis 客户端框架来辅助程序操作 Redis,比如 Jedis 框架。

使用 Jedis 框架首先需要在 pom.xml 文件中添加 Jedis 依赖,配置如下:

<!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
<dependency>
  <groupId>redis.clients</groupId>
  <artifactId>jedis</artifactId>
  <version>${version}</version>
</dependency>

List 实现消息队列的完整代码如下:

import redis.clients.jedis.Jedis;
publicclass ListMQTest {
    public static void main(String[] args){
        // 启动一个线程作为消费者
        new Thread(() -> consumer()).start();
        // 生产者
        producer();
    }
    /**
     * 生产者
     */
    public static void producer() {
        Jedis jedis = new Jedis("127.0.0.1", 6379);
        // 推送消息
        jedis.lpush("mq", "Hello, List.");
    }
    /**
     * 消费者
     */
    public static void consumer() {
        Jedis jedis = new Jedis("127.0.0.1", 6379);
        // 消费消息
        while (true) {
            // 获取消息
            String msg = jedis.rpop("mq");
            if (msg != null) {
                // 接收到了消息
                System.out.println("接收到消息:" + msg);
            }
        }
    }
}

以上程序的运行结果是:

接收到消息:Hello, Java.

但是以上的代码存在一个问题,可以看出以上消费者的实现是通过 while 无限循环来获取消息,但如果消息的空闲时间比较长,一直没有新任务,而 while 循环不会因此停止,它会一直执行循环的动作,这样就会白白浪费了系统的资源

此时我们可以借助 Redis 中的阻塞读来替代 rpop 的方法就可以解决此问题,具体实现代码如下:

import redis.clients.jedis.Jedis;
public class ListMQExample {
    public static void main(String[] args) throws InterruptedException {
        // 消费者
        new Thread(() -> bConsumer()).start();
        // 生产者
        producer();
    }
    /**
     * 生产者
     */
    public static void producer() throws InterruptedException {
        Jedis jedis = new Jedis("127.0.0.1", 6379);
        // 推送消息
        jedis.lpush("mq", "Hello, Java.");
        Thread.sleep(1000);
        jedis.lpush("mq", "message 2.");
        Thread.sleep(2000);
        jedis.lpush("mq", "message 3.");
    }
    /**
     * 消费者(阻塞版)
     */
    public static void bConsumer() {
        Jedis jedis = new Jedis("127.0.0.1", 6379);
        while (true) {
            // 阻塞读
            for (String item : jedis.brpop(0,"mq")) {
                // 读取到相关数据,进行业务处理
                System.out.println(item);
            }
        }
    }
}

以上程序的运行结果是:

接收到消息:Hello, Java.

以上代码是经过改良的,我们使用 brpop 替代 rpop 来读取最后一条消息,就可以解决 while 循环在没有数据的情况下,一直循环消耗系统资源的情况了。brpop 中的 b 是 blocking 的意思,表示阻塞读,也就是当队列没有数据时,它会进入休眠状态,当有数据进入队列之后,它才会“苏醒”过来执行读取任务,这样就可以解决 while 循环一直执行消耗系统资源的问题了。

使用 Stream 实现消息队列

在开始实现消息队列之前,我们必须先创建分组才行,因为消费者需要关联分组信息才能正常运行,具体实现代码如下:

import com.google.gson.Gson;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.StreamEntry;
import redis.clients.jedis.StreamEntryID;
import utils.JedisUtils;
import java.util.AbstractMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class StreamGroupExample {
    private static final String _STREAM_KEY = "mq"; // 流 key
    private static final String _GROUP_NAME = "g1"; // 分组名称
    private static final String _CONSUMER_NAME = "c1"; // 消费者 1 的名称
    private static final String _CONSUMER2_NAME = "c2"; // 消费者 2 的名称
    public static void main(String[] args) {
        // 生产者
        producer();
        // 创建消费组
        createGroup(_STREAM_KEY, _GROUP_NAME);
        // 消费者 1
        new Thread(() -> consumer()).start();
        // 消费者 2
        new Thread(() -> consumer2()).start();
    }
    /**
     * 创建消费分组
     * @param stream    流 key
     * @param groupName 分组名称
     */
    public static void createGroup(String stream, String groupName) {
        Jedis jedis = JedisUtils.getJedis();
        jedis.xgroupCreate(stream, groupName, new StreamEntryID(), true);
    }
    /**
     * 生产者
     */
    public static void producer() {
        Jedis jedis = JedisUtils.getJedis();
        // 添加消息 1
        Map<String, String> map = new HashMap<>();
        map.put("data", "redis");
        StreamEntryID id = jedis.xadd(_STREAM_KEY, null, map);
        System.out.println("消息添加成功 ID:" + id);
        // 添加消息 2
        Map<String, String> map2 = new HashMap<>();
        map2.put("data", "java");
        StreamEntryID id2 = jedis.xadd(_STREAM_KEY, null, map2);
        System.out.println("消息添加成功 ID:" + id2);
    }
    /**
     * 消费者 1
     */
    public static void consumer() {
        Jedis jedis = JedisUtils.getJedis();
        // 消费消息
        while (true) {
            // 读取消息
            Map.Entry<String, StreamEntryID> entry = new AbstractMap.SimpleImmutableEntry<>(_STREAM_KEY,
                    new StreamEntryID().UNRECEIVED_ENTRY);
            // 阻塞读取一条消息(最大阻塞时间120s)
            List<Map.Entry<String, List<StreamEntry>>> list = jedis.xreadGroup(_GROUP_NAME, _CONSUMER_NAME, 1,
                    120 * 1000, true, entry);
            if (list != null && list.size() == 1) {
                // 读取到消息
                Map<String, String> content = list.get(0).getValue().get(0).getFields(); // 消息内容
                System.out.println("Consumer 1 读取到消息 ID:" + list.get(0).getValue().get(0).getID() +
                        " 内容:" + new Gson().toJson(content));
            }
        }
    }
    /**
     * 消费者 2
     */
    public static void consumer2() {
        Jedis jedis = JedisUtils.getJedis();
        // 消费消息
        while (true) {
            // 读取消息
            Map.Entry<String, StreamEntryID> entry = new AbstractMap.SimpleImmutableEntry<>(_STREAM_KEY,
                    new StreamEntryID().UNRECEIVED_ENTRY);
            // 阻塞读取一条消息(最大阻塞时间120s)
            List<Map.Entry<String, List<StreamEntry>>> list = jedis.xreadGroup(_GROUP_NAME, _CONSUMER2_NAME, 1,
                    120 * 1000, true, entry);
            if (list != null && list.size() == 1) {
                // 读取到消息
                Map<String, String> content = list.get(0).getValue().get(0).getFields(); // 消息内容
                System.out.println("Consumer 2 读取到消息 ID:" + list.get(0).getValue().get(0).getID() +
                        " 内容:" + new Gson().toJson(content));
            }
        }
    }
}

以上代码运行结果如下:

消息添加成功 ID:1580971482344-0
消息添加成功 ID:1580971482415-0
Consumer 1 读取到消息 ID:1580971482344-0 内容:{"data":"redis"}
Consumer 2 读取到消息 ID:1580971482415-0 内容:{"data":"java"}

其中,jedis.xreadGroup() 方法的第五个参数 noAck 表示是否自动确认消息,如果设置 true 收到消息会自动确认 (ack) 消息,否则需要手动确认。

可以看出,同一个分组内的多个 consumer 会读取到不同消息,不同的 consumer 不会读取到分组内的同一条消息。

小贴士:Jedis 框架要使用最新版,低版本 block 设置大于 0 时,会出现 bug,抛连接超时异常。

小结

本课时我们讲了 Redis 中消息队列的四种实现方式:List 方式、ZSet 方式、发布订阅者模式、Stream 方式,其中发布订阅者模式不支持消息持久化、而其他三种方式支持持久化,并且 Stream 方式支持消费者确认。我们还使用 Jedis 框架完成了 List 和 Stream 的消息队列功能,需要注意的是在 List 中需要使用 brpop 来读取消息,而不是 rpop,这样可以解决没有任务时 ,while 一直循环浪费系统资源的问题。


第22讲:Redi 是如何实现高可用的?

高可用是通过设计,减少系统不能提供服务的时间,是分布式系统的基础也是保障系统可靠性的重要手段。而 Redis 作为一款普及率最高的内存型中间件,它的高可用技术也非常的成熟。

我们本课时的面试题是,Redis 是如何保证系统高可用的?它的实现方式有哪些?

典型回答

Redis 高可用的手段主要有以下四种:

  • 数据持久化
  • 主从数据同步(主从复制)
  • Redis 哨兵模式(Sentinel)
  • Redis 集群(Cluster)

其中数据持久化保证了系统在发生宕机或者重启之后数据不会丢失,增加了系统的可靠性和减少了系统不可用的时间(省去了手动恢复数据的过程);而主从数据同步可以将数据存储至多台服务器,这样当遇到一台服务器宕机之后,可以很快地切换至另一台服务器以继续提供服务;哨兵模式用于发生故障之后自动切换服务器;而 Redis 集群提供了多主多从的 Redis 分布式集群环境,用于提供性能更好的 Redis 服务,并且它自身拥有故障自动切换的能力。

考点分析

高可用的问题属于 Redis 中比较大的面试题了,因为很多知识点都和这个面试题有关,同时也属于比较难的面试题了。因为涉及了分布式集群,而分布式集群属于 Redis 中比较难懂的一个知识点。和此问题相关的面试题还有以下几个:

  • 数据持久化有几种方式?
  • Redis 主从同步有几种模式?
  • 什么是 Redis 哨兵模式?它解决了什么问题?
  • Redis 集群的优势是什么?

知识扩展

1.数据持久化

持久化功能是 Redis 和 Memcached 的主要区别之一,因为只有 Redis 提供了此功能。

在 Redis 4.0 之前数据持久化方式有两种:AOF 方式和 RDB 方式。

  • RDB(Redis DataBase,快照方式)是将某一个时刻的内存数据,以二进制的方式写入磁盘。
  • AOF(Append Only File,文件追加方式)是指将所有的操作命令,以文本的形式追加到文件中。

RDB 默认的保存文件为 dump.rdb,优点是以二进制存储的,因此占用的空间更小、数据存储更紧凑,并且与 AOF 相比,RDB 具备更快的重启恢复能力。

AOF 默认的保存文件为 appendonly.aof,它的优点是存储频率更高,因此丢失数据的风险就越低,并且 AOF 并不是以二进制存储的,所以它的存储信息更易懂。缺点是占用空间大,重启之后的数据恢复速度比较慢。

可以看出 RDB 和 AOF 各有利弊,RDB 具备更快速的数据重启恢复能力,并且占用更小的磁盘空间,但有数据丢失的风险;而 AOF 文件的可读性更高,但却占用了更大的空间,且重启之后的恢复速度更慢,于是在 Redis 4.0 就推出了混合持久化的功能。

混合持久化的功能指的是 Redis 可以使用 RDB + AOF 两种格式来进行数据持久化,这样就可以做到扬长避短物尽其用了,混合持久化的存储示意图如下图所示:

image (1).png

我们可以使用“config get aof-use-rdb-preamble”的命令来查询 Redis 混合持久化的功能是否开启,执行示例如下:

127.0.0.1:6379> config get aof-use-rdb-preamble
1) "aof-use-rdb-preamble"
2) "yes"

如果执行结果为“no”则表示混合持久化功能关闭,不过我们可以使用“config set aof-use-rdb-preamble yes”的命令打开此功能。
Redis 混合持久化的存储模式是,开始的数据以 RDB 的格式进行存储,因此只会占用少量的空间,并且之后的命令会以 AOF 的方式进行数据追加,这样就可以减低数据丢失的风险,同时可以提高数据恢复的速度。

2.Redis 主从同步

主从同步是 Redis 多机运行中最基础的功能,它是把多个 Redis 节点组成一个 Redis 集群,在这个集群当中有一个主节点用来进行数据的操作,其他从节点用于同步主节点的内容,并且提供给客户端进行数据查询。

Redis 主从同步分为:主从模式和从从模式。主从模式就是一个主节点和多个一级从节点,如下图所示:

image (3).png

从从模式是指一级从节点下面还可以拥有更多的从节点,如下图所示:

image (4).png

主从模式可以提高 Redis 的整体运行速度,因为使用主从模式就可以实现数据的读写分离,把写操作的请求分发到主节点上,把其他的读操作请求分发到从节点上,这样就减轻了 Redis 主节点的运行压力,并且提高了 Redis 的整体运行速度。

不但如此使用主从模式还实现了 Redis 的高可用,当主服务器宕机之后,可以很迅速的把从节点提升为主节点,为 Redis 服务器的宕机恢复节省了宝贵的时间。

并且主从复制还降低了数据丢失的风险,因为数据是完整拷贝在多台服务器上的,当一个服务器磁盘坏掉之后,可以从其他服务器拿到完整的备份数据。

3.Redis 哨兵模式

Redis 主从复制模式有那么多的优点,但是有一个致命的缺点,就是当 Redis 的主节点宕机之后,必须人工介入手动恢复,那么到特殊时间段,比如公司组织全体团建或者半夜突然发生主节点宕机的问题,此时如果等待人工去处理就会很慢,这个时间是我们不允许的,并且我们还需要招聘专职的人来负责数据恢复的事,同时招聘的人还需要懂得相关的技术才能胜任这份工作。既然如此的麻烦,那有没有简单一点的解决方案,这个时候我们就需要用到 Redis 的哨兵模式了。

Redis 哨兵模式就是用来监视 Redis 主从服务器的,当 Redis 的主从服务器发生故障之后,Redis 哨兵提供了自动容灾修复的功能,如下图所示:

image (5).png

Redis 哨兵模块存储在 Redis 的 src/redis-sentinel 目录下,如下图所示:

image (6).png

我们可以使用命令“./src/redis-sentinel sentinel.conf”来启动哨兵功能。

有了哨兵功能之后,就再也不怕 Redis 主从服务器宕机了。哨兵的工作原理是每个哨兵会以每秒钟 1 次的频率,向已知的主服务器和从服务器,发送一个 PING 命令。如果最后一次有效回复 PING 命令的时间,超过了配置的最大下线时间(Down-After-Milliseconds)时,默认是 30s,那么这个实例会被哨兵标记为主观下线。

如果一个主服务器被标记为主观下线,那么正在监视这个主服务器的所有哨兵节点,要以每秒 1 次的频率确认主服务器是否进入了主观下线的状态。如果有足够数量(quorum 配置值)的哨兵证实该主服务器为主观下线,那么这个主服务器被标记为客观下线。此时所有的哨兵会按照规则(协商)自动选出新的主节点服务器,并自动完成主服务器的自动切换功能,而整个过程都是无须人工干预的。

4.Redis 集群

Redis 集群也就是 Redis Cluster,它是 Redis 3.0 版本推出的 Redis 集群方案,将数据分布在不同的主服务器上,以此来降低系统对单主节点的依赖,并且可以大大提高 Redis 服务的读写性能。Redis 集群除了拥有主从模式 + 哨兵模式的所有功能之外,还提供了多个主从节点的集群功能,实现了真正意义上的分布式集群服务,如下图所示:

image (7).png

Redis 集群可以实现数据分片服务,也就是说在 Redis 集群中有 16384 个槽位用来存储所有的数据,当我们有 N 个主节点时,可以把 16384 个槽位平均分配到 N 台主服务器上。当有键值存储时,Redis 会使用 crc16 算法进行 hash 得到一个整数值,然后用这个整数值对 16384 进行取模来得到具体槽位,再把此键值存储在对应的服务器上,读取操作也是同样的道理,这样我们就实现了数据分片的功能。

小结

本课时我们讲了保障 Redis 高可用的 4 种手段:数据持久化保证了数据不丢失;Redis 主从让 Redis 从单机变成了多机。它有两种模式:主从模式和从从模式,但当主节点出现问题时,需要人工手动恢复系统;Redis 哨兵模式用来监控 Redis 主从模式,并提供了自动容灾恢复的功能。最后是 Redis 集群,除了可以提供主从和哨兵的功能之外,还提供了多个主从节点的集群功能,这样就可以把数据均匀的存储各个主机主节点上,实现了系统的横向扩展,大大提高了 Redis 的并发处理能力。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

办公模板库 素材蛙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值