本书是对开源项目Mybatis分析系列文章

面向读者

  • 了解Mybatis的基本用法,在Spring中的配置与SQL语法
  • 了解DSL的基本概念

为读者提供了

Mybatis通过XML定义了如何“查询”与如何“显示”的DSL,所以叫做半自动化ORM框架。本书将重点介绍这两点

  • Mybatis如何执行动态SQL
  • Mybatis如何实现ResultMapping
  • Mybatis各种插件与生成器介绍

预先准备

在阅读本书前,需要导入如下源码,并使用IDE打开此项目

git clone https://github.com/mybatis/mybatis-3.git
# 由于mybatis最新为快照,我们选择稳定版本
git checkout mybatis-3.4.6
mvn clean package -Dmaven.test.skip=true

技术交流

邮件: miao1007@gmail.com

license

本作品采用知识共享署名-非商业性使用 4.0 国际许可协议进行许可。

动态SQL

Language Driver

在MyBatis中,通过LanguageDriver担任Interceptor的工作,它是一个DSL解释器,如果这点你明白了,后面就很容易理解。它的主要流程如下

xml--(parser)-->AST-(apply)-->SQL

XMLLanguageDriver

在默认实现中,Mybatis首先在如下位置反序列化XML为MixedSqlNode,这里可以看作为一个S表达式

org.apache.ibatis.scripting.xmltags.XMLLanguageDriver#createSqlSource

MixedSqlNode内部含有一个SqlNode数组,通过深度遍历执行apply调用OGNL解析其中的text

org.apache.ibatis.scripting.xmltags.DynamicSqlSource#getBoundSql

其中$将被替换为字符串,而#将作为JDBC参数,供原生JDBC使用

定制LanguageDriver

当你认为XML的表达能力较弱,重复代码过多时,可以考虑对LanguageDriver进行定制

  • 开发Groovy-based的mybatis-dsl,提高表达效率
  • 开发基于其它模版引擎的dsl
  • 使用SpringEL替换原有采用Ognl的ExpressionEvaluator,这样进行<if>判断时更加简洁

但是目前研究LanguageDriver的文档并不多,后续想要提高MyBatis效率,就靠这里了

OGNL的简介

OGNL是一种表达式语言,用于获取对象的属性或者调用方法。它在Mybatis/Struts(以及漏洞)等涉及到模版的场景中经常使用。

语法这里就不讲了,需要注意的是

  • OGNL大部分场景是取属性,不建议new对象
  • OGNL不支持类似Groovy的safe-null,比如user?.role?.name这样的问号表达式,而SpingEL是支持的
  • OGNL定位是XML中的胶水,调试方便程度肯定不如Java,因此尽可能不折腾

从断点开始

在Mybatis中封装了如下的OGNL,测试用例如下

Object a = OgnlCache.getValue("a + 1", Collections.singletonMap("a", 10));
System.out.println("a = " + a);//返回 11

只要在OgnlCache.getValue中打上了断点,所有的动态SQL生成过程均可以看见细节

有了上面的断点处,你就可以明白平时很多可能含糊的位置了

假如你在XML中写了如下的语句,是否会有疑问需要!= null这个语句呢

<if test="example != null">

这时就可以断点,可以发现OgnlCache更上一层的调用栈是

org.apache.ibatis.scripting.xmltags.ExpressionEvaluator#evaluateBoolean

通过阅读这里的代码,可以发现只要为0或者空,就返回false(类似于Groovy的asBoolean),因此在这里就彻底搞懂了test标签的底层含义,你的xml可以简写为test="example"

OGNL非常方便扩展DSL,特别是在进行自定义注解时,可以帮助开发者节约很多时间

OGNL底层执行过程

OGNL与其它语言一样,也是从字符串到AST,到最后基于上下文的便利实现的,举个例子

(3 + (4 * 5)) - (a / 4)

将被转换为如下的树

(- (+ 3 (* 4 5) ) (/ a 4))

然后调用如下方法进行树的深度遍历

ognl.SimpleNode#getValue

最终返回结果

相关过程比较复杂,可以看更专业的怎样写一个解释器

线程安全

Mybatis内部使用了一个ConcurrentHashMap作为AST的cache,OGNL的执行本身是各自的上下文,是线程安全的。

OGNL相关暗坑

OGNL与Map

在ognl中,假如你的map是{'aaa.bbb.ccc' => v1}这样的,那么在求值时,一定要写

map.get('aaa.bbb.ccc')

而不是

map.aaa.bbb.ccc

通过OGNL的ObjectAcessor可以了解到解析详情,此外StrictMap也专门限制了这种歧义

XML如何转换为ASTNode

在上面的ONGL中,讲到了如下的方法是所有动态sql的执行

