天天看点

从0开始,手写MySQL数据管理器DM

作者:架构师尼恩

▌说在前面:

从0开始,手写一个MySQL的学习价值在于:

  • 可以深入地理解MySQL的内部机制和原理,Mysql可谓是面试的绝对重点和难点,

尼恩曾经指导过的一个7年经验小伙,凭借精通MySQL, 搞定月薪40K。

  • 从而更好地掌握MySQL的使用和优化。
  • 帮助你提高编程能力和解决问题的能力。
  • 作为一个优质的简历轮子项目,注意,是高质量的简历轮子项目

很多小伙伴的项目都很low,极度缺乏轮子项目,so,轮子项目这就来了。

前面已经发布了一篇:

《老架构带你手写数据库之1: 从0开始,手写MySQL事务管理器TM》

接下来,咱们来第二篇 :

《老架构带你手写数据库之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数据管理器DM

接下来,尼恩团队带大家,从基础的原理入手,一步一步从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开始,手写MySQL数据管理器DM

数据页

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

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

▌缓存页

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

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

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

从0开始,手写MySQL数据管理器DM

缓存页

▌Free链表

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

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

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

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

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

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

从0开始,手写MySQL数据管理器DM

Free链表

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

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

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

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

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

从0开始,手写MySQL数据管理器DM

数据页缓存哈希表

▌Flush链表

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

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

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

如何把脏页刷新呢?

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

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

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

Flush链表如下图:

从0开始,手写MySQL数据管理器DM

flush链表

▌LRU算法:

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

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

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

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

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

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

从0开始,手写MySQL数据管理器DM

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

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

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

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

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

从0开始,手写MySQL数据管理器DM

缓冲池 lru算法例子1

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

从0开始,手写MySQL数据管理器DM

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

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

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

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

从0开始,手写MySQL数据管理器DM

缓冲池 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链表,如下图所示:

从0开始,手写MySQL数据管理器DM

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 腾出内存空间。

来个整体的流程图如下:

从0开始,手写MySQL数据管理器DM

buffer pool 整体流程

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// 实际缓存的数据
private HashMap<Long, T> cache;
// 元素的引用个数
private HashMap<Long, Integer> references;
// 正在获取某资源的线程
private HashMap<Long, Boolean> 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,把资源放入缓存,引用计数器加1
    lock.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<Long> 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() 两个抽象方法。如下图所示:

从0开始,手写MySQL数据管理器DM

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

具体实现如下:

@Override
protected 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() 逐出页面时,也只需要根据页面是否是脏页面,来决定是否需要写回文件系统:

@Override
protected 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的FSO
public 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<PageInfo>[] lists;
}           

请求页面如下:

public PageInfo select(int spaceSize) {
    int number = spaceSize / THRESHOLD;
    if(number < INTERVALS_NO) number ++;
    while(number <= INTERVALS_NO) {
        if(lists[number].size() == 0) {
            number ++;
            continue;
        }
        return lists[number].remove(0);
    }
    return null;
}           

返回的 PageInfo 中包含页号和空闲空间大小的信息。

被选择的页,会直接从 PageIndex 中移除,这意味着,同一个页面是不允许并发写的。

在上层模块使用完这个页面后,需要将其重新插入PageIndex:

public void add(int pgno, int freeSpace) {
    int number = freeSpace / THRESHOLD;
    lists[number].add(new PageInfo(pgno, freeSpace));
}           

启动时,会获取所有页面并填充 PageIndex如下:

// 初始化pageIndex
void fillPageIndex() {
    int pageNumber = pc.getPageNumber();
    for(int i = 2; i <= pageNumber; i ++) {
        Page pg = null;
        try {
            pg = pc.getPage(i);
        } catch (Exception e) {
            Panic.panic(e);
        }
        pIndex.add(pg.getPageNumber(), PageX.getFreeSpace(pg));
        pg.release();
    }
}           

▌数据日志和恢复策略:

▌MySQL的日志

在开始介绍MySQL的日志之前,我们先来了解一下MySQL数据更新流程作为铺垫,如下图所示:

从0开始,手写MySQL数据管理器DM

MySQL数据更新流程

上图是MySQL更新数据的基础流程,其中包括redo log、bin log、undo log三种日志之间的大致关系。

日志是数据库的重要组成部分,主要用来记录数据库的运行情况、日常操作和错误信息。

若数据库发生故障,可通过不同日志记录恢复数据库的原来数据。

因此实际上日志系统直接决定着MySQL运行的鲁棒性和稳健性。

分析这些日志,可以帮助我们了解 MySQL 数据库的运行情况、日常操作、错误信息和哪些地方需要进行优化。

MySQL的日志有7种,接下来我们挨个来介绍一下。

▌redo log(重做日志)

redo log属于MySQL存储引擎InnoDB的事务日志,用来记录事务操作引起数据的变化,记录的是数据页的物理修改。。

重做日志就好比咱们的代码,万一哪天代码不小心delete了该如何处理呢?

以防这种不幸的发生,我们在写代码的时候,每次修改都使用git提交代码,并备注好修改了什么内容。这个就是重做日志。

▌脏数据刷盘

首先明确一下,脏数据是指未刷到磁盘上的数据。

redo log日志的大小是固定的,为了能够持续不断的对更新记录进行写入,在redo log日志中设置了两个标志位置,checkpoint和write_pos,分别表示记录擦除的位置和记录写入的位置。

redo log日志的写入示意图如下:

从0开始,手写MySQL数据管理器DM

redo log 日志的数据写入

当write_pos标志到了日志结尾时,会从结尾跳至日志头部进行重新循环写入。所以redo log的逻辑结构并不是线性的,而是可看作一个圆周运动。write_pos与checkpoint中间的空间可用于写入新数据,写入和擦除都是往后推移,循环往复的。

从0开始,手写MySQL数据管理器DM

redo log 循环写入

当write_pos追上checkpoint时,表示redo log日志已经写满。这时不能继续执行新的数据库更新语句,需要停下来先删除一些记录,执行checkpoint规则腾出可写空间。

checkpoint 规则: checkpoint 触发后,将buffer中脏数据页和脏日志页都刷到磁盘。

MySQL的数据是存放在磁盘中的,每次读写数据都需做磁盘IO操作,如果并发场景下性能就会很差。为此MySQL引入缓存Buffer Pool作为优化手段。Buffer Pool缓存池中包含了磁盘中部分数据页(page)的映射,以此来缓解数据库的磁盘压力。

当从数据库读数据时,首先从缓存中读取,如果缓存中没有,则从磁盘读取后放入缓存;当向数据库写入数据时,先向缓存写入,此时缓存中的数据页数据变更,这个数据页称为脏页,Buffer Pool中修改完数据后会按照设定的更新策略,定期刷到磁盘中,这个过程称为刷脏页。

如果刷脏页还未完成,可MySQL由于某些原因宕机重启,此时Buffer Pool中修改的数据还没有及时的刷到磁盘中,就会导致数据丢失,无法保证事务的持久性。

为了解决MySQL 宕机数据丢失的问题,引入了redo log ,redo log 记录的是数据库中每页的修改,可以用来恢复提交后的物理数据页,但是只能恢复到最后一次提交的位置。

redo log 采用的是WAL(Write-Ahead logging)技术,WAL技术的核心在于修改记录前,一定要先写日志,并保证日志先刷盘,才能算事务提交完成。

MySQL 引入redo log后,修改数据时,InnoDB引擎会把更新记录先写在redo log中,在修改Buffer Pool中的数据,当提交事务时,调用fsync把redo log刷入磁盘。至于缓存中更新的数据文件何时刷入磁盘,则由后台线程异步处理。

此时redo log的事务状态是prepare,还未真正提交成功,要等bin log日志写入磁盘完成才会变更为commit,事务才算真正提交完成。

由此一来,即使刷脏页之前MySQL意外宕机了也没问题,只要再重启时解析redo log 中更改记录进行重放,重新刷盘即可。

