MySQL数据库
三层架构
- Client Connectors 连接层 负责处理客户端的连接请求,身份验证(账号密码校验)以及连接状态的维护。MySQL内部维护了一个线程池,为每个建立的连接分配一个线程。
- Server 服务层 分析器:进行词法分析和语法分析。
优化器:SQL语句正确时,决定如何最高效地执行。生成执行计划。
执行器:检验用户对表是否有执行权限,根据执行计划,调用底层API操作数据,并将结果集返回客户端 - Storage Engine 存储引擎层 负责数据的真正存储、检索以及事务机制,Server通过标准的API与存储引擎交互,存储引擎可以更换而Server无需修改,默认的存储引擎是InnoDB。
InnoDB
内存部分:
- Buffer Pool(缓冲池):缓存了数据页,索引页,数据字典。
- Change Buffer(写缓冲):执行修改操作,而非唯一二级索引页不在Buffer Pool中,InnoDB则将修改操作记录在Change Buffer,下次将页读入内存时,再进行合并操作。
- Adaptive Hash Index(自适应哈希索引):InnoDB的索引结构是B+树,对于某些频繁被访问的索引值,内存中会动态构建一个哈希索引,哈希索引时间复杂度为O(1),热点数据查询速度提升。
- Log Buffer(日志缓冲):Log在写到磁盘之前先存到Log Buffer中,根据一定的配置,按时刷入磁盘,提高日志写入效率。
三种日志形式:
- Redo Log:InnoDB维护,记录对数据的操作。保证持久性。
- Undo Log:InnoDB维护,用于记录数据的逻辑反向操作,实现回滚。保证原子性。
- Binlog:Server维护的日志,记录所有DML和DDL语句,用于数据备份恢复和数据同步。 InnoDB通过日志系统来保证内存数据不丢失--WAL机制,Write-Ahead Logging。如果内存执行完一条命令,然后就断电宕机,未落盘的数据就会丢失。比起修改完一个数据然后落盘这种不确定的随机I/O,直接向磁盘上的一个指定位置写入数据这种顺序I/O速度更快。Redo Log会记录对具体位置的数据做了什么修改。宕机后重启,MySQL通过重放Redo Log,也可以恢复Buffer Pool中的数据。实现了事务的持久性。
两阶段提交,执行一条语句,InnoDB需要写Redo Log,Server需要写BinLog。为了确保两个日志的进度相同。
- Prepare阶段:执行器调用InnoDB接口修改数据,InnoDB将修改记录写入RedoLog,Redo Log的状态标记为prepare
- 写BinLog:Server层生成这条SQL的BinLog并写入磁盘。
- Commit阶段:执行器调用InnoDB的提交事务接口,InnoDB将Redo Log的状态从prepare修改为commit,更新完成。 如果写日志的过程中服务器宕机,重启恢复时按规则进行判断。如果Redo Log是commit,则直接恢复。如果Redo Log是prepare,则检测BinLog是否完整,如果Binlog不完整,则回滚事务。如果Binlog完整,则直接提交事务。
数据页与行的物理存储
数据页: 磁盘与内存交互的基本单位,默认大小16KB。
Compact: 数据在页内部是一行一行的存储,记录存储数据和额外的信息。InnoDB还会强制为每一行添加隐藏列,标记最后一次修改该行的事务ID,回滚指针。
索引系统与SQL优化
B+树索引
B+树与Hash函数相比,可以进行范围查询和排序,而Hash则需要遍历全表。B+树与B树相比,非叶子节点只存索引键值和指向下一层的指针,数据全部存放在叶子节点,使得一个16KB的页就可以存放上千个指针,高度为3的B+树可以存放千万级别数据,3次I/O就可以查询成功。而且叶子节点之间还通过双向链表相连,加快了一些顺序读取的操作,避免了重新从根节点遍历的开销。
聚簇索引与非聚簇索引
聚簇索引以主键作为索引建立一颗B+树,叶子节点为全量数据,非聚簇索引以普通字段作为索引建立一颗B+树,叶子节点为对应的主键索引。非聚簇索引的目的是为了借用B+树的结构加快特定SQL查找效率,叶子节点存主键又节省了内存和修改次数。查询非聚簇索引需要用到回表查询,先遍历二级索引树,获得该节点的主键值,去主键所在的聚簇索引B+树从根节点再搜索一次,得到完整的物理数据。
然而非聚簇索引的叶子节点只存主键值,回表再次查询主索引树是随机I/O,速度较慢,可以利用覆盖索引,使二级索引树的叶子节点不只包含一个主键值,还人为地包含其它常用的字段数据,利用叶子节点之间的双向链表进行快速查找,联合索引(age,name),先按age排,age内部再按name排。要想触发联合索引的快速树状搜索,查询条件必须从索引最左侧的列开始连续匹配,不能跳过。
EXPLAIN用于查看SQL的执行效果,EXPLAIN SELECT * FROM TABLE;Extra字段为Using index时,代表仅通过二级索引树,不需要回表。type:ALL,代表最差的性能,全表扫描。type为ref,非唯一性索引进行等值查找。index:表示从联合索引树的第一个子节点开始顺序遍历。表达式运算会导致索引树彻底失效,存储引擎会将全表的数据逐行读入内存,然后再对表达式的要求值进行判断。
在MySQL中,WHERE条件通常分为三类:Index Key,用索引列定位扫描范围;Index Filter,能用索引列直接过滤的条件,Table Filter,必须回表才能过滤的条件。没有ICP技术(Index Condition Pushdown索引下推),无论Index Filter是否满足,都会回表获取完整行,再交给Server进行过滤。有ICP时,在存储引擎层进行Index Filter判断,不满足的直接跳过,不回表。
事务、锁与并发控制
脏读
当事务A开启修改完数据但是未提交时,事务B如果进行读,读到了修改后的数据,但是事务A进行了回滚,于是事务B发生了脏读。为了杜绝脏读,数据库必须保证事务的隔离性,事务绝对不允许读取其它事务未提交的数据。
数据库两种读取机制:快照读,事务B执行普通查询,MySQL不会加锁,也不会去等待A释放锁,InnoDB通过MVCC(多版本并发控制),直接给B返回一个历史的一致性快照版本,实现了读写并发时不阻塞且不脏读。当前读(加锁SELECT或UPDATE),必须拿到最新真实的物理数据。
MVCC的读历史版本功能,InnoDB对于每一行数据都具有隐藏列,包含指向Undo Log的物理地址和最后一次修改这行数据的事务唯一编号。当需要MVCC读时,需要先判断需不需要进行Undo,通过Read View实现。Read View是每个事务开启查询的瞬间,记录的所有还在运行未提交的事务ID。如果B发现这行数据的事务编号在Read View中,则证明需要读Undo Log,然后找到历史版本的事务编号在Read View中进行校验,如果没有,则就是这个版本,反之则不需要。
RC读已提交,每次执行SELECT语句时,都会重新生成一个全新的Read View,总能看到最新数据;RR可重复读,仅在事务的第一次SELECT语句执行时,生成一个Read View,并在整个事务结束前一直重复用这个旧的Read View,使一个事务在执行时,看到的数据是一致的。
幻读
RC、RR都是快照读的逻辑,当前读不看Read View,当前读强制去数据页中读取最新已经Commit的物理数据。快照读与当前读交替出现会出现幻读,在RR的默认可重复读的状态下,中间有新事务进行数据更新,进行第二次查询时,范围不变,反而出现新的数据。为了解决幻读,必须将快照读换为当前读,SELECT * FROM table WHERE id = 99 FOR UPDATE;
在默认RR隔离级别下,InnoDB有三种行级锁算法:
- Record Lock(记录锁/行锁)仅仅锁住物理真实存在的某一行实体数据。
- Gap Lock(间隙锁)为了锁住两个物理记录之间的空隙,阻止其他事务在这个空隙中进行INSERT插入操作。
- Next-Key Lock(临键锁)是行锁和间隙锁的结合,能锁住记录前的间隙和实体记录本身,能够用来解决幻读。其他事务向被加锁的区间插入数据的操作都会被阻塞,进入锁等待状态。
不可重复读
两次查询之间,同一条的数据发生了变换。不使用加锁,因为读请求占比高,加锁会使执行串行化。MVCC的RR级别,会通过同一个Read View进行可见性判定与历史版本寻找,从而解决两次读同一个数据的不一致问题。
全局锁与表级锁
全局锁,整个数据库实例被锁定,处于只读状态,FLUSH TABLES WITH READ LOCK,所有其他线程的更新、插入、DDL操作全部被阻塞。主要用于全库逻辑备份。 表级锁:元数据锁Metadata Lock,执行查询时,自动加MDL,防止别人把表结构删了。意向锁:当事务准备在某几行加锁前,必须在表级别获取对应的意向锁,当有人试图加全表锁时,只需看表上有无意向锁。
死锁:两个或多个并发事务,因为互相持有对方需要的锁,并且都在等待对方释放,从而导致了物理上的循环等待。解决机制:默认开启主动死锁检测,InnoDB会维护一个"等待图",当事务请求锁被阻塞时,引擎会立刻检测图中是否存在环路,如果发现死锁,InnoDB会主动回滚其中一个"成本较小"的事务,让另一个事务继续执行。在业务代码中,强制要求所有并发操作必须以相同的顺序访问表和行数据。
MyBatis
HikariCP连接池
每次执行SQL,系统都要调用JDBC和MySQL Server进行底层的TCP三次握手,执行完SQL后再经过TCP四次挥手销毁连接。这种开销可以用HikariCP连接池来进行消除,HikariCP会提前向MySQL发起网络请求,建立数十个JDBC Connection对象,JPA和MyBatis通过HikariCP,减少了TCP握手的物理开销。
与JPA这种全自动ORM框架相比,MyBatis使用半自动ORM框架,开发者具有SQL的编写控制权,可以对SQL进行调优。
核心组件
MyBatis的SqlSessionFactoryBuilder负责解析全局配置文件和XML映射文件。SqlSessionFactory,单例对象,包含所有解析好的SQL节点和配置信息,保证线程安全。SqlSession,代表与数据库的一次完整会话,是多例对象,包含HikariCP的物理数据库连接和当前事务状态,调用底层的Executor执行SQL,并不是线程安全的。
Spring的mybatis-spring的SqlSessionTemplate作为底层代理,去当前线程上下文中寻找是否存在已经绑定的SqlSession,如果没有Spring会向单例工厂请求一个全新的SqlSession,如果当前方法处在@Transactional事务中,Spring会保证这个事务内的所有数据库操作,绝对复用同一个SqlSession和底层物理连接。方法执行完毕或事务结束后,Spring会自动清理并关闭会话,将连接安全还给HikariCP连接池。
动态SQL解析
Mybatis处理动态SQL依赖于OGNL表达式引擎。Mybatis在解析XML时,会将包含动态标签的SQL文本封装为DynamicSqlSource,内部维护一颗由多种SqlNode组成的树状结构。当执行查询时,MyBatis会实例化一个DynamicContext对象,将用户传入的参数对象包装入一个ContextMap,自动绑定内置参数。遍历SqlNode,当遇到动态标签时,通过OGNL的反射机制,在Context中寻址并计算布尔值,根据计算结果决定是否将当前节点的SQL片段追加到最终的SQL语句中。
在处理1对1和1对多、多对多的映射时,MyBatis有嵌套查询和嵌套结果两种配置,嵌套查询会额外发起一条或多条子查询,消耗TCP连接。嵌套结果先一次性地将主表和从表的数据查出,再将二维结果集组装成Java对象树。
如果全局配置lazyLoadingEnabled=true,MyBatis的ResultSetHandler在映射结果集并实例化目标后,并不会直接返回实例,而是通过ProxyFactory生成该类的子类代理对象,代理对象内部会被注入一个ResultLoaderMap和一个方法拦截器从而按需触发。
缓存
MyBatis默认开启一级缓存,同一个SqlSession执行相同SQL时,会直接从内存中拿数据。但是当方法缺失@Transactional时,每次Mapper方法调用时会创建新的SqlSession,一级缓存失效,只有当@Transactional存在时,才会将SqlSession绑定到当前线程上下文,命中一级缓存。
MyBatis还具有二级缓存,当多个独立的SqlSession针对同一个Mapper的相同SQL发起查询时,可以命中二级缓存,查询结果在当前的SqlSession执行commit()或close()后,才会真正序列化并写入二级缓存区域。实现跨SqlSession缓存。
然而二级缓存不能跨节点,当采用多节点集群部署时,必须关闭二级缓存,单节点只对自己的二级缓存负责,A节点UPDATE后不对B节点的二级缓存生效,导致B节点再次读取时,数据不一致。需要采用独立的Redis作为中间件来保障数据一致性。
核心接口
- Executor执行器,负责全局事务管理,缓存维护,协调组件
- StatementHandler语句处理器,JDBC封装,负责处理SQL的预编译和最终的数据库请求发送。
- ParameteHandler参数处理器,负责将SqlNode解析后的Java对象参数,设置到JDBC的PrepareStatement占位符中。
- ResultHandler结果处理器,负责处理JDBC返回的ResultSet,将其转换为配置好的Java实体对象。
MyBatis允许通过插件拦截四大组件,用于分页、慢SQL监控、自动填充创建/修改时间。
高可用与主从复制
采用多机架构分担单机的读写压力。
主从复制的基石是BinLog,当从库连接主库时,主库会创建一个Dump线程。一旦主库的BinLog有更新,Dump就会推送给从库。从库收到主库发来的BinLog后,先将其顺序写入本地磁盘的一个中转日志Relay Log中。从库的底层有一个后台线程,不断读取Relay Log中的日志记录,在从库上将物理操作重放一遍,从而保证数据一致。
主库是多线程并发写入,主库并发极高时,从库的回放速度跟不上,就会产生主从延迟。当主库宕机时,自动将延迟最小的从库提升为新主库,接管写入请求。
分库分表
如果表的数据量数千万,写请求依然会成为单点瓶颈,此时只能进行物理拆分。按业务拆分,垂直切分:将一个大库的两张表拆分到两个独立的数据库实例中。按数据拆分,水平拆分:将一张5000万行的表,拆成50张一模一样的表,还可以分布在不同的服务器上。
分库分表后,查询需要通过路由算法找到具体的库和表。Hash取模(数据分布均匀,但扩容极其困难)和Range范围(如按月份分表,扩容容易但容易产生热点数据)
分库分表后,MySQL表自带的自增主键失效了,业务必须引入全局唯一的分布式ID生成器,雪花算法,通过物理时间戳+机器标识+毫秒内序列号,在内存中极速生成完全不冲突的long型ID。