MySQL管理器

发布时间: 2023-11-21 11:29 阅读: 文章来源:1MUMB1679PS

接下来,咱们来第二篇 :

《老架构带你手写数据库之2: 从0开始,手写MySQL数据管理器DM》(本文)

《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF文件,请到公号【技术自由圈】取

本文目录:

- 说在前面- 回顾一下,手写DB架构设计- MySql的缓冲池Buffer pool- 数据页- 缓存页- Free链表- Flush链表- LRU算法- 基于冷热数据分离优化后的LRU链表- 使用引用计数实现缓存池- 使用缓存池缓存数据页- 数据页管理- 用于启动校验的特殊页- 用于存储数据的普通页- 页面空闲空间管理- 数据日志和恢复策略- Mysql的日志- redo log(重做日志)- 脏数据刷盘- crash-safe- 脏日志刷盘- undo log(回滚日志)- undo log的作用- bin log(归档日志)- 主从同步- bin log 和redo log 的区别- relay log(中继日志)- slow query log(慢查询日志)- general query log(一般查询日志)- error log(错误日志)- 采用二进制文件实现redo log- 恢复策略- 单线程- 多线程- 数据项及其操作- 数据操作管理器- 总结- 作者介绍- 说在后面- 推荐相关阅读

回顾一下,手写DB架构设计:

尼恩的风格: 开始写代码前,先做架构

从功能上来说,一个手写DB系统架构分为以下几个模块:

数据管理器(DM)事务管理器(TM)版本管理器(VM)表管理器(TBM)索引管理器(IM)

手写DB架构设计设计图,具体如下:

接下来,尼恩团队带大家,从基础的原理入手,一步一步从0开始,手写MySQL数据管理器

MySQL的缓冲池 Buffer pool:

首先,我们先来了解一下MySQL的缓冲池(Buffer Pool)。

我们知道数据以文件的形式存储在系统磁盘等存储介质中,但是真正处理数据的过程是发生在内存中,所以需要把磁盘中的数据文件加载到内存中。如果是处理修改或者增加等数据操作时,还需要把内存中的数据刷新到磁盘上。

问题来了。磁盘的读写速度非常慢,如果数据加载或者写入时还是一条条记录为单位,那样效率低的不敢想象。

如何提升性能呢?DM的绝招是:缓冲池。

什么是缓冲池?

首先来看 缓冲页(page)。 DM中在处理客户端的请求时,将数据划分为若干个页(page),以页作为磁盘和内存之间交互的单位。但如果需要访问某个页的数据,就会把完整的页中的数据全部加载到内存中,即使只访问页中的一条记录,也需要先把整个页的数据加载到内存中。将整个页加载到内存后就可以进行读写访问了,而且在读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以剩下磁盘IO的开销了。

什么是缓冲池? 为了缓存磁盘中的页,MYDB在MySQL服务器启动时就向操作系统申请了一片连续的内存,即Buffer Pool(缓冲池)。

默认情况下,Buffer Pool的大小为128M。

Buffer Pool对应的一片连续的内存被划分为若干个页面,页面大小与Innodb表空间用的页面大小一致,默认都是16kb,为了与磁盘中的页面区分开来,我们把这些Buffer Pool中的页面称为缓冲页。当我们修改了Buffer Pool中某个缓冲页的数据,它就与磁盘上的页不一致了,这样的缓冲页称为脏页。当然,我们可以每当修改完某个数据页时,就立即将其刷新到磁盘中对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能,所以每次修改缓冲页后,我们并不着急立即将修改刷新到磁盘上,而是在某个时间点进行刷新。后台有专门的线程负责每隔一段时间就把脏页刷新到磁盘,这样就可以不影响用户线程处理正常的请求。

总之:Innodb是以页为单位来管理存储空间的,在真正访问页面之前,需要先把磁盘中的页加载到内存中的Buffer Pool中,之后才可以访问,所有的变更都必须先更新缓冲池中的数据,然后缓冲池中的脏页以一定的频率刷新到磁盘(checkpoint机制),通过缓冲池来优化CPU和磁盘之间的鸿沟,这样就能保证整体的性能不会下降的太快。

缓冲池的巨大价值:Buffer Pool 缓存表数据与索引数据,把磁盘上的数据加载到缓冲池,避免每次访问都进行磁盘IO,起到加速访问的作用。虽然Buffer Pool 缓冲池的访问速度快,但是没有把所有的数据放到缓冲池的主要原因是缓冲池的存储容量小。只能把“最热”的数据放到“最近”的地方,以“最大限度”的降低磁盘访问。

尼恩的架构三板斧(缓冲、异步、池化)中, 缓冲是 其中的 第一板斧。

可见,不管是3高架构,还是 DB内部架构, 架构的原理和模式,都是相通的。

更多架构模式和架构思想,参见尼恩3高架构知识宇宙。

数据页

每个数据页的大小默认是16KB。

每个数据页存放着多条的数据,在执行增删改首先会定位到这条数据所在数据页,然后会将数据所在的数据页加载到 Buffer Pool 中。

数据页

尼恩提示:以上内容比较复杂,而且非常重要。如何把以上内容搞清楚? 可以看本文的配套视频《从0开始,老架构师带你手写DB》。

如果想把手写DB写入简历,可以找老架构师尼恩提供简历辅导,保证简历金光闪闪、天衣无缝。

缓存页

当数据页被加载到缓冲池Buffer Pool中后,Buffer Pool 有对应的缓存页与数据页一一对应,其大小和数据页一样,默认是16KB。

尼恩提示: 数据页在磁盘上, 缓冲页在内存中。

MySQL还会为每个缓存开辟额外的空间用来描述对应的缓存页的一些信息,这些是 元数据,例如:数据页所属的表空间、数据页号。 如下图:

缓存页

Free链表

内存中的缓存页,有些是空闲的。

当,数据页会被加载到一个缓存页中的时候,MySQL是如何区分哪些缓存页是空闲的状态可以用来存放数据页的。

为了解决这个问题,MySQL 为 Buffer Pool 设计了一个双向链表— free链表。

free链表是由每个空闲缓存页的描述数据组成的一个双向链表。

free链表作用是用来保存空闲缓存页的描述数据的。

free 链表还会有一个基础节点,它会引用该链表的头结点和尾结点,还会记录可用的空闲的缓存页的个数。

Free链表

当加载数据页到缓存池中的时候, MySQL会从 free 链表中获取一个描述数据的信息,根据描述节点的信息拿到其对应的缓存页,然后将数据页信息放到该缓存页中,同时将free 链表中的该描述数据的节点移除。

