记录些Spring+题集(57)

Rocketmq消息0丢失,如何实现?

消息的发送流程

Rocketmq和KafKa类似(实质上,最早的Rocketmq 就是KafKa 的Java版本),一条消息从生产到被消费,将会经历三个阶段:

  • 生产阶段,Producer 新建消息,而后经过网络将消息投递给 MQ Broker。这个发送可能会发生丢失,比如网络延迟不可达等。

  • 存储阶段,消息将会存储在 Broker 端磁盘中,Broker 根据刷盘策略持久化到硬盘中,刚收到Producer的消息在内存中了,但是如果Broker 异常宕机了,导致消息丢失。

  • 消费阶段, Consumer 将会从 Broker 拉取消息

图片

以上任一阶段, 都可能会丢失消息,只要这三个阶段0丢失,就能够完全解决消息丢失的问题。

生产阶段如何实现0丢失方式

生产阶段有三种send方法:

  • 同步发送

  • 异步发送

  • 单向发送。

三种send方法的 客户端api,具体如下:

/**
 * {@link org.apache.rocketmq.client.producer.DefaultMQProducer}
 */

// 同步发送
public SendResult send(Message msg) throws MQClientException, RemotingException,      MQBrokerException, InterruptedException {}

// 异步发送,sendCallback作为回调
public void send(Message msg,SendCallback sendCallback) throws MQClientException, RemotingException, InterruptedException {}

// 单向发送,不关心发送结果,最不靠谱
public void sendOneway(Message msg) throws MQClientException, RemotingException, InterruptedException {}

produce要想发消息时保证消息不丢失,可以采用同步发送的方式去发消息,send消息方法只要不抛出异常,就代表发送成功。

发送成功会有多个SendResult 状态,以下对每个状态进行说明:

  • SEND_OK:消息发送成功,Broker刷盘、主从同步成功

  • FLUSH_DISK_TIMEOUT:消息发送成功,但是服务器同步刷盘(默认为异步刷盘)超时(默认超时时间5秒)

  • FlUSH_SLAVE_TIMEOUT:消息发送成功,但是服务器同步复制(默认为异步复制)到Slave时超时(默认超时时间5秒)

  • SLAVE_NOT_AVAILABLE:Broker从节点不存在

注意:同步发送只要返回以上四种状态,就代表该消息在生产阶段消息正确的投递到了RocketMq,生产阶段没有丢失。

如果业务要求严格,可以使用同步发送,并且只取SEND_OK标识消息发送成功,

其他返回值类型的数据,在业务维度的 终极0丢失保护措施:本地消息表+定时扫描 

同步发送还是异步发送

AP 和 CP 是天然的矛盾, 到底是 CP 还是 AP的 需要权衡

  • 同步发送的方式 是 CP ,高可靠,但是性能低。

  • 异步发送的方式 是 AP ,低可靠,但是性能高。

为了高可靠(CP),可以采取同步发送的方式进行发送消息,发消息的时候会同步阻塞等待broker返回的结果,如果没成功,则不会收到SendResult,这种是最可靠的。

其次是异步发送,再回调方法里可以得知是否发送成功。

最后,单向发送(OneWay)是最不靠谱的一种发送方式,我们无法保证消息真正可达。

当然,具体的如何选择高可用方案,还是要看业务。

为了确保万无一失,可以选择异步发送 + 业务维度的 终极0丢失保护措施 , 实现消息的0丢失。

生产端的失败重试策略

发送消息如果失败或者超时了,则会自动重试。

同步发送默认是重试三次,可以根据api进行更改,比如改为10次:

producer.setRetryTimesWhenSendFailed(10);

其他模式是重试1次,具体请参见源码

/**
 * {@link org.apache.rocketmq.client.producer.DefaultMQProducer#sendDefaultImpl(Message, CommunicationMode, SendCallback, long)}
 */

// 自动重试次数,this.defaultMQProducer.getRetryTimesWhenSendFailed()默认为2,如果是同步发送,默认重试3次,否则重试1次
int timesTotal = communicationMode == CommunicationMode.SYNC ? 1 + this.defaultMQProducer.getRetryTimesWhenSendFailed() : 1;

int times = 0;
for (; times < timesTotal; times++) {
      // 选择发送的消息queue
    MessageQueue mqSelected = this.selectOneMessageQueue(topicPublishInfo, lastBrokerName);
    if (mqSelected != null) {
        try {
            // 真正的发送逻辑,sendKernelImpl。
            sendResult = this.sendKernelImpl(msg, mq, communicationMode, sendCallback, topicPublishInfo, timeout - costTime);
            switch (communicationMode) {
                case ASYNC:
                    return null;
                case ONEWAY:
                    return null;
                case SYNC:
                    // 如果发送失败了,则continue,意味着还会再次进入for,继续重试发送
                    if (sendResult.getSendStatus() != SendStatus.SEND_OK) {
                        if (this.defaultMQProducer.isRetryAnotherBrokerWhenNotStoreOK()) {
                            continue;
                        }
                    }
                    // 发送成功的话,将发送结果返回给调用者
                    return sendResult;
                default:
                    break;
            }
        } catch (RemotingException e) {
            continue;
        } catch (...) {
            continue;
        }
    }
}

上面的核心逻辑中,调用sendKernelImpl真正的去发送消息

通过核心的发送逻辑,可以看出如下:

  • 同步发送场景的重试次数是1 + this.defaultMQProducer.getRetryTimesWhenSendFailed() =3,其他方式默认1次。

  • this.defaultMQProducer.getRetryTimesWhenSendFailed()默认是2,我们可以手动设置producer.setRetryTimesWhenSendFailed(10);

  • 如果是同步发送sync,且发送失败了,则continue,意味着还会再次进入for,继续重试发送

同步模式下,可以设置严格的消息重试机制,比如设置 RetryTimes为一个较大的值如10。当出现网络的瞬时抖动时,消息发送可能会失败,retries 较大,能够自动重试消息发送,避免消息丢失。

Broker端保证消息不丢失的方法:

正常情况下,只要 Broker 在正常运行,就不会出现丢失消息的问题。

但是如果 Broker 出现了故障,比如进程死掉了或者服务器宕机了,还是可能会丢失消息的。

如果确保万无一失,实现Broker端保证消息不丢失,有两板斧:

  • Broker端第一板斧:设置严格的副本同步机制

  • Broker端第二板斧:设置严格的消息刷盘机制

Broker端第一板斧:设置严格的副本同步机制

RocketMQ 通过多副本机制来解决的高可用,核心思想也挺简单的:如果数据保存在一台机器上你觉得可靠性不够,那么我就把相同的数据保存到多台机器上,某台机器宕机了可以由其它机器提供相同的服务和数据。

首先,Broker需要集群部署,通过主从模式包括 topic 数据的高可用。

为了消息0丢失,可以配置设置严格的副本同步机制,等Master 把消息同步给 Slave后,才去通知Producer说消息ok。

设置严格的副本同步机制 , RocketMQ 修改broker刷盘配置如下:

所以我们还可以配置不仅是等Master刷完盘就通知Producer,而是等Master和Slave都刷完盘后才去通知Producer说消息ok了。

## 默认为 ASYNC_MASTER
brokerRole=SYNC_MASTER

Broker端第二板斧:设置严格的消息刷盘机制

RocketMQ持久化消息分为两种:同步刷盘和异步刷盘。

RocketMQ和kafka一样的,刷盘的方式有同步刷盘和异步刷盘两种。

  • 同步刷盘指的是:生产者消息发过来时,只有持久化到磁盘,RocketMQ、kafka的存储端Broker才返回一个成功的ACK响应,这就是同步刷盘。它保证消息不丢失,但是影响了性能。

  • 异步刷盘指的是:消息写入PageCache缓存,就返回一个成功的ACK响应,不管消息有没有落盘,就返回一个成功的ACK响应。这样提高了MQ的性能,但是如果这时候机器断电了,就会丢失消息。

同步刷盘和异步刷盘的区别如下:

  • 同步刷盘:当数据写如到内存中之后立刻刷盘(同步),在保证刷盘成功的前提下响应client。

  • 异步刷盘:数据写入内存后,直接响应client。异步将内存中的数据持久化到磁盘上。

同步刷盘和异步输盘的优劣:

  • 同步刷盘保证了数据的可靠性,保证数据不会丢失。

  • 同步刷盘效率较低,因为client获取响应需要等待刷盘时间,为了提升效率,通常采用批量输盘的方式,每次刷盘将会flush内存中的所有数据。(若底层的存储为mmap,则每次刷盘将刷新所有的dirty页)

  • 异步刷盘不能保证数据的可靠性.

  • 异步刷盘可以提高系统的吞吐量.

  • 常见的异步刷盘方式有两种,分别是定时刷盘和触发式刷盘。定时刷盘可设置为如每1s刷新一次内存.触发刷盘为当内存中数据到达一定的值,会触发异步刷盘程序进行刷盘。

Broker端第二板斧:设置严格的消息刷盘机制,设置为Kafka同步刷盘。

RocketMQ默认情况是异步刷盘,Broker收到消息后会先存到cache里,然后通知Producer说消息我收到且存储成功。Broker起个线程异步的去持久化到磁盘中,但是Broker还没持久化到磁盘就宕机的话,消息就丢失了。

同步刷盘的话是收到消息存到cache后并不会通知Producer说消息已经ok了,而是会等到持久化到磁盘中后才会通知Producer说消息完事了。

  • 同步刷盘的方式 是 CP ,高可靠,但是性能低。

  • 异步刷盘的方式 是 AP ,低可靠,但是性能高。

为了高可靠(CP),可以采取同步刷盘保障了消息不会丢失,但是性能不如异步高。

如何设置RocketMQ同步刷盘?

RocketMQ 修改broker刷盘配置如下:

## 默认情况为 ASYNC_FLUSH,修改为同步刷盘:SYNC_FLUSH,实际场景看业务,同步刷盘效率肯定不如异步刷盘高。
flushDiskType = SYNC_FLUSH

对应的RocketMQ源码类如下:

package org.apache.rocketmq.store.config;

