cherish

返朴归真


  • Home

  • Archives

  • Tags

  • Categories

【InnoDB详解三】锁和事务

Posted on 2020-09-21 | In 关系型数据库 , MySQL |
Words count in article: 9.6k | Reading time ≈ 34

1. InnoDB锁机制

锁是数据库系统区别于文件系统的一个关键特性。锁机制用于管理对共享资源的并发访问。InnoDB存储引擎会在行级别上对表数据上锁,这固然不错。不过InnoDB存储引擎也会在数据库内部其他多个地方使用锁,从而允许对多种不同资源提供并发访问。例如,操作缓冲池中的LRU列表,删除、添加、移动LRU列表中的元素,为了保证一致性,必须有锁的介入。数据库系统使用锁是为了支持对共享资源进行并发访问,提供数据的完整性和一致性。

InnoDB存储引擎锁的实现和Oracle数据库非常类似,提供一致性的非锁定读、行级锁支持。行级锁没有相关额外的开销,并可以同时得到并发性和一致性。

1.1 InnoDB中锁的类型

1.1.1 共享锁和排他锁

InoDB存储引擎实现了如下两种标准的锁∶

  1. 共享锁(S Lock),S是Share的缩写,也叫作读锁,允许事务读取共享资源的数据。
  2. 排他锁(X Lock),X是Exclusive的缩写,也叫作写锁,允许事务删除或更新资源的数据。

InnoDB存储引擎支持多粒度(granular)锁定,S Lock和X Lock锁定的对象可以是行,也可以是页,也可以是表。

如果一个事务T1已经获得了行r的共享锁,那么另外的事务T2可以立即获得行r 的共享锁,因为读取并没有改变行r的数据,我们称这种情况为锁兼容(Lock Compatible)。

但若有其他的事务T3想获得行r的排他锁,则其必须等待事务T1、T2释放行r上的共享锁才行——这种情况称为锁不兼容。

下图显示了共享锁和排他锁的兼容性:

总结为一句话,只有二者都是共享锁的时候才会兼容。

1.1.2 意向锁

我们之前说过,S/X锁针对的对象可以是行,也可以是表。这种多粒度(granular)锁定是InnoDB锁机制的特点,但多粒度锁定会不可避免的带来一种问题:

  • 假设事务A锁住了表中的一行,让这一行只能读,不能写。
  • 之后,事务B申请整个表的写锁。
  • 如果事务B申请成功,那么理论上它就能修改表中的任意一行,这与A持有的行锁是冲突的。

数据库需要避免这种冲突,就是说要让B的申请被阻塞,直到A释放了行锁。那么数据库要怎么判断这个冲突呢?

  1. step1:判断表是否已被其他事务用表锁锁表
  2. step2:判断表中的每一行是否已被行锁锁住。

注意step2,这样的判断方法需要遍历整个表,效率实在不高,于是就有了意向锁。

在意向锁存在的情况下,事务A必须先申请表的意向共享锁,成功后才能申请表中行的行锁。于是上面的判断可以改成:

  1. step1:判断表是否已被其他事务用表锁锁表
  2. step2:发现表上有意向锁:
    1. 如果是意向共享锁,说明表中有些行被共享行锁锁住了,因此,事务B申请表的写锁会被阻塞。
    2. 如果是意向排他锁,说明表中有些行被排他行锁锁住了,因此,事务B申请表的写锁会被阻塞。

是的没错,InnoDB的意向锁也支持如下两种,不过意向锁不是多粒度的,它只支持表级锁定:

  1. 意向共享锁(IS Lock),表示事务已经获得一张表中某几行的共享锁。
  2. 意向排他锁(IX Lock),表示事务已经获得一张表中某几行的排他锁。

IS和IX的I是intention的缩写,意向的意思可以理解为:一个事务在申请行级锁前,先宣称对行所在表的读/写的意向,宣称之后,在不兼容的情况,其他锁就会冲突了。

因为意向锁是表级锁,所以也不存在和行级锁/页级锁的兼容性问题,但意向锁之间,以及意向锁和表级共享/排他锁之间是存在不兼容的情况的,具体兼容性如下表(注意下标的S和X是表级锁):

一句话:意向锁内部都兼容,除此之外,意向共享锁只和表级共享锁兼容。

1.1.3 自增锁

自增长在数据库中是非常常见的一种属性,也是很多DBA或开发人员首选的主键方式。在InnoDB存储引擎的内存结构中,对每个含有自增长值的表都有一个自增长计数器(auto-increment counter)。当对含有自增长的计数器的表进行插入操作时,这个计数器会被初始化,执行如下的语句来得到计数器的值∶

SELECT MAX(auto_inc_col) FROM t FOR UPDATE;

插入操作会依据这个自增长的计数器值加1赋予自增长列。这个实现方式称做AUTO-INC Locking。这种锁其实是采用一种特殊的表锁机制,为了提高插人的性能,锁不是在一个事务完成后才释放,而是在完成对自增长值插人的SQL语句后立即释放。

1.2 行锁的加锁方式

前面我们说过,InnoDB存储引擎支持多粒度(granular)锁定,S Lock和X Lock锁定的对象可以是行,也可以是页,也可以是表。

不过当锁定的对象是行记录的时候,InnoDB有三种加锁方式,或者说,有三种锁的算法:

  1. Record Lock:锁单条行记录;
  2. Gap Lock:间隙锁,锁定一个范围,但不包含记录本身
  3. Next-Key Lock:Gap Lock+RecordLock,记录锁和间隙锁的组合,锁定一个范围,并且锁定记录本身

这里需要重点注意间隙锁,它可以解决幻读,因为MySQL默认的事务隔离级别是可重复读,其底层就是使用Next-Key Lock,也就是说Next-Key Lock是目前InnoDB对行锁默认的加锁方式。下文我们再对各个事务隔离级别的底层实现做描述。

InnoDB的行锁是通过给索引项加锁实现的(这个我们后面会说到),这就意味着只有通过索引条件检索数据时,InnoDB才使用行锁,否则使用表锁。也就是说,如果批量update,如果条件的字段没有索引,将会锁表,如果有索引,则只会出现行锁。

1.3 并发控制协议

1.3.1 MVCC和一致性非锁定读

MVCC全称Multi-Version Concurrent Control,即多版本并发控制,是一种乐观锁的实现。它最大的特点是:读可不加锁,读写不冲突。并发性能很高。

MVCC中默认的读是非锁定的一致性读,也称快照读。读取的是记录的可见版本,当读取的的记录正在被别的事务并发修改时,会读取记录的历史版本。读取过程中不对记录加锁。

如上图,如果读取的行正在执行DELETE或UPDATE操作,这时读取操作不会因此去等待行上锁的释放。相反地,InnoDB存储引擎会去读取行的一个快照数据。之所以称其为非锁定读,因为不需要等待访问的行上X锁的释放。

那么快照数据如何产生呢?

在InnoDB的行记录的列数据中有两个隐藏的列:当前行创建时的版本号和删除时的版本号(可能为空,其实还有一列称为回滚指针,用于事务回滚,这里暂不讨论)。这里的版本号并不是实际的时间值,而是系统版本号。每开始新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询每行记录的版本号进行比较。

每个事务又有自己的版本号,这样事务内执行CRUD操作时,就通过版本号的比较来达到数据版本控制的目的。

MVCC的实现依赖于undo日志(undo日志具体可见本站博客《【InnoDB详解四】redo log和undo log》),该日志通过回滚指针把一个数据行(Record)的所有快照数据(也都是数据行)连接起来:

后文会讲到,MVCC主要用于可重复读和读已提交这两种事务隔离级别的实现中。

那么MVCC下InnoDB的增删查改是怎么运作的呢?

1.3.1.1 MVCC下的insert

插入时,记录的版本号即当前事务的版本号。我们执行一条数据语句:

insert into testmvcc values(1,"test");

假设事务id为1,那么插入后的数据行如下:

1.3.1.2 MVCC下的update

在更新操作的时候,采用的是先标记旧的那行记录为已删除,并且删除版本号是事务版本号,然后插入一行新的记录的方式。

比如,针对上面那行记录,把name字段更新:

update table set name= 'new_value' where id=1;

假设事务id为2,那么更新后的结果如下:

1.3.1.3 MVCC下的delete

在删除操作的时候,就把事务版本号作为删除版本号。比如

delete from table where id=1;

假设事务id为3,那么删除后的结果如下:

1.3.1.4 MVCC下的select

综合上文,我们可以知道,在查询时要符合以下两个条件的记录才能被事务查询出来:

  1. 删除版本号未指定或者大于当前事务版本号,即要确保查询事务开启时,要读取的行未被删除。(比如上述事务id为2的事务查询时,依然能读取到被id=3的事务所删除的数据行)

  2. 创建版本号小于或者等于当前事务版本号 ,即要确保查询事务开启时,要读取的行记录正在(等于的情况)或者已经(小于的情况)被创建。(比如上述事务id为2的事务查询时,只能读取到被id=1或者id=2的事务所创建的行)

1.3.2 LBCC和一致性锁定读

在前文中我们说到,默认配置下,即事务的隔离级别为可重复读模式下,InnoDB存储引擎的SELECT操作使用一致性非锁定读。但是在某些情况下,用户需要显式地对数据库读取操作进行加锁以保证数据逻辑的一致性。而这要求数据库支持加锁语句,即使是对于SELECT的只读操作。

LBCC全称Lock-Based Concurrent Control,即基于锁的并发控制,是一种悲观锁的实现。LBCC中的读是一致性锁定读,也称当前读:读取的是记录的最新版本,并且会对记录加锁。

InnoDB存储引擎对于SELECT语句支持两种一致性的锁定读(locking read)操作∶

  1. SELECT…FOR UPDATE
    • SELECT…FOR UPDATE 对读取的行记录加一个X锁,其他事务不能对已锁定的行加上任何锁。
  2. SELECT…LOCK IN SHARE MODE
    • SELECT.·…LOCKIN SHARE MODE对读取的行记录加一个S锁,其他事务可以向被锁定的行加S锁,但是如果加X锁,则会被阻塞。

对于一致性非锁定读,即使读取的行已被执行了SELECT…FOR UPDATE,也是可以进行读取的,这和之前讨论的情况一样。

此外,SELECT.…FOR UPDATE,SELECT.…·LOCK IN SHARE MODE必须在一个事务中,当事务提交了,锁也就释放了。因此在使用上述两句SELECT锁定语句时,务必加上BEGIN,STARTTRANSACTION或者SET AUTOCOMMIT=0。

LBCC被用在seraliable隔离级别中,seraliable级别会对每个select语句后面自动加上lock in share mode。

1.4 锁的数据结构

锁升级(Lock Escalation)是指将当前锁的粒度降低。举例来说,如果一个页中,有大量的行都被加了锁,那么维护这么多的锁对象,需要占用大量内存,那为了节约内存提高效率,数据库会将锁升级,从行锁升级为页锁。这样只需要维护一个页锁对象就可以替代可能是几千个行锁对象了。同理,页锁升级为表锁也是同样的道理。

如果在数据库的设计中认为锁是一种稀有资源,而且想避免锁的开销,那数据库中会频繁出现锁升级现象,虽然这种做法会降低并发性能。

这种升级保护了系统资源,防止系统使用太多的内存来维护锁,在一定程度上提高了效率。

然而,InnoDB不需要锁升级机制,因为InnoDB对锁对象的维护十分特殊,InnoDB并非将行锁维护在每一个行记录中,而是使用了位图+哈希表,前者保证了占用少量内存,后者保证了查询效率极高。

1.4.1 锁对象和位图

InnoDB定义了页锁结构和表锁结构两种数据结构,来分别描述行级锁和表级锁

1.4.1.1 页锁结构

1
2
3
4
5
6
7
//页锁结构
typedef struct lock_rec_struct lock_rec_t
struct lock_rec_struct{
ulint space; /*space id*/
ulint page_no; /*page number*/
unint n_bits; /*number of bits in the lock bitmap*/
}
  • space+page_no可以唯一定位一个页,所以InnoDB中有多少个数据页,就最多有多少个页锁对象。
  • n_bits是一个位图。如果要查看锁对象某行记录是否上锁,只需要根据space/page_no找到对应的页,然后根据位图中对应位置是否是1来决定此行记录是否上锁。

假设页中有250条行记录,那么位图n_bit的占用空间为=250bit+64bit(额外预留的)=314bit,那么实际位图需要40个字节(320bit)用于位图的管理,若页中heap_no为2,3,4的记录都已经上锁,则对应的数据结构lock_rec_t 在内存中的关系如下图:

InnoDB使用一个哈希表映射页和它对应的锁结构的信息:

1
2
3
struct lock_sys_struct{
hash_table_t* rec_hash;
}

每次新建一个页锁对象,都要插入到lock_sys_struct->rec_hash中。lock_sys_struct中的key通过页的space和page_no计算得到,而value则是页锁结构lock_rec_struct。

我们可以看到,行级锁并非维护在数据页的行记录里面,而是另外寻了一处空间来存放,这种锁的实现机制可以最大程度地重用锁对象,节省系统资源,不存在锁升级的问题。

可想而知,如果每个行锁都生成一个锁对象,将会导致严重的性能损耗,比如接近于全表扫描的查询就会生成大量的锁对象,内存开销将会很大。位图的方式很好地避免了这个问题。

1.4.1.2 表锁结构

表级锁的数据结构(用于表的意向锁和自增锁)

1
2
3
4
5
typedef struct lock_table_struct lock_table_t;
struct lock_table_struct {
dict_table_t* table; /*database table in dictionary cache*/
UT_LIST_NODE_T(lock_t) locks; /*list of locks on the same table*/
}
1
2
3
4
5
#define UT_LIST_NODE_T(TYPE)
struct {
TYPE * prev; /* pointer to the previous node,NULL if start of list */
TYPE * next; /* pointer to next node, NULL if end of list */
}

一目了然,结构内的table变量是一个表结构(dict_table_t)的指针,它表示被锁住的是哪个表,一个表锁结构对应一张表。

然后locks变量是lock_struct结构(typedef struct lock_struct lock_t;)组成的链表节点,UT_LIST_NODE_T是一个典型的链表节点结构,有前驱和后驱。

lock_struct是锁信息的整合结构,下面我们会介绍,locks所在的这个链表,连接了所有加在当前table上的锁对象,这样就能通过locks变量,遍历到当前表级锁对象所锁定的表上一共有哪些类型的锁。

1.4.1.3 整合的锁结构

上面我们知道InnoDB定义了页锁结构和表锁结构,但通过这二者,我们只能知道哪些行记录或者表被加了锁,却不知道这锁是由哪个事务加的,加的是什么类型的锁,于是,InnoDB定义了一个新的锁结构,它就是我们前面看到的struct lock_struct lock_t:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct lock_struct  lock_t;
struct lock_struct{
trx_t* trx; /*这个锁属于哪个事务*/
UT_LIST_NODE_T(lock_t) trx_locks; /*该事务拥有的锁通过一个链表连接起来*/
ulint type_mode; /* lock type, mode, gap flag, and wait flag, ORed */
hash_node_t hash; /* hash chain node for a record lock */
dict_index_t* index; /* 在该锁类型是行锁是有效,指向一个索引,因为行锁本质是索引记录锁。 */
union {
lock_table_t tab_lock; /* table lock */
lock_rec_t rec_lock; /* record lock */
} un_member; /*如果是表锁则un_member为lock_table_t,如果是记录锁则un_member为lock_rec_t,通过type_mode来判断类型*/

};

lock_struct是<事务,页/表>维度的结构,不同事务的每个页(或每个表)都要定义一个lock_struct结构。但一个事务可能在不同页/表上有锁,trx_locks变量将一个事务所有的锁信息进行链接,这样就可以快速查询一个事务所有锁信息。

un_member变量是一个结构共同体,它可以是表锁对象lock_table_t,也可以是页锁对象lock_rec_t,这根据type_mode来区分,type_mode控制了该锁结构(lock_struct)是属于什么类型的锁,已经目前处于的状态。

type_mode变量是一个无符号的4字节32位整型,从低位排列,

  1. 第1个字节为lock_mode,定义如下;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /* Basic lock modes */
    enum lock_mode {
    LOCK_IS = 0, /* intention shared */
    LOCK_IX, /* intention exclusive */
    LOCK_S, /* shared */
    LOCK_X, /* exclusive */
    LOCK_AUTO_INC, /* locks the auto-inc counter of a table
    in an exclusive mode */
    LOCK_NONE, /* this is used elsewhere to note consistent read */
    LOCK_NUM = LOCK_NONE, /* number of lock modes */
    LOCK_NONE_UNSET = 255
    };
  2. 第2个字节为lock_type,目前只用前两位,大小为16和32,分别表示LOCK_TABLE 和LOCK_REC,这一个字节控制了lock_struct是表级锁还是行级锁。
    1
    2
    #define LOCK_TABLE      16
    #define LOCK_REC 32
  3. 剩下的高位bit表示行锁的类型record_lock_type
    1
    2
    3
    4
    5
    6
    #define LOCK_WAIT   256        /* 表示正在等待锁 */
    #define LOCK_ORDINARY 0 /* 表示 Next-Key Lock ,锁住记录本身和记录之前的 Gap*/
    #define LOCK_GAP 512 /* 表示锁住记录之前 Gap(不锁记录本身) */
    #define LOCK_REC_NOT_GAP 1024 /* 表示锁住记录本身,不锁记录前面的 gap */
    #define LOCK_INSERT_INTENTION 2048 /* 插入意向锁 */
    #define LOCK_CONV_BY_OTHER 4096 /* 表示锁是由其它事务创建的(比如隐式锁转换) */

1.4.2 行级锁的查询

有些时候,我们需要查询某个具体行记录的锁信息。比如行记录id=3是否有锁?

前文说了,InnoDB使用一个哈希表映射行数据和锁信息:

1
2
3
struct lock_sys_struct{
hash_table_t* rec_hash;
}

每次新建一个锁对象,都要插入到lock_sys_struct->rec_hash中。lock_sys_struct中的key通过页的space和page_no计算得到,而value则是页锁结构lock_rec_struct。

因此若需查询某一行记录是否有锁,首先则先要根据索引,定位到该行记录具体在哪一页。然后根据页的space和page_no进行哈希查询,得到lock_rec_struct,再根据lock_rec_struct里面的位图n_bits,最终得到该行记录是否有锁。

正是因为行锁的查询需要根据页的space和page_no,而页的定位又基于索引,所以才说InnoDB的行锁是通过给索引项加锁实现的,这就意味着只有通过索引条件检索数据时,InnoDB才使用行锁,否则使用表锁。也就是说,如果批量update,如果条件的字段没有索引,将会锁表,如果有索引,则只会出现行锁。

可以看出,根据页来查找行锁的查询并不是高效设计,但这种方式的资源开销非常小。某一事务对一个页任意行加锁开销都是一样的(不管锁住多少行)。因此也不需要支持锁升级的功能。

1.5 死锁

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

解决死锁常用的两个方案:

  1. 超时机制

    • 即两个事务互相等待时,当一个等待时间超过设置的某一阀值时,其中一个事务回滚,另一个事务继续执行。MySQL4.0版本开始,提供innodb_lock_wait_time用于设置等待超时时间。
  2. 等待图(wait-for graph)

    • 较之超时的解决方案,这是一种更为主动的死锁检测方式。InnoDB通过锁的信息链表和事务等待链表,判断是否存在等待回路。如有,则存在死锁。每次加锁操作需要等待时都判断是否产生死锁,若有则回滚事务。

1.6 锁的监控方式

show engine innodb status命令可以获取最近一次的死锁日志。
MySQL8之前,可以通过INFORMATION_SCHEMA下INNODB_TRX,INNODB_LOCKS,INNODB_LOCK_WAITS查看事务和锁信息。
INNODB_TRX在MySQL8依然保留。

2. InnoDB事务机制

在关系型数据库中,事务的重要性不言而喻,只要对数据库稍有了解的人都知道事务具有ACID四个基本特性。回顾一下事务的ACID特性,分别是原子性、一致性、隔离性、持久性, 一致性是事务的最终追求的目标,隔离性、原子性、持久性是达成一致性目标的手段。

  • A : atomicity 原子性。原子性是我们对事务最直观的理解:事务就是一系列的操作,要么全部都执行,要么全部都不执行。

  • C : consistency 一致性。数据库事务不能破坏关系数据的完整性以及业务逻辑上的一致性。它分为数据库外部的一致性和内部的一致性:

    • 数据库外部的一致性,例如对银行转帐事务,不管事务成功还是失败,应该保证事务结束后ACCOUNTS表中Tom和Jack的存款总和不变。这个由外部应用的编码来保证,即某个应用在执行转帐的数据库操作时,必须在同一个事务内部调用对帐户A和帐户B的操作。
    • 数据库内部的一致性,这是数据库层面去保证的,体现在我们利用事务将账户A和账户B的操作绑定时,要么一起成功,要么一起失败(原子性)。同时,如果在并发场景下,还要保证其他事务的操作不会影响当前事务(隔离性)。
  • I : isolation 隔离性。在并发环境中,当不同的事务同时操纵相同的数据时,每个事务都有各自的完整数据空间。

  • D : durability 持久性。只要事务成功结束,它对数据库所做的更新就必须永久保存下来。即使发生系统崩溃,重新启动数据库系统后,数据库还能恢复到事务成功结束时的状态。

事务的 ACID 特性概念简单,但需要注意的是这几个特性不是一种平级关系:

  1. 只有满足一致性,事务的执行结果才是正确的。
  2. 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性。 在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
  3. 事务满足持久化是为了能应对数据库崩溃的情况。

所以他们的关系如下图:

接下来就让我们来探究一下InnoDB是如何实现事务的——如何保证事务的ACID特性。

2.1 InnoDB事务原子性的保证

原子性,核心要点是,要么全部都执行成功,要么全部都不执行:

  1. 要么全部都执行成功:修改后的数据的新状态也是原子的,如果执行成功,那新状态(可能涉及到多个行的变更)应该就像一个操作那样同时全部生效。而不是这一秒3个行被更新完成,下一秒剩下2个行才被更新完成。
  2. 要么全部都不执行:也就是如果失败了,要可回滚,将一切都恢复原样。

前者通过MVCC来实现,前文我们已经介绍过MVCC了,同一个事务而产生的新的数据行都带有相同的版本号,配合上一致性非锁定读,可以实现统一事务的变更同时在一个瞬间(也就是同一个系统版本)生效。

而后者,则通过undo日志来实现,undo日志的详细介绍可见本站博客《【InnoDB详解四】redo log和undo log》,这里简单介绍一下:

在对数据库进行修改时,InnoDB存储引擎会产生一定量的undo log,它记录了事务中每一步操作的反向逻辑操作:

  1. 如果是一个INSERT操作,那么undo log会对应产生一条DELETE操作。
  2. 如果是一个DELETE操作,那么undo log会对应产生一条INSERT操作。
  3. 如果是一个UPDATE操作,假设是将行A的值从a变更为b,那么undo log会对应产生一条UPDATE操作,将行A的值从b变更为a。

这样如果用户执行的事务或语句由于某种原因失败了,又或者用户用一条ROLLBACK语句请求回滚,就可以利用这些undo信息将数据回滚到修改之前的样子。

前者我们说过,MVCC也是通过undo日志实现的,所以本质上,InnoDB事务原子性的保证(undo log + MVCC),其实全都是依赖于undo日志。

2.3 InnoDB事务持久性的保证

持久性的核心在于已经提交的修改必须永久的保存下来,对于数据库而言,也就是要写入磁盘中,这才能做到真正的数据持久化。

InnoDB事务持久性的保证依赖于redo日志,redo日志由两部分组成:

  1. 内存中的重做日志缓冲(redo log buffr),其是易失的;
  2. 重做日志文件(redo log file),其是持久的。

redo日志的详细介绍可见本站博客《【InnoDB详解四】redo log和undo log》

redo日志是物理日志,它的内容和undo日志那样的逻辑日志不一样,它记录的是数据页的物理变更信息,对于事务中的任何操作,都会产生redo日志,用来记录其对数据页的物理变动信息。

InnoDB通过Force Log at Commit机制实现事务的持久性,即当事务提交(COMMIT)时,必须先将该事务产生的所有redo日志写入到重做日志文件(redo log file)进行持久化,这样就能保证就算数据的变更还在缓冲池中(在脏页里面),如果系统崩溃了,也可以通过已经持久化的redo日志进行数据恢复。

将redo日志写入重做日志文件(redo log file)的操作叫做fsync操作,理论上为了保证持久性,每一次事务提交前,InnoDB应该都要执行一次fsync操作,不过很显然,频繁的fsync操作会影响并发性能。

InnoDB存储引擎允许用户手工设置fsync操作的频率,以此提高数据库的性能。参数innodb_flush_log_at_trx_commit用来控制重做日志刷新到磁盘的策略:

  1. 该参数的默认值为1,表示事务提交时必须调用一次 fsync操作。
    • 这是最安全的持久化策略,不会发生更新丢失的情况。
  2. 还可以设置该参数的值为0,表示事务提交时不进行写人重做日志操作,这个操作仅在master thread中完成,而在master thread中每1秒会进行一次重做日志文件的fsync操作。
    • 此时如果发生宕机,最多会丢失1秒的那部分更新。
  3. 还可以设置该参数的值为2,表示事务提交时将重做日志写入重做日志文件,但仅写入文件系统的缓存中,不进行fsync操作。
    • 在这个设置下,当MySQL数据库发生宕机而操作系统不发生宕机时,并不会导致事务的丢失。而当操作系统宕机时,重启数据库后会丢失未从文件系统缓存刷新到重做日志文件那部分更新。

2.3 InnoDB事务隔离性的保证

在本站博客《MySQL核心要点汇总》一文中,我们简单了解过MySQL中事务的隔离级别:

SQL 标准定义了四个隔离级别:

  • READ-UNCOMMITTED(读未提交): 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。
  • READ-COMMITTED(读已提交): 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。
  • REPEATABLE-READ(可重复读): 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生(这是针对Oracle,SQL server等数据库而言,InnoDB采用Next-Key Lock,在可重复读级别下就可以避免幻读)。
  • SERIALIZABLE(串行化): 最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。

同样的,我们回顾一下 脏读/不可重复读/幻读 这三类并发的问题。

  • 脏读:指的是在不同的事务下,当前事务可以读到另外事务未提交的数据,简单来说就是可以读到脏数据。
  • 不可重复读:是指在事务A中多次读取同一批数据。在这个多次访问之间,事务B也访问该批数据,并做了一些DML操作,并且提交(如果没提交就是脏读了)。因此,在事务A中的两次读数据之间,由于事务B的修改,导致事务A两次读到的数据可能是不一样的。不可重复读和脏读的区别是脏读是读到未提交的数据,而不可重复读读到的却是已经提交的数据。
  • 幻读:是指在同一事务(事务A)下,连续执行两次同样的SQL语句可能搜出不同的结果,第二次的SQL语句可能会返回之前不存在的行,就像产生了幻觉一样。也就是说,在两次查询之间,其他事务insert并且提交的行,如果满足事务A查询的where条件,那么也会被查出来。幻读和不可重复读的区别在于幻读是查询的行数量变多(因为其他事务insert),不可重复读是行数据不一致(因为其他事务update)。

那么,各个隔离级别是如何实现的呢?他们分别是如何解决脏读/不可重复读/幻读问题的呢?

我们可以先来个开门见山的总结:

  • READ-UNCOMMITTED(读未提交)
    • 无锁
    • 无并发控制协议
  • READ-COMMITTED(读已提交)
    • 使用乐观锁MVCC,其非一致性读取,可以避免事务读取被上锁的行记录(防止脏读),只能读取快照数据。
    • 但在该级别下,所有事务的非一致性读都会读取最新版本的快照,即便这个最新版本是在事务开启之后才提交的,这就可能产生不可重复读问题。
  • REPEATABLE-READ(可重复读)
    • 使用乐观锁MVCC,并且所有事务的非一致性读都会读取当前事务开启时最新版本的快照,这样就能避免不可重复读的发生。
    • 使用Next-Key Lock,给事务查询的where条件涵盖的行记录加上范围锁,防止其他事务在这些行数据间隙插入新的记录,从而避免幻读问题。
  • SERIALIZABLE(串行化)
    • 使用悲观锁LBCC。一致性读取会在操作的每一行数据上都加上锁,读取加S锁,其余加X锁。

