数据库原理入门 (1) - 持久化

91 阅读14分钟

一直都想找数据库原理的书来看, 但大部分都是只讲理论, 没有具体代码, 直到发现Edward Sciore所著的Database Design and Implementation。最近看完, 发现数据库涉及的内容庞大, 比我料想要难和复杂, 认为有必要写些东西记录自己学到的内容。注意, 尽管说是入门, 但不适合编程新手, 最好有一定的数据库实践, 做过一些后端项目看会比较好。

硬盘操作原理

虽然不少人希望一上来就可以直接干数据库原理, 干代码, 但在此之前, 有必要了解下硬盘的原理, 对之后数据库学习有一定帮助。

机械硬盘

从常见的机械硬盘说起, 先看一下它的内部图:

machine_disk.png

简单来说, 就是通过转动磁盘, 让磁头作读写操作, 这意味着, 转速越快, 读写速度越快, 这个参数就是 转/分钟 (Round Per Minute RPM)

要想决定到底是读写哪个位置的数据, 是通过磁头, 扇区(基于磁盘圆心划分的区域), 第几层磁盘, 还有磁道(磁盘上的圈)来决定的。要想通过上述参数来操作硬盘有够复盘, 所以一般引入中间层, LBA模式来操作。LBA, 即 Logical Block Address, 逻辑块地址, 把上述物理位置转成统一编号。比如, 某硬盘有6个磁头, 每面有1000个磁道, 每磁道有17个扇区, 那么:

LBA的0扇区对应0面, 0道, 1扇区

LBA的1扇区对应0面, 0道, 2扇区

...

到目前为此, 所说都是理论上的东西, 为了能更具体说明, 摘取李忠的x86汇编语言作一个演示。为什么要用汇编? 因为如果想直接操作硬件, 除了汇编没别的选择。别怕, 汇编本身不难:


mov dx, 0x1f2

mov al, 1

out dx, al

  


inc dx

mov ax, si

out dx, al

  


inc dx

mov ax, si

out dx, al

  


inc dx

mov al, ah

out dx, al

  


inc dx

mov ax, di

out dx, al

  


inc dx

mov ax, di

out dx, al

  


inc dx

mov al, 0xe0

or al, ah

out dx, al

  


inc dx

mov al, 0x20

out dx, al

  


.waits:

  in al, dx

  and al, 0x88

  cmp al, 0x08

  jnz .waits

  


  mov cx, 256

  mov dx, 0x1f0

  


.readw:

  in ax, dx

  mov [bx], ax

  add bx, 2

  loop .readw

上面的代码要怎么阅读呢? 举个例 mov dx, 0x1f2, mov是指令, 指的是移动数据, 赋值, 那是哪个赋给哪个呢? 这要看操作系统, 如果是linux的话, 是左边赋值给右边, 即0x1f2 = dx, 而在windows下, 是右边赋值给左边, dx = 0x1f2。 上述代码是在windows平台下的, 所以是后者。前缀为0x表示是16位操作, 汇编数值基本都是16位表示, 原因是计算机是以二进制操作, 但如果写成二进制数值看起来好长, 不方便阅读, 所以选用16进制来表示。明白规则后, 开始研究到底上面代码做了什么?


mov dx, 0x1f2

mov al, 1

out dx, al

第一步就是设要读取多少扇区, 这个数要写入0x1f2端口, 所以out dx, al指的是向0x1f2端口发送1这个参数, 说明要读取一个扇区。想想平时调用请求后端api函数就好理解了。

只有多少是不够的, 得要说明从哪开始读取:


inc dx      ; 0x1f3

mov ax, si  ; si 指的是扇区号

out dx, al  ; ; LBA 7-0

  


inc dx      ; 0x1f4

mov al, ah  

out dx, al  ; LBA 15-8

  


inc dx      ; 0x1f5

mov ax, di  

out dx, al  ; LBA 23-16

  


