十二. MySQL redo undo binLog

一. 先复习一下事物基础

  1. 事物的四大特性: ACID

a. 原子性: 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
b. 一致性: 执行事务前后,数据保持一致;
c. 隔离性: 并发访问数据库时,一个用户的事物不被其他事物所干扰,各并发事务之间数据库是独立的;
d. 持久性: 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响

  1. 事物中可能存在的问题
  1. 更新丢失(Lost to modify): 两个请求同时拿到数据资源进行更新,此时两个请求拿到的是相同的数据,第一个请求更新成功后,由于第二个请求拿到的是更新前的数据,造成第二个请求将第一个更新成功后的结果抹掉了
  2. 脏读(Dirty read): 一个请求访问数据库对数据进行了修改,并未提交时,另外一个请求访问了这个数据,读取到其它请求未提交的数据,假设在第一个请求在后续的操作中发生异常进行了回滚,或者又进行了其它操作最后提交的可能不是当前第二个请求读到的数据
  3. 不可重复读(Unrepeatableread): 指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。
  4. 幻读(Phantom read): 幻读与不可重复读类似。它发生在一个事务读取了数据,接着另一个并发事务插入了数据。在随后的查询中,第一个事务就会发现多了一些原本不存在的记录,就好像发生了幻觉一样
  1. 解决以上问题,进而提出了事物的隔离级别:
  1. READ-UNCOMMITTED 读取未提交: 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读
  2. READ-COMMITTED 读取已提交: 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生
  3. REPEATABLE-READ 可重读: 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
  4. SERIALIZABLE 可串行化: 最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。
    在这里插入图片描述
  1. MySQL使用InnoDB作为存储引擎时,基于MVCC+GAP间隙锁提供了事物隔离级别,默认隔离级别Repeatable Read可重复读(通过GAP间隙锁基本解决了幻读的问题)

二. redoLog

  1. 先了解一个redoLog, 又叫做重做日志,是InnoDB存储引擎层的一个日志文件,记录的是数据页的物理修改,不管事务是否提交都会记录下来,结合lsn 用来保证事物的持久性,当数据库掉电时,InnoDB存储引擎可以通过redolog恢复到掉电前的时刻,以此来保证数据的完整性, 例如执行一条insert语句时,redoLog会记录包含表空间id、插入的记录所在的页号、insert的具体数据等信息
  2. 没有redoLog情况下存在的问题(或者redoLog优点)
  1. 性能浪费: MySQL提供了BufferPool,在更新数据时会先更新BufferPool中的缓存页,然后基于刷盘机制将数据写入磁盘,怎么保证事物提交后一定能落盘,最简单的是刷盘成功才认为事物提交成功,但是刷盘每次是刷一个完整的页面,浪费性能,比如只修改了其中一个字节,但是输盘默认是16k
  2. 随机IO问题: 一个事物或者一条语句可能修改许多页面,并且不是相邻的,那刷盘这个动作在写入数据时执行的就是随机io,以前关注点时修改BufferPool顺序写,现在关注的是刷盘随机写
  1. 根据不同的修改场景,InnoDB设计了几十种(53种)不同类型的redolog格式,举例一种简单的redoLog格式
  1. type: 设置当前redoLog类型
  2. spaceID: 空间id
  3. pageNumber: 页号
  4. data: 当前readLog日志的内容数据
  5. 通过下图中MLOG_8BYTE进行解释: 记录了一个readLog日志,类型为type,该日志表示修改了spaceID表空间下的pageNumber页,偏移量为offset位置,修改了8个字节数据data, 后续就可以通过这个位置拿到数据
    在这里插入图片描述

redoLog 内部组成

参考博客

1. redolog 日志写入过程与Mini-transaction

  1. 在执行sql语句过程中产生的redo日志,会被划分为了若干个不可分割的组:如向聚簇索引对应的B+树的页面插入一条记录时产生的redo日志是一组的,不可分割;向某个二级索引对应的B+树的页面插入一条记录产生的redo日志也是不可分割的。因为在插入一条记录的时候,定位到相应的叶子节点页中,这个页可能有足够的空间供插入(乐观插入),也可能没有足够的空间(悲观插入),对于悲观插入来说,需要新申请数据页,改动统计信息等等,这些操作必须是原子性的,不可断开,所以以组的形式来记录redo日志
  2. 如何将这些redo日志划分到一个组里面呢?在每组的最后一条redo日志后面加上一条特殊类型的redo日志,只有一个type字段代表一组日志的结尾。在系统崩溃重启解析到该类型的日志时,就知道解析到了一组完整的redo日志,否则直接放弃之前解析到的redo日志。有的只生成一条redo日志的操作,直接使用redo日志中1个字节的type字段,用第一个bit代表是否是单一的日志,用后面的7个bit代表redo日志的类型
  3. Mini-transaction: 对底层页面进行一次原子访问的过程称为一个Mini-transaction(MTR)。一个事务可以包含多个语句,一个语句又包含多个MTR,每个MTR可以包含一组redo日志(也可以理解为,因为以组的形式写入redoLog,然后通过Mini-transaction进行实物处理)

2. redoLog存储结构 redo log block

  1. 为了更好的管理,InnoDB设计一个叫redo log block的结构,它和页很像。一个redo log block占用固定的512字节,其中logBlockBody中存储了实际的redoLog数据(占496字节)
    在这里插入图片描述
  2. 我们实际更关注logBlockHeader头信息,头信息中存储了一下数据
  1. LOG_BLOCK_HDR_NO: 每个redo log block都会分配一个唯一的编号
  2. LOG_BLOCK_HDR_DATA_LEN:代表该block已经使用了多少字节,从12开始,因为header部分是被固定占用的,512代表block被写满了。
  3. LOG_BLOCK_FIRST_REC_GROUP: 一个MTR会生成多条redolog,有时甚至会跨越多个block,该属性代表block内第一个MTR第一条redo log的起始地址
  4. LOG_BLOCK_CHECKPOINT_NO: 记录checkpoint序号