public enum FlushDiskType {
    // 同步刷盘
    SYNC_FLUSH,
    // 异步刷盘(默认)
    ASYNC_FLUSH
}

异步刷盘默认10s执行一次,源码如下:

/*
 * {@link org.apache.rocketmq.store.CommitLog#run()}
 */

while (!this.isStopped()) {
    try {
        // 等待10s
        this.waitForRunning(10);
        // 刷盘
        this.doCommit();
    } catch (Exception e) {
        CommitLog.log.warn(this.getServiceName() + " service has exception. ", e);
    }
}

Broker端0丢失的配置总结

Broker端的配置,若想很严格的保证Broker存储消息阶段消息不丢失,则需要如下配置

# master 节点配置
flushDiskType = SYNC_FLUSH
brokerRole=SYNC_MASTER

# slave 节点配置
brokerRole=slave
flushDiskType = SYNC_FLUSH

上面这个配置含义是:

Producer发消息到Broker后,Broker的Master节点先持久化到磁盘中,然后同步数据给Slave节点,Slave节点同步完且落盘完成后才会返回给Producer说消息ok了。

严格的消息刷盘机制 + 严格的消息同步机制,能够确保 Broker端保证消息不丢失

Consumer(消费者)保证消息不丢失的方法:

如果要保证 Consumer(消费者)0 丢失, Consumer 端的策略是啥呢?

普通的情况下,RocketMQ拉取消息后,执行业务逻辑。

一旦Consumer执行成功,将会返回一个ACK响应给 Broker,这时MQ就会修改offset,将该消息标记为已消费,不再往其他消费者推送消息。

如果出现消费超时(默认15分钟)、拉取消息后消费者服务宕机等消费失败的情况,此时的Broker由于没有等到消费者返回的ACK,会向同一个消费者组中的其他消费者间隔性的重发消息,直到消息返回成功(默认是重复发送16次,若16次还是没有消费成功,那么该消息会转移到死信队列,人工处理或是单独写服务处理这些死信消息)

但是 消费者,也有两种消费模式:

  • 同步消费,消费线程完成业务操作

  • 异步消息 ,独立业务线程池 完成业务操作

在RocketMQ 在并发消费模式下,这个模式,默认有20个消费线程:

如何保证客户端的高可用,两种场景:

  • 同步消费场景,业务代码手动发送CONSUME_SUCCESS ,保证 消息者的0丢失

  • 异步消费场景,需要通过业务维度的 终极0丢失保护措施:本地消息表+定时扫描 ,保证 消息者的0丢失

1、同步消费发送CONSUME_SUCCESS

同步消费指的是拉取消息的线程,先把消息拉取到本地,然后进行业务逻辑,业务逻辑完成后手动进行ack确认,这时候才会真正的代表消费完成。举个例子

 consumer.registerMessageListener(new MessageListenerConcurrently() {
     @Override
     public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
         for (MessageExt msg : msgs) {
             String str = new String(msg.getBody());
             
             // 消费者 线程 同步进行  业务处理
             System.out.println(str);
         }
         // ack,只有等上面一系列逻辑都处理完后,
         // 发 CONSUME_SUCCESS才会通知broker说消息消费完成,
         // 如果上面发生异常没有走到这步ack,则消息还是未消费状态。
         return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
     }
 });

2、异步消费场景,如何保证0丢失

RocketMQ 在并发消费模式下,默认有20个消费线程,但是这个还是有限制的。

如果实现高性能呢?

  • 可以一方面去进行线程扩容, 比如通过修改配置,扩容到100个RocketMQ 同步消费线程,但是这个会在没有 活儿干的场景,浪费宝贵的CPU资源。

  • 可以另一方便通过异步的 可动态扩容的业务专用线程池,去完成 消费的业务处理。那么,如果是设置了业务的专用线程池,则需要通过业务维度的 终极0丢失保护措施:本地消息表+定时扫描 ,保证 消息者的0丢失

3、业务维度的 终极0丢失保护措施

本地消息表+定时扫描 方案,和本地消息表事务机制类似,也是采用 本地消息表+定时扫描 相结合的架构方案。

图片

1、设计一个本地消息表,可以存储在DB里,或者其它存储引擎里,用户保存消息的消费状态

2、Producer 发送消息之前,首先保证消息的发生状态,并且初始化为待发送;

3、如果消费者(如库存服务)完成的消费,则通过RPC,调用Producer 去更新一下消息状态;

4、Producer 利用定时任务扫描 过期的消息(比如10分钟到期),再次进行发送。

本地消息表+定时扫描 的架构方案 ,是业务层通过额外的机制来保证消息数据发送的完整性,是一种很重的方案。这个方案的两个特点:

  • CP 不是 AP,性能低

  • 需要 做好幂等性设计

如果降低业务维度的 终极0丢失保护措施带来的性能耗损?

可以减少本地消息表的规模,对于正常投递的消息不做跟踪,只把生产端发送失败的消息、消费端消费失败的消息记录到数据库,并启动一个定时任务,扫描发送失败的消息,重新发送直到超过阈值(如10次),超过之后,发送邮件或短信通知人工介入处理。

RocketMQ的0丢失的最佳实践

  1. Producer端:使用同步发送方式发送消息,可以提高可靠性。

    记住,如果使用带有回调通知的 send异步 方法发送去提高性能,则需要结合 本地消息表+定时扫描 的架构,去实现业务维度的 高可靠。

  2. Producer端:同步模式下,可以设置严格的消息重试机制,比如设置 RetryTimes为一个较大的值如10。当出现网络的瞬时抖动时,消息发送可能会失败,retries 较大,能够自动重试消息发送,避免消息丢失。

  3. Broker 端:设置严格的副本同步机制 。

  4. Broker 端:设置严格的消息刷盘机制。

  5. Consumer 端:确保消息消费完成再提交。可以使用同步消费,并发送CONSUME_SUCCESS。

  6. 业务维度的的0丢失架构:采用 本地消息表+定时扫描 架构方案,实现业务维度的 0丢失,100%可靠性。

MySQL为什么用B+树,不用跳表? 

索引是帮助MySQL高效获取数据的数据结构,注意,是帮助高性能的获取数据

索引好比是一本书的目录,可以直接根据页码找到对应的内容,目的就是为了加快数据库的查询速度

  • 索引是对数据库表中一列或多列的值进行排序的一种结构,使用索引可快速访问数据库表中的特定信息。

  • 索引是一种能帮助mysql提高了查询效率的数据结构:索引数据结构

索引的存储原理大致可以概括为一句话:以空间换时间

数据库在未添加索引, 进行查询的时候默认是进行全文搜索,也就是说有多少数据就进行多少次查询,然后找到相应的数据就把它们放到结果集中,直到全文扫描完毕。

数据库添加了索引之后,通过索引快速找到数据在磁盘上的位置,可以快速的读取数据,而不同从头开始全表扫描。

一般来说索引本身也很大,不可能全部存储在内存中,因此索引往往是存储在磁盘上的文件中的(可能存储在单独的索引文件中,也可能和数据一起存储在数据文件中)。

索引的作用和重要性

  • 加快数据检索速度

    索引允许数据库系统快速定位到符合查询条件的记录,从而显著提高查询操作的效率。

  • 降低数据库IO成本

    通过索引,数据库在查询时需要读取的数据量减少,这样可以减少磁盘IO操作的次数和压力,进而提升整体的数据库性能。

  • 保证数据的完整性

    索引可以包含唯一性约束,这有助于确保表中数据的唯一性,防止出现重复记录。

  • 加速表连接

    在涉及多表查询时,索引可以帮助加速表与表之间的连接操作,实现表与表之间的参照完整性。

  • 优化排序和分组操作

    当使用分组、排序等操作进行数据检索时,索引可以显著减少处理的数据量,从而提高这些操作的效率。

B+树和跳表的整体结构

整体上,B+树和跳表 都是 链表+ 多级索引组合 的结构

图片

什么是MySQL中的B+Tree

MySQL中的B+Tree 原理

  • B+Tree一般由多个页、多层级组成,在MySQL中每个页 16 KB。

  • 主键索引的 B+ 树的叶子结点才是数据,非叶子结点(内节点)存放的是索引信息。

  • 上下层的页通过单指针相连。

  • 同一层级的相邻的数据页通过双指针相邻。

  • B+Tree的结构

    图片

B+Tree的查询过程

B+Tree是由多个页组成的多层级结构,每个页16kb,对于主键索引来说,最末级的叶子节点放行数据,非叶子节点放的是索引信息(主键ID和页号),用于加速查询。

我们想要查询数据5,会从顶层页的record开始,record里包含了主键Id和页号(页地址),顶层页 向左最小id是1,最右最小id是7,那id=5的数据如果存在,那必定在顶层页 左边箭头,于是顺着的record的页地址就到了6号数据页里,再判断id=5>4,所以肯定在右边的数据页里,于是加载105号数据页。

105号数据页里,虽然有多行数据,但也不是挨个遍历的,数据页内还有个页目录的信息,里边是有序的。

所以,数据页内可以通过二分查找的方式加速查询行数据,于是找到id=5的数据行,完成查询。

从上面可以看出,B+Tree利用了空间换时间的方式,将查询时间复杂度从O(n)优化为O(lg(n))

B+Tree的优点和缺点

  • B+Tree是一种平衡树结构,它具有根节点、内部节点和叶子节点。

  • 每个节点包含一定数量的键值对,键值对按键值大小有序排列。

  • 内部节点只包含键,叶子节点同时包含键和指向数据的指针。

B+Tree的优点

  • 范围查询效率高:B+Tree支持范围查询,因为在B+Tree中,相邻的叶子节点是有序的,所以在查找范围内的数据时非常高效。

  • 事务支持:B+Tree是一种多版本并发控制(MVCC)友好的数据结构,适用于事务处理场景,能够保证事务的ACID属性。

  • 数据持久性:B+Tree的叶子节点包含所有数据,这意味着数据非常容易持久化到磁盘上,支持高可靠性和数据恢复。

