这是我上周去面试的,技术问题一共问了十多个问题,项目介绍以及一些软技能的,这类就不提了。
1.说说MySQL事务的原理
mysql事务的实现基于数据库的存储引擎,不同的存储引擎对事务的支持程度不一样,MySQL中支持事务的存储引擎有InnoDB和NDB(NDB使用的非常少,不用关注,重点掌握InnoDB)。
InnoDB是高版本MySQL(5.5版本后)的默认的存储引擎,以InnoDB的事务实现为例,InnoDB是通过多版本并发控制(MVCC,Multiversion Concurrency Control)解决不可重复读问题,加上间隙锁(也就是并发控制)解决幻读问题。因此InnoDB的RR隔离级别其实实现了串行化级别的效果,而且保留了比较好的并发性能。事务的隔离性是通过锁实现,而事务的原子性、一致性和持久性则是通过事务日志实现。
2.事务的分类
事务可分为读事务与读写事务,具体可见下表:
事务分类 | 描述 | 事务ID |
---|---|---|
读事务 | 整个事务中只有读操作,则一直是读事务 | 使用事务对象地址进行计算得到一个唯一事务ID,比较大的一个数值 |
读写事务 | 事务中遇到第一个更新语句升级为读写事务 | 分配全局唯一的读写事务ID |
3.事务的特点
数据库事务特性可以简称为ACID,包含:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。
- 原子性:组成一个事务的多个数据库操作是一个不可分割的原子单元,只有所有操作都成功,整个事务才会提交。任何一个操作失败,已经执行的任何操作都必须撤销,让数据库返回初始状态。
- 一致性:事务操作成功后,数据库所处的状态和它的业务规则是一致的。即数据不会被破坏。如A转账100元给B,不管操作是否成功,A和B的账户总额是不变的。
- 隔离性:在并发数据操作时,不同的事务拥有各自的数据空间,它们的操作不会对彼此产生干扰
- 持久性:一旦事务提交成功,事务中的所有操作都必须持久化到数据库中。
4.索引的分类
我们经常从以下几个方面对索引进行分类:
1)从“数据结构的角度”对索引进行分类:
B-Tree索引(MySQL使用B Tree)。B-Tree能加快数据的访问速度,因为存储引擎不再需要进行全表扫描来获取数据,数据分布在各个节点之中。
B Tree索引。是B-Tree的改进版本,同时也是数据库索引索引所采用的存储结构。数据都在叶子节点上,并且增加了顺序访问指针,每个叶子节点都指向相邻的叶子节点的地址。相比B-Tree来说,进行范围查找时只需要查找两个节点,进行遍历即可。而B-Tree需要获取所有节点,相比之下B Tree效率更高。
Hash索引。基于哈希表实现,只有精确匹配索引所有列的查询才有效,对于每一行数据,存储引擎都会对所有的索引列计算一个哈希码,并且Hash索引将所有的哈希码存储在索引中,同时在索引表中保存指向每个数据行的指针。
Full-texts索引。主要用来查找文本中的关键字。
2)从“物理存储的角度”对索引进行分类
聚簇索引。按照每张表的主键构造一颗B 树,同时叶子节点中存放的就是整张表的行记录数据,也将聚集索引的叶子节点称为数据页。
二级索引(辅助索引)。在聚簇索引之上创建的索引称之为辅助索引,辅助索引访问数据总是需要二次查找。辅助索引叶子节点存储的不再是行的物理位置,而是主键值。
3)从“索引字段特性角度”分类
主键索引。是一种特殊的唯一索引,一个表只能有一个主键,不允许有空值。
唯一索引。索引列中的值必须是唯一的,但是允许为空值。
普通索引。是最基本索引类型,没有什么限制,允许在定义索引的列中插入重复值和空值。
前缀索引。在文本类型如CHAR、VARCHAR、TEXT类列上创建索引时,可以指定索引列的长度,但是数值类型不能指定。
4)从“组成索引的字段个数角度”分类
单列索引。一个索引只包含单个列,一个表可以有多个单列索引
联合索引(复合索引)。两个或更多个列上的索引被称作复合索引。
no5.为什么索引会快一些
这道题目问的还是挺有水平的。
索引其实是一种数据结构,加速查找速度的数据结构,常见的有两类:
1)哈希,例如HashMap,查询/插入/修改/删除的平均时间复杂度都是O(1);
2)树,例如平衡二叉搜索树,查询/插入/修改/删除的平均时间复杂度都是O(lg(n));
有个常识:不管是读请求,还是写请求,哈希类型的索引,都要比树型的索引更快一些。
那为什么,索引结构要设计成树型呢?
索引设计成树形,和SQL的需求相关。
对于这样一个单行查询的SQL需求:
select * from t where name=”zhangsan”;
确实是哈希索引更快,因为每次都只查询一条记录。
但是对于排序查询的SQL需求:
分组:group by
排序:order by
比较:<、>
…
哈希型的索引,时间复杂度会退化为O(n),而树型的“有序”特性,依然能够保持O(log(n))的高效率。
6.怎么优化sql语句
可以说下面的一些具体的优化方法:
避免select*,将需要查找的字段列出来;
使用连接(join)来代替子查询;
拆分大的delete或insert语句;
使用limit对查询结果的记录进行限定;
尽量减少子查询,使用关联查询(left join、right join、inner join)替代;
or的查询尽量用union或者union all代替(在确认没有重复数据或者不用剔除重复数据时,union all会更好);
减少使用IN或者NOT IN,使用exists,not exists或者关联查询语句替代;
用Where子句替换HAVING子句,因为HAVING只会在检索出所有记录之后才对结果集进行过滤;
不要在where子句中的“=”左边进行函数、算术运算或其他表达式运算,否则系统将可能无法正确使用索引;
尽量避免在where子句中对字段进行null值判断,否则将导致引擎放弃使用索引而进行全表扫描;
尽量避免在where子句中使用or来连接条件,否则将导致引擎放弃使用索引而进行全表扫描;
尽量避免在where子句中使用!=或<>操作符,否则将引擎放弃使用索引而进行全表扫描。
7.说一说用到过哪些集合
这道题可以回答以下三个方面的内容:
1)使用过哪些集合
主要使用过的有ArrayList、HashMap
2)使用的原因
使用ArrayList存储顺序表结构的数据;
使用HashMap存储键值对的数据。
3)底层原理
ArrayList底层使用的是数组实现的;
HashMap的底层使用的是数组加链表,但是当链表长度到达8的时候,会把链表转换成红黑树。
PS:回答红黑树了,很有可能面试官还会问,知道红黑树的特性或者特点吗?可以记住下面回答要点:
每个节点或者是黑色,或者是红色。
根节点是黑色。
每个叶子节点(NIL)是黑色。这里叶子节点,是指为空(NIL或NULL)的叶子节点。
如果一个节点是红色的,则它的子节点必须是黑色的。
从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。
8.hashmap底层原理
HashMap是基于hash原理的。
通过put()和get()方法获得和存储对象。
当进行put()方法时,先通过key的hashCode()方法计算出hashCode,通过indexFor(hashCode,length)方法得到对象存储于table中的下标位置,也就是找到bucked的位置用来存储Entry。当两个不同的对象hashcode相等的时候,就是用equals方法比较两个对象是否一样,一样则把之前的替换掉,不一样则加入到链表中。
当进行get()方法时,先通过先通过key的hashCode()方法计算出hashCode,通过indexFor(hashCode,length)方法得到对象存储于table中的下标位置,如果是链表,则再遍历链表中的节点,使用equals方法找出对应的value。不是链表形式则直接返回对应的value即可。
9.可重入锁的原理
这个问题要提到两个重要的变量:
一个是state,初始值为0,表示锁没有被获取,获取一次state就加1,释放一次就减1。
另一个是exclusiveOwnerThread,这个变量就是保存当前获取到锁的线程,可重入就是通过这个变量来判断的,如果当前线程和该锁拥有的线程是同一个,那么该线程就可以多次获取锁。
此外,可以说一下可重入锁又分为公平锁和非公平锁。
公平锁就是只要锁被其他线程获取了或者前面以后线程在等待获取锁时,此时进来的线程就必须进入队里了,排队依次等待获取锁,有先后关系。
非公平锁就是进来时先尝试获取锁(此时可能A线程释放了锁,但是后面还有B、C、D线程还在排队等待。但此时一个线程E进来了,然后线程E就直接开始抢A释放的锁),等获取不到了再进入队列进行排队。
10.说一下分布式锁
这道问题比较发散,所以可以多回答几个自己有把握的点,比如:
1)分布式锁的产生原因
同步锁、可重入锁都只是解决在同一个JVM中的场景,但是在不同JVM就没有保证了,于是就有了分布式锁。
2)分布式常见的3种实现方式
实现方式分布式锁的实现方式流行的主要有三种,分别是基于缓存Redis的实现方式、基于zookeeper节点的实现以及基于数据库。
数据库基于表字段version等来实现。
redis主要是基于setnx和有效期来实现。
zookeeper基于临时节点的唯一性有序性来实现的。
项目基本都是使用后两种,因为如果使用数据库来实现,就非常的依赖数据库的性能。使用redis和zookeeper更好。
11.分布式锁在刚获取到id时redis宕机了怎么办
(这个问题有可能没写清楚,真实场景下面试官也许是问:获取到id了,并且处理完业务代码后,此时如果redis宕机了该怎么办?)
获取到id了,并且处理完业务代码后,此时如果redis宕机了,那么就有两种情况:
第一种:如果id设置了有效期,那业务代码正常执行,删除id失败就失败不管他,因为超时了会自动删除。
第二种:如果有效期时间非常的长或者是永久,那么我们可以把id记录下来(落库),然后采用异步方式去删除redis中的这个id。
12.说一下线程池用过哪些
使用ThreadPoolExecutor是JDK原生态创建线程池,也可以使用Executors工具类来创建线程池,并Executors大多数都是基于ThreadPoolExecutor进行二次封装。
以下是Executors方式创建线程池的几种方式:
- newSingleThreadExecutor:创建一个单线程的线程池,此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
- newFixedThreadPool:创建固定大小的线程池,每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。
- newCachedThreadPool:创建一个可缓存的线程池,此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。
- newScheduledThreadPool:创建一个大小无限的线程池,此线程池支持定时以及周期性执行任务的需求。
- newSingleThreadExecutor:创建一个单线程的线程池。此线程池支持定时以及周期性执行任务的需求。
- newWorkStealingPoo0l:Java8新增创建线程池的方法,创建时如果不设置任何参数,则以当前机器CPU处理器数作为线程个数,此线程池会并行处理任务,不能保证执行顺序。
13.异步编程怎么用的
多线程方式。比如:使用线程池方式,把任务丢到线程池里,然后业务代码继续执行,线程池中的任务和主干业务代码分开执行。
使用消息队列方式。把生产出来的消息丢尽消息队列中,消费方这处理这些消息。
定时任务方式。比如说:把收到的订单先入库,然后通过定时任务方式去处理这些订单,而当前线程就可以不用等等待订单的处理了。
14.rabbitmq用在哪些地方
1)解耦
场景说明:用户下单后,订单系统需要通知库存系统。传统的做法是,订单系统调用库存系统的接口。传统模式有两大缺点:一是假如库存系统无法访问,则订单减库存将失败,从而导致订单失败;二是订单系统与库存系统耦合。引入消息队列之后,在下单时库存系统不能正常使用。也不影响正常下单,因为下单后,订单系统写入消息队列就不再关心其他的后续操作了。实现订单系统与库存系统的应用解耦。
2**)异步提升效率
场景说明:用户注册后,需要发注册邮件和注册短信。传统的做法有两种:一是串行的方式;二是并行方式。引入消息队列,将不是必须的业务逻辑,提升效率。
3**)流量削峰
流量削峰也是消息队列中的常用场景,一般在秒杀或团抢活动中使用广泛。
应用场景:系统其他时间A系统每秒请求量就100个,系统可以稳定运行。系统每天晚间八点有秒杀活动,每秒并发请求量增至1万条,但是系统最大的处理能力只能每秒处理1000个请求,于是系统崩溃,服务器宕机。
项目中使用场景:给用户发邮件、发站内信、异步持久化请求日志等。
15.如何保证消息不丢失?
数据的丢失问题,可能出现在生产者、MQ、消费者中:
生产者丢失:生产者将数据发送到RabbitMQ的时候,可能数据就在半路给搞丢了,因为网络问题啥的,都有可能。此时可以选择用RabbitMQ提供的事务功能,就是生产者发送数据之前开启RabbitMQ事务channel.txSelect,然后发送消息,如果消息没有成功被RabbitMQ接收到,那么生产者会收到异常报错,此时就可以回滚事务channel.txRollback,然后重试发送消息;如果收到了消息,那么可以提交事务channel.txCommit。吞吐量会下来,因为太耗性能。所以一般来说,如果你要确保说写RabbitMQ的消息别丢,可以开启confirm模式,在生产者那里设置开启confirm模式之后,你每次写的消息都会分配一个唯一的id,然后如果写入了RabbitMQ中,RabbitMQ会给你回传一个ack消息,告诉你说这个消息ok了。如果RabbitMQ没能处理这个消息,会回调你一个nack接口,告诉你这个消息接收失败,你可以重试。而且你可以结合这个机制自己在内存里维护每个消息id的状态,如果超过一定时间还没接收到这个消息的回调,那么你可以重发。事务机制和cnofirm机制最大的不同在于,事务机制是同步的,你提交一个事务之后会阻塞在那儿,但是confirm机制是异步的,你发送个消息之后就可以发送下一个消息,然后那个消息RabbitMQ接收了之后会异步回调你一个接口通知你这个消息接收到了。所以一般在生产者这块避免数据丢失,都是用confirm机制的。
MQ中丢失:就是RabbitMQ自己弄丢了数据,这个你必须开启RabbitMQ的持久化,就是消息写入之后会持久化到磁盘,哪怕是RabbitMQ自己挂了,恢复之后会自动读取之前存储的数据,一般数据不会丢。设置持久化有两个步骤:创建queue的时候将其设置为持久化,这样就可以保证RabbitMQ持久化queue的元数据,但是不会持久化queue里的数据。第二个是发送消息的时候将消息的deliveryMode设置为2,就是将消息设置为持久化的,此时RabbitMQ就会将消息持久化到磁盘上去。必须要同时设置这两个持久化才行,RabbitMQ哪怕是挂了,再次重启,也会从磁盘上重启恢复queue,恢复这个queue里的数据。持久化可以跟生产者那边的confirm机制配合起来,只有消息被持久化到磁盘之后,才会通知生产者ack了,所以哪怕是在持久化到磁盘之前,RabbitMQ挂了,数据丢了,生产者收不到ack,你也是可以自己重发的。注意,哪怕是你给RabbitMQ开启了持久化机制,也有一种可能,就是这个消息写到了RabbitMQ中,但是还没来得及持久化到磁盘上,结果不巧,此时RabbitMQ挂了,就会导致内存里的一点点数据丢失。
消费端丢失:你消费的时候,刚消费到,还没处理,结果进程挂了,比如重启了,那么就尴尬了,RabbitMQ认为你都消费了,这数据就丢了。这个时候得用RabbitMQ提供的ack机制,简单来说,就是你关闭RabbitMQ的自动ack,可以通过一个api来调用就行,然后每次你自己代码里确保处理完的时候,再在程序里ack一把。这样的话,如果你还没处理完,不就没有ack?那RabbitMQ就认为你还没处理完,这个时候RabbitMQ会把这个消费分配给别的 consumer去处理,消息是不会丢的。
下面这张图总结了对应的处理方案:
16.如何保证更改数据时数据一致性
第一种方案:先删除缓存,在更新数据库。
- 请求A会先删除Redis中的数据,然后去数据库进行更新操作
- 此时请求B看到Redis中的数据时空的,会去数据库中查询该值,补录到Redis中
- 但是此时请求A并没有更新成功,或者事务还未提交
那么这时候就会产生数据库和Redis数据不一致的问题。如何解决呢?其实最简单的解决办法就是延时双删的策略。
第二种方案:先更新数据库,再删除缓存
这一种情况也会出现问题,比如更新数据库成功了,但是在删除缓存的阶段出错了没有删除成功,那么此时再读取缓存的时候每次都是错误的数据了。
此时解决方案就是利用消息队列进行删除的补偿。具体的业务逻辑用语言描述如下:
- 请求A先对数据库进行更新操作
- 在对Redis进行删除操作的时候发现报错,删除失败
- 此时将Redis的key作为消息体发送到消息队列中
- 系统接收到消息队列发送的消息后再次对Redis进行删除操作
- 但是这个方案会有一个缺点就是会对业务代码造成大量的侵入,深深的耦合在一起,所以这时会有一个优化的方案,我们知道对Mysql数据库更新操作后再binlog日志中我们都能够找到相应的操作,那么我们可以订阅Mysql数据库的binlog日志对缓存进行操作。
总结
上面很多问题都没有回答上来,相对于我这种3粘不到的开发人员来说,很多问题还是很有难度。两方面原因:一是工作中很多没用过,另外一个就是本质原因(懒惰、好上进)。
本文来自于一位网友,如果你在面试中遇到了问题,或者有面试经历的,欢迎投稿。
推荐阅读
常见的SQL面试题:经典50例
面试官:分布式事务解决方案(附代码)
面试官:熟悉内部类吗?
面试官:为什么 SpringBoot 的 jar 可以直接运行?
面试官:MySQL 批量插入,如何不插入重复数据