其中MVCC和LBCC我们在前文已经介绍过了,这里不再赘述。

不过需要注意的是,各个隔离级别的加锁,其实是加在索引上的,而且在不同的情况下,加锁逻辑也不太相同,比如我们执行一条delete语句:delete from t1 where id=10;,那么在执行这条语句前,我们需要确定:

  1. id列是不是主键?
  2. 事务的隔离级别是什么?
  3. id非主键的话,其上有建立索引吗?
  4. 建立的索引是唯一索引吗?
  5. 该SQL的执行计划是什么?索引扫描?全表扫描?

不同场景的不同实现,我们逐一来看下:

2.3.1 READ-UNCOMMITTED的加锁方式

无锁,没有什么实现,忽略。

2.3.2 READ-COMMITTED的加锁方式

2.3.2.1 id列是主键

当id是主键的时候,我们只需要在该id=10的记录上加上x锁即可。如下图:

2.3.2.2 id列是辅助唯一索引

当id列不是主键,但是为辅助唯一索引时,辅助索引和聚集索引都会加X锁。如下图:

2.3.2.3 id列是辅助非唯一索引

当id列不是主键,如果id是非唯一索引,那么所对应的所有的辅佐索引和聚集索引记录上都会上x锁。如下图:

2.3.2.4 id列上没有索引

由于id列上没有索引,因此只能走聚簇索引,进行全表扫描。因此聚集索引上的每条记录,无论是否满足条件,都会被加上X锁。

但是,为了效率考量,MySQL做了优化,对于不满足条件的记录,会在判断后放锁,最终持有的,是满足条件的记录上的锁,但是不满足条件的记录上的加锁/放锁动作不会省略。同时,优化也违背了2PL约束(同时加锁同时放锁)。如下图:

最后只有id=10的锁留下了。

2.3.3 REPEATABLE-READ的加锁方式

2.3.3.1 id列是主键

与id列是主键,RC隔离级别的情况,完全相同。因为只有一条结果记录,只能在上面加锁。如下图:

RR级别是需要同时加间隙锁的,但因为id列是唯一主键,且delete的where条件是id=10,这种情况不会发生幻读,所以此例没加。但如果delete语句不是id=10,而是id>10 and id < 15,那么就要加间隙锁了。

2.3.3.2 id列是辅助唯一索引

与id列是辅助唯一索引,RC隔离级别的情况,完全相同。因为只有一条结果记录,只能在上面加锁。如下图:

RR级别是需要同时加间隙锁的,但因为id列是唯一索引,且delete的where条件是id=10,这种情况不会发生幻读,所以此例没加。但如果delete语句不是id=10,而是id>10 and id < 15,那么就要加间隙锁了。

2.3.3.3 id列是辅助非唯一索引

在RR隔离级别下,为了防止幻读的发生,会使用间隙锁(GAP锁)。

首先,通过辅助索引定位到第一条满足查询条件的记录,加记录上的X锁,加GAP上的GAP锁,然后加聚簇索引上的记录X锁,然后返回;

然后读取下一条,重复进行。直至进行到第一条不满足条件的记录[11,f],此时,不需要加记录X锁,但是仍旧需要加GAP锁,最后返回结束。如下图:

2.3.3.4 id列上没有索引

在这种情况下,聚集索引上的所有记录,都被加上了X锁。其次,聚集索引每条记录间的间隙(GAP),也同时被加上了GAP锁。

但是,InnoDB也做了相关的优化,就是所谓的semi-consistent read。semi-consistent read开启的情况下,对于不满足查询条件的记录,MySQL会提前放锁,同时也不会添加Gap锁。

2.3.4 SERIALIZABLE的加锁方式

因为这是DML(delete)操作,所以与REPEATABLE-READ级别的各种情况下表现完全相同。但如果是select操作,会有所不同:

  • REPEATABLE-READ级别默认是一致性非锁定读,在行记录有锁的情况下可以不用阻塞,而是去读取快照,除非SQL中主动加锁进行一致性锁定读(lock in share mode 或 for update);

  • 而Serializable级别下,会对每条select语句,自动加上lock in share mode,进行一致性锁定读,即如果行上有锁,只能阻塞。

2.4 InnoDB事务一致性的保证

前文我们已经阐述过了,数据库外的数据一致性,需要通过外部编码来实现,而数据库内的数据一致性,则依赖于原子性和隔离性。在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性。 在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。

所以InnoDB实现了原子性和隔离性,也就自然而然实现了一致性。

【图论】拓扑排序详解

Posted on 2020-09-15 | In 数据结构与算法 , 图 |
Words count in article: 1.6k | Reading time ≈ 5

前言

在正文开始前,我们先来了解一下有向无环图(Directed Acyclic Graph简称DAG)

如下图就是一个DAG图,DAG图是我们讨论拓扑排序的基础。

AOV网:数据在顶点 可以理解为面向对象
AOE网:数据在边上,可以理解为面向过程!

1. 什么是拓扑排序

拓扑排序(Topological Order),很多人听说过,但是不了解的一种算法。或许很多人只知道它是图论的一种排序,至于干什么的不清楚。又或许很多人可能还会认为它是一种啥排序。

而实质上它只是将DAG图的顶点排成一个线性序列,得到一个顶点的全序集合。其排序的顺序依据就是节点的指向关系。比如前言的DAG图:

  • …
  • 节点5在节点4和节点3的后面
  • 节点9在节点6和节点7的后面
  • …

那么最后得到的节点的线性序列结果,也一定要满足上面的指向顺序。

每一个节点都拥有入度(有多少点导向它,也就是开始它有多少前提)和出度(它导向多少点,也就是它是多少其他节点开始的前提)。例如节点5的入度为3和4,出度为7。

拓扑排序的结果不是唯一的,只要符合上面的条件,那么它就是拓扑序列,比如1 2 4 3 6 5 7 9和2 1 3 4 5 6 7 9,这两个结果都是可行的。

官方一点的定义:将有向图中的节点以线性方式进行排序。即对于任何连接自节点u到节点v的有向边uv,在最后的排序结果中,节点u总是在节点v的前面。

2. 现实案例

看了上面关于拓扑排序的概念如果还觉得十分抽象的话,那么不妨考虑一个非常非常经典的例子——选课。

假设我非常想学习一门《jsp入门》的课程,但是在修这么课程之前,我们必须要学习一些基础课程,比如《JAVA语言程序设计》,《HTML指南》等等。那么这个制定选修课程顺序的过程,实际上就是一个拓扑排序的过程,每门课程相当于有向图中的一个顶点,而连接顶点之间的有向边就是课程学习的先后关系。

只不过这个过程不是那么复杂,从而很自然的在我们的大脑中完成了。将这个过程以算法的形式描述出来的结果,就是拓扑排序。

可以看到,上图中的学习顺序,就是拓扑序列,其不止一个结果。

拓扑排序算法在工程学中十分重要。

节点成环的图,无法被拓扑排序,因为这在工程上本身没有意义,比如A——>B——>C——>A,那么这个工程永远无法被开始。

3. 算法实现

拓扑排序的最优时间复杂度是O(m+n),其中m和n是DAG图中节点数和边数。因为拓扑排序至少要对DAG图的节点和边进行一次完整的遍历。

拓扑排序的最优空间复杂度是O(m+n),其中m和n是DAG图中节点数和边数。我们一般使用邻接表来存储DAG图,因此空间复杂度是O(m+n)。

3.1 广度优先搜索法(BFS)

3.1.1 BFS实现拓扑排序

广度优先搜索法的思路很简单:

  1. 从DAG图中找到入度为0的节点A(也就是没有箭头指向它的节点),将其放入拓扑序列的结果集。
  2. 同时删除由节点A出发的所有边。
  3. 在剩下的DAG图中重复1-2两步。
  4. 如果最后可以把全部的节点都删除并加入到结果集,那表示DAG图可以被拓扑排序;否则,如果最后有节点被剩下,那说明该图是有环图,无法被拓扑排序。

如下图

3.1.2 BFS实现拓扑排序的优化

如果有时候,我们只需要知道某个DAG图是否可以拓扑排序,而不需要真正得到拓扑排序后的结果,那么可以不需要结果集列表,只需要统计被删除的节点的数量即可,如果该数量等于DAG图的节点数,那么DAG图可以被拓扑排序。

3.2 深度优先搜索法(DFS)

3.2.1 DFS实现拓扑排序

深度优先搜索法是广度优先搜索法的逆向思路,它的步骤如下:

  1. 选取图中任意一个节点A,将其状态标记为“搜索中”
  2. 寻找节点A的邻接点(沿着箭头指向寻找相邻的节点)
    1. 如果A存在邻接点
      1. 如果A的邻接点中存在状态为“搜索中”的邻接点,那么表示DAG图有环路,不可拓扑排序。
      2. 否则,那么任意选择一个状态为“未搜索”的邻接点B,使用递归对B重复做1和2操作,注意此时B的邻接点判断不包含来路(也就是A节点)。等到A的所有邻接点都被搜索到,递归回溯回A节点的时候,那么A节点也会被标记为“已搜索”,并压入结果栈。
    2. 如果A不存在邻接点,那么将节点A的状态改为“已完成”,并且将其压入一个结果集的栈中。
  3. A节点及其相邻节点都搜索完毕后,如果还有未搜索的节点,那么任意选取一个节点当做出发点,继续重复1,2,3步骤。
  4. 直到所有的节点都被搜索并压入栈,那么此时结果栈中,从栈顶到栈底的顺序,就是拓扑排序的顺序。

3.2.2 DFS实现拓扑排序的优化

如果有时候,我们只需要知道某个DAG图是否可以拓扑排序,而不需要真正得到拓扑排序后的结果,那么可以不需要结果栈,只需要判断整个深度优先搜索过程,没有发生“搜索中”节点的相邻节点(不包含来路的节点)也是“搜索中”就行。

4 算法题解

4.1 课程表I

leetcode-207. 课程表I

4.2 课程表II

leetcode-210.课程表II

【图论】广度/深度优先搜索算法

Posted on 2020-09-15 | In 数据结构与算法 , 图 |
Words count in article: 1.8k | Reading time ≈ 7

前言

我们首次接触广度优先搜索和深度优先搜索时,应该是在数据结构课上讲的 “图的遍历”。还有就是刷题的时候,遍历二叉树/拓扑排序我们会经常用到这两种遍历方法。

广度优先搜索算法(Breadth-First-Search,缩写为 BFS),是一种利用队列实现的搜索算法。简单来说,其搜索过程和 “湖面丢进一块石头激起层层涟漪” 类似。

深度优先搜索算法(Depth-First-Search,缩写为 DFS),是一种利用递归实现的搜索算法。简单来说,其搜索过程和 “不撞南墙不回头” 类似。

BFS 的重点在于队列,而 DFS 的重点在于递归。这是它们的本质区别。

1. 广度优先搜索法

广度优先搜索,也叫做广度优先遍历,其主要思想类似于树的层序遍历。

  1. 从任意一个节点A开始,遍历它的全部的邻接点B,C
  2. 然后再以它其中一个邻接点B为起点,遍历B的所有的邻接点D,F。
  3. 然后再以它另外一个邻接点C为起点,遍历C的所有的邻接点G,H。
  4. 然后再以它其中一个邻接点D为起点,遍历D的所有的邻接点…。
  5. 以此类推….

伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//queue:结果集,queue队列
//nodeA:开始节点
public void bfsSearch(List<Node> queue ,Node StrartNode){
//先选择一个出发点,加入队列。
queue.add(StrartNode);
int size = queue.getSize();
for(int index=0;index<size;index++){
//BFS的重点在于队列,它的思路就是沿着queue的添加顺序,依次遍历他们的邻接点。
for(Node node : queue.get(index).getNeighbor()){
//没被搜索过,那么加入结果集
if(!queue.contain(node)){
queue.add(node);
size = queue.getSize();
}
}
}
}

public void main(){
//定义一个结果集,queue队列
List<Node> queue = new LinkedList<Node>();
//先选择一个节点作为开始节点。
Node startNode=nodeA;
bfsSearch(queue,startNode);
while(queue.size()不等于节点总数){
//说明可能因为边有向的问题,有些节点没有被遍历到
//此时需要在剩下的节点中另找一个出发点(假设为B)
startNode = nodeB;//省略找出B的过程
bfsSearch(queue,startNode);
}
}

BFS比较适合判断二分图,以及用于实现寻找最小生成树(MST),如在BFS基础上的Kruskal算法。还有寻找最短路径问题(如Dijkstra算法)。

image

1.1 无向图的广度优先遍历

我们先给出一个图:

  1. 先找到A,这是第一层。
  2. 再找到A的邻接点,遍历到B,C,D,F。
  3. 再找到B,C,D,F的邻接点,遍历到G,E,H

1.2 有向图的广度优先遍历

思路和与无向图类似,只不过需要考虑边的走向问题。

  1. 先找到A,这是第一层。
  2. 再找到A的邻接点,遍历到B,C,F。
  3. 再找到B,C,F的邻接点,遍历到D,H
  4. 再找到D,H的邻接点,遍历到E,G

2. 深度优先搜索法

深度优先搜索,也叫做深度优先遍历,其主要思想是回溯法,它的核心是使用递归。

例如这张图,从1开始到2,之后到5,5不能再走了,退回2,到6,退回2退回1,到3,以此类推;

伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//stack:定义的结果集stack栈
//currentNode:本次递归搜索的当前node
public void dfsSearch(Stack<Node> stack,Node currentNode){
if(currentNode没有邻接点 && !stack.contain(currentNode)){
//压入结果栈
stack.push(currentNode);
return;
}
//node有邻接点,那么遍历邻接点,依次深搜
for(Node node : currentNode.getNeighbor()){
if(node.isVisited() || stack.contain(currentNode)){
continue;
}
node.setVisited(true);
dfsSearch(stack,node);
node.setVisited(false);
}
//currentNode的邻接点都已经遍历过了,现在逻辑回溯回currentNode
//那么需要将currentNode压入结果栈
stack.push(currentNode);
}

public void main(){
//定义一个结果集
Stack<Node> stack = new Stack<Node>();
//先选择一个节点作为开始节点。
Node startNode=nodeA;
dfsSearch(stack,startNode);
while(queue.size()不等于节点总数){
//说明可能因为边有向的问题,有些节点没有被遍历到
//此时需要在剩下的节点中另找一个出发点(假设为B)
startNode = nodeB;//省略找出B的过程
dfsSearch(stack,startNode);
}
}

image

2.1 无向图的深度优先遍历

我们先给出一个图:

对上无向图进行深度优先遍历,从A开始:

第1步:访问A。

第2步:访问B(A的邻接点)。 在第1步访问A之后,接下来应该访问的是A的邻接点,即”B,D,F”中的一个。但在本文的实现中,顶点ABCDEFGH是按照顺序存储,B在”D和F”的前面,因此,先访问B。

第3步:访问G(B的邻接点)。 和B相连只有”G”(A已经访问过了)

第4步:访问E(G的邻接点)。 在第3步访问了B的邻接点G之后,接下来应该访问G的邻接点,即”E和H”中一个(B已经被访问过,就不算在内)。而由于E在H之前,先访问E。

第5步:访问C(E的邻接点)。 和E相连只有”C”(G已经访问过了)。

第6步:访问D(C的邻接点)。

第7步:访问H。因为D没有未被访问的邻接点;因此,一直回溯到访问G的另一个邻接点H。

第8步:访问(H的邻接点)F。

因此访问顺序是:A -> B -> G -> E -> C -> D -> H -> F

2.2 有向图的深度优先遍历

对上有向图进行深度优先遍历,从A开始:

第1步:访问A。

第2步:访问(A的出度对应的字母)B。 在第1步访问A之后,接下来应该访问的是A的出度对应字母,即”B,C,F”中的一个。但在本文的实现中,顶点ABCDEFGH是按照顺序存储,B在”C和F”的前面,因此,先访问B。

第3步:访问(B的出度对应的字母)F。 B的出度对应字母只有F。

第4步:访问H(F的出度对应的字母)。 F的出度对应字母只有H。

第5步:访问(H的出度对应的字母)G。

第6步:访问(G的出度对应字母)E。 在第5步访问G之后,接下来应该访问的是G的出度对应字母,即”B,C,E”中的一个。但在本文的实现中,顶点B已经访问了,由于C在E前面,所以先访问C。

第7步:访问(C的出度对应的字母)D。

第8步:访问(C的出度对应字母)D。 在第7步访问C之后,接下来应该访问的是C的出度对应字母,即”B,D”中的一个。但在本文的实现中,顶点B已经访问了,所以访问D。

第9步:访问E。D无出度,所以一直回溯到G对应的另一个出度E。

因此访问顺序是:A -> B -> F -> H -> G -> C -> D -> E

【InnoDB详解二】MySQL文件系统和InnoDB存储结构

Posted on 2020-09-08 | In 关系型数据库 , MySQL |
Words count in article: 10.4k | Reading time ≈ 38

1 MySQL文件系统

本章节将分析构成MySQL数据库和InnoDB存储引擎表的各种类型文件。这些文件有以下这些。

  1. 参数文件∶告诉MySQL实例启动时在哪里可以找到数据库文件,并且指定某些初始化参数,这些参数定义了某种内存结构的大小等设置,还会介绍各种参数的类型。
  2. 日志文件∶用来记录MySQL实例对某种条件做出响应时写人的文件,如错误日志文件、二进制日志文件、慢查询日志文件、查询日志文件等。
  3. socket文件∶当用UNIX域套接字方式进行连接时需要的文件。
  4. pid文件∶MySQL实例的进程 ID文件。
  5. MySQL表结构文件∶用来存放 MySQL表结构定义文件。
  6. 存储引擎文件∶因为MySQL表存储引擎的关系,每个存储引擎都会有自己的文件来保存各种数据。这些存储引擎真正存储了记录和索引等数据。本章主要介绍与 InnoDB有关的存储引擎文件。

1.1 参数文件

当 MySQL实例启动时,数据库会先去读取一个配置参数文件,用来寻找数据库的各种文件所在位置以及指定某些初始化参数,这些参数通常定义了某种内存结构有多大等。

在默认情况下,MySQL实例会按照一定的顺序在指定的位置进行读取,用户只需通过命令mysql--help | grep my.cnf来寻找即可。

MySQL数据库参数文件的作用和Oracle数据库的参数文件极其类似,不同的是,Oracle实例在启动时若找不到参数文件,是不能进行装载(mount)操作的。MySQL稍微有所不同,MySQL实例可以不需要参数文件,这时所有的参数值取决于编译MySQL时指定的默认值和源代码中指定参数的默认值。

MySQL数据库的参数文件是以文本方式进行存储的。用户可以直接通过一些常用的文本编辑软件(如vi和emacs)进行参数的修改。

MySQL数据库参数是一个键/值(key/value)对。如innodb_buffer_pool_size=1G。

可以通过命令 SHOW VARIABLES查看数据库中的所有参数,也可以通过LIKE来过滤参数名。

1.1 参数的类型

MySQL数据库中的参数可以分为两类∶

  1. 动态(dynamic)参数
  2. 静态(static)参数

动态参数意味着可以在MySQL实例运行中进行更改,静态参数说明在整个实例生命周期内都不得进行更改,就好像是只读(read only)的。可以通过SET命令对动态的参数值进行修改,SET 的语法如下∶

这里可以看到global和session关键字,它们表明该参数的修改是基于当前会话还是整个实例的生命周期。

有些动态参数只能在会话中进行修改,如autocommit;

而有些参数修改完后,在整个实例生命周期中都会生效,如binlog_cache_size;

而有些参数既可以在会话中又可以在整个实例的生命周期内生效,如 read_buffer_size。

举例如下:


上述示例中将当前会话的参数read_buffer_size从2MB调整为了512KB,而用户可以看到全局的read_buffer_size的值仍然是2MB,也就是说如果有另一个会话登录到MySQL实例,它的read_buffer_size的值是2MB,而不是512KB。这里使用了set global | session来改变动态变量的值。用户同样可以直接使用SET@@global | @@session来更改,如下所示∶

这次把read_buffer_size全局值更改为IMB,而当前会话的read bufer_size的值还是512KB。

这里需要注意的是,对变量的全局值进行了修改,仅在这次的实例生命周期内都有效,但MySQL实例本身并不会对参数文件中的该值进行修改。也就是说,在下次启动时MySQL实例还是会读取参数文件。若想在数据库实例下一次启动时该参数还是保留为当前修改的值,那么用户必须去修改参数文件。

1.2 日志文件

日志文件相关介绍详见本站文章《MySQL日志体系详解》

1.3 socket文件

之前的文章中我们提到过,在UNIX系统下本地连接MySQL可以采用UNIX域套接字方式,这种方式需要一个套接字(socket)文件。套接字文件可由参数socket控制。一般在/tmp 目录下,名为mysql.sock∶

1.4 pid文件

当MySQL实例启动时,会将自己的进程ID写入一个文件中——该文件即为pid文件。该文件可由参数pid_file控制,默认位于数据库目录下,文件名为主机名.pid∶

1.5 表结构定义文件

MySQL数据的存储是根据表进行的,但因为MySQL插件式存储引擎的体系结构的关系,所以MySQL要在存储引擎之上将表信息记录下来,于是,MySQL为每个表都定义与之对应的文件。不论表采用何种存储引擎,MySQL都有一个以frm为后缀名的文件,这个文件记录了该表的表结构定义。

frm还用来存放视图的定义,如用户创建了一个va视图,那么对应地会产生一个v_a.frm文件,用来记录视图的定义,该文件是文本文件,可以直接使用cat命令进行查看∶

1.6 InnoDB存储引擎文件

之前介绍的文件都是MySQL数据库本身的文件,和存储引擎无关。除了这些文件外,每个表存储引擎还有其自己独有的文件。本节将具体介绍与InnoDB存储引擎密切相关的文件,这些文件包括重做日志文件、表空间文件。

1.6.1 表空间文件

InnoDB采用将存储的数据按表空间(tablespace)进行存放的设计。在默认配置下会有一个初始大小为10MB,名为ibdata1的文件。该文件就是默认的表空间文件(tablespace file),又称作共享表空间,用户可以通过参数innodb_data_file_path对其进行设置,格式如下∶

用户可以通过多个文件组成一个表空间,同时制定文件的属性,如∶

这里将/db/ibdata1和/dr2/db/ibdata2两个文件用来组成表空间。若这两个文件位于不同的磁盘上,磁盘的负载可能被平均,因此可以提高数据库的整体性能。同时,两个文件的文件名后都跟了属性,表示文件idbdata1的大小为2000MB,文件ibdata2的大小为2000MB,如果用完了这2000MB,该文件可以自动增长(autoextend)。

设置innodb_data_file_path参数后,所有基于InnoDB存储引擎的表的数据都会记录到该共享表空间中。若设置了参数innodb_file_per_table,则用户可以将每个基于InnoDB存储引擎的表产生一个独立表空间。独立表空间的命名规则为∶表名.ibd。通过这样的方式,用户不用将所有数据都存放于共享表空间中。

下面这台MySQL数据库服务器设置了innodb_file_per_table,故可以观察到∶

表Profile、t1和t2都是基于InnoDB存储的表,由于设置参数innodb_file_per_table=ON,因此产生了单独的.ibd独立表空间文件。

直到这里,我们知道了表空间有两种:

  1. 共享表文件:innodb_data_file_path参数指向的ibdata1这种文件。
  2. 单独表文件:由于设置参数innodb_file_per_table=ON,因此产生了单独的.ibd独立表空间文件。

需要注意的是,这些单独的表空间文件(tableName.ibd)仅存储该表的数据、索引和插入缓冲Bitmap等信息,其余信息,如回滚(undo)信息,插入缓冲索引页、系统事务信息,二次写缓冲(Double write buffer)等信息,还是存放在共享表空间(ibdata1文件)中。

下图显示了InnoDB存储引擎对于文件的存储方式

1.6.2 redo log文件

在默认情况下,在InnoDB存储引擎的数据目录下会有两个名为ib_logfile0和ib_logfile1的文件。在MySQL官方手册中将其称为InnoDB存储引擎的日志文件,不过更准确的定义应该是重做日志文件(redo log file)。为什么强调是重做日志文件呢?因为重做日志文件对于InnoDB存储引擎至关重要,它们记录了对于InnoDB存储引擎的事务日志。

当实例或介质失败(media failure)时,重做日志文件就能派上用场。例如,数据库由于所在主机掉电导致实例失败,InnoDB存储引擎会使用重做日志恢复到掉电前的时刻,以此来保证数据的完整性。

每个InoDB存储引擎至少有1个重做日志文件组(group),每个文件组下至少有2个重做日志文件,如默认的ib_logfile0和ib_logfile1。为了得到更高的可靠性,用户可以设置多个的镜像日志组(mirored log groups),将不同的文件组放在不同的磁盘上,以此提高重做日志的高可用性。在日志组中每个重做日志文件的大小一致,并以循环写入的方式运行。

InnoDB存储引擎先写重做日志文件1,当达到文件的最后时会切换至重做日志文件2,再当重做日志文件2也被写满时,会再切换到重做日志文件1中。

redo log文件详情,可见本站文章《【InnoDB详解四】redo log和undo log》

2 InnoDB存储结构

我们接下来将从InnoDB存储引擎表的逻辑存储及实现开始进行介绍,然后将重点分析表的物理存储特征,即数据在表中是如何组织和存放的。简单来说,表就是关于特定实体的数据集合,这也是关系型数据库模型的核心。

在InnoDB存储引擎中,表都是根据主键顺序组织存放的,这种存储方式的表称为索引组织表(index organized table)。在InnoDB存储引擎表中,每张表都有个主键(Primary Key),如果在创建表时没有显式地定义主键,则InnoDB存储引擎会按如下方式选择或创建主键∶

  1. 首先判断表中是否有非空的唯一索引(Unique NOTNULL),如果有,则该列即为主键。
  2. 如果不符合上述条件,InnoDB存储引擎自动创建一个6字节大小的指针。当表中有多个非空唯一索引时,InnoDB存储引擎将选择建表时第一个定义的非空唯一索引为主键。这里需要非常注意的是,主键的选择根据的是定义索引的顺序,而不是建表时列的顺序。

2.1 InnoDB逻辑存储结构

从 InnoDB存储引擎的逻辑存储结构看,所有数据都被逻辑地存放在一个空间中,称之为表空间(tablespace)。表空间又由段(segment)、区(extent)、页(page)组成。页在一些文档中有时也称为块(block),InnoDB存储引擎的逻辑存储结构大致如图4-1所示。

表空间可以看做是InnoDB存储引擎逻辑结构的最高层,所有的数据都存放在表空间中。前文我们已经介绍了表空间,并且知道了表空间分为共享表空间和独立表空间(若有),这里就不再赘述了。

2.1.1 段

图4-1中显示了表空间是由各个段组成的,常见的段有数据段、索引段、回滚段等。

因为前面已经介绍过了InnoDB存储引擎表是索引组织的(index organized),因此数据即索引,索引即数据。那么数据段即为B+树的叶子节点(图4-1的Leafnode segment),索引段即为B+树的非索引节点(图4-1的Non-leaf node segment)。

回滚段较为特殊,将会在后面的章节进行单独的介绍。

在InnoDB存储引擎中,对段的管理都是由引擎自身所完成,DBA不能也没有必要对其进行控制。这和Oracle数据库中的自动段空间管理(ASSM)类似,从一定程度上简化了DBA 对于段的管理。

2.1.2 区

