• 问:如何才能让mysql 出现一个死锁呢?

举个例子吧,我们用两个mysql 的客户端,分别起两个事务啊,然后在第一个事务里面去修改id=1的行。呃,然后第二个事务的话就去修改id=2的这一行。呃,这个时候呢呃第一个事务已经拿到了id=1这一行的锁啊,第二个事务呢就已经拿到了id=2这个行的锁。然后呢在第一个事务里面呃再去修改id=2这一行啊,然后再去第二个事务里面去修改id=1这一行啊。这个时候呢第一个事务会尝试拿id=2这一行的锁啊,但是拿不到啊,因为已经被第二个事务锁住了啊。然后第二个事务呢也尝试去拿id=1这一行的锁也拿不到啊。因为被第一个事务锁住了啊,这样的话就形成了死锁。

  • 问:发生了死锁怎么办呢?

呃,我知道的就那么几种,一个是设置锁等待的超时时间。还有一个就是开启mysql 检查死锁的功能。但是这两种方式都不是特别好。要是依靠锁等待超时来解这个死锁的话,这个超时时间不是特别好把握。比如超时时间设置短了,就有可能导致一些正常的事务回滚了。那如果设置长了,就会导致阻塞时间比较长,然后影响系统的性能。那mysql 死锁检查功能的话,性能也不是特别好。它是一个O(n)的算法,并发事务越多那么这个死锁检查就越耗时。然后检查到死锁完成了之后呢,mysql 就会kill 掉一个自己认为代价最小的事务。要是想完全避免死锁也是不可能的。我们只能说尽可能提前预防。然后设置一个相对保守一点的等待超时时间啊,不能影响正常的业务嘛。比如我们可以让一个DB只归一个的服务进行读写,然后这个服务自己内部在获取锁的时候,尽可能按顺序获取。比如先对数据进行排序,然后再去修改。那这样的话就可以按顺序拿到行锁啊,就可以最大程度避免死锁了。

  • 问:mysql 索引的问题比较基础,我就不问了。我们聊聊mysql 写方面的问题吧。mysql 写操作是直接修改的文件吗?

mysql 在修改数据的时候主要看内存池里面有没有缓存要修改的这条记录。是这样的,Innodb会维护一个内存池,里面维护的是16k的数据页。要是我们要修改的这个数据页已经被加载到了内存池里面的话,我们直接改内存池里面的页就行了。然后会生成redo log,还有binlog。那这个被修改的一页就成了脏页。那mysql 就会在之后合适的时间点啊,把这个脏页刷回到磁盘。要是刷脏页之前有查询请求来的话,就直接返回脏页里面的最新数据就行了。要是修改的目标页没有加载到内存池里面的话,那这个Innodb就会写一个change buffer 缓冲区记录要把哪一行的哪些列修改成哪些值啊。然后等到后面有其他事务要读取页里面的内容的话,对应的页就会加载到内存池。那这个时候就会应用change buffer 里面的修改。那这样的话,这个页就变成了脏页,然后查询结果也是从这个脏页返回的。那要是我们的修改涉及到了唯一索引的话,那就走不了change buffer 了啊。因为要保证唯一的话就需要把整个唯一索引的全部也都加载到内存里面,然后进行检查,然后就变成前面说的修改内存页,然后再把脏页刷回磁盘,变成这个逻辑了。

  • 问:那修改完了内存页之后,脏页写回磁盘之前啊,如果Mysql宕机了,他是如何保证这些修改不丢的呢?

主要是依靠binlog,还有redo log 这两个日志实现的。这两种日志都在内存里面对应了一个buffer 缓冲区。bin log 的话就是bin log buffer。redo log 的话就是redo log buffer。在这两种日志写入的时候都是先写内存buffer,然后buffer 数据就会刷到里面。然后是调那个sync方法,把日志刷到磁盘的。mysql 里面有两个开关,一个是用来控制redo log 刷盘的,另一个是用来控制bin log 刷盘的。这两个都要设置成1才能保证每次事务提交的时候redo log,还有bin log 刷到磁盘数据才不会丢。有些场景会调整这两个参数,变成每秒刷一次盘。比如批量导入数据,或者是那种新上的从库追主库的时候,这些场景都是那种操作时间比较短,要求写入性能高,一般不会出现宕机的场景。要是正常线上业务的话,这两个开关需要都设置成1才能保证数据修改不会丢。

  • 问:每次事务提交都刷磁盘,岂不是性能很差?

