innodb       

innodb

acid

atomicity

事务

consistency

InnoDB doublewrite buffer.

InnoDB crash recovery.

isolation

共享锁

排他锁

意向共享锁

意向排他锁

加锁原则

原则 1:加锁的基本单位是 next-key lock。next-key lock 是前开后闭区间。

原则 2:查找过程中访问到的对象才会加锁。

优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。

优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。

一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

一次执行流程

image-20191016170821021

删除记录

假设,我们要删掉 一个记录,InnoDB 引擎只会把这个记录标记为删除。如果之后要再插入新记录时,可能会复用这个位置。但是,磁盘文件的大小并不会缩小。

怎么看死锁

show engine innodb status

redo

为什么要有redo日志?

数据库事务提交后,必须将更新后的数据刷到磁盘上,以保证ACID特性。磁盘随机写性能较低,如果每次都刷盘,会极大影响数据库的吞吐量。

优化方式是,将修改行为先写到redo日志里(此时变成了顺序写),再定期将数据刷到磁盘上,这样能极大提高性能。

随机写优化为顺序写

持久化

  1. InnoDB 有一个后台线程,每隔 1 秒,就会把 redo log buffer 中的日志,调用 write 写到文件系统的 page cache,然后调用 fsync 持久化到磁盘。

  2. redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动写盘。注意,由于这个事务并没有提交,所以这个写盘动作只是 write,而没有调用 fsync,也就是只留在了文件系统的 page cache。

  3. 并行的事务提交的时候,顺带将这个事务的 redo log buffer 持久化到磁盘。

undo

为什么要有undo日志?

数据库事务未提交时,会将事务修改数据的镜像(即修改前的旧版本)存放到undo日志里,当事务回滚时,或者数据库奔溃时,可以利用undo日志,即旧版本数据,撤销未提交事务对数据库产生的影响。

对于insert操作,undo日志记录新数据的PK(ROW_ID),回滚时直接删除;

对于delete/update操作,undo日志记录旧数据row,回滚时直接恢复;

他们分别存放在不同的buffer里。

image-20191015215734654

实际上,图 中的三个虚线箭头,就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。

公式版:
如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
如果落在黄色部分,那就包括两种情况
 a.  若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;
 b.  若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。
 

口语版:
版本未提交,不可见;
版本已提交,但是是在视图创建后提交的,不可见;
版本已提交,而且是在视图创建前提交的,可见。

更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read),不是快照\一致性\可重复读。除了 update 语句外,select 语句如果加锁(select lock in share mode;select for update),也是当前读,所以更新数据也会加锁。

回滚段

存储undo日志的地方,是回滚段。

索引

InnoDB的行锁是实现在索引上的,而不是锁在物理行记录上。潜台词是,如果访问没有命中索引,也无法使用行锁,将要退化为表锁。

为什么主建,推荐自增整形

建立聚蔟索引树的时候,插入新的顺序的主建,1.减少节点分裂,2.整形比字符型大小比较更快

change buffer

插入一条数据过程

  1. 这个记录要更新的目标页在内存中

​ 对于唯一索引来说,找到 3 和 5 之间的位置,判断到没有冲突,插入这个值,语句执行结束; ​ 对于普通索引来说,找到 3 和 5 之间的位置,插入这个值,语句执行结束。

  1. 这个记录要更新的目标页不在内存中

​ 对于唯一索引来说,需要将数据页读入内存,判断到没有冲突,插入这个值,语句执行结束;

​ 对于普通索引来说,则是将更新记录在 change buffer,语句执行就结束了。

将数据从磁盘读入内存涉及随机 IO 的访问,是数据库里面成本最高的操作之一。change buffer 因为减少了随机磁盘访问,所以对更新性能的提升是会很明显的。

Change buffer只适用于普通索引

对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时 change buffer 的使用效果最好。这种业务模型常见的就是账单类、日志类的系统。

反过来,假设一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录在 change buffer,但之后由于马上要访问这个数据页,会立即触发 merge 过程。这样随机访问 IO 的次数不会减少,反而增加了 change buffer 的维护代价。所以,对于这种业务模式来说,change buffer 反而起到了副作用。

merge过程:要读 Page 的时候,需要把Page从磁盘读入内存中,然后应用 change buffer 里面的操作日志,生成一个正确的版本并返回结果。

普通索引和唯一索引应该怎么选择。其实,这两类索引在查询能力上是没差别的,主要考虑的是对更新性能的影响。所以,建议尽量选择普通索引。

image-20191016103100620

Page 1 在内存中,直接更新内存;
Page 2 没有在内存中,就在内存的 change buffer 区域,记录下“我要往 Page 2 插入一行”这个信息
将上述两个动作记入 redo log 中(图中 3 和 4)。