区是由连续页组成的空间,在任何情况下每个区的大小都为1MB。为了保证区中页的连续性,InnoDB存储引擎一次从磁盘申请4~5个区。在默认情况下,InnoDB存储引擎页的大小为16KB,即一个区中一共有64个连续的页。

InnoDB1.0.x版本开始引入压缩页,即每个页的大小可以通过参数KEY_BLOCK_SIZE设置为2K、4K、8K,因此每个区对应页的数量就应该为512、256、128。

InnoDB 1.2.x版本新增了参数 innodb_page_size,通过该参数可以将默认页的大小设置为4K、8K,但是页中的数据库不是压缩。这时区中页的数量同样也为256、128。总之,不论页的大小怎么变化,区的大小总是为1M。

但是,这里还有这样一个问题∶在用户启用了参数innodb_file_per_talbe后,创建的表默认大小是96KB。区中是64个连续的页,创建的表的大小至少是1MB才对啊,这是什么原因呢?

其实这是因为在每个段开始时,先用32个页大小的碎片页(fragment page)来存放数据,在使用完这些页之后才是64个连续页的申请。这样做的目的是,对于一些小表,或者是undo这类的段,可以在开始时申请较少的空间,节省磁盘容量的开销。

2.1.3 页

同大多数数据库一样,InnoDB有页(Page)的概念(也可以称为块),页是InnoDB磁盘管理的最小单位。在InnoDB存储引擎中,默认每个页的大小为16KB。而从InnoDB 1.2.x版本开始,可以通过参数innodb_page_size将页的大小设置为4K、8K、16K。

若设置完成,则所有表中页的大小都为innodb_page_size,不可以对其再次进行修改。除非通过mysqldump导入和导出操作来产生新的库。

页是InnoDB磁盘管理的最小单位:

在计算机中磁盘存储数据最小单元是扇区,一个扇区的大小是512字节,而文件系统(例如XFS/EXT4)他的最小单元是块,一个块的大小是4k,InnoDB存储引擎一个页的大小是16K。

2.1.3.1 页的类型

在InnoDB存储引擎中,常见的页类型有∶

  1. 数据页(B-tree Node)
  2. undo页(undo Log Page)
  3. 系统页(System Page)
  4. 事务数据页(Transaction system Page)
  5. 插入缓冲bitmap页(Insert Buffer Bitmap)
  6. 插入缓冲空闲列表页(Insert Buffer Free List)
  7. 未压缩的二进制大对象页(Uncompressed BLOB Page)
  8. 压缩的二进制大对象页(compressed BLOB Page)

在页的File Header结构中,FIL_PAGE_TYPE字段被用来区分数据页的类型(后文会介绍),他们的值如下:

2.1.4 行

InnoDB存储引擎是面向列的(row-oriented),也就说数据是按行进行存放的。每个页存放的行记录也是有硬性定义的,最多允许存放16KB/2-200行的记录,即7992行记录。

这里提到了row-oriented的数据库,也就是说,存在有colum-riented的数据库。MySQL infobright存储引擎就是按列来存放数据的,这对于数据仓库下的分析类 SQL语句的执行及数据压缩非常有帮助。类似的数据库还有Sybase IQ、Google Big Table。面向列的数据库是当前数据库发展的一个方向,但这超出了本书涵盖的内容,有兴趣的读者可以在网上寻找相关资料。

2.2 InnoDB存储格式

2.2.1 InnoDB行记录格式

InnoDB存储引擎和大多数数据库一样(如 Oracle和Microsof SQL Server数据库),记录是以行的形式存储的。这意味着页中保存着表中一行行的数据。在InmoDB1.0x版本之前,InnoDB存储引擎提供了Compact和Redundant两种格式来存放行记录数据,这也是目前使用最多的一种格式。

Redundant格式是为兼容之前版本而保留的,如果阅读过InnoDB的源代码,用户会发现源代码中是用PHYSICALRECORD(NEW STYLE)和PHYSICALRECORD(OLD STYLE)来区分两种格式的。

在MySQL5.1版本中,默认设置为Compact行格式。用户可以通过命令SHOW TABLE STATUS LIKE’table_name’来查看当前表使用的行格式,其中row_format 属性表示当前所使用的行记录结构类型。如∶

可以看到,这里的mytest表是Compact的行格式,mytest2表是Redundant的行格式。

2.2.1.1 Compact类型格式

Compact行记录是在MySQL5.0中引入的,其设计目标是高效地存储数据。简单来说,一个页中存放的行数据越多,其性能就越高。下图显示了Compact行记录的存储方式∶

  1. 变长字段长度列表
    • 这部分用来记录该行中每个varchar字段的长度(注意,只记录varchar字段的长度,单位是字节),假设数据行中会有n个varchar列,所以该部分也会对应存储n个长度值。
    • 每个varchar列的长度一般用一个字节(对应字段真正长度 < 128字节),最多只能用两个字节(16 bit)(对应字段真正长度 >= 128字节)表示,所以在MySQL数据库中varchar类型的最大长度限制为65535字节(2的16次方)。
    • 不过这里就有问题了,如果a列的长度占用1个字节,b列的长度占用两个字节,那解析的时候如何知道这三个字节的分界线呢?InnoDB规定如果某个字节最高位为0,那么这个字节就是独立的字节;如果某个字节最高位为1,那么就和它后面的字节共同表示一个长度(第二个字节可以用所有位表示长度)。也正是因为字节首位另有用处,所以一个字节最多表示长度为小于128。
    • 所以如果a,b两列长度紧密排列,如01111111 10000000 10000000,那就可以知道分界线是01111111 | 10000000 10000000。需要注意的是,MySQL采取 Little Endian 的计数方式,低位在前,高位在后,所以129用两个字节表示就是 10000001 10000000。
    • 变长字段长度列表中每个长度值的排序,和行中varchar列的顺序是相反的,也就是长度值在变长字段长度列表中是倒序存放
1
2
3
4
5
6
7
8
# 假如有三个字段 id,name,desc,age。其中name,desc是变长类型(Varchar)
|id|name|desc|age|
|1|wang|shuaige|18|
|2|li|meinv|20|

则磁盘里的存储为:
0x07 0x04 null值列表 数据头 1 wang shuaige 18 0x05 0x02 null值列表 数据头 2 li meinv 20
# 其中0x04表示name长度为4 ,0x07表示desc的长度为7,以此类推。
  1. NULL标志位

    • 该部分用来标记该行中哪些列的值是NULL值。
    • 它是一个bitmap,一般占用1个字节(8 bit),它的每一位位指示了该行数据中对应的列是否是NULL值,有则用1表示。
    • 比如NULL标志位如果为0x06,二进制是00000110,很显然第2位和第3位的值是1,那么就表示该行的第二列和第三列当前值为NULL。
    • NULL标志位一般是占用1个字节,但如果列的数量大于8个,那么会多扩充一个字节,直到能涵盖所有的列。
  2. 记录头信息(record header)

    • 固定占用5字节(40位),每位的含义见下图:
    • 值得注意的是RecordHeader的最后两个字节,这16 bit是next_recorder,代表下一个记录的偏移量,假设该值为0x2c,那么它表示当前记录的位置加上偏移量0x2c就是下条记录的起始位置。所以InnoDB存储引擎在页内部是通过一种链表的结构来串连各个行记录的。
  3. 列数据

    • 最后的部分就是实际存储每个列的数据。需要特别注意的是,NULL不占该部分任何空间,即NULL除了占有NULL标志位,实际存储不占有任何空间。
    • 另外有一点需要注意的是,每行数据除了用户定义的列外,还有两个隐藏列,事务ID列和回滚指针列,分别为6字节和7字节的大小,这两个部分与InnoDB实现MVCC有关,版本控制、事务回滚等内容,这里不详述。若InnoDB表没有定义主键,每行还会增加一个6字节的rowid列。

我们来用一个实际的例子分析Compact行记录格式吧:

我们先定义一个表mytest,其中t1,t2,t4是变长的varchar类型,t3是固定长度的char类型