我们先挑一个简单的例子

 <select id="getParentWithComplex" resultMap="personMapComplex">
     SELECT id, firstName, lastName, parent_id, parent_firstName, parent_lastName
     <if test="1+1">
         FROM ${'Person' + ''}
     </if>
     WHERE firstName = #{firstName,jdbcType=VARCHAR}
     AND lastName = #{lastName,jdbcType=VARCHAR}
     LIMIT 1
</select>

它将被构造为MixedSqlNodeMixedSqlNode就是类似S表达式的数组结构

// MixedSqlNode
public class MixedSqlNode implements SqlNode {
  private final List<SqlNode> contents;
}

在如下位置通过XML反序列化,一个用Map维护Tag的Pattern Match

org.apache.ibatis.scripting.xmltags.XMLScriptBuilder#parseDynamicTags

最终样式为

(text (if test text) text)

在执行时将从左到右进行深度优先遍历

动态代理

在MyBatis中,通过动态代理实现生成Mapper的实现类。

构造位置

org.apache.ibatis.binding.MapperRegistry#getMapper

动态代理的最终实现在这里,它是对JDBC的encapsule

org.apache.ibatis.binding.MapperMethod#execute

Nested映射实现

本部分只分析嵌套映射的实现

映射实现

多级映射实现

导入源码

git clone https://github.com/mybatis/mybatis-3.git
# 由于mybatis最新为快照,我们选择稳定版本
git checkout mybatis-3.4.6
mvn clean package -Dmaven.test.skip=true

MyBatis如何合并LEFT-JOIN

首先举一个例子,假如学生有很多课程,在Mybatis中最常见的写法应该是这样的

<select>
    select 
    	stu.id as id,
    	stu.name as name
    	les.id as les_id,
    	les.name as les_name
    from
    	student stu 
    left join lesson les stu.id = les.stu_id
</select>

这种场景就是一对多的查询,我们一般用LEFT JOIN来查。假如LEFT JOIN 查询了如下的结果

idnameles_idles_name
1tony11php
1tony12cpp
1tony13java

你肯定不希望有三个重复ID的Student

var arr = [
    {id: '1', name: 'tony',les_id: '11', les_name: "php"},
    {id: '1', name: 'tony',les_id: '12', les_name: "cpp"},
    {id: '1', name: 'tony',les_id: '13', les_name: "java"}
]

而是希望返回的是一个如下的对象

{
    id: '1',
    name: 'tony',
    lessons: [
        {id:11,name:'php'},
        {id:12,name:'cpp'},
        {id:13,name:'java'},
    ]
}

这时就要靠MyBatis神奇的ResultMap框架了,其实这个就是函数式编程中常见的GroupBy操作

分析

在Mybatis的多级映射中,使用Map作为GroupBy的依据,断点如下

org.apache.ibatis.executor.resultset.DefaultResultSetHandler#applyNestedResultMappings

其中重点在如下位置加上断点并对比combinedKey的变化情况

final CacheKey rowKey = createRowKey(nestedResultMap, rsw, columnPrefix);
final CacheKey combinedKey = combineKeys(rowKey, parentRowKey);
Object rowValue = nestedResultObjects.get(combinedKey);

更多层嵌套如何写?

假如有如下Student对象,LIST对象中嵌入了课程detail对象,那么这个时候如何写SQL的自动映射呢?

{
 	id: 1,
    name: 'yoki',
    lessons: [{
        id: '11',
        name: 'math',
        detail:{
            time: 40,
            hard: 'middile'
        }
    }]
}

假如我们使用columnPrefix,那么对应的映射为

<resultMap type="xxxx" id="vstudentMap" extend="studentMap">
    <id property="id" column="id" />
    <result property="name" column="name" />
    <collection property="lessons" resultMap="lessonMap"columnPrefix="les_" />
</resultMap>
<resultMap type="xxxx" id="lessonMap">
    <id property="time" column="time" />
    <result property="hard" column="hard" />
    <association property="detail" columnPrefix="ldt_" resultMap="resultMap"/>
</resultMap>

此刻关键就在于加入正确的前缀

select 
	stu.id,
	stu.name,
	les.id as les_id,
	les.name as les_name,
	-- 此次前缀要加两次,否则prefix将无法生效
	ldt.time as les_ldt_time,
	ldt.hard as les_ldt_hard
from
	student stu
left join lesson les on stu.lesson_id = les.id
left join lesson_dtl ldt on les.detail_id = ldt.id

上面的两次前缀如果没有加的话,那么将返回null,这个在MyBatis的官网上与StackOverflow上都没有的例子,这种方法能够完整复与继承用各种resultMap,对代码维护性是最佳的

(很多人不会这样写,就只能拷代码了,引入重复性)

如何返回完整的分页

从上面可以发现,假如当前业务有分页需求,你想查10条数据