inc dx      ; 0x1f6

mov al, 0xe0  ; LBA28模式, 主盘

or al, ah     ; LBA 27-24

out dx, al

  


inc dx      ; 0x1f7

mov al, 0x20  ; 读命令

out dx, al

inc指的是加1的意思, 首三部分汇编指定扇区, 因为每个端口只支持八位, 所以要分开传, 之后0xe0的指的通过LBA模式去操作主硬盘, 最后0x20指要读取。不要纠结参数指令, 了解要做的操作, 感受汇编的形式就好了, 毕竟这不是汇编课程。


.waits:

  in al, dx

  and al, 0x88

  cmp al, 0x08

  jnz .waits

  


发送读取请求后, 要开始等于, in al, dx 把状态写入寄存器al, 用0x88做位运算, 筛出要查看的状态, 比较是0x08, 是就跳出循环, 否则就自旋等待。


  mov cx, 256   ; 总共要读取字数

  mov dx, 0x1f0

  


.readw:

  in ax, dx

  mov [bx], ax

  add bx, 2

  loop .readw

终于要读取数据了, 先设cx为256, 表示要读取256字节, 每走一次loop指令, cx减一。dx设为0x1f0, 是因为规定从这端口读取。mov [bx], ax 是指针操作, 懂c的秒懂, 不懂的我简单解释下, 就是把ax的值赋值给bx所指定的内存位置, 例如bx为0x0001, 表示把ax的值传入内存0x0001的位置。add bx, 2就是加2, 作偏移, 然后继续loop, cx减1, 直到cx为0。

ssd固态硬盘

如果是早几年, 写完机械硬盘, 硬盘部分就完结, 但随着固态硬盘越来越普遍, 有必要简单介绍它。

ssd.jpg

ssd, Solid State Drive,即固态硬盘的缩写。目前主流的SSD是使用半导体闪存(Flash)作为介质的存储设备。相比于固态硬盘要依赖磁盘转动和磁头操作, ssd是依赖电子操作, 数据读写通过ssd控制器寻址, 从物理上可想而知, 速度远远超越机械。

基于操作系统上硬盘操作

看完汇编层面对硬盘操作, 是否觉得好麻烦? 所以一般来说, 我们不会直接去操作它, 而且通过操作系统提供给我们的api来操作, 这样既安全又方便。而基于操作系统, 可以分两个层面去操作硬盘, 一是从块层面, 另一种是从文件层面。

块层面硬盘操作

不同硬盘有不同特性, 如果开发者在做开发时要关注它们, 在做项目时要做相应调整, 那不得累死。所以操作系统把不同硬盘操作转化成块级操作, 开发者只需调用操作系统提供的api。例如:


readblock(n, p)

  


writeblock(n, p)

readblock从编号n的块读取数据, 写入指定p中, p指的是内存区域。写也是同理。

操作系统可以通过两种策略来管理块。一是利用位图, 把所有块的使用状态映射到一个块中, 用0, 1表示相应的块是否使用中。画个图来表示:

Bitmap.png

另一种用链表来管理。可以用一链表串起一系列的chunk, chunk指的是一系列连续的块。每一个chunk的第一个块要存两个值: 一是chunk的长度, 二是下一个空闲的chunk的块编号。而硬盘第一个块存有指向第一个空闲的chunk的位置。同理, 画个图吧:

freeChain.png

chunk 0 只有一个值, 指的是第一个空闲的chunk的位置, 而chunk 1被占用, chunk2 则有两个值, 1指的是只有一个块的长度, 二是下一个空闲chunk的位置。

位图的好处是直观, 方便操作, 但得另外开一块空间来管理。链表的好处是省空间, 但实现有一定难度。题外话, c语言的malloc实现原理就类似于这链表操作。

文件层面硬盘操作