1
2
3
4
5
6
CREATE TABLE `mytest` (
`t1` varchar(10) DEFAULT NULL,
`t2` varchar(10) DEFAULT NULL,
`t3` char(10) DEFAULT NULL,
`t4` varchar(10) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=latin1 ROW_FORMAT=COMPACT

我们插入如下记录(其中–表示NULL):

1
2
3
4
5
6
7
8
mysql> select * from mytest;
+----+----+----+-----+
| t1 | t2 | t3 | t4 |
+----+----+----+-----+
| a | bb | bb | ccc |
| d | ee | ee | fff |
| d | -- | -- | fff |
+----+----+----+-----+

然后将打开表空间文件mytest.ibd(这里启用了innodb_file_per_table,若没有启用该选项,打开默认的共享表空间文件 ibdata1)。

在Windows操作系统下,可以选择通过程序UltraEdit打开该二进制文件。在Linux 环境下,使用命令hexdump-C-v mytest.ibd>mytest.txt。这里将结果重定向到了文件mytes.txt,打开 mytest.txt文件,找到如下内容∶

1
2
3
4
5
6
7
8
0000c070 73 75 70 72 65 6d 75 6d 03 02 01 00 00 00 10 00|supremum……
0000c080 2c 00 00 00 2b 68 00 00 00 00 00 06 05 80 00 00|,……+h……
0000c090 00 32 01 10 61 62 62 62 62 20 20 20 20 20 20 20|.2..abbbb
0000c0a0 20 63 63 63 03 02 01 00 00 00 18 00 2b 00 00 00|ccc……+……
0000c0b0 2b 68 01 00 00 00 00 06 06 80 00 00 00 32 01 10|+h……2..
0000c0c0 64 65 65 65 65 20 20 20 20 20 20 20 20 66 66 66|deeeefff
0000c0d0 03 01 06 00 00 20 ff 98 00 00 00 2b 68 02 00 00|……+h……
0000c0e0 00 00 06 07 80 00 00 00 32 01 10 64 66 66 66 00|……2..dfff.

第一行记录(a,bb,bb,ccc)从0000c078开始,我们整理一下,下面都是16进制数,如03就是0x03:

1
2
3
4
5
6
7
8
9
10
03 02 01/*变长字段长度列表,分别记录t1,t2,t4的长度,逆序*/
00/*NULL标志位,第一行没有NULL值*/
00 00 10 00 2c/*记录头信息,固定5字节长度*/
00 00 00 2b 68 00/*RowID我们建的表没有主键,因此会有RowID*/
00 00 00 00 06 05/*TransactionID*/
80 00 00 00 32 01 10/*Roll Pointer*/
61/*t1数据'a'*/
62 62/*t2'bb'*/
62 62 20 20 20 20 20 20 20 20/*t3数据'bb',因为t3列是固定长度的char类型,所以可以看到,未占用的地方,char用0x20(空格)补全*/
63 63 63/*t4数据'ccc'*/

我们再来看有NULL值的第三行记录(d,NULL,NULL,fff),

1
2
3
4
5
6
7
8
03 01/*变长字段长度列表,逆序*/
06/*NULL标志位,06的二进制是00000110,很显然第2位和第3位的值是1,所以t2和t3是NULL*/
00 00 20 ff 98/*记录头信息*/
00 00 00 2b 68 02/*RowID*/
00 00 00 00 06 07/*TransactionID*/
80 00 00 00 32 01 10/*Roll Pointer*/
64/*t1数据'd'*/
66 66 66/*t4数据'fff'*/

可以发现不管是char还是varchar,NULL都不占用任何空间。

2.2.1.2 Redundant类型格式

Redundant是MySQL 5.0版本之前InnoDB的行记录存储方式,MySQL 5.0支持Redundant是为了兼容之前版本的页格式。Redundant行记录采用如下所示的方式存储。

  1. 字段长度偏移列表
    • 不同于Compact行记录格式,Redundant行记录格式的首部是一个字段长度偏移列表,同样是按照列的顺序逆序放置的。
    • 注意该列表记录的是每个列长度的偏移量,而不是长度值本身,比如某个字段长度偏移列表经整理后为23 20 16 14 13 0c 06,因为是逆序排布,所以我们先翻为正序06,0c,13,14,16,20,23,那么这表示:第一列的长度是6,第二列的长度是6(6+6=0x0C),第三列的长度为7(6+6+7=0x13),第四列的长度是1(6+6+7+1=0x14),第五列的长度是2(6+6+7+1+2=0x16),第六列的长度是10(6+6+7+1+2+10=0x20),第七列的长度是3(6+6+7+1+2+10+3=0x23)。
    • 同样的,长度列表中每个列的长度的偏移值一般用一个字节,最多用两个字节来存储。不过不同于compact格式,compact格式允许a列使用1个字节,b列使用两个字节,但是Redundant的话,要么所有列的偏移值都占用1字节,要么都占用2字节。
    • 到底每个偏移使用1字节还是2字节,是根据整行记录的长度决定,如果整行长度小于 128,则用1字节存储,否则,用2字节。
      • 如果是1字节存储的情况,那么每个字节最高的那个bit用来标记对应字段值是否为 NULL,如果为NULL,则最高位为1,否则为0。剩下的7位用来存储长度偏移量,所以最多是127。
      • 对于两字节存储,首个字节的最高位还是用来标记对应字段值是否为NULL。最高的第二位则用来标记这条记录是否在同一页,如果在则为0,如果不在则为1,这其实就涉及到了后面要说的溢出页。剩下的连同第二个字节完整8bit在内的14bit表示长度,所以最多是16383
  2. 记录头信息(record header)
    • 不同于Compact行记录格式,Redundant行记录格式的记录头占用6字节(48 位),每位的含义见下表
    • 从中可以发现,n_fields值代表一行中列的数量,占用10位。同时这也很好地解释了为什么MySQL数据库一行支持最多的列为1023。因为2的10次方为1024
    • 另一个需要注意的值为1byte_offs_flag,该值定义了字段长度偏移列表占用的是1字节还是2字节。
  3. 列数据
    • 最后的部分就是实际存储每个列的数据。需要特别注意的是,varchar类型的NULL不占该部分任何空间,char类型的NULL占用固定空间。
    • 另外有一点需要注意的是,每行数据除了用户定义的列外,还有两个隐藏列,事务ID列和回滚指针列,分别为6字节和7字节的大小,这两个部分与InnoDB实现MVCC有关,版本控制、事务回滚等内容,这里不详述。若InnoDB表没有定义主键,每行还会增加一个6字节的rowid列。

好,我们也来看下Redundant格式的例子,还是那张表和那些记录:

其中t1,t2,t4是变长的varchar类型,t3是固定长度的char类型

1
2
3
4
5
6
7
8
mysql> select * from mytest;
+----+----+----+-----+
| t1 | t2 | t3 | t4 |
+----+----+----+-----+
| a | bb | bb | ccc |
| d | ee | ee | fff |
| d | -- | -- | fff |
+----+----+----+-----+

我们直接来看有NULL的第三行(下面都是16进制表示):

1
2
3
4
5
6
7
8
a1 9e 94 14 13 0c 06/*长度偏移列表,逆序*/
00 00 20 0f 00 74/*记录头信息,固定6个字节*/
00 00 00 2b 68 0d/*RowID*/
00 00 00 00 06 53/*TransactionID*/
80 00 00 00 32 01 10/*Roll Point*/
64/*t1数据'd'*/
00 00 00 00 00 00 00 00 00 00/*t3数据NULL*/
66 66 66/*t4数据'fff'*/

可以看到:

  1. 来看长度偏移列表,21 9e 94 14 13 0c 06翻转为正序是06 0c 13 14 94 9e 21,我们前面说过,每个字节中首位用来表示字段是否为NULL,后面7位才表示偏移值,这里需要将每个字节分成两部分(1bit | 7bit),并转化为十进制是0|6 0|12 0|19 0|20 1|20 1|30 0|33
  2. 该行中varchar类型的t2列,因为值为NULL,故而在Redundant格式中没有占用任何空间,所以我们看不到t2,t2位NULL的信息其实旨在长度偏移列表中体现了,也就是上文说到的1|20这个字节。但同样为NULL值的t3数据,除了在偏移列表中体现外,却真的占用了10个字节,可见,在Redundant格式中,varchar类型的NULL不占用空间,char类型的NULL固定占用10字节空间。
  3. 记录头信息中应该注意48位中22~32位(n_fields),为0000000111,表示表共有7个列(包含了隐藏的3列),接下去的33位(1byte_offs_flag)为1,代表偏移列表中每个偏移量占用一个字节。

当前表mytest的字符集为Latin1,每个字符最多只占用1个字节。若这里将表mytest的字符集转换为utf8,则第三列char固定长度类型就不再是只占用10个字节了,而是10×3=30个字节,Redundant行格式下char固定字符类型将会占据可能存放的最大值字节数。

2.2.1.3 行溢出数据

InnoDB存储引擎可以将一条记录中的某些数据存储在数据页之外,而不是存放在行记录所在的当前页中,这类数据就叫行溢出数据。什么情况下会出现行溢出数据呢?答案是一个页(16K)放不下的时候,一些数据必然要溢出。

于是我们可以想到BLOB、LOB这类的大对象列类型的存储,InnoDB应该会把数据存放在数据页面之外。但是,这个理解有点偏差,其实BLOB、LOB这类的大对象并不一定非要溢出,而常见的varchar类型也并不一定不会溢出。

那么什么时候会产生行溢出数据呢?这个阈值是多少呢?

前文我们说过,数据页会被InnoDB以B+树的形式给整理起来,这就要求了:一个数据页中应该至少能存两条行记录(如果一个页只能存一行,那B+树就没有意义了,数据结构就成链表了)

基于这个要求,我们知道一个页为16KB,即16384字节,那么扣除掉页中如header,tail,dictionary等固定字段外,再对半分,则可以得出一行记录不发生行溢出的最大长度上限:8098字节。

那么,如果一行记录长度超过了8098字节,InnoDB又会如何存储呢?

在一般情况下,InnoDB存储引擎的数据都是存放在页类型为B-tree node(也就是数据页)的页中。但是当发生行溢出时,数据溢出部分存放在页类型为Uncompress BLOB的页中:

假设我们创建一个列a长度为65532的表t,并插入一条数据

通过工具可以观察到表空间中有一个数据页节点B-tree Node,另外有4个未压缩的二进制大对象页Uncompressed BLOB Page,在这些页中才真正存放了65532字节的数据。既然实际存放的数据都在BLOB页中,那数据页中又存放了些什么内容呢?同样通过之前的 hexdump来读取表空间文件,从数据页c000开始查看∶

可以看到,从0x0000c093到0x000c392数据页面其实只保存了VARCHAR(65532)的前768字节的前缀(prefix)数据(这里都是a)。然后之后是行溢出页指针(20字节),指向行溢出页,也就是前面用户看到的Uncompressed BLOB Page。因此,对于行溢出数据,其存放采用下图的方式。

2.2.1.4 Compressed/Dynamic类型格式

InnoDB Plugin引入了新的文件格式(file format,可以理解为新的页格式),对于以前支持的Compact和Redundant格式将其称为Antelope文件格式,新的文件格式称为Barracuda。

Barracuda文件格式下拥有两种新的行记录格式Compressed和Dynamic两种。新的两种格式对于存放BLOB的数据采用了完全的行溢出的方式,在数据页中只存放20个字节的指针,实际的数据都存放在BLOB Page中,而之前的Compact和Redundant两种格式会存放768个前缀字节。

下图是Barracuda文件格式的溢出行:

Compressed行记录格式的另一个功能就是,存储在其中的行数据会以zlib的算法进行压缩,因此对于BLOB、TEXT、VARCHAR这类大长度类型的数据能够进行非常有效的存储。

2.2.1.5 CHAR类型字段的存储

通常理解 VARCHAR是存储变长长度的字符类型,CHAR是存储固定长度的字符类型。而在前面的小节中,用户已经了解行结构的内部的存储,并可以发现每行的变长字段长度的列表都没有存储CHAR类型的长度。

然而,值得注意的是之前给出的两个例子中的字符集都是单字节的latin1格式。从MySQL4.1版本开始,CHAR(N)中的N指的是字符的个数,而不是之前版本的字节长度。也就说在不同的字符集下,CHAR类型列内部存储的可能不是定长的数据。

例如,对于UTF-8下CHAR(10)类型的列,其最小可以存储10字节的字符(都是拉丁字母),而最大可以存储30字节的字符(10个字符都是汉字)。因此,对于多字节字符编码的CHAR数据类型的存储,InnoDB存储引擎在内部将其视为VARCHAR变长字符类型。这也就意味着在变长长度列表中会记录CHAR 数据类型的长度。

因此可以认为在多字节字符集的情况下,CHAR和VARCHAR的实际行存储基本是没有区别的。

2.2.2 数据页的存储格式

我们已经知道页是InnoDB存储引擎管理数据库的最小磁盘单位。类型为B-tree Node的页存放的即是表中行的实际数据了。页的结构如下图所示:

2.2.2.1 File Header

File Header 字段用于记录 Page 的头信息。

其中比较重要的是 FIL_PAGE_PREV 和 FIL_PAGE_NEXT 字段,它们分别是B+树叶子节点双向链表的前驱和后驱,通过这两个字段,我们可以找到该页的上一页和下一页,实际上所有页通过两个字段可以形成一条双向链表。

2.2.2.2 Page Header

Page Header 字段用于记录页的状态信息。

2.2.2.3 Infimum 和 Supremum

Infimum 和 Supremum 是两个虚拟的行记录,用来确定真实的行记录的边界。

Infimum(下确界)记录比该页中任何主键值都要小的值,Supremum (上确界)记录比该页中任何主键值都要大的值,这个虚拟记录分别构成了页中记录的边界。

2.2.2.4 User Records

User Records 中存放的是实际的数据行记录,行记录的格式,我们在上文中已经介绍过了,有compact/redundant等格式。

我们再来复习一下,不论是什么格式,行记录都有一个记录头信息部分(record header)

记录头中各个字段如下图:

值得注意的是Record Header的最后两个字节,这16 bit是next_recorder,代表下一个记录的偏移量,假设该值为0x2c,那么它表示当前记录的位置加上偏移量0x2c就是下条记录的起始位置。所以行记录在User Records中是通过一种链表的结构来串连起来的。

排序顺序一般是根据primary key升序放置。

2.2.2.5 Free Space

Free Space 中存放的是空闲空间,当一条行记录被删除后,它的空间会被加入到空闲列表中。

2.2.2.6 Page Directory

Page Directory 页目录,记录着与二叉查找相关的信息。

前面我们介绍了User Records是有序的,那么维护User Records的记录有序是为了做什么呢?没错,还是为了性能。行记录之间以链表串联,链表的查询性能是O(n),这显然是不够理想的,为了提升性能,Page Directory应运而生。

我们可以打个比方,我们在看书的时候,如果要找到某一节,而这一节我们并不知道在哪一页,我们是不是就要从前往后,一节一节地去寻找我们需要的内容的页码呢?

答案是否定的,因为在书的前面,存在目录,它会告诉你这一节在哪一页,例如,第一节在第1页、第二节在第13页。在数据库的页中,实际上也使用了这种目录的结构,这就是页目录。

那么引入页目录之后,我们所理解的页结构,就变成了这样:

页目录是一个稀疏目录,它有限的目录项会离散的指向整个User Records列表的各个锚点,比如上图的目录项1指向id=1,目录项2指向id=3。

如此一来,假设我们要寻找id=5的数据,就不需要遍历一遍整个User Records列表了,只要通过页目录(假设是[1,3,7,10,...])定位到id=9是在7-10之间,那么就可以直接跳到id=7,之后再后溯两个行,就能定位到id=9。

2.2.2.7 File Trailer

File Trailer 存储用于检测数据完整性的校验和等数据。

为了检测页是否已经完整地写人磁盘(如可能发生的写人过程中磁盘损坏、机器关机等),InnoDB存储引擎的页中设置了File Trailer部分。

File Trailer只有一个FIL_PAGE_END_LSN部分,占用8字节。前4字节代表该页的checksum值,最后4字节和File Header中的FIL_PAGELSN相同。将这两个值与File Header中的FIL_PAGE_SPACE_OR_CHKSUM和FIL_PAGELSN值进行比较,看是否一致(checksum的比较需要通过InnoDB的checksum函数来进行比较,不是简单的等值比较),以此来保证页的完整性(not corrupted)。

2.2.3 索引和页的联系

我们已经知道InnoDB的索引采用B+树来实现,B+树中的叶子节点和非叶子节点,其实它们也都是页,只不过叶子结点的页(我们称为索引页)只存放键值和指向非叶子节点(我们称为数据页)的偏移量:

如上图可以看到,page 4/5/6都是叶子节点的数据页,他们存放实际的行记录。除此以外还有存放索引键值和指针的页,比如图中page number=3的页,该页存放键值和指向数据页的指针。这只是一个实例,实际上我们完整的B+树应该长这样:

这些页都是被各种指针给逻辑地组成了一个B+树,他们实际上都离散的存放在磁盘上,也就是我们之前说的独立表空间文件中(tableName.idb文件中)。

当我们需要对某个页做读写的时候,再将某个页从磁盘载入缓冲池,缓冲池大小有限,所以会通过LRU List做淘汰机制,将不常用的页从缓冲池删除。

那么,假设现在要查找一条数据,该怎么查,比如:

select * from t1 where id=6;

在这里我们假设t1表选择自增id来做主键,这时要通过B+树来查找:

  1. 首先查找根页。一般来说,每个表的根页位置在表空间(t1.ibd)中都是不变的,在这里也就是page number=3的页,将page number=3的页载入缓冲池。

    其实一般来说,根页只要进入缓冲池,就基本上都是热点数据,很难被LRU算法淘汰掉,因为基本上所有走t1表索引的查询,都要访问t1表的根页,即便是走非聚簇索引,也会定位到聚簇索引上来。

  2. 找到根页后通过二分查找法,定位到id=6的页应该在指针P5指向的页中。

    需要牢记的是,B+树索引本身并不能找到具体的一条记录,能找到只是该记录所在的页。

  3. 如果P5指向的页(page number=5)不在缓冲池中,那么把页载入到缓冲池。

  4. 发现page number=5的页是非叶子节点了,然后通过Page Directory再进行二叉查找,即可查找到id=6的对应记录了。

    Page Directory二叉查找的时间复杂度很低,同时在缓冲池(也就是内存)中的查找很快,因此通常忽略这部分查找所用的时间。

再看一张类似的图

【InnoDB详解一】体系架构和关键特性

Posted on 2020-08-31 | In 关系型数据库 , MySQL |
Words count in article: 14.7k | Reading time ≈ 53

前言

InnoDB存储引擎最早由Innobase Oy公司°开发,被包括在MySQL数据库所有的二进制发行版本中,从MySQL5.5版本开始是默认的表存储引擎(之前的版本IlmoDB 存储引擎仅在Windows下为默认的存储引擎)。该存储引擎是第一个完整支持ACID事务的MySQL存储引擎(BDB是第一个支持事务的MySQL存储引擎,现在已经停止开发),其特点是行锁设计、支持MVCC、支持外键、提供一致性非锁定读,同时被设计用来最有效地利用以及使用内存和 CPU。

InnoBD通过有如下机制来优化性能:

  1. 缓冲池:使用缓冲池来优化读写性能,写的时候,将页从磁盘刷入缓冲池,再做修改,读的时候,读缓冲池的页,脏数据通过异步适时的刷回磁盘。
  2. 后台线程:使用后台线程来减少对用户线程的阻塞。
  3. 插入缓冲:使用插入缓冲(Insert Buffer)机制来优化非唯一性索引的写性能,使其在缓冲池技术的基础上再少一次磁盘IO。
  4. 两次写:使用两次写(doublewrite)机制来确保数据页从内存刷新到硬盘时如果中途宕机,则仍可以通过数据页副本来恢复该数据页,保证数据页向磁盘flush过程的可靠性。
  5. 自适应哈希索引:通过自适应哈希索引(Adaptive Hash Index,AHI)机制来对缓冲中高频热点的B+树索引页自动建立哈希索引,以替代等值查询,优化查询性能。
  6. 异步IO:通过异步IO(Asynchronous IO,AIO)机制,将read ahead方式的读取,磁盘的写入,数据的恢复等诸多操作通过异步来处理,提高处理效率。
  7. 刷新邻接页:通过刷新邻接页(Flush Neighbor Page)机制,可以在flush数据页进入缓存的时候,顺序将该页所在区(extent)的所有脏页一起flush,将本该多次IO的操作合并一次完成,该机制在传统机械硬盘场景中性能提升明显。

InnoDB存储引擎已经被许多大型网站使用,如用户熟知的Google、Yahoo!、Facebook、YouTube、Flickr,在网络游戏领域有《魔兽世界》、《Second Life》、《神兵玄奇》等。

1 InnoDB的体系架构

下图2简单显示了InoDB的存储引擎的体系架构,从图可见,InnoDB存储引擎有多个内存块,可以认为这些内存块组成了一个大的内存池,负责如下工作∶

  • 维护所有进程/线程需要访问的多个内部数据结构。
  • 缓存磁盘上的数据,方便快速地读取,同时在对磁盘文件的数据修改之前在这里缓存。
  • 重做日志(redo log)缓冲。

后台线程的主要作用是负责刷新内存池中的数据,保证缓冲池中的内存缓存的是最近的数据。此外将已修改的数据文件刷新到磁盘文件,同时保证在数据库发生异常的情况下 InnoDB 能恢复到正常运行状态。

1.1 InnoDB的后台线程

InnoDB存储引擎是多线程的模型,因此其后台有多个不同的后台线程,负责处理不同的任务

1.1.1 Master Thread

Master Thread是一个非常核心的后台线程,主要负责将缓冲池中的数据异步刷新到磁盘,保证数据的一致性,包括脏页的刷新、合并插入缓冲(INSERTBUFER)、UNDO页的回收等。

Master Thread具有最高的线程优先级别。其内部由多个循环(loop)组成∶主循环(loop)、后台循环(backgroup loop)、刷新循环(flush loop)、暂停循环(suspend loop)。Master Thread会根据数据库运行的状态在loop、background loop、flush loop和suspend loop之间切换。

主循环(loop)

Loop被称为主循环,因为大多数的操作是在这个循环中,其中有两大部分的操作———每秒钟的操作和每10秒的操作。伪代码如下∶

1
2
3
4
5
6
7
8
9
void master thread(){
loop:
for(int i= 0;i<10;i++){
do thing once per second;
sleep 1 second if necessary;
}
do things once per ten seconds
goto loop;
}

可以看到,loop循环通过 thread sleep来实现,这意味着所谓的每秒一次或每10秒一次的操作是不精确的。在负载很大的情况下可能会有延迟(delay),只能说大概在这个频率下。当然,InnoDB源代码中还通过了其他的方法来尽量保证这个频率。

每秒一次的操作包括:(有概念尚不清晰的,后文会做详解)

  1. 日志缓冲刷新到磁盘,即使这个事务还没有提交(总是);
    • 即使某个事务还没有提交,InoDB存储引擎仍然每秒会将重做日志缓冲中的内容刷新到重做日志文件。这一点是必须要知道的,因为这可以很好地解释为什么再大的事务提交(commit)的时间也是很短的。
  2. 合并插入缓冲(可能);
    • 合并插入缓冲(Insert Buffr)并不是每秒都会发生的。InnoDB存储引擎会判断当前一秒内发生的IO次数是否小于5次,如果小于5次,InoDB认为当前的IO压力很小,可以执行合并插人缓冲的操作。
  3. 至多刷新100个InnoDB的缓冲池中的脏页到磁盘(可能);
    • 同样,刷新100个脏页也不是每秒都会发生的。InoDB存储引擎通过判断当前缓冲池中脏页的比例(buf get_modified_ratio pct)是否超过了配置文件中inodbmax_dirtypages pet这个参数(默认为90,代表90%),如果超过了这个阈值,InoDB存储引擎认为需要做磁盘同步的操作,将100个脏页写人磁盘中。
  4. 如果当前没有用户活动,则切换到 background loop(可能)。

每10秒一次的操作包括:(有概念尚不清晰的,后文会做详解)

  1. 刷新100个脏页到磁盘(可能的情况下);
    • 在以上的过程中,InnoDB存储引擎会先判断过去10秒之内磁盘的IO操作是否小于200次,如果是,InnoDB存储引擎认为当前有足够的磁盘IO操作能力,因此将100 个脏页刷新到磁盘。
  2. 合并至多5个插人缓冲(总是);
    • 接着,InnoDB存储引擎会合并插入缓冲。不同于每秒一次操作时可能发生的合并插入缓冲操作,这次的合并插入缓冲操作总会在这个阶段进行。
  3. 将日志缓冲刷新到磁盘(总是);
    • 之后,InoDB存储引擎会再进行一次将日志缓冲刷新到磁盘的操作。这和每秒一次时发生的操作是一样的。
  4. 删除无用的Undo 页(总是);
    • 接着InnoDB存储引擎会进行一步执行full purge操作,即删除无用的Undo 页。对表进行update、delete这类操作时,原先的行被标记为删除,但是因为一致性读(consistent read)的关系,需要保留这些行版本的信息。
    • 但是在full purge过程中,InoDB存储引擎会判断当前事务系统中已被删除的行是否可以删除,比如有时候可能还有查询操作需要读取之前版本的undo信息,如果可以删除,InnoDB会立即将其删除。
  5. 刷新100个或者10个脏页到磁盘(总是)。
    • 然后,InnoDB存储引擎会判断缓冲池中脏页的比例(buf get_modified_ratio pct),如果有超过70%的脏页,则刷新100个脏页到磁盘,如果脏页的比例小于70%,则只需刷新10%的脏页到磁盘。

后台循环(backgroup loop)

接着来看background loop,若当前没有用户活动(数据库空闲时)或者数据库关闭(shutdown),就会切换到这个循环。background loop 会执行以下操作∶

  1. 删除无用的 Undo 页(总是);
  2. 合并20个插人缓冲(总是);
  3. 如果当前数据库还是空闲,则跳回到主循环,否则进入flush loop(总是);

刷新循环(flush loop)
刷新循环只做一件事,每次刷新一百个页到磁盘,不断循环直到缓冲池中的脏页比例小于等于innodb_max_dirty_pages_pct的值(默认90)

  1. 不断刷新100个页直到符合条件(可能,跳转到flush loop中完成)。

暂停循环(suspend_loop)

若flush loop中也没有什么事情可以做了,InnoDB存储引擎会切换到suspend_loop,将Master Thread挂起,等待事件的发生。若用户启用(enable)了InnoDB存储引擎,却没有使用任何InnoDB存储引擎的表,那么Master Thread总是处于挂起的状态。

上述核心逻辑是MySQL 1.0.x版本之前的逻辑,在1.0.x版本和1.2.x版本中,Master Thread两次引入了更新

1.0.x版本的改动:

  1. 磁盘技术的快速发展中,对于缓冲池向磁盘刷新时都做了一定的hard coding,这些限制很大程度上限制了InnoDB存储引擎对磁盘IO的性能,尤其是写入性能。因此提供参数innodb_io_capacity用来表示IO的吞吐量,默认200,对于刷新到磁盘页的数量,会按照innodb_io_capacity的百分比来控制:
    • 并插入缓冲时,合并插入缓冲的数量为innodb_io_capacity值的5%;
    • 缓冲池刷新脏页时,刷行脏页的数量为innodb_io_capcity;
  2. 脏页比例参数innodb_max_dirty_pages_pct为90太大了。新版本将其改为了75。
  3. innodb_adaptive_flushing参数的引入,该值影响每秒刷新脏页的数量。
    • 原来的刷新规则是∶脏页在缓冲池所占的比例小于innodb_max_dirty pages pect时,不刷新脏页;大于inodb maxdirtypages_pct时,刷新100个脏页。
    • 随着innodb_adaptive flushing参数的引入,InnoDB存储引擎会通过一个名为buf_flush get_desired_fush_rate的函数来判断需要刷新脏页最合适的数量。
    • 粗略地翻阅源代码后发现 buf flush get desired_fush rate通过判断产生重做日志(redo log)的速度来决定最合适的刷新脏页数量。因此,当脏页的比例小于inodb_max_dirtypages_pct时,也会刷新一定量的脏页。
  4. 引入参数innodb_purge_batch_size
    • 之前每次进行 full purge操作时,最多回收20个Undo页
    • 从InnoDB 1.0.x版本开始引人了参数,该参数可以控制每次 full purge回收的Undo页的数量。该参数的默认值为20,并可以动态地对其进行修改

1.2.x版本的改动:

对于刷新脏页的操作,从Master Thread 线程分离到一个单独的Page Cleaner Thread,从而减轻了Master Thread的工作,同时进一步提高了系统的并发性。

整体伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
void master_thread(){
goto loop;
loop:
for (int i=0;i<10;i++){
thread_sleep(1) //sleep 1 second-->每秒执行操作(负载在情况下会延迟)
do log buffer flush to disk //重做日志缓冲刷新到磁盘,即使这个事务没有提交(总是)
if ( last_ten_second_ios < 5% innodb_io_capacity) //如果当前的10次数小于(5% * 200=10)(innodb_io_capacity默认值是200)
do merger 5% innodb_io_capacity insert buffer //执行10个合并插入缓冲的操作(5% * 200=10)
if ( buf_get_modified_ratio_pct > innodb_max_dirty_pages_pct ) //如果缓冲池中的脏页比例大于innodb_max_dirty_pages_pct(默认是75时)
do buffer pool plush 100% innodb_io_capacity dirty page //刷新200个脏页到磁盘
else if enable adaptive flush //如果开户了自适应刷新
do buffer pool flush desired amount dirty page //通过判断产生redo log的速度决定最合适的刷新脏页的数量
if ( no user activity ) //如果当前没有用户活动
goto backgroud loop //跳到后台循环
}

//每10秒执行的操作
if ( last_ten_second_ios < innodb_io_capacity) //如果过去10内磁盘IO次数小于设置的innodb_io_capacity的值(默认是200)
do buffer pool flush 100% innodb_io_capacity dirty page //刷新脏页的数量为innodb_io_capacity的值(默认是200)
do merger 5% innodb_io_capacity insert buffer //合并插入缓冲是innodb_io_capacity的5%(10)(总是)
do log buffer flush to disk //重做日志缓冲刷新到磁盘,即使这个事务没有提交(总是)
do full purge //删除无用的undo页 (总是)
if (buf_get_modified_ratio_pct > 70%) //如果缓冲池中的胜页比例大于70%
do buffer pool flush 100% innodb_io_capacity dirty page //刷新200个脏页到磁盘
else
do buffer pool flush 10% innodb_io_capacity dirty page //否则刷新20个脏页到磁盘
goto loop
backgroud loop: //后台循环
do full purge //删除无用的undo页 (总是)
do merger 5% innodb_io_capacity insert buffer //合并插入缓冲是innodb_io_capacity的5%(10)(总是)
if not idle: //如果不空闲,就跳回主循环,如果空闲就跳入flush loop
goto loop: //跳到主循环
else:
goto flush loop
flush loop: //刷新循环
do buf_get_modified_ratio_pct pool flush 100% innodb_io_capacity dirty page //刷新200个脏页到磁盘
if ( buf_get_modified_ratio_pct > innodb_max_dirty_pages_pct ) //如果缓冲池中的脏页比例大于innodb_max_dirty_pages_pct的值(默认75%)
goto flush loop //跳到刷新循环,不断刷新脏页,直到符合条件
goto suspend loop //完成刷新脏页的任务后,跳入suspend loop
suspend loop:
suspend_thread() //master线程挂起,等待事件发生
waiting event
goto loop;
}

1.1.2 IO Thread

在InnoDB存储引擎中大量使用了AIO(Async IO,异步IO)来处理写IO请求,这样可以极大提高数据库的性能。而IO Thread的工作主要是负责这些IO请求的回调(callback)处理。

InnoDB1.0版本之前共有4个IO Thread,分别是write、read、insert buffer和log IO thread。在Linux平台下,IO Thread的数量不能进行调整,但是在Windows平台下可以通过参数innodb file_io_threads来增大IO Thread。

从InnoDB1.0x版本开始,read thread和 write thread分别增大到了4个,并且不再使用innodb_file io threads参数,而是分别使用innodb_read_io threads和inodb_write io threads参数进行设置,如∶

可以通过命令SHOW ENGINE INNODB STATUS来观察 InnoDB中的IO Thread∶

可以看到IO Thread0为insert buffer thread。IO Thread1为log thread。之后就是根据参数innodb_readio threads及innodb_write_io threads来设置的读写线程,并且读线程的 ID总是小于写线程。

1.1.3 purge Thread

事务被提交后,其所使用的undo log可能不再需要,因此需要PurgeThread来回收已经使用并分配的undo页。

在InnoDB 1.1版本之前,purge操作仅在InnoDB存储引擎的Master Thread中完成。而从InoDB 1.1版本开始,purge操作可以独立到单独的线程中进行,以此来减轻Master Thread的工作,从而提高CPU的使用率以及提升存储引擎的性能。

用户可以在 MySQL数据库的配置文件中添加如下命令来启用独立的Purge Thread:

1
2
[mysqld]
innodb_purge_threads=1

从InnoDB 1.2版本开始,InnoDB 支持多个Purge Thread,这样做的目的是为了进一步加快undo页的回收。同时由于Purge Thread需要离散地读取undo页,这样也能更进一步利用磁盘的随机读取性能。如用户可以设置4个Purge Thread∶

1.1.4 Page Cleaner Thread

Page Cleaner Thread是在InnoDB1.2x版本中引人的。其作用是将之前版本中脏页的刷新操作都放入到单独的线程中来完成。而其目的是为了减轻原Master Thread的工作及对于用户查询线程的阻塞,进一步提高InnoDB存储引擎的性能。

1.2 InnoDB的内存

1.2.1 缓冲池

InnoDB存储引擎是基于磁盘存储的,并将其中的记录按照页的方式进行管理。因此可将其视为基于磁盘的数据库系统(Disk-base Database)。在数据库系统中,由于CPU 速度与磁盘速度之间的鸿沟,基于磁盘的数据库系统通常使用缓冲池技术来提高数据库的整体性能。

缓冲池简单来说就是一块内存区域,通过内存的速度来弥补磁盘速度较慢对数据库性能的影响。在数据库中进行读取页的操作,首先将从磁盘读到的页存放在缓冲池中,这个过程称为将页”FIX”在缓冲池中。下一次再读相同的页时,首先判断该页是否在缓冲池中。若在缓冲池中,称该页在缓冲池中被命中,直接读取该页。否则,读取磁盘上的页。

对于数据库中页的修改操作,则首先修改在缓冲池中的页,然后再以一定的频率刷新到磁盘上。这里需要注意的是,页从缓冲池刷新回磁盘的操作并不是在每次页发生更新时触发,而是通过一种称为Checkpoint的机制刷新回磁盘。同样,这也是为了提高数据库的整体性能。

对于InnoDB存储引擎而言,其缓冲池的配置通过参数innodb_buffer poolsize来设置。下面显示一台 MySQL数据库服务器,其将InnoDB存储引擎的缓冲池设置为15GB。

具体来看,缓冲池中缓存的数据页类型有∶索引页、数据页、undo页、插入缓冲(insert buffer)、自适应哈希索引(adaptive hash index)、InnoDB存储的锁信息(lock info)、数据字典信息(data dictionary)等。不能简单地认为,缓冲池只是缓存索引页和数据页,它们只是占缓冲池很大的一部分而已。下图很好地显示了InnoDB存储引擎中内存的结构情况。

从InnoDB1.0.x版本开始,允许有多个缓冲池实例。每个页根据哈希值平均分配到不同缓冲池实例中。这样做的好处是减少数据库内部的资源竞争,增加数据库的并发处理能力。实例数量可以通过参数innodb_buffer_pool_instances来进行配置,该值默认为1:

从 MySQL5.6版本开始,还可以通过information_schema架构下的表INNODB_BUFFER_POOL_STATS来观察缓冲的状态,如运行下列命令可以看到各个缓冲池的使用状态∶

1.2.2 LRU/Free/Flush List

在前一章节中我们知道了缓冲池是一个很大的内存区域,其中存放各种类型的页,一个页的大小默认为16KB,即缓冲池中会存在大量16KB的数据页结构。那么InnoDB存储引擎是怎么对这么大的内存区域进行管理的呢?

1.2.2.1 LRU List

通常来说,数据库中的缓冲池是通过LRU(Latest Recent Used,最近最少使用)算法来进行管理的。即最频繁使用的页在LRU列表的前端,而最少使用的页在LRU列表的尾端。当缓冲池不能存放从磁盘新读取到的页时,将首先释放LRU列表中尾端的页。

在InnoDB存储引擎中,缓冲池中页的大小默认为16KB,同样使用LRU算法对缓冲池进行管理。稍有不同的是InoDB存储引擎对传统的LRU算法做了一些优化。在InoDB的存储引擎中,LRU列表中还加入了midpoint位置。在默认配置下,该位置在LRU列表长度的5/8处。midpoint位置可由参数inodb old blocks pct控制,如∶

从上面的例子可以看到,参数 innodb oldblocks pect默认值为37。表示新读取的页插入到LRU列表尾端的37%的位置(差不多3/8的位置)。

在InnoDB存储引擎中,把midpoint 之后的列表称为old列表,之前的列表称为new列表。可以简单地理解为new 列表中的页都是最为活跃的热点数据。

当用户需要访问数据时,InnoDB首先会在InnoDB缓冲池查找数据,如果缓冲池中没有数据时,InnoDB会查询硬盘上的数据,并将缓冲池中生成新的页;如果InnoDB缓冲池已满,InnoDB通过LRU算法清除InnoDB缓存池中个别数据块。

每当有新数据块需要加载到InnoDB缓冲池中时,该数据块应变为‘‘数据页’’被插到midpoint的位置,并声明为old数据页。这个算法在InnoDB存储引擎下称为midpoin insertion strategy。

那么old链表中的数据页什么时候能移动到new链表中呢?参数InnoDB_old_blocks_time可以控制这个时间:

  1. 当InnoDB_old_blocks_time的参数值设置为0时。当old部分的数据页被访问到时,该数据页会被提升到链表的头部,并被标记为new数据页。
  2. 当InnoDB_old_blocks_time的参数值大于0时(以1000毫秒或者1秒为例)。old部分数据页插入缓冲池后,1秒之后再次被访问,则该数据页会被提升到链表的头部,并被标记为new数据页。在刚插入到一秒内,即便old部分的数据页被访问,该数据页也不会移动到new链表的头部。

那为什么不采用朴素的LRU算法,直接将最近被sql访问的页放入到LRU列表的首部呢?

这是因为若直接将最近被访问到的页放入到LRU的首部,那么某些SQL操作可能会使热点的页被顶到靠后的位置去,从而影响LRU List的效率。

常见的这类操作为索引或数据的扫描操作。这类操作需要访问表中的许多页,甚至是全部的页,而这些页通常来说又仅在这次查询操作中需要,并不是活跃的热点数据。如果页被放入LRU列表的首部,那么非常可能将原本在队首的热点数据页顶到队尾,甚至因为内存空间原因从LRU列表中移除,导致在下一次需要读取该页时,InnoDB存储引擎需要再次访问磁盘。

1.2.2.2 Free List

LRU列表用来管理已经读取的页,但当数据库刚启动时,LRU列表是空的,即没有任何的页。这时页都存放在Free List中。当需要在缓冲池中划分数据页时,首先从Free列表中查找是否有可用的空闲页。

  • 若有,则用磁盘中读取的数据填充该页,并将该页从Free列表中移动到LRU列表中。

  • 若没有,则根据LRU算法,淘汰LRU列表末尾的页,将该内存空间分配给新的页。

1.2.2.3 Flush List

在LRU列表中的页被修改后,称该页为脏页(dirty page),即缓冲池中的页和磁盘上的页的数据产生了不一致。这时数据库会通过CHECKPOINT机制将脏页刷新回磁盘,而Flush列表中的页即为脏页列表。

需要注意的是,脏页既存在于LRU列表中,也存在于 Flush列表中。LRU列表用来管理缓冲池中页的可用性,Flush列表用来管理将页刷新回磁盘,二者互不影响。

1.2.2.4 List状态查看

可以通过命令SHOW ENGINE INNODB STATUS来观察LRU列表,Free列表和Flush列表的使用情况和运行状态。

当页从LRU列表的old部分加入到new部分时,称此时发生的操作为page made young,而因为innodb old_blocks time的设置而导致页没有从old部分移动到new部分的操作称为page not made young。

通过命令SHOW_ENGINE_INNODB_STATUS可以看到∶当前Buffer_pool_size共有327679个页,即327679*16K,总共5GB的缓冲池。

Free buffers表示当前Free列表中页的数量,Database pages表示LRU列表中页的数量。可能的情况是Free buffers与Database pages的数量之和不等于Buffer pool size。正如之前所说的那样,因为缓冲池中的页还可能会被分配给自适应哈希索引、Lock信息、Insrt Buffer等页,而这部分页不需要LRU算法进行维护,因此不存在于LRU列表中。

pages made young 显示了LRU列表中页从old端移动到new端的次数,因为该服务器在运行阶段没有改变inodb old blocks_time的值,因此not young为0。

youngs/s、non-youngs 表示每秒这两类操作的次数。

Modifed db pages24673就显示了Flush List中脏页的数量。

这里还有一个重要的观察变量——Buffer pool hit rate,表示缓冲池的命中率,这个例子中为100%,说明缓冲池运行状态非常良好。通常该值不应该小于95%。若发生Bufer pool hit rate的值小于95%这种情况,用户需要观察是否是由于全表扫描引起的LRU 列表被污染的问题。

1.2.3 重做日志缓冲

在看上图,InnoDB存储引擎的内存区域除了有缓冲池外,还有重做日志缓冲(redo log buffer)。InoDB存储引擎首先将重做日志信息先放入到这个缓冲区,然后按一定频率将其刷新到重做日志文件。重做日志缓冲一般不需要设置得很大,因为一般情况下每一秒钟会将重做日志缓冲刷新到日志文件,因此用户只需要保证每秒产生的事务量在这个缓冲大小之内即可。该值可由配置参数 innodb_log buffrsize控制,默认为8MB:

在通常情况下,8MB的重做日志缓冲池足以满足绝大部分的应用,因为重做日志在下列三种情况下会将重做日志缓冲中的内容刷新到外部磁盘的重做日志文件中。

  • Master Thread每一秒将重做日志缓冲刷新到重做日志文件;
  • 每个事务提交时会将重做日志缓冲刷新到重做日志文件;
  • 当重做日志缓冲池剩余空间小于1/2时,重做日志缓冲刷新到重做日志文件。

1.2.4 额外的内存池

额外的内存池通常被DBA忽略,他们认为该值并不十分重要,事实恰恰相反,该值同样十分重要。在InnoDB存储引擎中,对内存的管理是通过一种称为内存堆(heap)的方式进行的。在对一些数据结构本身的内存进行分配时,需要从额外的内存池中进行申请,当该区域的内存不够时,会从缓冲池中进行申请。

例如,分配了缓冲池(innodb_buffer_pool),但是每个缓冲池中的帧缓冲(fame buffer)还有对应的缓冲控制对象(buffer control block),这些对象记录了一些诸如LRU、锁、等待等信息,而这个对象的内存需要从额外内存池中申请。因此,在申请了很大的InnoDB缓冲池时,也应考虑相应地增加这个值。

2 InnoDB的关键特性

InnoDB存储引擎的关键特性包括∶

  • Checkpoint技术
  • 插入缓冲(Insert Buffer)
  • 两次写(Double Write)
  • 自适应哈希索引(Adaptive Hash Index)
  • 异步IO(Async IO)
  • 刷新邻接页(Flush Neighbor Page)

上述这些特性为InnoDB存储引擎带来更好的性能以及更高的可靠性。

2.1 Checkpoint技术

前面已经讲到了,缓冲池的设计目的为了协调 CPU速度与磁盘速度的鸿沟。因此页的操作首先都是在缓冲池中完成的。如果一条 DML语句,如 Update或Delete改变了页中的记录,那么此时页是脏的,即缓冲池中的页的版本要比磁盘的新。数据库需要将新版本的页从缓冲池刷新到磁盘。

刷新到磁盘的操作,就是Checkpoint。

倘若每次一个页发生变化,就将新页的版本刷新到磁盘,那么这个开销是非常大的。若热点数据集中在某几个页中,那么数据库的性能将变得非常差。同时,如果在从缓冲池将页的新版本刷新到磁盘时发生了宕机,那么数据就不能恢复了。为了避免发生数据丢失的问题,当前事务数据库系统普遍都采用了Write Ahead Log策略,即当事务提交时,先写重做日志,再修改页。当由于发生宕机而导致数据丢失时,通过重做日志来完成对未刷新到硬盘的数据的恢复。这也是事务 ACID中D(Durability持久性)的要求。

既然不能每次一个页发生变化,就将新页的版本刷新到磁盘,那么,什么时候将脏页数据刷新到硬盘是合适的呢?先不谈我们应该以什么频率进行一次Checkpoint,我们先来谈什么时候必须要Checkpoint(否则会导致 缓冲池+重做日志 机制出问题)

  1. 当缓冲池不够用时:
    • 当缓冲池不够用时,根据LRU算法会清除最近最少使用的页,如果此页为脏页,那么需要强制执行Checkpoint,将脏页也就是页的新版本刷回磁盘。
  2. 重做日志出现不可用/不够用时:
    • 因为当前事务数据库系统对重做日志的设计都是循环使用的,并不是让其无限增大的,这从成本及管理上都是比较困难的。重做日志中记录的已经被flush到磁盘中的部分,我们就认为它是可覆盖重用的。如果重做日志空间中没有可重用的部分,即目前重用日志记录的都是未flush到磁盘的数据,那么必须强制Checkpoint,使得部分重做日志变为可重用。

2.1.1 LSN

对于InnoDB存储引擎而言,是通过LSN(Log Sequence Number)来标记版本的。LSN是一个一直递增的8字节整型数字,表示事务写入到redo日志的字节总量(注意LSN的含义是日志的字节总量)。每个页都有LSN字段,重做日志中也有LSN,Checkpoint也有LSN。

在每个数据页头部的LSN字段,记录当前页最后一次数据修改所对应的重做日志的LSN值,用于在recovery时对比重做日志LSN值,以决定是否对该页进行恢复数据。前面说的checkpoint也是有LSN号记录的,checkpoint的LSN表示已刷新到磁盘的最新的数据所对应的重做日志的LSN,LSN号串联起一个事务开始到恢复的过程。

比如重做日志的文件是600M,LSN的值已经为1G了,也就是LSN=1000000000。因为重做日志是循环使用的,所以我们可以知道LSN=1G=600M+400M,所以重做日志已经重复使用过一整遍后,目前最新的可写入点,在重做日志偏移量400M的位置。

我们执行了一个update语句,产生了一个事务t,这次数据的修改,假设产生了512个字节的日志量,那么LSN就会增加到1000000512,而事务t的修改使得A、B、C三个数据页成为了脏页,那么A、B、C三个数据页的LSN值就会更新为1000000512。如果这时,触发了checkpoint,刚刚好将事务t为止的修改刷新到磁盘,那么此时checkpoint LSN也是1000000512。

可以通过命令SHOW ENGINE INNODB STATUS来观察∶

2.1.2 Checkpoint发生的时机

在InnoDB存储引擎中,Checkpoint发生的时间、条件及脏页的选择等都非常复杂。而Checkpoint所做的事情无外乎是将缓冲池中的脏页刷回到磁盘。不同之处在于每次刷新多少页到磁盘,每次从哪里取脏页,以及什么时间触发Checkpoint。

在InnoDB存储引擎内部,有两种Checkpoint,分别为∶

  1. Sharp Checkpoint
    • Sharp Checkpoint发生在数据库关闭时将所有的脏页都刷新回磁盘,这是默认的工作方式,即参数 innodb_fast_shutdown=1。
  2. Fuzy Checkpoint
    • 若数据库在运行时也使用Sharp Checkpoint,那么数据库的可用性就会受到很大的影响。故在InnoDB存储引擎内部使用Fuzzy Checkpoint 进行页的刷新,即只刷新一部分脏页,而不是刷新所有的脏页回磁盘。

在InnoDB存储引擎中可能发生如下几种情况的Fuzzy Checkpoint:

  1. Master Thread Checkpoint
  2. FLUSH_LRU_LIST Checkpoint
  3. Async/Sync Flush Checkpoint
  4. Dirty Page too much Checkpoint

2.1.2.1 Master Thread Checkpoint

对于Master Thread中发生的Checkpoint,差不多以每秒或每十秒的速度从缓冲池的脏页列表中刷新一定比例的页回磁盘。这个过程是异步的,即此时InnoDB存储引擎可以进行其他的操作,用户查询线程不会阻塞。

2.1.2.2 FLUSH_LRU_LIST Checkpoint

FLUSH_LRU_LIST Checkpoint是因为InoDB存储引擎需要保证LRU列表中需要有差不多100个空闲页可供使用。在InoDB1.1.x版本之前,需要检查LRU列表中是否有足够可用空间的操作发生在用户查询线程中,显然这会阻塞用户的查询操作。

倘若没有100个可用空闲页,那么InoDB存储引擎会将LRU列表尾端的页移除。如果要移除的这些页中有脏页,那么需要进行Checkpoint,而这些页是来自LRU和FLUSH列表的,因此称为FLUSH_LRU_LIST Checkpoint。

而从MySQL5.6版本,也就是InnoDB1.2.x版本开始,这个检查被放在了一个单独的Page Cleaner线程中进行,并且用户可以通过参数innodb_Iru_scan_depth控制LRU列表中可用页的数量,该值默认为1024。

2.1.2.3 Async/Sync Flush Checkpoint

Async/Sync Flush Checkpoint指的是重做日志文件不可用的情况,这时需要强制将一些页刷新回磁盘,而此时脏页是从脏页列表中选取的。

若将已经写入到重做日志的LSN记为redo_lsn,将已经刷新回磁盘最新页的LSN记为checkpoint_lsn,则可定义:

checkpoint_age = redo_lsn - checkpoint_lsn,表示重做日志中还有多少个字节量的数据没有刷新到磁盘。

再定义以下的变量:

  1. async_water_mark = 75% * total_redo_log_file_size
  2. sync_water_mark = 90% * total_redo_log_file_size

若每个重做日志文件的大小为1GB,并且定义了两个重做日志文件,则重做日志文件的总大小为2GB。那么async_water_mark=1.5GB,sync_water_mark=1.8GB。则:

  1. 当checkpoint_age<async_water_mark时,不需要刷新任何脏页到磁盘;
  2. 当async_water_mark<checkpoint_age<sync_water_mark时触发Async Flush,从Flush列表中刷新足够的脏页回磁盘,使得刷新后满足checkpoint_age<async_water_mark;
  3. 当checkpoint_age>sync_water_mark(这种情况一般很少发生,除非设置的重做日志文件太小,并且在进行类似LOAD DATA的BULK INSERT操作),此时触发Sync Flush操作,从Flush列表中刷新足够的脏页回磁盘,使得刷新后满足checkpoint_age<async_water_mark。

可见,Async/Sync Flush Checkpoint是为了保证重做日志的循环使用的可用性。在InnoDB 1.2.x版本之前,Async Flush Checkpoint会阻塞发现问题的用户查询线程,而Sync Flush Checkpoint会阻塞所有的用户查询线程,并且等待脏页刷新完成。从InnoDB 1.2.x版本开始——也就是MySQL 5.6版本,这部分的刷新操作同样放入到了单独的Page Cleaner Thread中,故不会阻塞用户查询线程。

2.1.2.4 Dirty Page too much Checkpoint

即脏页的数量太多,导致InnoDB存储引擎强制进行Checkpoint。其目的总的来说还是为了保证缓冲池中有足够可用的页。其可由参数innodb_max_dirty_pages_pct控制:

innodb_max_dirtypages_pct值为75表示,当缓冲池中脏页的数量占据75%时,强制进行Checkpoint,刷新一部分的脏页到磁盘。在InnoDB 1.0.x版本之前,该参数默认值为90,之后的版本都为75。

2.2 插入缓冲

插入缓冲本质上是对于为非唯一索引而言的,即对辅助索引的修改操作并非实时更新磁盘中索引的叶子页(索引存于该表的ibd文件中),而是把若干对同一页面的更新缓存起来做,合并为一次性更新操作,减少IO,转随机IO为顺序IO,这样可以避免随机IO带来性能损耗,提高数据库的写性能

2.2.1 Insert Buffer

Insert Buffer可能是InnoDB存储引擎关键特性中最令人激动与兴奋的一个功能。insert buffer是一种特殊的数据结构(B+ tree)并不是缓存的一部分,而是物理页。

在InoDB存储引擎中,主键是行唯一的标识符。通常应用程序中行记录的插人顺序是按照主键递增的顺序进行插入的。因此,插入聚集索引(Primary Key)一般是顺序的,不需要磁盘的随机读取。

但一个表除了聚集索引外,还可能定义辅助索引,我们知道InnoDB中辅助索引是非聚集的。假设我们有一张表t,其中主键是id字段,除此之外还在name字段上面建了一个辅助索引。那么我们在表t每插入一条数据,都需要在id聚集索引树和name非聚集索引上新增索引节点。

  • 前面说过,因为表t的插入顺序就是按照主键自增的,而id聚集索引又是按照id排序的,所以在id聚集索引上新增节点十分方便,只要在顺序插入即可,性能很高。

  • 而在name非聚集索引上新增索引节点,因为表t记录的插入顺序按照id自增的顺序,不是按照name自增的顺序,但name非聚集索引又是按照name字段顺序排列的,所以表t的每次插入,都需要在name非聚集索引上离散的插入新的索引节点,随机IO的消耗太大,性能十分蛋疼。

为了应对这种情况,InnoDB存储引擎开创性地设计了Insert Buffer,对于非聚集索引的插入或更新操作,不是每一次直接插入到索引页中,而是先判断插入的非聚集索引页是否在缓冲池中,若在,则直接插入;若不在,则先放入到一个Insert Buffer对象中。

好似欺骗数据库这个非聚集的索引已经插到叶子节点了,而实际并没有,只是存放在另一个位置。然后再以Master Thread的调度规则进行Insert Buffer和辅助索引页子节点的merge(合并)操作,这时通常能将多个插入合并到一个操作中(因为插入的都是在一个索引页中),这就大大提高了对于非聚集索引插入的性能。

然而Insert Buffer的使用需要同时满足以下三个条件∶

  1. 修改的非聚集索引页不在缓冲池中
    • 因为如果在缓冲池中,直接改缓冲池就行了,改内存不比改磁盘,没有什么顺序IO/随机IO的性能差异。
  2. 索引是辅助索引(secondary index);
    • 因为聚集索引的性能很好,不需要用到Insert Buffer。
  3. 辅助索引不是唯一(unique)的。
    • 因为如果辅助索引是唯一的,那么在插入辅助索引树前,要先判断插入的值是否已经在树中重复了,查询操作又是随机IO。
    • 本来Insert Buffer就是为了避免随机IO,既然唯一性辅助索引的插入避免不了随机IO,那Insert Buffer也就没有什么意义了。

2.2.2 Change Buffer

InnoDB从1.0.x版本开始引入了Change Buffer,可将其视为Insert Buffer的升级。从这个版本开始,InnoDB存储引擎可以对DML操作——INSERT、DELETE、UPDATE 都进行缓冲,他们分别是∶Insert Buffer、Delete Buffer、Purge buffer。

当然和之前Insert Buffer一样,Change Buffer适用的对象依然是非唯一的辅助索引。

同时,InnoDB存储引擎提供了参数innodb_change_buffering,用来开启各种Buffer的选项。该参数可选的值为∶inserts、deletes、purges、changes、all、none。

inserts、deletes、purges就是前面讨论过的三种情况。changes表示启用inserts和deletes,all表示启用所有,none表示都不启用。该参数默认值为 all。

从InnoDB1.2.x版本开始,可以通过参数innodb_change_buffr_max_size来控制Change Buffer最大使用内存的数量∶

innodb_change_buffer_max_size值默认为25,表示最多使用1/4的缓冲池内存空间。而需要注意的是,该参数的最大有效值为 50。

2.2.3 Insert Buffer的内部实现

Insert Buffer具体是什么呢,内部怎么实现呢?

可能令绝大部分用户感到吃惊的是,Insert Buffer的数据结构是一棵B+树。在MySQL 4.1之前的版本中每张表有一棵Insert Buffer B+树。而在现在的版本中,全局只有一棵Insert Buffer B+树,负责对所有的表的辅助索引进行Insert Buffer。而这棵B+树存放在共享表空间中,默认也就是ibdata1中。

因此,试图通过独立表空间ibd文件恢复表中数据时,往往会导致CHECK TABLE失败。这是因为表的辅助索引中的数据可能还在Insert Buffer中,也就是共享表空间中,所以通过ibd文件进行恢复后,还需要进行REPAIR TABLE 操作来重建表上所有的辅助索引。

Insert Buffer是一棵B+树,因此其也由叶节点和非叶节点组成。非叶节点存放的是查询的search key(键值),其构造如下图所示。

search key一共占用9个字节,其中

  • space表示待插入记录所在表的表空间id,在InnoDB存储引擎中,每个表有一个唯一的 space id,可以通过 space id查询得知是哪张表。space占用4字节。
  • marker占用1字节,它是用来兼容老版本的Insert Buffer。
  • offset 表示页所在的偏移量,你可以理解为页的下标,用来定位页的位置,占用4字节。

当一个辅助索引要插入到页(由<space,offset>这个二元组可唯一定位一个页)时,如果这个页不在缓冲池中,那么InnoDB存储引擎首先根据上述规则构造一个search key结构,接下来查询Insert Buffer这棵B+树,然后再将这条记录插入到Insert Buffer B+树的合适的叶子节点中。

和非叶子节点一样,Insert Buffer B+树的叶子节点也有一种特殊的结构:

  • space、marker、offset字段和之前非叶节点中的含义相同,一共占用9字节。
  • 第4个字段metadata 占用4字节,其存储的内容如下表所示。
    • 最核心的字段是IBUF_REC_OFFSET_COUNT字段,它保存两个字节的整数,用来排序每个记录进入Insert Buffer的顺序。因为从InnoDB1.0.x开始支持Change Buffer,所以这个值同样记录进入Change Buffer的顺序。merge的时候通过这个顺序回放(replay)才能得到记录的正确值。
  • 从Insert Buffer 叶子节点的第5列开始,就是实际插入记录的各个字段了。因此较之原插入记录,Insert Buffer B+树的叶子节点记录需要额外13字节的开销。

因为启用Insert Buffer索引后,辅助索引页(space,offset)中的记录可能被插入到Insert Buffer B+树中,所以为了保证每次Merge Insert Buffer页都能成功,还需要有一个特殊的页用来标记每个辅助索引页(space,offset)的可用空间。这个页的类型为Insert Buffer Bitmap。

每个Insert Buffer Bitmap页用来追踪16384个辅助索引页,也就是256个区(Extent)。每个辅助索引页在Insert Buffer Bitmap页中占用4位(bit),这四位的含义见下表

2.2.4 Merge Insert Buffer

我们已经知道了Insert/Change Buffer是一棵B+树。若需要做插入操作的辅助索引页不在缓冲池中,那么需要将辅助索引记录首先插入到这棵B+树中。但是Insert Buffer中的记录何时合并(merge)到真正的辅助索引中呢?这是我们接下来关注的重点。

概括地说,Merge Insert Buffer的操作可能发生在以下几种情况下∶

  1. 辅助索引页被读取到缓冲池时;
    • 例如这在执行正常的SELECT查询操作,如果辅助索引页不在缓冲池中,这时我们需要优先将辅助索引读入缓冲池
    • 紧接着检查Insert Buffer Bitmap页,看看该辅助索引页是否有记录存放于Insert Buffer B+树中。若有,则将Insert Buffer B+树中该页的记录插入到缓冲池中的该辅助索引页中。
    • 这样便可以将对该页多次的记录操作通过一次操作合并到了原有的辅助索引页中,因此性能会有大幅提高。
  2. Insert Buffer Bitmap页追踪到该辅助索引页已无可用空间时;
    • Insert Buffer Bitmap页用来追踪每个辅助索引页的可用空间,若插入辅助索引记录时检测到插入记录后,辅助索引页的可用空间会小于1/32页,则会强制进行一个合并操作,即强制读取辅助索引页至缓冲池,然后将Insert Buffer B+树中该页的记录及待插入的记录插入到缓冲池的辅助索引页中。
  3. Master Thread。
    • 在Master Thread线程中每秒或每10秒会进行一次Merge Insert Buffer的操作,不同之处在于每次进行merge操作的页的数量不同。
    • 在Master Thread中,执行merge操作的不止是一个页,而是根据 srv_inodb io capactiy的百分比来决定真正要合并多少个辅助索引页。

那么InnoDB存储引擎又是根据怎样的算法来确定Insert Buffer B+树中哪些记录是需要合并的呢?

在Insert Buffer B+树中,辅助索引修改记录会根据(space,offset)排序好,故可以根据(space,offset)的排序顺序进行页的选择。然而,对于Insert Buffer页的选择,InnoDB存储引擎并非采用这个方式,它随机地选择Insert Buffer B+树的一个页,读取该页中的space及之后所需要数量(不同场景需要的数量不同)的页。

该算法在复杂情况下应有更好的公平性。

同时,若进行merge时,要进行merge的表已经被删除,此时可以直接丢弃已经被Insert/Change Buffer 的数据记录。

2.2.5 缓冲池和Insert Buffer的区别

我们前面学过缓冲池技术,假如我们要修改页号为40的索引页,而这个页正好不在缓冲池内。

此时我们依照缓冲池的机制,整个写过程如上图需要3步:

  1. 先把需要为40的索引页,从磁盘加载到缓冲池,一次磁盘随机读操作;
  2. 修改缓冲池中的页,一次内存操作;
  3. 写入redo log,一次磁盘顺序写操作;

注意:没有命中缓冲池的时候,至少产生一次磁盘IO?

而InnoDB加入Insert Buffer优化后,则写入流程优化为:

  1. 在Insert Buffer B+树中记录这个操作,一次内存操作;
  2. 写入redo log,一次磁盘顺序写操作;

可以看到,Insert Buffer机制能在缓冲池技术的基础上减少一次磁盘IO,其性能与这个索引页在缓冲池中的情况相近。可以看到,40这一页,并没有加载到缓冲池中。此时数据库异常奔溃,则能够从redo log中恢复数据;

假设稍后的一个时间,有请求查询索引页40的数据。

此时的流程如序号1-3:

  1. 缓冲池未命中,则从磁盘载入索引页,这次磁盘IO不可避免;
  2. 从Insert Buffer B+树中读取辅助索引页的修改记录;
  3. 根据Insert Buffer修改从缓存载入的索引页,使其达到最终态,并放到缓冲池LRU List里;

可以看到,40这一页,在真正被读取时,才会被加载到缓冲池中。

注意,insert buffer的merge操作是将索引文件从磁盘载入到缓冲池的索引页中,并且将insert buffer里的更改再执行到缓冲池索引页上。

系统大部分空闲时或在慢速关闭期间运行的清除(purge)操作会定期将缓冲池中的辅助索引页(此时一般为脏页)写入磁盘。与每个值立即写入磁盘相比,purge操作可以更有效地为一系列索引值写入磁盘块。

2.2.6 查看insert/change Buffer

用户可以通过命令SHOW ENGINE INNODB STATUS来查看Insert Buffer的信息∶


  • seg size显示了当前Insert Buffer的大小为11336×16KB,大约为177MB;
  • free list len代表了空闲列表的长度;
  • size代表了已经合并记录页的数量。

而黑体部分的第2行可能是用户真正关心的,因为它显示了插入性能的提高。

  • Inserts代表了插入的记录数;
  • mergedrecs代表了合并的插入记录数量;
  • merges代表合并的次数,也就是实际读取页的次数。
  • merges∶merged recs大约为1∶3,代表了插入缓冲将对于非聚集索引页的离散1O 逻辑请求大约降低了23。

在MySQL5.5版本中通过命令SHOW ENGINE INNODB STATUS,可以观察到change Buffer的信息∶


可以看到这里显示了merged operations和discarded operation,并且下面具体显示Change Buffr中每个操作的次数。

  • insert表示Insert Buffer;
  • delete mark表示 Delete Buffer;
  • delete表示Purge Buffer;
  • discarded operations表示当Change Buffer发生merge 时,表已经被删除,此时就无需再将记录合并(merge)到辅助索引中了。

2.3 两次写

如果说Insert Buffer带给InnoDB存储引擎的是性能上的提升,那么doublewrite(两次写)带给InnoDB存储引擎的是数据页的可靠性。

当发生数据库宕机时,可能InnoDB存储引擎正在将某个页写入到表中,而这个页只写了一部分,比如16KB的页,只写了前4KB,之后就发生了宕机,这种情况被称为部分写失效(partial page write)。在InnoDB存储引擎未使用doublewrite技术前,曾经出现过因为部分写失效而导致数据丢失的情况。

有经验的DBA也许会想,如果发生写失效,可以通过重做日志进行恢复。这是一个办法。但是必须清楚地认识到,重做日志中记录的是对页的物理操作,如偏移量800,写’aaa’记录。如果这个页本身已经发生了损坏,再对其进行重做是没有意义的。这就是说,在应用(apply)重做日志前,用户需要一个页的副本,当写入失效发生时,先通过页的副本来还原该页,再进行重做,这就是doublewrite。在InnoDB存储引擎中doublewrite的体系架构如下图所示。

doublewrite由两部分组成,一部分是内存中的doublewrite buffer,大小为2MB,另一部分是物理磁盘上共享表空间中连续的128个页,即2个区(extent),大小同样为2MB。

在对缓冲池的脏页进行flush时,并不直接写磁盘,而是会通过memcpy函数将脏页先复制到内存中的doublewrite buffer,之后通过doublewrite buffer再分两次,每次1MB顺序地写入共享表空间的物理磁盘上,然后马上调用fsync函数,同步磁盘,避免缓冲写带来的问题。在这个过程中,因为doublewrite页是连续的,因此这个过程是顺序写的,开销并不是很大。

在完成doublewrite页的写入后,再将doublewrite buffer中的页写入各个表空间文件中,此时的写入则是离散的。可以通过以下命令观察到doublewrite 运行的情况∶

可以看到,doublewrite一共写了6325194个页,但实际的写人次数为100399,基本上符合64∶1。

如果发现系统在高峰时的Innodb_dblwr_pages_written∶Innodb_dblwr_writes远小于64∶1,那么可以说明系统写人压力并不是很高。

如果操作系统在将页写入磁盘的过程中发生了崩溃,在恢复过程中,InnoDB存储引擎可以从共享表空间中的doublewrite中找到该页的一个副本,将其复制到表空间文件,再应用重做日志。

参数skip_innodb_doublewrite可以禁止使用doublewrite功能,这时可能会发生前面提及的写失效问题。不过如果用户有多个从服务器(slave server),需要提供较快的性能(如在slaves server上做的是RAID0),也许启用这个参数是一个办法。不过对于需要提供数据高可靠性的主服务器(master server),任何时候用户都应确保开启doublewrite 功能。

2.4 自适应哈希索引

哈希(hash)是一种非常快的查找方法,在一般情况下这种查找的时间复杂度为O(1),即一般仅需要一次查找就能定位数据。而B+树的查找次数,取决于B+树的高度,在生产环境中,B+树的高度一般为3~4层,故需要3~4次的查询。

InnoDB存储引擎会监控对表上各索引页的查询。如果观察到建立哈希索引可以带来速度提升,则建立哈希索引,称之为自适应哈希索引(Adaptive Hash Index,AHI)。

AHI是通过缓冲池中的的B+树页构造而来,因此建立的速度很快,而且不需要对整张表构建哈希索引。InnoDB存储引擎会自动根据访问的频率和模式来自动地为某些热点页建立哈希索引。

AHI有一个要求,即对这个页的连续访问模式必须是一样的。例如对于(a,b)这样的联合索引页,其访问模式可以是以下情况∶

  1. WHERE a=xxx
  2. WHERE a=xxx and b=xxx

访问模式一样指的是查询的条件一样,若交替进行上述两种查询,那么InnoDB存储引擎不会对该页构造 AHI。

此外 AHI 还有如下的要求∶

  1. 以该模式访问了100次
  2. 页通过该模式访问了N次,其中N=页中记录/16

根据InnoDB存储引擎官方的文档显示,启用AHI后,读取和写入速度可以提高2 倍,辅助索引的连接操作性能可以提高5倍。毫无疑问,AHI是非常好的优化模式,其设计思想是数据库自优化的(self-tuning),即无需 DBA对数据库进行人为调整。

通过命令SHOW ENGINE INNODB STATUS可以看到当前AHI的使用状况∶

现在可以看到AHI的使用信息了,包括AHI的大小、使用情况、每秒使用AHI搜索的情况。

值得注意的是,哈希索引只能用来搜索等值的查询,如SELECT* FROM table WHERE index_col='xxx'。而对于其他查找类型,如范围查找,是不能使用哈希索引的,因此这里出现了non-hash searches/s的情况。通过 hash searches∶non-hash searches的比值,可以大概了解使用哈希索引后的效率。

参数 innodb_adaptive_hash_index可以控制是否启动AHI。

2.5 异步IO

为了提高磁盘操作性能,当前的数据库系统都采用异步IO(Asynchronous IO,AIO)的方式来处理磁盘操作。InnoDB存储引擎亦是如此。

与AIO对应的是Sync IO,即每进行一次IO操作,需要等待此次操作结束才能继续接下来的操作。但是如果用户发出的是一条索引扫描的查询,那么这条SQL查询语句可能需要扫描多个索引页,也就是需要进行多次的IO操作。在每扫描一个页并等待其完成后再进行下一次的扫描,这是没有必要的。用户可以在发出一个IO请求后立即再发出另一个IO请求,当全部IO请求发送完毕后,等待所有IO操作的完成,这就是AIO。

AIO的另一个优势是可以进行IO Merge操作,也就是将多个IO合并为1个IO,这样可以提高IOPS的性能。例如用户需要访问页的(space,page_no)为∶(8,6),(8,7),(8,8)

每个页的大小为16KB,那么同步IO需要进行3次IO操作。而AIO会判断到这三个页是连续的(显然可以通过(space,page_no)得知)。因此AIO底层会发送一个IO 请求,从(8,6)开始,读取48KB的页。

在InnoDB1.1.x之前,AIO的实现通过InnoDB存储引擎中的代码来模拟实现。而从InnoDB1.1.x开始(InnoDB Plugin不支持),提供了内核级别AIO的支持,称为Native AIO。因此在编译或者运行该版本MySQL时,需要libaio库的支持。

需要注意的是,Native AIO需要操作系统提供支持。Windows系统和Linux系统都提供Native AIO支持,参数innodb_use_native_aio用来控制是否启用Native AIO,在Linux操作系统下,默认值为 ON。

用户可以通过开启和关闭Native AIO功能来比较InnoDB性能的提升。官方的测试显示,启用Native AIO,恢复速度可以提高 75%。

在InnoDB存储引擎中,read ahead方式的读取都是通过AIO完成,脏页的刷新,即磁盘的写人操作则全部由 AIO完成。

2.6 刷新邻接页

InnoDB存储引擎还提供了Flush Neighbor Page(刷新邻接页)的特性。其工作原理为∶当flush一个脏页时,InnoDB存储引擎会检测该页所在区(extent)的所有页,如果是脏页,那么一起进行flush。

这样做的好处显而易见,通过AIO可以将多个IO写人操作合并为一个IO操作,故该工作机制在传统机械磁盘下有着显著的优势。但是需要考虑到下面两个问题∶

  1. 是不是可能将不怎么脏的页进行了写入,而该页之后又会很快变成脏页?
  2. 固态硬盘有着较高的IOPS,是否还需要这个特性?

为此,InnoDB存储引擎从1.2x版本开始提供了参数 innodb_flush_neighbors,用来控制是否启用该特性。对于传统机械硬盘建议启用该特性,而对于固态硬盘有着超高IOPS性能的磁盘,则建议将该参数设置为0,即关闭此特性。

【I/O设计总结二】详解IO多路复用和其三种模式——select/poll/epoll

Posted on 2020-08-13 | In 计算机协议和技术 , Linux相关 |
Words count in article: 7.2k | Reading time ≈ 25

前言

我们平常采用的多进程方式实现的服务器端,即一次创建多个工作子进程来给客户端提供服务。其实这种方式是存在问题的。

可以打个比方:如果我们先前创建的几个进程承载不了目前快速发展的业务的话,是不是还得增加进程数?我们都知道系统创建进程是需要消耗大量资源的,多进程方式实现的服务器端会导致系统出现资源不足的情况。

那么有没有一种方式可以让一个进程同时为多个客户端端提供服务?

接下来要讲的IO多路复用技术就是对于上述问题的最好解答。即一个进程同时为多个客户端端提供服务。

对于IO复用,我们可以通过一个例子来很好的理解它。(例子来自于《TCP/IP网络编程》)

某教室有10名学生和1名老师,这些学生上课会不停的提问,所以一个老师处理不了这么多的问题。那么学校为每个学生都配一名老师,

也就是这个教室目前有10名老师。此后,只要有新的转校生,那么就会为这个学生专门分配一个老师,因为转校生也喜欢提问题。如果把以上例子中的学生比作客户端,那么老师就是负责进行数据交换的服务端。则该例子可以比作是多进程的方式。

后来有一天,来了一位具有超能力的老师,这位老师回答问题非常迅速,并且可以应对所有的问题。为了让这位老师更有效率的回答学生问题,老师让班长站在讲台边不停的扫视班级,如果有同学有问题举手了,那么班长就会提醒老师一下,老师这时候再扫视一下班上看谁举手,进而回答问题。

这就是IO复用,老师是应用程序进程,班长是多路复用的代理,学生是IO的流,在这种情况下,老师只需要关注班长就可以了,加入了班长这个角色,使得一个老师可以回答许多学生的问题。

常规模型下,老师(应用进程)会阻塞在班长(代理)这里,直到班长反馈说有人举手,老师才会被唤醒,然后扫视班级回答问题。值得注意的是,班长每次只会反馈有人举手,不会反馈到底是谁举手。

目前的常用的IO复用模型有三种:select,poll,epoll。

在了解IO复用模型前,我们需要连接一些Linux操作系统中的前置知识。

1 前置知识

1.1 socket编程

socket编程内容繁多,具体详见该文章

Linux的SOCKET编程详解

1.2 用户空间 / 内核空间

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。

操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。

1.3 文件描述符

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。

我们都知道在Linux下一切皆文件。当然设备也不例外,如果要对某个设备进行操作,就不得不打开此设备文件,打开文件就会获得该文件的文件描述符fd( file discriptor),它就是一个很小的整数。

每个进程在PCB(Process Control Block,进程控制块)中保存着一份文件描述符表,文件描述符就是这个表的索引,文件描述符表中每个表项都有一个指向已打开文件的指针。现在我们明确一下:已打开的文件在内核中用file结构体表示,文件描述符表中的指针指向file结构体。file结构体才是内核中用来描述文件属性的结构体。

file结构体如下:

1
2
3
4
5
6
7
8
9
10
11
struct FILE
{
char *_ptr;//文件输入的下一个位置
int _cnt;//当前缓冲区的相对位置
char *_base;//指基础位置(文件的起始位置)
int _flag;//文件标志
int _file;//文件的有效性验证
int _charbuf;//检查缓冲区状况,如果缓冲区则不读取
int _bufsiz;//文件的大小
char *_tmpfname;//临时文件名
};

具体详见文章Linux下 文件描述符(fd)与 文件指针(FILE*)

2 select模式

select模型的原理,套用前言中的老师回答学生问题的例子,则是:老师仅仅知道有学生举手了,但是到底是哪些学生举手了,他需要用眼睛扫描一遍全班同学,找出举手的同学,然后倾听他的问题,并回答他的问题。

所以select具有O(n)的无差别轮询时间复杂度,同时处理的流越多,无差别轮询时间就越长。

2.1 select函数

Linux系统提供了一个函数来供开发者使用select多路复用机制:

int select(int maxfdp,fd_set *readfds,fd_set *writefds,fd_set *errorfds,struct timeval *timeout);

该函数的作用是:通过轮询,可以同时监视多个文件描述符是否发生了读/写/异常这三类IO变化,最后返回发生变化的文件描述符数量,以及读/写/异常这三种变化分别发生在哪些文件描述符中。

我们来看看它的参数的含义:

在看参数前,我们要了解:struct fd_set可以理解为一个集合,这个集合中存放的是文件描述符(file descriptor),即文件句柄。fd_set集合可以通过下列宏由人为来操作。
FD_ZERO(fd_set *fdset):清空fdset与所有文件句柄的联系。
FD_SET(int fd, fd_set *fdset):建立文件句柄fd与fdset的联系。
FD_CLR(int fd, fd_set *fdset):清除文件句柄fd与fdset的联系。
FD_ISSET(int fd, fdset *fdset):检查fdset联系的文件句柄fd是否可读写,>0表示可读写。

  1. int maxfdp:是一个整数值,是指集合中所有文件描述符的范围,即所有文件描述符的最大值加1,不能错!在Windows中这个参数的值无所谓,可以设置不正确。

  2. readfds:传入select函数的需要被监控读IO的fd_set文件描述符集合,select函数会负责监视readfds的读变化,如果readfds中的某个文件描述符指向的文件能读出数据,那么在返回的时候,select不仅会统计它的数量,而且还会改写readfds,以标出是它的位置。

  3. writefds:传入select函数的需要被监控写IO的fd_set文件描述符集合,select函数会负责监视writefds的写变化,如果writefds中的某个文件描述符指向的文件能写入数据,那么在返回的时候,select不仅会统计它的数量,而且还会改写writefds,以标出是它的位置。

  4. errorfds:传入select函数的需要被监控异常IO的fd_set文件描述符集合,select函数会负责监视errorfds的异常变化,如果readfds中的某个文件描述符指向的文件能读出异常数据,那么在返回的时候,select不仅会统计它的数量,而且还会改写errorfds,以标出是它的位置。

  5. struct timeval:用来代表时间值,有两个成员,一个是秒数,另一个是毫秒数。

    1. 若将NULL以形参传入,即不传入时间结构,就是将select置于阻塞状态,一定等到监视文件描述符集合中某个文件描述符发生变化为止;
    2. 若将时间值设为0秒0毫秒,就变成一个纯粹的非阻塞函数,不管文件描述符是否有变化,都立刻返回继续执行,文件无变化返回0,有变化返回一个正值;
    3. timeout的值如果大于0,这就是等待的超时时间,即select在timeout时间内阻塞,超时时间之内有事件到来就返回了,否则在超时后不管怎样一定返回。
      1
      2
      3
      4
      5
      6
      7
      struct timeval{

      long tv_sec; /*秒 */

      long tv_usec; /*微秒 */

      }

2.2 select函数的使用

select函数用来验证3种读、写、异常三种监视项的变化情况。使用前,我们先声明3个fd_set变量,然后分别向其注册文件描述符信息,并把变量的地址传入到函数的readfds/writefds/errorfds参数上。

同时我们要明确要监控的文件描述符数量,原本这个数量不好计算,但好在每次新建文件描述符时,其值(文件描述符是非负整数)都会增1,故只需将最大的文件描述符值加1(因为文件描述符从0开始,所以要+1)再传递到select函数的maxfdp参数即可。

最后再设置一下超时时间(如果需要的话)到timeval参数即可。

2.3 select函数的返回

在超时时间之内,如果三个fd_set对应的文件描述符有变化,那么select会返回一个大于0的值,表示发生变化的文件描述符数量。如果没有变化,则在timeout的时间后select返回0,若发生错误返回负值。

那么问题来了,select函数只返回了变化的文件描述符数量,那么怎样获知哪些文件描述符发生了变化呢?

原来select函数还会改写传进去的readfds/writefds/errorfds集合,即将他们都用FD_ZERO(fd_set *fdset)清空,即fd_set中的所有位数都置为0,然后如果某个文件描述符有读IO,那么在其对应项上用FD_SET(int fd, fd_set *fdset)来设置1;

select函数调用完成后,向其传递的fd_set变量中将发生变化。发生变化的文件描述符对应位除外,其他原来为1的所有位均变为0。因此,可以认为readfds中值为1的位置上的文件描述符发生了读变化,writefds中值为1的位置上的文件描述符发生了写变化,errorfds中值为1的位置上的文件描述符发生了异常变化。

因为传入的三个fd_set会被改写,所以使用前记得备份原set。

2.4 select的实现机制

我们来简单了解一下select机制的源码:

  1. 使用copy_from_user从用户空间拷贝fd_set到内核空间
  2. 注册回调函数__pollwait,__pollwait的主要工作就是把current进程(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。驱动程序在得知设备有IO事件时(通常是该设备上IO事件中断),会调用wakeup,wakeup –> _wake_up_common -> curr->func(即pollwake),pollwake函数里面调用_pollwake函数, 通过pwq->triggered = 1将进程标志为唤醒。再调用default_wake_function(&dummy_wait, mode, sync, key)这个默认的通用唤醒函数唤醒调用select的进程,这时current进程便被唤醒了。
  3. 遍历所有fd,依次调用其fd的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll),poll函数调用poll_wait函数,poll_wait函数调用__pollwait()。
  4. 以tcp_poll为例,tcp_poll的核心实现就是pollwait,也就是上面注册的回调函数。它调用pollwait检查是否有读写操作,并返回一个描述读写操作是否就绪的mask掩码(可以理解为我们上面说的fd_set对应项置为1的那个1),根据这个mask掩码给fd_set中当前fd的对应项赋值。
  5. 如果遍历完所有的fd,fd_set中还没有一个表示可读/写/异常的mask掩码(也就是三个fd_set还没有位置置1),则会调用schedule_timeout使调用select的进程(也就是current进程)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。
  6. 重复直到要么超时,要么有就绪的fd,然后返回,并把fd_set从内核空间拷贝回用户空间。

2.5 select机制的缺点

  1. 每次调用select,都需要把fd_set集合从用户态拷贝到内核态,如果fd_set集合很大时,那这个开销也很大
  2. 同时每次调用select都需要在内核遍历传递进来的所有fd_set,如果fd_set集合很大时,那这个开销也很大
  3. 为了减少数据拷贝带来的性能损坏,内核对被监控的fd_set集合大小做了限制,并且这个是通过宏控制的,一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048。

3 poll模式

poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构,pollfd结构使用链表而非数组,这导致pollfd的长度没有限制。除此之外,二者的原理基本一致,即对多个描述符也是进行轮询,根据描述符的状态进行处理。

3.1 poll函数

int poll (struct pollfd *fds, unsigned int nfds, int timeout);

不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现。

同时也不需要三个fd_set来表示分别要监控哪些事件,poll定义的pollfd结构,就封装了该fd需要监控的事件:

1
2
3
4
5
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};

