【3.2】单调栈、MySQL日志、Buffer Pool、Redis实战

6.1 MySQL日志:undo log、redo log、binlog 有什么用?

在执行update语句时,会涉及到MySQL的三个日志:

  • undo log(回滚日志):是 Innodb 存储引擎层生成的日志,实现了事务中的原子性,主要用于事务回滚和 MVCC
  • redo log(重做日志):是 Innodb 存储引擎层生成的日志,实现了事务中的持久性,主要用于掉电等故障恢复
  • binlog (归档日志):是 Server 层生成的日志,主要用于数据备份和主从复制

为什么需要undo log?

  • 实现事务回滚,保障事务的原子性。事务处理过程中,如果出现了错误或者用户执 行了 ROLLBACK 语句,MySQL 可以利用 undo log 中的历史数据将数据恢复到事务开始之前的状态。

    • 在事务还没提交之前,MSQL会先记录更新前的数据到undo log日志中,当事务回滚时,可以利用undo log 日志文件进行回滚。
      • 比如:在插入一条记录时,要把这条记录的主键值记下来,这样之后回滚时只需要把这个主键值对应的记录删掉就好了;在删除一条记录时,要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了;
  • 实现 MVCC(多版本并发控制)关键因素之一。MVCC 是通过 ReadView + undo log 实现的。undo log 为每条记录保存多份历史数据,MySQL 在执行快照读(普通 select 语句)的时候,会根据事务的 Read View 里的信息,顺着 undo log 的版本链找到满足其可见性的记录。

    • 一条记录的每一次更新操作产生的 undo log 格式都有一个 roll_pointer 指针和一个 trx_id 事务id:

      • 通过 trx_id 可以知道该记录是被哪个事务修改的;

      • 通过 roll_pointer 指针可以将这些 undo log 串成一个链表,这个链表就被称为版本链;

为什么需要redo log?

  • 为什么需要redo log?

    • 实现事务的持久性,让 MySQL 有 crash-safe 的能力,能够保证 MySQL 在任何时间段突然崩溃,重启后之前已提交的记录都不会丢失;

    • 因为使用了WAL(预先日志持久化)技术,将写操作从「随机写」变成了「顺序写」,提升 MySQL 写入磁盘的性能。(redo log是顺序写的,数据是随机写的)

      • WAL技术:当有一条记录需要更新的时候,InnoDB 引擎就会先更新内存(同时标记为脏页),然后将本次对这个页的修改以 redo log 的形式记录下来,这个时候更新就算完成了。然后InnoDB 引擎会在适当的时候,由后台线程将缓存在 Buffer Pool 的脏页刷新到磁盘里,这就是 WAL (Write-Ahead Logging)技术,MySQL 的写操作并不是立刻写到磁盘上,而是先写日志,然后在合适的时间再写到磁盘上。
  • 什么是redo log?

    **redo log是物理日志,记录了某个数据页做了什么修改。**在事务提交时,只要先将redo log 持久化到磁盘即可,可以不需要等待Buffer Pool 中的脏页数据持久化到磁盘,这样,即使脏页数据没有持久化,但是redo log已经持久化,MySQL重启后,可以根据redo log的内容,将所有数据恢复到最新状态。

  • undo页面被修改,需要记录redo log吗?

    • 开启事务后,InnoDB更新记录前,要先记录undo log,如果是更新操作,需要把更新的列的旧值记录,也就是要生成undo log,undo log会写入Buffer Pool 的undo页面。在内存修改过undo页面后,也需要记录对应的redo log,所以只要redo log写入磁盘了,undo就不怕丢失。
  • undo log 和redo log

    • undo log日志记录此次事务开始之前的状态,记录的是更新数据之前的值。
    • redo log日志记录此次事务完成后的数据状态,记录的是更新之后的值。
    • 事务执行时发生崩溃,重启后使用undo log恢复事务,事务提交后发生崩溃,重启后通过redo log恢复事务。有了redo log 和WAL技术,使得数据库具有crash-safe(崩溃恢复)的能力。
  • redo log 是直接写入磁盘吗?

    • redo log有自己的缓存——redo log buffer,每产生一条redo log 时,会先写入到redo log buffer ,后续在持久化到磁盘。

    • redo log buffer 默认大小 16 MB,可以通过 innodb_log_Buffer_size 参数动态的调整大小,增大它的大小可以让 MySQL 处理「大事务」是不必写入磁盘,进而提升写 IO 性能。

