当前位置 博文首页 > 科技缪缪的专栏:5万字 | 2020大厂面试总结,PDF供下载

    科技缪缪的专栏:5万字 | 2020大厂面试总结,PDF供下载

    作者:[db:作者] 时间:2021-07-28 15:20

    前言

    为什么写这些内容,因为2020年3月份疫情的原因裁员,而我自己本身从2015年开始就基本没有出去面试过了。

    所以,在年初的时候就开始网上找问题、刷面试题,然后疯狂的面试。

    中间形成了一个文档,不过内容都是网上整理来的,然后从2020年7月底的时候,开始想自己写个公众号,同时把这些东西分享出来,因为觉得虽然是从网上看来的部分面试题,但是同时也加上了自己和朋友真实的面试真题,对其他人应该也是挺有帮助的。

    然后陆续发了一个月,慢慢的把这些笔记都发到公众号上了,只不过后面发现其实这不是原创啊,没办法,于是乎,就重新再次整理,画图,这个过程也可以算是一个重新提高自己的过程。毕竟,给自己看的明白和能让别人能明白还是不一样的。

    今天,2020年最后一天,几天前突然发烧了,体温一会上去一会下来的,熬了几天发现扛不住了,去医院一检查,好家伙,肺炎了,挂水10天,请了几天假,顺便就趁着今天最后一天把这4个多月以来写的还不错的整理出来了,超过5万字了,4个多月5万多字也不少了,哈哈。

    这是我的公众号二维码,欢迎大家关注。

    Mysql

    能说下myisam 和 innodb的区别吗?

    myisam引擎是5.1版本之前的默认引擎,支持全文检索、压缩、空间函数等,但是不支持事务和行级锁,所以一般用于有大量查询少量插入的场景来使用,而且myisam不支持外键,并且索引和数据是分开存储的。

    innodb是基于聚簇索引建立的,和myisam相反它支持事务、外键,并且通过MVCC来支持高并发,索引和数据存储在一起。

    说下mysql的索引有哪些吧,聚簇和非聚簇索引又是什么?

    索引按照数据结构来说主要包含B+树和Hash索引。

    假设我们有张表,结构如下:

    create table user(
    	id int(11) not null,
      age int(11) not null,
      primary key(id),
      key(age)
    );
    

    B+树是左小右大的顺序存储结构,节点只包含id索引列,而叶子节点包含索引列和数据,这种数据和索引在一起存储的索引方式叫做聚簇索引,一张表只能有一个聚簇索引。假设没有定义主键,InnoDB会选择一个唯一的非空索引代替,如果没有的话则会隐式定义一个主键作为聚簇索引。

    这是主键聚簇索引存储的结构,那么非聚簇索引的结构是什么样子呢?非聚簇索引(二级索引)保存的是主键id值,这一点和myisam保存的是数据地址是不同的。

    最终,我们一张图看看InnoDB和Myisam聚簇和非聚簇索引的区别

    那你知道什么是覆盖索引和回表吗?

    覆盖索引指的是在一次查询中,如果一个索引包含或者说覆盖所有需要查询的字段的值,我们就称之为覆盖索引,而不再需要回表查询。

    而要确定一个查询是否是覆盖索引,我们只需要explain sql语句看Extra的结果是否是“Using index”即可。

    以上面的user表来举例,我们再增加一个name字段,然后做一些查询试试。

    explain select * from user where age=1; //查询的name无法从索引数据获取
    explain select id,age from user where age=1; //可以直接从索引获取
    

    锁的类型有哪些呢

    mysql锁分为共享锁排他锁,也叫做读锁和写锁。

    读锁是共享的,可以通过lock in share mode实现,这时候只能读不能写。

    写锁是排他的,它会阻塞其他的写锁和读锁。从颗粒度来区分,可以分为表锁行锁两种。

    表锁会锁定整张表并且阻塞其他用户对该表的所有读写操作,比如alter修改表结构的时候会锁表。

    行锁又可以分为乐观锁悲观锁,悲观锁可以通过for update实现,乐观锁则通过版本号实现。

    你能说下事务的基本特性和隔离级别吗?

    事务基本特性ACID分别是:

    原子性指的是一个事务中的操作要么全部成功,要么全部失败。

    一致性指的是数据库总是从一个一致性的状态转换到另外一个一致性的状态。比如A转账给B100块钱,假设中间sql执行过程中系统崩溃A也不会损失100块,因为事务没有提交,修改也就不会保存到数据库。

    隔离性指的是一个事务的修改在最终提交前,对其他事务是不可见的。

    持久性指的是一旦事务提交,所做的修改就会永久保存到数据库中。

    而隔离性有4个隔离级别,分别是:

    read uncommit 读未提交,可能会读到其他事务未提交的数据,也叫做脏读。

    用户本来应该读取到id=1的用户age应该是10,结果读取到了其他事务还没有提交的事务,结果读取结果age=20,这就是脏读。

    read commit 读已提交,两次读取结果不一致,叫做不可重复读。

    不可重复读解决了脏读的问题,他只会读取已经提交的事务。

    用户开启事务读取id=1用户,查询到age=10,再次读取发现结果=20,在同一个事务里同一个查询读取到不同的结果叫做不可重复读。

    repeatable read 可重复复读,这是mysql的默认级别,就是每次读取结果都一样,但是有可能产生幻读。

    serializable 串行,一般是不会使用的,他会给每一行读取的数据加锁,会导致大量超时和锁竞争的问题。

    那ACID靠什么保证的呢?

    A原子性由undo log日志保证,它记录了需要回滚的日志信息,事务回滚时撤销已经执行成功的sql

    C一致性一般由代码层面来保证

    I隔离性由MVCC来保证

    D持久性由内存+redo log来保证,mysql修改数据同时在内存和redo log记录这次操作,事务提交的时候通过redo log刷盘,宕机的时候可以从redo log恢复

    那你说说什么是幻读,什么是MVCC?

    要说幻读,首先要了解MVCC,MVCC叫做多版本并发控制,实际上就是保存了数据在某个时间节点的快照。

    我们每行数实际上隐藏了两列,创建时间版本号,过期(删除)时间版本号,每开始一个新的事务,版本号都会自动递增。

    还是拿上面的user表举例子,假设我们插入两条数据,他们实际上应该长这样。

    idnamecreate_versiondelete_version
    1张三1
    2李四2

    这时候假设小明去执行查询,此时current_version=3

    select * from user where id<=3;
    

    同时,小红在这时候开启事务去修改id=1的记录,current_version=4

    update user set name='张三三' where id=1;
    

    执行成功后的结果是这样的

    idnamecreate_versiondelete_version
    1张三1
    2李四2
    1张三三4

    如果这时候还有小黑在删除id=2的数据,current_version=5,执行后结果是这样的。

    idnamecreate_versiondelete_version
    1张三1
    2李四25
    1张三三4

    由于MVCC的原理是查找创建版本小于或等于当前事务版本,删除版本为空或者大于当前事务版本,小明的真实的查询应该是这样

    select * from user where id<=3 and create_version<=3 and (delete_version>3 or delete_version is null);
    

    所以小明最后查询到的id=1的名字还是’张三’,并且id=2的记录也能查询到。这样做是为了保证事务读取的数据是在事务开始前就已经存在的,要么是事务自己插入或者修改的

    明白MVCC原理,我们来说什么是幻读就简单多了。举一个常见的场景,用户注册时,我们先查询用户名是否存在,不存在就插入,假定用户名是唯一索引。

    1. 小明开启事务current_version=6查询名字为’王五’的记录,发现不存在。

    2. 小红开启事务current_version=7插入一条数据,结果是这样:

    idNamecreate_versiondelete_version
    1张三1
    2李四2
    3王五7
    1. 小明执行插入名字’王五’的记录,发现唯一索引冲突,无法插入,这就是幻读。

    那你知道什么是间隙锁吗?

    间隙锁是可重复读级别下才会有的锁,结合MVCC和间隙锁可以解决幻读的问题。我们还是以user举例,假设现在user表有几条记录

    idAge
    110
    220
    330

    当我们执行:

    begin;
    select * from user where age=20 for update;
    
    begin;
    insert into user(age) values(10); #成功
    insert into user(age) values(11); #失败
    insert into user(age) values(20); #失败
    insert into user(age) values(21); #失败
    insert into user(age) values(30); #失败
    

    只有10可以插入成功,那么因为表的间隙mysql自动帮我们生成了区间(左开右闭)

    (negative infinity,10],(10,20],(20,30],(30,positive infinity)
    

    由于20存在记录,所以(10,20],(20,30]区间都被锁定了无法插入、删除。

    如果查询21呢?就会根据21定位到(20,30)的区间(都是开区间)。

    需要注意的是唯一索引是不会有间隙索引的。

    你们数据量级多大?分库分表怎么做的?

    首先分库分表分为垂直和水平两个方式,一般来说我们拆分的顺序是先垂直后水平。

    垂直分库

    基于现在微服务拆分来说,都是已经做到了垂直分库了

    垂直分表

    如果表字段比较多,将不常用的、数据较大的等等做拆分

    水平分表

    首先根据业务场景来决定使用什么字段作为分表字段(sharding_key),比如我们现在日订单1000万,我们大部分的场景来源于C端,我们可以用user_id作为sharding_key,数据查询支持到最近3个月的订单,超过3个月的做归档处理,那么3个月的数据量就是9亿,可以分1024张表,那么每张表的数据大概就在100万左右。

    比如用户id为100,那我们都经过hash(100),然后对1024取模,就可以落到对应的表上了。

    那分表后的ID怎么保证唯一性的呢?

    因为我们主键默认都是自增的,那么分表之后的主键在不同表就肯定会有冲突了。有几个办法考虑:

    1. 设定步长,比如1-1024张表我们设定1024的基础步长,这样主键落到不同的表就不会冲突了。
    2. 分布式ID,自己实现一套分布式ID生成算法或者使用开源的比如雪花算法这种
    3. 分表后不使用主键作为查询依据,而是每张表单独新增一个字段作为唯一主键使用,比如订单表订单号是唯一的,不管最终落在哪张表都基于订单号作为查询依据,更新也一样。

    分表后非sharding_key的查询怎么处理呢?

    1. 可以做一个mapping表,比如这时候商家要查询订单列表怎么办呢?不带user_id查询的话你总不能扫全表吧?所以我们可以做一个映射关系表,保存商家和用户的关系,查询的时候先通过商家查询到用户列表,再通过user_id去查询。
    2. 打宽表,一般而言,商户端对数据实时性要求并不是很高,比如查询订单列表,可以把订单表同步到离线(实时)数仓,再基于数仓去做成一张宽表,再基于其他如es提供查询服务。
    3. 数据量不是很大的话,比如后台的一些查询之类的,也可以通过多线程扫表,然后再聚合结果的方式来做。或者异步的形式也是可以的。
    List<Callable<List<User>>> taskList = Lists.newArrayList();
    for (int shardingIndex = 0; shardingIndex < 1024; shardingIndex++) {
        taskList.add(() -> (userMapper.getProcessingAccountList(shardingIndex)));
    }
    List<ThirdAccountInfo> list = null;
    try {
        list = taskExecutor.executeTask(taskList);
    } catch (Exception e) {
        //do something
    }
    
    public class TaskExecutor {
        public <T> List<T> executeTask(Collection<? extends Callable<T>> tasks) throws Exception {
            List<T> result = Lists.newArrayList();
            List<Future<T>> futures = ExecutorUtil.invokeAll(tasks);
            for (Future<T> future : futures) {
                result.add(future.get());
            }
            return result;
        }
    }
    

    说说mysql主从同步怎么做的吧?

    首先先了解mysql主从同步的原理

    1. master提交完事务后,写入binlog
    2. slave连接到master,获取binlog
    3. master创建dump线程,推送binglog到slave
    4. slave启动一个IO线程读取同步过来的master的binlog,记录到relay log中继日志中
    5. slave再开启一个sql线程读取relay log事件并在slave执行,完成同步
    6. slave记录自己的binglog

    由于mysql默认的复制方式是异步的,主库把日志发送给从库后不关心从库是否已经处理,这样会产生一个问题就是假设主库挂了,从库处理失败了,这时候从库升为主库后,日志就丢失了。由此产生两个概念。

    全同步复制

    主库写入binlog后强制同步日志到从库,所有的从库都执行完成后才返回给客户端,但是很显然这个方式的话性能会受到严重影响。

    半同步复制

    和全同步不同的是,半同步复制的逻辑是这样,从库写入日志成功后返回ACK确认给主库,主库收到至少一个从库的确认就认为写操作完成。

    那主从的延迟怎么解决呢?

    1. 针对特定的业务场景,读写请求都强制走主库
    2. 读请求走从库,如果没有数据,去主库做二次查询

    Redis

    说说Redis基本数据类型有哪些吧

    1. 字符串:redis没有直接使用C语言传统的字符串表示,而是自己实现的叫做简单动态字符串SDS的抽象类型。C语言的字符串不记录自身的长度信息,而SDS则保存了长度信息,这样将获取字符串长度的时间由O(N)降低到了O(1),同时可以避免缓冲区溢出和减少修改字符串长度时所需的内存重分配次数。
    2. 链表linkedlist:redis链表是一个双向无环链表结构,很多发布订阅、慢查询、监视器功能都是使用到了链表来实现,每个链表的节点由一个listNode结构来表示,每个节点都有指向前置节点和后置节点的指针,同时表头节点的前置和后置节点都指向NULL。
    3. 字典hashtable:用于保存键值对的抽象数据结构。redis使用hash表作为底层实现,每个字典带有两个hash表,供平时使用和rehash时使用,hash表使用链地址法来解决键冲突,被分配到同一个索引位置的多个键值对会形成一个单向链表,在对hash表进行扩容或者缩容的时候,为了服务的可用性,rehash的过程不是一次性完成的,而是渐进式的。
    4. 跳跃表skiplist:跳跃表是有序集合的底层实现之一,redis中在实现有序集合键和集群节点的内部结构中都是用到了跳跃表。redis跳跃表由zskiplist和zskiplistNode组成,zskiplist用于保存跳跃表信息(表头、表尾节点、长度等),zskiplistNode用于表示表跳跃节点,每个跳跃表的层高都是1-32的随机数,在同一个跳跃表中,多个节点可以包含相同的分值,但是每个节点的成员对象必须是唯一的,节点按照分值大小排序,如果分值相同,则按照成员对象的大小排序。
    5. 整数集合intset:用于保存整数值的集合抽象数据结构,不会出现重复元素,底层实现为数组。
    6. 压缩列表ziplist:压缩列表是为节约内存而开发的顺序性数据结构,他可以包含多个节点,每个节点可以保存一个字节数组或者整数值。

    基于这些基础的数据结构,redis封装了自己的对象系统,包含字符串对象string、列表对象list、哈希对象hash、集合对象set、有序集合对象zset,每种对象都用到了至少一种基础的数据结构。

    redis通过encoding属性设置对象的编码形式来提升灵活性和效率,基于不同的场景redis会自动做出优化。不同对象的编码如下:

    1. 字符串对象string:int整数、embstr编码的简单动态字符串、raw简单动态字符串
    2. 列表对象list:ziplist、linkedlist
    3. 哈希对象hash:ziplist、hashtable
    4. 集合对象set:intset、hashtable
    5. 有序集合对象zset:ziplist、skiplist

    Redis为什么快呢?

    redis的速度非常的快,单机的redis就可以支撑每秒10几万的并发,相对于mysql来说,性能是mysql的几十倍。速度快的原因主要有几点:

    1. 完全基于内存操作
    2. C语言实现,优化过的数据结构,基于几种基础的数据结构,redis做了大量的优化,性能极高
    3. 使用单线程,无上下文的切换成本
    4. 基于非阻塞的IO多路复用机制

    那为什么Redis6.0之后又改用多线程呢?

    redis使用多线程并非是完全摒弃单线程,redis还是使用单线程模型来处理客户端的请求,只是使用多线程来处理数据的读写和协议解析,执行命令还是使用单线程。

    这样做的目的是因为redis的性能瓶颈在于网络IO而非CPU,使用多线程能提升IO读写的效率,从而整体提高redis的性能。

    (见网络篇下单线程Reactor模型)

    知道什么是热key吗?热key问题怎么解决?

    所谓热key问题就是,突然有几十万的请求去访问redis上的某个特定key,那么这样会造成流量过于集中,达到物理网卡上限,从而导致这台redis的服务器宕机引发雪崩。

    针对热key的解决方案:

    1. 提前把热key打散到不同的服务器,降低压力
    2. 加入二级缓存,提前加载热key数据到内存中,如果redis宕机,走内存查询

    什么是缓存击穿、缓存穿透、缓存雪崩?

    缓存击穿

    缓存击穿的概念就是单个key并发访问过高,过期时导致所有请求直接打到db上,这个和热key的问题比较类似,只是说的点在于过期导致请求全部打到DB上而已。

    解决方案:

    1. 加锁更新,比如请求查询A,发现缓存中没有,对A这个key加锁,同时去数据库查询数据,写入缓存,再返回给用户,这样后面的请求就可以从缓存中拿到数据了。
    2. 将过期时间组合写在value中,通过异步的方式不断的刷新过期时间,防止此类现象。

    https://tva

    缓存穿透

    缓存穿透是指查询不存在缓存中的数据,每次请求都会打到DB,就像缓存不存在一样。

    针对这个问题,加一层布隆过滤器。布隆过滤器的原理是在你存入数据的时候,会通过散列函数将它映射为一个位数组中的K个点,同时把他们置为1。

    这样当用户再次来查询A,而A在布隆过滤器值为0,直接返回,就不会产生击穿请求打到DB了。

    显然,使用布隆过滤器之后会有一个问题就是误判,因为它本身是一个数组,可能会有多个值落到同一个位置,那么理论上来说只要我们的数组长度够长,误判的概率就会越低,这种问题就根据实际情况来就好了。

    缓存雪崩

    当某一时刻发生大规模的缓存失效的情况,比如你的缓存服务宕机了,会有大量的请求进来直接打到DB上,这样可能导致整个系统的崩溃,称为雪崩。雪崩和击穿、热key的问题不太一样的是,他是指大规模的缓存都过期失效了。

    针对雪崩几个解决方案:

    1. 针对不同key设置不同的过期时间,避免同时过期
    2. 限流,如果redis宕机,可以限流,避免同时刻大量请求打崩DB
    3. 二级缓存,同热key的方案。

    Redis的过期策略有哪些?

    redis主要有2种过期删除策略

    惰性删除

    惰性删除指的是当我们查询key的时候才对key进行检测,如果已经达到过期时间,则删除。显然,他有一个缺点就是如果这些过期的key没有被访问,那么他就一直无法被删除,而且一直占用内存。