和select函数一样,poll会改写pollfd,返回后,需要轮询pollfd来获取就绪的描述符。

3.2 poll函数的实现

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。

因为poll和select的区别不大,所以除了fd集合没有限制外(但是数量过大后性能也是会下降),select其他的缺点poll都有。

4 epoll模式

由于epoll的实现机制与select/poll机制完全不同,上面所说的 select的缺点在epoll上不复存在。

设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?

在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。

4.1 epoll的设计

epoll的设计和实现与select完全不同。epoll通过在Linux内核中申请一个简易的文件系统,这个文件系统早期使用哈希表实现,后来改用红黑树来实现。

epoll把原先的select/poll调用分成了3个部分:

  1. 调用epoll_create()建立一个epoll句柄对象(在epoll文件系统中为这个句柄对象分配资源)

  2. 调用epoll_ctl向epoll对象中添加这100万个连接的套接字

  3. 调用epoll_wait收集发生的事件的连接

如此一来,要实现上面说是的场景,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除连接。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有一股脑的向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接(epoll通过内核和用户空间共享一块内存来实现共享句柄数据)。

4.2 epoll函数

epoll操作过程需要三个函数,分别如下:

  1. int epoll_create(int size);

    • 创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。
    • 当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
  2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

    • 该函数是对指定描述符fd执行op操作,你可以理解为将套接字以及它要监控的事件,注册到epoll句柄中。通过此调用向epoll对象中添加、删除、修改感兴趣的事件,返回0标识成功,返回-1表示失败。其各参数含义如下
    • epfd:是epoll_create()的返回值,可以理解为指向epoll句柄的指针。
    • op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
    • fd:是需要监听的fd(文件描述符)
    • epoll_event:是告诉内核需要监听什么事,struct epoll_event结构如下:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      struct epoll_event {
      __uint32_t events; /* Epoll events */
      epoll_data_t data; /* User data variable */
      };

      //events可以是以下几个宏的集合:
      EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
      EPOLLOUT:表示对应的文件描述符可以写;
      EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
      EPOLLERR:表示对应的文件描述符发生错误;
      EPOLLHUP:表示对应的文件描述符被挂断;
      EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
      EPOLLONESHOT:只监听一次,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

      epoll的全程是eventpoll,顾名思义,它的实现机制是基于event事件的,所以不同于select使用三个fd_set来对应读/写/异常的IO变化,也不同于poll只是在pollfd的结构体中使用short events来对应事件,epoll专门定义了一个epoll_event结构体,将其作为读/写/异常的IO变化的逻辑封装,称为事件(event)。

  3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

    • 等待epfd上的io事件,最多返回maxevents个事件。
    • epfd参数是epoll_create()的返回值,可以理解为指向epoll句柄的指针。
    • events参数用来从内核得到事件的集合
    • maxevents参数告知内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size。
    • timeout参数是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