redo log什么时候刷新到磁盘?

  • 缓存在redo log buffer中的redo log还是在内存中,它什么时候刷新到磁盘?

    • MySQL 正常关闭时;

    • 当 redo log buffer 中记录的写入量大于 redo log buffer 内存空间的一半时,会触发落盘;

    • InnoDB 的后台线程每隔 1 秒,将 redo log buffer 持久化到磁盘。

    • 每次事务提交时都将缓存在 redo log buffer 里的 redo log 直接持久化到磁盘(这个策略可由 innodb_flush_log_at_trx_commit 参数控制,下面会说)。

  • innodb_flush_log_at_trx_commit 参数

    InnoDB 还提供了另外两种策略,由参数 innodb_flush_log_at_trx_commit 参数控制,可取的值有:0、1、2,默认值为 1,这三个值分别代表的策略如下:

    • 当设置该参数为 0 时,表示每次事务提交时 ,还是将 redo log 留在 redo log buffer 中 ,该模式下在事务提交时不会主动触发写入磁盘的操作。
      • 参数0时:InnoDB后台进程每隔一秒,会把缓存在 redo log buffer 中的 redo log ,通过调用 write() 写到操作系统的 Page Cache,然后调用 fsync() 持久化到磁盘。所以参数为 0 的策略,MySQL 进程的崩溃会导致上一秒钟所有事务数据的丢失;
    • 当设置该参数为 1 时(默认),表示每次事务提交时,都将缓存在 redo log buffer 里的 redo log 直接持久化到磁盘,这样可以保证 MySQL 异常重启之后数据不会丢失。
    • 当设置该参数为 2 时,表示每次事务提交时,都只是缓存在 redo log buffer 里的 redo log 写到 redo log 文件,注意写入到「 redo log 文件」并不意味着写入到了磁盘,因为操作系统的文件系统中有个 Page Cache,Page Cache 是专门用来缓存文件数据的,所以写入「 redo log文件」意味着写入到了操作系统的文件缓存。
      • 参数 2 :InnoDB后台进程每隔一秒,调用 fsync( ),将缓存在操作系统中 Page Cache 里的 redo log 持久化到磁盘。所以参数为 2 的策略,较取值为 0 情况下更安全,因为 MySQL 进程的崩溃并不会丢失数据,只有在操作系统崩溃或者系统断电的情况下,上一秒钟所有事务数据才可能丢失
  • 三个参数的应用场景

    • 数据安全性:参数 1 > 参数 2 > 参数 0
    • 写入性能:参数 0 > 参数 2> 参数 1
    • 在一些对数据安全性要求比较高的场景中,显然 innodb_flush_log_at_trx_commit 参数需要设置为 1。
    • 在一些可以容忍数据库崩溃时丢失 1s 数据的场景中,我们可以将该值设置为 0,这样可以明显地减少日志同步到磁盘的 I/O 操作。
    • 安全性和性能折中的方案就是参数 2,虽然参数 2 没有参数 0 的性能高,但是数据安全性方面比参数 0 强,因为参数 2 只要操作系统不宕机,即使数据库崩溃了,也不会丢失数据,同时性能方便比参数 1 高。

redo log文件写满了怎么办?

  • 默认情况下, InnoDB 存储引擎有 1 个重做日志文件组( redo log Group),「重做日志文件组」由有 2 个 redo log 文件组成,这两个 redo 日志的文件名叫 :ib_logfile0ib_logfile1

    • 在重做日志组中,每个 redo log File 的大小是固定且一致的,假设每个 redo log File 设置的上限是 1 GB,那么总共就可以记录 2GB 的操作。

    • 重做日志文件组是以循环写的方式工作的,从头开始写,写到末尾就又回到开头,相当于一个环形。

    • 所以 InnoDB 存储引擎会先写 ib_logfile0 文件,当 ib_logfile0 文件被写满的时候,会切换至 ib_logfile1 文件,当 ib_logfile1 文件也被写满时,会切换回 ib_logfile0 文件。

  • 我们知道 redo log 是为了防止 Buffer Pool 中的脏页丢失而设计的,那么如果随着系统运行,Buffer Pool 的脏页刷新到了磁盘中,那么 redo log 对应的记录也就没用了,这时候我们擦除这些旧记录,以腾出空间记录新的更新操作。

    • redo log 是循环写的方式,相当于一个环形,InnoDB 用 write pos 表示 redo log 当前记录写到的位置,用 checkpoint 表示当前要擦除的位置,如下图:

      img
    • 如果 write pos 追上了 checkpoint,就意味着 redo log 文件满了,这时 MySQL 不能再执行新的更新操作,也就是说 MySQL 会被阻塞因此所以针对并发量大的系统,适当设置 redo log 的文件大小非常重要),此时会停下来将 Buffer Pool 中的脏页刷新到磁盘中,然后标记 redo log 哪些记录可以被擦除,接着对旧的 redo log 记录进行擦除,等擦除完旧记录腾出了空间,checkpoint 就会往后移动(图中顺时针),然后 MySQL 恢复正常运行,继续执行新的更新操作。

    • 一次 checkpoint 的过程就是脏页刷新到磁盘中变成干净页,然后标记 redo log 哪些记录可以被覆盖的过程

为什么需要binlog?

MySQL 在完成一条更新操作后,Server 层还会生成一条 binlog,等之后事务提交的时候,会将该事物执行过程中产生的所有 binlog 统一写 入 binlog 文件。

binlog 文件是记录了所有数据库表结构变更和表数据修改的日志,不会记录查询类的操作,比如 SELECT 和 SHOW 操作。

  • redo log 和bin log

    binlog没有crash-safe的功能,只能用于归档。而InnoDB 是另一个公司以插件形式引入 MySQL 的,既然 binlog 没有 crash-safe 能力,所以 InnoDB 使用 redo log 来实现 crash-safe 能力 。

redo log 和binlog 有什么区别?

  1. 适用对象不同:
  • binlog 是 MySQL 的 Server 层实现的日志,所有存储引擎都可以使用;
  • redo log 是 Innodb 存储引擎实现的日志;

2、文件格式不同:

  • binlog 有 3 种格式类型,分别是 STATEMENT(默认格式)、ROW、 MIXED,区别如下:
    • STATEMENT:每一条修改数据的 SQL 都会被记录到 binlog 中(相当于记录了逻辑操作,所以针对这种格式, binlog 可以称为逻辑日志),主从复制中 slave 端再根据 SQL 语句重现。但 STATEMENT 有动态函数的问题,比如你用了 uuid 或者 now 这些函数,你在主库上执行的结果并不是你在从库执行的结果,这种随时在变的函数会导致复制的数据不一致;
    • ROW:记录行数据最终被修改成什么样了(这种格式的日志,就不能称为逻辑日志了),不会出现 STATEMENT 下动态函数的问题。但 ROW 的缺点是每行数据的变化结果都会被记录,比如执行批量 update 语句,更新多少行数据就会产生多少条记录,使 binlog 文件过大,而在 STATEMENT 格式下只会记录一个 update 语句而已;
    • MIXED:包含了 STATEMENT 和 ROW 模式,它会根据不同的情况自动使用 ROW 模式和 STATEMENT 模式;
  • redo log 是物理日志,记录的是在某个数据页做了什么修改,比如对 XXX 表空间中的 YYY 数据页 ZZZ 偏移量的地方做了AAA 更新;