尽管用块接口来操作硬盘已经方便多了, 但要想读写硬盘依然有一定难度, 读写指定数据得要知道数据在什么编号的块, 要知道放到什么内存位置上。这就要用文件系统来解决了。

用一段java代码来展示其方便性:


RandomAccessFile f = new RandomAccessFile("foo", "rw");

f.seek(7992);

int n = f.readInt();

f.seek(7992);

f.writeInt(n + 1);

f.close();

RandomAccessFile类可以随机访问指定文件, 即不需要把文件内容全部读取出来, 而是把文件作为字节流, 用指针来访问。上述代码很简单, 就是创建foo文件的RandomAccessFile, 然后把指针指到7992字节, 然后做int读写操作, 最后关了它。

使用文件系统来操作硬盘大大方便了开发。首先, 看上述代码就好像是直接操作硬盘, 事实上是操作系统留有一定内存空间作缓冲区用, 它帮我们把数据写入那里。另外一点是它把文件转成字节流, 只需关注字节位置即可, 不需知道块的编号和长度, 哪个块是空闲的, 操作系统都处理好了。

调用seek时, 操作系统作了两个转换:

  1. 先把指定字节位置转成逻辑块位置

  2. 把逻辑块位置转成物理块位置

第一个很简单, 就是数值运算。例如一个块是4k长字节, 7992就是在块1 (7992 / 4096)。第二个得看操作系统是按什么策略来管理文件, 相关信息放在文件系统的文件夹中, 因此seek方法得去那里查看。

1. 连续分配 (Continuous Allocation)

这是最直接粗暴的策略, 即把文件视为连续的块, 文件系统文件夹存有每个文件的块大小和它第一个块的位置。要想访问某个文件, 即去找它第一个块的位置, 它的界限则是 它第一个块的位置 + 文件的块大小。这个策略实现很简单, 但会带来两个问题。一是文件的大小是创建时固定的, 没法扩建, 意味着创建时要想好用多少, 很容易导致空间浪费。第二个问题是文件块之间的空闲空间没法充分利用, 当文件容量满了后, 这些空闲空间只能阁置。前者是内部碎片化问题, 后者是外部碎片化。

2. 基于扩展分配 (Extent-Based Allocation)

为了解决上述的碎片化问题, 可以把硬盘分成固定长度的块集合, 然后文件另外存有链表, 串起所使用的集合的第一个块。举个例子, 现在系统中的每个集合是存8个块, 有一文件foo, 它持有以下块集合:

|  文件   | 块集合  |

|  ----  | ----  |

| foo  | 32, 480, 696, 72, 528, 336 |

块集合的编号指的是集合中的第一个块。现在要找出foo文件的第21个块:

  1. 因为每个集合存有8个块, 所以可以推出第21个块是在第二个集合 (21 整除 8 = 2)

  2. 第二个集合的第一个块是文件的第16个块, 因为8 * 2是16

  3. 由此得出, 第21个块是在第二个集合的第五个块

  4. 从列表得知, 第二个集合的第一个块是696号

  5. 所以第21个块是701号 (696 + 5)

3. 索引分配 (Indexed Allocation)

还有另一种方法, 就是文件利用一个块存放块的索引, 这样可以比刚才的方法更充分利用硬盘空间, 毕竟前一个方法是以块集合为单位, 多少依然有些碎片化。方法如下所示:

|  文件   | 索引块  |

|  ----  | ----  |

| foo  | 34 |

|  索引块   | 内容  |

|  ----  | ----  |

| 34  | 32, 103, 16, 17, 98 |

但索引分配有一个问题, 就是文件会有最大的空间, 因为它只能存放索引的空间是由索引块的空间决定。

数据库的文件管理

现在知道操作系统两个层面的接口, 要怎样实现数据库的文件管理? 如果直接用块级层面的api, 好处是灵活,坏处是实现非常复杂; 用文件层面的api, 好处是简单, 例如一张表一个文件, 但坏处是所有io操作都得局限于操作系统, 性能会大打折扣。