4.3 epoll的两种工作模式

epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:

  • LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
    • LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。
  • ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
    • ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核也不会发送更多的通知(only once)
    • ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作,把处理多个文件描述符的任务饿死。

二者的区别举个一个例子:

  1. 我们已经把一个用来从管道中读取数据的文件句柄(RFD)添加到epoll描述符
  2. 这个时候从管道的另一端被写入了2KB的数据
  3. 调用epoll_wait(2),并且它会返回RFD,说明它已经准备好读取操作
  4. 然后我们读取了1KB的数据
  5. 调用epoll_wait(2)……

如果是LT模式,那么在第5步调用epoll_wait(2)之后,仍然能收到通知RFD有事件。因为第四步我们没有把RFD的数据读完,只读了1KB。

如果是ET模式,那么在第5步调用epoll_wait(2)之后,不会收到通知RFD有事件了,ET模式只会在第三步提醒一次。

4.4 epoll的实现机制

当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示:

1
2
3
4
5
6
7
8
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};

每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。

而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的event事件添加到rdlist双链表中。

在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:

1
2
3
4
5
6
7
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}

epitem可以理解为:事件逻辑结构体epoll_event与双向链表/红黑树之间的映射关系,其关系如下图所示:

当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。

5 三种模型的区别和取舍

  1. 支持一个进程所能打开的最大连接数

    • select:单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小,当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响,这需要进一步的测试。
    • poll:poll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的。
    • epoll:虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接。
  2. FD剧增后带来的IO效率问题

    • select:因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。
    • poll:同上
    • epoll:因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。
  3. 消息传递方式

    • select:内核需要将消息传递到用户空间,都需要内核拷贝动作
    • poll:同上
    • epoll:epoll通过内核和用户空间共享一块内存来实现的。

综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点。

表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。

select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。

select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善

select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。

volatile关键字详解

Posted on 2020-08-07 | In JAVA , JAVA线程与并发控制 |
Words count in article: 3.3k | Reading time ≈ 11

前言

提到JAVA的并发编程,就不得不提volatile关键字,不管是在面试还是实际开发中,volatile关键字的使用都是一个应该掌握的技能。它之所以重要,是因为它和JAVA并发编程中会遇到三种重要问题中的两种密切相关。

在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。而volatile关键字之所以神奇,在于它可以解决可见性问题和有序性问题。

1 可见性

1.1 可见性问题

如果我们学过java内存模型的话,对下面这张图想必不陌生:

每一个线程都有一份自己的本地内存,所有线程共用一份主内存。如果一个线程A对主内存中的某个数据V进行了修改,而此时另外一个线程B不知道该数据V已经发生了修改,它会从本地内存中去读取这个数据V,显然数据V已经过时了。这就是说,本次线程A修改后的数据V,对线程B来说,此时是不可见的。

1.2 volatile保证内存可见性

可见性问题在并发场景中是十分常见,那么volatile关键字如何保证内存可见性呢?

volatile关键字的作用很简单,就是一个线程在对主内存的某一份数据进行更改时,改完之后会立刻刷新到主内存。并且会强制让缓存了该变量的线程中的数据清空,必须从主内存重新读取最新数据。这样一来就保证了可见性。

其底层原理如下:

JMM把主内存和线程的本地内存之间的交互分为8个原子操作,他们分别是:

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

JMM要求,如果要把一个变量从主内存复制到工作内存,那么应该顺序的执行read和load操作。反之,应该顺序的执行store和write操作。JMM只要求上述两个指令是顺序执行,不要求必须要连续执行,也就说是,可以在read和load操作之间插入其他指令。这基本构成了JMM的主内存和工作内存之间的交互逻辑。

而volatile如何实现内存可见性呢?其实很简单,下面我截取一段被volatile关键字修饰的变量的赋值逻辑的汇编指令:

1
2
3
4
5
···
0x0000000002931351: lock add dword ptr [rsp],0h ;
*putstatic instance;
- org.xrq.test.design.singleton.LazySingleton::getInstance@13 (line 14)
···

你不用去关心这段字节码什么意思,你知道知道它的语意是对一个volatile修饰的变量进行赋值操作。和没有volatile修饰的变量的赋值操作字节码相比,volatile修饰的变量的赋值操作仅仅是多了一个lock指令前缀。

lock指令前缀有什么作用呢?lock后的写操作会强制回写已修改的数据到主内存,相当于连续执行了store和write操作。

看到这里有同学不禁要问了,lock操作只是强制回写数据到主内存,但没有强制其他线程去刷新他们工作内存中的值啊。

这要结合缓存一致性协议——MESI协议来看了,不懂的同学可回顾本博客另外一篇文章《JAVA内存模型》的MESI协议一章。

假如线程A操作赋值逻辑,使用lock操作强制回写数据V到主内存,根据MESI协议,线程A会将主内存中该数据V的状态改为M,其他线程一直在监听主内存,发现该数据V的状态为M后,会将他们的工作内存中的数据V的状态改为I——即失效状态,最终迫使其他线程在使用V之前,必须去主内存读取新值。

这就是volatile保证内存可见性的原理,其实只是一个lock指令前缀而已。

2 有序性

2.1 有序性问题

并发场景中,有序性问题,许多是由JVM的指令重排优化引起的。

指令重排序是JVM为了优化指令、提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。指令重排序包括编译器重排序和运行时重排序。

实质上指令重排序是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理

我们来看一个因为指令重排而引起的并发问题,懒加载的双重检查模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Singleton {
private static Singleton instance;
private Singleton(){}
public static Singleton getInstance() {
if ( instance == null ) { //当instance不为null时,仍可能指向一个“被部分初始化的对象”
synchronized (Singleton.class) {
if ( instance == null ) {
instance = new Singleton();
}
}
}
return instance;
}
}

这个模型我们并不陌生,在 《Effecitve Java》一书中作者层提到了双重检查模式,并指出这种模式在Java中通常并不适用。并不适用的原因,就是因为指令重排。

上面这段代码,初看没问题,但是在并发模型下,可能会出错。那是因为instance = new Singleton();并非一个原子操作,编译器会将其编译为三行字节码,也就是三个步骤:

  1. memory=allocate();// 分配内存

  2. ctorInstanc(memory) //初始化对象

  3. instance=memory //设置instance指向刚分配的地址

在编译器运行时,因为步骤三和步骤二无依赖关系,故而JVM会对其进行指令重排优化,从1-2-3顺序优化为1-3-2顺序。

可以看到指令重排之后,操作3排在了操作2之前,即引用instance指向内存memory时,这段崭新的内存还没有初始化——也就是说引用instance指向了一个”被部分初始化的对象”。

此时,如果另一个线程调用getInstance方法,由于instance已经指向了一块内存空间,从而if (instance == null) 条件判为false,方法返回instance引用,那么用户则得到了没有完成初始化的“半个”单例。从而发生问题。

2.2 volatile防止指令重排

解决这个该问题,只需要将instance声明为volatile变量:private static volatile Singleton instance;

volatile关键字能够禁止JVM对修饰的变量的读写做指令重排,从而保证了instance = new Singleton();在底层能够按照顺序执行。

其底层原理其实和volatile保证可见性的原理是一样的,也就是在汇编指令层面加入一个lock前缀:

这里的lock前缀指令相当于一个内存屏障(但实际上不是内存屏障),它保证了:当程序执行到volatile变量的读或写时,在lock指令前面的操作必须全部执行完毕,且结果必须已经对后面的操作可见(也就是上面说的lock会强制刷新新值到主存);

指令重排的原则是无依赖关系间的优化排序,而volatile字段带来的lock前缀,则会使instance = new Singleton();的三个字节码相当于变成这样(lock是汇编指令,所以说只是相当于):

  1. memory=allocate();// 分配内存

  2. ctorInstanc(memory) //初始化对象

  3. lock instance=memory //设置instance指向刚分配的地址

这使得原本没有依赖关系的2和3操作,因为lock前缀的语意强制,被强加上了一个2必须在3之前完成的“依赖”,形成了虽然不是内存屏障,但却达到了内存屏障功能的效果,给人一种指令重排无法越过volatile读写操作两边的观感。

3 lock指令前缀作用总结

Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。

  • 确保指令重排序时不会把lock指令后面的指令排到lock之前的位置,也不会把前面的指令排到lock的后面;即在执行到lock这句指令时,在它前面的操作已经全部完成;

  • 强制将对缓存的修改操作立即写入主存,同时利用缓存一致性机制,让其他工作线程从主存重新读值;

4 volatile不能保证原子性

从上文我们知道volatile关键字保证了操作的可见性,但是volatile能保证对变量的操作是原子性吗?

下面看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
//有10个线程分别进行了1000次操作inc的自增操作
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}

大家想一下这段程序的输出结果是多少?也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。

可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。

这里面就有一个误区了,volatile关键字能保证可见性没有错,但可见性只能保证每次读取的是最新的值,volatile没办法保证对变量的操作的原子性。

自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

  1. 假如某个时刻变量inc的值为10,
  2. 线程1对变量进行自增操作,线程1先读取了变量inc的原始值10。
  3. 线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,还没有没有对变量进行修改操作,线程2读到的10也是10;
  4. 这时候线程1对inc进行自增,并且通过可见性,将结果11写回主存,并将线程2的工作内存中inc的状态改为无效。
  5. 但是此时线程2对inc的读操作已经结束了,已经在进行+1操作了,inc就算在线程2中被置为无效,线程2也过了能感知到的时间点了,导致线程2也是对10+1,得到11再写回主存。

以此类推,导致最后的结果必定小于10000;这说明了volatile无法保证原子性,它本身也不适用类似场景。

volatile比较适合用来修饰一个会被单线程更改,但又需要立刻让其他线程感知到值变化的值,比如代码逻辑里面的业务开关等。

JAVA的CAS及其ABA问题

Posted on 2020-08-05 | In JAVA , JAVA线程与并发控制 |
Words count in article: 2.4k | Reading time ≈ 9

1 CAS是什么

CAS是Compare-And-Swap的缩写,即对比和替换,它在保证数据原子性的前提下尽可能的减少了锁的使用,很多编程语言或者系统实现上都大量的使用了CAS。

因为没有线程阻塞唤醒带来的性能消耗问题。这也是为什么CAS比synchronized性能高的原因!

1.1 JAVA中CAS的实现

JAVA中的CAS主要使用的是Unsafe类。Unsafe的CAS操作主要是基于硬件平台的汇编指令,目前的处理器基本都支持CAS,只不过不同的厂家的实现不一样罢了。

sun.misc.Unsafe类虽然提供了一系列直接操作内存对象的方法,但只是在jdk内部使用,JAVA官方不建议开发者直接调用Unsafe类;所以我们一般直接使用到的,都是java.util.concurrent.atomic包下的Atomic*类,比如AtomicBoolean、AtomicInteger等,其compareAndSet方法,也都是调用的Unsafe的CAS方法。

1
2
3
4
5
6
7
public final class Unsafe {
...
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
...
}
  • value 表示 需要操作的对象
  • valueOffset 表示 对象(value)的地址的偏移量(通过Unsafe.objectFieldOffset(Field valueField)获取)
  • expect 表示更新时value的期待值
  • update 表示将要更新的值

具体过程为每次在执行CAS操作时,线程会根据valueOffset去内存中获取当前值去跟expect的值做对比如果一致则修改并返回true,如果不一致说明有别的线程也在修改此对象的值,则返回false。

Unsafe类中compareAndSwapInt的具体实现所对应的cpp代码为:

1
2
3
4
5
6
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

1.2 CAS和自旋的配合

很多文章都信誓旦旦的说CAS底层使用自旋,从而达到高效的无锁并发。这是将Atomic的CAS实现和Unsafe的CAS实现混淆的结果,JAVA的CAS追本溯源都在Unsafe的CAS方法中,它顾名思义,只有比较和替换,没有自旋。

但不可否认,当CAS和自旋搭配使用的时候,确实效果更佳,尤其是在并发做加减的时候,所以Unsafe类提供了一个将CAS和自旋搭配使用的自增方法。

1
2
3
4
5
6
7
8
9
10
11
public final int getAndSetInt(Object var1, long var2, int var4) {
int var5;
do {
//getIntVolatile方法获取对象var1中offset=var2偏移地址对应的整型field的值,支持volatile load语义。
//说人话就是取出var1内存中偏移量为var2位置对应的整型field的值
var5 = this.getIntVolatile(var1, var2);
//自旋操作,不停比较该值,如果CAS成功,则退出,否则一直循环。
} while(!this.compareAndSwapInt(var1, var2, var5, var4));

return var5;
}

相同原理的还有getAndSetLong/getAndSetObject/getAndAddLong/getAndAddInt等方法。

1.3 Java 8对CAS机制的优化——LongAdder

当并发操作一个AtomicInteger而不是使用synchronize时,我们确实可以享受到CAS无锁并发带来的高性能,但CAS就完美无缺么?肯定不是的,比如说大量的线程同时并发修改一个AtomicInteger,可能有很多线程会不停的自旋,进入一个无限重复的循环中。

这些线程不停地获取值,然后发起CAS操作,但是发现这个值被别人改过了,于是再次进入下一个循环,获取值,发起CAS操作又失败了,再次进入下一个循环。

在大量线程高并发更新AtomicInteger的时候,这种问题可能会比较明显,导致大量线程空循环,自旋转,性能和效率都不是特别好。

于是,Java 8推出了一个新的类,LongAdder,他尝试使用分段CAS以及自动分段迁移的方式来大幅度提升多线程高并发执行CAS操作的性能!

在LongAdder的底层实现中,首先有一个base值,刚开始多线程来不停的累加数值,都是对base进行累加的,比如刚开始累加成了base = 5。

接着如果发现并发更新的线程数量过多,就会开始施行分段CAS的机制,也就是内部会搞一个Cell数组,每个数组是一个数值分段。这时,让大量的线程分别去对不同Cell内部的value值进行CAS累加操作,这样就把CAS计算压力分散到了不同的Cell分段数值中。

如此操作可以大幅度的降低多线程并发更新同一个数值时出现的无限循环的问题,大幅度提升了多线程并发更新数值的性能和效率!

更重要的是他内部实现了自动分段迁移的机制,也就是如果某个Cell的value执行CAS失败了,那么就会自动去找另外一个Cell分段内的value值进行CAS操作。这样也解决了线程空旋转、自旋不停等待执行CAS操作的问题,让一个线程过来执行CAS时可以尽快的完成这个操作。

最后,如果你要从LongAdder中获取当前累加的总值,就会把base值和所有Cell分段数值加起来返回给你。

LongAdder顾名思义,只是一个自增器,只能用来做增长操作

2 CAS的ABA问题

CAS还存在一个更加严重的问题——ABA问题:

线程1准备用CAS修改变量值A,在此之前,其它线程将变量的值由A替换为B,又由B替换为A,然后线程1执行CAS时发现变量的值仍然为A,所以CAS成功。但实际上这时的现场已经和最初不同了。

有没有解决方案呢?有的,JAVA中ABA中解决方案有两种,我们依次介绍

2.1 AtomicStampedReference类

解决ABA最简单的方案就是给值加一个修改版本号,每次值变化,都会修改它版本号,CAS操作时都对比此版本号。

AtomicStampedReference就是这种思路在JAVA中的产物,它主要维护包含一个对象引用以及一个可以自动更新的整数”stamp”的pair对象来解决ABA问题。

其关键代码如下(省略无用代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//关键代码
public class AtomicStampedReference<V> {
private static class Pair<T> {
final T reference; //维护对象引用
final int stamp; //用于标志版本
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
private volatile Pair<V> pair;
....
/**
* expectedReference :更新之前的原始值
* newReference : 将要更新的新值
* expectedStamp : 期待更新的标志版本
* newStamp : 将要更新的标志版本
*/
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair; //获取当前pair
return
expectedReference == current.reference && //原始值等于当前pair的值引用,说明值未变化
expectedStamp == current.stamp && // 原始标记版本等于当前pair的标记版本,说明标记未变化
((newReference == current.reference &&
newStamp == current.stamp) || // 将要更新的值和标记都没有变化
casPair(current, Pair.of(newReference, newStamp))); // cas 更新pair
}
}

不需要过分在意源码,我们只要知道怎么用就好,demo如下:

1
2
3
4
public static void main(String[] args) {
AtomicStampedReference<String> reference = new AtomicStampedReference<String>("aaa",1);
reference.compareAndSet("aaa","bbb",reference.getStamp(),reference.getStamp()+1);
}

2.2 AtomicMarkableReference类

AtomicMarkableReference可以理解为AtomicStampedReference的简化版,就是不关心修改过几次,仅仅关心是否修改过。因此变量mark是boolean类型,仅记录值是否有过修改。

关键代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Pair对象维护对象的引用和对象标记
private static class Pair<T> {
final T reference;
final boolean mark;// 通过标记的状态区分对象是否有更改

private Pair(T reference, boolean mark) {
this.reference = reference;
this.mark = mark;
}

static <T> Pair<T> of(T reference, boolean mark) {
return new Pair<T>(reference, mark);
}
}
/**
* @param expectedReference 期待的原始对象
* @param newReference 将要更新的对象
* @param expectedMark 期待原始对象的标记
* @param newMark 将要更新对象的标记
*/
public boolean compareAndSet(V expectedReference,
V newReference,
boolean expectedMark,
boolean newMark) {
Pair<V> current = pair;
return
expectedReference == current.reference && // 如果期待的原始对象与Pair的reference一样则返回true
expectedMark == current.mark && // 如果期待的原始对象的标记与Pair的mark一样则返回true
((newReference == current.reference &&
newMark == current.mark) || // 如果要更新的对象和对象标记与Pair的refernce和mark一样的话直接返回true,否则执行CAS操作
casPair(current, Pair.of(newReference, newMark)));
}

不需要过分在意源码,我们只要知道怎么用就好,demo如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class AtomicMarkableReferenceDemo {

private static final Integer INIT_NUM = 10;

private static final Integer TEM_NUM = 20;

private static final Integer UPDATE_NUM = 100;

private static final Boolean INITIAL_MARK = Boolean.FALSE;

private static AtomicMarkableReference atomicMarkableReference = new AtomicMarkableReference(INIT_NUM, INITIAL_MARK);

public static void main(String[] args) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " : 初始值为:" + INIT_NUM + " , 标记为: " + INITIAL_MARK);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (atomicMarkableReference.compareAndSet(INIT_NUM, UPDATE_NUM, atomicMarkableReference.isMarked(), Boolean.TRUE)) {
System.out.println(Thread.currentThread().getName() + " : 修改后的值为:" + atomicMarkableReference.getReference() + " , 标记为: " + atomicMarkableReference.isMarked());
}else{
System.out.println(Thread.currentThread().getName() + " CAS返回false");
}
}, "线程A").start();