3、写入方式不同:

  • binlog 是追加写,写满一个文件,就创建一个新的文件继续写,不会覆盖以前的日志,保存的是全量的日志。
  • redo log 是循环写,日志空间大小是固定,全部写满就从头开始,保存未被刷入磁盘的脏页日志。

4、用途不同:

  • binlog 用于备份恢复、主从复制;

  • redo log 用于掉电等故障恢复。

  • 如果整个数据库都被删除了,就不能使用redo log文件恢复。因为redo log有check point机制,而binlog保存的是全量的日志,可以使用binlog恢复数据。

主从复制是怎么实现的?

  • 主从复制是怎么实现的?

    • MySQL 的主从复制依赖于 binlog 实现,也就是记录 MySQL 上的所有变化并以二进制形式保存在磁盘上。复制的过程就是将 binlog 中的数据从主库传输到从库上。

    • 这个过程一般是异步的,也就是主库上执行事务操作的线程不会等待复制 binlog 的线程同步完成。

  • 主从复制的好处

    • 在完成主从复制之后,你就可以在写数据时只写主库,在读数据时只读从库,这样可以降低主库的压力。
    • 如果主库存在问题,可以快速切换到从库提供服务。
    • 可以在从库进行备份,以免备份期间影响主库的服务。
  • MySQL 集群的主从复制过程的 3 个阶段:

    • 主库写入 Binlog:MySQL 主库在收到客户端提交事务的请求之后,主库写 binlog 日志,提交事务,并更新本地存储数据,事务提交完成后,返回给客户端“操作成功”的响应。

    • 从库同步 Binlog:把 binlog 复制到所有从库上,每个从库把 binlog 写到暂存日志中。

      • 从库会创建一个专门的 I/O 线程,连接主库的 log dump 线程,来接收主库的 binlog 日志,再把 binlog 信息写入 relay log 的中继日志里,再返回给主库“复制成功”的响应。
    • 从库回放 Binlog:回放 binlog,并更新存储引擎中的数据。

      • 从库会创建一个用于回放 binlog 的线程,去读 relay log 中继日志,然后回放 binlog ,更新存储引擎中的数据,最终实现主从的数据一致性。
  • 从库是不是越多越好?

    • 从库数量增加,从库连接上来的 I/O 线程也比较多,主库也要创建同样多的 log dump 线程来处理复制的请求,对主库资源消耗比较高,同时还受限于主库的网络带宽。所以在实际使用中,一个主库一般跟 2~3 个从库(1 套数据库,1 主 2 从 1 备主),这就是一主多从的 MySQL 集群结构。
  • MySQL 主从复制还有哪些模型?

    • 同步复制:MySQL 主库提交事务的线程要等待所有从库的复制成功响应,才返回客户端结果。这种方式在实际项目中,基本上没法用,原因有两个:一是性能很差,因为要复制到所有节点才返回响应;二是可用性也很差,主库和所有从库任何一个数据库出问题,都会影响业务。
    • 异步复制(默认模型):MySQL 主库提交事务的线程并不会等待 binlog 同步到各从库,就返回客户端结果。这种模式一旦主库宕机,数据就会发生丢失。
    • 半同步复制:MySQL 5.7 版本之后增加的一种复制方式,介于两者之间,事务线程不用等待所有的从库复制成功响应,只要一部分复制成功响应回来就行,比如一主二从的集群,只要数据成功复制到任意一个从库上,主库的事务线程就可以返回给客户端。这种半同步复制的方式,兼顾了异步复制和同步复制的优点,即使出现主库宕机,至少还有一个从库有最新的数据,不存在数据丢失的风险

binlog 什么时候刷新到磁盘?

  • binlog cache

    • MySQL 给每个线程分配了一片内存用于缓冲 binlog ,该内存叫 binlog cache,参数 binlog_cache_size 用于控制单个线程内 binlog cache 所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。

    • 事务执行过程中,先把日志写到 binlog cache(Server 层的 cache),事务提交的时候,再把 binlog cache 写到 binlog 文件中。

    • 一个事务的binlog不能被拆开,要保证一次性写入,确保事务的原子性。

  • 什么时候binlog cache 会写到binlog文件

    • 在事务提交的时候,执行器使用write()把 binlog cache 里的完整事务写入到 binlog 文件中,并清空 binlog cache。虽然每个线程有自己 binlog cache,但是最终都写到同一个 binlog 文件。但是并没有把数据持久化到磁盘,因为数据还缓存在文件系统的 page cache 里,write 的写入速度还是比较快的,因为不涉及磁盘 I/O。
    • fsync()函数,才是将数据持久化到磁盘的操作,这里就会涉及磁盘 I/O,所以频繁的 fsync 会导致磁盘的 I/O 升高。MySQL提供一个 sync_binlog 参数来控制数据库的 binlog 刷到磁盘上的频率。
  • sync_binlog参数

    • sync_binlog = 0 的时候,表示每次提交事务都只 write,不 fsync,后续交由操作系统决定何时将数据持久化到磁盘;
      • 这时候的性能是最好的,但是风险也是最大的。因为一旦主机发生异常重启,还没持久化到磁盘的数据就会丢失。
    • sync_binlog = 1 的时候,表示每次提交事务都会 write,然后马上执行 fsync;
      • 是最安全但是性能损耗最大的设置。因为当设置为 1 的时候,即使主机发生异常重启,最多丢失一个事务的 binlog,而已经持久化到磁盘的数据就不会有影响,不过就是对写入性能影响太大。
    • sync_binlog =N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。
      • 如果能容少量事务的 binlog 日志丢失的风险,为了提高写入的性能,一般会 sync_binlog 设置为 100~1000 中的某个数值。