3. redo log buffer

  1. 在写redoLog文件时实际也不是直接写入文件的,而是先将数据以block为单位将日志一组一组的写入redoLogBuffer缓存,然后基于刷盘机制将数据刷出到redoLog文件中,
  2. MySQL会在启动时向操作系统申请一块连续的内存空间,然后将其划分成若干个redoLogBlock,就是只redoLogBuffer, 通过innodb_log_buffer_size来控制大小,默认是16MB,也是顺序写入的
  3. 提供了一个全局变量buf_free, buf_free前面的是已经使用的空间,后面的是空闲空间,通过buf_free就知道每次该往哪个block的哪个位置写了
  4. redo log buffer刷盘策略(redoLogBuffer中的数据是什么时候保存到磁盘的)
  1. redo log buffer空间被占用超过50%时。
  2. 事务提交的时候,必须刷盘,否则数据丢失。
  3. 后台线程以每秒一次的频率刷盘。
  4. 正常关闭MySQL时。
  5. 做checkpoint操作时

4. redo log file

  1. redo log buffer空间有限,部分场景下InnoDB会将log buffer里的redo log刷新到磁盘,对应的磁盘文件就是redo log file
  2. redo log file由一组文件组成,默认情况下有ib_logfile0和ib_logfile1两个日志文件,是循环写的,从下标为0的文件开始写,写满了自动切换到下一个,都写满了又会从下标为0的文件开始写。可通过启动项innodb_log_group_home_dir指定日志文件的路径,innodb_log_file_size指定单个日志文件的大小,innodb_log_files_in_group指定日志文件的个数
  3. 如何解决循环写的"追尾问题": 提供了checkpoint操作,先寻找其实点,只要redo log对应的脏页已经刷新到磁盘,那么当前redolog就可以被覆盖
  4. redo log file中使用前4个block来存储log file的头信息和checkpoint相关的信息。也就是说,redo log file是从第2048个字节开始写入redo log block的。
  5. checkpoint操作时,需要把此次checkpoint相关的信息写入到第二和第四个block里,当checkpoint_no是偶数时写入到checkpoint1,奇数写入到checkpoint2

5. LSN日志序列号

  1. 为了记录redolog日志的增长或者日志量提供的
  2. log sequence number:缩写lsn, 表示redolog中写入的日志总量,初始值是8704,执行sql语句,分组记录redolog,当MTR结束时会把redolog复制到redologbuffer里,同时lsn会累加上对应的redo log占用的空间大小。lsn是不断累加的,不可能减少,这意味着lsn越小对应的redo log生成的越早
  3. redo log buffer会在适当的时机刷盘,InnoDB有一个全局变量buf_next_to_write代表log buffer中哪些log已经写入磁盘,它到buf_free的部分代表日志已经写入log buffer,但是还没写入磁盘,两者相等代表log buffer里的所有日志都写入到磁盘了。
    在这里插入图片描述
  4. redolog首先被写到redologbuffer,通过变量buf_free, InnoDB就知道该往logbuffer的哪个位置写了。
  5. 当redo log buffer要刷盘时,那么首先面临的问题就是:要写到哪个redologfile的哪个位置?redologfile文件大小是固定的,且是顺序写的,提供了全局变量flushed_to_disk_lsn代表redolog写入磁盘的日志总量,最开始flushed_to_disk_lsn与lsn相同,flushed_to_disk_lsn是在redolog真正落盘时进行记录,而lsn是在记录日志到redoBuffer就开始记录,根据这两个值就能计算出偏移量获取到日志写入位置
  6. MTR结束时,除了把redolog复制到redologbuffer里,还需要把BufferPool里被修改的脏页加入到flush链表,后台线程会异步的将这些脏页同步到磁盘,只要脏页同步到磁盘,那么它对应的redo log就没用了
    在这里插入图片描述

6. checkpoint

  1. 再次回顾一下,redo log的作用是什么?它是为了防止Buffer Pool里的脏页还没来得及刷盘时,系统崩溃后做数据恢复用的。也就是说,只要Buffer Pool里的脏页刷盘了,那么这些已经刷盘的脏页对应的redo log就一点用都没有了,这些redo log是可以被覆盖的
  2. 如何判断哪些redo log可以被覆盖: Buffer Pool中存在一个flush链表,通过索引页的控制块管理所有脏页, 这个控制块里有一个oldest_modification属性代表脏页最早修改时的LSN值,redo log file中小于该LSN的redo log都是可以被覆盖的
  3. 实际checkpoint分两步:
  1. 将flush链表的链尾控制块的oldest_modification属性值赋值给checkpoint_lsn。
  2. 将checkpoint_lsn、checkpoint_no、checkpoint_offset等信息写入到redo log file的checkpoint1或checkpoint2中

7. 事物提交时的刷盘策略

  1. 前几种刷盘策略
  1. 当redoLogBuffer使用超过50%时触发刷盘
  2. 当事物执行commit操作时,会触发redoLog刷盘
  3. mysql后台提供了定时任务,默认每秒执行一次刷盘
  1. 注意针对事物的commit操作redoLog提供了innodb_flush_log_at_trx_commit选项,有三种情况
    有三种情况
  1. 配置为 0,也就是提交事物不执行刷盘处理,只依赖与定时刷盘去持久化,掉电可能会造成数据丢失
  2. 配置为1,表示事务提交时,会将此时事务所对应的 redolog 所在的redoLogBlock从内存写入到磁盘,并调用fysnc,确保数据写入磁盘
  3. 配置为2,表示只是将日志写入到操作系统的缓存,而不执行fysnc操作,就是此时不进行数据持久化(进程在向磁盘写入数据时,是先将数据写到操作系统的缓存中os cache,再调用fsync方法,才会将数据从 os cache缓存中刷新到磁盘上)