new Thread(() -> {
Thread.yield();
System.out.println(Thread.currentThread().getName() + " : 初始值为:" + atomicMarkableReference.getReference() + " , 标记为: " + INITIAL_MARK);
atomicMarkableReference.compareAndSet(atomicMarkableReference.getReference(), TEM_NUM, atomicMarkableReference.isMarked(), Boolean.TRUE);
System.out.println(Thread.currentThread().getName() + " : 修改后的值为:" + atomicMarkableReference.getReference() + " , 标记为: " + atomicMarkableReference.isMarked());
}, "线程B").start();
}
}

输出结果

1
2
3
4
线程A : 初始值为:10 , 标记为: false
线程B : 初始值为:10 , 标记为: false
线程B : 修改后的值为:20 , 标记为: true
线程A CAS返回false

由于线程B修改了对象,标记由false改为true,所以当上下文切换为线程A的时候,如果标记不一致,线程A执行CAS方法就会返回false。

LRU和LFU算法以及其在Redis中的实现

Posted on 2020-08-04 | In 中间件 , Redis |
Words count in article: 6.6k | Reading time ≈ 25

前言

本文讲述的两个缓存淘汰算法,LRU算法(Least recently used)和LFU算法(Least Frequently used),两者看起来很相似,但我们要明确其区别在于:

  • LRU是按访问时间排序,发生淘汰的时候,把访问时间最旧的淘汰掉。

  • LFU是按频次排序,一个数据被访问过,把它的频次+1,发生淘汰的时候,把频次低的淘汰掉。

本文旨在描述LRU/LFU算法定义,并给出性能最佳的实现方式,最后再延伸至当前最热门的缓存中间件Redis中二者的实现。

其中,LRU/LFU算法性能最优的实现,也是各大厂技术面的常问题。leetcode上有两个这样的题目,要求是缓存的加入put(),缓存读取get(),都要在O(1)内实现:

  • LRU:https://leetcode.com/problems/lru-cache/description/ 或者 https://leetcode-cn.com/problems/lru-cache/
  • LFU:https://leetcode.com/problems/lfu-cache/description/ 或者 https://leetcode-cn.com/problems/lfu-cache/

1 LRU算法

LRU(Least recently used)算法,也叫作最近最久未使用算法,顾名思义,就是哪个是最近不用的,就把他淘汰掉。它根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。它经常使用在内存/缓存空间不足的场景,以便在受限时舍弃掉不常用的数据。

1.1 链表实现简单LRU

使用链表,可以实现最简单的LRU算法:

  1. 维护一个定长的链表
  2. 当一个新的key被访问时
    • 如果这个key不存在链表中,那么新key插入到链表头部;
    • 如果这个key存在链表中,那么将这个key移到链表头部;
  3. 当链表满的时候,如果还有新的key要插入,则将链表尾部的key丢弃。

这种简单的实现固然能达到我们的目的,但也有致命的要求:这种实现的性能不是很好,查询一个key是否存在链表中,以及在链表中的具体位置的时间复杂度是O(n),这在数据数量巨大的场景下是灾难的。

1.2 HashMap和双向链表实现高性能LRU

链表实现的LRU算法瓶颈主要在定位一个key在链表中位置的消耗。

为了规避这个代价,我们可以引入在查询和定位方面具有极高优势的HashMap来作为互补,整体的设计思路是,可以使用 HashMap 存储 key,而HashMap的Value指向双向链表实现的LRU的 Node 节点。这样可以做到save和get的时间都是 O(1)。

假如我们预设链表的大小是3,下图展示了LRU链表在存储和访问过程中的变化。为了简化图复杂度,图中没有展示HashMap部分的变化,仅仅演示了上图LRU双向链表的变化。

1.3 继承LinkedHashMap实现LRU

LinkedHashMap底层就是用的HashMap加双链表实现的,而且本身已经实现了按照访问顺序的存储(也就是其put方法会将最近访问的数据放到表头)。

此外,LinkedHashMap中本身就实现了一个方法removeEldestEntry,用于在每次数据发生变更时(put和get)判断是否需要移除最不常读取的数,方法默认是直接返回false,不会移除元素(也正因此,LinkedHashMap是无限长的)。所以为了将其改造为一个定长且会自动移除队尾数据的链表,需要重写removeEldestEntry方法,即当缓存满后就移除最不常用的数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class LRUCache<K, V> extends LinkedHashMap<K, V> {

private final int CACHE_SIZE;

// 这里就是传递进来最多能缓存多少数据
public LRUCache(int cacheSize) {
// 设置一个hashmap的初始大小,最后一个true指的是让linkedhashmap按照访问顺序来进行排序,最近访问的放在头,最老访问的就在尾
super((int) Math.ceil(cacheSize / 0.75) + 1, 0.75f, true);
CACHE_SIZE = cacheSize;
}

@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
// 当map中的数据量大于指定的缓存个数的时候,就自动删除最老的数据
return size() > CACHE_SIZE;
}
}

2 Redis中的LRU

在讨论Redis的LRU之前,需要明确,Redis的缓存淘汰策略(LRU)与Redis键的过期删除策略不是一回事,LRU是在Redis内存使用超过一定值的时候(一般这个值可以配置)使用的淘汰降级策略;而后者是通过定期删除+惰性删除两者结合的方式进行过期删除的。

2.1 Redis缓存淘汰策略

当内存达到极限时,Redis就要开始利用回收策略对内存进行回收释放。回收的配置在 redis.conf 中填写,如下:

1
2
3
maxmemory 1073741824
maxmemory-policy noeviction
maxmemory-samples 5
  • maxmemory: 指定了内存使用的极限,以字节为单位。当内存达到极限时,他会尝试去删除一些键值。
  • maxmemory-policy:指定删除的策略。Redis提供了如下几种缓存淘汰策略的取值
    • noeviction:当内存使用超过配置的时候(如SET、LPUSH 等等命令)会返回错误,不会驱逐任何键。
    • allkeys-lru:加入键的时候,如果过限,首先通过LRU算法驱逐最久没有使用的键
    • volatile-lru:加入键的时候如果过限,首先从设置了过期时间的键集合中驱逐最久没有使用的键
    • allkeys-random:加入键的时候如果过限,从所有key随机删除
    • volatile-random:加入键的时候如果过限,从过期键的集合中随机驱逐
    • volatile-ttl:从配置了过期时间的键中驱逐过期时间最近 (TTL 最小)的键
    • volatile-lfu:从所有配置了过期时间的键中驱逐使用频率最少的键
    • allkeys-lfu:从所有键中驱逐使用频率最少的键
  • maxmemory-samples :指定了在进行删除时的键的采样数量。LRU 和 TTL 都是近似算法,所以可以根据参数来进行取舍,到底是要速度还是精确度。默认值一般填 5。10 的话已经非常近似正式的 LRU 算法了,但是会多一些 CPU 消耗;3 的话执行更快,然而不够精确。

上述说到的缓存淘汰策略中,带lru后缀的,就是采用Redis LRU算法的策略,带有lfu后缀的策略,就是采用Redis LFU算法的策略(后文详述)。

2.2 Redis中的LRU时钟

在LRU实现中,最核心的要点就是标记哪些数据是“最久”的,前文提到的LRU实现,我们利用链表的顺序来确定哪个数据“最久”,但如果按照性能较好的HashMap和双向链表来实现,在Redis key数量巨大的情况下,HashMap和双向链表的长度也会非常巨大,会牺牲比较大的存储空间,显然是不划算的。

我们知道Redis中的所有对象都被定义为redisObject结构体。Redis LRU算法回收的数据,也正是这些对象。

Redis不采用链表来确定哪些redisObject是最久的,而是在redisObject结构体中定义了一个lru成员来用来记录该对象的最近一次被访问的时间。由于时钟的最大值只需要 24 个比特位就能表示,所以结构体定义时采用了位域。定义如下:

1
2
3
4
5
6
7
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS;
int refcount;
void *ptr;
} robj;

而在Redis在全局中也维护了一个24位全局时钟,可以简单理解为当前系统的时间戳。

1
2
3
4
5
6
7
struct redisServer {
pid_t pid;
char *configfile;
//全局时钟
unsigned lruclock:LRU_BITS;
...
};

Redis每隔一定时间会通过全局的定时器函数serverCron来更新这个时钟。

1
2
3
4
5
int serverCron(...) {
...
server.lruclock = getLRUClock();
...
}

这个时钟的刷新频率由 server.hz 决定,即每秒钟会调用 server.hz (默认值为 10)次 serverCron 函数。那么,服务器每 1 / server.hz 秒就会调用一次定时器函数 serverCron。

当一个对象redisObject新建或者被访问时,redis使用全局lru时钟来赋值对象内的lru时钟。

基于上面的基础,redis就可以很轻易的得到一个对象的空闲时间了:用全局的lru时钟减去对象本身的lru时钟,得到的就是这个对象没有被访问的时间间隔(也称空闲时间,idle time),空闲时间最大的就是需要淘汰的对象。

2.3 Redis LRU回收流程

Redis并不需要一个完全准确的LRU算法,就算移除了一个最近访问过的Key,影响也不大。为了性能计,Redis采用了一个近似LRU的实现:

Redis的数据库是一个巨大的字典,redisDb结构体中,维护着一个全局的,保存了数据库中的所有键值对的字典——dict字典,我们也称它做键空间。还维护着一个保存了所有带过期配置的键值对的字典——expire字典。

当内存使用超过最大使用数(即超过maxmemory的上限)时,就需要采用回收策略进行内存回收。如果回收策略采用带有LRU算法的策略,那么就会使用到Redis的近似LRU算法实现,流程如下

  1. 触发淘汰:在每一次处理客户端命令时。当 server.maxmemory的值非 0,则检测是否有需要回收淘汰的内存,如果有则触发redis.c/freeMemoryIfNeeded(void)函数以清理超出的内存,即步骤2的逻辑

  2. 更新回收池:随机按策略从dict或者expire中取出maxmemory_samples个键(实际取到的数量取决于大字典原本的大小)

    • 然后用一个长度为16(由宏 MAXMEMORY_EVICTION_POOL_SIZE 指定)的evictionPool(回收池)对这几个键进行筛选
    • 依次将取出的键的idle time和evictionPool中最小的idle time比较。将随机取出的键中,idle time比当前evictionPool中最小的idle time还要大的键,按idle time从小到大的顺序插入到evictionPool内的相应位置中(因为evictionPool是定长,所以如果在evictionPool已满的情况下插入新key,则要释放idle time较小的key)。

  1. 删除淘汰的键:最后再从evictionPool池中取出idle time最大且在字典中存在的键作为bestkey执行删除,并且将该key从evictionPool池中移除;

注意这个清理过程是阻塞的,直到清理出足够的内存空间。所以如果在达到maxmemory并且调用方还在不断写入的情况下,可能会反复触发主动清理策略,导致请求会有一定的延迟。

Redis采用回收池,把一个全局排序问题转化成为了局部的比较问题。要想知道idle time最大的key,精确的LRU需要对全局的key的idle time排序,这样的成本对于Redis来说太高了。Redis的LRU算法采用一种近似的思想,即随机采样(samping)若干个key,这若干个key就代表着全局的key,把samping得到的key放到pool里面,每次采样之后更新pool,使得pool里面总是保存着随机选择过的key的idle time最大的那些key。

需要evict key时,直接从pool里面取出idle time最大的key,将之evict掉。这种思想是很值得借鉴的。

而且,Redis团队经过试验,发现当samples=10时,Redis随机的LRU算法,已经能够很准确的淘汰掉最久没有使用的键,其效果和精确的LRU基本持平。如下图(浅灰色表示已经删除的键,深灰色表示没有被删除的键,绿色表示新加入的键,越往上表示键加入的时间越久):

3 LFU算法

LFU(Least Frequently used)算法,也叫作最近最少使用算法,顾名思义,就是淘汰缓存里面用的最少的数据。它根据数据的访问频次来进行淘汰数据,一个数据被访问过,把它的频次+1,发生淘汰的时候,把频次低的淘汰掉。

3.1 使用双哈希表实现高性能LFU

有了LRU的打底,我们知道,在排序问题中(LFU和LRU本质都是排序问题)要想实现O(1)时间复杂度的get性能,必须要借助哈希表来实现。但LFU相比LRU有个难点:频次相比于访问时间,更容易重复,即容易同时出现多于一个的key,他们的频次是一样的,且都是最低的。这时候出现平局,则需要在频次最低的基础上,再在重复的key中间,找到最久未使用的key,并淘汰。

也就是说,LFU的实现,除了要按照访问频率来排序,还要按照访问时间来排序。排序顺序是:访问频率降序>访问时间降序。

为了达到上述目的,并且达到put和get都为O(1)复杂度,那么我们引入了双哈希表。

  • 第一个哈希表的含义是HashMap<缓存的key,缓存数据节点的地址>
    • 第一个哈希表,和lru的实现一样,是用来实现O(1)时间查找key对应的节点。
  • 第一个哈希表的含义是HashMap<访问频率,链表的头结点的地址>
    • 这个哈希表的每一个value,都是采用拉链法,挂上了一个缓存数据节点组成的双向链表(链表节点按照访问时间从近到远排序,表头访问时间最近,表尾访问时间最远)。
    • 该哈希表的value值指向链表头部,而这个双向链表内存的,都是目前访问频率为其value对应的key值的缓存数据。
    • 比如key=3的value是一个三个节点的链表,则表示这个链表内的三个缓存节点,访问频次都是3次。

一图胜万言:

这样的实现下,我们对于get和put操作就可以:

  • get:如果第一个哈希表中能查到key,那么取得相应链表节点数据。接下来在第二个哈希表中,把该节点移到其访问频率+1位置的链表头部。
  • put:如果第一个哈希表中能查找key,那么操作和get(key)一样,只是最后要把新节点的value更新为新value。
  • 当发生淘汰时:也就是要执行put操作,但是容量已经达到限制时,这时直接找到第二个哈希表中最小引用计数的链表,删除其末尾节点(最晚使用)。之后再添加新节点即可。

容量超限需要删除节点时,删除了第二个哈希表中的项的同时,第一个哈希表中对应的映射也应该删掉。

需要在双哈希表之外维护一个额外的min_cnt变量用来保存当前的最小访问频率。因为容量超限需要删除节点时,我们需要O(1)时间找到需要删除的节点。及调用get(min_cnt)来定位到要被删除的那个链表。

4 Redis中的LFU

Redis4.0开始,maxmemory_policy淘汰策略添加了两个LFU模式:

  • volatile-lfu:对有过期时间的key采用LFU淘汰算法
  • allkeys-lfu:对全部key采用LFU淘汰算法

使用这两种淘汰策略,便会使用到Redis的LFU算法,一种近似计数算法。

4.1 常规LFU算法面临的问题

在数据请求模式比较稳定(没有对于某个数据突发的高频访问这样的不稳定模式)的情况下,LFU的表现还是很不错的。

但在数据的请求模式大多不稳定的情况下,LFU一般会有这样一些问题:

  1. 热点数据问题:热点数据一般只是几天内有较高的访问频次,过了这段时间就没那么大意义去缓存了。但是因为在热点期间他的频次被刷上去了,导致之后很长一段时间内很难被淘汰;
  2. 新增数据问题:如果采用只记录缓存中的数据的访问信息,新加入的高频访问数据在刚加入的时候由于没有累积优势,很容易被淘汰掉;
  3. 空间问题:如果记录全部出现过的数据的访问信息,会占用更多的内存空间。

对于上面这些问题,其实也都有一些对应的解决方式,相应的出现了很多LFU的变种。如:Window-LFU、LFU*、LFU-Aging。在Redis的LFU算法实现中,也有其解决方案。

4.2 Redis中的频次计算

在常规操作中,我们一般会引入一个字段作为计数器,对每个key的访问频次做简单的加法,但这样的实现显然无法规避上述的三个问题:一味做加法,过期的热点数据很难淘汰;新增的数据频次太低,容易被淘汰;Redis的访问频次量级非常大,每个key都维护一个长的字段,空间代价太大。

为了解决这三个问题,Redis的频次计算实现,引入了三个策略:

  1. 概率量级计数:该策略可以解决空间问题。
    • 可配参数server.lfu_log_factor就服务于该策略,它能够影响计数的量级范围,整计数器counter的增长速度,lfu-log-factor越大,counter增长的越慢。
  2. 计数衰减:该策略可以解决热点数据问题。
    • 可配参数server.lfu-decay-time就服务于该策略,它能够控制LFU计数衰减,是一个以分钟为单位的数值,可以调整counter的减少速度。
  3. 新增数据赋值:该策略可以解决新增数据问题。
    • 固定常量LFU_INIT_VAL就服务于该策略,其值默认为5,即为新生key的counter设置一个初始频次,默认为5。

4.2.1 概率量级计数

Redis的LFU实现也是需要为每个key维护一个字段来承载该key的访问频次的,而且这个字段不能太大,不然Redis这么多key,那么消耗的空间将是一个可怕的数字,同时,本着Redis一贯对空间锱铢必较的心态,能重复利用的字段,我们绝不维护新的字段。

1
2
3
4
5
6
7
8
9
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;

看来看去,redisObject结构中,也只有lru字段可以重复利用了,因为淘汰策略是互斥的,Redis同时只能选择一种淘汰策略,要么LRU,要么LFU,要么其他,所以lru字段重复利用不会冲突。

在LRU算法中,24 bits的lru是用来记录LRU time的,在LFU中使用这个字段,却是分成16 bits与8 bits使用:

1
2
3
4
*          16 bits      8 bits
* +----------------+--------+
* + Last decr time | LOG_C |
* +----------------+--------+

高16 bits用来记录最近一次计数器衰减的时间ldt,单位是分钟,这个我们下文再说。

低8 bits记录计数器数值counter。8个bit位最大为255,显然如果只是简单的对counter做加法,那8 bit的counter根本无法容纳Redis那动辄百万或千万级别的命中频次。

那么,Redis如何使用8 bit的counter来承载百万或者千万级别的命中频次呢?相关源码在evict.c文件中的LFULogIncr方法中实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* Logarithmically increment a counter. The greater is the current counter value
* the less likely is that it gets really implemented. Saturate it at 255. */
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255;
//这里的rand()方法会生成一个 0 ~ RAND_MAX 之间的随机数,所以r的范围也就是0~1之间。
double r = (double)rand()/RAND_MAX;
double baseval = counter - LFU_INIT_VAL;
if (baseval < 0) baseval = 0;
//根据目前counter和server.lfu_log_factor值得出一个p
double p = 1.0/(baseval*server.lfu_log_factor+1);
//如果r < p,counter才+1
if (r < p) counter++;
return counter;
}

我们先看p字段:

对于p=1.0/(baseval*server.lfu_log_factor+1);

等价于p=1/((counter−LFU_INIT_VAL)*factor+1);

因为LFU_INIT_VAL是常数,所以当counter够大时,近似等于:p=1/(counter*factor+1)

factor是个常数,server.lfu_log_factor默认值是10,下图展示了factor不同时,p=f(counter)的函数曲线

紧接着再来看r,r是由random函数随机出来的范围在0~1之间的值,我们可以认为r的值是随机的,那么我们可以认为:

r的值在0 ~ 1范围内,也就是r<=1的概率为100%(1);

r的值在0 ~ 0.9范围内,也就是r<=0.9的概率为90%(0.9);

以此类推。

r的值在0 ~ p的范围内,也就是r<=p的概率为p;

所以综上所诉,Redis的概率量级计数的核心逻辑就是:

  1. 每一次key被访问,counter都有近似p=1/(counter*factor+1)的概率会+1。在factor是常数的情况下,counter+1的概率随着counter值的增大而减小。

  2. factor值我们设置的越大,则counter+1的概率在同等情况下则会越低,counter字段8 bit一共255的上限也就越不容易被触达,换句话说,factor越大,Redis的counter字段能够记录的访问频次量级也就越高。

概率量级计数,就体现在p和factor上,p控制的是counter的概率上升,factor控制的是counter承载的访问量级。

下表是不同的factor的值能够控制计数代表的量级的范围,当factor为100时,能够最大代表10M,也就是千万级别的命中数。

factor 100 hits 1000 hits 100K hits 1M hits 10M hits
0 104 255 255 255 255
1 18 49 255 255 255
10 10 18 142 255 255
100 8 11 49 143 255

下图是不同factor场景下,不同key的counter字段的值(颜色不同的线)在固定访问频率下随着时间的上升走势。

4.2.2 计数衰减

上一章节我们讲了counter是概率增加,但为了解决热点问题,使热点数据能够随着时间推移慢慢的降低频次,以至于最后淘汰,那么Redis引入了计数衰减的策略。

某个key的counter被衰减的时机是在它被访问的时候。在缓存被访问时,会更新数据的访问计数,更新的步骤是:

  1. 先在现有数据的计数上进行计数衰减。
  2. 再对完成衰减后的计数进行概率增加。

所以要注意,计数衰减的触发也是被动的,而非Redis主动或者定时触发的。

计数衰减的实现在LFUDecrAndReturn方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/* Return the current time in minutes, just taking the least significant
* 16 bits. The returned time is suitable to be stored as LDT (last decrement
* time) for the LFU implementation. */
unsigned long LFUGetTimeInMinutes(void) {
return (server.unixtime/60) & 65535;
}

/* Given an object last access time, compute the minimum number of minutes
* that elapsed since the last access. Handle overflow (ldt greater than
* the current 16 bits minutes time) considering the time as wrapping
* exactly once. */
unsigned long LFUTimeElapsed(unsigned long ldt) {
//计算当前时间和ldt的时间差值,如果now < ldt,默认为过了一个周期了,那么差值应该是65535-ldt+now。
unsigned long now = LFUGetTimeInMinutes();
if (now >= ldt) return now-ldt;
return 65535-ldt+now;
}

/* If the object decrement time is reached decrement the LFU counter but
* do not update LFU fields of the object, we update the access time
* and counter in an explicit way when the object is really accessed.
* And we will times halve the counter according to the times of
* elapsed time than server.lfu_decay_time.
* Return the object frequency counter.
*
* This function is used in order to scan the dataset for the best object
* to fit: as we check for the candidate, we incrementally decrement the
* counter of the scanned objects if needed. */
unsigned long LFUDecrAndReturn(robj *o) {
unsigned long ldt = o->lru >> 8;
unsigned long counter = o->lru & 255;
//算出该key已经经历过num_periods个周期了
unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
if (num_periods)
counter = (num_periods > counter) ? 0 : counter - num_periods;
return counter;
}

代码很晦涩,没关系,逻辑其实并不复杂:

  1. 可配参数server.lfu-decay-time所代表的含义是计数衰减的周期长度,单位是分钟。当时间过去一个周期(也就是lfu-decay-time分钟),计数值就会减1。

  2. redisObject结构中的lru字段的高16bit,记录的是该key上次进行衰减的时间。

  3. 有上述两个数据可以算出从上次衰减到现在,该key已经经历过n个周期了,这也表示着,key需要先将counter衰减n。

    • n的计算过程如代码所示,即从上次衰减到现在经过的时间除以衰减周期长度 server.lfu_decay_time:
    • unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
  4. 通过LFUDecrAndReturn方法得到该key的counter需要衰减的值n,将counter=counter-n,然后再执行概率增加计数的操作。

4.2.3 新增数据赋值

为了解决新增数据问题,即如果采用只记录缓存中的数据的访问信息,新加入的高频访问数据在刚加入的时候由于没有累积优势,很容易被淘汰掉;

那么对于新增加的key,则不能将他们的counter设为0,Redis为新增的key的counter设置了一个初始值,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
robj *createObject(int type, void *ptr) {
robj *o = zmalloc(sizeof(*o));
o->type = type;
o->encoding = OBJ_ENCODING_RAW;
o->ptr = ptr;
o->refcount = 1;

/* Set the LRU to the current lruclock (minutes resolution), or
* alternatively the LFU counter. */
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
} else {
o->lru = LRU_CLOCK();
}
return o;
}

即counter会被初始化为LFU_INIT_VAL,默认5。

回顾我们上文说道的p=1/((counter−LFU_INIT_VAL)*factor+1),可以看到,当计数值等于LFU_INIT_VAL时, p=1,也就是说,对于新增的key,下一次访问时,counter增加的概率为100%

4.3 Redis LFU回收流程

Redis LFU回收流程和Redis LRU的回收流程一模一样(有所遗忘可以回顾本文2.3章),都是采用抽样+回收池的实现方式,不同的是LRU比较的是idle time空闲时间,而LFU比较的是counter访问频次。故不再赘述。

JAVA静态/动态代理

Posted on 2020-07-26 | In JAVA , JAVA实现或特性 |
Words count in article: 4.8k | Reading time ≈ 24

1 静态代理(简单描述)

先定义一个接口,里面定义目标方法

1
2
3
4
5
6
//目标类要实现的接口
public interface ITarget {

//目标方法
void doFunc(String words);
}

定义一个代理类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class StaticProxy implements ITarget{

private ITarget target = null;

//关联要增强的目标类
    public StaticProxy(ITarget target){
        this.target = target;
    }

//在这里增强目标类的目标方法
@Override
public void doFunc() {
...
...
...
        //增强目标方法
        target.doFunc();
        ...
...
...
}
}

以后,任意的一个ITarget接口的子类,都可以注入给StaticProxy类,然后实现一套增强,不再赘述。

2 动态代理

代理类在程序运行时创建的代理方式被成为 动态代理。也就是说,这种情况下,代理类并不是像静态代理一样,是在Java代码中定义的,而是在运行时根据我们在Java代码中的“指示”动态生成的。

动态代理是spring AOP的实现原理,spring有两种动态代理模式,cglib和jdk,我们先来将java jdk的动态代理。

2.1 jdk的动态代理

首先,我们需要知道,jdk的动态代理只能代理实现了接口的类 没有实现接口的类不能实现JDK动态代理。其次,我们还要了解一个重要的中介接口InvocationHandler,这是jdk的动态代理的基石,它的定义如下:

1
2
3
4
5
public interface InvocationHandler {

public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}

我们先来写一个jdk的动态代理实例,再来讨论其中的原理吧

2.1.1 jdk动态代理实例

我们先来定义一个目标类,或者说委托类,或者又叫被代理类,它实现了我们上面定义的那个接口ITarget:

1
2
3
4
5
public class Entrust implements ITarget {
public void doFunc(String words){
System.out.println(words);
}
}