为什么需要两阶段提交?

  • 为什么需要两阶段提交:

    • 在持久化 redo log 和 binlog 这两份日志的时候,由于两个日志的写入时机不同,(redo log在事务执行过程中就可以写入,而binlog在事务提交时才可以写入),可能会出现半成功的状态,出现主从数据不一致的情况。这是因为 redo log 影响主库的数据,binlog 影响从库的数据,所以 redo log 和 binlog 必须保持一致才能保证主从数据一致。

    • MySQL 为了避免出现两份日志之间的逻辑不一致的问题,使用了「两阶段提交」来解决,两阶段提交其实是分布式事务一致性协议,它可以保证多个逻辑操作要不全部成功,要不全部失败,不会出现半成功的状态。

  • 两阶段提交的过程?

    • 两阶段提交把单个事务的提交拆分成了 2 个阶段,分别是「准备(Prepare)阶段」和「提交(Commit)阶段」,每个阶段都由协调者(Coordinator)和参与者(Participant)共同完成。

    • 为了保证这两个日志的一致性,MySQL 使用了内部 XA 事务,内部 XA 事务由 binlog 作为协调者,存储引擎是参与者。当客户端执行 commit 语句或者在自动提交的情况下,MySQL 内部开启一个 XA 事务,分两阶段来完成 XA 事务的提交。

      • prepare 阶段:将 XID(内部 XA 事务的 ID) 写入到 redo log,同时将 redo log 对应的事务状态设置为 prepare,然后将 redo log 持久化到磁盘(innodb_flush_log_at_trx_commit = 1 的作用);
      • commit 阶段:把 XID 写入到 binlog,然后将 binlog 持久化到磁盘(sync_binlog = 1 的作用),之后将 redo log 状态设置为 commit。
异常重启出现什么问题?
  • 不管是时刻 A(redo log 已经写入磁盘, binlog 还没写入磁盘),还是时刻 B (redo log 和 binlog 都已经写入磁盘,还没写入 commit 标识)崩溃,此时的 redo log 都处于 prepare 状态

    时刻 A 与时刻 B
  • 在 MySQL 重启后会按顺序扫描 redo log 文件,碰到处于 prepare 状态的 redo log,就拿着 redo log 中的 XID 去 binlog 查看是否存在此 XID:

    • 如果 binlog 中没有当前内部 XA 事务的 XID,说明 redolog 完成刷盘,但是 binlog 还没有刷盘,则回滚事务。对应时刻 A 崩溃恢复的情况。

    • 如果 binlog 中有当前内部 XA 事务的 XID,说明 redolog 和 binlog 都已经完成了刷盘,则提交事务。对应时刻 B 崩溃恢复的情况。

    • 所以,两阶段提交是以 binlog 写成功为事务提交成功的标识。因为 binlog 写成功了,就意味着能在 binlog 中查找到与 redo log 相同的 XID。即使redo log 处于prepare状态,MySQL在时刻B崩溃,此时重启服务器,就提交事务。

  • 为什么要这么设计?

    • 因为binlog 已经写入了,之后就会被从库(或者用这个 binlog 恢复出来的库)使用。所以,在主库上也要提交这个事务。采用这个策略,主库和备库的数据就保证了一致性。
  • 事务没提交时,redo log 会被持久化到磁盘吗?

    • 事务执行中间过程的 redo log 也是直接写在 redo log buffer 中的,这些缓存在 redo log buffer 里的 redo log 也会被「后台线程」每隔一秒一起持久化到磁盘。也就是说,事务没提交的时候,redo log 也是可能被持久化到磁盘的

    • 如果Mysql崩溃了,但是没提交事务的redo log却被持久化到磁盘中,重启后,会不会造成数据不一致?

      • 这种情况下MySQL会进行回滚操作,因为事务没有提交,binlog没有持久化到磁盘。
      • 所以, redo log 可以在事务没提交之前持久化到磁盘,但是 binlog 必须在事务提交之后,才可以持久化到磁盘
两阶段提交的问题?

两阶段提交虽然保证了两个日志文件的数据一致性,但是性能很差,主要有两个方面的影响:

  • 磁盘 I/O 次数高:对于“双1”配置,每个事务提交都会进行两次 fsync(刷盘)操作,会影响性能。
    • 当 sync_binlog = 1 的时候,表示每次提交事务都会将 binlog cache 里的 binlog 直接持久化到磁盘;
    • 当 innodb_flush_log_at_trx_commit = 1 时,表示每次事务提交时,都将缓存在 redo log buffer 里的 redo log 直接持久化到磁盘;
  • 锁竞争激烈:在两阶段提交的流程基础上,还需要加一个锁来保证提交的原子性,从而保证多事务的情况下,两个日志的提交顺序一致。在并发量较大的时候,就会导致对锁的争用,性能不佳。
组提交

MySQL 引入了 binlog 组提交(group commit)机制,当有多个事务提交的时候,会将多个 binlog 刷盘操作合并成一个,从而减少磁盘 I/O 的次数

引入了组提交机制后,prepare 阶段不变,只针对 commit 阶段,将 commit 阶段拆分为三个过程:

  • flush 阶段:多个事务按进入的顺序将 binlog 从 cache 写入文件(不刷盘);
    • 每个阶段都有一个队列,每个阶段有锁进行保护,因此保证了事务写入的顺序,第一个进入队列的事务会成为 leader,leader领导所在队列的所有事务,全权负责整队的操作,完成后通知队内其他事务操作结束。
    • 对每个阶段引入了队列后,锁就只针对每个队列进行保护,不再锁住提交事务的整个过程,可以看的出来,锁粒度减小了,这样就使得多个阶段可以并发执行,从而提升效率
  • sync 阶段:对 binlog 文件做 fsync 操作(多个事务的 binlog 合并一次刷盘);
  • commit 阶段:各个事务按顺序做 InnoDB commit 操作;