B+Tree的缺点

  • 插入和删除开销较高:由于B+Tree的平衡性质,插入和删除操作可能需要进行节点的分裂和合并,这会导致性能开销较大。

  • 高度不稳定:B+Tree的高度通常比较大,可能需要多次磁盘I/O才能访问叶子节点,对于某些特定查询可能效率不高。

跳表

跳表的原理

跳表是一种采用了用空间换时间思想的数据结构。

跳表会随机地将一些节点提升到更高的层次,以创建一种逐层的数据结构,以提高操作的速度。

跳表的结构

跳表的做法就是给链表做索引,而且是分层索引,

单层跳表

单层跳表, 可以退化到一个链表

查找的时间复杂度是 O(N)

图片

两层跳表

两层跳表 = 原始链表 + 一层索引

图片

两层跳表查询

如查询id=11的数据,我们先在上层遍历,依次判断1,6,12,很快就可以判断出11在6到12之间

第二步,然后往下一跳,进入原始链表,就可以在遍历6,7,8,9,10,11之后,确定id=11的位置。

通过第一级索引,直接将查询范围从原来的1到11,缩小到现在的1,6,7,8,9,10,11。

三层跳表

三层跳表 = 原始链表 + 第一层索引 + 第二层索引

图片

三层跳表查询

如果还是查询id=11的数据,就只需要查询1,6,9,10,11就能找到,比两层的时候更快一些。

图片

跳表查找的时间复杂度

在一个单链表中查询某个数据的时间复杂度是 O(n)。也就是说,单层的跳表, 时间复杂度是 O(n)。

跳表 就是 为链表 增加多级索引, 完成空间换时间, 实现 时间复杂度是 O(logn)。

图片

这个时间复杂度的分析方法比较难想到。

先问题分解一下,先来看这样一个问题,如果链表里有 n 个结点,会有多少级索引呢?

在跳表中,假设每两个结点,会抽出一个结点作为上一级索引的结点。

那么,索引有多少级,每一级有多少个node呢:

  • 第一级索引的结点个数大约就是 n/2

  • 第二级索引的结点个数大约就是 n/4

  • 第三级索引的结点个数大约就是 n/8

依次类推,也就是说,

  • 第 k 级索引的结点个数是第 k-1 级索引的结点个数的 1/2

  • 那第 k级索引结点的个数就是 n/(2的k次方)。

图片

假设索引有 h 级,最高级的索引有 2 个结点。

通过上面的公式,我们可以得到 n/(2^h)=2,从而求得 h=log2n-1。

如果包含原始链表这一层,整个跳表的高度就是 log2n。

我们在跳表中查询某个数据的时候,如果每一层都要遍历 m 个结点,那在跳表中查询一个数据的时间复杂度就是 O(m*logn)。

那m到底是多少呢?

假设我们要查找的数据是 x,在第 k 级索引中,我们遍历到 y 结点之后,发现 x 大于 y,小于后面的结点 z,所以我们通过 y 的 down 指针,从第 k 级索引下降到第 k-1 级索引。

在第 k-1 级索引中,y 和 z 之间只有 3 个结点(包含 y 和 z),所以,我们在 K-1 级索引中最多只需要遍历 3 个结点,依次类推,每一级索引都最多只需要遍历 3 个结点。

图片

过上面的分析,我们得到 m=3,所以在跳表中查询任意数据的时间复杂度就是 O(logn)。

这个查找的时间复杂度跟二分查找是一样的,这也体现了空间换时间的效率之高。

跳表(Skip List)的优点和缺点

跳表是一种多层级的数据结构,每一层都是一个有序链表,最底层包含所有数据,而上层包含的数据是下层的子集,通过跳跃节点快速定位目标数据。

跳表(Skip List)的优点

  • 平均查找时间较低:跳表的查询时间复杂度为O(log n),与平衡树结构相似,但实现起来较为简单。

  • 插入和删除操作相对较快:由于跳表不需要进行节点的频繁平衡调整,插入和删除操作的性能较好。

跳表(Skip List)的缺点

  • 难以实现事务和数据持久性:跳表的更新操作可能涉及多个层级,实现事务和数据持久性要求更复杂。

  • 空间开销较大:跳表需要额外的指针来连接不同层级,占用的内存空间较多。

B+Tree 和 跳表(Skip List) 的在数据结构上的区别

都是 多级索引 +链表

图片

IO 操作的单位 不同

B+Tree 是page (16K)

跳表(Skip List) 是 node 节点 ,一个node 几十个字节

树的高度 不同

B+树是多叉树结构,每个结点都是一个16k的数据页,能存放较多索引信息。

同样的数据,树的高度比较小。 三层B+左右就可以存储2kw左右的数据。

如果,把三层B+树塞满,那大概需要2kw左右的数据。 也就是说查询一次数据,如果这些数据页都在磁盘里,那么最多需要查询三次磁盘IO

跳表是链表结构,一条数据一个结点,如果最底层要存放2kw数据,且每次查询都要能达到二分查找的效果,2kw大概在2的24次方 左右,所以,2kw数据的跳表大概高度在24层左右。 如果要一个节点要进行一次磁盘IO,大概要进行 24次。

B+Tree 和 跳表(Skip List) 的新增数据区别

了解了二者的基本情况之后,接下来,对B+Tree 和 跳表(Skip List) 的数据插入进行对比。

B+Tree和跳表的叶子层,都包含了所有的数据,且叶子层都是顺序的,适合用于范围查询。

来看看,B+Tree和跳表新增和删除数据的差异

B+Tree 新增数据

场景1: 叶子结点和索引结点都没满

B+Tree 直接插入到叶子结点中就好了。

图片

场景2:叶子结点满了,但索引结点没满

B+Tree 需要拆分叶子结点,同时索引结点要增加新的索引信息。

图片

场景3:叶子结点满了,且索引结点也满了

叶子和索引结点都要拆分,同时往上还要再加一层索引。

图片

B+树是一种多叉平衡二叉树,要维护各个分支的高度差距,不能太大,平衡意味着子树们的高度层级尽量一致(一般最多差一个层级)。

为啥要平衡呢?平衡意味着在搜索的时候,不管走哪个子树分支,搜索次数都差不了太多。

所以,为了维持B+树的平衡,在插入新的数据时,B+树会不断将进行 数据页的 分裂

跳表新增数据

跳表同样也是很多层,新增一个数据时,最底层的链表需要插入数据,然后,考虑是否需要在上面几层中加入数据做索引 ? 这个就靠随机函数了。

例如: 如果跳表中插入数据id=6,且随机函数返回第三层(有25%的概率),那就需要在跳表的最底层到第三层都插入数据。

跳表跟B+树不一样,跳表是否新增层数,纯粹靠随机函数,不太关心平衡的问题。

B+Tree和跳表的在新增数据上的区别

B+Tree 需要维护 树的平衡

为了维持B+树的平衡,在插入新的数据时,B+树会不断将进行 数据页的 分裂

维护平衡意味维护搜索的稳定性, 意味着着在搜索的时候,不管走哪个子树分支,搜索次数都差不了太多。

跳表 需要不太关心平衡问题

跳表在新增数据 时,不太关心平衡的问题。跳表插入数据的时候,跟B+树不一样,是否新增层数,纯粹靠随机函数去决定。

为什么B+Tree 采用Page作为 IO操作的单位?

前面讲到,B+Tree和跳表 IO 操作的单位 不同

  • B+Tree 是 page(16K),粗粒度IO

  • 跳表(Skip List) 是 node 节点 ,一个node 几十个字节 , 细粒度IO

这是和 MySQL的存储介质有关系, MySQL的数据需要持久化存储, 并且需要事务机制保证持久性,所以,必须存储在磁盘上。

内存和磁盘的访问速度对比

机械硬盘的读写速度,大致如下

图片

固态硬盘的读写速度,大致如下

图片

内存的读写速度,和磁盘读写速度的对比

图片

为什么磁盘慢,和磁盘的结构有关。

机械硬盘的扇区(sector)

机械硬盘的性能为什么那么慢? 看看结构就知道:

图片

机械磁盘上的每个磁道被等分为若干个弧段,这些弧段称之为扇区。

如何在磁盘中读/写数据? 需要 物理动作,去移动 “磁头” 到目标 扇区

图片

机械磁盘的读写以扇区为基本单位。

硬盘的物理读写以扇区为基本单位。通常情况下每个扇区的大小是 512 字节。linux 下可以使用 fdisk -l 了解扇区大小:

$ sudo /sbin/fdisk -l
Disk /dev/sda: 20 GiB, 21474836480 bytes, 41943040 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x7d9f5643

其中 Sector size,就是扇区大小,本例中为 512 bytes。

注意,扇区是磁盘物理层面的概念,不是操作系统的概率。

操作系统是不直接与扇区交互的,而是与多个连续扇区组成的磁盘块交互。由于扇区是物理层面的概念,所以无法在系统中进行大小的更改。

操作系统 IO 块 Block

文件系统读写数据的最小单位,也叫磁盘簇,IO区块 BLOCK。

什么是IO 块 Block? 扇区是磁盘最小的物理存储单元,操作系统将相邻的扇区组合在一起,形成一个块,对块进行管理。

每个Block 磁盘块可以包括 2、4、8、16、32 或 64 个扇区。

所以,Block 磁盘块是操作系统所使用的逻辑概念,而非磁盘的物理概念。

Block 磁盘块的大小可以通过命令 stat /boot 来查看:

$ sudo stat /boot
  File: /boot
  Size: 4096        Blocks: 8          IO Block: 4096   directory
Device: 801h/2049d  Inode: 655361      Links: 3
Access: (0755/drwxr-xr-x)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2019-07-06 20:19:45.487160301 +0800
Modify: 2019-07-06 20:19:44.835160301 +0800
Change: 2019-07-06 20:19:44.835160301 +0800
 Birth: -

其中 IO Block 就是磁盘块大小,本例中是 4096 Bytes,一般也是 4K。

MySQL的InnoDB Page 数据页

磁盘IO是低性能的,如何提升性能, 最好是 减少IO, 基于时间局部性和空间局部性原理, 一次读取足够多的数据到内存。