select * from (
	select temp.*, rownum as row_id from(
    	select 
            stu.id,
            stu.name,
            les.id as les_id,
            les.name as les_name,
            ldt.time as les_ldt_time,
            ldt.hard as les_ldt_hard
        from
            student stu
        left join lesson les on stu.lesson_id = les.id
        left join lesson_dtl ldt on les.detail_id = ldt.id
    ) where rownum <= 20
) where row_id > 10;

上面的SQL,看起来是没有问题的,网上大部分也是这种场景,但是这种可以说与鸡肋差不多

  • 单表的分页一般通过Mybatis各种开源插件即可实现
  • 而多表分页,一般涉及到JOIN查询,就会有一对多,由于rownum被MyBatis软分组合并影响,导致最终结果会变少,导致在查看数据时前台每次分页的size会不断变化,用户体验较差

你可能需要用临时表这样写

with stu_view as (
	select * from (
        select temp.*, rownum as row_id from(
            select 
                DISTINCT stu.id
            from
                student stu
            left join lesson les on stu.lesson_id = les.id
            left join lesson_dtl ldt on les.detail_id = ldt.id
            order by stu.id
        ) where rownum <= 20
    ) where row_id > 10;
)
select 
    stu.id,
    stu.name,
    les.id as les_id,
    les.name as les_name,
    ldt.time as les_ldt_time,
    ldt.hard as les_ldt_hard
from
    student stu
left join lesson les on stu.lesson_id = les.id
left join lesson_dtl ldt on les.detail_id = ldt.id
where stu.id in (select stu_view.id from stu_view)
order by stu.id

这样写性能明显变差了,但是也是没有办法的办法,因为你查出的并不是每次10条,所以统计去重后的count就要代入条件计算,后面还要再left join一次。虽然慢了,但是普通10行或者30行的分页应该是够了

  • distinct: 对重复字段进行过滤,如果你在这里的DISTINCT后面引入了LEFT JOIN的表字段,那么在一对多的情况下,DISTINCT将不会生效
  • order by id: 保证id是按照顺序提取出符合条件的前N条
  • where stu.id in: 由于这里的id是存储在B-Tree中的连续字段,因此实际用in并不会慢,相反如果这里使用了INNER JOIN的HASH INDEX,速度反而变得更慢

如何写动态SQL

如下是我在进行复杂查询时, 涉及到分页与报表导出的SQL例子

<select id="selectByCondition">
    <if test="!param.export">
    with stu_view as (
        select * from (
            select temp.*, rownum as row_id from(
                select 
                    DISTINCT stu.id
                from
                    student stu
                left join lesson les on stu.lesson_id = les.id
                left join lesson_dtl ldt on les.detail_id = ldt.id
        		<include refid="whereclause">
                order by stu.id
            ) where rownum ;lt= 20
        ) where row_id ;gt 10;
    )
    </if>
    select 
        stu.id,
        stu.name,
        les.id as les_id,
        les.name as les_name,
        ldt.time as les_ldt_time,
        ldt.hard as les_ldt_hard
    from
        student stu
    left join lesson les on stu.lesson_id = les.id
    left join lesson_dtl ldt on les.detail_id = ldt.id
    <if test="!param.export">where stu.id in (select stu_view.id from stu_view)</if>
    <if test="param.export"><include refid="whereclause"></if>
    order by stu.id
</select>

充分使用if标签,避免维护多个sql,这样基本上就玩到MyBatis的较高水平了

更加推荐的方案是,使用metabase、redash等BI工具进行查询,配合jinja模版引擎实现

Mybatis实践

L2 Cache

如何启动L2 Cache(How to enable L2 cache)

See at http://www.mybatis.org/mybatis-3/sqlmap-xml.html#cache

Cache是如何工作的(How cache works)

构造步骤(Construct Steps)

We can read source code here

// Impl: PERPETUAL(永久): org.apache.ibatis.cache.impl.PerpetualCache
// Decorator: LRU(最近使用): org.apache.ibatis.cache.decorators.LruCache
org.apache.ibatis.builder.MapperBuilderAssistant#useNewCache
  1. Construct a new instance of cache using reflection.
  2. Set individual properties using SystemMetaObject.
  3. Call org.apache.ibatis.builder.InitializingObject#initialize for customization[/ˌkʌstəmɪ'zeʃən/], see #816
  4. Add decorator cache chains by build
  5. Put the cache into a Map<NameSpace, Cache>

If your are using a standard cache, your will get

// see org.apache.ibatis.annotations.CacheNamespace
// It may **produce dirty data** on distributed scopes.
SynchronizedCache -> LoggingCache -> LruCache -> PerpetualCache

And if you are using a customized cache, you will get

LoggingCache -> CustomCache

If you want to get a log, please override getId and return the id with the mapper's namespace.

Cache流程(Process flow)