mysql 会对事务进行分组提交,就是多个事务的redo log,还有binlog 一起刷盘。然后这两个日志都是顺序写文件啊,那就是批量顺序写了啊,这个速度是非常快的。

  • 那为什么要刷两种log 呢?我只刷一种日志不行吗?

在我看来,两种日志的使用场景是不一样的。redo log 是Innodb这个存储层的日志。然后bin log 的话是mysql server 层的日志。redo log 它只能用来恢复单机的数据啊,它是物理文件的日志。比如说记录哪个数据页修改了哪些内容。那那个binlog 的话可能会被下游的从库或者是缓存之类的组件消费,可以认为是实现集群最终一致性的手段。所以它是以类似于SQL语句这种标准的形式记录。

  • 问:redo log 和bin log 会不会出现不一致的情况啊,比如binlog 写成功了,read log 写不成功之类的情况

不会出现这种情况。mysql 使用两阶段提交的方式,来保证两种日志的一致性。mysql 在事务提交的时候,会先写redo log。这里像redo log 写入prepare 状态,然后再写bin log,然后再写redo log 的commit 状态。只有在这个redo log 的prepare 状态,还有bin log 都写入成功的话,才认为这个事务提交成功了。这个时候binlog 也产生了。那下游从库也能感知到这个事务了。要是binlog 写入失败了,这个本地事务就要回滚。

  • 问:好,我们再来聊聊redis 吧。你用过redis 里面的哪些数据结构。

呃,我用过字符串,哈希表,Set集合,列表,有序列表,还了解过stream。

  • 问:这些数据结构底层的实现了解过吗?可以说说你了解过的实现。

String 的话在redis 里面是使用一个字节数组实现的。然后还有一个用来记录数组长度的字段,还有一个字段是用来记录这个数组实际的使用长度。然后它封装了多个SDS结构体实现。比如SDS8,SDS16一直到SDS64。比如说SDS8这个实现啊,它里面用于记录长度的这两个字段分别占了8位。然后SDS64里面这两个字段分别占了64位。之所以这样实现的,是因为redis 里面很多数据都是字符串存储的。那要是每个字符串节省一个字节,那数据量大了节省的空间就比较多了。然后列表的话,底层实现是quicklist 的结构。呃,quicklist 的话是一个列表结构。列表里面每一个节点都维护一个ziplist。ziplist的话才是真正存放数据的地方。然后ziplist是一个连续的内存空间啊,然后里面存放了真正的数据啊。然后除了真正的数据之外,里面还有很多控制信息啊,控制信息非常复杂。具体是什么我现在也有点记不清了。然后这个quick list 只会在首尾的几个节点存储原样的ziplist。而中间部分的ziplist全部都被压缩了。我记得默认使用的是LZF压缩算法。之所以这么做呢,就是redis 做了一个假设啊,假设链表会经常从两端去读写数据啊,中间的这些数据很少会被使用到了。再就是哈希表。哈希表的话,如果里面存储的键值对都小于一个阈值。然后键值对的数量也小于一个阈值的话啊,它就可以使用ziplist这种连续的内存空间储存。如果超出了这个阈值的话,就会转化成字典进行存储。字典结构的话和我们java 里面的HashMap就很类似了啊,是一个数组啊。然后哈希冲突的值会放到一个哈希桶里面啊,然后底下挂一个链表。还有就是set结构的话,就是set里面的元素,全部都能转化成整数的话,就会用inset这种数据结构存储。它底层的话其实就是一个数组。如果元素不全是整数或者是元素的个数超过了某个域值之后,它也会转化成字典结构进行存储。字典的key 用来存储具体的元素。然后value 的话就是一个空值。有序集合的话,也是有两个限制条件的啊,一个是单个键值对的大小,还有就是键值对的数量。如果这两个都没有超过阈值的话,就使用ziplist进行存储。如果超过的话,就会转化成跳表进行存储。那这个跳表的话,它是一个多层链表的结构。最底层的话就包含了全部的元素。然后这些元素都是按照score 进行排序的。然后上面的层呢就是索引啊,这些索引呢也都是按照score 进行排序的。然后层级越往上元素越稀疏。这样的话就可以按照score 进行快速的查询。然后效率的话应该是呃O(logn)。redis 的数据结构都是尝试在数据量比较少的时候,使用连续空间去存储数据。这主要就是为了省那个指针这种无效负载占用的空间啊,能省多少就省多少嘛。还有就是stream。stream 的话就是redis 里面用来做消息队列的一个数据结构。它底层的话是用Rax树,还有listpack 实现的。Rax树是一种前缀压缩树,用来存储消息的i d 。Rax树节点里面维护的指针,指向的就是listpack 列表。listpack 列表的话其实也是一个连续的内存空间。它是一个简单版的ziplist。没有ziplist那么复杂。redis 的stream 消费侧的概念就参考了Kafka consumer然后抽象出了consumer group,还有offset 这些概念。我了解的大概就这样了。

  • 问:Redis 哈希表如何扩容的了解过吗?