MySQL的 InnoDB将数据划分为若干页,以Page 页作为磁盘与内存交互的基本单位,一般页的大小为16KB。

InnoDB,为了通过减少内存与磁盘的交互次数,把一次读取和写入的 数据量, 从4K 扩大到了16K,也就是一次操作 4个 OS Block,从而提升性能。

这样的话,一次性至少读取1Page 页数据到内存中或者将1 Page页数据写入磁盘。而不是一个操作系统的block。

Page 本质上就是一种典型的缓存设计思想,一般缓存的设计基本都是从时间局部性和空间局部性进行考量的:

  • 时间局部性:如果一条数据正在在被使用,那么在接下来一段时间内大概率还会再被使用。可以认为热点数据缓存都属于这种思路的实现。

  • 空间局部性:如果一条数据正在在被使用,那么存储在它附近的数据大概率也会很快被使用。InnoDB的数据页和操作系统的页缓存则是这种思路的体现。

InnoDB Page 数据页的结构

图片

一开始生成页的时候,并没有User Records这个部分.

每当我们插⼊⼀条记录,都会从Free Space部分,也就是尚未使⽤的存储空间中申请⼀个记录⼤⼩的空间划分到User Records部分,当Free Space部分的空间全部被User Records部分替代掉之后,也就意味着这个页使⽤完了,如果还有新的记录插⼊的话,就需要去申请新的页了。

一次IO一个page的优势

MySQL的InnoDB存储引擎使用B+树而不是跳表,这是因为B+树一次IO一个page,大大节省了磁盘IO的操作。

如果使用跳表,那么一个node节点一次io, 存储的性能 估计要下降1000倍以上。

总结:MySQL的索引为什么使用B+树而不使用跳表

B+树更适合磁盘IO

B+Tree一个节点是一个page,是一种多叉树结构,每个结点都是一个16k的数据页,能存放较多索引信息。一次IO一个page,大大节省了磁盘IO的操作。

B+Tree一个page 能存放较多索引信息 ,所以树的层数比较低, 三层左右就可以存储2kw左右的数据也就是说查询一次数据,如果这些数据页都在磁盘里,那么最多需要查询三次磁盘IO

原生跳表不适合磁盘IO

跳表是链表结构,一条数据一个结点,那么一个node节点一次磁盘io, 一个page 页规模的IO存储的性能 估计要下降1000倍以上。

原生跳表 一个node存放一个 索引信息 ,所以树的层数比较高

如果最底层要存放2kw数据,且每次查询都要能达到二分查找的效果,2kw大概在2的24次方 左右,所以,2kw数据的跳表大概高度在24层左右。 如果要进行查找,大概要进行 24次磁盘IO。

所以,虽然在理论上,跳表的时间复杂度和B+树相同 ,但是:

  • B+树更适合 磁盘IO, 更合适MYSQL。

  • 从反面来说, 跳表更适合内存IO, 更适合redis。

手写跳表(skiplist)

什么是跳跃表?

Redis 是一个开源(BSD许可)的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件。

Redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员(member)是比较长的字符串时,Redis就会使用跳跃表来作为有序集合键的底层实现。

跳跃表(skiplist)是一种随机化的数据, 由 William Pugh 在论文《Skip lists: a probabilistic alternative to balanced trees》中提出, 跳跃表以有序的方式在层次化的链表中保存元素, 效率和平衡树媲美 — 查找、删除、添加等操作都可以在对数期望时间下完成, 并且比起平衡树来说, 跳跃表的实现要简单直观得多。

跳表是一种带多级索引的链表,本质上也是一种查找结构, 用于解决算法中的查找问题,即根据给定的key,快速查找它所在的位置(value)。跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

跳跃表支持平均0 (1ogN)、最坏O(N) 复杂度的节点查找,还可以通过顺序性操作来批量处理节点。

在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。

从直观理解的维度来绘制,一个3层索引结构的skiplist , demo 示意图如下:

图片

换一种方式,从编码实现的维度来绘制, 一个3层索引结构的skiplist 的 demo 示意图如下:

图片

对有序链表的查询优化过程

对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。

这样查找效率就会很低,时间复杂度会很高,是 O(n)。

图片

step1:建立一级索引

对链表中每两个节点建立第一级索引 , 大致的方法如下:

假如为每相邻两个节点增加一个指针,让指针指向下下个节点 ,如下图:

图片

这样所有新增加的指针连成了一个新的链表(上图中第一级索引指向的链表),但第一级索引 包含的节点个数只有原来的一半(上图中是5, 9, 16)。

请注意:实际应用中的skiplist每个节点应该包含key和value两部分。这里为了方便描述,并没有具体区分key和value,但实际上列表中是按照key进行排序的,查找过程也是根据key进行比较。

现在当我们想查找数据的时候,查找的过程如下:

  • 可以先第一级索引层遍历进行查找。

  • 当碰到比待查数据大的节点时,再回到原来的链表中进行查找。

比如,我们想查找14,查找的路径是沿着下图中标红的指针所指向的方向进行的:

图片

  • 14首先和5比较,再和9比较,比它们都大,继续向后比较。

  • 但14和16比较的时候,比16要小,因此回到下面的链表(原链表),与13比较。

  • 14比13要大,沿下面的指针继续向后和16比较。14比16小,说明待查数据14在原链表中不存在,而且它的插入位置应该在13和16之间。

在这个查找过程中,由于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了。

通过第一层索引,我们发现:需要比较的节点数大概只有原来的一半

step2:建立二级索引

利用同样的方式,我们可以在第一层新产生的链表上,继续为每相邻的两个节点增加一个指针,从而产生第二层链表,这一层链表是第二层索引。

第二层索引如下图:

图片

在这个新的第二层索引结构上,如果我们还是查找14,那么沿着最上层链表首先要比较的是9,发现14比9大,接下来我们就知道只需要到9的后面去继续查找,从而一下子跳过了9前面的所有节点。

从上述过程我们看出,加了一层索引之后,查找一个结点需要遍历的结点个数减少了,也就是说查找效率提高了。

跳表的特点

跳表是一种动态数据结构,支持快速地插入、删除、查找操作,时间复杂度都是 O(logn)。

跳表的空间复杂度是 O(n)。不过,跳表的实现非常灵活,可以通过改变索引构建策略,有效平衡执行效率和内存消耗。

跳表即表示跳跃表,指的就是除了最下面第1层链表之外,它会产生若干层稀疏的链表,这些链表里面的指针故意跳过了一些节点(而且越高层的链表跳过的节点越多)。这就使得我们在查找数据的时候,可由高层链表到底层链表逐层降低,在这个过程中,跳过了一些节点,从而也就加快了查找速度。

跳表使用空间换时间的设计思路,通过构建多级索引来提高查询的效率,实现了基于链表的“二分查找”。即上面每一层链表的节点个数,是下面一层的节点个数的一半。

随机层数在插入性能上的优化

新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。

如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成O(n)。删除数据也有同样的问题。

skiplist为了避免这一问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level)。

比如,一个节点随机出的层数是3,那么就把它链入到第1层到第3层这三层链表中。

为了表达清楚,下面通过多个图的方式,一步一步展示了插入操作如何通过随机层数,决定一个节点要插入跳表的哪几层的问题:

图片

上图, 插入9的时候, 随机层数为2,就需要插入2层:

  • 在原始链表层,插入 9。

  • 和第一个索引层,插入 9。

图片

上图, 插入5的时候, 随机层数为4,就需要插入4层:

  • 在原始链表层,插入 5。

  • 和第一个索引层,插入 5。

  • 和第二个索引层,插入 5。

  • 和第三个索引层,插入 5。

图片

上图, 插入1的时候, 随机层数为1,就需要插入1层:

  • 在原始链表层,插入 1。

图片

上图, 插入21的时候, 随机层数为3,就需要插入3层:

  • 在原始链表层,插入 21。

  • 和第一个索引层,插入 21。

  • 和第二个索引层,插入 21。

图片

上图, 插入6的时候, 随机层数为1,就需要插入1层:

  • 在原始链表层,插入 6。

图片

上图, 插入16的时候, 随机层数为1,就需要插入1层:

  • 在原始链表层,插入 16。

从上面skiplist的创建和插入过程可以看出,每一个节点的层数(level)是随机出来的,而且新插入一个节点不会影响其它节点的层数。

skiplist 在插入的效率比较高,插入操作只需要修改插入节点前后的指针,而不需要对很多节点都进行调整。这就降低了插入操作的复杂度。

与之相比,B+树在插入的时候要维护树的平衡,插入过程中会发生 page 的分裂, 插入的性能就会差很多。

插入的性能很高,这是skiplist的一个很重要的特性,这让它在插入性能上明显优于平衡树的方案。

skiplist执行插入,需要计算随机数,是一个很关键的过程,它对skiplist的统计特性有着很重要的影响。

这并不是一个普通的服从均匀分布的随机数,它的计算过程如下:

  • 首先,每个节点肯定都有第1层指针(每个节点都在第1层链表里)。

  • 如果一个节点有第i层(i>=1)指针(即节点已经在第1层到第i层链表中),那么它有第(i+1)层指针的概率为p。

  • 节点最大的层数不允许超过一个最大值,记为MaxLevel。

跳表查找元素的过程

刚刚创建的这个skiplist总共包含4层链表,现在假设我们在它里面依然查找14,下图给出了查找路径:

图片

Redis中ZSet是怎么实现的

Redis中 ZSet(Sorted Set)是Redis中的一种特殊数据结构,它内部维护一个有序的dict,这个字典dict包括两个属性:成员(member)、分数(score,double类型)。

这个ZSet 结构价值很大:

  • 可以帮助我们实现排行榜

  • 朋友圈点赞等记分类型的排行数据

  • 以及实现延迟队列、限流。

Redis中ZSet实现包括多种结构,有ziplist(压缩列表)、skiplist(跳表)、listpack(紧凑列表,在Redis5.0中新增)。

listpack是为了替代ziplist,在Redis7.0中已经彻底弃用ziplist。

Redis中Zset底层数据结构

图片