需要注意的是,redo log日志满了,在擦除之前,需要确保这些要被擦除记录对应在内存中的数据页都已经刷到磁盘中了。擦除旧记录腾出新空间这段期间,是不能再接收新的更新请求的,此刻MySQL的性能会下降。所以在并发量大的情况下,合理调整redo log的文件大小非常重要。

▌crash-safe

因为redo log的存在使得Innodb引擎具有了crash-safe的能力,即MySQL宕机重启,系统会自动去检查redo log,将修改还未写入磁盘的数据从redo log恢复到MySQL中。

MySQL启动时,不管上次是正常关闭还是异常关闭,总是会进行恢复操作。会先检查数据页中的LSN,如果这个 LSN 小于 redo log 中的LSN,即write pos位置,说明在redo log上记录着数据页上尚未完成的操作,接着就会从最近的一个check point出发,开始同步数据。

简单理解,比如:redo log的LSN是500,数据页的LSN是300,表明重启前有部分数据未完全刷入到磁盘中,那么系统则将redo log中LSN序号300到500的记录进行重放刷盘。

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

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

▌脏日志刷盘

除了对脏数据进行刷盘,实际上redo log日志在记录时,为了保证日志文件的持久化,也需要经历将日志记录从内存写入到磁盘的过程。redo log日志可分为两个部分,一是存在易失性内存中的缓存日志redo log buff,二是保存在磁盘上的redo log日志文件redo log file。

为了确保每次记录都能够写入到磁盘中的日志中,每次将redo log buffer中的日志写入redo log file的过程中都会调用一次操作系统的fsync操作。

fsync函数是属于系统核心函数;fsync 函数把 fd 对应的文件数据以及文件属性信息(inode等信息)写入到磁盘,并且等待写磁盘操作完成后而返回。应用程序一般都是通过调用该函数确保修改过的数据能够立即写入到磁盘上,比如 redis等。

fsync函数包含在UNIX系统头文件#include <unistd.h>中,其man文档如下:

从0开始,手写MySQL数据管理器DM

fsync说明

在写入的过程中,还需要经过操作系统内核空间的os buffer。redo log日志的写入过程如下图:

从0开始,手写MySQL数据管理器DM

脏日志刷盘

▌undo log(回滚日志)

undo log 和redo log一样,也是属于MySQL存储引擎InnoDB的事务日志。

undo log 属于逻辑日志,主要是起到回滚的作用,它保证事务原子性的关键。它对SQL语句执行相关的信息进行记录。当发生回滚时,InnoDB引擎会根据undo log日志中的记录做与之前相反的工作。

  • 比如对于每个数据插入操作(insert),回滚时会执行数据删除操作(delete);
  • 对于每个数据删除操作(delete),回滚时会执行数据插入操作(insert);
  • 对于每个数据更新操作(update),回滚时会执行一个相反的数据更新操作(update),把数据改回去。

undo log由两个作用,一是提供回滚,二是实现MVCC。

例如:执行一个update语句:update t_user set name="Niki" where id=1;

在执行这个update语句之前,会在undo log中记录一条相反逻辑的update t_user set name="Linda" where id=1; 记录,这样做的原因是当某些原因导致服务异常事务失败,就可以借助undo log将数据回滚到事务执行前的状态,保证事务的完整性。其执行流程如下:

从0开始,手写MySQL数据管理器DM

undo 执行流程

每当我们要对一条记录做改动时(这里的改动可以指INSERT、DELETE、UPDATE),都需要"留一手"把回滚时所需的东西记下来。undo log 日志的记录细节如下:

(1)插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。(对于每个INSERT,InnoDB存储引擎会完成一个DELETE);

(2)删除了一条记录,至少要把这条记录中的内容都记下来,在回滚时再把由这些内容组成的记录插入到表中就好了。(对于每个DELETE,InnoDB存储引擎会执行一个INSERT)

(3)修改了一条记录,至少要把修改这条记录前的旧值都记录下来,在回滚时再把这条记录更新为旧值就好了。(对于每个UPDATE,InnoDB存储引擎会执行一个相反的UPDATE,将修改前的行放回去)

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

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