MySQL磁盘I/O很高的优化方法

  • 设置组提交的两个参数: binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 参数,延迟 binlog 刷盘的时机,从而减少 binlog 的刷盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间。
  • 将 sync_binlog 设置为大于 1 的值(比较常见是 100~1000),表示每次提交事务都 write,但累积 N 个事务后才 fsync,相当于延迟了 binlog 刷盘的时机。
  • 将 innodb_flush_log_at_trx_commit 设置为 2。表示每次事务提交时,都只是缓存在 redo log buffer 里的 redo log 写到 redo log 文件(Page Cache中)。

总结

具体更新一条记录 UPDATE t_user SET name = 'xiaolin' WHERE id = 1; 的流程如下:

  1. 执行器通过主键索引获取记录。执行器负责具体执行,会调用存储引擎的接口,通过主键索引树搜索获取 id = 1 这一行记录:
    • 如果 id=1 这一行所在的数据页本来就在 buffer pool 中,就直接返回给执行器更新;
    • 如果记录不在 buffer pool,将数据页从磁盘读入到 buffer pool,返回记录给执行器。
  2. 执行器得到聚簇索引记录后,会看一下更新前的记录和更新后的记录是否一样:
    • 如果一样的话就不进行后续更新流程;
    • 如果不一样的话就把更新前的记录和更新后的记录都当作参数传给 InnoDB 层,让 InnoDB 真正的执行更新记录的操作;
  3. 更新前,记录undo log ,redo log 记录BufferPool中的Undo页面。开启事务, InnoDB 层更新记录前,首先要记录相应的 undo log,因为这是更新操作,需要把被更新的列的旧值记下来,也就是要生成一条 undo log,undo log 会写入 Buffer Pool 中的 Undo 页面,不过在内存修改该 Undo 页面后,需要记录对应的 redo log。
  4. 使用WAL技术,语句更新完成,记录redo log。 InnoDB 层开始更新记录,会先更新内存(同时标记为脏页),然后将记录写到 redo log 里面,这个时候更新就算完成了。为了减少磁盘I/O,不会立即将脏页写入磁盘,后续由后台线程选择一个合适的时机将脏页写入到磁盘。这就是 WAL 技术,MySQL 的写操作并不是立刻写到磁盘上,而是先写 redo 日志,然后在合适的时间再将修改的行数据写到磁盘上。
  5. 至此,一条记录更新完了。
  6. 记录binglog,保存到binlog cache。在一条更新语句执行完成后,然后开始记录该语句对应的 binlog,此时记录的 binlog 会被保存到 binlog cache,并没有刷新到硬盘上的 binlog 文件,在事务提交时才会统一将该事务运行过程中的所有 binlog 刷新到硬盘。
  7. 事务提交(redo log 刷盘,binlog刷盘):
    • prepare 阶段:将 redo log 对应的事务状态设置为 prepare,然后将 redo log 刷新到硬盘;
    • commit 阶段:将 binlog 刷新到磁盘,接着调用引擎的提交事务接口,将 redo log 状态设置为 commit(将事务设置为 commit 状态后,刷入到磁盘 redo log 文件);
  8. 至此,一条更新语句执行完成。

7.1 解开Buffer Pool 的面纱