前面提到过,Redis中的ZSet是在dict、ziplist(listpack)、skiplist基础上构建起来的:

  • 当数据较少时,sorted set是由一个ziplist来实现的。

  • 当数据多的时候,sorted set是由一个叫zset的数据结构来实现的,这个zset包含一个dict + 一个skiplist。

当数据多的时候,zset包含一个dict + 一个skiplist:

  • dict用来查询数据到分数(score)的对应关系,

  • 而skiplist用来根据分数查询数据(可能是范围查找)。

ziplist就是由很多数据项组成的一大块连续内存。由于sorted set的每一项元素都由数据和score组成,因此,当使用zadd命令插入一个(数据, score)对的时候,底层在相应的ziplist上就插入两个数据项:数据在前,score在后。

ziplist的主要优点是节省内存,但它上面的查找操作只能按顺序查找(可以正序也可以倒序)。因此,sorted set的各个查询操作,就是在ziplist上从前向后(或从后向前)一步步查找,每一步前进两个数据项,跨域一个(数据, score)对。

ZSet中的字典和跳表布局

图片

其中skiplist用来实现有序集合,其中每个元素按照其分值大小在跳表中进行排序,跳表的插入、删除和查找操作时间复杂度都是O(1),能够保证较好的性能。

dict用来实现元素到分值的映射,其中元素作为键,分值作为值。哈希表的插入、删除和查找操作的时间复杂度都是O(1),具备非常高的性能。

redis的zpilist和skiplist之间何时进行转换

随着数据的插入,Redis底层会将ziplist转换成skiplist,那么到底插入多少数据才会转换,下面进行分析。

本文主要涉及两个Redis配置(在redis.conf中的ADVANCED CONFIG部分)

Similarly to hashes and lists, sorted sets are also specially encoded in order to save a lot of space. This encoding is only used when the length and elements of a sorted set are below the following limits:

Redis7.0之前的配置

zset-max-ziplist-entries 128 // ziplist中元素个数 zset-max-ziplist-value 64 // ziplist中元素大小

Redis7.0之后的配置

zset-max-listpack-entries 128 // listpack中元素个数 zset-max-listpack-value 64 // listpack中元素大小

在Redis中,以上两个配置说明,只有当长度和排序集的元素个数,同时满足以下两个条件时会使用ziplist(listpack)作为其内部表示,具体条件如下:

  • 元素数量少:集合中的元素数量必须小于配置的阈值:zset-max-ziplist-entries(zset-max-listpack-entries)

  • 插入数据长度:当ZSet中插入的任意一个数据的长度超过64的时候。

总结:当元素数量少于128,每个元素的长度都小于64字节的时候,Redis使用ziplist(listpack),否则使用是skiplist。

/* Convert the sorted set object into a listpack if it is not already a listpack
 * and if the number of elements and the maximum element size and total elements size
 * are within the expected ranges. */
void zsetConvertToListpackIfNeeded(robj *zobj, size_t maxelelen, size_t totelelen) {
    if (zobj->encoding == OBJ_ENCODING_LISTPACK) return;
    zset *zset = zobj->ptr;

    if (zset->zsl->length <= server.zset_max_listpack_entries &&
        maxelelen <= server.zset_max_listpack_value &&
        lpSafeToAdd(NULL, totelelen))
    {
        zsetConvert(zobj,OBJ_ENCODING_LISTPACK);
    }
}

Redis对跳表的实现及改进与优化

Redis 的跳跃表是由 redis.h/zskiplistNode和 redis.h/zskiplist 两个结构定义,其中 zskiplistNode 用于表示跳跃节点,而 zskiplist 结构则用于保存跳跃表节点的相关信息,比如节点的数量以及指向表头节点和表尾节点的指针等等。

图片

上图最左边的是 zskiplist 结构,该结构包含以下属性:

  • header:指向跳跃表的表头节点

  • tail:指向跳跃表的表尾节点

  • level:记录目前跳跃表内,层数最大的那个节点层数(表头节点的层数不计算在内)

  • length:记录跳跃表的长度,也就是跳跃表目前包含节点的数量(表头节点不计算在内)

位于 zskiplist 结构右侧是四个 zskiplistNode 结构,该结构包含以下属性:

  • 层(level):节点中用 L1、L2、L3 等字样标记节点的各个层,L1 代表第一层,L2 代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其它节点,而跨度则记录了前进指针所指向节点和当前节点的距离。

  • 后退(backward)指针:节点中用 BW 字样标识节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。

  • 分值(score):各个节点中的 1.0、2.0 和 3.0 是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。

总结起来,Redis中的skiplist跟前面介绍的经典的skiplist相比,有如下不同:

  • 分数(score)允许重复,即skiplist的key允许重复。这在最开始介绍的经典skiplist中是不允许的。

  • 在比较时,不仅比较分数(相当于skiplist的key),还比较数据本身。在Redis的skiplist实现中,数据本身的内容唯一标识这份数据,而不是由key来唯一标识。另外,当多个元素分数相同的时候,还需要根据数据内容来进字典排序。

  • 第1层链表不是一个单向链表,而是一个双向链表。这是为了方便以倒序方式获取一个范围内的元素。

  • 在skiplist中可以很方便地计算出每个元素的排名(rank)。

跳表与平衡树、哈希表的比较

  • skiplist和各种平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做单个key的查找,不适宜做范围查找。所谓范围查找,指的是查找那些大小在指定的两个值之间的所有节点。

  • 在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。

  • 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。

  • 从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。

  • 查找单个key,skiplist和平衡树的时间复杂度都为O(log n),大体相当;而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近O(1),性能更高一些。所以我们平常使用的各种Map或dictionary结构,大都是基于哈希表实现的。

  • 从算法实现难度上来比较,skiplist比平衡树要简单得多。

Redis为什么使用跳表而不使用平衡树

关于这个问题,Redis作者是这么说的:

There are a few reasons:
1、They are not very memory intensive. It's up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
2、A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
3、They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch(already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.

主要是从内存占用、对范围查找的支持、实现难易程度这三方面总结的原因,简单翻译如下:

  • 它们不是非常内存密集型的。基本上由你决定。改变关于节点具有给定级别数的概率的参数将使其比 btree 占用更少的内存。

  • Zset 经常需要执行 ZRANGE 或 ZREVRANGE 的命令,即作为链表遍历跳表。通过此操作,跳表的缓存局部性至少与其他类型的平衡树一样好。

  • 它们更易于实现、调试等。例如,由于跳表的简单性,我收到了一个补丁(已经在Redis master中),其中扩展了跳表,在 O(log(N) 中实现了 ZRANK。它只需要对代码进行少量修改。

关于上述观点,做几点补充如下:

  • 从内存占用上来比较,跳表比平衡树更灵活一些。平衡树每个节点包含 2 个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均为 1/(1-p),具体取决于参数 p 的大小。如果像 Redis 里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。

  • 在做范围查找的时候,跳表比平衡树操作要简单。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在跳表上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以实现。

  • 从算法实现难度上来比较,跳表比平衡树要简单得多。平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速。

跳表与B+树的比较

  • 相同点:skiplist和B+树的最下面一层,都包含了所有数据,且都是有序的,二者都是 有序链表+多级索引的结构

    图片

  • 不同点:

    • B+树本质上是一种多叉平衡二叉树。当数据库表不断插入新的数据时,为了维持B+树的平衡,B+树会不断分裂调整数据页,来保证B+树的子树们的高度层级尽量一致(一般最多差一个层级)。适合读多写少的场景。(存储引擎RocksDB内部使用了跳表,对比使用B+树的innodb,虽然写性能更好,但读性能属实差了些。)

    • skiplist在新增/删除数据时,依靠随机函数,即可确定是否需要向上添加索引,达到一个二分的效果,无需平衡数据结构,少了旋转平衡的开销。

    • skiplist占用更少的内存,且更容易实现、调试。

  • B+树是多叉平衡搜索树,只需要3层左右就能存放2kw左右的数据,同样情况下跳表则需要24层左右,假设层高对应磁盘IO,那么B+树的读性能会比跳表要好,因此mysql选了B+树做索引。

  • redis的读写全在内存里进行操作,不涉及磁盘IO,同时跳表实现简单,相比B+树、AVL树、少了旋转树结构的开销,因此redis使用跳表来实现ZSET,而不是树结构。

总之:

  • B+树是 "磁盘友好型" 的 数据机构,适合于 DB,比如mysql就使用 B+树。

  • 跳表 是 "内存友好型" 的数据结构, 适合于 Cache,比如redis就使用 跳表 。

字节的真题:手写一个跳表

第一步:定义好 跳表的节点

每一个节点, 都带着一个 指针数组,一个最大16层的跳表,每一个node结构 包含一个 规模为16大小的指针数组

图片

第二步:定义好 生产 随机层数的方法

  // 理论来讲,一级索引中元素个数应该占原始数据的 50%,二级索引中元素个数占 25%,三级索引12.5% ,一直到最顶层。
  // 因为这里每一层的晋升概率是 50%。对于每一个新插入的节点,都需要调用 randomLevel 生成一个合理的层数。
  // 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 :
  //        50%的概率返回 1
  //        25%的概率返回 2
  //      12.5%的概率返回 3 ...
  private int randomLevel() {
    int level = 1;

    while (Math.random() < SKIPLIST_P && level < MAX_LEVEL)
      level += 1;
    return level;
  }

第三步:定义好 数据插入的方法

图片

第四步:定义好 数据删除的方法

图片

总结

Redis 中的有序集合支持的核心操作主要有下面这几个:

  • 插入一个数据;

  • 删除一个数据;

  • 按照区间查找数据(比如查找值在[100, 356]之间的数据);

  • 迭代输出有序序列。

其实红黑树和跳表也是常常用来对比的。

首先,在插入、删除、查找以及迭代输出有序序列这几个操作上,红黑树 时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。

对于按照区间查找数据这个操作,跳表可以做到 O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。这样做非常高效。

当然,Redis 之所以用跳表来实现有序集合,还有其他原因,比如,跳表更容易代码实现。虽然跳表的实现也不简单,但比起红黑树来说还是好懂、好写多了,而简单就意味着可读性好,不容易出错。还有,跳表更加灵活,它可以通过改变索引构建策略,有效平衡执行效率和内存消耗。

不过,跳表也不能完全替代红黑树。因为红黑树比跳表的出现要早一些,很多编程语言中的 Map 类型都是通过红黑树来实现的。我们做业务开发的时候,直接拿来用就可以了,不用费劲自己去实现一个红黑树,但是跳表并没有一个现成的实现,所以在开发中,如果你想使用跳表,必须要自己实现。

MySQL死锁 是什么,如何解决?

1 什么是MySQL死锁?

死锁是指两个或多个事务在执行过程中,因争夺锁资源而造成的相互等待的现象,若无外力干涉它们都将无法继续执行。

通俗来说,就是两个或多个事务在等待对方释放锁,从而造成僵持不下,使得整个系统陷入停滞状态。

2 从操作的粒度进行的MySQL锁的分类

从操作的粒度可分为表级锁、行级锁和页级锁。

表级锁:

每次操作锁住整张表锁定粒度大,发生锁冲突的概率最高,并发度最低

应用在MyISAM、InnoDB、BDB 等存储引擎中。

表锁的特点:

  • 开销小,加锁快

  • 不会出现死锁

  • 锁定粒度大,发生锁冲突的概率最高,并发度最低

行级锁:

每次操作锁住一行数据锁定粒度最小,发生锁冲突的概率最低,并发度最高

应用在InnoDB 存储引擎中。

行锁的特点:

  • 开销大,加锁慢

  • 会出现死锁

  • 锁定粒度小,发生锁冲突的概率最低,并发度最高

页级锁:

每次锁定相邻的一组记录,锁定粒度界于表锁和行锁之间,开销和加锁时间界于表锁和行锁之间,并发度一般。

页锁的特点:

  • 开销和加锁时间介于表锁和行锁之间

  • 会出现死锁

  • 锁定粒度介于表锁和行锁之间,并发度一般

3 从操作的类型进行的MySQL锁的分类

从操作的类型可分为读锁和写锁。

读锁(S锁)

读锁(S锁):共享锁,针对同一份数据,多个读操作可以同时进行而不会互相影响。

S锁:事务A对记录添加了S锁,可以对记录进行读操作,不能做修改,其他事务可以对该记录追加S锁,但是不能追加X锁,要追加X锁,需要等记录的S锁全部释放。

写锁(X锁)

写锁(X锁):排他锁,当前写操作没有完成前,它会阻断其他写锁和读锁

X锁:事务A对记录添加了X锁,可以对记录进行读和修改操作,其他事务不能对记录做读和修改操作。

意向锁

  • IS: 意向共享锁,表级锁,已加S锁的表,肯定会有IS锁,反过来,有IS锁的表,不一定会有S锁

  • IX: 意向排它锁,表级锁,已加X锁的表,肯定会有IX锁,反过来,有IX锁的表,不一定会有X锁

4 从操作的性能进行的MySQL锁的分类

从操作的性能可分为乐观锁和悲观锁。

  • 乐观锁:一般的实现方式是对记录数据版本进行比对,在数据更新提交的时候才会进行冲突检测,如果发现冲突了,则提示错误信息。

  • 悲观锁:在对一条数据修改的时候,为了避免同时被其他人修改,在修改数据之前先锁定,再修改的控制方式。共享锁和排他锁是悲观锁的不同实现,但都属于悲观锁范畴。

5 InnoDB存储引擎三种行锁模式

InnoDB引擎行锁是通过对索引数据页上的记录加锁实现的,

主要实现算法有 3 种:Record Lock、Gap Lock 和 Next-key Lock,

也就是InnoDB的三种行锁模式。

  • RecordLock锁(行锁):锁定单个行记录的锁。(RecordLock锁 是记录锁,RC、RR隔离级别都支持)

  • GapLock锁:间隙锁,锁定索引记录间隙(不包括记录本身),确保索引记录的间隙不变。(GapLock是范围锁,RR隔离级别支持。RC隔离级别不支持)

  • Next-key Lock 锁(临键锁):记录锁和间隙锁组合,同时锁住数据,并且锁住数据前后范围。(记录锁+范围锁,RR隔离级别支持。RC隔离级别不支持)

5.1 记录锁(Record Locks)

(1)记录锁, 仅仅锁住索引记录的一行,在单条索引记录上加锁。

(2)record lock锁住的永远是索引,而非记录本身,即使该表上没有任何索引,那么innodb会在后台创建一个隐藏的聚集主键索引,那么锁住的就是这个隐藏的聚集主键索引。

所以说当一条sql没有走任何索引时,那么将会在每一条聚合索引后面加X锁,这个类似于表锁,但原理上和表锁应该是完全不同的。

5.2 间隙锁(Gap Locks)

(1)区间锁, 仅仅锁住一个索引区间(开区间,不包括双端端点)。

(2)在索引记录之间的间隙中加锁,或者是在某一条索引记录之前或者之后加锁,并不包括该索引记录本身。

(3)间隙锁可用于防止幻读,保证索引间的不会被插入数据

比如在 100、10000中,间隙锁的可能值有 (∞, 100),(100, 10000),(10000, ∞),

图片

5.3 行锁:临键锁(Next-Key Locks)

(1)record lock + gap lock, 左开右闭区间。

(2)默认情况下,innodb使用next-key locks来锁定记录。select … for update 

(3)但当查询的索引含有唯一属性的时候,Next-Key Lock 会进行优化,将其降级为Record Lock,即仅锁住索引本身,不是范围。 

(4)Next-Key Lock在不同的场景中会退化:

图片

比如在 100、10000中,临键锁(Next-Key Locks)的可能有 (∞, 100],(100, 10000] , 这里的关键是左开右闭

图片

6 事务隔离级别和锁的关系

6.1 数据库事务的隔离级别

先来回顾一下,数据库事务的隔离级别,目前数据库事务的隔离级别一共有 4 种,由低到高分别为:

事务的四个隔离级别:

  • 未提交读(READ UNCOMMITTED):所有事务都可以看到其他事务未提交的修改。一般很少使用;

  • 读已提交(READ COMMITTED):Oracle默认隔离级别,事务之间只能看到彼此已提交的变更修改;

  • 可重复读(REPEATABLE READ):MySQL默认隔离级别,同一事务中的多次查询会看到相同的数据行;可以解决不可重复读,但可能出现幻读;

  • 可串行化(SERIALIZABLE):最高的隔离级别,事务串行的执行,前一个事务执行完,后面的事务会执行。读取每条数据都会加锁,会导致大量的超时和锁争用问题;

图片

数据库一般默认的隔离级别为 读已提交 RC ,比如 Oracle。

也有一些数据的默认隔离级别为 可重复读 RR,比如 MySQL。

"可重复读"(Repeatable Read)这个级别确保了对同一字段的多次读取结果是一致的,除非数据是被本身事务自己所修改。

RR它能够防止脏读、不可重复读,但可能会遇到幻读的情况。

MySQL默认的Repeatable Read隔离级别,被改成了RC。​

一般而言,数据库的读已提交(READ COMMITTED)能够满足业务绝大部分场景了。

6.2 事务隔离级别和锁的关系

  1. 事务隔离级别是SQL92定制的标准,相当于事务并发控制的整体解决方案,本质上是对锁和MVCC使用的封装,隐藏了底层细节。

  2. 锁是数据库实现并发控制的基础,事务隔离性是采用锁来实现,对相应操作加不同的锁,就可以防止其他事务同时对数据进行读写操作。

  3. 对用户来讲,首先选择使用隔离级别,当选用的隔离级别不能解决并发问题或需求时,才有必要在开发中手动的设置锁。

    MySQL 默认隔离级别:可重复读, 一般建议改为 RC 读已提交

    Oracle、SQLServer默认隔离级别:读已提交

7 死锁产生原因和解决方案

InnoDB与MyISAM的最大不同有两点

  • 支持事务

  • 采用行锁

行级锁和表级锁本来就有许多不同之处,另外,事务的引入也带来了一些问题 ,比如 死锁。

7.1 查看Innodb行锁争用情况

通过show status like 'innodb_row_lock_%'; 命令可以查询MySQL整体的锁状态,如下:

图片

  • Innodb_row_lock_current_waits:当前正在阻塞等待锁的事务数量。

  • Innodb_row_lock_timeMySQL启动到现在,所有事务总共阻塞等待的总时长。

  • Innodb_row_lock_time_avg:平均每次事务阻塞等待锁时,其平均阻塞时长。

  • Innodb_row_lock_time_maxMySQL启动至今,最长的一次阻塞时间。

  • Innodb_row_lock_waitsMySQL启动到现在,所有事务总共阻塞等待的总次数。

7.2 详细介绍 死锁的概念

什么是死锁DeadLock?

是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去.

此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

举例:

假设有两个事务 A 和 B,它们同时试图获取对方持有的资源,但又都在等待对方释放资源,导致了僵持不下的局面。

举个例子,假设有两个人 A 和 B,他们同时想要通过一扇门进入一个房间,但这扇门只能由一人单独打开。

图片

  • A 想要进入房间1,但门被 B 挡住了,所以 A 无法进入,于是 A 抓住了 B 的右手,不让 B 打开房间2的门。
  • B 想要进入房间2,但门被 A 挡住了,所以 B 无法进入,于是 B 抓住了 A 的左手,不让 A 打开房间1的门。

现在的情况是:

  • A 等待着 B 放开 B 的左手,以便自己能打开门进入房间,

    B 等待着 A 放开A的右手,以便自己能够进入房间。

这就形成了死锁,因为两个人都在 对方放开资源,而对方又不愿意放开自己所持有的资源,导致了相互等待,最终无法继续执行下去。

7.3 表级锁死锁

a)产生原因:
  • 用户A先访问表1(锁住了表1),然后再访问表2;

  • 用户B先访问表2(锁住了表2),然后再企图访问表1;

    用户A和用户B加锁的顺序如下:

  • 用户A--》表1(表锁)--》表2(表锁)

  • 用户B--》表2(表锁)--》表1(表锁)

    这时, 出现了二者的相互争抢:

  • 用户A由于用户B已经锁住表2,它必须等待用户B释放表2才能继续

  • 同样用户B要等用户A释放表1才能继续

这就死锁就产生了。如下图所示:

图片

b)解决方案:

这种死锁比较常见,是由于程序的BUG产生的,除了调整的程序的逻辑没有其它的办法。

仔细分析程序的逻辑,对于数据库的多表操作时,尽量按照相同的顺序进行处理,尽量避免同时锁定两个资源,如操作A和B两张表时,总是按先A后B的顺序处理, 必须同时锁定两个资源时,要保证在任何时刻都应该按照相同的顺序来锁定资源。

7.4 行级锁死锁

a) 产生原因1:

如果在事务中执行了一条没有索引条件的查询,引发全表扫描,行锁 膨胀 为表锁( 或者等价于 表级锁),多个这样的 锁表事务 执行后,就很容易产生死锁和阻塞,最终应用系统会越来越慢,发生阻塞或 死锁。

解决方案1:

SQL语句中不要使用太复杂的关联多表的查询;

使用explain“执行计划"对SQL语句进行分析,对于有全表扫描和全表锁定的SQL语句,建立相应的索引进行优化。

b) 产生原因2:

两个事务分别想拿到对方持有的锁,互相等待,于是产生死锁。

c) 产生原因3:

每个事务只有一个SQL,但是有些情况还是会发生死锁.

假设有下面的一个表 t1

create table t1(
id int(32) not null,
name varchar(50) not null,
reg_time int(32) not null,
city varchar(50) ,
primary key (`name`),
index index_name(`name`),
index index_reg_time(`reg_time`)
);

事务1, 假设有下面的一个 session1 会话

update t1 set city = "香港" where name="aaa"

首先,session1 从name 非聚族索引索引出发,读到的 [aaa, 1],[aaa, 4] ,会加name索引上的记录[aaa, 1],[aaa, 4] 两个记录的X锁,然后,session1 会加聚簇索引上的记录X锁,聚簇索引上 加锁顺序为先[1] 记录,后[4] 记录

图片

事务2 假设有下面的一个 session2 会话

Select * from t1 where reg_time>=1000 for update

session2 从reg_time 非聚族索引索引出发 , 读到的 [1000, 4], [1100, 3] , [1200,1], [1300, 2] ,

首先,session2 会加reg_time索引上的记录 [1000, 4],[1100, 3],[1200,1],[1300, 2] 四个 记录的X锁,然后,session2 而且会加聚簇索引上的记录X锁,聚簇索引上 加锁顺序为 [4] 、[3] 、[2] 、[1] ,其中 先[4] 记录,后[1] 记录

图片

session2 加聚簇索引上的记录X锁时,发现跟session1的加锁顺序正好相反,两个Session恰好都持有了第一把锁,请求加第二 把锁,死锁就发生了。

解决方案: 如上面的原因2和原因3,对索引加锁顺序的不一致很可能会导致死锁,所以如果可以,尽量以相同的顺序来访问索引记录和表

在程序以批量方式处理数据的时候,如果事先对数据排序,保证每个线程按固定的顺序来处理记录,也可以大大降低出现死锁的可能;

通过统一的锁定顺序,可以有效地避免不同事务之间的锁定顺序不一致导致的死锁问题。

8 InnoDB预防死锁策略

InnoDB引擎内部(或者说是所有的数据库内部),有多种锁类型:事务锁(行锁表锁),Mutex(保护内部的共享变量操作)、RWLock(又称之为Latch,保护内部的页面读取与修改)。

InnoDB每个页面为16K,读取一个页面时,需要对页面加S锁(共享锁),更新一个页面时,需要对页面加上X锁(排他锁)。

任何情况下,操作一个页面,都会对页面加锁,页面锁加上之后,页面内存储的索引记录才不会被并发修改。

因此,为了修改一条记录,InnoDB内部如何处理:

  • 根据给定的查询条件,找到对应的记录所在页面;

  • 对页面加上X锁(RWLock),然后在页面内寻找满足条件的记录;

  • 在持有页面锁的情况下,对满足条件的记录加事务锁(行锁:根据记录是否满足查询条件,记录是否已经被删除,分别对应于上面提到的3种加锁策略之一);

相对于事务锁,页面锁是一个短期持有的锁,而事务锁(行锁、表锁)是长期持有的锁

因此,为了防止页面锁与事务锁之间产生死锁,InnoDB做了死锁预防的策略:

  • 持有事务锁(行锁、表锁),可以等待获取页面锁

  • 但反之,持有页面锁,不能等待持有事务锁。

根据死锁预防策略,在持有页面锁,加行锁的时候,如果行锁需要等待,则释放页面锁,然后等待行锁。

此时,行锁获取没有任何锁保护,因此加上行锁之后,记录可能已经被并发修改。因此,此时要重新加回页面锁,重新判断记录的状态,重新在页面锁的保护下,对记录加锁。

如果此时记录未被并发修改,那么第二次加锁能够很快完成,因为已经持有了相同模式的锁。但是,如果记录已经被并发修改,那么,就有可能导致死锁问题。

在数据库系统中,死锁的检测和解决通常是通过**锁管理器(Lock Manager)**来实现的。

  • 当一个事务请求某个数据页的锁时,锁管理器会检查当前锁的状态以及其他事务是否持有或等待相同的锁。

  • 如果存在潜在的死锁风险,系统会通过死锁检测算法来检测并解决死锁。其中,常用的死锁检测算法包括等待图(Wait-for graph)算法和超时算法。

在数据库系统的实现中,锁管理器会维护一个锁表(Lock Table),用于记录当前数据页的锁状态以及事务之间的关系。

当一个事务请求锁时,锁管理器会根据锁定顺序来判断是否存在死锁风险,并根据具体情况采取相应的措施,比如阻塞等待或者回滚事务。

在数据库系统的源代码级别,锁管理器通常是数据库引擎的一部分,具体实现方式会根据不同的数据库系统而有所不同。例如,MySQL、PostgreSQL、Oracle等数据库系统都有自己的锁管理器实现,通常会涉及到并发控制、事务管理等核心模块的代码。

总之,在MySQL 5.5.5及以上版本中,MySQL的默认存储引擎是InnoDB。该存储引擎使用的是行级锁,在某种情况下会产生死锁问题,所以InnoDB存储引擎采用了一种叫作等待图(wait-for graph)的方法来自动检测死锁,如果发现死锁,就会自动回滚一个事务

9 死锁案例分析

为了帮助大家了解mysql的死锁,下面有三个和死锁有关的案例:

9.1 案例一 拆借款

需求:投资人将投资的钱,拆成几份随机分配给借款人。

第一个版本的业务逻辑:投资人投资后,将金额随机分为几份,然后随机从借款人表里面选几个,然后通过一条条select for update 去更新借款人表里面的余额等。

例如两个用户同时投资,

  • A用户金额随机分为2份,分给借款人小明,小亮,

  • B用户金额随机分为2份,分给借款人小亮,小明

由于加锁的顺序不一样,死锁当然很快就出现了。

如何改进呢?对于这个问题的改进很简单,直接把所有分配到的借款人直接一次锁住就行了。

Select * from xxx where id in (xx,xx,xx) for update

在in里面的列表值mysql是会自动从小到大排序,加锁也是一条条从小到大加的锁。

例如(以下会话id为主键):

Session1:
mysql> select * from t3 where id in (8,9) for update;
+----+--------+------+---------------------+
| id | course | name | ctime |
+----+--------+------+---------------------+
| 8 | WA | f | 2016-03-02 11:36:30 |
| 9 | JX | f | 2016-03-01 11:36:30 |
+----+--------+------+---------------------+
rows in set (0.04 sec)

Session2:
select * from t3 where id in (10,8,6) for update;
锁等待中……

其实这个时候id=10这条记录没有被锁住的,但id=6的记录已经被锁住了,锁的等待在id=8(被Session1 锁住)的这里

Session3:

mysql> select * from t3 where id=6 for update;
锁等待中

可以看到id=6被Session2 锁住,锁等待中

Session4:

mysql> select * from t3 where id=10 for update;
+----+--------+------+---------------------+
| id | course | name | ctime |
+----+--------+------+---------------------+
| 10 | JB | g | 2016-03-10 11:45:05 |
+----+--------+------+---------------------+
row in set (0.00 sec)

在其它session中id=6是加不了锁的,但是id=10是可以加上锁的, 说明id=10这条记录没有被Session2锁住 。

9.2 案例二 有则插入无则更新

在开发中,经常会做这类的判断需求:

  • 根据字段值查询(有索引),如果不存在,则插入;

  • 否则更新。

以id为主键为例,目前还没有id=22的行

Session1:
select * from t3 where id=22 for update;
Empty set (0.00 sec)

session2:
select * from t3 where id=23 for update;
Empty set (0.00 sec)

Session1:
insert into t3 values(22,'ac','a',now());
锁等待中……

Session2:
insert into t3 values(23,'bc','b',now());
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

  • 当对存在的行 进行锁的时候(主键),mysql就只有行锁。

  • 当对未存在的行进行锁的时候(即使条件为主键),mysql是会锁住一段范围(有gap锁),也就是间隙锁(Gap Locks)

间隙锁(Gap Locks) 锁住的范围为:(无穷小或小于表中锁住id的最大值,无穷大或大于表中锁住id的最小值)

  • 如果表中目前有已有的id为(11,12),那么就锁住(12,无穷大)

  • 如果表中目前已有的id为(11,30),那么就锁住(11,30)

对于这种死锁的解决办法是:

insert into t3(xx,xx) on duplicate key update xx='XX';

用mysql特有的语法来解决此问题。因为insert语句对于主键来说,插入的行不管有没有存在,都会只有行锁

9.3 案例三 死锁日志分析

数据准备

--创建表 t2
create table t2(
id int primary key,
  name varchar(50),
age int
);
--插入数据
insert into t2 values(1,'lisi',11),(2,'zhangsan',22),(3,'wangwu',33);

数据库隔离级别查看