redolog恢复流程

  1. MySQL正常运行是不需要redoLog的,只有异常恢复是使用,将崩溃前BufferPool里被修改的还没来得及刷盘的脏页给恢复回去
  1. 首先确认恢复点, redoLogFile文件可能会很大且很多,通过checkpoint_lsn代表可以被覆盖的redolog日志总量,可被覆盖意味着对应的脏页已经刷新到磁盘了,也就是说小于checkpoint_lsn的redolog是不用恢复的,checkpoint_lsn就是恢复的起点。
  2. 那么要寻找checkpoint_lsn恢复起点, InnoDB会将checkpoint相关信息写入到redologfile的第二或第四个block里,也就是checkpoint1或checkpoint2。只需要将checkpoint1和checkpoint2读取出来,比较一下checkpoint_no的大小就知道最近一次checkpoint操作时对应的checkpoint_lsn了,以及它在redo log file的偏移量checkpoint_offset。
  3. 接下来就是往后顺序读取red log并解析,然后调用系统函数进行页面恢复。由于redologfile是顺序写的,该如何确定恢复的终点呢?也很简单,redo log block的header部分有一个属性LOG_BLOCK_HDR_DATA_LEN,它代表当前block被使用的大小。恢复时,只要读取到第一个LOG_BLOCK_HDR_DATA_LEN值小于512的block,就意味着是终点了。
  1. MySQL针对redo log的恢复还做了一些优化来加速恢复过程:
  1. 根据redo log的SpaceID和PageNumber建立哈希表,相同页面的redolog放到一起,并根据时间排序,对于同一个页面的所有修改,可以一起恢复,避免了多次随机IO。
  2. 索引页的File Header部分有FIL_PAGE_LSN属性,记录了最近一次修改当前页的LSN值,如果redo log的LSN值小于它,那么该redo log就可以不用执行了。
  1. 小总结
  1. 在执行事物时,InnoDB时,首先会修改BufferPool中的缓存页,然后生成redo log记录下对页做了哪些修改,累加lsn值,并将脏页加入到flush链表,同时写入oldest_modification记录下当时的lsn值,最后将redo log复制到redo log buffer中
  2. 事务提交时,redo log buffer刷盘,redo log会被写入到redo log file中。只要redo log刷盘成功,事务就算提交完成了,尽管脏页还没有刷盘。脏页会由后台线程异步刷盘,哪怕系统崩溃也没关系,MySQL重启时会根据redo log恢复数据页。
  3. redo log file是循环写的,会存在追尾的情况。所以InnoDB提供了checkpoint操作,后台异步执行刷脏操作,只要脏页同步到磁盘,它对应的redo log就可以被覆盖了。由于lsn是redo log的日志总量,不断递增不会减小,所以所谓的checkpoint操作其实就是将flush链表里最早的lsn赋值给checkpoint_lsn,并将此次checkpoint相关的数据写入到redo log file里而已

1. binLog 与 redoLog有什么不同

  1. 或者问为什么不使用binLog日志恢复数据
  1. 使用方式不同: binLog主要用于人工恢复数据,例如主从同步时通过binLog拿到所有DDL记录在从库中重放,而redoLog可以理解为是mysql内部自己使用的
  2. 使用机制不同: redoLog是innodb存储引擎层的, binLog是mysqlServer层的,所有存储引擎都可以使用
  3. 记录数据类型不同,redo中保存的可以看为物理日志,记录的是指定数据页位置上对应的数据, 而binlog可以看为逻辑日志,记录的是业务数据变动
    4.在数据有效层面: redo是循环写,binLog是追加写,redo中关注的是数据未落盘时的记录,等数据真实落盘后对应的redo日志可以看为无效,而binLog中保存的是全量日志
  4. 通过redo的这些特性,能确认数据是否有没有真实落盘,进而可以通过redo日志进行崩溃恢复,而binlog中全量数据日志,无法进行恢复
  1. 接着说一下redoLog的恢复过程

三. undoLog

  1. undoLog主要用来保证事物的原子性(也可以勉强说成保证了事物的一致性,因为保证一致性的前提就是原子性)
  2. 假设事务执行过程中出现异常,导致事务无法正常结束,此时事物执行到一半可能已经修改了一些数据,根据事务的原子性要求,整个事务没有完成,必须回滚到执行前的状态,对应不同的sql回滚的实现方式大致如下:
  1. insert: 插入一条记录时,将这条记录的主键记录下来,回滚时根据这个主键删除即可
  2. delete: 删除一条记录时,将这条记录的内容记录下来,回滚时重新插入到表中即可
  3. update:修改一条记录时,将被更新的列的旧值记录下来,回滚时将这些值更新回去即可
  1. MySQL将这种用于回滚的记录称为 undolog,查询操作并不会对记录造成影响,因此undoLog中不会记录select操作
  2. 参考博客

