这个问题其实很重要,要说服大家和自己投入大量精力自研orm框架是需要充分理由的。
面临的问题
彼时,公司的目标是研发一套低代码开发平台(【低代码】这个名字是近两年冒出来的,当时还没有,不过本质上就是这类产品),简单的说,用户可以通过可视化的界面,通过拖拽等方式配置出一个表单,并生成表单对应的业务数据模型,完成包括但不限于CRUD等功能。
这是后来的成品图,大家可以感受一下(PS.这个低代码平台不是代码生成器)
我们先忽略图中的细节,大家想想,对于【商品档案】这个业务对象来说,我们通过怎样的方式去定义,至少可以满足CRUD?
mybatis & hibernate
这是两个最常见的数据持久化框架(限于关系型数据库),都需要在代码里编写一个模型类,进而通过Mapper定义sql语句或者JPA注解等方式,从而实现ORM映射——将对象和关系表映射起来。
这里,关键问题在于,我们只能在编码阶段处理模型的定义,如果需要做模型改动,不得不修改代码,这与【低代码】的初衷是相违背的。
这也是需要自研orm的首要的的原因,甚至可以认为它一票否决了上述两个开源框架。
比较
在自研之前,我还是想分享下关于这两个框架的一些想法。
客观地说,hibernate和mybatis是没有可比性的,前者注重于java对象与数据库的映射,我们可以称之为ORM框架,后者注重于SQL的配置和ResultSet映射,可以认为是一个构建sql和数据库访问的框架。两者的侧重点不同,各自有各自的优缺点。
个人认为,就开发效率来说,对于绝大多数场景,hibernate要优于mybatis,毕竟后者仍旧需要自己编写大量sql,尽管tkmybatis借鉴了hibernate的思想,实现了简单的对象映射以便自动生成sql(就这点来说,tkmybatis的诞生恰恰印证了以上观点)。
强调一句,我并非一个hibernate的拥护者,我也对hibernate内部一些功能和机制持怀疑态度,但就大多数场景,尤其是OLTP场景,它的确是适用的,而且开发效率要优于mybatis。
对于一些特殊的业务场景,特别是OLAP,直接用sql语句操作数据库可能是更好的选择,这个时候mybatis相比于hibernate就有优势了,不过话又说回来,如果不在意mybatis的sql配置功能,单单就sql访问数据库来说,为什么我们不使用更加轻量的spring data框架呢?
另外还有个关键话题:性能。
很多人都说,mybatis比hibernate要轻量、灵活。没有错,只是我认为这个是特点,而非优点。至于说用mybatis性能要优于hibernate,我只能说——1.如果较真的话,也对,毕竟hibernate做了很多mybatis没有做的事(生成sql、多表映射、懒加载、主键生成等等);2.如果站在一个更加宏观的层面来说,这个说法是站不住脚的。
系统性能不佳,是多方面的原因,如果我们只考虑数据库性能的话,那么造成性能的主要原因可以分成一下几类:
数据库和sql层面
- 不合适的数据表设计;
- 复杂、冗长、糟糕的sql,造成执行慢和死锁;
- 不合适的索引、没有正确利用索引;
- 多余的查询和更新;
- 没有使用批量操作;
缓存层面
- 没有使用缓存(持久层,非应用层)
- 缓存命中过低(比如:只使用一级缓存、有效时间过短)
应用层面
- 糟糕的代码设计,产生了大量不妥的、不必要的sql;
- 过大的事务边界、锁的生命周期过长;
我们会发现,以上问题的产生和具体框架选型没有太大的关系。对于mybatis,由于高度自由的sql配置功能,导致语句的质量完全取决于开发人员的知识水平,框架层面是不可控的。而hibernate的性能问题在于,大部分开发人员并不理解其内部API的实现机制,API的错用和滥用导致了很多性能问题。
所以,从功能和特点等角度,对hibernate和mybatis进行比较,我是认同的,但说hibernate性能比mybatis差,真的很没道理。
hibernate和mybatis都有各自的问题,同时加上代码平台的要求,我开始思索,如何设计一个不依赖代码维护、更加好用、确保性能没有明显问题的ORM框架?
思考与设计
ORM框架,除了满足基本数据的映射、CRUD功能之外,还要考虑性能、api友好度等非功能性问题。下面阐述下框架需要考虑到的各类问题和解决方案。
聚合
首先,我们先说一个概念:
什么是聚合?
一个业务对象,我们可以称之为实体,是由若干属性组成的,这些属性大部分都是值类型,比如字符串、数字、日期等,但有些属性也是实体。如果我们要为这个业务对象设计数据表的话,那么需要多张表。当我们试图保存一个业务对象时,通常会操作多张表的数据。
这里我不强调DDD(Domain Driven Design 领域驱动设计)中的聚合,对于持久化层的业务对象来说,聚合可以简单认为是——一些有相关性的对象,通过某种方式组合在一起,然后持久化层将其作为一个整体操作。
举个例子:部门是实体,员工也是实体,两者是一对多关系,我们保存一个"部门"对象的时候,也会同时对部门下的员工对象进行保存,当我们加载一个“部门”对象的时候,也会把其下的员工列表加载出来。
聚合对象在软件设计中非常常见,也非常重要。如果没有聚合,持久化层的代码会“平铺”在领域层的代码中,同时还要考虑一些本可以不用操心的内容:事务、批量执行,当然最大的缺陷就是,开发人员需要关注:“哦,我需要先保存A对象,嗯,再获取它的XX集合,调用B接口的保存方法,再…”,这个体验的确很糟糕。
另外,从分层的角度来说,聚合是模型层需要考虑的,不是由业务层考虑的,前者做了聚合后,可以简化后者的复杂度,前提是这种聚合是合理的、可复用的。
题外话
如果数据库用的是mongoDB这样的文档数据库,你会发现聚合是一种本能的设计,保存的一个复杂对象是一件很轻松的事,我希望关系型数据库的ORM框架也是同样的体验!
mybatis中,select配置可以借助association和collection配置,实现查询层面的聚合,但是保存无法支持聚合,而支持JPA标准的hibernate则没有这方面的问题,无论从功能还是开发效率来说,hibernate都更有优势(tkmybatis的出现,从某种角度来说就是为了弥补mybatis在这方面的不足,真是否定之否定规律的一个好例子)。
聚合带来的问题
聚合的好处大家都能理解,但它也有明显的缺陷——对象可能会过于庞大,进而影响加载速度(很多时候我们不需要完整的数据)、占用大量内存(在密集IO型系统+没有使用二级缓存的场景下,内存问题会被放大)。
二级缓存能缓解这个问题带来的影响,这个相信也很好理解,不多赘述。
除此之外,hibernate在设计的时候,通过懒加载的机制来避免这个问题。虽然这个方案在一定程度上缓解了大对象的问题,但也带来新的问题:
- 定义类的时候,就要确定懒加载的属性(包括值属性、一对一实体,一对多实体),显然在模型层掺和了业务层的内容,嗯,一股"坏味道";
- 懒加载字段固定了,并不能解决所有场景的问题,一旦对象的使用场景多了,无论怎样定义,要么多余加载,要么断断续续加载;
总体上,hibernate的这个瑕疵并不会对系统造成严重影响,只要合理设置懒加载的属性,大聚合的弊端可以忽略。
另一方面,这也是个把柄,很多人把大聚合的缺陷作为hibernate性能不好的一大主因,也许是不知道懒加载、也是是觉得懒加载字段的设计非常消耗心智(确实如此),而mybatis可以定义若干不同的select查询器来避免大聚合的问题,就是“我需要哪些字段就配置哪些字段”,只是带来新的问题——select标签满天飞…,什么?你想定义一个公用的select?恭喜你,成功引入了"大聚合问题"。
题外话
框架的设计有时候就是那么矛盾,多年的经验告诉我,没有十全十美的框架,所以我们需要理解不同框架的特点、内部机制,才能更好地使用它们。
还有有更好的方案吗?
tkmybatis好像没有考虑这个问题(我没有在文档中找到这方面的信息)。
我自己想的方案和DDD中的小聚合概念非常相似。
简单地说,小聚合是一个子集,在调用持久化api的时候,以参数的方式告诉框架——“我需要加载A对象,但是我只需要其中几个属性,它们是…”。
从效果上来说,没有多余加载,做到了需要什么加载什么,而且也不会存在mybatis中的“大量select配置”(一个case——设计|编码任务转化为运行时任务,这个一种提升开发效率的方法),当然也有个缺点:小聚合也是会占据内存的,不过权衡之下,还是利大于弊。
小聚合使用场景
- 某个场景中,只需要A对象的部分属性,尤其是A对象全集是一个属性很多的时候;
- A对象至少存在一个引用属性(即外键字段),假设被引用的对象为B,而B可以用小聚合的方式加载,通常来说这个小聚合仅包含id、名称等少数字段,没有必要加载完整的B对象;
- 对于不同对象不同的引用属性,往往会存在引用同一类型的对象,对于这些引用,如果类型一致、聚合属性一致、id一致的时候,我们可以让它们引用同一个小聚合对象,这样做是为了节省内存、避免发生数据不一致的问题。
缓存
缓存对于任何一个系统都是非常重要的,尤其是对于读场景大于写场景的系统尤为如此。通常情况下,缓存的意义在于减少数据库的访问,反过来说,大量读请求集中到数据库层面,很容易引起单点性能问题。
缓存的话题很大,这里我专注于一个数据访问框架的缓存应该做到什么?需要注意什么?
mybatis的缓存
mybatis的缓存分为一级缓存和二级缓存,前者是sqlSession级别,后者是namespace级别的,其详细定义,请自行查阅相关资料或文档,本文不予赘述。
能如果开启一级缓存,那么查询的数据会被保存在sqlSession对象里,后续用同一个sqlSession进行查询时,可以从缓存获取。
这里的关键点就是:sqlSession。
也就是说,多个sqlSession的数据不共享。有人会问,那么多线程请求有什么意义?对了,问到点上了,对于多线程请求,的确没有意义,尤其考虑到同一个sqlSession查询相同数据的可能微乎其微,对于减少数据压力这个目的来说,我也认为一级缓存意义不大。(当前有个场景是有作用的,后文会分析)
因此,二级缓存出马了,二级缓存是以namespace为单位的,同一个namespace下的多个mapper共享缓存,自然,在多线程查询的场景下,大家可以获取到共享数据,真正做到减少数据库的压力。
什么时候清空缓存?
对于一级缓存,dml类型的接口执行后,sqlSession容器内的缓存即清空,对于二级缓存,namespace下任一mapper的dml类型的接口执行后,该二级缓存清空。
这样处理,数据一致性没有问题,但是觉得有点粗暴,举个例子,缓存里存放了N条数据,一个更新操作实际只是影响了一行,然后整个缓存都被清了,这样做会导致缓存命中率的下降,尤其是写比率比较高的场景。
分析下来,这样做已经是最好的处理方式了,因为mybatis的查询和更新不是以对象为单位的,其查询缓存的key取决于查询器的参数内容,它可以是id,也可以是一个条件,而执行更新操作的时候,mybatis无法分析出,当前更新究竟会影响哪些缓存里的数据,本着“宁可错杀一千也不可放过一个”的原则,只能清空整个缓存。
不是缓存不给力,mybatis的特性才是硬伤。
多节点缓存共享问题
mybatis本身不支持,但是可以自定义一个基于redis的二级缓存来达到这个效果。java和redis缓存一致性问题并不简单,这里不详细展开。
事务内的缓存问题
这个点大家可能不会想到,但本质上和数据库的事务隔离解决的是一个问题,即解决脏读和不可重复读的问题。
对于一级缓存,因为数据都存在于sqlSession内,即使别的线程修改了这条数据,在sqlSession的生命周期内,依然获取的是自己缓存的数据。所以我感觉一级缓存不是用来缓解数据库压力的,而是为了解决数据隔离问题。需要注意的是,一级缓存的这个特性与当前上下文有没有开启事务没有关系。
而二级缓存就会有这个问题存在的可能,假设A线程开启事务,先更新了一条记录,然后缓存清空,接着它又读取了这条记录,这个时候缓存内的数据是未提交的,B线程可能会读到它,造成了脏读。(这个例子我没有做过测试,除非mybatis在二级缓存针对事务内环境做了特殊处理,否则这个问题一定会发生)。
另外,即使不考虑事务,mybatis的namespace定义不合理也会造成脏读。
缓存要点总结
分析了mybatis的缓存机制,以及其优缺点后,我认为一个持久层框架的缓存应该需要做到的内容做了整理(hibernate的缓存没有研究过- -):
-
以key-value的方式管理缓存,其中key是对象标识,value是聚合对象;
-
缓存可以分成多个,每个缓存可以独立管理(比如不同的过期时间),逻辑上一种实体共享一个缓存;
-
缓存命中率尽可能高,清空缓存时不可“一棍子打死”,只清除需要清除的对象;
-
单节点系统的二级缓存使用本地共享缓存,多节点系统使用redis共享缓存,另:两种缓存的切换做到与代码解耦;
-
对于多节点共享缓存:
- 确保多节点的数据一致性,如T1时刻,a对象保存到数据库(已事务提交为准),T1时刻以后,任何节点都不能读到老数据;
- 数据包的大小:不同于内存读写,访问redis的数据是要通过网络传输的,所以数据大小是一个需要关注的问题,个人认为json这类带有自描述的格式,数据包会比较大,可以尝试自定义序列化,这是一个优化项,一般数据量的情况下json序列化是足够的;
- 访问频度问题:访问redis的频次过高也要考虑,在设计时尽量做到“必要时才访问redis”,充分利用本地缓存;
-
以聚合为单位清除缓存对象:举个例子,缓存里存在A对象以及A的若干小聚合对象,当A对象被清除时,其对应的小聚合也需要被清除;
-
非事务上下文,不考虑一级缓存,直接启用二级缓存;
-
确保二级缓存返回的对象的“不可更改性”,否则某线程对数据的更改会影响整个节点其他线程;
-
对于事务上下文,避免缓存的“脏读”和“不可重复读”:
- 事务内,执行更新后的数据为脏数据,除了本事务,其他线程均不可读到,否则其他线程就产生了“脏读”;
- 事务内,对本线程(即本连接)产生的脏数据,应当优先读取到,否则就产生了“不可重复读”;
- 脏数据务必确保在事务提交后,更新到二级缓存
-
支持注解和配置的方式,设置聚合对象的缓存有效期,或是其他缓存相关的属性(匹配约定大于配置的原则);
-
缓存的读写接口必须支持批量,尤其是读写redis缓存时,是否批量对性能有很大影响;
映射方式
映射即如何将关系型数据表的元数据与java对象的元数据关联起来。JPA规范就是一个很好的例子,hibernate和tkMybatis都是基于JPA实现映射的。
只是我觉得JPA稍微有点重,另外低代码的要求也无法让我直接使用JPA,我必须设计一个更通用的映射关系——它当然可以像JPA一样,通过类+注解的方式,也可以通json xml等描述出来(低代码平台本质就是维护它们)。
虽说是为了更通用,但实际上其内容只会比JPA规范更少。映射关系是一个可以描述对象和数据表关系的实例,这个实例的必要内容有:
- 明确对象映射到哪个数据源(当系统存在多个数据源时,必须指明该对象的数据源)
- 对象映射哪些表,至少存在一张主表,其他表或者是一对一从表,或者是一对多从表;
- 出于简单的目的,要求一对一从表的主键与主表主键一致,一对多从表关联主表的外键与主表主键一致,主表和其一对一从表的字段映射到同一个java对象上(俗称:拉平);
- 明确java类中每个字段映射到某张表的某个列,至少明确列名和类型;
- java类上一定要声明主键属性,主键属性或者是Long类型或者是String类型;
- 对于引用属性,用于映射外键字段,可以声明引用的对象需要哪些附加属性,默认只会引入主键、代码、名称3个属性(每个类都可以声明代码和名称属性,方便对象描述)
- 对于一对多子表,用集合字段映射,集合中的泛型即子表映射的对象,同理,子表对应的java类也可以有若干一对一和一对多子表,可以不断递归;
- 主表模型上,可以声明一个Long类型的时间戳字段,保存时会依赖此字段进行乐观锁检测,通过检测后,会自动为该字段赋值;
数据查询
首先,我想区分一下两种查询类型,一种是针对ORM对象的查询,一种是相对自由的、支持随意联表、支持分组聚合、支持top、支持分页的查询,我们姑且称前者叫做对象查询,后者叫做表查询(因为逻辑上输出的是一张二维表)。
两者的主要区别如下:
-
对象查询的结果映射到java对象,其数据结构或是一个对象,或者这个对象的集合,如果对象存在子表,那么这些数据会填充到对象中对应的集合属性中;表查询的结果,通常来说就是二维表,也有可能是一个值(比如聚合查询),结果集与任何业务对象都没有关系(当然,像mybatis那样用一个pojo对象作为sql查询结果的数据容器是另一回事),表对象结构依赖于sql结果集(ResultSet)的元数据,而orm对象的查询是sql依赖于对象映射模型;
-
对象查询的联表是有边界约束的,比如主表和其一对一主表可以联表(不会产生笛卡尔积),而表查询的联表原则上没有限制,需要开发人员结合实际场景编写合适的sql语句;
-
对象查询通常来说是通过标识作为条件,这样可以直接利用缓存,也可以通过属性构建条件,通常来说这些条件是主体表上的,通过条件查询出标识,然后再通过标识加载;表查询不存在利用缓存的概念,因为其结果集完全依赖于sql的select部分的内容,可能是映射单表的对象,也可能是一对多主从表的笛卡尔积,甚至是结果集分组聚合后的结果集,总之,它是不确定的。
可以做个简单的总结,对象查询就是查对象的,而表查询可以认为是做报表和分析的,例如OLAP。
题外话,复杂的OLAP想用一个sql搞定,也基本是不靠谱的。
对于持久层框架,我们需要处理的就是对象查询,当然sql查询也可以做,这个可以作为一个分支需求设计,和持久层框架的主体关系不大。
对象状态
有个很重要的特性,即“对象状态”,它有什么用呢?比如:
- 保存对象时,框架需要识别是insert还是update(对于多表聚合的对象,一个保存过程,可能会同时产生insert、update和delete语句);
- 如果一个对象从数据库读取后,一个属性都没有更改,那么保存时“什么都不做”;
- 如果一个对象从数据库读取后,只更新了其中N个字段,那么update语句只set这N个字段,另外可以提供额外的api知道这次更新前后的字段值(额外功能);
- 标识一个对象是只读的还是可编辑的,上文有述:对于二级缓存内的对象,我们需要确保其“不可更改性”;
上述几点也明确对象状态的使用场景,出于开发者使用简单的原则,我不会让它在开发层面增加使用的复杂度。
题外话
用【是否存在id】作为新增还是更新的依据,我总觉得不靠谱。因此提出这个概念
写入数据库
写入数据库,包括insert、update和delete这些都是写,我们统称这类语句为DML语句(数据操作语言Data Manipulation Language)。
我们希望达到的效果是:
- 新增:创建对象,设置属性,添加明细,最后调用保存方法;
- 修改:从数据库读取对象,设置属性,添加、修改、删除明细,最后调用保存方法;
- 删除:通过id,调用删除方法;
- 上述执行过程均以聚合为单位,必要时自动开启事务(比如涉及多表操作时);
- 对于同一张表、相同的sql语句,确保使用jdbc的批量语句更新;
- 对于同一个聚合,表按统一的顺序提交,对于同一张表同一类型的语句,如有多行记录,按主键递增的顺序提交,这样做可以确保在持久化框架层面避免死锁;
- 如果有时间戳字段,保存时需要进行乐观锁检测,并负责维护时间戳字段的值;
- 新增时,如果传入的对象没有为主键赋值,那么框架会自动生成标识并赋值,对于String类型,生成32位UUID,对于Long类型,或者使用雪花算法或者使用redis序列,之所以不考虑数据库的自增值,是为了后续可能的数据库水平拆分而考虑。
代码设计层面,我参考了ADO.NET中,对于DataSet提交的设计,简单的说,就是逐行分析对象,产生语句,分析后将语句按类型、按表进行分组,然后开启事务(如果必要的话),按合理的顺序执行这些语句。
建议:如果某个业务服务的保存操作,只需要对一个聚合进行保存,那么它完全可以依赖持久层框架内部的事务,不需要service层开启事务。即使存在多个聚合的保存,那么也建议通过编程事务的方式,而不是方法注解事务,目的是尽量缩小事务边界。
总结
以上便是我在设计持久层框架中的思考,这个过程其实分为2个阶段,其中主体的思路在13年时候就成型了,主要是元数据的设计和sql拼接的部分。但那个时候,知识广度和深度还比较欠缺,很多细节没有考虑到,比如多节点缓存、事务内缓存隔离等,对于状态维护的方式也很粗糙,现在对这些欠缺的地方重新做了思考。
预告
下一期我会给出一些代码示例,让大家体验一下pisces-orm是如何使用的,暂不包含低代码的部分。