这就是数据页被读取 Buffer Pool 中的缓存页的过程。

数据库中有一个哈希表结构, 来记录一个 数据页的缓存状态。key为 数据页的编号,具体来说,表空间号 + 数据页号作为数据页的key,value为缓存页内存地址。

通过这个 哈希表结构,可以很容易判断 数据页是否被缓冲池: 在数据页加载的时候就会通过哈希表中的key来确定数据页是否被缓存了。

数据页缓存哈希表结构如下图所示:

数据页缓存哈希表

Flush链表

内存的数据修改了,就会和磁盘上的数据页不一致。

问题是:如何保证 内存数据,磁盘数据的一致性呢?

MySQL 在执行增删改的时候会一直将数据以数据页的形式加载到 Buffer Pool 的缓存页中,增删改的操作都是在内存中执行的。 修了了的 内存页,就是脏页。

如何把脏页刷新呢?

MySQL会有一个后台的线程数将脏数据刷新到磁盘中,但是后台的线程是如何知道应该刷新哪些脏数据到磁盘呢?

针对这个问题,MySQL设计出了 Flush 链表,他的作用就是记录被修改过的脏数据所在的缓存页对应的描述数据。如果内存中的数据和数据库和数据库中的数据不一样,那这些数据就称为脏数据,本质上就是被缓存到缓存池中的数据被修改了,但是还没有刷新到磁盘中。

同样的这些已经被修改了的数据所在的缓存页的描述数据会被维护到 Flush 链表中;当某个脏数据页被刷新到磁盘后,其空间就腾出来了,然后又会跑到 Free 链表中了。

Flush链表如下图:

flush链表

LRU算法:

缓存的性能,和缓存的命中率紧密相关。 如何提升缓存的命中率

Buffer Pool 用一个链表管理 缓存页, 当 内存不够,或者缓存不够的时候。 这个缓存页列表使用 LRU 算法 进行数据页的淘汰。

尼恩提示:这是一个非常重要的算法。

尼恩的 100OPS 三级缓存组件实操中,重点介绍了这个算法。

这个算法在数据库、操作系统、中间件中,都有大家的使用。 大家最好能够手写一下。

如果一直在进行数据库的增删改操纵,数据库内部的基本流程如下:

数据查询时数据库内部基本流程

传统的LRU把新入缓冲池的数据页放到LRU的头部,作为最近访问的元素,从而最晚被淘汰,这里又分为两种情况:

(1) 页已经在缓冲池中:只做移至LRU头部的动作,没有页被淘汰。

(2)页不在缓冲池中:除了做放入LRU头部的动作,还要做淘汰LRU尾部页的动作;

举栗子: 假设缓冲池的LRU长度为10,缓冲池的页号如下:

缓冲池 lru算法例子1

接下来要访问的数据在页号为4的页中:

缓冲池 lru算法例子-页号在缓冲池中

由于 页号为4的页在缓冲池中,直接把页号为4的页放到LRU的头部即可,此时没有页被淘汰。

LRU为了减少数据的移动,由于链表具有快速插入和修改的优点,LRU一般会采用链表来实现。

接着,需要访问的数据在页号为50的页中:

缓冲池 lru算法例子-页号不在缓冲池中

由于页号为50的页原来不在缓冲池中,此时需要把页号为50的页放入到LRU头部,同时淘汰页号为7的页。

传统的LRU缓冲池算法十分简单直接,但是存在两个问题:

(1)空间局部性

操作系统级别的spatial locality(空间局部性)是指读取一个数据,在它周围内存地址存储的数据也很有可能被读取到,于是操作系统会帮你预读一部分数据。

MySQL也是存在存在预读机制的:

①、通过参数innodb_read_ahead_threshold控制,默认是56。这个参数表示如果顺序访问了一个区里的多个数据页,这里的多个就是56,就会触发预读机制,把下一个区中所有的数据页都加载到缓存页里。

②、通过参数innodb_random_read_ahead控制,默认是off。这个参数表示如果缓存了一个区的13个连续数据页,就会触发预读机制,把这个区里的页全都加载到缓存页里。

当你执行select * from xxx 时,如果表中的数据页非常多,那这些数据页就会一一将buffer pool中的经常使用的缓存页挤下去,可能留在lru链表中的全部是你不经常使用的数据。

此时,预读机制的优势就违背了LRU实现最近最少使用的数据页刷入磁盘的设计初衷了。

(2)全表扫描

如果是全表扫描,会把全表都加载到buffer pool中,有可能就把LRU链表中经常访问的都挤到后面去,就有可能被淘汰。

基于冷热数据分离优化后的LRU链表

Buffer Pool没有直接常用传统的LRU算法,而是采用了基于冷热数据分离的LRU链表,如下图所示:

innodb-buffer-pool-list

新进入缓存池的页并不会直接进入LRU链表的头部,而是插入到距离链表尾3/8的位置(可以由innodb_old_blocks_pct参数进行配置),我们将距离链表尾3/8以上的位置称为新子列表,以下的位置称为旧子列表,数据在链表中自底而上称为变年轻,反之称为变老。

(1)变年轻

变年轻分为两种情况

用户的操作而需要读取页面,此时会直接使该页直接移至新子列表链表头部。数据库内部的预读操作,则在距离插入innodb_old_blocks_time(默认为1000ms)的时间内,即使访问了该页,该页也不会别移到LRU链表的头部。

也就是说,如果是来源于用户的操作,则最起码需要两次操作才能变年轻。而如果是预读操作,则需要加上一个等待期限。

(2)变老

随着链表数据的替换和访问,整个列表中的数据会自然的变老。

最终最老的页面会从尾部逐出,清空这些缓存页,并放入free链表,从LRU链表删除,从Flush链表删除。

如果 Buffer Pool 不够使用了,那么 MySQL就会将 LRU 链表中的尾节点刷入到磁盘中, Buffer Pool 腾出内存空间。

来个整体的流程图如下:

buffer pool 整体流程

尼恩提示:以上内容比较复杂,而且非常重要。如何把以上内容搞清楚? 可以看本文的配套视频《从0开始,老架构师带你手写DB》。

如果想把手写DB写入简历,可以找老架构师尼恩提供简历辅导,保证简历金光闪闪、天衣无缝。

使用引用计数实现缓存池:

采用LRU 缓存淘汰策略只需要设计一个get(key)接口即可,释放缓存可以在缓存满之后自动完成。

但是先进的LRU缓存淘汰策略会出现一个尴尬的场景:

在缓存池满的时候,缓存池需要逐出一个缓存数据,这个时候,模块A想要将某个数据强制刷回磁盘,但是这个数据又恰好是刚被逐出的数据,那么模块A发现这个数据在缓存中不见了,这是让人尴尬的问题就出现了:是否有必要做回源操作?

(1)不回源。由于没法确定缓存被逐出的时间,更没法确定被驱逐之后数据是否被修改,这样是极其不安全的。

(2)回源。如果数据被逐出时的数据和现在又是相同的,那就是一次无效回源。

(3)放回缓存里,等下次被逐出时回源。看起来解决了问题,但是此时缓存已经满了,这意味着还需要逐出一个缓存数据才能放进去。

这有可能会导致缓存抖动问题。

出现这个问题的根源是因为LRU 策略中,缓存数据被逐出不可控,调用模块无法感知。

而引用计数策略正好解决了这个问题: 只有调用模块主动释放引用,缓存在确保没有其他模块在使用这个数据了,才会去逐出缓存数据

引用计数法实现的缓存池中实现一个方法 release(key),用于在调用模块不使用某个数据页时,释放对数据的引用。当引用归零时,缓存池就会逐出这个缓存数据。

同样,在缓存池满了之后,引用计数法无法自动释放缓存,此时就直接报错。

AbstractCache 是一个抽象类,内部有两个抽象方法,留给实现类去实现:

/** * 当资源不在缓存时的获取行为 */protected abstract T getForCache(long key) throws Exception;/** * 当资源被逐出时的写回行为 */protected abstract void releaseForCache(T obj);

引用计数除了缓存功能以外,还维护一个计数;为了应对多线程场景,还需要记录哪些数据页是从数据源获取的。

所以定义了三个HashMap类型的变量,如下所示:

// 实际缓存的数据private HashMap cache;// 元素的引用个数private HashMap references;// 正在获取某资源的线程private HashMap getting;

通过get()方法获取数据页的步骤如下:

step1:通过一个while 死循环无限尝试才从缓存中获取;

step2:检查此时是否有其他线程正从数据源获取这个数据资源,如果有,即就让线程等待;

step3:如果数据在缓冲池中,直接获取并返回,此时数据页的引用数需要加1.

step4:如果缓存池中没有数据所在的数据页,就从数据源中把数据所在的数据页加载到缓存池中,并返回,此时,数据页的引用数也需要加1;

get()方法的具体实现如下:

protected T get(long key) throws Exception {//在通过 get() 方法获取资源时,首先进入一个死循环,来无限尝试从缓存里获取。// 首先就需要检查这个时候是否有其他线程正在从数据源获取这个资源,如果有,就过会再来看看while(true) {lock.lock();if(getting.containsKey(key)) {// 请求的资源正在被其他线程获取lock.unlock();try {Thread.sleep(1);} catch (InterruptedException e) {e.printStackTrace();continue;}continue;}//如果资源在缓存中,就可以直接获取并返回了,记得要给资源的引用数 +1。// 否则,如果缓存没满的话,就在 getting 中注册一下,该线程准备从数据源获取资源了。if(cache.containsKey(key)) {// 资源在缓存中,直接返回T obj = cache.get(key);references.put(key, references.get(key) + 1);lock.unlock();return obj;}// 尝试获取该资源if(maxResource > 0 && count == maxResource) {lock.unlock();throw Error.CacheFullException;}// 如果资源不在缓存中,且缓冲没满,就在 getting 中注册一下,该线程准备从数据源获取资源了count ++;getting.put(key, true);lock.unlock();break;}//从数据源获取资源就比较简单了,直接调用那个抽象方法即可,// 获取完成记得从 getting 中删除 key。T obj = null;try {//从数据库中获取资源obj = getForCache(key);} catch(Exception e) {lock.lock();count --;getting.remove(key);lock.unlock();throw e;}//成功从数据库获取资源后,移除getting,把资源放入缓存,引用计数器加1lock.lock();getting.remove(key);cache.put(key, obj);references.put(key, 1);lock.unlock();return obj;}

释放缓存是直接从references中减1,如果已经减到0了,就可以逐出,并且删除缓存中所有相关的结构,释放缓存的实现如下:

/** * 强行释放一个缓存 */protected void release(long key) {lock.lock();try {int ref = references.get(key)-1;if(ref == 0) {T obj = cache.get(key);releaseForCache(obj);references.remove(key);cache.remove(key);count --;} else {references.put(key, ref);}} finally {lock.unlock();}}

缓存池还有一个安全关闭的功能,在关闭时,需要将缓存中所有的数据页全部强制逐出,具体实现如下:

/** * 关闭缓存,写回所有资源 */protected void close() {lock.lock();try {Set keys = cache.keySet();for (long key : keys) {T obj = cache.get(key);releaseForCache(obj);references.remove(key);cache.remove(key);}} finally {lock.unlock();}}

使用缓存池缓存数据页:

有了缓存池后,接下来我们就是需要把数据页缓存到缓存池中。

首先,我们需要定义好页面的结构,如下:

public class PageImpl implements Page {//pageNumber 是这个页面的页号,该页号从 1 开始private int pageNumber;//data 就是这一页实际包含的字节数据private byte[] data;//dirty 标志着这个页面是否是脏页面,在缓存驱逐的时候private boolean dirty;private Lock lock;//PageCache的引用,用来方便在拿到 Page 的引用时private PageCache pc;}

定义好结构后,我们需要定义数据页缓存的接口,如下:

public interface PageCache {int newPage(byte[] initData);Page getPage(int pgno) throws Exception;void close();void release(Page page);void truncateByBgno(int maxPgno);int getPageNumber();}

数据页缓冲缓存的具体实现类继承了抽象缓存池类AbstractCache,并实现了getForCache()releaseForCache() 两个抽象方法。如下图所示:

由于此处的数据源是文件系统,getForCache()直接从文件中读取,并组装成page, 要求同一条数据是不允许跨页存储的,即单条数据的大小不能超过数据库页面的大小。

具体实现如下:

@Overrideprotected Page getForCache(long key) throws Exception {int pgno = (int)key;long offset = PageCacheImpl.pageOffset(pgno);ByteBuffer buf = ByteBuffer.allocate(PAGE_SIZE);fileLock.lock();try {fc.position(offset);fc.read(buf);} catch(IOException e) {Panic.panic(e);}fileLock.unlock();return new PageImpl(pgno, buf.array(), this);}// 页号从1开始private static long pageOffset(int pgno) {return (pgno-1) * PAGE_SIZE;}

releaseForCache() 逐出页面时,也只需要根据页面是否是脏页面,来决定是否需要写回文件系统:

@Overrideprotected void releaseForCache(Page pg) {if(pg.isDirty()) {flush(pg);pg.setDirty(false);}}private void flush(Page pg) {int pgno = pg.getPageNumber();//该页初始位置的偏移量long offset = pageOffset(pgno);fileLock.lock();try {ByteBuffer buf = ByteBuffer.wrap(pg.getData());fc.position(offset);fc.write(buf);fc.force(false);} catch(IOException e) {Panic.panic(e);} finally {fileLock.unlock();}}

在新建数据页的时候,使用了AtomicInteger的自增来记录了当前打开的数据库文件有多少页。这个数字在数据库文件被打开时就会被计算,并在新建页面时自增。

public int newPage(byte[] initData) {int pgno = pageNumbers.incrementAndGet();Page pg = new PageImpl(pgno, initData, null);// 新建的页面需要立刻写回flush(pg);return pgno;}

尼恩提示:以上内容比较复杂,而且非常重要。如何把以上内容搞清楚? 可以看本文的配套视频《从0开始,老架构师带你手写DB》。

如果想把手写DB写入简历,可以找老架构师尼恩提供简历辅导,保证简历金光闪闪、天衣无缝。

数据页管理:

数据页分为特殊页PageOne和普通页PageX。

特殊页是数据库文件的第一页,用来启动检查。第一页之外的数据页都属于普通页。

数据页默认大小为8K。

PageOne和PageX针对数据内存的第一页和普通页不同的数据页管理管理方式。

用于启动校验的特殊页

首先,我们来看下特殊页。

特殊页启动检查的逻辑是在每次数据库启动时,会生成一串随机字节,存储在100~107字节,在数据库正常关闭时,会将这串字节,拷贝到第一页的 108 ~ 115 字节。

在启动启动时,设置初始字节如下:

//启动时设置初始字节public static void setVcOpen(Page pg) {//对页面有修改时就设置为脏数据,然后flush到磁盘pg.setDirty(true);setVcOpen(pg.getData());}// 数据库启动时,在100-108设置随机字节private static void setVcOpen(byte[] raw) {System.arraycopy(RandomUtil.randomBytes(LEN_VC), 0, raw, OF_VC, LEN_VC);}

数据库关闭时拷贝字节:

public static void setVcClose(Page pg) {pg.setDirty(true);setVcClose(pg.getData());}private static void setVcClose(byte[] raw) {System.arraycopy(raw, OF_VC, raw, OF_VC+LEN_VC, LEN_VC);}

数据库在每次启动时,就会检查第一页两处的字节是否相同,以此来判断上一次是否正常关闭。如果是异常关闭,就需要执行数据的恢复流程。

校验字节如下:

public static boolean checkVc(Page pg) {return checkVc(pg.getData());}private static boolean checkVc(byte[] raw) {return Arrays.equals(Arrays.copyOfRange(raw, OF_VC, OF_VC+LEN_VC), Arrays.copyOfRange(raw, OF_VC+LEN_VC, OF_VC+2*LEN_VC));}

用于存储数据的普通页

一个普通页面以一个 2 字节无符号数起始,表示这一页的空闲位置的偏移。

剩下的部分的作用是存储的数据。

因此,对于普通页的管理都是围绕空闲位置偏移进行的。

在写入数据之前,先获取空闲位置的偏移确定要写入的位置,获取空闲位置的偏移如下

// 获取pg的FSOpublic static short getFSO(Page pg) {return getFSO(pg.getData());}private static short getFSO(byte[] raw) {return Parser.parseShort(Arrays.copyOfRange(raw, 0, 2));}

获取空闲位置的偏移后,就可以向页面插入数据,具体插入如下:

// 将raw插入pg中,返回插入位置public static short insert(Page pg, byte[] raw) {pg.setDirty(true);short offset = getFSO(pg.getData());System.arraycopy(raw, 0, pg.getData(), offset, raw.length);setFSO(pg.getData(), (short)(offset + raw.length));return offset;}

写入完成后,还需要更新空闲位置的偏移,具体如下:

//raw要写入的字节数组,ofData偏移量,把偏移量写入到raw字节数组的前两个字节private static void setFSO(byte[] raw, short ofData) {System.arraycopy(Parser.short2Byte(ofData), 0, raw, OF_FREE, OF_DATA);}

页面空闲空间管理

数据页的空闲空间采用页面索引机制来计算记录。

方便在上层模块进行数据内存插入操作时,能够快速定位到一个有合适空间的页面,而无需从磁盘或者缓存中挨个检查每一个页面的空间信息。

具体方法是将一页的空间划分成了 40 个区间。

在服务端启动时,遍历所有的页面信息,获取页面的空闲空间,安排到这 40 个区间中。

insert 在请求一个页时,会首先将所需的空间向上取整,映射到某一个区间,随后取出这个区间的任何一页,都可以满足需求。

具体如下:

public class PageIndex {// 将一页划成40个区间private static final int INTERVALS_NO = 40;private static final int THRESHOLD = PageCache.PAGE_SIZE / INTERVALS_NO; // 所有页号和空闲空间大小private List[] lists;}

请求页面如下:

public PageInfo select(int spaceSize) {int number = spaceSize / THRESHOLD;if(number < INTERVALS_NO) number ++;while(number show variables like ‘expire_logs_days‘;+------------------+-------+| Variable_name| Value |+------------------+-------+| expire_logs_days | 0 |+------------------+-------+1 row in set (0.01 sec)MySQL> SET GLOBAL binlog_expire_logs_seconds =2592000;Query OK, 0 rows affected (0.00 sec)

bin log主要应用于MySQL主从模式(master-slave)中,主从节点间的数据同步;以及基于时间点的数据还原。

主从同步

我们用一张图来看下MySQL的主从同步过程,如下图所示:

MySQL 主从同步原理图

MySQL主从同步的流程如下:

(1)用户在主库master 执行DDL 和DML 操作,修改记录顺序写入bin log;

(2)从库slave 的I/O线程连接上Master,并请求读取指定位置position的日志内容;

(3)Master收到从库slave请求后,将指定位置position之后的日志内容,和主库bin log 文件的名称以及在日志中的位置推送给从库;

(4)slave 的I/O线程接收到数据后,将接收到的日志内容依次写入到relay log文件最末端,并将读取到的主库bin log 文件名和位置position记录到master-info文件中,以便在下一次读取用。

(5)slave的SQL线程检测到relay log职工内容更新后,读取日志并解析成可执行的SQL语句。

通过以上步骤,就可以实现主从库的数据一致。

bin log 和redo log 的区别

从上述的分析我们知道bin log 和redo log 都可以做数据的恢复,那他们之间的区别有哪些:

(1)层次不同:redo log 是InnoDB存储引擎实现的,bin log 是MySQL的服务器层实现的,但MySQL数据库中的任何存储引擎对于数据库的更改都会产生bin log。

(2)作用不同:redo log 用于碰撞恢复(crash recovery),保证MySQL宕机也不会影响持久性;bin log 用于时间点恢复(point-in-time recovery),保证服务器可以基于时间点恢复数据和主从复制。

(3)内容不同:redo log 是物理日志,内容基于磁盘的页Page;bin log的内容是二进制,可以根据binlog_format参数自行设置。

(4)写入方式不同:redo log 采用循环写的方式记录;binlog 通过追加的方式记录,当文件大小大于给定值后,后续的日志会记录到新的文件上。

(5)刷盘时机不同:bin log在事务提交时写入;redo log 在事务开始时即开始写入。

bin log 和redo log 功能并不冲突而是起到相辅相成的作用,需要二者同时记录,才能保证数据库发生宕机重启时,数据不会丢失。

在MySQL执行更新语句时,都会涉及到redo log日志和binlog日志的读写。一条更新语句的执行过程如下:

update 执行过程

MySQL在执行更新语句的时候,在服务层进行语句的解析和执行,在引擎层进行数据的提取和存储;同时在服务层对binlog进行写入,在InnoDB内进行redo log的写入。

而且在对redo log写入时有两个阶段的提交,一是binlog写入之前prepare状态的写入,二是binlog写入之后commit状态的写入,使得binlog和redo log中保存的信息是一致的。

relay log(中继日志)

relay log日志文件具有与bin log日志文件相同的格式,从上边MySQL主从同步的流程可以看出,relay log起到一个中转的作用,slave先从主库master读取二进制日志数据,写入从库本地,后续再异步由SQL线程读取解析relay log为对应的SQL命令执行。

slow query log(慢查询日志)

慢查询日志(slow query log)在 SQL 优化过程中会经常使用到。通过慢查询日志,我们可以查找出哪些查询语句的执行效率低,耗时严重。

MySQL 的慢查询日志,用来记录在 MySQL 中响应时间超过阀值的语句,具体指运行时间超过 long_query_time 值的 SQL,则会被记录到慢查询日志中。long_query_time 的默认值为 10,意思是运行 10 秒以上 (不含 10 秒) 的语句,认为是超出了我们的最大忍耐时间值。

它的主要作用是,帮助我们发现那些执行时间特别长的 SQL 查询,并且有针对性地进行优化,从而提高系统的整体效率。当我们的数据库服务器发生阻塞、运行变慢的时候,检查一下慢查询日志,找到那些慢查询,对解决问题很有帮助。比如一条 sql 执行超过 5 秒钟,我们就算慢 SQL,希望能收集超过 5 秒的 sql,结合 explain 进行全面分析。

默认情况下,MySQL 数据库没有开启慢查询日志,需要我们手动来设置这个参数。

如果不是调优需要的话,一般不建议启动该参数,因为开启慢查询日志会或多或少带来一定的性能影响。

出于性能方面的考虑,一般只有在排查慢SQL、调试参数时才会开启,默认情况下,慢查询日志功能是关闭的。可以通过以下命令查看是否开启慢查询日志:

MySQL> show variables like ‘slow_query%‘;+---------------------+--------------------------------------+| Variable_name| Value|+---------------------+--------------------------------------+| slow_query_log| OFF|| slow_query_log_file | /var/lib/MySQL/c63bb810aea0-slow.log |+---------------------+--------------------------------------+2 rows in set (0.00 sec)

通过如下命令开启慢查询日志。

MySQL> set global slow_query_log=on;Query OK, 0 rows affected (0.19 sec)

超过 指定时间 的查询语句才算是慢查询,那么这个时间阈值又是多少嘞?我们通过 long_query_time 参数来查看一下,发现默认是 10 秒。

MySQL> show variables like ‘long_query_time‘;+-----------------+-----------+| Variable_name| Value |+-----------------+-----------+| long_query_time | 10.000000 |+-----------------+-----------+1 row in set (0.00 sec)

尼恩提示:以上内容比较复杂,而且非常重要。如何把以上内容搞清楚? 可以看本文的配套视频《从0开始,老架构师带你手写DB》。

如果想把手写DB写入简历,可以找老架构师尼恩提供简历辅导,保证简历金光闪闪、天衣无缝。

general query log(一般查询日志)

一般查询日志(general query log)主要用来记录用户的所有操作,包括客户端何时连接了服务器、客户端发送的所有SQL以及其他事件,比如 MySQL 服务启动和关闭等等。MySQL服务器会按照它接收到语句的先后顺序写入日志文件。

由于一般查询日志记录的内容过于详细,开启后 Log 文件的体量会非常庞大,所以出于对性能的考虑,默认情况下,该日志功能是关闭的,通常会在排查故障需获得详细日志的时候才会临时开启。

我们可以通过以下命令查看一般查询日志是否开启,命令如下:

MySQL> show variables like ‘general_log‘;+---------------+-------+| Variable_name | Value |+---------------+-------+| general_log| OFF|+---------------+-------+1 row in set (0.00 sec)

下边开启一般查询日志并查看日志存放的位置,

MySQL> SET GLOBAL general_log=on;Query OK, 0 rows affected (0.01 sec)MySQL>show variables like ‘general_log_file‘;+------------------+---------------------------------+| Variable_name| Value|+------------------+---------------------------------+| general_log_file | /var/lib/MySQL/c63bb810aea0.log |+------------------+---------------------------------+1 row in set (0.00 sec)

执行一条查询 SQL 看看日志内容的变化。

select * from MySQL_study.t_student where id =1 ;

打开一般查询日志/var/lib/MySQL/c63bb810aea0.log。结果如下:

error log(错误日志)

错误日志(Error log)是MySQL 中最常用的一种日志,主要记录MySQL服务器启动和停止过程中的信息、服务器在运行过程中发生的故障和异常情况等。

默认情况下,错误日志功能是开启的。可以通过以下命令查看:

MySQL> SHOW VARIABLES LIKE ‘log_error‘;+---------------+--------+| Variable_name | Value|+---------------+--------+| log_error | stderr |+---------------+--------+1 row in set (0.00 sec)

一般情况下,错误日志存储在 MySQL 数据库的数据文件夹下,通常名称为 hostname.err。其中,hostname 表示的是MySQL 服务器的主机名。

在 MySQL 配置文件中,错误日志所记录的信息可以通过 log-error 和 log-warnings 来定义,其中,log-err 定义是否启用错误日志功能和错误日志的存储位置,log-warnings 定义是否将警告信息也记录到错误日志中。

采用二进制文件实现redo log:

MYDB数据库提供了崩溃后的数据恢复功能,在每次对底层数据操作时,都会记录一条日志到磁盘上。

在数据库崩溃之后,再次启动时,可以根据日志的内容,恢复数据文件,保持其一致性。

日志的二进制文件,按照如下的格式进行排布:

[XChecksum][Log1][Log2][Log3]...[LogN][BadTail]

其中 XChecksum 是一个四字节的整数,是对后续所有日志计算的校验和。Log1 ~ LogN 是常规的日志数据,BadTail 是在数据库崩溃时,没有来得及写完的日志数据,这个 BadTail 不一定存在。

每条日志的格式如下:

[Size][Checksum][Data]

其中,Size 是一个四字节整数,标识了 Data 段的字节数。Checksum 则是该条日志的校验和。

单条日志的校验和是通过一个指定的种子实现的:

private int calChecksum(int xCheck, byte[] log) {for (byte b : log) {xCheck = xCheck * SEED + b;}return xCheck;}

这样,对所有日志求出校验和,求和就能得到日志文件的校验和了。

Logger 被实现成迭代器模式,通过 next() 方法,不断地从文件中读取下一条日志,并将其中的 Data 解析出来并返回。next() 方法的实现主要依靠 internNext(),大致如下,其中 position 是当前日志文件读到的位置偏移:

/** * 采用迭代器模式读取日 * 通过 next() 方法,不断地从文件中读取下一条日志,并将日志文件的 Data 解析出来并返回。 * 其中 position 是当前日志文件读到的位置偏移: * @return */private byte[] internNext() {if(position + OF_DATA >= fileSize) {return null;}// 读取单条日志的size(data段的字节数)ByteBuffer tmp = ByteBuffer.allocate(4);try {fc.position(position);fc.read(tmp);} catch(IOException e) {Panic.panic(e);}int size = Parser.parseInt(tmp.array());if(position + size + OF_DATA > fileSize) {return null;}// 读取checksum+dataByteBuffer buf = ByteBuffer.allocate(OF_DATA + size);try {fc.position(position);fc.read(buf);} catch(IOException e) {Panic.panic(e);}//单条日志的checksum+databyte[] log = buf.array();// 一条日志根据存放的数据算出来的校验和int checkSum1 = calChecksum(0, Arrays.copyOfRange(log, OF_DATA, log.length));//一条日志的Checksum部分存的东西int checkSum2 = Parser.parseInt(Arrays.copyOfRange(log, OF_CHECKSUM, OF_DATA));//毁坏的日志文件if(checkSum1 != checkSum2) {return null;}position += log.length;return log;}@Overridepublic byte[] next() {lock.lock();try {byte[] log = internNext();if(log == null) return null;return Arrays.copyOfRange(log, OF_DATA, log.length);} finally {lock.unlock();}}

在打开一个日志文件时,需要首先校验日志文件的 XChecksum,并移除文件尾部可能存在的 BadTail,由于 BadTail 该条日志尚未写入完成,文件的校验和也就不会包含该日志的校验和,去掉 BadTail 即可保证日志文件的一致性。

// 检查并移除bad tailprivate void checkAndRemoveTail() {rewind();int xCheck = 0;while(true) {//遍历日志byte[] log = internNext();if(log == null) break;xCheck = calChecksum(xCheck, log);}//xCheck 多条日志总的校验和if(xCheck != xChecksum) {Panic.panic(Error.BadLogFileException);}try {truncate(position);} catch (Exception e) {Panic.panic(e);}try {file.seek(position);} catch (IOException e) {Panic.panic(e);}rewind();}

向日志文件写入日志时,也是首先将数据封装成日志格式,写入文件后,再更新文件的校验和,更新校验和时,会刷新缓冲区,保证内容写入磁盘。

@Overridepublic void log(byte[] data) {byte[] log = wrapLog(data);ByteBuffer buf = ByteBuffer.wrap(log);lock.lock();try {fc.position(fc.size());fc.write(buf);} catch(IOException e) {Panic.panic(e);} finally {lock.unlock();}updateXChecksum(log);}private byte[] wrapLog(byte[] data) {byte[] checksum = Parser.int2Byte(calChecksum(0, data));byte[] size = Parser.int2Byte(data.length);return Bytes.concat(size, checksum, data);}private void updateXChecksum(byte[] log) {this.xChecksum = calChecksum(this.xChecksum, log);try {fc.position(0);fc.write(ByteBuffer.wrap(Parser.int2Byte(xChecksum)));fc.force(false);} catch(IOException e) {Panic.panic(e);}}

恢复策略:

数据管理层的日志恢复策略是在进行插入新数据(I)和更新现有数据(U)操作之前,必须先进行对应的日志操作,在保证日志写入磁盘后,才进行数据操作。

日志在数据操作之前,保证到达了磁盘,那么即使该数据操作最后没有来得及同步到磁盘,数据库就发生了崩溃,后续也可以通过磁盘上的日志恢复该数据

对于两种数据操作,数据管理层记录的日志如下:

(Ti, I, A, x),表示事务 Ti 在 A 位置插入了一条数据 x(Ti, U, A, oldx, newx),表示事务 Ti 将 A 位置的数据,从 oldx 更新成 newx

现在对第二点做个详细说明。DM操作数据项时,先修改的是内存中的数据,至于什么时候刷到磁盘,是需要策略的。

如果在修改数据项之前, 能够保证对应日志已经到达磁盘。那在修改数据项后,即使暂时不刷新到磁盘。也没关系。

假如在该段时间内MYDB发生了崩溃, 那磁盘上的数据项则会有误,因为正确的数据项内容还未被刷新到磁盘上。 但是因为MYDB在修改数据项前就保证了日志已经到达磁盘, 那么在下次启动时,一定可以根据上的日志内容来进行恢复。

我们首先不考虑并发的情况,那么在某一时刻,只可能有一个事务在操作数据库。日志会看起来像下面那样:

(Ti, x, x), ..., (Ti, x, x), (Tj, x, x), ..., (Tj, x, x), (Tk, x, x), ..., (Tk, x, x)

单线程

由于单线程,Ti、Tj 和 Tk 的日志永远不会相交。这种情况下利用日志恢复很简单,假设日志中最后一个事务是 Ti:

(1)对 Ti 之前所有的事务的日志,进行重做(redo);

(2)接着检查 Ti 的状态(XID 文件),如果 Ti 的状态是已完成(包括 committed 和 aborted),就将 Ti 重做,否则进行撤销(undo)。

接着,对事务 T 进行 redo操作如下:

(1)正序扫描事务 T 的所有日志;

(2)如果日志是插入操作 (Ti, I, A, x),就将 x 重新插入 A 位置;

(3)如果日志是更新操作 (Ti, U, A, oldx, newx),就将 A 位置的值设置为 newx;

undo 操作如下:

(1)倒序扫描事务 T 的所有日志

(2)如果日志是插入操作 (Ti, I, A, x),就将 A 位置的数据删除

(3)如果日志是更新操作 (Ti, U, A, oldx, newx),就将 A 位置的值设置为 oldx

注意,MYDB中其实没有真正的删除操作,对于插入操作的 undo,只是将其中的标志位设置为 invalid。

多线程

经过以上单线程的操作,能保证crazydb 在单线程下的数据恢复,但是还需考虑多线程下如何恢复数据;

在多线程下,我们需要考虑2中情况:

情况一:

T1 beginT2 beginT2 U(x)T1 R(x)...T1 commitMYDB break down

在系统崩溃时,T2 仍然是活跃状态。那么当数据库重新启动,执行恢复例程时,会撤销 T2,它对数据库的影响会被消除。但是由于 T1 读取了 T2 更新的值,既然 T2 被撤销,那么 T1 也应当被撤销。这种情况,就是级联回滚。但是,T1 已经 commit 了,所有 commit 的事务的影响,应当被持久化。这里就造成了矛盾。因此做了个规定1:正在进行的事务,不会读取其他任何未提交的事务产生的数据。

情况二:假设 x 的初值是 0

T1 beginT2 beginT1 set x = x+1 // 产生的日志为(T1, U, A, 0, 1)T2 set x = x+1 // 产生的日志为(T1, U, A, 1, 2)T2 commitMYDB break down

在系统崩溃时,T1 仍然是活跃状态。那么当数据库重新启动,执行恢复例程时,会对 T1 进行撤销,对 T2 进行重做,但是,无论撤销和重做的先后顺序如何,x 最后的结果,要么是 0,要么是 2,这都是错误的。

出现这个错误的原因是因为日志太过简单, 仅仅记录了"前相"和"后相". 并单纯的依靠"前相"undo, 依靠"后相"redo. 这种简单的日志方式和恢复方式, 并不能涵盖住所有数据库操作形成的语义。

解决方法有两种:

(1)增加日志种类

(2)限制数据库操作

MYDB采用的是限制数据库操作,并做了规定2:正在进行的事务,不会修改其他任何未提交的事务修改或产生的数据。

在 MYDB中,由于 版本管理层 的存在,传递到 数据管理 层,真正执行的操作序列,都可以保证规定 1 和规定 2。有了这两条规定,并发情况下日志的恢复也就很简单了:

(1)重做所有崩溃时已完成(committed 或 aborted)的事务

(2)撤销所有崩溃时未完成(active)的事务

在恢复后,数据库就会恢复到所有已完成事务结束,所有未完成事务尚未开始的状态。

在日志恢复策略Recover类中,定了了两种日志的格式:insert 和update。如下:

private static final byte LOG_TYPE_INSERT = 0;private static final byte LOG_TYPE_UPDATE = 1;

其格式如下:

updateLog:[LogType] [XID] [UID] [OldRaw] [NewRaw]insertLog:[LogType] [XID] [Pgno] [Offset] [Raw]

recover 实例主要完成2个任务:

重做所有已完成事务撤销所有未完成事务:private static void redoTranscations(TransactionManager tm, Logger lg, PageCache pc) {lg.rewind();while(true) {//返回logger的数据部分byte[] log = lg.next();if(log == null) break;if(isInsertLog(log)) {InsertLogInfo li = parseInsertLog(log);long xid = li.xid;//事务不是处于正在被处理的阶段if(!tm.isActive(xid)) {doInsertLog(pc, log, REDO);}} else {UpdateLogInfo xi = parseUpdateLog(log);long xid = xi.xid;if(!tm.isActive(xid)) {doUpdateLog(pc, log, REDO);}}}}private static void undoTranscations(TransactionManager tm, Logger lg, PageCache pc) {//事务xid号 list里面装日志Map logCache = new HashMap();lg.rewind();while(true) {//log的data部分byte[] log = lg.next();if(log == null) break;if(isInsertLog(log)) {InsertLogInfo li = parseInsertLog(log);long xid = li.xid;//log的data部分if(tm.isActive(xid)) {if(!logCache.containsKey(xid)) {logCache.put(xid, new ArrayList());}logCache.get(xid).add(log);}} else {UpdateLogInfo xi = parseUpdateLog(log);long xid = xi.xid;if(tm.isActive(xid)) {if(!logCache.containsKey(xid)) {logCache.put(xid, new ArrayList());}logCache.get(xid).add(log);}}}// 对所有active log进行倒序undofor(Entry entry : logCache.entrySet()) {List logs = entry.getValue();for (int i = logs.size()-1; i >= 0; i --) {byte[] log = logs.get(i);if(isInsertLog(log)) {doInsertLog(pc, log, UNDO);} else {doUpdateLog(pc, log, UNDO);}}tm.rollback(entry.getKey());}}

updateLog 和insertLog的重做和撤销处理,分别合并成一个方法来实现:

private static void doUpdateLog(PageCache pc, byte[] log, int flag) {int pgno;short offset;byte[] raw;if(flag == REDO) {UpdateLogInfo xi = parseUpdateLog(log);pgno = xi.pgno;offset = xi.offset;raw = xi.newRaw;} else {UpdateLogInfo xi = parseUpdateLog(log);pgno = xi.pgno;offset = xi.offset;raw = xi.oldRaw;}Page pg = null;//从页号在数据库中得到页的对象try {pg = pc.getPage(pgno);} catch (Exception e) {Panic.panic(e);}try {PageX.recoverUpdate(pg, raw, offset);} finally {pg.release();}}private static void doInsertLog(PageCache pc, byte[] log, int flag) {InsertLogInfo li = parseInsertLog(log);Page pg = null;try {pg = pc.getPage(li.pgno);} catch(Exception e) {Panic.panic(e);}//doInsertLog() 方法中的删除,使用的是 DataItem.setDataItemRawInvalid(li.raw);,// dataItem 将在下一节中说明,大致的作用,就是将该条 DataItem 的有效位设置为无效,来进行逻辑删除try {if(flag == UNDO) {DataItem.setDataItemRawInvalid(li.raw);}PageX.recoverInsert(pg, li.raw, li.offset);} finally {pg.release();}}

doInsertLog() 方法中的删除,使用的是 DataItem.setDataItemRawInvalid(li.raw);,dataItem 将在下一节中说明,大致的作用,就是将该条 DataItem 的有效位设置为无效,来进行逻辑删除。

数据项及其操作:

先从DM为上层模块提供的抽象说起:

为了数据的持久化,需要将数据存储在磁盘上,这肯定涉及到磁盘的读写。于是DM的首先工作就是对磁盘文件进行封装,这样上层模块不用关心磁盘读写细节。总的来说,DM的实现方式对DB进行分页管理,并为上层模块提供数据项(dataitem)的概念。

通过DM,上层模块可以将DB当做集合来访问,而集合内的元素就是一个个的数据项。为了方便描述,示例为x,y,z。标识数据项也可能会标识某个数据项的值。围绕数据项封装了访问和修改数据项的操作:

1)insert(x) 在DB中插入x;

2)Read(x) 调取x的值;

3)update(x) 更新x的值,至于将x的值更新成多少;

所以上层模块通过地址,向DM请求到对应的DataItem,再获取到其中的数据;

public class DataItemImpl implements DataItem {private SubArray raw;private byte[] oldRaw;private DataManagerImpl dm;private long uid;private Page pg;}

DataItem 中保存的数据,结构如下:

[ValidFlag] [DataSize] [Data]

其中 ValidFlag 占用 1 字节,标识了该 DataItem 是否有效。删除一个 DataItem,只需要简单地将其有效位设置为 0。DataSize 占用 2 字节,标识了后面 Data 的长度。

其中 ValidFlag 占用 1 字节,标识了该 DataItem 是否有效。删除一个 DataItem,只需要简单地将其有效位设置为 0。DataSize 占用 2 字节,标识了后面 Data 的长度。

上层模块在获取到 DataItem 后,可以通过 data() 方法,该方法返回的数组是数据共享的,而不是拷贝实现的,所以使用了 SubArray。

@Overridepublic SubArray data() {return new SubArray(raw.raw, raw.start+OF_DATA, raw.end);}

在上层模块试图对 DataItem 进行修改时,需要遵循一定的流程:在修改之前需要调用 before() 方法,想要撤销修改时,调用 unBefore() 方法,在修改完成后,调用 after() 方法。整个流程,主要是为了保存前相数据,并及时落日志。DM 会保证对 DataItem 的修改是原子性的。

@Overridepublic void before() {wLock.lock();pg.setDirty(true);System.arraycopy(raw.raw, raw.start, oldRaw, 0, oldRaw.length);}@Overridepublic void unBefore() {System.arraycopy(oldRaw, 0, raw.raw, raw.start, oldRaw.length);wLock.unlock();}@Overridepublic void after(long xid) {dm.logDataItem(xid, this);wLock.unlock();}

在使用完 DataItem 后,也应当及时调用 release() 方法,释放掉 DataItem 的缓存(由 DM 缓存 DataItem)。

@Overridepublic void release() {dm.releaseDataItem(this);}

数据操作管理器:

DataManager是DM层对外提供接口的类,同时,也实现DataItem对象的缓存,DataItem存储的key, 是由页号和页内偏移组成的一个 8 字节无符号整数,页号和偏移各占 4 字节。

只需要从 key 中解析出页号,从 pageCache 中获取到页面,再根据偏移,解析出 DataItem 即可 。

@Overrideprotected DataItem getForCache(long uid) throws Exception {short offset = (short)(uid & ((1L >>= 32;int pgno = (int)(uid & ((1L PageX.MAX_FREE_SPACE) {throw Error.DataTooLargeException;}// 尝试获取可用页PageInfo pi = null;for(int i = 0; i < 5; i ++) {pi = pIndex.select(raw.length);if (pi != null) {break;} else {int newPgno = pc.newPage(PageX.initRaw());pIndex.add(newPgno, PageX.MAX_FREE_SPACE);}}if(pi == null) {throw Error.DatabaseBusyException;}Page pg = null;int freeSpace = 0;try {pg = pc.getPage(pi.pgno);// 首先做日志byte[] log = Recover.insertLog(xid, pg, raw);logger.log(log);// 再执行插入操作short offset = PageX.insert(pg, raw);pg.release();return Types.addressToUid(pi.pgno, offset);} finally {// 将取出的pg重新插入pIndexif(pg != null) {pIndex.add(pi.pgno, PageX.getFreeSpace(pg));} else {pIndex.add(pi.pgno, freeSpace);}}}

尼恩提示:以上内容比较复杂,而且非常重要。如何把以上内容搞清楚? 可以看本文的配套视频《从0开始,老架构师带你手写DB》。

如果想把手写DB写入简历,可以找老架构师尼恩提供简历辅导,保证简历金光闪闪、天衣无缝。

总结:

DM的三个主要作用:

1)管理DB, 提供抽象;2)缓存;3)保证可恢复性。

但关于DM有下面几个需要特别注意的地方:

DM将DB从文件抽象为了数据项的集合,使得上层模块不再需要关系文件细节。在提供抽象时,没有提供delete(x)的操作, 这是由VM造成的。由于DM的缓存,数据的位置将会有"内存"和"磁盘"的概念,这两个位置之间的数据是需要同步的。DM通过日志的方式来保证可恢复性。DM的可恢复性保证,需要得到上层模块对两个规则的支持, 而这是由VM模块完成的. 其他DM的细节请参考代码注释。

作者介绍:

一作:Marry, 资深Java架构师、大数据架构师,近20年Java、大数据架构和开发经验。资深架构导师,成功指导了多个中级Java、高级Java转型架构师岗位。

二作: 尼恩,资深系统架构师、IT领域资深作家、著名博主。近20年高性能Web平台、高性能通信、高性能搜索、数据挖掘等领域的3高架构研究、系统架构、系统分析、核心代码开发工作。超级的架构升级导师:成功指导了大量的中级Java、高级Java转型架构,最高的年薪拿到 90W

说在后面:

持续迭代、持续升级 是 尼恩团队的宗旨,持续迭代、持续升级 也是 《从0开始,手写MySQL》的灵魂。

后面会收集更多的面试真题,同时,遇到面试难题,可以来尼恩的社区《技术自由圈(原 疯狂创客圈)》中交流和求助。

咱们的目标,打造宇宙最牛的《手写MySQL》高薪实操项目。 如果大家看不懂的,可以找尼恩获取本文的配套视频。

《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,到公号【技术自由圈】取

推荐相关阅读:

《》

《》

《》

•••展开全文