redo log 主要节省的是随机写磁盘的 IO 消耗(转成顺序写),而 change buffer 主要节省的则是随机读磁盘的 IO 消耗。

事务两阶段提交

binlog 的写入逻辑比较简单:事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中

每个线程有自己 binlog cache,但是共用同一份 binlog 文件

事务在执行过程中,生成的 redo log 是要先写到 redo log buffer 的

image-20191017153625464

两种解释

如果在图中时刻 A 的地方,也就是写入 redo log 处于 prepare 阶段之后、写 binlog 之前,发生了崩溃(crash),由于此时 binlog 还没写,redo log 也还没提交,所以崩溃恢复的时候,这个事务会回滚。这时候,binlog 还没写,所以也不会传到备库。


B处崩溃
如果 redo log 里面的事务是完整的,也就是已经有了 commit 标识,则直接提交;
如果 redo log 里面的事务只有完整的 prepare,则判断对应的事务 binlog 是否存在并完整:
a.  如果是,则提交事务;
b.  否则,回滚事务。

下面这个解释比较好,崩溃时候的处理合理

第一阶段:InnoDB prepare,持有prepare_commit_mutex,并写入到redo log中。将回滚段(undo)设置为Prepared状态,binlog不做任何操作。

第二阶段:将事务写入Binlog中,将redo log中的对应事务打上commit标记,并释放prepare_commit_mutex。

MySQL以binlog的写入与否作为事务是否成功的标记,innodb引擎的redo commit标记并不是这个事务成功与否的标记。
 
崩溃时:

扫描最后一个Binlog文件,提取其中所有的xid。

InnoDB维持了状态为Prepare的事务链表,将这些事务的xid与刚刚提取的xid做比较,若存在,则提交prepare的事务,若不存在,回滚。

细化

image-20191018112623953

主备同步

image-20191018112028180

binlog 格式是 statement有主备不一致的风险

所以可以用row格式,或者mixed(statement和row混合,因为row格式文件比较大)格式

自增id

MyISAM 引擎的自增值保存在数据文件中。

InnoDB 引擎的自增值,其实是保存在了内存里,并且到了 MySQL 8.0 版本后,才有了“自增值持久化”的能力,也就是才实现了“如果发生重启,表的自增值可以恢复为 MySQL 重启前的值”,具体情况是:

MySQL 5.1.22 版本引入了一个新策略,新增参数 innodb_autoinc_lock_mode,默认值是 1。

  1. 这个参数的值被设置为 0 时,表示采用之前 MySQL 5.0 版本的策略,即语句执行结束后才释放锁;

  2. 这个参数的值被设置为 1 时:

  3. 这个参数的值被设置为 2 时,所有的申请自增主键的动作都是申请后就释放锁。

表的自增 id 达到上限后,再申请时它的值就不会改变,进而导致继续插入数据时报主键冲突的错误。

row_id 达到上限后,则会归 0 再重新递增,如果出现相同的 row_id,后写的数据会覆盖之前的数据。

Xid 只需要不在同一个 binlog 文件中出现重复值即可。虽然理论上会出现重复值,但是概率极小,可以忽略不计。

InnoDB 的 max_trx_id 递增值每次 MySQL 重启都会被保存起来,所以我们文章中提到的脏读的例子就是一个必现的 bug,好在留给我们的时间还很充裕。

thread_id 是我们使用中最常见的,而且也是处理得最好的一个自增 id 逻辑了。

Buffer Pool

InnoDB中的数据访问是以Page为单位的,每个Page的大小默认为16KB,Buffer Pool是用来管理和缓存这些Page的

InnoDB将一块连续的内存大小划分给Buffer Pool来使用,并将其划分为多个Buffer Pool Instance来更好地管理这块内存,每个Instance的大小都是相等的,通过算法保证一个Page只会在一个特定的Instance中,划分为多个Instance的模式提升了Buffer Pool的并发性能。

在每一个Buffer Pool Instance中,实际都会维护一个自己的Buffer Pool模块,InnoDB通过16KB Page的方式将数据从文件中读取到Buffer Pool中,并通过一个LRU List来缓存这些Page,经常访问的Page在LRU List的前面,不经常访问的Page在后面。InnoDB访问一个Page时,首先会从Buffer Pool中获取,如果未找到,则会访问数据文件,读取到Page,并将其put到LRU List中,当一个Instance的Buffer Pool中没有可用的空闲Page时,会对LRU List中的Page进行淘汰。

Buffer chunks:

Buffer chunks是每个Buffer Pool Instance中实际的物理存储块数组,一个Buffer Pool Instance中有一个或多个chunk,每个chunk的大小默认为128MB,最小为1MB,且这个值在8.0中时可以动态调整生效的。每个Buffer chunk中包含一个buf_block_t的blocks数组(即Page),Buffer chunk主要存储数据页和数据页控制体,blocks数组中的每个buf_block_t是一个数据页控制体,其中包含了一个指向具体数据页的*frame指针,以及具体的控制体buf_page_t,后面在数据结构中详细阐述他们的关系。

buf_pool_create()->buf_chunk_init(){
    ...
    //分配内存,默认每个chunk的大小为128M,默认通过mmap来分配
    chunk->mem = buf_pool->allocator.allocate_large(mem_size, &chunk->mem_pfx);
    //从内存的头部开始分配block控制信息
    chunk->blocks = (buf_block_t *)chunk->mem;
    //frame是指向实际Page的指针,需要将其通过UNIV_PAGE_SIZE对齐,此时frame也指向内存区域的头部
    frame = (byte *)ut_align(chunk->mem, UNIV_PAGE_SIZE);
    //计算出该chunk能分配出多少个Page,
    chunk->size = chunk->mem_pfx.m_size / UNIV_PAGE_SIZE - (frame != chunk->mem);
    ulint size = chunk->size;
    /*
      一个Page包含一个的16KB的Page和一个对应的控制信息(buf_block_t),一个buf_block_t对应一个Page
      所有的Page页面都是连续在一起存储的组成了Page区,buf_block_t也是连续存储的组成了控制信息区
      控制信息区处于这块内存的前半部分,Page区域位于后半部分
      为了更容易理解这个循环所做的事情,我们先理一理思路
      如何把一块连续的内存分为两个区域,即控制信息区和Page区,且每个Page必须要有一个对应的buf_block_t	 	   我们把整个连续内存拆分为一个个16KB大小的Page,然后把其中第一个Page用于存储所有的buf_block_t
      如果buf_block_t的数量太多导致第一个Page放不下,则需要把第二个Page也用于存储buf_block_t
      依次类推,每使用一个Page页用于存储buf_block_t,那么chunk的Page size就要减1
      frame是一个指向Page页的指针,它从chunk的头部出发,当有足够的空间用于存储buf_block_t,        	   即frame的地址大于整个buf_block_t控制信息需要的总长度,就会跳出While循环
      反之,空间不足则需要再花费一片Page,同时size--
      这样的分配模式能减少内存碎片的产生,能提高内存的使用率
    */
    while (frame < (byte *)(chunk->blocks + size)) {
      frame += UNIV_PAGE_SIZE;
      size--;
    }
    //最终获得的size是准确的Page数量
    chunk->size = size;
    
    block = chunk->blocks;
    //循环初始化所有的控制信息buf_block_t和Page
    for (i = chunk->size; i--;) {
      //初始化控制信息buf_block_t,并将其frame指针指向对应的Page地址
      buf_block_init(buf_pool, block, frame);
      UNIV_MEM_INVALID(block->frame, UNIV_PAGE_SIZE);
	  //把所有的空闲Page添加到Buffer Pool Instance的Free List中
      UT_LIST_ADD_LAST(buf_pool->free, &block->page);
      //标记当前控制信息buf_block_t所指向的Page是在Free List中
      ut_d(block->page.in_free_list = TRUE);
      ut_ad(buf_pool_from_block(block) == buf_pool);
		
      block++;
      //frame指针指向下一个Page
      frame += UNIV_PAGE_SIZE;
    }
    //互斥量lock
    if (mutex != nullptr) {
      mutex->lock();
    }
	//注册chunk
    buf_pool_register_chunk(chunk);
	//互斥量unlock
  	if (mutex != nullptr) {
      mutex->unlock();
    }
    ...
}
void*
os_mem_alloc_large(
	ulint*	n)
{
  ...
size = getpagesize();
	/* Align block size to system page size */
	ut_ad(ut_is_2pow(size));
	size = *n = ut_2pow_round(*n + (size - 1), size);
	ptr = mmap(NULL, size, PROT_READ | PROT_WRITE,
		   MAP_PRIVATE | OS_MAP_ANON, -1, 0);
	if (UNIV_UNLIKELY(ptr == (void*) -1)) {
		ib::error() << "mmap(" << size << " bytes) failed;"
			" errno " << errno;
		ptr = NULL;
	} else {
		os_atomic_increment_ulint(
			&os_total_large_mem_allocated, size);
		UNIV_MEM_ALLOC(ptr, size);
	}
}

参数调优

image-20200725103258719

image-20200725103311220

reference

极客时间-MySQL实战45讲

http://mysql.taobao.org/monthly/2020/02/02/

ppt

ppt