为什么要有Buffer Pool?

  • Innodb 存储引擎设计了一个缓冲池(Buffer Pool,来提高数据库的读写性能。

  • Buffer Pool 以页为单位缓冲数据,可以通过 innodb_buffer_pool_size 参数调整缓冲池的大小,默认是 128 M。

  • 有了 Buffer Pool 后

    • 当读取数据时,如果数据存在于 Buffer Pool 中,客户端就会直接读取 Buffer Pool 中的数据,否则去磁盘中读取到buffer pool中,返回给执行器。

    • 当修改数据时,如果数据存在于 Buffer Pool 中,那直接修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页(该页的内存数据和磁盘上的数据已经不一致),为了减少磁盘I/O,不会立即将脏页写入磁盘,后续由后台线程选择一个合适的时机将脏页写入到磁盘。

  • Buffer Poll 缓存了什么?

    • 在 MySQL 启动的时候,InnoDB 会为 Buffer Pool 申请一片连续的内存空间,然后按照默认的16KB的大小划分出一个个的页, Buffer Pool 中的页就叫做缓存页。此时这些缓存页都是空闲的,之后随着程序的运行,才会有磁盘上的页被缓存到 Buffer Pool 中。

      • 为了更好的管理这些在 Buffer Pool 中的缓存页,InnoDB 为每一个缓存页都创建了一个控制块,控制块信息包括「缓存页的表空间、页号、缓存页地址、链表节点」等等。
    • Buffer Pool 除了缓存「索引页」和「数据页」,还包括了 Undo 页,插入缓存、自适应哈希索引、锁信息等等。

      • undo log 会写入到 Buffer Pool的Undo页面中。
  • Buffer Pool 是提高了读写效率没错,但是问题来了,Buffer Pool 是基于内存的,而内存总是不可靠,万一断电重启,还没来得及落盘的脏页数据就会丢失。

如何管理Buffer Pool?

  • Innodb 通过三种链表来管理缓页:

    • Free List (空闲页链表),管理空闲页;
    • Flush List (脏页链表),管理脏页;
    • LRU List,管理脏页+干净页,将最近且经常查询的数据缓存在其中,而不常查询的数据就淘汰出去。;
  • 如何提高缓存命中率?

    • 使用LRU算法,链表头部的节点是最近使用的,而链表末尾的节点是最久没被使用的。那么,当空间不够了,就淘汰最久没被使用的节点,从而腾出空间。
  • 简单的 LRU 算法的实现思路

    • 当访问的页在 Buffer Pool 里,就直接把该页对应的 LRU 链表节点移动到链表的头部。

    • 当访问的页不在 Buffer Pool 里,除了要把页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的节点。

  • 简单的LRU算法的局限性

    • 预读失效:MySQL 在加载数据页时,会提前把它相邻的数据页一并加载进来,目的是为了减少磁盘 IO。但是可能这些被提前加载进来的数据页,并没有被访问,相当于这个预读是白做了,这个就是预读失效
    • 当某一个 SQL 语句扫描了大量的数据时,在 Buffer Pool 空间比较有限的情况下,可能会将 Buffer Pool 里的所有页都替换出去,导致大量热数据被淘汰了,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 IO,MySQL 性能就会急剧下降,这个过程被称为 Buffer Pool 污染
  • 解决预读失效:要避免预读失效带来影响,最好就是让预读的页停留在 Buffer Pool 里的时间要尽可能的短,让真正被访问的页才移动到 LRU 链表的头部,从而保证真正被读取的热数据留在 Buffer Pool 里的时间尽可能长

    • 将 LRU 划分了 2 个区域:old 区域 和 young 区域划分这两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。
  • 解决Buffer Pool污染:LRU 链表中 young 区域就是热点数据,只要我们提高进入到 young 区域的门槛,就能有效地保证 young 区域里的热点数据不会被替换掉。进入到 young 区域条件增加了一个停留在 old 区域的时间判断

    • 对某个处在 old 区域的缓存页进行第一次访问时,就在它对应的控制块中记录下来这个访问时间:

      • 如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该缓存页就不会被从 old 区域移动到 young 区域的头部
      • 如果后续的访问时间与第一次访问的时间不在某个时间间隔内,那么该缓存页移动到 young 区域的头部
      • 这个间隔时间是由 innodb_old_blocks_time 控制的,默认是 1000 ms。也就说,只有同时满足「被访问」与「在 old 区域停留时间超过 1 秒」两个条件,才会被插入到 young 区域头部,这样就解决了 Buffer Pool 污染的问题 。
    • 另外,MySQL 针对 young 区域其实做了一个优化,为了防止 young 区域节点频繁移动到头部。young 区域前面 1/4 被访问不会移动到链表头部,只有后面的 3/4被访问了才会。

  • 脏页什么时候会被刷入磁盘?

    脏页需要被刷入磁盘,保证缓存和磁盘数据一致,但是若每次修改数据都刷入磁盘,则性能会很差,因此一般都会在一定时机进行批量刷盘。

    • 当 redo log 日志满了的情况下,会主动触发脏页刷新到磁盘;
    • Buffer Pool 空间不足时,需要将一部分数据页淘汰掉,如果淘汰的是脏页,需要先将脏页同步到磁盘;
    • MySQL 认为空闲时,后台线程会定期将适量的脏页刷入到磁盘;
    • MySQL 正常关闭之前,会把所有的脏页刷入到磁盘;

Redis实战

Redis如何实现延迟队列?

  • 什么是延迟队列:把当前要做的事情,往后推迟一段时间做。

    • 在淘宝、京东等购物平台上下单,超过一定时间未付款,订单会自动取消;

    • 打车的时候,在规定时间没有车主接单,平台会取消你的单并提醒你暂时没有车主接单;

    • 点外卖的时候,如果商家在10分钟还没接单,就会自动取消订单

  • 实现方法:

    • 在 Redis 可以使用有序集合(ZSet)的方式来实现延迟消息队列的,ZSet 有一个 Score 属性可以用来存储延迟执行的时间。(生产者)

    • 之后使用 zadd key score1 value1 score2 value2 命令就可以一直往内存中生产消息。再利用 zrangebyscore 查询符合条件的所有待处理的任务, 通过循环执行队列任务即可。(消费者)

Redis的大key如何处理?

  • 什么是大key?

    大 key 并不是指 key 的值很大,而是 key 对应的 value 很大。一般而言,下面这两种情况被称为大 key:

    • String 类型的值大于 10 KB;
    • Hash、List、Set、ZSet 类型的元素的个数超过 5000个;
  • 大key会造成什么问题?

    • 客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
    • 引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
    • 阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
    • 内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。
  • 如何找到大key?

    • redis-cli --bigkeys 查找大key
      • 不足:只能找到每种类型中最大的bigkey;对于集合类型来说,只统计了集合元素个数的多少。
    • 使用 SCAN 命令查找大 key
      • 使用 SCAN 命令对数据库扫描,然后用 TYPE 命令获取返回的每一个 key 的类型。
    • 使用 RdbTools 工具查找大 key
      • 使用 RdbTools 第三方开源工具,可以用来解析 Redis 快照(RDB)文件,找到其中的大 key。
  • 如何删除大key?

    释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。

    所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。

    • 分批次删除:对于删除大 Hash,使用 hscan 命令,每次获取 100 个字段,再用 hdel 命令,每次删除 1 个字段。
    • 异步删除:从 Redis 4.0 版本开始,可以采用异步删除法,用 unlink 命令代替 del 来删除。这样 Redis 会将这个 key 放入到一个异步线程中进行删除,这样不会阻塞主线程。

Redis管道有什么用?

管道技术(Pipeline)是客户端提供的一种批处理技术,用于一次处理多个 Redis 命令,从而提高整个交互的性能。

  • 使用管道技术可以解决多个命令执行时的网络等待,它是把多个命令整合到一起发送给服务器端处理之后统一返回给客户端,这样就免去了每条命令执行后都要等待的情况,从而有效地提高了程序的执行效率。

  • 但使用管道技术也要注意避免发送的命令过大,或管道内的数据太多而导致的网络阻塞。

  • 要注意的是,管道技术本质上是客户端提供的功能,而非 Redis 服务器端的功能

Redis支持事务回滚吗?

  • Redis 中并没有提供回滚机制,虽然 Redis 提供了 DISCARD 命令,但是这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果。
  • Redis主要认为失败都是使用者造成的,所以就没有提供回滚机制。
  • 因为不需要对回滚进行支持,所以Redis的内部可以保持简单且快速。

事务执行过程中,如果命令入队时没报错,而事务提交后,实际执行时报错了,正确的命令依然可以正常执行,所以这可以看出 Redis 并不一定保证原子性

如何用 Redis 实现分布式锁?

  • 分布式锁是用于分布式环境下并发控制的一种机制,**用于控制某个资源在同一时刻只能被一个应用所使用。**而Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁,而且 Redis 的读写性能高,可以应对高并发的锁操作场景。

  • Redis 的 SET 命令有个 NX 参数可以实现「key不存在才插入」,所以可以用它来实现分布式锁:

    • 如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
    • 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。
  • 基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。

    • 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁;
    • 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间;
    • 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端;
  • 解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。

  • 可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。

  • 基于 Redis 实现分布式锁有什么优缺点?

    • 基于 Redis 实现分布式锁的优点
      1. 性能高效(这是选择缓存实现分布式锁最核心的出发点)。
      2. 实现方便。很多研发工程师选择使用 Redis 来实现分布式锁,很大成分上是因为 Redis 提供了 setnx 方法,实现分布式锁很方便。
      3. 避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)。
    • 基于 Redis 实现分布式锁的缺点
      • 超时时间不好设置。如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短会保护不到共享资源。比如在有些场景中,一个线程 A 获取到了锁之后,由于业务代码执行时间可能比较长,导致超过了锁的超时时间,自动失效,注意 A 线程没执行完,后续线程 B 又意外的持有了锁,意味着可以操作共享资源,那么两个线程之间的共享资源就没办法进行保护了。
        • 那么如何合理设置超时时间呢? 我们可以基于续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。实现方式就是:写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可,不过这种方式实现起来相对复杂。
      • Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。
  • Redis 如何解决集群情况下分布式锁的可靠性?

    • 为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)。

    • 它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。

    • Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,并且总耗时没有超过锁的有效时间,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败

    • 这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。

  • Redlock 算法加锁三个过程:

    • 第一步是,客户端获取当前时间(t1)。
    • 第二步是,客户端按顺序依次向 N 个 Redis 节点执行加锁操作:
      • 加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。
      • 如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间,加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。
    • 第三步是,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。
  • 加锁后

    • 加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。
    • 加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。