mysql> select @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+

查看加锁信息

-- information_schema.innodb_trx: 当前出现的锁
select * from information_schema.innodb_locks;
-- information_schema.innodb_trx: 当前运行的所有事务
select * from information_schema.innodb_trx;
-- information_schema.innodb_lock_waits: 锁等待的对应关系
select * from information_schema.innodb_lock_waits;

查看InnoDB状态 ( 包含最近的死锁日志信息 )

show engine innodb status;

案例分析

这里我们进行细致的分析,两个事物每执行一条SQL,可以查看下innodb锁状态及锁等待信息以及当前innodb事务列表信息,最后可以通过 show engine innodb status 查看最近的死锁日志信息.

场景1:

  • 事务1, 执行begin开始事务执行一条SQL,查询 id=1 的数据

mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t2 where id = 1 for update;
+----+------+------+
| id | name | age |
+----+------+------+
|  1 | lisi |  11 |
+----+------+------+
1 row in set (0.00 sec)

分析加锁过程:

  1. 事务1进行首先申请IX锁 (意向排它锁,因为是for update)

  2. 然后申请X锁进行查询是否存在 id = 1 的记录

  3. 存在该记录,因为id字段是唯一索引,所以添加的是 Record Lock

  4. 查看 information_schema.innodb_trx表,发现存在事务1 的信息

select trx_id '事务id',trx_state '事务状态',
trx_started '事务开始时间',trx_weight '事务权重',
  trx_mysql_thread_id '事务线程ID',
  trx_tables_locked '事务拥有多少个锁',
  trx_lock_memory_bytes '事务锁住的内存大小',
  trx_rows_locked '事务锁住的行数',
  trx_rows_modified '事务更改的行数'
from information_schema.innodb_trx;

  • 执行事务2的 delete语句, 删除成功,因为id=3的数据并没有被加锁

mysql> delete from t2 where id = 3; -- 删除成功

  • 事务1对 id=3 的记录进行修改操作,发生阻塞. 因为id=3的数据的X锁已经被事务2拿到,其他事务的操 作只能被阻塞.

mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t2 where id = 1 for update;
+----+------+------+
| id | name | age |
+----+------+------+
|  1 | lisi |  11 |
+----+------+------+
1 row in set (0.00 sec)
mysql> update t2 set name = 'aaa' where id = 3;
-- 阻塞

  • 查看当前锁信息

-- 查看当前锁信息
select
lock_id '锁ID',
 lock_trx_id '拥有锁的事务ID',
 lock_mode '锁模式',
 lock_type '锁类型' ,
 lock_table '被锁的索引',
 lock_space '被锁的表空间号',
 lock_page '被锁的页号',
 lock_rec '被锁的记录号',
 lock_data '被锁的数据'
from information_schema.innodb_locks;

lock_rec=4 表示是对唯一索引进行的加锁. lock_mode= X 表示这里加的是X锁.

-- 查看锁等待的对应关系
select requesting_trx_id '请求锁的事务ID',
  requested_lock_id '请求锁的锁ID',
  blocking_trx_id '当前拥有锁的事务ID',
  blocking_lock_id '当前拥有锁的锁ID'
from information_schema.innodb_lock_waits;

  • 事务2 执行删除操作,删除 id = 1的数据成功.

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> delete from t2 where id = 3;
Query OK, 1 row affected (0.00 sec)

mysql> delete from t2 where id = 1;
Query OK, 1 row affected (0.00 sec)

  • 但是事务1已经检测到了死锁的发生

mysql> update t2 set name = 'aaa' where id = 3;
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

--事务1 commit,更新操作失败
mysql> commit;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from test_dead;
-- ERROR 1146 (42S02): Table 'test_lock.test_dead' doesn't exist

mysql> select * from t2;
+----+----------+------+
| id | name | age |
+----+----------+------+
| 1 | lisi | 11 |
| 2 | zhangsan | 22 |
+----+----------+------+
2 rows in set (0.00 sec)

-- 事务2 commit ,删除操作成功
mysql> commit;

mysql> select * from t2;
+----+----------+------+
| id | name | age |
+----+----------+------+
| 1 | lisi | 11 |
| 2 | zhangsan | 22 |
+----+----------+------+
2 rows in set (0.00 sec)

查看死锁日志

  • ACTIVE 309秒 sec : 表示事务活动时间

  • starting index read : 表示读取索引

  • tables in use 1: 表示有一张表被使用了

  • LOCK WAIT 3 lock struct(s): 表示该事务的锁链表的长度为3,每个链表节点代表该事务持有的一个锁结构,包括表锁,记录锁以及 autoinc 锁等.

  • heap size 1136 : 为事务分配的锁堆内存大小

  • 3 row lock(s): 表示当前事务持有的行锁个数/gap锁的个数

    LATEST DETECTED DEADLOCK
    ------------------------
    2022-04-04 06:22:01 0x7fa66b39d700
    *** (1) TRANSACTION: 事务1
    TRANSACTION 16472, ACTIVE 309 sec starting index read
    -- 事务编号 16472,活跃秒数 309,starting index read 表示事务状态为根据索引读取数据.

    mysql tables in use 1, locked 1
    -- 表示有一张表被使用了 ,locked 1 表示表上有一个表锁,对于DML语句为LOCK_IX

    LOCK WAIT 3 lock struct(s), heap size 1136, 3 row lock(s)
    MySQL thread id 20, OS thread handle 140352739985152, query id 837 localhost root updating

    update t2 set name = 'aaa' where id = 3
    --当前正在等待锁的SQL语句.

    *** (1) WAITING FOR THIS LOCK TO BE GRANTED:
    *** (2) TRANSACTION:
    *** (2) HOLDS THE LOCK(S):
    *** (2) WAITING FOR THIS LOCK TO BE GRANTED:

10 死锁产生的前提和建议

前提

  • 互斥:不能共享

  • 持有并等待:当前事务保持至少一个资源,同时在等待获取其他资源。

  • 不可剥夺:已获得的资源不能被强制释放,只能由获取该资源的事务主动释放。

  • 循环等待:系统中若干事务之间形成了一个循环等待资源的链。

    死锁的关键在于:两个(或以上)的Session加锁的顺序不一致。那么对应的解决死锁问题的关键就是:让不同的session加锁有次序

建议:

  • 一致性排序

    对索引加锁顺序的不一致很可能会导致死锁, 所以如果可以, 尽量以相同的顺序来访问索引记录和表.

    在程序以批量方式处理数据的时候, 如果事先对数据排序, 保证每个线程按固定的顺序来处理记录, 也可以大大降低出现死锁的可能.

  • 间隙锁

    往往是程序中导致死锁的真凶, 由于默认情况下 MySQL 的隔离级别是 RR(Repeatable Read,可重复读), 所以如果能确定幻读和不可重复读对应用的影响不大, 可以考虑将隔离级别改成 RC, 可以避免 Gap 锁导致的死锁.

  • 为表添加合理的索引, 如果不走索引将会为表的每一行记录加锁, 死锁的概率就会大大增大.

  • 避免大事务, 尽量将大事务拆成多个小事务来处理.

    因为大事务占用资源多, 耗时长, 与其他事务冲突的概率也会变高.

  • 避免在同一时间点运行多个对同一表进行读写的脚本, 特别注意加锁且操作数据量比较大的语句.

  • 超时和重试机制设置锁等待超时参数

    innodb_lock_wait_timeout,在并发访问比较高的情况下,如果大量事务因无法立即获得所需的锁而挂起,会占用大量计算机资源,造成严重性能问题,甚至拖跨数据库。

    我们通过设置合适的锁等待超时阈值,可以避免这种情况发生。

11 线上发生了死锁,应该如何具体操作?

数据库的死锁是指不同的事务在获取资源时相互等待,导致无法继续执行的情况。

MySQL中可能发生死锁的情况包括事务同时更新多个表、事务嵌套、索引顺序不一致以及不同事务同时更新相同的索引等。

虽然数据库有死锁的预防策略,以及自动的处理措施。但是,在线上很多场景下, 数据的的死锁预防策略和回滚策略 , 通常达不到预期的效果。

如果线上发生了死锁,我们应该采取以下步骤进行处理:

11.1 监控死锁

通过数据库的监控工具或命令查看是否存在死锁情况,了解死锁的具体情况,包括死锁的事务和死锁的资源。

step1:查看当前正在等待锁的事务

SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCK_WAITS;

运行以上SQL语句,可以查看当前正在等待锁的事务列表。

根据返回结果,可以分析哪些事务在等待哪些锁,以及等待锁的具体类型。

step2:查看当前持有的锁信息

SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS;

运行以上SQL语句,可以查看当前数据库中的锁信息。

通过分析返回结果,可以了解哪些锁正在被持有,以及锁的持有者和锁的类型。

step3:查看当前的死锁信息

SHOW ENGINE INNODB STATUS;

运行以上SQL语句,可以显示当前的InnoDB存储引擎的状态信息。

其中包括死锁检测结果。如果存在死锁,可以通过分析该信息来解决死锁问题。

11.2 终止死锁事务

一旦发现死锁,需要找到造成死锁的事务,并选择其中一个事务终止。可以根据事务的执行时间、影响行数、优先级等因素进行终止决策。

可以采取以下方法来解决死锁问题:

  • 回滚事务:

    使用以下命令回滚某个事务以解除死锁:

    ROLLBACK;

  • 杀死进程:

    使用以下命令查找引起死锁的进程:

    SHOW PROCESSLIST;

    找到引起死锁的进程ID后,使用以下命令杀死该进程:

    KILL <process_id>;

11.3 重试事务

终止死锁事务后,需要重新执行被终止的事务。

重试事务 之前,需要调整事务顺序

这可能需要一些逻辑处理,例如对数据进行回滚或者重新执行一些操作。

11.4 防止死锁再次发生

通过数据库的日志和监控信息,分析死锁的原因。

可以根据死锁原因对数据库的设计和代码进行优化,以尽量减少死锁的发生。

根据分析结果,针对性地进行数据库结构调整、索引优化、事务隔离级别调整等措施,以降低死锁的概率。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值