还有一种折中的方法, 就是把数据存入一个或多个文件中, 而文件则视为一堆硬盘块般操作, 这就既可保持一定灵活性, 又可利用操作系统提供的文件系统。

文件管理器

接下来用代码展示上述折中方法如何实现。

数据库中会存放不同文件, 有的是表, 有的是日志, 也有的是索引。另外得有类可以操作其中的块。先列出涉及的接口:


// BlockId

public BlockId(String filename, int blknum);

public String filename();

public int number();

  


// Page

public Page(int blocksize);

public Page(byte[] b);

public int getInt(int offset);

public byte[] getBytes(int offset);

public String getString(int offset);

public void setInt(int offset, int val);

public void setBytes(int offset, byte[] val);

public void setString(int offset, String val);

public int maxLength(int strlen);

  


// FileMgr

public FileMgr(String dbDirectory, int blocksize);

public void read(BlockId blk, Page p);

public void write(BlockId blk, Page p);

public BlockId append(String filename);

public boolean isNew();

public int length(String filename);

public int blockSize()

BlockId指的是文件中的块id, 通过它就可以找出文件中对应的块。Page指的是持有文件块数据的内存页, 内存页用来作数据库的缓冲用的, 是下一节的主要内容。FileMgr用来操作文件, 就是用它来操作BlockId, 它的构造方法, 指定数据库文件夹和里面每个块的大小。

看一下代码实现吧:


public class BlockId {

  


    private String filename;

    private int blknum;

  


    public BlockId(String filename, int blknum) {

        this.filename = filename;

        this.blknum = blknum;

    }

  


    public String fileName() {

        return filename;

    }

  


    public int number() {

        return blknum;

    }

  


    @Override

    public  boolean equals(Object obj) {

        if (!(obj instanceof BlockId)) {

            return false;

        }

        BlockId blockId = (BlockId) obj;

  


        return this.blknum == blockId.blknum && this.filename == blockId.filename;

    }

  


    @Override

    public String toString() {

        String result = String.format("[file %s, block %d", this.filename, this.blknum);

        return result;

    }

  


    @Override

    public int hashCode() {

        return toString().hashCode();

    }

}

BlockId没什么好说, 主要用来存放数据。


public class Page {

  


    private ByteBuffer bb;

    public static final Charset CHARSET = StandardCharsets.US_ASCII;

  


    public Page(int blocksize) {

        bb = ByteBuffer.allocateDirect(blocksize);

    }

  


    public Page(byte[] b) {

        bb = ByteBuffer.wrap(b);

    }

  


    public int getInt(int offset) {

        return bb.getInt(offset);

    }

  


    public void setInt(int offset, int value) {

        bb.putInt(offset, value);

    }

  


    public byte[] getBytes(int offset) {

        bb.position(offset);

        // 先获取长度

        int length = bb.getInt();

        byte[] b = new byte[length];

        bb.get(b);

        return b;

    }

  


    public void setBytes(int offset, byte[] b) {

        bb.position(offset);

        bb.putInt(b.length);

        bb.put(b);

    }

  


    public String getString(int offset) {

        byte[] b = getBytes(offset);

        return new String(b, CHARSET);

    }

  


    public void setString(int offset, String s) {

        byte[] b = s.getBytes(CHARSET);

        setBytes(offset, b);

    }

  


    public static int maxLength(int strlen) {

        float bytesPerChar = CHARSET.newEncoder().maxBytesPerChar();

        // Integer.Bytes是预留给长度的值

        return Integer.BYTES + (strlen * (int) bytesPerChar);

    }

  


    ByteBuffer contents() {

        bb.position(0);

        return bb;

    }

}

可以看出Page就是对ByteBuffer的封装, 重点看一下setBytesgetBytes:


    public byte[] getBytes(int offset) {

        bb.position(offset);

        // 先获取长度

        int length = bb.getInt();

        byte[] b = new byte[length];

        bb.get(b);

        return b;

    }

  


    public void setBytes(int offset, byte[] b) {

        bb.position(offset);

        bb.putInt(b.length);

        bb.put(b);

    }