Redis数据结构

Zset

  • Zset 类型(有序集合类型)相比于 Set 类型多了一个排序属性 score(分值),对于有序集合 ZSet 来说,每个存储元素相当于有两个值组成的,一个是有序集合的元素值,一个是排序值。有序集合保留了集合不能有重复成员的特性(分值可以重复),但不同的是,有序集合中的元素可以排序。

  • Zset 类型(Sorted Set,有序集合) 可以根据元素的权重来排序,我们可以自己来决定每个元素的权重值。比如说,我们可以根据元素插入 Sorted Set 的时间确定权重值,先插入的元素权重小,后插入的元素权重大。

  • ZSet实现消息队列

    • Redis ZADD命令将一个或多个 member 元素及其 score 值加入到有序集 key 当中。

    • Redis ZRANGEBYSCORE 命令返回有序集合 key 中,所有 score 值介于 min 和 max 之间(包括等于 min 或 max )的成员。有序集成员按 score 值递增(从小到大)顺序排列。

      语法:

      ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
      

单调栈

题目关键点
739. 每日温度 - 力扣(LeetCode)栈、存放下标
496. 下一个更大元素 I - 力扣(LeetCode)栈、Map
503. 下一个更大元素 II - 力扣(LeetCode)栈、取模n、下标
  • 739. 每日温度 - 力扣(LeetCode)

    本题可以使用单调递增栈(栈头—>栈尾)。使用递增栈,顺序遍历数组放入栈中,在气温更高的时候,弹出之前气温低的一天,在弹出过程中,可以进行操作,比如返回更高的气温。本题要求返回与更高气温的相距天数,所以可以在栈中保存下标,更方便计算天数。

    class Solution {
        public int[] dailyTemperatures(int[] temperatures) {
            Stack <Integer> stack= new Stack <Integer>();
            int count = 0 ;
            int n = temperatures.length;
            int [] ans = new int [n];
            /** 
                保持栈中元素为单调递增。(从栈头——栈尾)
             */
             stack.push(0);
            for(int j = 1 ; j < n ; j ++){
                int temp = temperatures[j];
                if(temp <=temperatures[stack.peek()] ){
                    stack.push(j);
                }else{
                    while(!stack.empty() && temp > temperatures[stack.peek()] ){
                    int i = stack.pop();
                    ans[i] = j - i;
                    }
                    stack.push(j);
                }
            }
            return ans;
        }
    }
    
  • 496. 下一个更大元素 I - 力扣(LeetCode)

    简化本题,就是要找到nums2数组中的下一个更大元素。自然联想到使用单调递增栈(栈头—>栈尾),基本逻辑与739. 每日温度 - 力扣(LeetCode)一致。但是本题需要多考虑一点,就是如何将nums1和nums2联系起来。我们使用Map这种数据结构,保存K-V,将nums1[i]与i映射。value的目的是在返回值时快速定位到下标,而key的作用是在弹出元素时,比较nums2中是否出现了nums1中的元素。

    class Solution {
        public int[] nextGreaterElement(int[] nums1, int[] nums2) {
            Stack <Integer> stack = new Stack<>();
            int n = nums1.length;
            int m = nums2.length;
            int [] ans = new int [n];
            Map <Integer,Integer> map = new HashMap <>();
            for(int i = 0 ; i < n ; i ++){
                map.put(nums1[i] , i);
            }
            Arrays.fill(ans , -1);
            for(int i = 0 ; i < m ; i ++){
                if(stack.empty()){
                    stack.push(nums2[i]);
                    //比当前栈中数小的数字,维持单调递增性,直接入栈。
                }else if(nums2[i] <= stack.peek()){
                    stack.push(nums2[i]);
                }else{
                    //找到了下一个更大的元素。
                    while(!stack.empty() && nums2[i] > stack.peek()){
                        int num = stack.pop();
                        //如果map中有这个数字,说明存在与nums1中.
                        if(map.containsKey(num)){
                            ans[map.get(num)] = nums2[i];
                        }
                    }
                    stack.push(nums2[i]);
                }
            }
            return ans;
        }
    }
    
  • 503. 下一个更大元素 II - 力扣(LeetCode)

    取模/多复制一份数组,这里只演示取模的效果(循环次数改为原来的二倍)。

    这里的单调栈同样存放下标,方便确定返回值的下标。

    class Solution {
        public int[] nextGreaterElements(int[] nums) {
            int n = nums.length;
            Stack <Integer> stack = new Stack <>();
            int [] ans = new int [n];
            Arrays.fill(ans , -1);
            for(int i = 0 ; i < n * 2 - 1 ; i ++){
                if(!stack.empty() && nums[stack.peek()] >= nums[i % n]){
                    stack.push( (i % n) );
                }else {
                    while(!stack.empty() && nums[stack.peek()] < nums[i % n]){
                        int index = stack.pop();
                        ans[index] = nums[i % n];
                    }
                    
                    stack.push((i % n));
    
                }
            }
            return ans;
        }
    }
    
  • 42. 接雨水 - 力扣(LeetCode)

    备忘录解法:我们创建两个备忘录,l_max表示 i 左边的最高高度 。r_max 表示 i 右边的最高高度。

    最终,所盛水的总高度取决于两边的最低高度减去当前位置的高度。即:Math.min(l_max[i],r_max[i]) - height[i]

    class Solution {
        public int trap(int[] height) {
            int sum = 0;
            int n = height.length;
            int l_max [] = new int [n];
            int r_max [] = new int [n];
            l_max[0] = height[0];
            r_max[n - 1] = height[n - 1];
            for(int i = n - 2 ; i > 0 ; i --){
                r_max[i] = Math.max(r_max[i + 1] , height[i]);
            } 
            for(int i = 1 ; i < n ; i ++){
                l_max[i] = Math.max(l_max[i - 1] , height[i]);
            }
            for(int i = 1 ;i < n - 1  ; i ++){
                sum += Math.min(l_max[i] , r_max[i]) - height[i];
            }
            return sum;
        }
    }
    

    单调栈解法:

    本题可以使用单调递增栈(栈头—>栈尾)。在弹出较小的值时(表示可以装水),计算弹出值下标的装水量。

    class Solution {
        public int trap(int[] height) {
            int n = height.length;
            Stack <Integer> stack = new Stack<>();
            int ans = 0;
            stack.push(0);
            for(int i = 1 ; i < n ; i ++){
                if(height[stack.peek()] > height[i]){
                    stack.push(i);
                }else if(height[stack.peek()] == height[i]){
                    stack.pop();
                    stack.push(i);
                }else{
                    while(!stack.empty() && height[stack.peek()] < height[i]){
                        int r = height[i];
                        int mid = height[stack.pop()];
                        if(!stack.empty()){
                            int l = height[stack.peek()];
                            int h =  Math.min(r , l) - mid;
                            int w = i - stack.peek() - 1;
                            ans += h * w;
                        }
                    }
                    stack.push(i);
                }
            }
            return ans;
        }
    }
    
  • 84. 柱状图中最大的矩形 - 力扣(LeetCode)

    因为要求最大的矩形,所以当放入栈中的元素小于栈顶元素时,应该计算栈顶元素的最大面积,所以使用单调递减的栈(栈头—>栈尾)。栈顶和栈顶的下一个元素以及要入栈的三个元素组成了我们要求最大面积的高度和宽度

    class Solution {
        public int largestRectangleArea(int[] heights) {
    
            //在元素头尾各加一个0,来排除全部升序、降序的情况。
            int [] newHeights = new int[heights.length + 2];
            newHeights[0] = 0;
            newHeights[newHeights.length - 1] = 0;
            for (int index = 0; index < heights.length; index++){
                newHeights[index + 1] = heights[index];
            }
    
            heights = newHeights;
    
            int n = heights.length;
            Stack <Integer> s = new Stack<>();
            s.push(0);
            int result = 0;
    
            for(int i = 1; i < n ; i ++){
                if(heights[i] > heights[s.peek()]){
                    s.push(i);
                }else if(heights[i] == heights[s.peek()]){
                    s.pop();
                    s.push(i);
                }else {
                    while(!s.empty() && heights[i] < heights[s.peek()]){
                        int mid = heights[s.pop()];
                        if(!s.empty()){
                            int w = i - s.peek() - 1;
                            result = Math.max(result , w * mid);
                        }
                    }
                    s.push(i);
                }
            }
            return result;
        }
    }
    
    class Solution {
        public int largestRectangleArea(int[] heights) {
            //在元素头尾各加一个0,来排除全部升序、降序的情况。
            int [] newHeights = new int[heights.length + 2];
            newHeights[0] = 0;
            newHeights[newHeights.length - 1] = 0;
            for (int index = 0; index < heights.length; index++){
                newHeights[index + 1] = heights[index];
            }
    
            heights = newHeights;
    
            int n = heights.length;
            Stack <Integer> s = new Stack<>();
            s.push(0);
            int result = 0;
    
            for(int i = 1; i < n ; i ++){
                if(heights[i] > heights[s.peek()]){
                    s.push(i);
                }else if(heights[i] == heights[s.peek()]){
                    s.pop();
                    s.push(i);
                }else {
                    while(!s.empty() && heights[i] < heights[s.peek()]){
    
                        int mid = heights[s.pop()];
                        System.out.println("mid是:" + mid);
                        //栈顶左边的元素
                        int left = s.peek();
                        //栈顶元素
                        int right = i;
                        if(!s.empty()){
                            int w = right - left - 1;
                            System.out.println("w是:" + w);
                            result = Math.max(result , w * mid);
                            System.out.println("w * mid是:" + (w * mid));
                            System.out.println("result是:" + result +"\n");
                        }
                    }
                    s.push(i);
                }
            }
            return result;
        }
    }
    
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Sivan_Xin

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

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

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

打赏作者

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

抵扣说明:

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

余额充值