By default(cacheEnabled=true), the framework will create a CachingExecutor[/ɪg'zekjʊtə/] as a proxy(which is called the second level cache) for the database executor.

// Query -> CachingExecutor -> SimpleExecutor
org.apache.ibatis.session.Configuration#newExecutor

There is a brief process flow digram demonstrates how Mybatis caches when a query comes.

  • L1 cache implementation: Java HashMap, aka LocalCache.
  • L2 cache implementation: Redis. HGET and HSET are commands for Redis hash data type. And id is the namespace of mapper.
sequenceDiagram
  	Query-->>CacheExecutor: HGET id cacheKey?
	CacheExecutor-->> SimpleExecutor: HashMap.get(cacheKey)
	SimpleExecutor -->> DB: select * form TABLE
	activate DB
    DB -->> SimpleExecutor: value
    deactivate DB
    CacheExecutor -->> SimpleExecutor: HashMap.put(cacheKey, value)
    SimpleExecutor -->> CacheExecutor: value
    Query -->>CacheExecutor: HSET id cacheKey value
    CacheExecutor-->>Query: value  	




For more information(distribute redis lock), read my gitbook for redis

事务(TransactionalCacheManager)

In L2 cache, only put, get and clear will be called despite all methods of interface are implemented.

改进Redis缓存(Redis caching Improvement)

In addition to LinkedHashMap-based LRU cache, We also use Redis for distributed caching. Of course, there is already a Jedis-based open source project called Redis-cache

However, there are some improvements to be done.

  • it creates a pool on each construction, singleton instance is better.
  • Doesn't support Redis sentinel mode.
  • JDK-based Serializer is risky when deployed on different platform. JSON, XML or Parcelable is preferred.
  • Lack of namespace for Redis. cache:com.xx.mapper is more maintainable and debuggabe when you DEL keys by prefix.

Your need to fork the project and create your own cache.

Handle mutiple table with cache-ref

If there is a student with lessons, two mapper turns cache on.

<!-- com.xxx.lessonMapper -->
<mapper namespace="com.xxx.lessonMapper">
    <cache type="REDIS"/>
    <select id="selectLessonWithStudent">
        SELECT s.name, s.age, l.name as L_NAME 
        from student s left join lesson l
        on s.lesson_id = l.id
    </select>
</mapper>
<!-- com.xxx.studentMapper -->
<mapper namespace="com.xxx.studentMapper">
    <cache type="REDIS"/>
    <update>
    UPDATE student set name= #name
    </update>
</mapper>

when student's name is updated, the result of selectLessonWithStudent is not flushed, and dirty data will be fetched.

fixed by shared namespace

<!-- com.xxx.lessonMapper -->
<mapper namespace="com.xxx.lessonMapper">
-    <cache type="REDIS"/>
+    <cache-ref namespace="com.xxx.studentMapper"/>
    <select id="selectLessonWithStudent">
        SELECT s.name, s.age, l.name as L_NAME 
        from student s left join lesson l
        on s.lesson_id = l.id
    </select>
</mapper>

When data in lesson or student updates, flushCache will be called, and ALL cache in the namespace will be flushed, no update by special cacheKey, so the hit ratio will be explicitly lower.

// ALL cache in the same namespace will be flushed.
org.apache.ibatis.executor.CachingExecutor#flushCacheIfRequired

Concuclusion

  • It's better to use cache on the only one table.
  • When using cache with joined tables, use cache-ref to share namespace or turn cache off mannually.
  • It's better to handle cache in business code and find your own cachekey(eg: put in Elastic as a document)

Alternative performance improvement

  • analyse SQL AST in interceptor and flush only changed changed -> It's too complex.

  • Do static analyse on XML and SQL -> It's too complex too.

APPENDIX

Voiding the risk of L1 Cache

In most situations, turning L1 cache on is risky if you have no control over the project. The two cached results may refer to the same pointer(eg: repeat queries in a for loop).

// eg: in a service
List<Student> list1 = mapper.select();
// do modification
list1.get(0).setName("Modified");
// get dirty data from cache
List<Student> list2 = mapper.select();
assert(list1 == list2)

to fix the problem

  • avoid same query in @Transactional, and always remove repeat queries.

  • turn L1 cache off(see issue #482) and directly hit the DB.

<settings>
  <!-- will flush the hashMap after the query in BaseExecutor. -->
  <setting name="localCacheScope" value="STATEMENT"/>
</settings>

本文是多级查询(例如下图)的Mybatis设计思路分享。

某宝商品筛选框

  • 如何让前台生成上面的筛选框(todo)
  • 如何实现前端传入筛选条件后端自动拼接SQL

关键词: mybatis, restful

1. 需求分析

在开发中,经常会遇到筛选框等复杂查询,但是每次开发效率都不是很高。

1.1. 现有方案与不足

  • Elastic缺少实时性,需要搭建MQ,搭建Elastic集群,牵一发动全身。它更加适用于保存计算结果后的数据仓库(DW)
  • 对于单表查询,可以用开源的通用Mapper方案
  • 对于多表查询,除了在mapper.xml 中加入大量硬编码或者代码生成器的产物,似乎也并没有什么好的办法。此外前后台接口也是一锅粥,需要定制VO对象,同时开发人员良莠不齐,在xml中拷贝了一大堆代码,后期改动成本非常高。

举个例子,有下面的对象,分别是学生,课程与分数

@Table(name = "STUDENT")
public class Student{
    public Integer id;
    public String name;
    public List<Lesson> lessons;
}
@Table(name = "LESSON")
public class Lesson{
    public Integer id;
    public Integer studentId;
    public String name;
    public List<Grade> grades;
}
@Table(name = "GRADE")
publib class Grade{
    public Integer id;
    public Integer lessonId;
    public String rate;
}

对应的SQL如下

SELECT 
	s.id,
	s.name,
	l.id as l_id,
	l.name as l_name,
	g.rate
FROM student s
LEFT JOIN LESSON l on s.id = l.student_id
LEFT JOIN GRADE g on l.id = g.lesson_id

假如此刻你的需求是做一个筛选网站,比如筛选出“选择了数学课,而且数学分数在60-70之间”的所有学生,而且这个查询需要前台动态拼接,不希望写额外的VO对象,那么应该如何设计呢?

针对上面的问题,愿景如下

  • 前端最好能够动态直接拼接通用请求,避免重复写VO
  • 前后端开发效率希望从3~5天,降低至一天出Demo

2. 系统设计

结合前端开发能力与其他开源方案,主要有如下方案

  • 如果直接暴露SQL给前端,这个是不可能的,安全性先不说,由于细分工导致前端不会拼SQL的大有人在。
  • 基于ElasticSearch的QueryDSL设计,这个可以说是AST了,但是过于复杂,没有Parser(它的Client只能透传Netty报文)
  • 自己设计查询DSL,自己通过反射解析并生成复用通用Mapper中的ExampleCriterion

既然本文写了,肯定是用第三种方法啦,最终前台接口设计如下,发送的是一个原始的AST,树形的path被拍瘪(flatten)过,本方法比较类似原生Lucense的查询方案

[
    {
        'path': 'lessons.name',
        'opt': 'EQUALS',
        'args': ['数学']
    },
    {
        'path': 'lessons.grades.rate',
        'opt': 'IN',
        'args': ['60','70']
    }
]

后台将根据前端自动通过path生成SQL

SELECT 
	s.id,
	s.name,
	l.id as l_id,
	l.name as l_name,
	g.rate
FROM student s
LEFT JOIN LESSON l on s.id = l.student_id
LEFT JOIN GRADE g on l.id = g.lesson_id
WHERE l.name = #{?} AND g.rate in #{?}

3. 方案实现

3.1. 解析AST(可复用)

这一步需要将前台的JSON解析为通用Mapper(tk)的Example,伪代码如下

Map<String, Example> tkExampleMap = [:];
asts.stream()
.filter(ast -> ast.path?.trim()) //过滤空字符串
// 按照点号进行分组, "aa.bb.cc" -> "aa.bb"
.groupBy(ast -> ast.path.find("\\w+\\.\\w+"))
.forEach(path, groupAsts -> {
    // 通过反射获取类型,比如getFieldTypeByPath('lessons.grades')
    // => Class Gradle
    Class c = getFieldTypeByPath(path)
    Example e = new Example(c);
    // 通过 opt 与 args 生成Criterion,一个简单的模式匹配映射
    // 这里可以查询通用Mapper的API
    createCriterionFromOpts(e, groupAsts)
    tkExampleMap.put(path, e);
})

很多人可能会在这里疑惑为什么需要分组(GroupBy),这里分组是为了避免生成多个对应了同一个表的Example,这样后期查询再去重就不好做了

3.2. 引入mapper.xml模版(可复用)

首先找一个放公共的xml,把这个代码段放进去,这个也是来源于通用Mapper的,一个字没改

<sql id='byExampe'>
    <if test="criteria.valid">
        ${@tk.mybatis.mapper.util.OGNL@andOr(criteria)}
        <trim prefix="(" prefixOverrides="and |or " suffix=")">
            <foreach collection="criteria.criteria" item="criterion">
                <choose>
                    <when test="criterion.noValue">
                        ${@tk.mybatis.mapper.util.OGNL@andOr(criterion)} ${alias}.${criterion.condition}
                    </when>
                    <when test="criterion.singleValue">
                        ${@tk.mybatis.mapper.util.OGNL@andOr(criterion)} ${alias}.${criterion.condition} #{criterion.value}
                    </when>
                    <when test="criterion.betweenValue">
                        ${@tk.mybatis.mapper.util.OGNL@andOr(criterion)} ${alias}.${criterion.condition} #{criterion.value} and
                        #{criterion.secondValue}
                    </when>
                    <when test="criterion.listValue">
                        ${@tk.mybatis.mapper.util.OGNL@andOr(criterion)} ${alias}.${criterion.condition}
                        <foreach close=")" collection="criterion.value" item="listItem" open="(" separator=",">
                            #{listItem}
                        </foreach>
                    </when>
                </choose>
            </foreach>
        </trim>
    </if>
</sql>

3.3. 编写自有XML(复用不可)

实现自己的接口

<sql id="whereExample">
  <where>
    <if test="example.student">
      <foreach collection="example.student.oredCriteria" item="criteria">
        <include refid="byExampe">
          <property name="alias" value="s"/>
        </include>
      </foreach>
      <if test="example.get('student.lessons')">
        <foreach collection="example.get('student.lessons').oredCriteria" item="criteria">
         <include refid="byExampe">
          <property name="alias" value="l"/>
         </include>
        </foreach>
      </if>
      <if test="example.get('student.lessons.grades')">
        <foreach collection="example.get('student.lessons.grades').oredCriteria" item="criteria">
          <include refid="byExampe">
            <property name="alias" value="g"/>
          </include>
        </foreach>
      </if>
    </if>
  </where>
</sql>

<select id="selectStudentByExample">
  SELECT
    s.id,
    s.name,
    l.id as l_id,
    l.name as l_name,
    g.rate
  FROM student s
  LEFT JOIN LESSON l on s.id = l.student_id
  LEFT JOIN GRADE g on l.id = g.lesson_id
  <include refid="whereExample"/>
</select>

<!-- 下面的是count需要用的,默认left-join的个数是不正确的,如果你使用了PageHelper的话 -->
<select id="selectStudentByExample_COUNT">
  SELECT
    count(distinct s.id)
  FROM student s
  LEFT JOIN LESSON l on s.id = l.student_id
  LEFT JOIN GRADE g on l.id = g.lesson_id
  <include refid="whereExample"/>
</select>

在java侧只用传入Example代码即可

studentMapper.selectStudentByExample([
    'example': tkExampleMap
])

这样就全部ok了,后端只用写一个xml即可完成查询业务的开发,前端拼接AST即可

4. 方案的优缺点

优点

  • 针对查询复杂度非常高的企业IT系统,开发效率有质的提升
  • 对中低级开发比较友好,拷贝xml代码的现象更少
  • 再说一个政治不正确的,领导一般对这种改进都比较重视

缺点

  • 对通用mapper强依赖
  • 由于OGNL语言的不足,空判断与重复代码写的很丑
  • 没有经过大规模的考验
  • 安全扫描可能无法通过

针对上述缺点的解决方法

  • 持续迭代底层实现
  • 自己定制Mybatis的LanguageDriver或者OGNL

5. 附录

5.1. OGNL与Map

在ognl中,假如你的map是{'aaa.bbb.ccc' => v1}这样的,那么在求值时,一定要写

map.get('aaa.bbb.ccc')

而不是

map.aaa.bbb.ccc

通过OGNL的ObjectAcessor可以了解到解析详情

5.2. #{}${}的区别

  • 美元符号是OGNL执行语句,用来生成字符串,比如test="a>0"

    include标签中,${}为特例,它只能传递文本,不能传递Object,有点类似于C中的宏#define alias "student"

  • 而井号是一个占位符,内部不支持执行OGNL表达式,执行时将被替换为JDBC中的问号?,并在运行时填值,可以防止SQL注入

  • 这两个可以在OgnlCache.getValue中打断点,就能明白OGNL何时执行

5.3. 如何自定义XML标签

在mybatis中,虽然xml已经提供了丰富的操作,比如if, forEach,但是这些标签特别是进行空判断时,与Groovy等语言相比就非常冗长,能不能像Angular或者VUE一样定制自己的xml的标签呢?

很遗憾,目前并不能很容易做到,从XMLLanguageDriver中可以看出,所有的标签都是硬编码的,所以如果你想自己实现那么你就要维护自己的LanguageDriver

5.4 不错的网站

Parent-child-id-tree

事务

首先需要注意的是,MyBatis的事务与HttpSession是无关的。MyBatis是基于动态代理进行Wrapper的,而HttpSession是基于Filter的。

原理

在Spring-session包中

org.mybatis.spring.SqlSessionTemplate.SqlSessionInterceptor#invoke

通过MyBatis的Mapper中的动态代理前后包装实现事务,而非使用了Spring的AOP

SqlSession sqlSession = getSqlSession(
      SqlSessionTemplate.this.sqlSessionFactory,
      SqlSessionTemplate.this.executorType,
      SqlSessionTemplate.this.exceptionTranslator);
try {
  Object result = method.invoke(sqlSession, args);
  if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
    // force commit even on non-dirty sessions because some databases require
    // a commit/rollback before calling close()
    sqlSession.commit(true);
  }
  return result;
} catch (Throwable t) {
  Throwable unwrapped = unwrapThrowable(t);
  if (SqlSessionTemplate.this.exceptionTranslator != null && unwrapped instanceof PersistenceException) {
    // release the connection to avoid a deadlock if the translator is no loaded. See issue #22
    closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
    sqlSession = null;
    Throwable translated = SqlSessionTemplate.this.exceptionTranslator.translateExceptionIfPossible((PersistenceException) unwrapped);
    if (translated != null) {
      unwrapped = translated;
    }
  }
  throw unwrapped;
} finally {
  if (sqlSession != null) {
    closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
  }
}

分布式数据库系统(DDS)

DDS是一个远程的反向代理数据库,它可以

  1. 对内将各种厂商,各种业务的DB进行统一管理
  2. 对外暴露SQL92等标准语法的连接,用户不知道内部的表用户,也不知道DB密码
  3. 实现读写分离与最终一致性
  4. 实现SQL热点查询命令的预编译优化、缓存查询结果、统计缓慢SQL等。

目前各大厂商都有实现,比如阿里/华为云的云数据库,这个玩意还是很难搞的,需要很多专家团队去对业务进行优化。

与zk的集成

在很多DDS的架构中,一般通过zk作为"Naming Service"进行发布注册

服务侧发布节点,举个例子

zookeeper:
/
    /namingservice
        /dds
            /node
                [node1,node2,node3]

客户端调用

  • 开发支持zk的JDBC驱动
  • 对node中节点的健康度进行查询,并选择最好的
  • 把DDS当作单机数据库使用

DDS的大致介绍

只进行理论介绍

事务一致性

这个是个难题

详见: http://www.infoq.com/cn/articles/solution-of-distributed-system-transaction-consistency

水平分库/垂直分库

这里注意是分库,不是分表

  • 水平分库: 按照地区等枚举进行分库
  • 垂直分库: 如果把数据表看成结构体的话,垂直分库就是将结构体砍成几份

读写分离

todo

调试与分析功能

此部分主要提供日志与JMX功能供上游开发调试

  • 耗时日志定位
  • JMX支持

可以统计出耗时、报错等详细信息

快速过一下存储过程

学习流程

  • 先去Wiki/IBM等资料库快速学习入门一下
  • 掌握一下某个厂商(比如Oracle)的DDL
  • 掌握其在Hibernate等库中的使用方法(本文略)

如何创建存储过程?

存储过程即可以通过鼠标在软件中点点点完成创建(强烈推荐),也可以直接手写

Oracle的文档中对存储过程有一个明确的定义,可以看出它过于接近原生AST了,因此写起来有样板代码过多的感觉。

create_procedure

我们在网上找一个PROCEDURE的例子

CREATE OR REPLACE PROCEDURE count_comments (  
   postId IN NUMBER,  
   commentCount OUT NUMBER )  
AS 
BEGIN 
    SELECT COUNT(*) INTO commentCount  
    FROM post_comment  
    WHERE post_id = postId; 
END;

如果通过Groovy的Closure进行类比,它实际上就是这样的

def count_comments = {NUMBER postId->
	NUMBER commentCount = "SELECT COUNT(*) FROM post_comment WHERE post_id = postId".eval();
  	return commentCount
}

所以存储过程是一个非常简单的函数,纯体力劳动,甚至外包都会写。并不是很多博客上说的高级DBA操作。

一般用存储过程干啥?

上面也说了存储过程一般只是一个纯函数,而在实际业务开发中,我们通常直接用XML/注解等DSL在Java源码中去描述数据库中的对象,然后再调用Hibernate等工具帮你生成SQL,而不是手动拼接SQL(有注入风险,而且拼接的SQL项目难维护)。

那么为什么需要存储过程呢?当然是不得不用时才会用,比如超大数据,性能问题才会使用。

SQL是一种对数据库进行查询操作的外部DSL,相对于Java/js等语言来说,学起来并不舒服。王垠曾在一篇文章中批评过“数据库完全可以使用普通的程序语言(Java,Scheme 等)的“远程执行”来进行查询,而不需要专门的查询语言"。

今天本文说的存储过程就是SQL中的”函数“,它用于直接操作数据库中的”结构体“数组,与JVM类语言中设计的Closure并没有什么本质的不同。

在分析索引前,为了控制学习进度防止一口吃个大胖子

  • 建议将数据库的数据抽象为全在内存中,不涉及磁盘IO读写,忽略它的序列化与反序列化
  • 将数据与索引抽象为两个集合,先不要一步到位就去折腾红黑树
  • 可以把索引集合看成一个“瘦表”
class Student{
    String id;
    String name;
    String age;
}
List<Student> dates;
Collection<Map<name,rowId>> idx;
Closure findTableByIndex = {query->
    return dates.select{rowId in (idx.findAll{it.name==query}.map{it.value})}
}
findTableByIndex("john smith")

数据库的基本结构

索引的优劣

  • 通过空间换时间,节约了查询速度
  • 需要维护索引内部结构的排序,因此进行写入修改操作时,速度较慢

参考

  • https://tech.meituan.com/mysql-index.html

位图索引

位图索引(Bitmap-Index)是一个性能强大,但是稍微使用不当就反而降低性能的索引类型,一般用于给枚举做索引

SELECT count(*) FROM INV_SKU_PHOHE WHERE VENDOR IN ('HAUWEI', 'XIAOMI', 'APPLE')

上面就统计了三个厂商的库存

位图在更新索引时,是整个表都会锁住,重新计算所有的BitArray,但是它又没有像Redis那样的渐进式rehash,因此相对于B-tree更重,不适合频繁输入不同的数据。

生成的索引例子

比如有如下数据

RIDNAMEVENDOR
1AHAUWEI
2BXIAOMI
3CHAUWEI
4DHAUWEI
5EAPPLE

由于有三个索引,将生成如下索引(注意下文Bit-Array是数组,而不是字符串)

R1R2R3R4R5
HAUWEI10110
XIAOMI01000
APPLE00001
Bit-Array00010010000100010100

最终当搜索HUAWEI时,可以理解为进行BIT-OR运算

QUERYROWbitwise-OR
000100011
000100100
000100011
000100011
000101000

这样就查出来了,由于Bitwise运算非常简单,因此查询速度较快

使用场景

一般用于高读低写的场景,可以在晚上离线生成,但是非实时数据

  • 数据仓库(data warehouses),比如BOM数据库,卡号数据库,采购数据库
  • 报表生成与查询业务

替代工具(alternative)

Elastic等全文检索工具的keyword类型,它将不进行分词(not_analyzed)而精确存储

PUT inv_sku_phone
{
  "mappings": {
    "_doc": {
      "properties": {
        "vendor": {
          "type":  "keyword"
        }
      }
    }
  }
}

NoSQL与倒排索引

相比于传统的SQL中的like搜索,在Elastic/MongoDB等NoSql中,采用了倒排索引(inverted index)的技术

https://www.elastic.co/guide/en/elasticsearch/guide/current/inverted-index.html

调试执行计划

在掌握任何一门新语言时,都需要如下途径

  • 直观掌握语言的AST与用法
  • 调试并掌握语言如何运行

而SQL就是一门查询过滤List数据结构的DSL,但是SQL本身的语法糟糕,表达力低下的语言,稍微where写错位置查询效率就变了很多。

本部分将介绍我的调试环境的配置

  • 旗舰版Intellij/DataGrip

事务

有如下表格

Isolation LevelDirty ReadNonrepeatable ReadPhantom/ˈfæntǝm/ Read
Read uncommittedPossiblePossiblePossible
Read committedNot possiblePossiblePossible
Repeatable readNot possibleNot possiblePossible
SerializableNot possibleNot possibleNot possible

read commit

Read Committed的全称是read committed rather than transaction

详见 https://docs.oracle.com/database/121/CNCPT/consist.htm#CNCPT1319

查询场景

只要其它事务有commit,无论是update/insert/delete,变更马上就能实时看到

update场景

当两个事务同时更新冲突事务时,后更新的事务会阻塞等待前者commit,前者commit后,会覆盖前者的数据

@startuml
|Trans1|
start
:begin;
:update salary=11 where ID='1' \n => OK;
|#AntiqueWhite|Trans2|
:begin;
:update salary=12 where ID='1' \n => Blocked;
|Trans1|
:commit;
|Trans2|
:1 row updated.;
:select salary where ID='1' \n => 12;
|#AntiqueWhite|Trans1|
:select salary where ID='1' \n => 12;

stop
@enduml

当两个事务同时更新不冲突的事务时,只要没有commit,就是各搞各的。

insert/delete场景

Repeatable read

Serializable

数据仓库

比如HW的DWS https://www.huaweicloud.com/product/dws.html

切片方案

存储方案

列存储与行存储

图数据库

https://www.huaweicloud.com/product/ges.html

参考文献与Wiki

最佳实践项目

我个人建议学习Mybatis中参考SonarQube的项目,优点如下

  • @CheckForNull, @Nullable, @Param等严谨的注解
  • 支持MySql, Oracle等多个Vendor(databaseId)
  • 与Elastic结合的数据库设计
  • SonarQube本身就是做代码质量检测的,因此开源项目本身就是一个样板