从两者可以得知, 写入字节, 得先写入字节大小, 然后才写入内容。还有maxLength:


    public static int maxLength(int strlen) {

        float bytesPerChar = CHARSET.newEncoder().maxBytesPerChar();

        // Integer.Bytes是预留给长度的值

        return Integer.BYTES + (strlen * (int) bytesPerChar);

    }

要另外加上Integer.BYTES, 是要考虑长度值。所以Page里的内容如下图所示:

pageContent.png

FileMgr:


/**

 * 文件管理, 封装数据库的文件操作

 */

public class FileMgr {

  


    private File dbDirectory;

    private int blocksize;

    private boolean isNew;

    private Map<String, RandomAccessFile> openFiles = new HashMap<>();

  


    public FileMgr(File dbDirectory, int blocksize) {

        this.dbDirectory = dbDirectory;

        this.blocksize = blocksize;

        isNew = !dbDirectory.exists();

  


        if (isNew) {

            dbDirectory.mkdirs();

  


            // 如果有临时文件, 就删除

            for (String filename : dbDirectory.list()) {

                if (filename.startsWith("temp")) {

                    new File(dbDirectory, filename).delete();

                }

            }

        }

    }

  


    public synchronized void read(BlockId blk, Page p) {

        try {

            RandomAccessFile f = getFile(blk.fileName());

            f.seek(blk.number() * blocksize);

            // 注入相应位置的字节到page的buffer

            f.getChannel().read(p.contents());

        } catch (IOException e) {

            throw new RuntimeException("cannot read block " + blk);

        }

    }

  


    public synchronized void write(BlockId blk, Page p) {

        try {

            RandomAccessFile f = getFile(blk.fileName());

            f.seek(blk.number() * blocksize);

            f.getChannel().write(p.contents());

        } catch (IOException e) {

            throw new RuntimeException("cannot write block " + blk);

        }

    }

  


    public synchronized BlockId append(String filename) {

        int newblknum = length(filename);

        BlockId blk = new BlockId(filename, newblknum);

        byte[] b = new byte[blocksize];

        try {

            RandomAccessFile f = getFile(blk.fileName());

            f.seek(blk.number() * blocksize);

            f.write(b);

        }

        catch (IOException e) {

            throw new RuntimeException("cannot append block" + blk);

        }

        return blk;

    }

  


    /**

     * 基于blocksize, 返回文件长度

     * @param filename

     * @return

     */

    public int length(String filename) {

        try {

            RandomAccessFile f = getFile(filename);

            return (int) (f.length() / blocksize);

        } catch (IOException e) {

            throw new RuntimeException("cannot access " + filename);

        }

    }

  


    public boolean isNew() {

        return isNew;

    }

  


    private RandomAccessFile getFile(String filename) throws IOException {

        RandomAccessFile f = openFiles.get(filename);

        if (f == null) {

            File dbTable = new File(dbDirectory, filename);

            // 把涉及数据的元信息也同步写入

            f = new RandomAccessFile(dbTable, "rws");

            openFiles.put(filename, f);

        }

  


        return f;

    }

  


    public int blockSize() {

        return blocksize;

    }

}

  


可以看出FileMgr才是真正操作文件的类, 通常BlockIdRandomAccessFile结合, 获取块级操作的灵活性, 这样就不用每次读写都要把文件数据放入内存才能操作。代码没什么复杂, 不过是通过传入BlockId作索引, 借助blocksize转换到真正的字节位置。至于为什么涉及内容的方法要加上synchronized? 是因为数据库一般是多用户操作, 要考虑并发问题。

源代碼: gitee.com/Dominguito/…

切換到分支 FileManager 就是涉及本節代碼。