事物类型与trx_id事物id

  1. 在回滚事物时为了区分每次事物提出了事物id的概念, Innodb行格式中,有一个trx_id的隐藏列,用于表示对当前记录进行写操作的事务id,首先要知道什么是事物id,事物id是怎么分配的(注意点trx_id只记录在聚簇索引中)
    在这里插入图片描述
  2. MySQL内部定义了一个全局变量,每当有事务需要id时,则将当前的值分配给该事务,并进行自增1, 当这个变量值为256的倍数时,将改制刷新保存到系统表空间页号为5的页面下的一个名为max trx id的属性中,占用8字节,当系统下次启动时,会将该值加载到内存中并再加上256(防止上次关闭时内存中的值没有及时刷到盘中),继续用
  3. 事物类型: 事务分为两种只读事务和读写事务
  1. 只读事物: 是指通过"start transaction read only" 开启的事务, 只读事务中不可以对普通的表(也就是手动通过create table 创建的表)进行增删改,但是可以对临时表(是指执行事物时内部通过"careate tempory table"创建出的表做这些操作
  2. 读写事物: 是指通过"start transaction read write" 开启一事务,读写事务中可以对表进行增删改查操作
  1. 也就是说在执行事物时内部会根据是否存在增删改操作,通过"start transaction read only"指令开启只读事物,或者通过"start transaction read write"开启读写事物,
  2. 事物id的分配: 在读写事物时,第一次执行增删改语句操作时分配事物id, 在只读事物时第一次对临时表执行增删改操作分配事物id,如果一个事物执行过程中没有增删改语句则不会分配事物id(不存在数据修改,也就不存在回滚),通过max trx id+1获取事物id,分配给当前事物,将分配的事物id保存在在Innodb行格式中的trx_id中(注意以上说的是5.7或以上版本)

undoLog的存储

  1. 记录undo日志时,执行的操作不同undo类型格式也不同,会被记录在类型为fil_page_undo_log的页面中,这些页面从系统表空间或undoTablespace中分配,这个"fil_page_undo_log"页也被称为回滚页
  2. Innodb行格式中存在一个roll_pointer的隐藏列,又被称为roll_ptr,可以看成一个指针,指向该数据在fil_page_undo_log回滚页中对应的undoLog日志的地址,所以又被称为回滚指针
    在这里插入图片描述
  3. 实际roll_pointer由7个字节组成,内部包含4个属性分别是:
  1. is_insert:表示该指针指向的undo日志是否是trx_undo_insert类的undo日志
  2. rseg id:表示该指针指向的undo日志的回滚段编号
  3. page number:该指针指向的undo日志所在页面的页号
  4. offset:该指针指向的undo日志在叶面中的偏移量
  1. undo类型格式的分类:被分为两种,
  1. 第一种是 trx_undo_insert(十进制的1表示),产生的插入日志都属于这种类型,也称为insert undo日志,
  2. 第二种是 trx_undo_update(十进制的2表示),除了insert类型,其他都属于这种类型,统称update undo日志。
  3. 为什么对undo进行分类: 一个页面只能存储一种类型的undo日志,不可以混合存储,之所以做出区分,是因为insert类型的日志可以在事务提交后直接删除,而update类型的由于需要为MVCC服务,因此要区别对待

1. fil_page_undo_log回滚页

  1. undo 日志存储在类型为 fil_page_undo_log 的页面中,又被称为回滚页,该页面中分为4部分
  1. File Header:页面通用结构
  2. Undo Page Header
  3. body:存放真正的undo日志
  4. File Trailer:页面通用结构
  1. 其中Undo Page Header 保存了:
  1. trx_undo_page_type:本页面准备存储什么类型的undo日志,undo日志类型被分为两种

1.1 第一种是 trx_undo_insert(十进制的1表示),产生的插入日志都属于这种类型,也称为insert undo日志,
1.2 第二种是 trx_undo_update(十进制的2表示),除了insert类型,其他都属于这种类型,统称update undo日志。
1.3 一个页面只能存储一种类型的undo日志,不可以混合存储,之所以做出区分,是因为insert类型的日志可以在事务提交后直接删除,而update类型的由于需要为MVCC服务,因此要区别对待。

  1. trx_undo_page_start:第一条undo日志在本页面中的起始偏移量
  2. trx_undo_page_free:最后一条undo日志结束时偏移量
  3. trx_undo_page_node:链表节点结构,上面提到的

2. undo页面链表 trx_undo_page_node

  1. undo页面链表分为单事物的页面链表与多事物的页面链表
  2. 页面链表的作用: 一个事务中可能存在多个SQL语句,并且一个语句可能对若干个记录进行改动,对每条记录改动前(聚簇索引记录),都需要记录1-2条undo日志,所以一个事务可能产生很多undo日志,这些日志在一个页面中可能放不下,需要放到更多的页面中,这些页面就通过 trx_undo_page_node 形成一个链表
  3. 单个事物中可能存在几个undo链表?4个:
  1. 首先undo中每个页面只能存储一种类型类型,undo类型分为insert undo日志与update undo日志,这是两个
  2. Innodb还规定,普通表和临时表的undo日志也要分别记录,所以一个事务中如果同时对临时表,普通表进行增删改操作
  1. 当有多个事务发起增删改的操作时,为了提高undo日志的写入效率,不同的事务要将日志写到不同的undo页面链表中,因此事务越多,undo页面链表越多

3. 段与undo页状态

  1. 一个B+树会被分为一个叶子节点段和一个非叶子节点段,叶子节点就会被尽可能的存放在一起,非叶子节点就会尽可能的存在一起, 每一个段对应一个INODE Entry结构,该结构保存了段的具体信息,例如段的ID、段内的各种链表节点,零散页面的页号有哪些等等
  2. 为了可以定位到一个INODE Entry等,Innodb设计了一个Segment Header结构,如下包含:
  1. Space ID of the INODE Entry:INODE Entry结构所在的表空间ID
  2. Page Number of the INODE Entry:INODE Entry结构所在的页面页号
  3. Byte Offset of the INODE Entry:INODE Entry结构在该页面中的偏移量
  1. 每个Undo页面链表都对应着一个段,称为 Undo Log Segment,链表中的页面都是从该段中申请的,所以Undo页面链表的第一个页面中设计了一个 Undo Log Segment Header 部分,这个部分包含了链表对应的短的 Segment Header信息,以及其他一些关于这个段的信息
  2. Undo Log Segment Header的结构如下:
  1. trx_undo_state: 当前undo页面链表处于什么状态
  2. trx_undo_last_log: 当前undo页面链表中最后一个Undo Log Header的位置
  3. trx_undo_fseg_header:当前undo页面链表对应的段的Segment Header信息
  4. trx_undo_page_list:当前undo页面链表的基节点,用于串联起其他页面的 trx_undo_page_node 属性,形成一个链表
  1. 其中undo页中"trx_undo_state"状态有一下几种:
  1. trx_undo_active: 活跃状态, 即一个活跃的事务正在向这个Undo页面链表中写入Undo日志
  2. trx_undo_cached: 被缓存状态, 该状态的Undo页面链表等待被其他事务重用
  3. trx_undo_to_free: 等待被释放的状态,对于insert undo类型,在其对应的事务提交后,该链表不会被重用,就是这种状态
  4. trx_undo_purge: 等待被purge的状态, 对于update undo类型,如果在其对应的事物提交后,该链表不能被重用,则处于这种状态
  5. trx_undo_prepared:此状态用于存储处于prepare阶段事务产生的日志

4. Undo Log Header

  1. 写undo日志时,同一个事务向一个Undo页面链表中写入的undo日志算是一个组,例如一个事务需要分配3个Undo页面链表,那就是3个组的日志,
  2. 每写一组日志时,都需要先记录一下关于组的一些属性,这些属性被存在第一个页面的Undo Log Header属性中,内部包含:
  1. trx_undo_trx_id:生成本组undo日志的事物id
  2. trx_undo_trx_no:事务提交后生成的序号,此序号用来标记事务的提交顺序(先提交的序号小,后提交的序号大)
  3. trx_undo_del_marks:标记本组日志中是否包含由delete mark操作产生的undo日志
  4. trx_undo_log_start:表示本组日志中第一条undo日志在页面中的偏移量
  5. trx_undo_xid_exists:本组日志是否包含XID信息
  6. trx_undo_dict_trans:标记本组undo日志是不是由DDL语句产生的
  7. trx_undo_table_id:如果 trx_undo_dict_trans 为真,本属性表示DDL操作的表id
  8. trx_undo_next_log:下一组日志在页面中开始的偏移量
  9. trx_undo_prev_log:上一组日志在页面中开始的偏移量, 一般一个Undo页面链表只存储一个事务执行过程中产生的一组undo日志,不过某些情况下,一个事务提交后,后续开启的事务又重复利用这个页面链表,这样一个页面可能存放多组undo日志,trx_undo_next_log 、prev_log 用于标记上一组和下一组日志在页面中的偏移量。
  10. trx_undo_history_node:一个12字节的链表节点结构,表示名为history链表的节点

5. Undo页面的重用与重用策略

  1. 为了提高并发执行多个事务写入undo日志的性能,会为每个事务单独分配相应的undo页面链表,但是当事物执行完毕后undo页面链表只产生了非常少的undo日志,占用很小的一部分空间,页面中就会存在空间浪费了,因此提出了undo页面链表的重用
  2. 什么时候可以重用undo页面链表,要满足下面两种情况
  1. 该链表中只包含一个undo页面: 事务在执行过程中产生了很多的undo日志,可能申请了很多的页面加入链表中,当事务提交后,如果将整个链表的页面都重用,就意味着新事务即使不需要写很多日志,也需要维护一个庞大的链表,并且那些用不到的页面也不可以继续被其他的新事务使用,因此一个事物undo页链表中只包含一个undo页面才可以被重用
  2. undo页面已使用空间小于等于整个页面空间的3/4, 如果空间已经被使用的所剩不多,那么重用意义也就不大了
  1. 重用策略: insert undo链表与update undo链表在重用时,策略不同:
  1. insert undo链表: insert类型的undo日志在事务提交后就没用了,可以直接清除,所以在满足上面提到的两种情况下,新的事务可以直接将该链表中的页面直接覆盖使用,覆盖写入时不仅会写入新的 Undo Log Header,还会适当调整 Undo Page Header、Undo Log Segment Header、Undo Log Header中的一些属性
  2. update undo链表: 在满足上面提到的两种条件,由于update类型的日志要用于MVCC,所以不可以覆盖写入,而是追加写入,此时这个页面中就有了多组undo日志

回滚段

1. 什么是回滚段

  1. 一个事务执行过程中可以分配4种Undo页面链表(为什么时4种参考undo页面链表)不同事务拥有的Undo页面链表不同,整个系统中同一时刻可能存在很多个Undo页面链表
  2. 为了方便管理这些Undo页面链表,会获取Undo页面链表的first undo page的页号,存储在一个 Rollback Segment Header 的页面中,这些页号被称为 undo slot, 每个 Rollback Segment Header 都对应一个回滚段 Rollback Segment,回滚段中只存在一个页面。
  3. Rollback Segment Header结构如下:
  1. trx_rseg_max_size(4字节):表示回滚段中所有的Undo页面链表中的Undo页面数量之和的最大值,表示最多能创建多少个undo页面。
  2. trx_rseg_history_size(4字节):history链表占用的页面数量
  3. trx_rseg_history(16字节):history链表的基节点
  4. trx_rseg_fseg_header(10字节):回滚段对应的Segment Header结构,通过它可以找到本回滚段对应的INODE Entry
  5. trx_rseg_undo_slots(4096字节):各个页面链表的first undo page的页号集合,即undo slot集合一个页号占用4字节,trx_rseg_undo_slots可以存储1024个页号

2. 从回滚段中申请Undo页面链表

  1. 没有事务申请undo页面链表时 Rollback Segment Header页面中的页号值都是 FIL_NULL(十六进制的0xFFFFFFFF),表示页号不指向任何页面,当事务开始申请undo页面链表时,会从回滚段的第一个页号开始查看该值是否是 FIL_NULL:
  1. 是 FIL_NULL: 在表空间中创建一个新的段(Undo Log Segment),然后从段中申请一个页面作为undo页面链表的 first undo page,最后将该页面的地址赋值给 undo slot
  2. 不是 FIL_NULL: 此时undo slot已经指向了一个链表,即已经被其他事务占用,重复判断回滚段中的下一个页,如果1024个slot都不是FIL_NULL,那么新事务无法获得新的Undo页面链表,则会停止这个事务,并且向用户报错:Too many active concurrent transactions
  1. 当事务提交时,undo slot有两种状况:
  1. 如果undo slot指向的链表符合被重用的条件,则状态变为被缓存的状态,页面链表的trx_undo_state属性会被设置为 trx_undo_cached,根据当前页面的类型将当前页面选择添加到对应的insert undo 链表或update undo 链表中,后续在插入日志时会先在cached链表中找,找不到再去回滚段中寻找。
  2. 如果不符合重用的条件,根据不同的链表类型,处理也不同

2.1 insert undo链表: 则该页面链表的 trx_undo_state属性被设置为 trx_undo_to_free,之后该页面链表对应的段会被释放,然后将该undo slot的值设置为FIL_NULL
2.2 update undo链表: 则页面链表的 trx_undo_state属性被设置为 trx_undo_to_purge,并且undo slot的值设置为FIL_NULL,然后将本次事务写入的一组undo日志放到History链表中,但是Undo页面链表对应的段不会释放,需要给MVCC使用

3. 回滚段的分类

  1. 默认情况下有128个回滚段,每个段都有自己的编号,从0到127,共分成两大类:
  1. 第0号、33-127号属于一类,第0号必须分配在系统表空间中,33-127可以在系统表空间,也可以在用户配置的undo表空间中。如果一个事务在执行过程中对普通标的记录进行了改动,就必须从这个类的段中分配相应的undo slot。
  2. 1~32属于一类,这类回滚段必须在临时表空间中,对应数据目录中的ibtmp1文件,如果一个事务在执行过程中对临时表记录做了改动,页面链表将从这里分配相应的undo slot。
  1. 回滚段进行分类的原因是: 在运行过程中对每一个页面做出修改时都需要记录redo日志,方便系统崩溃重启时恢复,undo日志的写入也是写到页面中,因此写undo日志也需要记录redo日志,但是对于临时表的操作,仅仅是在系统运行中的,重启后我们并不需要恢复这些临时表,因此将临时表的undo日志和普通表的undo日志进行分开,可以判别对应的undo段需不需要写redo日志,如果是1~32就不需要写redo,其他的则需要。

4. 为事务分配Undo页面链表的详细过程

  1. 共有5步
  1. 事务首次修改普通表的记录时,先去系统表空间的5号页面中分配到一个回滚段,之后该事务再修改记录时,不会重复分配,多个回滚段的分配方式使用 round-robin 来分配,从第一大类中循环分配回滚段给多个事务。
  2. 分配到回滚段后,查看回滚段的两个cached链表是否有缓存的undo slot,不同的操作看不同的链表,insert类的看insert undo cached,update类型的看 update undo cached。
  3. 如果在缓存中没找到,就从回滚段中分配一个可用的undo slot。
  4. 找到可用的undo slot,如果该slot是从缓存链表中获取的,其Undo Log Segment已经分配,否则就需要重新分配一个Undo Log Segment,然后从该 Segment 中申请一个页面作为Undo页面链表的 first undo page,并把该页填入undo slot中。
  5. 事务开始写入日志到Undo页面链表中。

不同类型的undoLog

1. insert日志类型

  1. 在执行insert操作时,会记录insert类型的undoLog日志格式,该undoLog中包含以下信息
  1. end of record: 下一条undo日志的位置
  2. undo type: 日志类型,即insert
  3. undo no: 日志编号,一个事务中,日志编号从0开始,事务中的每个日志都会递增+1
  4. table id: 日志所属的表id
  5. 主键各类信息: 记录主键的长度和值,是一个列表,因为主键可能是多个键组成的,len代表值的长度, value代表值的内容
  6. start of record:上一条undo日志的位置
    在这里插入图片描述
  1. 在回滚是大概只需要通过undoLog定位到查询数据所在位置,删除这一条插入即可
  2. 注意: 在insert一条数据时,会分别向聚簇索引、二级索引各插入一条记录,但是undo日志只会记录一条针对聚簇索引的日志,后面回滚的时候使用聚簇索引进行回滚删除会将其他索引也一起删除,后面的delete、update类型也是一样

2. delete日志类型

复习一下删除数据时的执行流程
  1. 先复习一下innodb存储时的行格式与数据存储
    在这里插入图片描述
  1. 首先InnoDB的行格式中存在一个delete_mask字段用于标识数据是否被删除
  2. 并且行格式的记录头信息中有一个next_record指针,通过next_record指针可以形成了了两个链表,一个是通过next_record连接所有正常数据的正常链表,一个是通过next_record连接所有被标记为删除状态数据的删除链表,又叫做垃圾链表, Page Header中的 page_free 指向垃圾链表的头节点
  1. 在innoDB与数据页层面简述一下删除数据的流程: 删除数据时并不是真实删除,而是分为"delete mark"与"purge"两个阶段
  1. 第一步: delete mark阶段, 先将innoDB行格式的deleted_flage标记为1,表示该数据被删除,注意此时这条记录所在的位置并不会加入到垃圾链表中
  2. 第二步: purge阶段, 当事物提交后将当前数据所在位置在正常链表中移除,并加入到垃圾链表中,并且是垃圾链表的头节点位置,当前节点的next指向上一个头节点,此时的page_free也会指向当前节点,这个阶段被称为purge阶段
  3. 删除数据时通过delete_mark标记,并不真删除的好处: 减少索关系的维护,假设执行真删除操作,那么被删除数据后面的节点为了维护链表关系可能需要全部向前移动
  1. 删除后的添加:
  1. 当有新数据插入时,首先判断垃圾链表的头节点指向的已删除记录的空间是否可以容纳这条新记录,如果不可以,则直接向页面申请新的空间来存储这条记录,
  2. 如果可以,则重用这条标记为删除记录的存储空间,并让page_free指向垃圾链表中的下一条已删除记录
  3. 还有一种情况,新插入的数据大小小于垃圾链表头节点的存储空间,会产生碎片,此时这些碎片空间会被统计到page_garbage中,等到页面空间不足以再分配一条完整记录的空间时,会查看page_garbage和剩余空间相加之后是否可以容纳这条新记录,如果可以Innodb会尝试重新组织页内的记录,即先开辟一个临时页面,将页面内的记录依次插入一遍,然后再将临时页面的内容复制回来,即可利用碎片空间容纳新记录
deleteUndoLog 日志的插入
  1. 通过上面了解到删除一条数据时InnoDB层面分"delete mark"与"purge"两个阶段去执行的, 在"delete mark"阶段实际就会插入deleteUndoLog日志
  2. deleteUndoLog日志格式和insert不同,多出了一个索引列各列信息的内容,这部分信息主要在事务提交后使用,用于对中间状态的记录进行真正的删除, 整个格式中包含一下内容:
  1. end of record:下一条undo日志的位置
  2. undo type:日志类型,即delete
  3. undo no:日志编号,一个事务中,日志编号从0开始,事务中的每个日志都会递增+1
  4. table id:日志所属的表id
  5. info bits:记录头信息的前4个bit值
  6. trx_id:旧记录的trx_id值
  7. roll_pointer:旧记录的roll_pointer值
  8. 主键各列信息:记录主键的长度和值,是一个列表,因为主键可能是多个键组成的,len代表值的长度,value代表值的内容
  9. len of index_col_info:索引列各列信息的总大小
  10. 索引列各列信息:所有索引列的信息
  11. start of record:上一条undo日志的位置

3. Update日志类型

  1. 在执行Update操作,记录undoLog时分为不更新主键跟更新主键两种情况
  2. 在不更新主键的场景中又分为:
  1. 数据更新前后所占空间大小一致时的,就地更新
  2. 数据更新前后所占空间大小不一致时,触发的先删除旧记录(物理上删除),再插入新记录方式
  1. 如果更新了主键,意味着该记录在聚簇索引中的位置将发生改变,极有可能不在同一个页面中,更新记录的主键分为两步操作
  1. 将旧记录进行delete mark操作,标记为删除状态,并且后续提交的时候要进行purge操作
  2. 根据新的主键,将该记录进行一次insert操作,放到所属的页面中
  1. 针对更新主键这种情况,会产生两条日志,一个类型为 trx_undo_del_mark_rec 另一个则是 trx_undo_insert_rec,即这种情况将删除和插入两种组合起来了

4. 增删改查对二级索引的影响

  1. 对于二级索引,insert操作和delete操作时产生的影响与聚簇索引中执行产生的影响类似,update操作则略有不同,要考虑执行的sql是否更新了二级索引的值, 如果更新了那么需要有下面两步操作:
  1. 将旧的二级索引执行delete mark,标记为删除。
  2. 创建一条新的二级索引记录插入到对应的B+树中
  1. 注意: 二级索引是没有trx_id、roll_pointer这类属性的,不过当增删改一条二级索引记录时,会影响其所在页面的Page Header中的 page_max_trx_id 属性,这个属性代表修改当前页的最大的事务id

undo日志在崩溃恢复时的作用

  1. 在系统崩溃重启后,redo日志会将各个页面中的数据恢复到崩溃前的状态,但是带来一个问题,没有提交的事务写的redo日志可能也刷盘了,造成未提交数据被恢复了出来
  2. 为了保证事务的原子性,这些未提交的事务必须回滚到之前的样子,回滚就需要通过undo日志来做,通过系统表空间5号页面找定位到128个回滚段的位置,
  3. 在每个回滚段中的undo slot中找到不为FIL_NULL的slot,然后找到undo页面链表,如果链表的trx_undo_state属性是trx_undo_active,则意味着有一个活跃的事务向该链表中写入日志
  4. 再通过 Undo Segment Header中找到trx_undo_last_log属性,通过该属性找到链表中最后一个undo log header的位置,从header中找到对应事务的id以及其他信息,该事务就是未提交事务
  5. 通过undo日志中记录的信息将该事务对页面所做的更改全部回滚掉,保证原子性

版本链

  1. 版本链在MVCC中也叫快照
  2. 在InnoDB的行格式中包含trx_id事物id和roll_pointer回滚指针两个字段
  1. trx_id: 事务id,当执行事物中遇到第一个增删改操作(或内部操作临时表遇到)时会分配事物id,将这个事物id赋值给trx_id
  2. roll_pointer: 回滚指针,在执行增删改操作时,操作的数据会生成对应的undo日志,该指针指向的就是undo日志所在的位置,
  1. 以更新一个数据为例,更新成功后会将该数据行中的roll_pointer回滚指针指向之前的undoblog日志,如下图:
  1. 已经存在trx_id为50的undo日志
  2. 然后修改这条数据,将name值"小明"修改为"小明1",分配事物id为60,同时会添加对应当前的undo日志
  3. 观察trx_id事物id为60的roll_pointer回滚指针,此时指向上一次的undo即可
    在这里插入图片描述
  1. 插入与删除的执行流程与Update大致相同,INSERT 会产生一条新纪录,trx_id为当前插入记录的事务 ID , DELETE 某条记录时可看成是一种特殊的 UPDATE ,其实时软删除将delete_flag标记为删除状态,真正执行删除操作会在 commit 时,trx_id则记录下删除该记录的事务 ID
  2. 多个事务并行操作时,不同事务对同一行数据的 UPDATE 会产生多个版本(也就是多个undo日志),通过roll_pointer回滚指针形成版本链,遍历这个链表可以看到这条记录的变迁

三. binLog

  1. binLog 解释
  1. binLog: MySQLServer层面的,以二进制的形式存储执行语句的原始逻辑,又称为归档日志,属于逻辑日志,通常基于该日志文件实现主从同步等功能,或canal 监听binlog日志处理数据库缓存双写不一致问题
  2. 基于binLog进行主从同步的流程简介: master节点将修改数据的操作记录到binLog中,slave从库会启动一个io线程读取master的binLog日志,将日志文件读取到slave从库的中继日志中(relay log),后续会定时检查这个中继日志Realylog是否变化,如果有就会做数据同步
  1. binlog cache: 系统为每个线程分配了一片binlog cache内存,可以通过binlog_cache_size参数控制单个线程内binlog cache大小,如果超过了这个大小就要暂存磁盘
  2. binlog写入机制
  1. 事务执行过程中,先把日志写到binlog cache,事务提交时再把binlog cache写到binlog文件中
  2. 事务提交的时候,执行器把binlog cache里完整的事务写入binlog中,并清空binlog cache
  3. 每个线程都有自己的binlog cache,共用一份binlog文件
  4. write: 是把日志写入到文件系统的page cache内存中,没有持久化到磁盘,速度比较快
  5. fsync: 是将数据持久化到磁盘,占用磁盘的IOPS
  1. 何时write、fsync是由参数sync_binlog控制的
  1. sync_binlog = 0时,每次提交事务都只write,不fsync;
  2. sync_binlog = 1时,每次提交事务都会执行fsync;
  3. sync_binlog = N(N>1)时,表示每次提交事务都write,但累积N个事务后才fsync
  1. sync_binlog控制binlog真正刷盘的频率,对于一个IO非常大的情景,这个数字调大可以提高性能,但是如果容错率非常低的情况下,必须设为1(sync_binlog设置为N对应的风险是:如果主机发生异常重启,会丢失最近N个事务的binlog日志)
  2. 时序上先写redo log到prepare预提交状态 再写binlog并将redolog置为commit状态

redoLog与binLog写入流程与redo为什么引入prepare预提交状态?

  1. 什么是redo的prepare两阶段提交: 在写redo与binlog时实际底层分两步走
  1. 第一步prepare阶段: 写redolog,此时这个日志时预提交状态
  2. 第二步commit阶段: 写binlog并且将redo log的状态改成commit状态
  1. 根据一条更新SQL分析redo与binlog详细流程: update T set name = ‘winner’ where ID=2
  1. 首先查询数据, 如果ID=2这一行记录所在的数据页在内存当中,通过内存直接返回数据给执行器,否则从磁盘读取数据到内存当中,然后再返回给优化器
  2. 优化器拿到行数据之后,先对内存中的数据页进行修改,同时将这个更新操作记录到RedoLog,此时RedoLog处于 perpare 状态,然后告知执行器已经完成了,等待事物提交
  3. 接下来执行器会生成这个更新操作的binlog
  4. 执行器调用存储引擎的提交事务的接口, 将刚刚写入的RedoLog改成commit状态
  1. 为什么redo要有一个prepare预提交状态,下方时分析:
  2. 第一种情况:先写redolog直接提交,然后写binlog;

如果在写完redolog之后宕机了, 此时binlog日志并没有写入,后续机器重启可以通过redo log恢复数据,但是binlog是没有记录这条数据数据的,最终可能造成基于binlog备份时数据丢失,或基于binlog主从同步数据丢失

  1. 第二种情况:先写binlog 然后写redo log

假设写完binlog机器宕机,由于没有redolog这条记录,在宕机重启后本机是无法恢复这条数据,但是基于binlog进行数据备份或主从同步却有这条数据,最终导致数据不一致

  1. 而redolog 和binlog俩阶段提交,可以避免以上问题,保证了数据的一致性
  2. 引出极端情况问题: 假设redo log 处于预提交状态,binglog也已经写完了,这个时候发生了异常重启会怎么样呢?mysql的处理过程如下:
  1. 判断redolog是否完整,如果判断是完整的,就立即提交。
  2. 如果redolog只是预提交但不是commit状态,这个时候需要判断binlog是否完整,如果完整就提交redo log,不完整就回滚事物,进而解决数据一致性的问题

undolog的写入时机

  1. DML操作修改聚簇索引前记录undo日志, 二级索引记录的修改,不记录undo日志,
  2. 需要注意的是,undo页面的修改,同样需要记录redo日志

版本链(undo插入的详细流程)

  1. 示例: 向persion表中插入了一条新纪录:name = jerry,age = 24
    在这里插入图片描述
  2. 此时"事务1"执行,将上面数据中的name字段修改为tom,内部详细流程:
  1. 执行sql语句时,mysql内部遇到的第一个update,delete,或inster语句,会申请事物id
  2. 在执行update,delete等操作时,基于InnoDB数据库会先对该数据加锁(命中主键索引添加行锁,非主键索引添加行锁间隙锁,没有命中索引添加表锁)
  3. 上锁完毕后,将该行数据拷贝到 undoLog 中,作为旧记录,即在 undolog 中有当前行的拷贝副本。
  4. 拷贝完毕后,修改该行的 name 为 tom,并且将分配到的事物id复制给该行的trx_id, 回滚指针指向拷贝到 undolog 的副本记录, 如果出现异常,基于该 undolog 的副本记录进行回滚
  5. 事务提交后,释放锁。
    在这里插入图片描述
  1. 接着"事物2"执行,将该行数据中的age字段修改为30
  1. 与上面相同,加锁,申请事物id
  2. 上锁完毕之后,把该行数据拷贝到undolog中,作为旧记录,
  3. 注意此时发现操作的这行记录已经有undolog 的记录了,那么最新的旧数据作为链表的表头,插在这行记录的 undolog 日志的最前面
  4. 修改该行age为30岁,并且将当前分配到的事物id复制给该行记录的trx_id,回滚指针指向刚刚拷贝到 undolog 的副本记录
  5. 事务提交,释放锁
    在这里插入图片描述
  1. 通过上面几个例子可以看出,不同事物或者相同事务对同一个记录的修改,可能产生多条 undo log,形成一条版本记录链,链首就是最新的旧记录,尾部就是最旧的记录

当然,为了节省磁盘空间,InnoDB有专门的 purge清除线程来清理,无效的undo日志,像第一条 insertUndoLog,在事务提交之后可能就被删除丢失了,这里为了演示所以还放在这里,假设没被清除)

