LearnProj
内容管理
- MySQL系统结构
- 一条SQL执行流程
- cfengDB整体结构
- 事务管理TM模块
- TID文件规则定义
- 文件读写 -- NIO
- RandomAccessFile、FileChannel、ByteBuffer
- 接口实现
- 文件合法检测
- begin()
- commit(tid)
- rollback(tid)
- tid文件创建
本文作为数工底层的项目CfengDB开始篇章,介绍开发缘由和实现思路
cfeng之前对数据库研究不深入,之前只是能够做到基本的SQL查询和基本的慢SQL优化,之前拿到数据库系统工程师证书还是只在业务上对于DB系统使用更深入,但是cfeng基于work的理解,当作为一个优秀的产品使用者之后,再来作为产品的开发者,二者是互相促进的, 现在信息时代的发展大数据量变得非常普遍,了解DB的设计开发对于我们的code是非常有帮助的,从SQLboy变成engineer
MySQL系统结构
工作中最常使用的DB系统应该就是MySQL了,当然随着信创生态,一些国产数据库也广泛普及,如何让我们的SQL更高效,查询更迅速,一致性问题等都是需要考虑的问题,MySQL最常见的几个概念应该就是索引,锁 + 事务MVCC了, 本文就不探究这些业务层面的实现了,这里主要介绍一下MySQL的架构。
这是网络上一个比较简化的结构图, 整体SQL的执行来说就4个部分:
- 连接器: 管理相关MySQL客户端与服务端的连接同时会进行旋前的验证
- 分析器: 词法分析和语法分析,也就是MySQL需要读懂整条SQL的意思
- 优化器: 客户端传送的SQL可能非常臃肿繁杂,server端需要进行简单的优化
- 执行器: MySQL读懂SQL含义并优化之后,开始执行,底层的存储引擎是插件式的,可供选择
一条SQL执行流程
上面的各个模块都是比较简化版的,这里再实例详述一遍。
后台应用和MySQL之间都维护了连接池,用于管理所有的连接,通过Connect就可以将待执行的SQL传送给MySQL服务端执行。
SQL语句到达MySQL服务端之后
- server的线程会将接收到的语句交给SQL接口(组件),该interface专门由于执行SQL语句
- SQL接口将语句传递给SQL解析器parser,进行词法语法分析,理解SQL语句的意思(从哪张表,什么过滤条件,提取哪些字段…)
- 之后将语句传送给查询优化器,选择最优的路径,达到最佳的性能(比如select x from student where id =1, 可以先查找所有的x再过滤,或者先过滤再取x)这类的路径选择就是优化器的工作
- 选择最佳路径后,再传递给执行器将SQL语句按逻辑执行(比如调用存储引擎的接口,获取user表的数据,判断,继续…)
- 调用存储引擎,执行,执行器会调用存储引擎完成 SQL的操作,存储引擎按照一定的步骤查询内存缓存数据,更新磁盘数据。
为了提高效率,MySQL中也存在缓存,如果SQL命中缓存,就不会再走流程,节约时间
cfengDB整体结构
就模块划分来说,MySQL是十分优秀的,cfengDB最多只能说是帮助us更加理解数据库这种产品的一种最简单的设计而已。
因为cfengDB需要完成的最基础的任务就是识别并正确执行SQL,因此1.0.0就只针对这一过程进行结构设计(其余的向连接池化,用户管理…都后续再设计)
cfengDB整体上也是分为前端和后端,前后端通过网络Socket同学,前端的职责就是读取用户的SQL并发送给后端server指向,输出返回的结果,后台和MySQL流程一样需要识别合法的SQL并执行
为了整体的可扩展性和实现的难度,采用分层和分治的思想进行模块划分,整体上划分为事务管理模块Transaction Manager, 数据管理模块Data Manager, 版本锁管理Version Manager,索引管理Index Manager,表管理Table Manager
最终需要实现的就是表管理Table Manager,通过表管理模块就可以提供server的相关服务,模块分层和依赖关系如下:
- 事务管理TM: 维护TID文件来维护事务的状态,提供接口供其他的模块来查询事务(参考的MySQL的MVCC)
- 数据管理DM: 直接管理数据库DB文件和日志文件,需要分页管理DB文件并且需要缓存,同时需要管理日志文件以供故障回复,抽象DB文件类提供上层使用
- 锁管理VM: 并发管理模块,基于2PL协议实现调度可串行化,利用MVCC实现隔离级别
- 索引管理IM: 基于B+树实现索引
- 表管理TBM: 整体上的表和字段管理,完成SQL解析和执行
事务管理TM模块
从模块层级来看,TM作为底层是最基础的部分,所以先从最基本的模块开始讲解:
TM主要是通过维护TID来维护事务的状态,提供接口供其他的模块查询状态
TID文件规则定义
cfengDB中每一个事务都会有一个TID进行表示,事务ID从1开始自增,不重复; 特殊规定 ----- TID为0表示无事务noneTransaction,代表事务不申请事务直接执行,该类型事务状态永远都是committed
TM中维护一个TID格式文件,记录Transaction的状态,其中cfengDB中规定事务的状态为三种:
0 running
: 正在执行,还没有结束1 commited
: 已提交3 rollback
: 撤销回滚
TID中,每一个事务有1字节空间保存状态,同时,TID头部还有8字节的数字,记录TID文件管理的事务的个数,那么事务tid在文件中的状态就存储在8 +(tid-1) 字节位置,【从0开始计数,并且TID为0代表none】
文件读写 – NIO
IO种类很多,有磁盘IO和网络IO, BIO、NIO、IO多路复用、AIO的概念大多用于网络IO — 用户程序从网络中获取数据socket
- 用户进程系统调用进入内核态
- OS等待远程客户端发送数据(TCP建立连接),OS从网卡设备获取数据,从Socket协议栈拷贝到内核缓冲区
- 把内核缓冲区的数据拷贝到用户缓冲区
- 用户进程获取到数据,继续执行
NIO相比BIO,AIO的区别就是:
步骤1,用户进程要进行IO了,是阻塞挂起,还是非阻塞
步骤3: 内核缓冲区数据拷贝到用户缓冲区,用户进程是阻塞还是非阻塞
BIO是同步阻塞,数据的读写都会阻塞在一个线程中
NIO是同步非阻塞,通过Selector监听不同的channel上的数据变化,当channel数据发生变化时,通知该线程进行读写操作,然后线程就会自己进行读写
AIO是异步,接收到客户端管道后,交给底层处理IO通信,自己本身做其余的事情
(同步和异步的意思是是否需要自己处理,而阻塞和非阻塞就是点餐后是否需要一直等着饭做好)
【步骤一只是通知(点餐),3就是真正开始读写】,1,3都阻塞,就是BIO,1不阻塞,3阻塞,就是NIO; 1,3都不阻塞,就是AIO
- BIO:阻塞IO,包括常见的ServerSocket/Socket, accept(); //连接阻塞; InputStream/OutputStream; IO读写阻塞
- NIO: new IO,noBlock; Selector复路器,缓冲区Buffer,ServerSocketChannel; IO未就绪的时候都是非阻塞的; 通过Linux提供的epoll + Selector + Channel实现多路复用【一个线程处理N个连接】
NIO核心组件:
- Channel: 通道,NIO数据源头/目的地,缓冲区Buffer的唯一接口: 双向读写【可读出和写入】、数据in/out总是先到缓冲区; {文件IO: FileChannel 从文件中读取数据, DataChannel: UDP读写网络数据,SocketChannel: TCP读写 ServerSocketChannel:服务端监听新TCP连接,每进入一个创建SocketChannel}
- Buffer: 缓冲区, NIO数据读写的中转地【一块连续内存块】,Buffer和传入的数组共享相同的数据存储区域; 可以简单理解两指针指向相同的块,作为数据缓存, 适用于除了boolean之外所有基本类型, 比如ByteBuffer、IntBuffer… allocate()动态分配yi
文件读写采用的是NIO方式,(NIO非阻塞,支持基于通道的IO操作,区别传统的Input/OutputStream),使用FileChannel,创建TxManager后,需要对TID文件校验,保证TID文件合法
校验方式: 文件头的8byte数字推断文件的理论长度,如果不同就认为不合法,不合法的,直接panic强制停机【对于无法回复的错误只能先粗暴停机】
//事务tid在tid文件中的位置
LEN_TID_HEADER_LEN + (tid - 1)* TID_FIELD_SIZE [头长度 + (序号 -1) * 每个tid长度]
所有的文件操作,执行后立刻刷入文件,防止崩溃后丢失, 使用NIO的fileChannel的force方法,和BIO的flush类似
【为了方便,java8开始支持在interface中定义静态方法体,static的方法: 调用时直接采用接口名称调用即可】
create()和open() 创建TID文件,从TID文件创建TransactionManager,创建XID文件时需要写一个空的TID的header,设置tidCounter为0(数量为0),否则后续会不合法
RandomAccessFile、FileChannel、ByteBuffer
IO的底层涉及的概念: 缓冲区操作
, 内核空间与用户空间
, 虚拟内存
,分页技术
磁盘操作属于内存管理,是OS系统级功能,需要在内核态执行,所以读取的数据是到内核缓冲区,之后再拷贝到用户态缓冲区
java中的IO常见操作read()和write()完成的作用: 数据在内核缓冲区和用户缓冲区进行交换, 传统IO是面向流(按字节读取),而NIO则是面向Buffer的
在java的内存结构中, 直接内存不受JVM管理, 而堆heap是受JVM用户进程管理; NIO中的Selector可以监听channel,【channel是数据源的抽象】,只有当某个channel准备好之后,线程才会阻塞去取数据操作,而不需要一直等待【BIO从最开始准备数据就开始阻塞】
- RandomAccessFile允许随机读写文件,也就是可以按照设定的位置开始读取(部分读取); 访问模式包括: r: 只读; rw: 读写; rws:读写,每次文件内容和元数据修改都同步磁盘; rwd: 读写,每次文件内容同步磁盘
- FileChannel: 通道, 通过文件位置指定开始读写, instance可以通过RandomAccessFile.getChannel()获得, 读写buffer后会自动向后移动pos
- ByteBuffer: 缓冲区,对byte数组的一种封装, position表示当前的小标,limit结束标记,capacity就是底层的byte数组的容量, 可以通过wrap或者allocate进行内存的分配,通过getXXX方法可以进行类型转换; 每次进行write的时候可以进行force来避免数据丢失
在这里为了方便进行各种类型的转换, 实现了一个byte[] 和 类型的转换工具类:
public static byte[] long2Byte(long value) {
return ByteBuffer.allocate(Long.SIZE/Byte.SIZE).putLong(value).array();
}
public static long parseLong(byte[] buf) {
ByteBuffer buffer = ByteBuffer.wrap(buf,0,8);
return buffer.getLong();
}
接口实现
首先TM中都是对于事务的操作,NO Transaction使用TID为0, TID的状态变化和事务的新增都是通过操作TID文件实现的, 所以TM的实现就是对于TID文件的创建和修改
//事务管理大多是对tid文件的管理,所以需要nio的RandomAccessFile和相关的channel
private RandomAccessFile randomAccessFile;
private FileChannel fileChannel;
private long tidCounter; //tid计数器
private Lock counterLock; //使用Lock锁保证线程安全
再进行TM初始化的时候就会进行TIdCounter的检查,检查的方式就是先检查头的长度,之后再整体计算文件长度
文件合法检测
getTidPosition就可以获取tid对应事务的状态字节开始的位置, 而实际的长度就需要再加上一个TID状态的长度 再和 fileLen相比即可
fileLen = randomAccessFile.length(); //文件实际长度
if(fileLen < TransactionConstant.LEN_TID_HEADER_LEN)
this.tidCounter = ByteBufferParser.parseLong(buffer.array()); //tid就是头8个字节表示的数据
long end = getTidPosition(this.tidCounter) + TransactionConstant.TID_FIELD_SIZE; //getTidPosition相当于取得的是tid该状态byte开始的位置,结束位置需要
if(end != fileLen)
private long getTidPosition(long tid) {
return TransactionConstant.LEN_TID_HEADER_LEN + (tid - 1) * TransactionConstant.TID_FIELD_SIZE;
}
而修改事务TID的状态的方法也很简单, 直接获取到TID的开始位置,之后将待写入的byte进行wrap为Buffer,写入再force即可
long offset = getTidPosition(tid);
byte[] tmp = new byte[TransactionConstant.TID_FIELD_SIZE];
tmp[0] = status; //修改状态为status
ByteBuffer buffer = ByteBuffer.wrap(tmp);
try {
fileChannel.position(offset);
fileChannel.write(buffer);
} catch (IOException e) {
FaultHandler.forcedStop(e);
}
//每次写buffer时候强制刷新一下,避免丢失
try {
fileChannel.force(false);
而检查状态就是读取对应位置的Buffer通过buffer.array()获取之后再进行比较即可
对于定义的相关的接口对于事务的相关操作,就是修改TID文件中的TID的状态; eg:
begin()
开始一个新的事务,所以首先tid ++,之后将该新事务的状态设置为运行RUNNING, 再将头部tidCounter数量加一写入头部
因为需要修改类属性,在并发状态下存在安全问题,所以使用ReentrantLock进行加锁修改
tidCounter ++;
//修改数量
ByteBuffer buffer = ByteBuffer.wrap(ByteBufferParser.long2Byte(tidCounter));
try {
fileChannel.position(0);
fileChannel.write(buffer);
} catch (IOException e) {
FaultHandler.forcedStop(e);
}
//每次写buffer时候强制刷新一下,避免丢失
try {
fileChannel.force(false);
this.counterLock.lock();
try {
long tid = tidCounter + 1; //当前事务
updateStatus(tid, TransactionConstant.FIELD_TRAN_RUNNING); //事务激活
incrTidCounter(); //头部size增加
return tid;
} finally {
this.counterLock.unlock();
}
commit(tid)
提交事务,有了之前的操作,这里就很好实现 — 直接修改事务TID文件中tid的状态为commited即可
rollback(tid)
和commit同理, 直接修改TID的状态(文件中)
isXXXX(tid)
状态检查,直接buffer读取对应tid位置的事务的状态再进行比较即可
tid文件创建
tid的文件创建可以直接利用File即可,因为TM的类属性有RandomAccessFile和FileChannel,所以再初始化一下
//创建文件
File file = new File(path + TransactionConstant.TID_SUFFIX);
try {
if(!file.createNewFile()) {
//文件创建失败已存在,直接粗暴处理,因为不能进行后续操作了
FaultHandler.forcedStop(new DatabaseException(ErrorCode.FILE_EXISTS));
}
}catch (Exception e) {
FaultHandler.forcedStop(e);
}
//查看文件是否可读写,直接调用File的接口
if(!file.canRead() || !file.canWrite()) {
FaultHandler.forcedStop(new DatabaseException(ErrorCode.FILE_CANNOT_READ_OR_WRITE));
}
//使用NIO进行文件读写
FileChannel fc = null;
RandomAccessFile raf = null; //与简单IO不同,可以调转到文件任何位置进行IO,访问文件部分内容
try {
raf = new RandomAccessFile(file,"rw"); //将file转为随机读写File,文件权限为rw
fc = raf.getChannel(); //利用randomAccessFile创建channel通道
} catch (FileNotFoundException e) {
FaultHandler.forcedStop(e);
}
//利用buffer和channel进行文件读写
//写空的文件头,将byte[]包装为buffer
ByteBuffer buf = ByteBuffer.wrap(new byte[TransactionConstant.LEN_TID_HEADER_LEN]);
try {
fc.position(0); //RandomAccessFile定位到0处开始
fc.write(buf); //缓冲区写入
} catch (IOException e) {
FaultHandler.forcedStop(e);
}
return new TransactionManagerImpl(raf, fc);
}
整个TM的实现都是依靠的tid文件的操作,主要是定义好规则,实现不难,这里的两重点技术: 可重入锁保证线程安全 + NIO方式进行文件操作提升性能🎄