▌undo log的作用

undo log 回滚日志具有2个作用:

(1)用于数据的回滚;如数据执行时候发生错误,操作系统的错误,断点等,程序员手动rollback等操作场景都会用到数据的回滚。

(2)实现MVCC;即在InnoDB存储引擎中MVCC的实现是通过undo来完成。当用户读取一行记录时,若该记录已经被其他事务占用,当前事务可以通过undo读取之前的行版本信息,以此实现非锁定读取。

注意:同一个事物内的一条记录被多次修改,不会每次都要把数据修改前的状态都写入undo log日志中。

undo log只负责记录事务开始前要修改数据的原始版本,当我们再次对这行数据进行修改,所产生的修改记录会写入到redo log,undo log负责完成回滚,redo log负责完成前滚。

回滚是指未提交的事务,即事务未执行commit。但该事务内修改的脏页中,可能有一部分脏块已经刷盘。如果此时数据库实例宕机重启,就需要用回滚来将先前那部分已经刷盘的脏块从磁盘上撤销。

前滚是指未完全提交的事务,即事务已经执行commit,但该事务内修改的脏页中只有一部分数据被刷盘,另外一部分还在buffer pool缓存上,如果此时数据库实例宕机重启,就需要用前滚来完成未完全提交的事务。将先前那部分由于宕机在内存上的未来得及刷盘数据,从redo log中恢复出来并刷入磁盘。

数据库实例恢复时,先做前滚,后做回滚。

▌bin log(归档日志)

bin log是一种数据库Server层,以二进制形式存储在磁盘中的逻辑日志。binlog主要记录数据库的变化情况,内容包括数据库所有的更新操作。所有涉及数据变动的操作,都要记录进二进制日志中。因此有了binlog可以很方便的对数据进行复制和备份,因而也常用作主从库的同步。

默认情况下,二进制日志功能是关闭的。可以通过以下命令查看二进制日志是否开启:

MySQL> show variables like 'log_bin';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| log_bin       | ON    |
+---------------+-------+
1 row in set (0.00 sec)           

bin log也被叫做归档日志,属于逻辑日志,因为它不会像redo log那样循环写擦除之前的记录,而是会一直记录日志。一个bin log日志文件默认最大容量1G(也可以通过max_binlog_size参数修改),单个日志超过最大值,则会新创建一个文件继续写。

show binary logs;
+---------------+------------+-----------+
| Log_name      | File_size  | Encrypted |
+---------------+------------+-----------+
| binlog.000011 | 1080360853 | No        |
| binlog.000012 |  942374569 | No        |
+---------------+------------+-----------+           

bin log日志的内容格式其实就是执行SQL命令的反向逻辑,这点和undo log有点类似。一般来说开启bin log都会给日志文件设置过期时间(expire_logs_days参数,默认永久保存),要不然日志的体量会非常庞大。

MySQL> 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的主从同步过程,如下图所示:

从0开始,手写MySQL数据管理器DM

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日志的读写。一条更新语句的执行过程如下:

从0开始,手写MySQL数据管理器DM

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。结果如下:

从0开始,手写MySQL数据管理器DM

▌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+data
    ByteBuffer buf = ByteBuffer.allocate(OF_DATA + size);
    try {
        fc.position(position);
        fc.read(buf);
    } catch(IOException e) {
        Panic.panic(e);
    }

    //单条日志的checksum+data
    byte[] 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;
}

@Override
public 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 tail
private 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();
}           

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

@Override
public 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 begin
T2 begin
T2 U(x)
T1 R(x)
...
T1 commit
MYDB break down           

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

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