再定义一个中介类,实现InvocationHandler接口,这个中介类,持有被代理的对象,在invoke中利用反射,调用目标类的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class JdkDynamicProxyHandler<T>  implements InvocationHandler {
//invocationHandler持有的被代理对象
T target;

public JdkDynamicProxyHandler(T target) {
this.target = target;
}
/**
* proxy:代表动态代理对象
* method:代表正在执行的方法
* args:代表调用目标方法时传入的实参
*
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("do before");
Object result = method.invoke(target, args);
System.out.println("do after");
return result;
}
}

好了,我们现在来写一个代理demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public static void main(String[] a){
//创建一个实例对象,这个对象是被代理的对象
ITarget attorney = new Attorney();

//创建一个与代理对象相关联的InvocationHandler
InvocationHandler jdkDynamicProxyHandler = new JdkDynamicProxyHandler<ITarget>(attorney);

//创建一个代理对象proxy来代理attorney,代理对象的每个执行方法都会替换执行Invocation中的invoke方法
ITarget proxy = (ITarget) Proxy.newProxyInstance(ITarget.class.getClassLoader(), new Class<?>[]{ITarget.class}, jdkDynamicProxyHandler);

//代理执行上交班费的方法
proxy.doFunc("hello word");

//将生成的代理类写到桌面
writeProxyClassToHardDisk("/home/ls/Desktop/$Proxy22.class");
}

public static void writeProxyClassToHardDisk(String path) {
byte[] classFile = ProxyGenerator.generateProxyClass("$Proxy22", Attorney.class.getInterfaces());
FileOutputStream out = null;
try {
out = new FileOutputStream(path);
out.write(classFile);
out.flush();
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}

最后输出:

2.1.2 原理剖析

我们来看看Proxy.newProxyInstance方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)
throws IllegalArgumentException
{
Objects.requireNonNull(h);

final Class<?>[] intfs = interfaces.clone();
final SecurityManager sm = System.getSecurityManager();
if (sm != null) {
//验证一些参数
checkProxyAccess(Reflection.getCallerClass(), loader, intfs);
}

/*
* Look up or generate the designated proxy class.
* 从缓存里面获取某个类的代理类,如果该类的代理类不存在,就根据该类的类型创建一个
* 如果要深挖逻辑,可以看看ProxyClassFactory的apply方法。
* 其实生成代理类字节码文件的工作是通过 ProxyGenerate类中的generateProxyClass方法来完成的。
*/
Class<?> cl = getProxyClass0(loader, intfs);

/*
* Invoke its constructor with the designated invocation handler.
*
* /** parameter types of a proxy class constructor */
* private static final Class<?>[] constructorParams = { InvocationHandler.class };
*
*/
try {
if (sm != null) {
checkNewProxyPermission(Reflection.getCallerClass(), cl);
}
//看上面的注释,constructorParams={ InvocationHandler.class },
//这是在生成代理类的构造函数,获得一个参数为InvocationHandler的构造方法
final Constructor<?> cons = cl.getConstructor(constructorParams);
final InvocationHandler ih = h;
if (!Modifier.isPublic(cl.getModifiers())) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
cons.setAccessible(true);
return null;
}
});
}
//这行代码的意思是将h,也就是实现InvocationHandler的实现类,
//我们传入的是jdkDynamicProxyHandler,注入到cons中。
//然后newInstance生成一个已经组装过参数的代理类。
return cons.newInstance(new Object[]{h});
} catch (IllegalAccessException|InstantiationException e) {
throw new InternalError(e.toString(), e);
} catch (InvocationTargetException e) {
Throwable t = e.getCause();
if (t instanceof RuntimeException) {
throw (RuntimeException) t;
} else {
throw new InternalError(t.toString(), t);
}
} catch (NoSuchMethodException e) {
throw new InternalError(e.toString(), e);
}
}

我们最应该关注的是 Class<?> cl = getProxyClass0(loader, intfs);这句,这里产生了代理类,后面代码中的构造器也是通过这里产生的类来获得,可以看出,这个类的产生就是整个动态代理的关键,由于是动态生成的类文件,我这里不具体进入分析如何产生的这个类文件,只需要知道这个类文件时缓存在java虚拟机中的。

我们对这个代理类进行反编译:(本次使用http://www.javadecompilers.com/在线反编译工具 )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
import java.lang.reflect.UndeclaredThrowableException;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import aopLearn.ITarget;
import java.lang.reflect.Proxy;

//
// Decompiled by Procyon v0.5.30
//

public final class $Proxy22 extends Proxy implements ITarget
{
private static Method m1;
private static Method m3;
private static Method m2;
private static Method m0;
/**
*注意这里是生成代理类的构造方法,方法参数为InvocationHandler类型,看到这,是不是就有点明白了
*为何代理对象调用方法都是执行InvocationHandler中的invoke方法,而InvocationHandler又持有一个
*被代理对象的实例
*
*super(paramInvocationHandler),是调用父类Proxy的构造方法。
*父类持有:protected InvocationHandler h;
*Proxy构造方法:
* protected Proxy(InvocationHandler h) {
* Objects.requireNonNull(h);
* this.h = h;
* }
*
*/
public $Proxy22(final InvocationHandler invocationHandler) {
super(invocationHandler);
}

//这个静态块本来是在最后的,我把它拿到前面来,方便描述
static {
try {
//doFunc通过反射得到的名字m3
$Proxy22.m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
$Proxy22.m3 = Class.forName("aopLearn.ITarget").getMethod("doFunc", Class.forName("java.lang.String"));
$Proxy22.m2 = Class.forName("java.lang.Object").getMethod("toString", (Class<?>[])new Class[0]);
$Proxy22.m0 = Class.forName("java.lang.Object").getMethod("hashCode", (Class<?>[])new Class[0]);
}
catch (NoSuchMethodException ex) {
throw new NoSuchMethodError(ex.getMessage());
}
catch (ClassNotFoundException ex2) {
throw new NoClassDefFoundError(ex2.getMessage());
}
}

public final boolean equals(final Object o) {
try {
return (boolean)super.h.invoke(this, $Proxy22.m1, new Object[] { o });
}
catch (Error | RuntimeException error) {
throw;
}
catch (Throwable t) {
throw new UndeclaredThrowableException(t);
}
}
/**
*
*这里调用代理对象的doFunc方法,直接就调用了InvocationHandler中的invoke方法,并把m3传了进去。
*this.h.invoke(this, m3, null);这里简单,明了。
*代理对象持有一个InvocationHandler对象,InvocationHandler对象持有一个被代理的对象,
*再联系到InvacationHandler中的invoke方法。其实就是代理对象调用InvocationHandler,
* InvocationHandler对象反射调用委托类对象。
*/
public final void doFunc(final String s) {
try {
super.h.invoke(this, $Proxy22.m3, new Object[] { s });
}
catch (Error | RuntimeException error) {
throw;
}
catch (Throwable t) {
throw new UndeclaredThrowableException(t);
}
}

public final String toString() {
try {
return (String)super.h.invoke(this, $Proxy22.m2, null);
}
catch (Error | RuntimeException error) {
throw;
}
catch (Throwable t) {
throw new UndeclaredThrowableException(t);
}
}

public final int hashCode() {
try {
return (int)super.h.invoke(this, $Proxy22.m0, null);
}
catch (Error | RuntimeException error) {
throw;
}
catch (Throwable t) {
throw new UndeclaredThrowableException(t);
}
}

}

看完了这些,我们来想一下,为什么jdk的动态代理,一定要委托类实现一个接口?这是因为我们可以看到,我们生成的代理类Proxy22 extends Proxy implements ITarget,已经继承了Proxy类,而java中不能多继承,为了让$Proxy22和委托类建立联系,只能实现一个接口。这里的建立联系,是指通过接口,得到委托类方法的反射等,并且,委托类实现自接口的方法,才能被增强。

故而,本质上来说,jdk的动态代理,是为接口产生代理。

在spring AOP中,我们使用jdk动态代理时当然也要定义InvocationHandler的实现类对象,spring中的是org.springframework.aop.framework.JdkDynamicAopProxy类。

2.2 cglib的动态代理

cglib的动态代理针对类来实现代理,对指定目标产生一个子类 通过方法拦截技术拦截所有父类方法的调用。我们要使用cglib代理必须引入cglib的jar包。

2.2.1 cglib动态代理实例

同样,定义一个跟上面例子一样的委托类。

1
2
3
4
5
public class Entrust {
public void doFunc(String words){
System.out.println(words);
}
}

实现MethodInterceptor接口生成方法拦截器

1
2
3
4
5
6
7
8
9
10
public class EntrustInterceptor implements MethodInterceptor{

@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
System.out.println("before");
Object o = proxy.invokeSuper(obj,args);
System.out.println("after");
return o;
}
}

实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] a){
//cglib自带的debug工具,可以将代理类输出到指定路径
System.setProperty(DebuggingClassWriter.DEBUG_LOCATION_PROPERTY, "/home/ls/Desktop/cglib");
Enhancer enhancer = new Enhancer();
//继承被代理类
enhancer.setSuperclass(Entrust.class);
enhancer.setCallback(new EntrustInterceptor());
//生成的代理类对象
Entrust entrust = (Entrust) enhancer.create();
//在调用我们代理类中的方法时会被我们实现的方法拦截器拦截
entrust.doFunc("hello word");
}

输出结果如下:

2.2.2 原理剖析

CGLIB会让生成的代理类继承被代理类,并在代理类中对代理方法进行强化处理(前置处理、后置处理等)。在CGLIB底层,其实是借助了ASM这个非常强大的Java字节码生成框架。

我们看到,代理类对象是由Enhancer类创建的。Enhancer是CGLIB的字节码增强器,可以很方便的对类进行拓展,创建代理对象的几个步骤:

  1. 生成代理类的二进制字节码文件;
  2. 加载二进制字节码,生成Class对象( 例如使用Class.forName()方法 );
  3. 通过反射机制获得实例构造,并创建代理类对象

我们来看看将代理类Class文件反编译之后的Java代码,一个动态代理,产生了三个类:

主要的代理类是

Entrust$$EnhancerByCGLIB$$832e20ab

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
package com.lufax.util.aopCache.cglibProxy;

import net.sf.cglib.core.Signature;
import net.sf.cglib.core.ReflectUtils;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.Callback;
import net.sf.cglib.proxy.Factory;

/**
* 生成的代理类Entrust$$EnhancerByCGLIB$$832e20ab继承被代理类Entrust。
* 在这里我们需要注意一点:如果委托类被final修饰,那么它不可被继承,即不可被代理;
* 同样,如果委托类中存在final修饰的方法,那么该方法也不可被代理;
*
*/
public class Entrust$$EnhancerByCGLIB$$832e20ab extends Entrust implements Factory
{
private boolean CGLIB$BOUND;
private static final ThreadLocal CGLIB$THREAD_CALLBACKS;
private static final Callback[] CGLIB$STATIC_CALLBACKS;
private MethodInterceptor CGLIB$CALLBACK_0;
private static final Method CGLIB$doFunc$0$Method;
private static final MethodProxy CGLIB$doFunc$0$Proxy;
private static final Object[] CGLIB$emptyArgs;
private static final Method CGLIB$finalize$1$Method;
private static final MethodProxy CGLIB$finalize$1$Proxy;
private static final Method CGLIB$equals$2$Method;
private static final MethodProxy CGLIB$equals$2$Proxy;
private static final Method CGLIB$toString$3$Method;
private static final MethodProxy CGLIB$toString$3$Proxy;
private static final Method CGLIB$hashCode$4$Method;
private static final MethodProxy CGLIB$hashCode$4$Proxy;
private static final Method CGLIB$clone$5$Method;
private static final MethodProxy CGLIB$clone$5$Proxy;

static void CGLIB$STATICHOOK1() {
CGLIB$THREAD_CALLBACKS = new ThreadLocal();
CGLIB$emptyArgs = new Object[0];
final Class<?> forName = Class.forName("com.lufax.util.aopCache.cglibProxy.Entrust$$EnhancerByCGLIB$$832e20ab");
final Class<?> forName2;
final Method[] methods = ReflectUtils.findMethods(new String[] { "finalize", "()V", "equals", "(Ljava/lang/Object;)Z", "toString", "()Ljava/lang/String;", "hashCode", "()I", "clone", "()Ljava/lang/Object;" }, (forName2 = Class.forName("java.lang.Object")).getDeclaredMethods());
CGLIB$finalize$1$Method = methods[0];
CGLIB$finalize$1$Proxy = MethodProxy.create((Class)forName2, (Class)forName, "()V", "finalize", "CGLIB$finalize$1");
CGLIB$equals$2$Method = methods[1];
CGLIB$equals$2$Proxy = MethodProxy.create((Class)forName2, (Class)forName, "(Ljava/lang/Object;)Z", "equals", "CGLIB$equals$2");
CGLIB$toString$3$Method = methods[2];
CGLIB$toString$3$Proxy = MethodProxy.create((Class)forName2, (Class)forName, "()Ljava/lang/String;", "toString", "CGLIB$toString$3");
CGLIB$hashCode$4$Method = methods[3];
CGLIB$hashCode$4$Proxy = MethodProxy.create((Class)forName2, (Class)forName, "()I", "hashCode", "CGLIB$hashCode$4");
CGLIB$clone$5$Method = methods[4];
CGLIB$clone$5$Proxy = MethodProxy.create((Class)forName2, (Class)forName, "()Ljava/lang/Object;", "clone", "CGLIB$clone$5");
final Class<?> forName3;
CGLIB$doFunc$0$Method = ReflectUtils.findMethods(new String[] { "doFunc", "(Ljava/lang/String;)V" }, (forName3 = Class.forName("com.lufax.util.aopCache.cglibProxy.Entrust")).getDeclaredMethods())[0];
CGLIB$doFunc$0$Proxy = MethodProxy.create((Class)forName3, (Class)forName, "(Ljava/lang/String;)V", "doFunc", "CGLIB$doFunc$0");
}
//代理类会为委托方法生成两个方法,一个是重写的doFunc方法,
//另一个是CGLIB$doFunc$0方法,我们可以看到它是直接调用父类的doFunc方法;

final void CGLIB$doFunc$0(final String s) {
super.doFunc(s);
}
//当执行代理对象的doFunc方法时,会首先判断一下是否存在实现了MethodInterceptor接口的CGLIB$CALLBACK_0;
//如果存在,则将调用MethodInterceptor中的intercept方法,如图2.1。
public final void doFunc(final String s) {
MethodInterceptor cglib$CALLBACK_2;
MethodInterceptor cglib$CALLBACK_0;
if ((cglib$CALLBACK_0 = (cglib$CALLBACK_2 = this.CGLIB$CALLBACK_0)) == null) {
CGLIB$BIND_CALLBACKS(this);
cglib$CALLBACK_2 = (cglib$CALLBACK_0 = this.CGLIB$CALLBACK_0);
}
if (cglib$CALLBACK_0 != null) {
//这里开始调用我们定义的EntrustInterceptor中的intercept方法。
//参数1、代理对象;2、委托类方法;3、方法参数;4、代理方法的MethodProxy对象(注意这个对象)。
cglib$CALLBACK_2.intercept((Object)this, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$doFunc$0$Method, new Object[] { s }, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$doFunc$0$Proxy);
return;
}
super.doFunc(s);
}

final void CGLIB$finalize$1() throws Throwable {
super.finalize();
}

protected final void finalize() throws Throwable {
MethodInterceptor cglib$CALLBACK_2;
MethodInterceptor cglib$CALLBACK_0;
if ((cglib$CALLBACK_0 = (cglib$CALLBACK_2 = this.CGLIB$CALLBACK_0)) == null) {
CGLIB$BIND_CALLBACKS(this);
cglib$CALLBACK_2 = (cglib$CALLBACK_0 = this.CGLIB$CALLBACK_0);
}
if (cglib$CALLBACK_0 != null) {
cglib$CALLBACK_2.intercept((Object)this, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$finalize$1$Method, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$emptyArgs, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$finalize$1$Proxy);
return;
}
super.finalize();
}

final boolean CGLIB$equals$2(final Object o) {
return super.equals(o);
}

public final boolean equals(final Object o) {
MethodInterceptor cglib$CALLBACK_2;
MethodInterceptor cglib$CALLBACK_0;
if ((cglib$CALLBACK_0 = (cglib$CALLBACK_2 = this.CGLIB$CALLBACK_0)) == null) {
CGLIB$BIND_CALLBACKS(this);
cglib$CALLBACK_2 = (cglib$CALLBACK_0 = this.CGLIB$CALLBACK_0);
}
if (cglib$CALLBACK_0 != null) {
final Object intercept = cglib$CALLBACK_2.intercept((Object)this, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$equals$2$Method, new Object[] { o }, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$equals$2$Proxy);
return intercept != null && (boolean)intercept;
}
return super.equals(o);
}

final String CGLIB$toString$3() {
return super.toString();
}

public final String toString() {
MethodInterceptor cglib$CALLBACK_2;
MethodInterceptor cglib$CALLBACK_0;
if ((cglib$CALLBACK_0 = (cglib$CALLBACK_2 = this.CGLIB$CALLBACK_0)) == null) {
CGLIB$BIND_CALLBACKS(this);
cglib$CALLBACK_2 = (cglib$CALLBACK_0 = this.CGLIB$CALLBACK_0);
}
if (cglib$CALLBACK_0 != null) {
return (String)cglib$CALLBACK_2.intercept((Object)this, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$toString$3$Method, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$emptyArgs, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$toString$3$Proxy);
}
return super.toString();
}

final int CGLIB$hashCode$4() {
return super.hashCode();
}

public final int hashCode() {
MethodInterceptor cglib$CALLBACK_2;
MethodInterceptor cglib$CALLBACK_0;
if ((cglib$CALLBACK_0 = (cglib$CALLBACK_2 = this.CGLIB$CALLBACK_0)) == null) {
CGLIB$BIND_CALLBACKS(this);
cglib$CALLBACK_2 = (cglib$CALLBACK_0 = this.CGLIB$CALLBACK_0);
}
if (cglib$CALLBACK_0 != null) {
final Object intercept = cglib$CALLBACK_2.intercept((Object)this, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$hashCode$4$Method, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$emptyArgs, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$hashCode$4$Proxy);
return (intercept == null) ? 0 : ((Number)intercept).intValue();
}
return super.hashCode();
}

final Object CGLIB$clone$5() throws CloneNotSupportedException {
return super.clone();
}

protected final Object clone() throws CloneNotSupportedException {
MethodInterceptor cglib$CALLBACK_2;
MethodInterceptor cglib$CALLBACK_0;
if ((cglib$CALLBACK_0 = (cglib$CALLBACK_2 = this.CGLIB$CALLBACK_0)) == null) {
CGLIB$BIND_CALLBACKS(this);
cglib$CALLBACK_2 = (cglib$CALLBACK_0 = this.CGLIB$CALLBACK_0);
}
if (cglib$CALLBACK_0 != null) {
return cglib$CALLBACK_2.intercept((Object)this, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$clone$5$Method, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$emptyArgs, Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$clone$5$Proxy);
}
return super.clone();
}

public static MethodProxy CGLIB$findMethodProxy(final Signature signature) {
final String string = signature.toString();
switch (string.hashCode()) {
case -1574182249: {
if (string.equals("finalize()V")) {
return Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$finalize$1$Proxy;
}
break;
}
case -508378822: {
if (string.equals("clone()Ljava/lang/Object;")) {
return Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$clone$5$Proxy;
}
break;
}
case 346793840: {
if (string.equals("doFunc(Ljava/lang/String;)V")) {
return Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$doFunc$0$Proxy;
}
break;
}
case 1826985398: {
if (string.equals("equals(Ljava/lang/Object;)Z")) {
return Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$equals$2$Proxy;
}
break;
}
case 1913648695: {
if (string.equals("toString()Ljava/lang/String;")) {
return Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$toString$3$Proxy;
}
break;
}
case 1984935277: {
if (string.equals("hashCode()I")) {
return Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$hashCode$4$Proxy;
}
break;
}
}
return null;
}

public Entrust$$EnhancerByCGLIB$$832e20ab() {
CGLIB$BIND_CALLBACKS(this);
}

public static void CGLIB$SET_THREAD_CALLBACKS(final Callback[] array) {
Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$THREAD_CALLBACKS.set(array);
}

public static void CGLIB$SET_STATIC_CALLBACKS(final Callback[] cglib$STATIC_CALLBACKS) {
CGLIB$STATIC_CALLBACKS = cglib$STATIC_CALLBACKS;
}

private static final void CGLIB$BIND_CALLBACKS(final Object o) {
final Entrust$$EnhancerByCGLIB$$832e20ab entrust$$EnhancerByCGLIB$$832e20ab = (Entrust$$EnhancerByCGLIB$$832e20ab)o;
if (!entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$BOUND) {
entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$BOUND = true;
Object o2;
if ((o2 = Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$THREAD_CALLBACKS.get()) != null || (o2 = Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$STATIC_CALLBACKS) != null) {
entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$CALLBACK_0 = (MethodInterceptor)((Callback[])o2)[0];
}
}
}

public Object newInstance(final Callback[] array) {
CGLIB$SET_THREAD_CALLBACKS(array);
final Entrust$$EnhancerByCGLIB$$832e20ab entrust$$EnhancerByCGLIB$$832e20ab = new Entrust$$EnhancerByCGLIB$$832e20ab();
CGLIB$SET_THREAD_CALLBACKS(null);
return entrust$$EnhancerByCGLIB$$832e20ab;
}

public Object newInstance(final Callback callback) {
CGLIB$SET_THREAD_CALLBACKS(new Callback[] { callback });
final Entrust$$EnhancerByCGLIB$$832e20ab entrust$$EnhancerByCGLIB$$832e20ab = new Entrust$$EnhancerByCGLIB$$832e20ab();
CGLIB$SET_THREAD_CALLBACKS(null);
return entrust$$EnhancerByCGLIB$$832e20ab;
}

public Object newInstance(final Class[] array, final Object[] array2, final Callback[] array3) {
CGLIB$SET_THREAD_CALLBACKS(array3);
switch (array.length) {
case 0: {
final Entrust$$EnhancerByCGLIB$$832e20ab entrust$$EnhancerByCGLIB$$832e20ab = new Entrust$$EnhancerByCGLIB$$832e20ab();
CGLIB$SET_THREAD_CALLBACKS(null);
return entrust$$EnhancerByCGLIB$$832e20ab;
}
default: {
throw new IllegalArgumentException("Constructor not found");
}
}
}

public Callback getCallback(final int n) {
CGLIB$BIND_CALLBACKS(this);
Object cglib$CALLBACK_0 = null;
switch (n) {
case 0: {
cglib$CALLBACK_0 = this.CGLIB$CALLBACK_0;
break;
}
default: {
cglib$CALLBACK_0 = null;
break;
}
}
return (Callback)cglib$CALLBACK_0;
}

public void setCallback(final int n, final Callback callback) {
switch (n) {
case 0: {
this.CGLIB$CALLBACK_0 = (MethodInterceptor)callback;
break;
}
}
}

public Callback[] getCallbacks() {
CGLIB$BIND_CALLBACKS(this);
return new Callback[] { this.CGLIB$CALLBACK_0 };
}

public void setCallbacks(final Callback[] array) {
this.CGLIB$CALLBACK_0 = (MethodInterceptor)array[0];
}

static {
CGLIB$STATICHOOK1();
}
}

逻辑进入到我们在EntrustInterceptor 中定义的intercept方法

1
2
3
4
5
6
7
8

@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
System.out.println("before");
Object o = proxy.invokeSuper(obj,args);
System.out.println("after");
return o;
}

我们看看MethodProxy的invokeSuper方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Invoke the original (super) method on the specified object.
* @param obj the enhanced object, must be the object passed as the first
* argument to the MethodInterceptor
* @param args the arguments passed to the intercepted method; you may substitute a different
* argument array as long as the types are compatible
* @see MethodInterceptor#intercept
* @throws Throwable the bare exceptions thrown by the called method are passed through
* without wrapping in an <code>InvocationTargetException</code>
*/
public Object invokeSuper(Object obj, Object[] args) throws Throwable {
try {
init();
FastClassInfo fci = fastClassInfo;
//f2是由CGlib生成的,在输出的class中有这个类。
//它就是Entrust$$EnhancerByCGLIB$$832e20ab$$FastClassByCGLIB$$817a77c.class
return fci.f2.invoke(fci.i2, obj, args);
} catch (InvocationTargetException e) {
throw e.getTargetException();
}
}

我们把

Entrust$$EnhancerByCGLIB$$832e20ab$$FastClassByCGLIB$$817a77c.class

也反编译出来,然后贴出invoke方法,注意case14调用了

entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$doFunc$0((String)array[0]);:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
public Object invoke(final int n, final Object o, final Object[] array) throws InvocationTargetException {
final Entrust$$EnhancerByCGLIB$$832e20ab entrust$$EnhancerByCGLIB$$832e20ab = (Entrust$$EnhancerByCGLIB$$832e20ab)o;
try {
switch (n) {
case 0: {
entrust$$EnhancerByCGLIB$$832e20ab.setCallbacks((Callback[])array[0]);
return null;
}
case 1: {
Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$SET_STATIC_CALLBACKS((Callback[])array[0]);
return null;
}
case 2: {
Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$SET_THREAD_CALLBACKS((Callback[])array[0]);
return null;
}
case 3: {
return entrust$$EnhancerByCGLIB$$832e20ab.getCallback(((Number)array[0]).intValue());
}
case 4: {
return entrust$$EnhancerByCGLIB$$832e20ab.getCallbacks();
}
case 5: {
entrust$$EnhancerByCGLIB$$832e20ab.doFunc((String)array[0]);
return null;
}
case 6: {
entrust$$EnhancerByCGLIB$$832e20ab.setCallback(((Number)array[0]).intValue(), (Callback)array[1]);
return null;
}
case 7: {
return Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$findMethodProxy((Signature)array[0]);
}
case 8: {
Entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$STATICHOOK1();
return null;
}
case 9: {
entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$finalize$1();
return null;
}
case 10: {
return entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$toString$3();
}
case 11: {
return new Integer(entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$hashCode$4());
}
case 12: {
return entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$clone$5();
}
case 13: {
return new Boolean(entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$equals$2(array[0]));
}
case 14: {
entrust$$EnhancerByCGLIB$$832e20ab.CGLIB$doFunc$0((String)array[0]);
return null;
}
case 15: {
return new Boolean(entrust$$EnhancerByCGLIB$$832e20ab.equals(array[0]));
}
case 16: {
return entrust$$EnhancerByCGLIB$$832e20ab.toString();
}
case 17: {
return new Integer(entrust$$EnhancerByCGLIB$$832e20ab.hashCode());
}
case 18: {
return entrust$$EnhancerByCGLIB$$832e20ab.newInstance((Callback)array[0]);
}
case 19: {
return entrust$$EnhancerByCGLIB$$832e20ab.newInstance((Class[])array[0], (Object[])array[1], (Callback[])array[2]);
}
case 20: {
return entrust$$EnhancerByCGLIB$$832e20ab.newInstance((Callback[])array[0]);
}
case 21: {
Entrust.main((String[])array[0]);
return null;
}
case 22: {
entrust$$EnhancerByCGLIB$$832e20ab.wait(((Number)array[0]).longValue(), ((Number)array[1]).intValue());
return null;
}
case 23: {
entrust$$EnhancerByCGLIB$$832e20ab.wait(((Number)array[0]).longValue());
return null;
}
case 24: {
entrust$$EnhancerByCGLIB$$832e20ab.wait();
return null;
}
case 25: {
return entrust$$EnhancerByCGLIB$$832e20ab.getClass();
}
case 26: {
entrust$$EnhancerByCGLIB$$832e20ab.notify();
return null;
}
case 27: {
entrust$$EnhancerByCGLIB$$832e20ab.notifyAll();
return null;
}
}
}
catch (Throwable t) {
throw new InvocationTargetException(t);
}
throw new IllegalArgumentException("Cannot find matching method/constructor");
}

事实证明,最后确实是进入了case14,调用了代理类的代理doFunc方法,最后再回到EntrustInterceptor.invoke中。完成逻辑

1234…7
cherish-ls

cherish-ls

纸上得来终觉浅

68 posts
27 categories
92 tags
GitHub
© 2021 cherish-ls | Site words total count: 457.5k
Powered by Hexo
|
Theme — NexT.Muse v5.1.4
访问人数 访问总量 次
0%