这个哈希表在扩容的时候使用的是渐进式扩容。reHash里面有两个比较关键的字段,一个是reHash的状态,另一个是reHash的指针。如果这个哈希表处于reHash的状态,我们每次读写这个哈希表的时候啊,都会从reHash的这个指针那儿开始迭代。如果迭代到下一个哈希桶不是空的啊,那就把这个哈希桶里面的元素值进行reHash。然后放到扩容之后的哈希数组里面去。那如果迭代到的哈希桶是空的啊,那就继续迭代下一个桶。如果连续碰到十个空的哈希桶,那么reHash就结束了。然后这个哈希指针就会移动到最后一个空哈希桶的位置,然后再reHash的时候就从这里开始。之所以会采用渐进式的这种扩容方式呢,就是因为redis 执行命令的线程是单线程的。如果一次对整个哈希表进行reHash扩容的话,那整个redis 服务就阻塞了,所以分散到每次东西里面。每次reHash一点点啊,慢慢就reHash完了。

  • 问:好,你之前使用过的Redis 的持久化是什么?

主要是使用AOF,还有RDB这两种持久化方式。线上的话我们使用的是AOF。然后RDB的话只作为定期的冷备。

  • 问:简单说说这两种持久化的区别吧。

RDB持久化的话,就相当于给redis 内存做了一个快照。他是Fork 出一个子进程来执行的。Fork出来的子进程就使用了copyOnWrite,然后子进程里面的内存和Fork 时刻的主进程的内存是完全一致的。之后主进程进行修改也只会修改自己的内存,子进程的内存不会发生任何变化。然后这个子进程呢会把redis 里面的键值对全部扫一遍,然后持久化到RDB文件里面。RDB文件是一个二进制的文件。里面有个文件头啊,然后再就是一些控制信息啊,然后是按照redis 自己的格式啊,存储了不同类型的键值的数据啊,最后再加上一个文件尾。AOF的话是直接把收到的redis 命令按照文本的方式直接换到了AOF文件里面啊,它是类似于一个日志格式的样子。我们直接打开AOF这个文件的话大概能够看到里面记录的redis 命令的。这个写AOF日志是在redis 主线程里面完成的,然后的话redis 运行时间久了之后啊,这个AOF它会不断膨胀。因为它每条写命令都记录,然后redis 又提供了一个aof rewrite 功能。aof rewrite 是单独Fork一个子进程来完成的。这个rewrite的思路其实就是合并一个key 的多次修改。你比如说我开始把这个key 设置成了1这个值,然后过一段时间之后我把key 设置成了2,那内存里面肯定就是等于2嘛。这个时候AOF只需要记录set key 等于2这个这一条命令就可以了,就没有必要再记录,set key 等于1这条命令。redis 的实现其实就是直接dump redis 内存。没有走合并命令的思路,如果同时开启了混合持久化的话,aof rewrite 会先执行RDB持久化的逻辑,就是先生成一个RDB快照啊。生成完了之后呢把它放到那个AOF 的文件头里面去。然后呢再往后追加AOF日志,那这样的话在使用AOF文件恢复数据的时候,既用到了RDB加载快的特性,又可以用AOF来保存RDB之后的数据。

  • 问:如果我开启了持久化之后,redis宕机的时候是不是就能保证不丢失数据呢?

这个要看AOF的刷盘策略,我记得它是有三个刷盘策略的,一个是每次来一个AOF日志都会刷盘。还有就是定时刷盘啊,默认是每秒刷一次。再就是完全交给操作系统自己去控制是不是刷盘。如果想完全不丢的话,需要开启那个每条命令都刷盘的策略。但这样的话就会影响主线程的速度。还有一个思路,就是使用主从的方式来解决。我把这个命令同步到从库上。保证至少有一定数量的从库能够同步到这条命令的话也可以认为这条命令是不会丢的啊,不可能全部从库都挂了,对吧