T1 begin
T2 begin
T1 set x = x+1 // 产生的日志为(T1, U, A, 0, 1)
T2 set x = x+1 // 产生的日志为(T1, U, A, 1, 2)
T2 commit
MYDB 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<Long, List<byte[]>> 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进行倒序undo
    for(Entry<Long, List<byte[]>> entry : logCache.entrySet()) {
        List<byte[]> 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。

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

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

@Override
public void before() {
    wLock.lock();
    pg.setDirty(true);
    System.arraycopy(raw.raw, raw.start, oldRaw, 0, oldRaw.length);
}

@Override
public void unBefore() {
    System.arraycopy(oldRaw, 0, raw.raw, raw.start, oldRaw.length);
    wLock.unlock();
}

@Override
public void after(long xid) {
    dm.logDataItem(xid, this);
    wLock.unlock();
}           

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

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

▌数据操作管理器:

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

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

@Override
protected DataItem getForCache(long uid) throws Exception {
    short offset = (short)(uid & ((1L << 16) - 1));
    uid >>>= 32;
    int pgno = (int)(uid & ((1L << 32) - 1));
    Page pg = pc.getPage(pgno);
    return DataItem.parseDataItem(pg, offset, this);
}           

值得注意的是DataManger在打开已有文件时,需要额外对第一页进行校验,来判断是否需要执行恢复流程,同时重新对第一页生成随机字节。下面是创建和打开文件的具体代码。

public static DataManager create(String path, long mem, TransactionManager tm) {
    PageCache pc = PageCache.create(path, mem);
    Logger lg = Logger.create(path);
    DataManagerImpl dm = new DataManagerImpl(pc, lg, tm);
    dm.initPageOne();
    return dm;
}

public static DataManager open(String path, long mem, TransactionManager tm) {
    PageCache pc = PageCache.open(path, mem);
    Logger lg = Logger.open(path);
    DataManagerImpl dm = new DataManagerImpl(pc, lg, tm);
    if(!dm.loadCheckPageOne()) {
        Recover.recover(tm, lg, pc);
    }
    dm.fillPageIndex();
    PageOne.setVcOpen(dm.pageOne);
    dm.pc.flushPage(dm.pageOne);
    return dm;
}           

而创建文件的初始化第一页和打开文件的校验都是调用PageOne方法来实现:

// 在创建文件时初始化PageOne
void initPageOne() {
    int pgno = pc.newPage(PageOne.InitRaw());
    assert pgno == 1;
    try {
        pageOne = pc.getPage(pgno);
    } catch (Exception e) {
        Panic.panic(e);
    }
    pc.flushPage(pageOne);
}

// 在打开已有文件时时读入PageOne,并验证正确性
boolean loadCheckPageOne() {
    try {
        pageOne = pc.getPage(1);
    } catch (Exception e) {
        Panic.panic(e);
    }
    return PageOne.checkVc(pageOne);
}           

除了数据文件读写,DM层还提供了读、插入数据库操作。而修改操作其实是先删除旧对象后再插入新对象值。这个会在下面的TBM模块具体讲解。读操作是根据UID从缓存中获取DataItem, 并检验有效位:

@Override
public DataItem read(long uid) throws Exception {
    DataItemImpl di = (DataItemImpl)super.get(uid);
    if(!di.isValid()) {
        di.release();
        return null;
    }
    return di;
}           

插入操作是在PageIndex中获取一个足以存储插入内容的页面页号, 获取页面后,首先需要写入插入日志,接着才可以通过 pageX 插入数据,并返回插入位置的偏移。最后需要将页面信息重新插入 pageIndex。

@Override
public long insert(long xid, byte[] data) throws Exception {
    byte[] raw = DataItem.wrapDataItemRaw(data);
    if(raw.length > 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重新插入pIndex
        if(pg != null) {
            pIndex.add(pi.pgno, PageX.getFreeSpace(pg));
        } else {
            pIndex.add(pi.pgno, freeSpace);
        }
    }
}           

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

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

▌总结:

DM的三个主要作用:

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

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

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

▌作者介绍:

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

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

▌说在后面:

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

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

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

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

▌推荐相关阅读:

《从0开始,手写MySQL事务》

《京东太猛,手写hashmap又一次重现江湖》

《网易一面:如何设计线程池?请手写一个简单线程池?》

继续阅读