undolog 是否是redolog的逆过程

  1. 不是, undolog是逻辑日志,事务回滚时,通过undo将数据库逻辑地恢复到原来的样子,redolog是物理日志,记录的是数据页的物理变化

redo,undo写入流程示例

  1. 假设执行一个事物,有A、B两个数据,值分别为1、2,开启事务分别对其进行修改A → 3,B → 4,在提交,过程如下
  1. 事务开始
  2. 记录A=1到undo log
  3. 修改A=3
  4. 记录A=3到 redo log
  5. 记录B=2到 undo log
  6. 修改B=4
  7. 记录B=4到redo log
  8. 注意事务执行过程中,写入的redoLog是预提交状态,并且在记录binlog时先把日志写到binlog cache,事务提交时再把binlog cache写到binlog文件中,并将redoLog设置为提交状态

undo的写入流程

  1. 了解undo写入流程需要先了解一下undo中的一下几个成员
  1. innoDB行格式中的trx_id事物id与roll_pointer回滚指针
  2. undo页面链表 trx_undo_page_node
  3. 回滚段
  4. 回滚段中如何申请Undo页面链表

通过redolog解释为什么要优化批量插入

Mysql基于redoLog日志文件记录事务操作的变化,记录的是数据修改之后的值,防止数据库掉电丢失数据问题,redoLog是由redoLogBuffer缓存与redoLog日志文件两部分构成,当触发刷盘时,会将数据由redoLogBuffer刷出保存到redoLog文件中,其中事物提交会触发刷盘动作,假设100条数据每次插入1条提交一次,中间就需要执行100次的刷盘动作,而批量插入优化就是减少刷盘存储的动作,将多次提交修改为一次提交,这样100条数据插入,只执行一次刷盘存储

undo log 分类简述与purge清除线程

  1. insert undo log:插入一条记录时,日志中主要记录插入数据的主键,回滚时只需要把主键对应的记录删除即可
  2. update undo log:修改一条记录时,至少要把修改这条记录前的旧值都记录下来,回滚时把这条记录的值更新为旧值
  3. delete undo log:删除一条记录时,至少要把这条记录中的全部内容都记录下来,回滚时再重新将这些内容插入到表中
  1. 并且删除操作底层实际执行的是假删,将innoDB行格式中的 DELETE_FLAG标记为删除状态,
  2. 为了节省磁盘空间,InnoDB有专门的 purge清除线程来清理 DELETED_BIT 为 true 的记录。
  3. 为了不影响MVCC的正常工作,purge线程自己也维护了一个readView, 这个 readView 相当于当前系统中最老活跃的事务的 readView
  4. 如果某个记录的 DELETED_BIT 为 true,并且 DB_TRX_ID最后一个操作的事务ID相对于 purge线程的 read view 可见,那么这条记录一定是可以被安全清除的
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值