本文作为 Code Insight 目录,梳理 h2 数据库的知识点以及感兴趣的实现细节。
作为一个教学演示用的数据库,性能优化肯定不是其优势,重点在协议、SQL规范实现的思路和解决方案上。❌
h2 数据库提供DBMS 完整的实现、丰富的特性和其简练的实现,通过了解其设计思路,举一反三,在日常开发设计、MySQL 数据库深度研究都有借鉴意义。✔
核心实现的原理分析会持续更新、补充链接。
h2 数据库支持嵌入式(开源产品 demo 演示)和服务器模式,可以使用磁盘存储(B 树索引存储引擎、日志结构存储引擎)或内存数据库,并提供事务支持和多版本并发控制(MVCC)。
同时还提供了一个基于浏览器的控制台应用程序(JavaWeb),支持加密数据库和全文搜索(based on Apache Lucene)等扩展特性。功能非常丰富,使用Java 编写,可以作为学习数据库原理和架构的范例。
H2 使用教程 10min 带你玩转 H2 数据库 😀
H2 支持的SQL 语法 增删改查、存储过程、触发器等,兼容 ANSI-SQL89 规范。
H2 特性介绍和原理 Code Insight 主要参考资料
目录结构参考 官网介绍架构 的Top-down Overview。
结合关系型数据库和 DBMS 相关的知识点,理论结合实际,深入理解数据库思想。
org.h2.Driver
org.h2.engine.SessionRemote
参考 Database URL Overview URL 连接和配置示例
org.h2.command.Parser
SQL 语法解释器使用递归下降分析器(recursive-descent),按照语法规则解析输入的文本。有性能问题,优点是易于实现和理解。
package org.h2.command.dml
org.h2.expression.Expression
h2 没有生成查询 IR(中间表示)这一中间步骤,而是直接生成一个命令执行对象。然后对命令对象进行一些优化步骤(org.h2.expression.Expression#optimize),生成更有效的命令。
Insight H2 database auto increment
org.h2.table.RegularTable
org.h2.mvstore.db.MVTable
org.h2.index.PageBtreeIndex
RegularTable 采用常用的数据表实现方案,使用B Tree 索引结构,聚集索引数据存储等。
MVTable 是基于新一代的存储引擎 MVStore 实现的数据表实现。
Undo log, 撤销日志是每个会话独立的,用于回滚操作或撤销失败的更改。
redo log, 重做日志也是每个会话独立的,用于在崩溃后恢复数据库。
transaction log, 事务日志是在所有会话之间共享的(MVCC),用于记录数据库的所有更改。
基于 B-tree 的存储引擎,使用磁盘页面(块)存储数据。
B树是一种树状数据结构,用于组织和存储数据,可用于在大量数据集中快速查找和访问数据。针对磁盘存储介质,实现数据快速检索和更新。
笔记 h2database BTree 设计实现与查询优化思考
MVStore 是一种持久化的、基于日志结构的键值存储。用作新版本 H2 的默认存储引擎。
H2 数据库官网特有一章节用来描述 MVStore。
这种引擎将修改的数据缓存在内存中,然后在累积足够的修改后,将它们一次性写入磁盘。这种方式可以提高写入性能,特别是对于不支持小随机写入的文件系统和存储系统(如Btrfs),以及SSD。
每个修改集合称为一个“chunk”,其中包含了所有被修改的B树的父节点和根节点,以及元数据。
为了重用磁盘空间,会压缩具有最少活动数据的chunk。与传统存储引擎相比,这种引擎更简单、更灵活,并且通常需要更少的磁盘操作。
org.h2.store.FileStore
org.h2.mvstore.OffHeapStore
文件抽象层,方便存储引擎层进行操作。封装了seek、readFully、write、sync 等方法,屏蔽了具体存储(也可以使用堆外存储)的实现细节。
ByteBuffer.allocateDirect
for update 应用场景:对需要读写操作(query data and then insert or update related data within the same transaction)的数据进行加锁,防止其他写操作的影响。把业务并发彻底变为串行。业务操作变安全。
MySQL :: MySQL 5.7 Reference Manual :: 14.7.2.4 Locking Reads
多个session(事务) 在没有修改操作时,都可以对当前的数据加共享锁。
如果有session 有修改操作,那么加锁会阻塞,一直等待操作session commit后,加锁成功,并且返回最新的数据。
这种适用于对加锁数据只读操作的场景。文档Example 以parent /child表 级联操作案例来说明应用场景。
多个session(事务) ,对当前数据,只有一个可以加锁成功,其余等待(包括LOCK IN SHARE MODE)。
这种适用于对加锁数据进行读写操作的场景。文档Example 以 counter 累加的案例说明应用场景。
START TRANSACTION;
-- try lock
SELECT counter_field FROM child_codes FOR UPDATE;
-- modify...
UPDATE child_codes SET counter_field = counter_field + 1;
-- release lock
COMMIT;
START TRANSACTION;
SELECT * FROM child_codes WHERE id = 10567 LOCK IN SHARE MODE;
-- 多个session 在此互相等待,死锁发生
UPDATE child_codes SET name = 'foo' WHERE id = 10567;
COMMIT;
mysql 通过locking reads 机制,解决了并发场景下读写数据不一致的问题。
我们比较常用的是 for update。优点是简单,缺点是tps不高。
还有其他的控制业务并发的方案,比如乐观锁机制(自旋)。需要根据场景去选用合适的技术方案。
如果使用不当,会造成SQL 死锁。又一种死锁的方式😂。
使用MybatisPlus 过程中,发现有逻辑删除的内置功能,比较好奇,引出此次的Code Insight。 注意:逻辑删除是设置针对全局的。
mybatis-plus:
check-config-location: fals
# 这个是 Mybatis 的配置
configuration:
auto-mapping-unknown-column-behavior: partial
cache-enabled: false
call-setters-on-nulls: true
map-underscore-to-camel-case: true
# 这个是MybatisPlus 的配置
global-config:
db-config:
logic-delete-field: isDelete
# 逻辑删除全局值(默认 1、表示已删除)
logic-delete-value: 1
logic-not-delete-value: 0
com.baomidou.mybatisplus.core.config.GlobalConfig 插件的配置项, 对应上述的配置
com.baomidou.mybatisplus.annotation.TableLogic 表字段逻辑处理注解(逻辑删除)
com.baomidou.mybatisplus.core.metadata.TableFieldInfo 数据库表字段信息
initLogicDelete:316, TableFieldInfo initTableFields:319, TableInfoHelper initTableInfo:144, TableInfoHelper inspectInject:53, AbstractSqlInjector parserInjector:133, MybatisMapperAnnotationBuilder parse:123, MybatisMapperAnnotationBuilder addMapper:83, MybatisMapperRegistry bindMapperForNamespace:432, XMLMapperBuilder
/**
* 逻辑删除初始化
*
* @param dbConfig 数据库全局配置
* @param field 字段属性对象
*/
private void initLogicDelete(...) {
/* 获取注解属性,逻辑处理字段 */
TableLogic tableLogic = field.getAnnotation(TableLogic.class);
if (null != tableLogic) {
// ...
} else if (!existTableLogic) {
// 'isDelete'
String deleteField = dbConfig.getLogicDeleteField();
if (StringUtils.isNotBlank(deleteField) && this.property.equals(deleteField)) {
// 0
this.logicNotDeleteValue = dbConfig.getLogicNotDeleteValue();
this.logicDeleteValue = dbConfig.getLogicDeleteValue();
this.logicDelete = true;
}
}
}
/**
* 应用场景
* 根据ID 查询一条数据(MappedStatement 模板)
* 自动追加逻辑删除条件(AND is_delete = 0)
*/
public class SelectById extends AbstractMethod {
@Override
public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
// SELECT %s FROM %s WHERE %s=#{%s} %s 最后一个参数:逻辑删除条件
SqlMethod sqlMethod = SqlMethod.SELECT_BY_ID;
SqlSource sqlSource = new RawSqlSource(configuration, String.format(sqlMethod.getSql(),
sqlSelectColumns(tableInfo, false),
tableInfo.getTableName(), tableInfo.getKeyColumn(), tableInfo.getKeyProperty(),
// " AND " + logicDeleteFieldInfo.getColumn() + "=" + logicDeleteFieldInfo.getLogicNotDeleteValue();
tableInfo.getLogicDeleteSql(true, true)), Object.class);
return this.addSelectMappedStatementForTable(mapperClass, getMethod(sqlMethod), sqlSource, tableInfo);
}
}
MybatisPlus 和tk.Mybatis 针对@Table 处理思路一样。在初始化过程中,会缓存表的相关信息,方便后续拼装SQL 使用。logic-delete
就是应用在初始化过程中。
A sequence of elements supporting sequential and parallel aggregate operations.
A stream pipeline consists of a source, zero or more intermediate operations, and a terminal operation
Streams are lazy
了解了上述的概念,要分析 parallel stream 作用原理,就要从terminal operation 源码入手。
/**
* Stream 定义的接口可以作为源码阅读的切入口
*
* Returns whether this stream, if a terminal operation were to be executed,
* would execute in parallel. 注意:terminal operation executed
* @see java.util.stream.BaseStream#isParallel
*/
boolean isParallel();
java.util.stream.AbstractPipeline the core implementations of the Stream interface
java.util.stream.TerminalOp
java.util.stream.ForEachOps.ForEachOp
java.util.stream.ReduceOps.ReduceOp
/**
* 通过TerminalOp 发生真正的调用,是否并行,在此处确定
* Evaluate the pipeline with a terminal operation to produce a result.
*
* @see java.util.stream.AbstractPipeline#evaluate(java.util.stream.TerminalOp<E_OUT,R>)
*/
final <R> R evaluate(TerminalOp<E_OUT, R> terminalOp) {
return isParallel() ? terminalOp.evaluateParallel(...) : terminalOp.evaluateSequential(...);
}
// 这样的Terminal Operation
persons.parallelStream().map(person -> person.name).forEach(System.out::println)
如何实现并发调用,只需要关注evaluateParallel
方法。
new ForEachTask<>(helper, spliterator, helper.wrapSink(this)).invoke();
class ForEachTask<S, T> extends ForkJoinTask<T>
至此,我们就知道并发是依靠ForkJoinTask 实现的。
近期,在使用SpringBoot Test 单测验证service 逻辑过程中,发现service “注入”的mapper 竟然是null, 导致业务方法NPE。
之前的单测一直是这样的,是因为SpringBoot 版本问题,导致注入失败么?
@Slf4j
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
public class FooServiceProcessTest {
/**
* 诡异的问题就在这里,serviceProcess 注入并没有问题。
* serviceProcess 的属性mapper 是null
*/
@Autowired
private FooServiceProcess serviceProcess;
/**
* 测试更新关联关系操作
* cascadeUpdateFoo 方法中,是通过this.fooMapper.update() 方式编写的。
* 需要测试的方法是private ,为了方便,直接用反射调用的。就是因为这个非常规使用,导致个诡异问题出现
*/
@Test
void cascadeUpdateFoo() throws InvocationTargetException, IllegalAccessException {
Method method = ReflectionUtils.findMethod(serviceProcess.getClass(), "cascadeUpdateFoo", FooParam.class);
ReflectionUtils.makeAccessible(method);
FooParam param = new FooParam();
param.setId(180L);
param.setName("foo");
param.setUpdateUser("foo");
method.invoke(serviceProcess, param);
}
}
从报错日志和直观的判断,mapper 为null,可能的问题是Mybatis 或者Mybatis 插件扫描,加载出了问题。通过查看ApplicationContext, mapper 是存在的,况且注入使用的是@Autowired,如果容器没有mapper,启动就会报错。
然后就是从spring-test 去排查,是否会存在父子容器注入替换的问题。之前在排查SpringMVC 就遇到过这种问题。
参考上述代码,FooServiceProcess
可以正常注入,追查容器的初始化或者注入线索的意义就不太大了。那就从单测代码上追查。
通过debug, 发现真正的问题。serviceProcess 实例并非原始的对象,是经过cglib 动态代理过的proxy。如果是调用private 方法,调用的就是这个proxy 的方法,自然会出现NPE 的问题。
通过上述的排查和分析,这个问题和SpringBoot 并没有关系。
问题出在cglib 动态代理类的调用方式和继承机制上,通过编写测试类HotPot,再按照上述的反射调用private 方法,即可复现。
/**
* private, cglib can't enhance...<br/>
* this 是指proxy, getMaterial() 则调用了目标instance.getMaterial(), 是可以正常返回的。<br/>
* 打印结果:<br/>
* x.y.simple.HotPot$$EnhancerByCGLIB$$23f73db0
* intercept method--class x.y.simple.HotPot.getMaterial
* this material is china., need heat
*/
private void prepare() {
System.out.println(this.getClass().getName());
System.out.println("this material is " + this.getMaterial() + ", need heat");
}
/**
* 同样是无法代理的private, this 是proxy, material 是proxy 实例的属性,所以是null<br/>
* 打印结果:<br/>
* the material null need wash, after used
*/
private void finish () {
System.out.println("the material " + this.material + " need wash, after used");
}
/**
* public 方法,proxy.toString, 拦截后,变为目标instance.toString()<br/>
* 打印结果:<br/>
* x.y.simple.HotPot
* HotPot{temperature=99.9, material='china.'}
*/
@Override
public String toString() {
System.out.println(this.getClass().getName());
return "HotPot{temperature=" + this.temperature + ", material='" + this.material + '\'' + '}';
}
代码库: https://github.com/MrRobot5/sample-base-more
不要使用非常规的写法,不常用的功能或者api,遇到问题的概率会大很多。
这次的问题深入追查后,是cglib 代理的问题,是java 继承理论知识的充分表现。
越是离奇诡异的问题,原因越是简单和直白。但是排查弯路总是要走的,经验就是这么来的。