Dunwu Blog

大道至简,知易行难

MyBatis 快速入门

MyBatis 的前身就是 iBatis ,是一个作用在数据持久层的对象关系映射(Object Relational Mapping,简称 ORM)框架。

img

Mybatis 简介

img

什么是 MyBatis

MyBatis 是一款持久层框架,它支持定制化 SQL、存储过程以及高级映射。MyBatis 避免了几乎所有的 JDBC 代码和手动设置参数以及获取结果集。MyBatis 可以使用简单的 XML 或注解来配置和映射原生类型、接口和 Java 的 POJO(Plain Old Java Objects,普通老式 Java 对象)为数据库中的记录。

MyBatis vs. Hibernate

MyBatis 和 Hibernate 都是 Java 世界中比较流行的 ORM 框架。我们应该了解其各自的优势,根据项目的需要去抉择在开发中使用哪个框架。

Mybatis 优势

  • MyBatis 可以进行更为细致的 SQL 优化,可以减少查询字段。
  • MyBatis 容易掌握,而 Hibernate 门槛较高。

Hibernate 优势

  • Hibernate 的 DAO 层开发比 MyBatis 简单,Mybatis 需要维护 SQL 和结果映射。
  • Hibernate 对对象的维护和缓存要比 MyBatis 好,对增删改查的对象的维护要方便。
  • Hibernate 数据库移植性很好,MyBatis 的数据库移植性不好,不同的数据库需要写不同 SQL。
  • Hibernate 有更好的二级缓存机制,可以使用第三方缓存。MyBatis 本身提供的缓存机制不佳。

快速入门

这里,我将以一个入门级的示例来演示 Mybatis 是如何工作的。

注:本文后面章节中的原理、源码部分也将基于这个示例来进行讲解。

数据库准备

在本示例中,需要针对一张用户表进行 CRUD 操作。其数据模型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE IF NOT EXISTS user (
id BIGINT(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Id',
name VARCHAR(10) NOT NULL DEFAULT '' COMMENT '用户名',
age INT(3) NOT NULL DEFAULT 0 COMMENT '年龄',
address VARCHAR(32) NOT NULL DEFAULT '' COMMENT '地址',
email VARCHAR(32) NOT NULL DEFAULT '' COMMENT '邮件',
PRIMARY KEY (id)
) COMMENT = '用户表';

INSERT INTO user (name, age, address, email)
VALUES ('张三', 18, '北京', 'xxx@163.com');
INSERT INTO user (name, age, address, email)
VALUES ('李四', 19, '上海', 'xxx@163.com');

添加 Mybatis

如果使用 Maven 来构建项目,则需将下面的依赖代码置于 pom.xml 文件中:

1
2
3
4
5
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>x.x.x</version>
</dependency>

Mybatis 配置

XML 配置文件中包含了对 MyBatis 系统的核心设置,包括获取数据库连接实例的数据源(DataSource)以及决定事务作用域和控制方式的事务管理器(TransactionManager)。

本示例中只是给出最简化的配置。

【示例】mybatis-config.xml 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC" />
<dataSource type="POOLED">
<property name="driver" value="com.mysql.cj.jdbc.Driver" />
<property name="url"
value="jdbc:mysql://127.0.0.1:3306/spring_tutorial?serverTimezone=UTC" />
<property name="username" value="root" />
<property name="password" value="root" />
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="mybatis/mapper/UserMapper.xml" />
</mappers>
</configuration>

说明:上面的配置文件中仅仅指定了数据源连接方式和 User 表的映射配置文件。

Mapper

Mapper.xml

个人理解,Mapper.xml 文件可以看做是 Mybatis 的 JDBC SQL 模板。

【示例】UserMapper.xml 文件

下面是一个通过 Mybatis Generator 自动生成的完整的 Mapper 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="io.github.dunwu.spring.orm.mapper.UserMapper">
<resultMap id="BaseResultMap" type="io.github.dunwu.spring.orm.entity.User">
<id column="id" jdbcType="BIGINT" property="id" />
<result column="name" jdbcType="VARCHAR" property="name" />
<result column="age" jdbcType="INTEGER" property="age" />
<result column="address" jdbcType="VARCHAR" property="address" />
<result column="email" jdbcType="VARCHAR" property="email" />
</resultMap>
<delete id="deleteByPrimaryKey" parameterType="java.lang.Long">
delete from user
where id = #{id,jdbcType=BIGINT}
</delete>
<insert id="insert" parameterType="io.github.dunwu.spring.orm.entity.User">
insert into user (id, name, age,
address, email)
values (#{id,jdbcType=BIGINT}, #{name,jdbcType=VARCHAR}, #{age,jdbcType=INTEGER},
#{address,jdbcType=VARCHAR}, #{email,jdbcType=VARCHAR})
</insert>
<update id="updateByPrimaryKey" parameterType="io.github.dunwu.spring.orm.entity.User">
update user
set name = #{name,jdbcType=VARCHAR},
age = #{age,jdbcType=INTEGER},
address = #{address,jdbcType=VARCHAR},
email = #{email,jdbcType=VARCHAR}
where id = #{id,jdbcType=BIGINT}
</update>
<select id="selectByPrimaryKey" parameterType="java.lang.Long" resultMap="BaseResultMap">
select id, name, age, address, email
from user
where id = #{id,jdbcType=BIGINT}
</select>
<select id="selectAll" resultMap="BaseResultMap">
select id, name, age, address, email
from user
</select>
</mapper>

Mapper.java

Mapper.java 文件是 Mapper.xml 对应的 Java 对象。

【示例】UserMapper.java 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface UserMapper {

int deleteByPrimaryKey(Long id);

int insert(User record);

User selectByPrimaryKey(Long id);

List<User> selectAll();

int updateByPrimaryKey(User record);

}

对比 UserMapper.java 和 UserMapper.xml 文件,不难发现:

UserMapper.java 中的方法和 UserMapper.xml 的 CRUD 语句元素( <insert><delete><update><select>)存在一一对应关系。

在 Mybatis 中,正是通过方法的全限定名,将二者绑定在一起。

数据实体.java

【示例】User.java 文件

1
2
3
4
5
6
7
8
9
10
11
12
public class User {
private Long id;

private String name;

private Integer age;

private String address;

private String email;

}

<insert><delete><update><select>parameterType 属性以及 <resultMap>type 属性都可能会绑定到数据实体。这样就可以把 JDBC 操作的输入输出和 JavaBean 结合起来,更加方便、易于理解。

测试程序

【示例】MybatisDemo.java 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MybatisDemo {

public static void main(String[] args) throws Exception {
// 1. 加载 mybatis 配置文件,创建 SqlSessionFactory
// 注:在实际的应用中,SqlSessionFactory 应该是单例
InputStream inputStream = Resources.getResourceAsStream("mybatis/mybatis-config.xml");
SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
SqlSessionFactory factory = builder.build(inputStream);

// 2. 创建一个 SqlSession 实例,进行数据库操作
SqlSession sqlSession = factory.openSession();

// 3. Mapper 映射并执行
Long params = 1L;
List<User> list = sqlSession.selectList("io.github.dunwu.spring.orm.mapper.UserMapper.selectByPrimaryKey", params);
for (User user : list) {
System.out.println("user name: " + user.getName());
}
// 输出:user name: 张三
}

}

说明:

SqlSession 接口是 Mybatis API 核心中的核心,它代表了 Mybatis 和数据库的一次完整会话。

  • Mybatis 会解析配置,并根据配置创建 SqlSession
  • 然后,Mybatis 将 Mapper 映射为 SqlSession,然后传递参数,执行 SQL 语句并获取结果。

Mybatis xml 配置

配置的详细内容请参考:“ Mybatis 官方文档之配置 ” 。

MyBatis 的配置文件包含了会深深影响 MyBatis 行为的设置和属性信息。主要配置项有以下:

Mybatis xml 映射器

SQL XML 映射文件详细内容请参考:“ Mybatis 官方文档之 XML 映射文件 ”。

XML 映射文件只有几个顶级元素:

  • cache – 对给定命名空间的缓存配置。
  • cache-ref – 对其他命名空间缓存配置的引用。
  • resultMap – 是最复杂也是最强大的元素,用来描述如何从数据库结果集中来加载对象。
  • parameterMap – 已被废弃!老式风格的参数映射。更好的办法是使用内联参数,此元素可能在将来被移除。文档中不会介绍此元素。
  • sql – 可被其他语句引用的可重用语句块。
  • insert – 映射插入语句
  • update – 映射更新语句
  • delete – 映射删除语句
  • select – 映射查询语句

Mybatis 扩展

Mybatis 类型处理器

MyBatis 在设置预处理语句(PreparedStatement)中的参数或从结果集中取出一个值时, 都会用类型处理器将获取到的值以合适的方式转换成 Java 类型。下表描述了一些默认的类型处理器。

从 3.4.5 开始,MyBatis 默认支持 JSR-310(日期和时间 API) 。

你可以重写已有的类型处理器或创建你自己的类型处理器来处理不支持的或非标准的类型。 具体做法为:实现 org.apache.ibatis.type.TypeHandler 接口, 或继承一个很便利的类 org.apache.ibatis.type.BaseTypeHandler, 并且可以(可选地)将它映射到一个 JDBC 类型。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ExampleTypeHandler.java
@MappedJdbcTypes(JdbcType.VARCHAR)
public class ExampleTypeHandler extends BaseTypeHandler<String> {

@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, parameter);
}

@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
return rs.getString(columnName);
}

@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return rs.getString(columnIndex);
}

@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return cs.getString(columnIndex);
}
}
1
2
3
4
<!-- mybatis-config.xml -->
<typeHandlers>
<typeHandler handler="org.mybatis.example.ExampleTypeHandler"/>
</typeHandlers>

使用上述的类型处理器将会覆盖已有的处理 Java String 类型的属性以及 VARCHAR 类型的参数和结果的类型处理器。 要注意 MyBatis 不会通过检测数据库元信息来决定使用哪种类型,所以你必须在参数和结果映射中指明字段是 VARCHAR 类型, 以使其能够绑定到正确的类型处理器上。这是因为 MyBatis 直到语句被执行时才清楚数据类型。

Mybatis 插件

MyBatis 允许你在映射语句执行过程中的某一点进行拦截调用。默认情况下,MyBatis 允许使用插件来拦截的方法调用包括:

  • Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
  • ParameterHandler (getParameterObject, setParameters)
  • ResultSetHandler (handleResultSets, handleOutputParameters)
  • StatementHandler (prepare, parameterize, batch, update, query)

这些类中方法的细节可以通过查看每个方法的签名来发现,或者直接查看 MyBatis 发行包中的源代码。 如果你想做的不仅仅是监控方法的调用,那么你最好相当了解要重写的方法的行为。 因为在试图修改或重写已有方法的行为时,很可能会破坏 MyBatis 的核心模块。 这些都是更底层的类和方法,所以使用插件的时候要特别当心。

通过 MyBatis 提供的强大机制,使用插件是非常简单的,只需实现 Interceptor 接口,并指定想要拦截的方法签名即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ExamplePlugin.java
@Intercepts({@Signature(
type= Executor.class,
method = "update",
args = {MappedStatement.class,Object.class})})
public class ExamplePlugin implements Interceptor {
private Properties properties = new Properties();
public Object intercept(Invocation invocation) throws Throwable {
// implement pre processing if need
Object returnObject = invocation.proceed();
// implement post processing if need
return returnObject;
}
public void setProperties(Properties properties) {
this.properties = properties;
}
}
1
2
3
4
5
6
<!-- mybatis-config.xml -->
<plugins>
<plugin interceptor="org.mybatis.example.ExamplePlugin">
<property name="someProperty" value="100"/>
</plugin>
</plugins>

上面的插件将会拦截在 Executor 实例中所有的 “update” 方法调用, 这里的 Executor 是负责执行底层映射语句的内部对象。

参考资料

MyBatis 原理

MyBatis 的前身就是 iBatis ,是一款优秀的持久层框架,它支持自定义 SQL、存储过程以及高级映射。本文以一个 MyBatis 完整示例为切入点,结合 MyBatis 底层源码分析,图文并茂的讲解 MyBatis 的核心工作机制。

MyBatis 完整示例

这里,我将以一个入门级的示例来演示 MyBatis 是如何工作的。

注:本文后面章节中的原理、源码部分也将基于这个示例来进行讲解。

完整示例源码地址

数据库准备

在本示例中,需要针对一张用户表进行 CRUD 操作。其数据模型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE IF NOT EXISTS user (
id BIGINT(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Id',
name VARCHAR(10) NOT NULL DEFAULT '' COMMENT '用户名',
age INT(3) NOT NULL DEFAULT 0 COMMENT '年龄',
address VARCHAR(32) NOT NULL DEFAULT '' COMMENT '地址',
email VARCHAR(32) NOT NULL DEFAULT '' COMMENT '邮件',
PRIMARY KEY (id)
) COMMENT = '用户表';

INSERT INTO user (name, age, address, email)
VALUES ('张三', 18, '北京', 'xxx@163.com');
INSERT INTO user (name, age, address, email)
VALUES ('李四', 19, '上海', 'xxx@163.com');

添加 MyBatis

如果使用 Maven 来构建项目,则需将下面的依赖代码置于 pom.xml 文件中:

1
2
3
4
5
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>x.x.x</version>
</dependency>

MyBatis 配置

XML 配置文件中包含了对 MyBatis 系统的核心设置,包括获取数据库连接实例的数据源(DataSource)以及决定事务作用域和控制方式的事务管理器(TransactionManager)。

本示例中只是给出最简化的配置。

【示例】MyBatis-config.xml 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<configuration>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC" />
<dataSource type="POOLED">
<property name="driver" value="com.mysql.cj.jdbc.Driver" />
<property name="url"
value="jdbc:mysql://127.0.0.1:3306/spring_tutorial?serverTimezone=UTC" />
<property name="username" value="root" />
<property name="password" value="root" />
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="mybatis/mapper/UserMapper.xml" />
</mappers>
</configuration>

说明:上面的配置文件中仅仅指定了数据源连接方式和 User 表的映射配置文件。

Mapper

Mapper.xml

个人理解,Mapper.xml 文件可以看做是 MyBatis 的 JDBC SQL 模板。

【示例】UserMapper.xml 文件

下面是一个通过 MyBatis Generator 自动生成的完整的 Mapper 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="io.github.dunwu.spring.orm.mapper.UserMapper">
<resultMap id="BaseResultMap" type="io.github.dunwu.spring.orm.entity.User">
<id column="id" jdbcType="BIGINT" property="id" />
<result column="name" jdbcType="VARCHAR" property="name" />
<result column="age" jdbcType="INTEGER" property="age" />
<result column="address" jdbcType="VARCHAR" property="address" />
<result column="email" jdbcType="VARCHAR" property="email" />
</resultMap>
<delete id="deleteByPrimaryKey" parameterType="java.lang.Long">
delete from user
where id = #{id,jdbcType=BIGINT}
</delete>
<insert id="insert" parameterType="io.github.dunwu.spring.orm.entity.User">
insert into user (id, name, age,
address, email)
values (#{id,jdbcType=BIGINT}, #{name,jdbcType=VARCHAR}, #{age,jdbcType=INTEGER},
#{address,jdbcType=VARCHAR}, #{email,jdbcType=VARCHAR})
</insert>
<update id="updateByPrimaryKey" parameterType="io.github.dunwu.spring.orm.entity.User">
update user
set name = #{name,jdbcType=VARCHAR},
age = #{age,jdbcType=INTEGER},
address = #{address,jdbcType=VARCHAR},
email = #{email,jdbcType=VARCHAR}
where id = #{id,jdbcType=BIGINT}
</update>
<select id="selectByPrimaryKey" parameterType="java.lang.Long" resultMap="BaseResultMap">
select id, name, age, address, email
from user
where id = #{id,jdbcType=BIGINT}
</select>
<select id="selectAll" resultMap="BaseResultMap">
select id, name, age, address, email
from user
</select>
</mapper>

Mapper.java

Mapper.java 文件是 Mapper.xml 对应的 Java 对象。

【示例】UserMapper.java 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface UserMapper {

int deleteByPrimaryKey(Long id);

int insert(User record);

User selectByPrimaryKey(Long id);

List<User> selectAll();

int updateByPrimaryKey(User record);

}

对比 UserMapper.java 和 UserMapper.xml 文件,不难发现:

UserMapper.java 中的方法和 UserMapper.xml 的 CRUD 语句元素( <insert><delete><update><select>)存在一一对应关系。

在 MyBatis 中,正是通过方法的全限定名,将二者绑定在一起。

数据实体.java

【示例】User.java 文件

1
2
3
4
5
6
7
8
9
10
11
12
public class User {
private Long id;

private String name;

private Integer age;

private String address;

private String email;

}

<insert><delete><update><select>parameterType 属性以及 <resultMap>type 属性都可能会绑定到数据实体。这样就可以把 JDBC 操作的输入输出和 JavaBean 结合起来,更加方便、易于理解。

测试程序

【示例】MybatisDemo.java 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MybatisDemo {

public static void main(String[] args) throws Exception {
// 1. 加载 MyBatis 配置文件,创建 SqlSessionFactory
// 注:在实际的应用中,SqlSessionFactory 应该是单例
InputStream inputStream = Resources.getResourceAsStream("MyBatis/MyBatis-config.xml");
SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
SqlSessionFactory factory = builder.build(inputStream);

// 2. 创建一个 SqlSession 实例,进行数据库操作
SqlSession sqlSession = factory.openSession();

// 3. Mapper 映射并执行
Long params = 1L;
List<User> list = sqlSession.selectList("io.github.dunwu.spring.orm.mapper.UserMapper.selectByPrimaryKey", params);
for (User user : list) {
System.out.println("user name: " + user.getName());
}
// 输出:user name: 张三
}

}

说明:

SqlSession 接口是 MyBatis API 核心中的核心,它代表了 MyBatis 和数据库的一次完整会话。

  • MyBatis 会解析配置,并根据配置创建 SqlSession
  • 然后,MyBatis 将 Mapper 映射为 SqlSession,然后传递参数,执行 SQL 语句并获取结果。

MyBatis 生命周期

img

SqlSessionFactoryBuilder

SqlSessionFactoryBuilder 的职责

SqlSessionFactoryBuilder 负责创建 SqlSessionFactory 实例SqlSessionFactoryBuilder 可以从 XML 配置文件或一个预先定制的 Configuration 的实例构建出 SqlSessionFactory 的实例。

Configuration 类包含了对一个 SqlSessionFactory 实例你可能关心的所有内容。

img

SqlSessionFactoryBuilder 应用了建造者设计模式,它有五个 build 方法,允许你通过不同的资源创建 SqlSessionFactory 实例。

1
2
3
4
5
SqlSessionFactory build(InputStream inputStream)
SqlSessionFactory build(InputStream inputStream, String environment)
SqlSessionFactory build(InputStream inputStream, Properties properties)
SqlSessionFactory build(InputStream inputStream, String env, Properties props)
SqlSessionFactory build(Configuration config)

SqlSessionFactoryBuilder 的生命周期

SqlSessionFactoryBuilder 可以被实例化、使用和丢弃,一旦创建了 SqlSessionFactory,就不再需要它了。 因此 SqlSessionFactoryBuilder 实例的最佳作用域是方法作用域(也就是局部方法变量)。你可以重用 SqlSessionFactoryBuilder 来创建多个 SqlSessionFactory 实例,但最好还是不要一直保留着它,以保证所有的 XML 解析资源可以被释放给更重要的事情。

SqlSessionFactory

SqlSessionFactory 职责

SqlSessionFactory 负责创建 SqlSession 实例。

img

SqlSessionFactory 应用了工厂设计模式,它提供了一组方法,用于创建 SqlSession 实例。

1
2
3
4
5
6
7
8
9
SqlSession openSession()
SqlSession openSession(boolean autoCommit)
SqlSession openSession(Connection connection)
SqlSession openSession(TransactionIsolationLevel level)
SqlSession openSession(ExecutorType execType, TransactionIsolationLevel level)
SqlSession openSession(ExecutorType execType)
SqlSession openSession(ExecutorType execType, boolean autoCommit)
SqlSession openSession(ExecutorType execType, Connection connection)
Configuration getConfiguration();

方法说明:

  • 默认的 openSession() 方法没有参数,它会创建具备如下特性的 SqlSession
    • 事务作用域将会开启(也就是不自动提交)。
    • 将由当前环境配置的 DataSource 实例中获取 Connection 对象。
    • 事务隔离级别将会使用驱动或数据源的默认设置。
    • 预处理语句不会被复用,也不会批量处理更新。
  • TransactionIsolationLevel 表示事务隔离级别,它对应着 JDBC 的五个事务隔离级别。
  • ExecutorType 枚举类型定义了三个值:
    • ExecutorType.SIMPLE:该类型的执行器没有特别的行为。它为每个语句的执行创建一个新的预处理语句。
    • ExecutorType.REUSE:该类型的执行器会复用预处理语句。
    • ExecutorType.BATCH:该类型的执行器会批量执行所有更新语句,如果 SELECT 在多个更新中间执行,将在必要时将多条更新语句分隔开来,以方便理解。

SqlSessionFactory 生命周期

SqlSessionFactory 应该以单例形式在应用的运行期间一直存在。

SqlSession

SqlSession 职责

MyBatis 的主要 Java 接口就是 SqlSession。它包含了所有执行语句,获取映射器和管理事务等方法。

详细内容可以参考:“ MyBatis 官方文档之 SqlSessions ” 。

SqlSession 类的方法可以按照下图进行大致分类:

img

SqlSession 生命周期

SqlSessions 是由 SqlSessionFactory 实例创建的;而 SqlSessionFactory 是由 SqlSessionFactoryBuilder 创建的。

🔔 注意:当 MyBatis 与一些依赖注入框架(如 Spring 或者 Guice)同时使用时,SqlSessions 将被依赖注入框架所创建,所以你不需要使用 SqlSessionFactoryBuilder 或者 SqlSessionFactory

每个线程都应该有它自己的 SqlSession 实例。

SqlSession 的实例不是线程安全的,因此是不能被共享的,所以它的最佳的作用域是请求或方法作用域。 绝对不能将 SqlSession 实例的引用放在一个类的静态域,甚至一个类的实例变量也不行。 也绝不能将 SqlSession 实例的引用放在任何类型的托管作用域中,比如 Servlet 框架中的 HttpSession。 正确在 Web 中使用 SqlSession 的场景是:每次收到的 HTTP 请求,就可以打开一个 SqlSession,返回一个响应,就关闭它。

编程模式:

1
2
3
try (SqlSession session = sqlSessionFactory.openSession()) {
// 你的应用逻辑代码
}

映射器

映射器职责

映射器是一些由用户创建的、绑定 SQL 语句的接口。

SqlSession 中的 insertupdatedeleteselect 方法都很强大,但也有些繁琐。更通用的方式是使用映射器类来执行映射语句。一个映射器类就是一个仅需声明与 SqlSession 方法相匹配的方法的接口类

MyBatis 将配置文件中的每一个 <mapper> 节点抽象为一个 Mapper 接口,而这个接口中声明的方法和跟 <mapper> 节点中的 <select|update|delete|insert> 节点相对应,即 <select|update|delete|insert> 节点的 id 值为 Mapper 接口中的方法名称,parameterType 值表示 Mapper 对应方法的入参类型,而 resultMap 值则对应了 Mapper 接口表示的返回值类型或者返回结果集的元素类型。

MyBatis 会根据相应的接口声明的方法信息,通过动态代理机制生成一个 Mapper 实例;MyBatis 会根据这个方法的方法名和参数类型,确定 Statement Id,然后和 SqlSession 进行映射,底层还是通过 SqlSession 完成和数据库的交互。

下面的示例展示了一些方法签名以及它们是如何映射到 SqlSession 上的。

img

注意

  • 映射器接口不需要去实现任何接口或继承自任何类。只要方法可以被唯一标识对应的映射语句就可以了。
  • 映射器接口可以继承自其他接口。当使用 XML 来构建映射器接口时要保证语句被包含在合适的命名空间中。而且,唯一的限制就是你不能在两个继承关系的接口中拥有相同的方法签名(潜在的危险做法不可取)。

映射器生命周期

映射器接口的实例是从 SqlSession 中获得的。因此从技术层面讲,任何映射器实例的最大作用域是和请求它们的 SqlSession 相同的。尽管如此,映射器实例的最佳作用域是方法作用域。 也就是说,映射器实例应该在调用它们的方法中被请求,用过之后即可丢弃。

编程模式:

1
2
3
4
try (SqlSession session = sqlSessionFactory.openSession()) {
BlogMapper mapper = session.getMapper(BlogMapper.class);
// 你的应用逻辑代码
}
  • 映射器注解

MyBatis 是一个 XML 驱动的框架。配置信息是基于 XML 的,而且映射语句也是定义在 XML 中的。MyBatis 3 以后,支持注解配置。注解配置基于配置 API;而配置 API 基于 XML 配置。

MyBatis 支持诸如 @Insert@Update@Delete@Select@Result 等注解。

详细内容请参考:MyBatis 官方文档之 sqlSessions,其中列举了 MyBatis 支持的注解清单,以及基本用法。

MyBatis 的架构

从 MyBatis 代码实现的角度来看,MyBatis 的主要组件有以下几个:

  • SqlSession - 作为 MyBatis 工作的主要顶层 API,表示和数据库交互的会话,完成必要数据库增删改查功能。
  • Executor - MyBatis 执行器,是 MyBatis 调度的核心,负责 SQL 语句的生成和查询缓存的维护。
  • StatementHandler - 封装了 JDBC Statement 操作,负责对 JDBC statement 的操作,如设置参数、将 Statement 结果集转换成 List 集合。
  • ParameterHandler - 负责对用户传递的参数转换成 JDBC Statement 所需要的参数。
  • ResultSetHandler - 负责将 JDBC 返回的 ResultSet 结果集对象转换成 List 类型的集合。
  • TypeHandler - 负责 java 数据类型和 jdbc 数据类型之间的映射和转换。
  • MappedStatement - MappedStatement 维护了一条 <select|update|delete|insert> 节点的封装。
  • SqlSource - 负责根据用户传递的 parameterObject,动态地生成 SQL 语句,将信息封装到 BoundSql 对象中,并返回。
  • BoundSql - 表示动态生成的 SQL 语句以及相应的参数信息。
  • Configuration - MyBatis 所有的配置信息都维持在 Configuration 对象之中。

这些组件的架构层次如下:

img

配置层

配置层决定了 MyBatis 的工作方式。

MyBatis 提供了两种配置方式:

  • 基于 XML 配置文件的方式
  • 基于 Java API 的方式

SqlSessionFactoryBuilder 会根据配置创建 SqlSessionFactory

SqlSessionFactory 负责创建 SqlSessions

接口层

接口层负责和数据库交互的方式。

MyBatis 和数据库的交互有两种方式:

  • 使用 SqlSession:SqlSession 封装了所有执行语句,获取映射器和管理事务的方法。
    • 用户只需要传入 Statement Id 和查询参数给 SqlSession 对象,就可以很方便的和数据库进行交互。
    • 这种方式的缺点是不符合面向对象编程的范式。
  • 使用 Mapper 接口:MyBatis 会根据相应的接口声明的方法信息,通过动态代理机制生成一个 Mapper 实例;MyBatis 会根据这个方法的方法名和参数类型,确定 Statement Id,然后和 SqlSession 进行映射,底层还是通过 SqlSession 完成和数据库的交互。

数据处理层

数据处理层可以说是 MyBatis 的核心,从大的方面上讲,它要完成两个功能:

  • 根据传参 Statement 和参数构建动态 SQL 语句
    • 动态语句生成可以说是 MyBatis 框架非常优雅的一个设计,MyBatis 通过传入的参数值,使用 Ognl 来动态地构造 SQL 语句,使得 MyBatis 有很强的灵活性和扩展性。
    • 参数映射指的是对于 java 数据类型和 jdbc 数据类型之间的转换:这里有包括两个过程:查询阶段,我们要将 java 类型的数据,转换成 jdbc 类型的数据,通过 preparedStatement.setXXX() 来设值;另一个就是对 resultset 查询结果集的 jdbcType 数据转换成 java 数据类型。
  • 执行 SQL 语句以及处理响应结果集 ResultSet
    • 动态 SQL 语句生成之后,MyBatis 将执行 SQL 语句,并将可能返回的结果集转换成 List<E> 列表。
    • MyBatis 在对结果集的处理中,支持结果集关系一对多和多对一的转换,并且有两种支持方式,一种为嵌套查询语句的查询,还有一种是嵌套结果集的查询。

框架支撑层

  • 事务管理机制 - MyBatis 将事务抽象成了 Transaction 接口。MyBatis 的事务管理分为两种形式:

    • 使用 JDBC 的事务管理机制:即利用 java.sql.Connection 对象完成对事务的提交(commit)、回滚(rollback)、关闭(close)等。
    • 使用 MANAGED 的事务管理机制:MyBatis 自身不会去实现事务管理,而是让程序的容器如(JBOSS,Weblogic)来实现对事务的管理。
  • 连接池管理

  • SQL 语句的配置 - 支持两种方式:

    • xml 配置
    • 注解配置
  • 缓存机制 - MyBatis 采用两级缓存结构

    • 一级缓存是 Session 会话级别的缓存 - 一级缓存又被称之为本地缓存。一般而言,一个 SqlSession 对象会使用一个 Executor 对象来完成会话操作,Executor 对象会维护一个 Cache 缓存,以提高查询性能。
      • 一级缓存的生命周期是 Session 会话级别的。
    • 二级缓存是 Application 应用级别的缓存 - 用户配置了 "cacheEnabled=true",才会开启二级缓存。
      • 如果开启了二级缓存,SqlSession 会先使用 CachingExecutor 对象来处理查询请求。CachingExecutor 会在二级缓存中查看是否有匹配的数据,如果匹配,则直接返回缓存结果;如果缓存中没有,再交给真正的 Executor 对象来完成查询,之后 CachingExecutor 会将真正 Executor 返回的查询结果放置到缓存中,然后在返回给用户。
      • 二级缓存的生命周期是应用级别的。

img

SqlSession 内部工作机制

从前文,我们已经了解了,MyBatis 封装了对数据库的访问,把对数据库的会话和事务控制放到了 SqlSession 对象中。那么具体是如何工作的呢?接下来,我们通过源码解读来进行分析。

img

SqlSession 对于 insert、update、delete、select 的内部处理机制基本上大同小异。所以,接下来,我会以一次完整的 select 查询流程为例讲解 SqlSession 内部的工作机制。相信读者如果理解了 select 的处理流程,对于其他 CRUD 操作也能做到一通百通。

SqlSession 子组件

前面的内容已经介绍了:SqlSession 是 MyBatis 的顶层接口,它提供了所有执行语句,获取映射器和管理事务等方法。

实际上,SqlSession 是通过聚合多个子组件,让每个子组件负责各自功能的方式,实现了任务的下发。

在了解各个子组件工作机制前,先让我们简单认识一下 SqlSession 的核心子组件。

Executor

Executor 即执行器,它负责生成动态 SQL 以及管理缓存。

img

  • Executor 即执行器接口。
  • BaseExecutorExecutor 的抽象类,它采用了模板方法设计模式,内置了一些共性方法,而将定制化方法留给子类去实现。
  • SimpleExecutor 是最简单的执行器。它只会直接执行 SQL,不会做额外的事。
  • BatchExecutor 是批处理执行器。它的作用是通过批处理来优化性能。值得注意的是,批量更新操作,由于内部有缓存机制,使用完后需要调用 flushStatements 来清除缓存。
  • ReuseExecutor 是可重用的执行器。重用的对象是 Statement,也就是说,该执行器会缓存同一个 SQL 的 Statement,避免重复创建 Statement。其内部的实现是通过一个 HashMap 来维护 Statement 对象的。由于当前 Map 只在该 session 中有效,所以使用完后需要调用 flushStatements 来清除 Map。
  • CachingExecutor 是缓存执行器。它只在启用二级缓存时才会用到。

StatementHandler

StatementHandler 对象负责设置 Statement 对象中的查询参数、处理 JDBC 返回的 resultSet,将 resultSet 加工为 List 集合返回。

StatementHandler 的家族成员:

img

  • StatementHandler 是接口;
  • BaseStatementHandler 是实现 StatementHandler 的抽象类,内置一些共性方法;
  • SimpleStatementHandler 负责处理 Statement
  • PreparedStatementHandler 负责处理 PreparedStatement
  • CallableStatementHandler 负责处理 CallableStatement
  • RoutingStatementHandler 负责代理 StatementHandler 具体子类,根据 Statement 类型,选择实例化 SimpleStatementHandlerPreparedStatementHandlerCallableStatementHandler

ParameterHandler

ParameterHandler 负责将传入的 Java 对象转换 JDBC 类型对象,并为 PreparedStatement 的动态 SQL 填充数值。

ParameterHandler 只有一个具体实现类,即 DefaultParameterHandler

ResultSetHandler

ResultSetHandler 负责两件事:

  • 处理 Statement 执行后产生的结果集,生成结果列表
  • 处理存储过程执行后的输出参数

ResultSetHandler 只有一个具体实现类,即 DefaultResultSetHandler

TypeHandler

TypeHandler 负责将 Java 对象类型和 JDBC 类型进行相互转换。

SqlSession 和 Mapper

先来回忆一下 MyBatis 完整示例章节的 测试程序部分的代码。

MybatisDemo.java 文件中的代码片段:

1
2
3
4
5
6
7
8
9
// 2. 创建一个 SqlSession 实例,进行数据库操作
SqlSession sqlSession = factory.openSession();

// 3. Mapper 映射并执行
Long params = 1L;
List<User> list = sqlSession.selectList("io.github.dunwu.spring.orm.mapper.UserMapper.selectByPrimaryKey", params);
for (User user : list) {
System.out.println("user name: " + user.getName());
}

示例代码中,给 sqlSession 对象的传递一个配置的 Sql 语句的 Statement Id 和参数,然后返回结果

io.github.dunwu.spring.orm.mapper.UserMapper.selectByPrimaryKey 是配置在 UserMapper.xml 的 Statement ID,params 是 SQL 参数。

UserMapper.xml 文件中的代码片段:

1
2
3
4
5
<select id="selectByPrimaryKey" parameterType="java.lang.Long" resultMap="BaseResultMap">
select id, name, age, address, email
from user
where id = #{id,jdbcType=BIGINT}
</select>

MyBatis 通过方法的全限定名,将 SqlSession 和 Mapper 相互映射起来。

SqlSession 和 Executor

org.apache.ibatis.session.defaults.DefaultSqlSessionselectList 方法的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public <E> List<E> selectList(String statement) {
return this.selectList(statement, null);
}

@Override
public <E> List<E> selectList(String statement, Object parameter) {
return this.selectList(statement, parameter, RowBounds.DEFAULT);
}

@Override
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
try {
// 1. 根据 Statement Id,在配置对象 Configuration 中查找和配置文件相对应的 MappedStatement
MappedStatement ms = configuration.getMappedStatement(statement);
// 2. 将 SQL 语句交由执行器 Executor 处理
return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error querying database. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}

说明:

MyBatis 所有的配置信息都维持在 Configuration 对象之中。中维护了一个 Map<String, MappedStatement> 对象。其中,key 为 Mapper 方法的全限定名(对于本例而言,key 就是 io.github.dunwu.spring.orm.mapper.UserMapper.selectByPrimaryKey ),value 为 MappedStatement 对象。所以,传入 Statement Id 就可以从 Map 中找到对应的 MappedStatement

MappedStatement 维护了一个 Mapper 方法的元数据信息,其数据组织可以参考下面的 debug 截图:

img

小结:

通过 “SqlSession 和 Mapper” 以及 “SqlSession 和 Executor” 这两节,我们已经知道:

SqlSession 的职能是:根据 Statement ID, 在 Configuration 中获取到对应的 MappedStatement 对象,然后调用 Executor 来执行具体的操作。

Executor 工作流程

继续上一节的流程,SqlSession 将 SQL 语句交由执行器 Executor 处理。Executor 又做了哪些事儿呢?

(1)执行器查询入口

1
2
3
4
5
6
7
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
// 1. 根据传参,动态生成需要执行的 SQL 语句,用 BoundSql 对象表示
BoundSql boundSql = ms.getBoundSql(parameter);
// 2. 根据传参,创建一个缓存Key
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}

执行器查询入口主要做两件事:

  • 生成动态 SQL:根据传参,动态生成需要执行的 SQL 语句,用 BoundSql 对象表示。
  • 管理缓存:根据传参,创建一个缓存 Key。

(2)执行器查询第二入口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@SuppressWarnings("unchecked")
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
// 略
List<E> list;
try {
queryStack++;
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
// 3. 缓存中有值,则直接从缓存中取数据;否则,查询数据库
if (list != null) {
handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
} finally {
queryStack--;
}
// 略
return list;
}

实际查询方法主要的职能是判断缓存 key 是否能命中缓存:

  • 命中,则将缓存中数据返回;
  • 不命中,则查询数据库:

(3)查询数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
List<E> list;
localCache.putObject(key, EXECUTION_PLACEHOLDER);
try {
// 4. 执行查询,获取 List 结果,并将查询的结果更新本地缓存中
list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
localCache.removeObject(key);
}
localCache.putObject(key, list);
if (ms.getStatementType() == StatementType.CALLABLE) {
localOutputParameterCache.putObject(key, parameter);
}
return list;
}

queryFromDatabase 方法的职责是调用 doQuery,向数据库发起查询,并将返回的结果更新到本地缓存。

(4)实际查询方法

SimpleExecutor 类的 doQuery()方法实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
Statement stmt = null;
try {
Configuration configuration = ms.getConfiguration();
// 5. 根据既有的参数,创建StatementHandler对象来执行查询操作
StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);
// 6. 创建java.Sql.Statement对象,传递给StatementHandler对象
stmt = prepareStatement(handler, ms.getStatementLog());
// 7. 调用StatementHandler.query()方法,返回List结果
return handler.query(stmt, resultHandler);
} finally {
closeStatement(stmt);
}
}

上述的 Executor.query()方法几经转折,最后会创建一个 StatementHandler 对象,然后将必要的参数传递给 StatementHandler,使用 StatementHandler 来完成对数据库的查询,最终返回 List 结果集。
从上面的代码中我们可以看出,Executor 的功能和作用是:

  1. 根据传递的参数,完成 SQL 语句的动态解析,生成 BoundSql 对象,供 StatementHandler 使用;

  2. 为查询创建缓存,以提高性能

  3. 创建 JDBC 的 Statement 连接对象,传递给 StatementHandler 对象,返回 List 查询结果。

prepareStatement() 方法的实现:

1
2
3
4
5
6
7
8
private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
Statement stmt;
Connection connection = getConnection(statementLog);
stmt = handler.prepare(connection, transaction.getTimeout());
//对创建的Statement对象设置参数,即设置SQL 语句中 ? 设置为指定的参数
handler.parameterize(stmt);
return stmt;
}

对于 JDBC 的 PreparedStatement 类型的对象,创建的过程中,我们使用的是 SQL 语句字符串会包含 若干个? 占位符,我们其后再对占位符进行设值。

StatementHandler 工作流程

StatementHandler 有一个子类 RoutingStatementHandler,它负责代理其他 StatementHandler 子类的工作。

它会根据配置的 Statement 类型,选择实例化相应的 StatementHandler,然后由其代理对象完成工作。

【源码】RoutingStatementHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public RoutingStatementHandler(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {

switch (ms.getStatementType()) {
case STATEMENT:
delegate = new SimpleStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
break;
case PREPARED:
delegate = new PreparedStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
break;
case CALLABLE:
delegate = new CallableStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
break;
default:
throw new ExecutorException("Unknown statement type: " + ms.getStatementType());
}

}

【源码】RoutingStatementHandlerparameterize 方法源码

【源码】PreparedStatementHandlerparameterize 方法源码

StatementHandler 使用 ParameterHandler 对象来完成对 Statement 的赋值。

1
2
3
4
5
@Override
public void parameterize(Statement statement) throws SQLException {
// 使用 ParameterHandler 对象来完成对 Statement 的设值
parameterHandler.setParameters((PreparedStatement) statement);
}

【源码】StatementHandlerquery 方法源码

StatementHandler 使用 ResultSetHandler 对象来完成对 ResultSet 的处理。

1
2
3
4
5
6
7
@Override
public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
PreparedStatement ps = (PreparedStatement) statement;
ps.execute();
// 使用ResultHandler来处理ResultSet
return resultSetHandler.handleResultSets(ps);
}

ParameterHandler 工作流程

【源码】DefaultParameterHandlersetParameters 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
 @Override
public void setParameters(PreparedStatement ps) {
// parameterMappings 是对占位符 #{} 对应参数的封装
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
if (parameterMappings != null) {
for (int i = 0; i < parameterMappings.size(); i++) {
ParameterMapping parameterMapping = parameterMappings.get(i);
// 不处理存储过程中的参数
if (parameterMapping.getMode() != ParameterMode.OUT) {
Object value;
String propertyName = parameterMapping.getProperty();
if (boundSql.hasAdditionalParameter(propertyName)) { // issue #448 ask first for additional params
// 获取对应的实际数值
value = boundSql.getAdditionalParameter(propertyName);
} else if (parameterObject == null) {
value = null;
} else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
value = parameterObject;
} else {
// 获取对象中相应的属性或查找 Map 对象中的值
MetaObject metaObject = configuration.newMetaObject(parameterObject);
value = metaObject.getValue(propertyName);
}

TypeHandler typeHandler = parameterMapping.getTypeHandler();
JdbcType jdbcType = parameterMapping.getJdbcType();
if (value == null && jdbcType == null) {
jdbcType = configuration.getJdbcTypeForNull();
}
try {
// 通过 TypeHandler 将 Java 对象参数转为 JDBC 类型的参数
// 然后,将数值动态绑定到 PreparedStaement 中
typeHandler.setParameter(ps, i + 1, value, jdbcType);
} catch (TypeException | SQLException e) {
throw new TypeException("Could not set parameters for mapping: " + parameterMapping + ". Cause: " + e, e);
}
}
}
}
}

ResultSetHandler 工作流程

ResultSetHandler 的实现可以概括为:将 Statement 执行后的结果集,按照 Mapper 文件中配置的 ResultTypeResultMap 来转换成对应的 JavaBean 对象,最后将结果返回。

【源码】DefaultResultSetHandlerhandleResultSets 方法

handleResultSets 方法是 DefaultResultSetHandler 的最关键方法。其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Override
public List<Object> handleResultSets(Statement stmt) throws SQLException {
ErrorContext.instance().activity("handling results").object(mappedStatement.getId());

final List<Object> multipleResults = new ArrayList<>();

int resultSetCount = 0;
// 第一个结果集
ResultSetWrapper rsw = getFirstResultSet(stmt);
List<ResultMap> resultMaps = mappedStatement.getResultMaps();
// 判断结果集的数量
int resultMapCount = resultMaps.size();
validateResultMapsCount(rsw, resultMapCount);
// 遍历处理结果集
while (rsw != null && resultMapCount > resultSetCount) {
ResultMap resultMap = resultMaps.get(resultSetCount);
handleResultSet(rsw, resultMap, multipleResults, null);
rsw = getNextResultSet(stmt);
cleanUpAfterHandlingResultSet();
resultSetCount++;
}

String[] resultSets = mappedStatement.getResultSets();
if (resultSets != null) {
while (rsw != null && resultSetCount < resultSets.length) {
ResultMapping parentMapping = nextResultMaps.get(resultSets[resultSetCount]);
if (parentMapping != null) {
String nestedResultMapId = parentMapping.getNestedResultMapId();
ResultMap resultMap = configuration.getResultMap(nestedResultMapId);
handleResultSet(rsw, resultMap, null, parentMapping);
}
rsw = getNextResultSet(stmt);
cleanUpAfterHandlingResultSet();
resultSetCount++;
}
}

return collapseSingleResultList(multipleResults);
}

参考资料

Shiro 快速入门

Shiro 是一个安全框架,具有认证、授权、加密、会话管理功能。

一、Shiro 简介

Shiro 特性

核心功能:

  • Authentication - 认证。验证用户是不是拥有相应的身份。
  • Authorization - 授权。验证某个已认证的用户是否拥有某个权限;即判断用户是否能做事情,常见的如:验证某个用户是否拥有某个角色。或者细粒度的验证某个用户对某个资源是否具有某个权限。
  • Session Manager - 会话管理。即用户登录后就是一次会话,在没有退出之前,它的所有信息都在会话中。会话可以是普通 JavaSE 环境的,也可以是如 Web 环境的。
  • Cryptography - 加密。保护数据的安全性,如密码加密存储到数据库,而不是明文存储。

辅助功能:

  • Web Support - Web 支持。可以非常容易的集成到 Web 环境;
  • Caching - 缓存。比如用户登录后,其用户信息、拥有的角色 / 权限不必每次去查,这样可以提高效率;
  • Concurrency - 并发。Shiro 支持多线程应用的并发验证,即如在一个线程中开启另一个线程,能把权限自动传播过去;
  • Testing - 测试。提供测试支持;
  • Run As - 运行方式。允许一个用户假装为另一个用户(如果他们允许)的身份进行访问;
  • Remember Me - 记住我。即一次登录后,下次再访问免登录。

:bell: 注意:Shiro 不会去维护用户、维护权限;这些需要我们自己去提供;然后通过相应的接口注入给 Shiro 即可。

Shiro 架构概述

  • Subject - 主题。它代表当前用户,Subject 可以是一个人,但也可以是第三方服务、守护进程帐户、时钟守护任务或者其它——当前和软件交互的任何事件。Subject 是 Shiro 的入口。

    • PrincipalsSubject 的“识别属性”。Principals 可以是任何可以识别 Subject 的东西,例如名字(姓氏),姓氏(姓氏或姓氏),用户名,社会保险号等。当然,Principals 在应用程序中最好是惟一的。
    • Credentials 通常是仅由 Subject 知道的秘密值,用作他们实际上“拥有”所主张身份的佐证 凭据的一些常见示例是密码,生物特征数据(例如指纹和视网膜扫描)以及 X.509 证书。
  • SecurityManager - 安全管理。它是 Shiro 的核心,所有与安全有关的操作(认证、授权、及会话、缓存的管理)都与 SecurityManager 交互,且它管理着所有 Subject

  • Realm - 。用于访问安全相关数据,可以视为应用自身的数据源,需要开发者自己实现。Shiro 会通过 Realm 获取安全数据(如用户、角色、权限),就是说 SecurityManager 要验证用户身份,那么它需要从 Realm 获取相应的用户进行比较以确定用户身份是否合法;也需要从 Realm 得到用户相应的角色/权限进行验证用户是否能进行操作;可以把 Realm 看成 DataSource,即安全数据源。

SecurityManager

SecurityManager 是 Shiro 框架核心中的核心,它相当于 Shiro 的总指挥,负责调度所有行为,包括:认证、授权、获取安全数据(调用 Realm)、会话管理等。

img

SecurityManager 聚合了以下组件:

  • Authenticator - 认证器,负责认证。如果用户需要定制认证策略,可以实现此接口。
  • Authorizer - 授权器,负责权限控制。用来决定主体是否有权限进行相应的操作;即控制着用户能访问应用中的哪些功能;
  • SessionManager - 会话管理器。Shiro 抽象了一个自己的 Session 来管理主体与应用之间交互的数据。
  • SessionDAO - 会话 DAO 用于存储会话,需要用户自己实现。
  • CacheManager - 缓存控制器。用于管理如用户、角色、权限等信息的缓存。
  • Cryptography - 密码器。用于对数据加密、解密。

二、Shiro 认证

认证 Subject

验证 Subject 的过程可以有效地分为三个不同的步骤:

(1)收集 Subject 提交的 PrincipalsCredentials

1
2
3
4
5
//Example using most common scenario of username/password pair:
UsernamePasswordToken token = new UsernamePasswordToken(username, password);

//"Remember Me" built-in:
token.setRememberMe(true);

(2)提交 PrincipalsCredentials 以进行身份验证。

1
2
3
Subject currentUser = SecurityUtils.getSubject();

currentUser.login(token);

(3)如果提交成功,则允许访问,否则重试身份验证或阻止访问。

1
2
3
4
5
6
7
8
9
10
try {
currentUser.login(token);
} catch ( UnknownAccountException uae ) { ...
} catch ( IncorrectCredentialsException ice ) { ...
} catch ( LockedAccountException lae ) { ...
} catch ( ExcessiveAttemptsException eae ) { ...
} ... catch your own ...
} catch ( AuthenticationException ae ) {
//unexpected error?
}

Remembered 和 Authenticated

  • Remembered - 记住我。被记住的 Subject 不是匿名的,并且具有已知的身份(即 subject.getPrincipals() 是非空的)。 但是,在先前的会话期间,通过先前的身份验证会记住此身份。 如果 subject.isRemembered() 返回 true,则认为该主题已被记住。
  • Authenticated - 已认证。已认证的 Subject 是在当前会话期间已成功认证的 Subject。 如果 subject.isAuthenticated() 返回 true,则认为该 Subject 已通过身份验证。

登出

当 Subject 与应用程序完成交互后,可以调用 subject.logout() 登出,即放弃所有标识信息。

1
currentUser.logout();

认证流程

img

  1. 应用程序代码调用 Subject.login 方法,传入构造的 AuthenticationToken 实例,该实例代表最终用户的 PrincipalsCredentials

  2. Subject 实例(通常是 DelegatingSubject(或子类))通过调用 securityManager.login(token)委托应用程序的 SecurityManager,在此处开始实际的身份验证工作。

  3. SecurityManager 接收令牌,并通过调用 authenticator.authenticate(token)来简单地委派给其内部 Authenticator 实例。这几乎总是一个 ModularRealmAuthenticator 实例,它支持在身份验证期间协调一个或多个 Realm 实例。

  4. 如果为该应用程序配置了多个 Realm,则 ModularRealmAuthenticator 实例将利用其配置的 AuthenticationStrategy 发起多域验证尝试。在调用领域进行身份验证之前,期间和之后,将调用 AuthenticationStrategy 以使其对每个领域的结果做出反应。

  5. 请咨询每个已配置的 Realm,以查看其是否支持提交的 AuthenticationToken。 如果是这样,将使用提交的令牌调用支持 RealmgetAuthenticationInfo 方法。 getAuthenticationInfo 方法有效地表示对该特定 Realm 的单个身份验证尝试。

认证策略

当为一个应用程序配置两个或多个领域时,ModularRealmAuthenticator 依赖于内部 AuthenticationStrategy 组件来确定认证尝试成功或失败的条件。

例如,如果只有一个 Realm 成功地对 AuthenticationToken 进行身份验证,而所有其他 Realm 都失败了,那么该身份验证尝试是否被视为成功?还是必须所有领域都成功进行身份验证才能将整体尝试视为成功?或者,如果某个领域成功通过身份验证,是否有必要进一步咨询其他领域? AuthenticationStrategy 根据应用程序的需求做出适当的决定。

AuthenticationStrategy 是无状态组件,在尝试进行身份验证时会被查询 4 次(这 4 种交互所需的任何必要状态都将作为方法参数给出):

  • 在任何领域被调用之前
  • 在调用单个 RealmgetAuthenticationInfo 方法之前
  • 在调用单个 RealmgetAuthenticationInfo 方法之后
  • 在所有领域都被调用之后

AuthenticationStrategy 还负责汇总每个成功 Realm 的结果,并将它们“捆绑”成单个 AuthenticationInfo 表示形式。最终的聚合 AuthenticationInfo 实例是 Authenticator 实例返回的结果,也是 Shiro 用来表示主体的最终身份(也称为委托人)的东西。

AuthenticationStrategy 描述
AtLeastOneSuccessfulStrategy 只要有一个 Realm 成功认证,则整个尝试都被视为成功。
FirstSuccessfulStrategy 仅使用从第一个成功通过身份验证的 Realm 返回的信息,所有其他 Realm 将被忽略。
AllSuccessfulStrategy 只有所有 Realm 成功认证,则整个尝试才被视为成功。

:link: 更多认证细节可以参考:Apache Shiro Authentication

三、Shiro 授权

授权,也称为访问控制,是管理对资源的访问的过程。 换句话说,控制谁有权访问应用程序中的内容。

授权元素

授权有三个核心要素:权限、角色和用户。

权限

权限示例:

  • 打开一个文件
  • 查看 /user/list web 页面
  • 查询记录
  • 删除一条记录

大多数资源都支持一般的 CRUD 操作。除此以外,对于一些特定的资源,任何有意义的行为都是可以的。基本的设计思路是:权限控制,至少是基于资源和行为。

角色

角色是一个命名实体,通常代表一组行为或职责。这些行为会转化为:谁可以在应用程序中执行哪些行为?谁不可以在程序中执行哪些行为?

角色通常是分配给用户帐户的,因此通过关联,用户可以获得自身角色所赋予的权限。

用户

用户本质上是应用程序的“用户”。

用户(即 Shiro 的 Subject)通过与角色或直接权限的关联在应用程序中执行某些行为。

基于角色的授权

如果授权是基于角色赋予权限的数据模型,编程模式如下:

【示例一】

1
2
3
4
5
6
7
Subject currentUser = SecurityUtils.getSubject();

if (currentUser.hasRole("administrator")) {
//show the admin button
} else {
//don't show the button? Grey it out?
}

【示例二】

1
2
3
4
5
6
Subject currentUser = SecurityUtils.getSubject();

// 检查当前 Subject 是否有某种权限
// 如果有,直接跳过;如果没有,Shiro 会抛出 AuthorizationException
currentUser.checkRole("bankTeller");
openBankAccount();

提示:方式二相比方式一,代码更简洁

基于权限的授权

更好的授权策略通常是基于权限的授权。基于权限的授权,由于它和应用程序的原始功能(针对具体资源上的行为)紧密相关,所以基于权限的授权源代码会在功能更改时同步更改(而不是在安全策略发生更改时)。 这意味着与类似的基于角色的授权代码相比,修改代码的影响面要小得多。

【示例】基于对象的权限检查

1
2
3
4
5
6
7
8
9
Permission printPermission = new PrinterPermission("laserjet4400n", "print");

Subject currentUser = SecurityUtils.getSubject();

if (currentUser.isPermitted(printPermission)) {
//show the Print button
} else {
//don't show the button? Grey it out?
}

在对象中存储权限控制信息,但这种方式较为繁重

【示例】字符串定义权限控制信息

1
2
3
4
5
6
7
Subject currentUser = SecurityUtils.getSubject();

if (currentUser.isPermitted("printer:print:laserjet4400n")) {
//show the Print button
} else {
//don't show the button? Grey it out?
}

使用 : 分隔,表示资源类型、行为、资源 ID,Shiro 提供了默认实现: org.apache.shiro.authz.permission.WildcardPermission

这种权限控制方式的好处在于:轻量、灵活。

基于注解的授权

Shiro 提供了一些用于授权的注解,来进一步简化授权代码。

@RequiresAuthentication

@RequiresAuthentication 注解要求当前 Subject 必须是已认证用户才可以访问被修饰的方法。

【示例】

1
2
3
4
5
6
@RequiresAuthentication
public void updateAccount(Account userAccount) {
//this method will only be invoked by a
//Subject that is guaranteed authenticated
...
}

@RequiresGuest

@RequiresGuest 注解要求当前 Subject 的角色是 guest 才可以访问被修饰的方法。

授权流程

img

  1. 应用程序或框架代码调用任何 SubjecthasRole*checkRole*isPermitted*checkPermission* 方法,并传入所需的权限或角色。

  2. Subject 实例,通常是 DelegatingSubject(或子类),通过调用 securityManager 几乎相同的各自 hasRole*checkRole*isPermitted*checkPermission* 方法来委托 SecurityManager (实现了 org.apache.shiro.authz.Authorizer 接口)处理授权。

  3. SecurityManager 通过调用授权者各自的 hasRole*checkRole*isPermitted*checkPermission* 方法来中继/委托其内部的 org.apache.shiro.authz.Authorizer 实例。默认情况下,authorizer 实例是 ModularRealmAuthorizer 实例,该实例支持在任何授权操作期间协调一个或多个 Realm 实例。

  4. 检查每个已配置的 Realm,以查看其是否实现相同的 Authorizer 接口。如果是这样,则将调用 Realm 各自的 hasRole*checkRole*isPermitted*checkPermission* 方法。

:link: 更多授权细节可以参考:Apache Shiro Authorization

四、Shiro 会话管理

Shiro 提供了一套独特的会话管理方案:其 Session 可以使用 Java SE 程序,也可以使用于 Java Web 程序。

在 Shiro 中,SessionManager 负责管理应用所有 Subject 的会话,如:创建、删除、失效、验证等。

【示例】会话使用示例

1
2
3
4
Subject currentUser = SecurityUtils.getSubject();

Session session = currentUser.getSession();
session.setAttribute( "someKey", someValue);

会话超时

默认情况下,Shiro 中的会话有效期为 30 分钟,超时后,该会话将被 Shiro 视为无效。

可以通过 globalSessionTimeout 方法设置 Shiro 会话超时时间。

会话监听

Shiro 提供了 SessionListener 接口(或 SessionListenerAdapter 接口),用于监听重要的会话事件,并允许使用者在事件触发时做定制化处理。

【示例】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ShiroSessionListener implements SessionListener {

private final Logger log = LoggerFactory.getLogger(this.getClass());

private final AtomicInteger sessionCount = new AtomicInteger(0);

@Override
public void onStart(Session session) {
sessionCount.incrementAndGet();
}

@Override
public void onStop(Session session) {
sessionCount.decrementAndGet();
}

@Override
public void onExpiration(Session session) {
sessionCount.decrementAndGet();
}
}

会话存储

大多数情况下,应用需要保存会话信息,以便在稍后可以使用它。

Shiro 提供了 SessionManager 接口,负责将针对会话的 CRUD 操作委派给内部组件 SessionDAO,该组件反映了数据访问对象(DAO)设计模式。

:bell: 注意:由于会话通常具有时效性,所以一般会话天然适合存储于缓存中。存储于 Redis 中是一个不错的选择。

五、Realm

Realm 是 Shiro 访问程序安全相关数据(如:用户、角色、权限)的接口。

Realm 是有开发者自己实现的,开发者可以通过实现 Realm 接口,接入应用的数据源,如:JDBC、文件、Nosql 等等。

认证令牌

Shiro 支持身份验证令牌。在咨询 Realm 进行认证尝试之前,将调用其支持方法。 如果返回值为 true,则仅会调用其 getAuthenticationInfo(token) 方法。通常,Realm 会检查所提交令牌的类型(接口或类),以查看其是否可以处理它。

令牌认证处理流程如下:

  1. 检查用于标识 principal 的令牌(帐户标识信息)。
  2. 根据 principal,在数据源中查找相应的帐户数据。
  3. 确保令牌提供的凭证与数据存储中存储的凭证匹配。
  4. 如果 credentials 匹配,则返回 AuthenticationInfo 实例。
  5. 如果 credentials 不匹配,则抛出 AuthenticationException 异常。

加密

通过前文,可以了解:Shiro 需要通过一对 principal 和 credentials 来确认身份是否匹配(即认证)。

一般来说,成熟软件是不允许存储账户、密码这些敏感数据时,使用明文存储。所以,通常要将密码加密后存储。

Shiro 提供了一些加密器,其思想就是用 MD5、SHA 这种数字签名算法,加 Salt,然后转为 Base64 字符串。为了避免被暴力破解,Shiro 使用多次加密的方式获得最终的 credentials 字符串。

【示例】Shiro 加密密码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.apache.shiro.crypto.hash.Sha256Hash;
import org.apache.shiro.crypto.RandomNumberGenerator;
import org.apache.shiro.crypto.SecureRandomNumberGenerator;
...

//We'll use a Random Number Generator to generate salts. This
//is much more secure than using a username as a salt or not
//having a salt at all. Shiro makes this easy.
//
//Note that a normal app would reference an attribute rather
//than create a new RNG every time:
RandomNumberGenerator rng = new SecureRandomNumberGenerator();
Object salt = rng.nextBytes();

//Now hash the plain-text password with the random salt and multiple
//iterations and then Base64-encode the value (requires less space than Hex):
String hashedPasswordBase64 = new Sha256Hash(plainTextPassword, salt, 1024).toBase64();

User user = new User(username, hashedPasswordBase64);
//save the salt with the new account. The HashedCredentialsMatcher
//will need it later when handling login attempts:
user.setPasswordSalt(salt);
userDAO.create(user);

六、配置

过滤链

运行 Web 应用程序时,Shiro 将创建一些有用的默认 Filter 实例。

Filter Name Class
anon org.apache.shiro.web.filter.authc.AnonymousFilter
authc org.apache.shiro.web.filter.authc.FormAuthenticationFilter
authcBasic org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter
logout org.apache.shiro.web.filter.authc.LogoutFilter
noSessionCreation org.apache.shiro.web.filter.session.NoSessionCreationFilter
perms org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter
port org.apache.shiro.web.filter.authz.PortFilter
rest org.apache.shiro.web.filter.authz.HttpMethodPermissionFilter
roles org.apache.shiro.web.filter.authz.RolesAuthorizationFilter
ssl org.apache.shiro.web.filter.authz.SslFilter
user org.apache.shiro.web.filter.authc.UserFilter

RememberMe

1
2
3
UsernamePasswordToken token = new UsernamePasswordToken(username, password);
token.setRememberMe(true);
SecurityUtils.getSubject().login(token);

参考资料

Spring Security 快速入门

快速开始

参考:Securing a Web Application

核心 API

设计原理

Spring Security 对于 Servlet 的支持基于过滤链(FilterChain)实现。

Spring 提供了一个名为 DelegatingFilterProxyFilter 实现,该实现允许在 Servlet 容器的生命周期和 Spring 的 ApplicationContext 之间进行桥接。 Servlet 容器允许使用其自己的标准注册 Filters,但它不了解 Spring 定义的 Bean。 DelegatingFilterProxy 可以通过标准的 Servlet 容器机制进行注册,但是可以将所有工作委托给实现 Filter 的 Spring Bean。

1
2
3
4
5
6
7
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
// Lazily get Filter that was registered as a Spring Bean
// For the example in DelegatingFilterProxy delegate is an instance of Bean Filter0
Filter delegate = getFilterBean(someBeanName);
// delegate work to the Spring Bean
delegate.doFilter(request, response);
}

FilterChainProxy 使用 SecurityFilterChain 确定应对此请求调用哪些 Spring Security 过滤器。

SecurityFilterChain 中的安全过滤器通常是 Bean,但它们是使用 FilterChainProxy 而不是 DelegatingFilterProxy 注册的。

实际上,FilterChainProxy 可用于确定应使用哪个 SecurityFilterChain。如果您的应用程序可以为不同的模块提供完全独立的配置。

multi securityfilterchain

ExceptionTranslationFilter 可以将 AccessDeniedException 和 AuthenticationException 转换为 HTTP 响应。

exceptiontranslationfilter

核心源码:

1
2
3
4
5
6
7
8
9
try {
filterChain.doFilter(request, response);
} catch (AccessDeniedException | AuthenticationException e) {
if (!authenticated || e instanceof AuthenticationException) {
startAuthentication();
} else {
accessDenied();
}
}

认证

数据模型

Spring Security 框架中的认证数据模型如下:

img

  • Authentication - 认证信息实体。
    • principal - 用户标识。如:用户名、账户名等。通常是 UserDetails 的实例(后面详细讲解)。
    • credentials - 认证凭证。如:密码等。
    • authorities - 授权信息。如:用户的角色、权限等信息。
  • SecurityContext - 安全上下文。包含一个 Authentication 对象。
  • SecurityContextHolder - 安全上下文持有者。用于存储认证信息。

【示例】注册认证信息

1
2
3
4
5
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication =
new TestingAuthenticationToken("username", "password", "ROLE_USER");
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);

【示例】访问认证信息

认证基本流程

AbstractAuthenticationProcessingFilter 用作验证用户凭据的基本过滤器。 在对凭证进行身份验证之前,Spring Security 通常使用 AuthenticationEntryPoint 请求凭证。

abstractauthenticationprocessingfilter

  • (1)当用户提交其凭据时,AbstractAuthenticationProcessingFilter 从要验证的 HttpServletRequest 创建一个 Authentication。创建的身份验证类型取决于 AbstractAuthenticationProcessingFilter 的子类。例如,UsernamePasswordAuthenticationFilter 根据在 HttpServletRequest 中提交的用户名和密码来创建 UsernamePasswordAuthenticationToken
  • (2)接下来,将身份验证传递到 AuthenticationManager 进行身份验证。
  • (3)如果身份验证失败,则认证失败
    • 清除 SecurityContextHolder
    • 调用 RememberMeServices.loginFail。如果没有配置 remember me,则为空。
    • 调用 AuthenticationFailureHandler
  • (4)如果身份验证成功,则认证成功。
    • 如果是新的登录,则通知 SessionAuthenticationStrategy
    • 身份验证是在 SecurityContextHolder 上设置的。之后,SecurityContextPersistenceFilterSecurityContext 保存到 HttpSession 中。
    • 调用 RememberMeServices.loginSuccess。如果没有配置 remember me,则为空。
    • ApplicationEventPublisher 发布一个 InteractiveAuthenticationSuccessEvent

用户名/密码认证

读取用户名和密码的方式:

  • 表单
  • 基本认证
  • 数字认证

存储机制

表单认证

spring security 支持通过从 html 表单获取登录时提交的用户名、密码。

loginurlauthenticationentrypoint

一旦,登录信息被提交,UsernamePasswordAuthenticationFilter 就会验证用户名和密码。

usernamepasswordauthenticationfilter

基本认证

1
2
3
4
5
protected void configure(HttpSecurity http) {
http
// ...
.httpBasic(withDefaults());
}

内存认证

InMemoryUserDetailsManager 实现了 UserDetailsService ,提供了基本的用户名、密码认证,其认证数据存储在内存中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Bean
public UserDetailsService users() {
// The builder will ensure the passwords are encoded before saving in memory
UserBuilder users = User.withDefaultPasswordEncoder();
UserDetails user = users
.username("user")
.password("password")
.roles("USER")
.build();
UserDetails user = users
.username("admin")
.password("password")
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}

JDBC 认证

JdbcUserDetailsManager 实现了 UserDetailsService ,提供了基本的用户名、密码认证,其认证数据存储在关系型数据库中,通过 JDBC 方式访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Bean
UserDetailsManager users(DataSource dataSource) {
UserDetails user = User.builder()
.username("user")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("USER")
.build();
UserDetails admin = User.builder()
.username("admin")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("USER", "ADMIN")
.build();
JdbcUserDetailsManager users = new JdbcUserDetailsManager(dataSource);
users.createUser()
}

基本的 scheam:

1
2
3
4
5
6
7
8
9
10
11
12
create table users(
username varchar_ignorecase(50) not null primary key,
password varchar_ignorecase(50) not null,
enabled boolean not null
);

create table authorities (
username varchar_ignorecase(50) not null,
authority varchar_ignorecase(50) not null,
constraint fk_authorities_users foreign key(username) references users(username)
);
create unique index ix_auth_username on authorities (username,authority);

UserDetailsService

UserDetailsUserDetailsService 返回。 DaoAuthenticationProvider 验证 UserDetails,然后返回身份验证,该身份验证的主体是已配置的 UserDetailsService 返回的 UserDetails

DaoAuthenticationProvider 使用 UserDetailsService 检索用户名,密码和其他用于使用用户名和密码进行身份验证的属性。 Spring Security 提供 UserDetailsService 的内存中和 JDBC 实现。

您可以通过将自定义 UserDetailsService 公开为 bean 来定义自定义身份验证。

PasswordEncoder

Spring Security 的 servlet 支持通过与 PasswordEncoder 集成来安全地存储密码。 可以通过公开一个 PasswordEncoder Bean 来定制 Spring Security 使用的 PasswordEncoder 实现。

daoauthenticationprovider

Remember-Me

Spring Boot 集成

@EnableWebSecurity@Configuration 注解一起使用, 注解 WebSecurityConfigurer 类型的类。

或者利用@EnableWebSecurity注解继承 WebSecurityConfigurerAdapter 的类,这样就构成了 Spring Security 的配置。

  • configure(WebSecurity):通过重载该方法,可配置 Spring Security 的 Filter 链。
  • configure(HttpSecurity):通过重载该方法,可配置如何通过拦截器保护请求。

参考资料

Netty 快速入门

Netty 简介

Netty 是一款基于 NIO(Nonblocking I/O,非阻塞 IO)开发的网络通信框架

Netty 的特性

  • 高并发:Netty 是一款基于 NIO(Nonblocking IO,非阻塞 IO)开发的网络通信框架,对比于 BIO(Blocking I/O,阻塞 IO),他的并发性能得到了很大提高。
  • 传输快:Netty 的传输依赖于内存零拷贝特性,尽量减少不必要的内存拷贝,实现了更高效率的传输。
  • 封装好:Netty 封装了 NIO 操作的很多细节,提供了易于使用调用接口。

核心组件

  • Channel:Netty 网络操作抽象类,它除了包括基本的 I/O 操作,如 bind、connect、read、write 等。
  • EventLoop:主要是配合 Channel 处理 I/O 操作,用来处理连接的生命周期中所发生的事情。
  • ChannelFuture:Netty 框架中所有的 I/O 操作都为异步的,因此我们需要 ChannelFuture 的 addListener()注册一个 ChannelFutureListener 监听事件,当操作执行成功或者失败时,监听就会自动触发返回结果。
  • ChannelHandler:充当了所有处理入站和出站数据的逻辑容器。ChannelHandler 主要用来处理各种事件,这里的事件很广泛,比如可以是连接、数据接收、异常、数据转换等。
  • ChannelPipeline:为 ChannelHandler 链提供了容器,当 channel 创建时,就会被自动分配到它专属的 ChannelPipeline,这个关联是永久性的。

Netty 有两种发送消息的方式:

  • 直接写入 Channel 中,消息从 ChannelPipeline 当中尾部开始移动;
  • 写入和 ChannelHandler 绑定的 ChannelHandlerContext 中,消息从 ChannelPipeline 中的下一个 ChannelHandler 中移动。

高性能

Netty 高性能表现在哪些方面:

  • NIO 线程模型:同步非阻塞,用最少的资源做更多的事。
  • 内存零拷贝:尽量减少不必要的内存拷贝,实现了更高效率的传输。
  • 内存池设计:申请的内存可以重用,主要指直接内存。内部实现是用一颗二叉查找树管理内存分配情况。
  • 串形化处理读写:避免使用锁带来的性能开销。
  • 高性能序列化协议:支持 protobuf 等高性能序列化协议。

零拷贝

传统意义的拷贝

是在发送数据的时候,传统的实现方式是:

File.read(bytes)

Socket.send(bytes)

这种方式需要四次数据拷贝和四次上下文切换:

  1. 数据从磁盘读取到内核的 read buffer

  2. 数据从内核缓冲区拷贝到用户缓冲区

  3. 数据从用户缓冲区拷贝到内核的 socket buffer

  4. 数据从内核的 socket buffer 拷贝到网卡接口(硬件)的缓冲区

零拷贝的概念

明显上面的第二步和第三步是非必要的,通过 java 的 FileChannel.transferTo 方法,可以避免上面两次多余的拷贝(当然这需要底层操作系统支持)

  • 调用 transferTo,数据从文件由 DMA 引擎拷贝到内核 read buffer
  • 接着 DMA 从内核 read buffer 将数据拷贝到网卡接口 buffer

上面的两次操作都不需要 CPU 参与,所以就达到了零拷贝。

Netty 中的零拷贝

主要体现在三个方面:

bytebuffer

Netty 发送和接收消息主要使用 bytebuffer,bytebuffer 使用对外内存(DirectMemory)直接进行 Socket 读写。

原因:如果使用传统的堆内存进行 Socket 读写,JVM 会将堆内存 buffer 拷贝一份到直接内存中然后再写入 socket,多了一次缓冲区的内存拷贝。DirectMemory 中可以直接通过 DMA 发送到网卡接口

Composite Buffers

传统的 ByteBuffer,如果需要将两个 ByteBuffer 中的数据组合到一起,我们需要首先创建一个 size=size1+size2 大小的新的数组,然后将两个数组中的数据拷贝到新的数组中。但是使用 Netty 提供的组合 ByteBuf,就可以避免这样的操作,因为 CompositeByteBuf 并没有真正将多个 Buffer 组合起来,而是保存了它们的引用,从而避免了数据的拷贝,实现了零拷贝。

对于 FileChannel.transferTo 的使用

Netty 中使用了 FileChannel 的 transferTo 方法,该方法依赖于操作系统实现零拷贝。

Netty 流程

应用

Netty 是一个广泛使用的 Java 网络编程框架。很多著名软件都使用了它,如:Dubbo、Cassandra、Elasticsearch、Vert.x 等。

有了 Netty,你可以实现自己的 HTTP 服务器,FTP 服务器,UDP 服务器,RPC 服务器,WebSocket 服务器,Redis 的 Proxy 服务器,MySQL 的 Proxy 服务器等等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class NettyOioServer {

public void server(int port) throws Exception {
final ByteBuf buf = Unpooled.unreleasableBuffer(
Unpooled.copiedBuffer("Hi!\r\n", Charset.forName("UTF-8")));
EventLoopGroup group = new OioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap(); //1

b.group(group) //2
.channel(OioServerSocketChannel.class)
.localAddress(new InetSocketAddress(port))
.childHandler(new ChannelInitializer<SocketChannel>() {//3
@Override
public void initChannel(SocketChannel ch)
throws Exception {
ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { //4
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.writeAndFlush(buf.duplicate()).addListener(ChannelFutureListener.CLOSE);//5
}
});
}
});
ChannelFuture f = b.bind().sync(); //6
f.channel().closeFuture().sync();
} finally {
group.shutdownGracefully().sync(); //7
}
}
}

参考资料

Java 缓存中间件

关键词:Spring Cache、J2Cache、JetCache

一 、JSR 107

JSR107 中制订了 Java 缓存的规范。

因此,在很多缓存框架、缓存库中,其 API 都参考了 JSR 107 规范。

img

Java Caching 定义了 5 个核心接口

  • CachingProvider - 定义了创建、配置、获取、管理和控制多个 CacheManager。一个应用可以在运行期访问多个 CachingProvider
  • CacheManager - 定义了创建、配置、获取、管理和控制多个唯一命名的 Cache,这些 Cache 存在于 CacheManager 的上下文中。一个 CacheManager 仅被一个 CachingProvider 所拥有。
  • Cache - 是一个类似 Map 的数据结构并临时存储以 Key 为索引的值。一个 Cache 仅被一个 CacheManager 所拥有。
  • Entry - 是一个存储在 Cache 中的 key-value 对。
  • Expiry - 每一个存储在 Cache 中的条目有一个定义的有效期,即 Expiry Duration。一旦超过这个时间,条目为过期的状态。一旦过期,条目将不可访问、更新和删除。缓存有效期可以通过 ExpiryPolicy 设置。

二、Spring Cache

详见:Spring Cache 官方文档

Spring 作为 Java 开发最著名的框架,也提供了缓存功能的框架—— Spring Cache。

Spring 支持基于注释(annotation)的缓存(cache)技术,它本质上不是一个具体的缓存实现方案(例如:EHCache 或 OSCache),而是一个对缓存使用的抽象,通过在既有代码中添加少量它定义的各种 annotation,即能够达到缓存方法的返回对象的效果。

Spring Cache 的特点:

  • 通过缓存注解即可支持缓存功能
  • 支持 Spring EL 表达式
  • 支持 AspectJ
  • 支持自定义 key 和缓存管理

开启缓存注解

Spring 为缓存功能提供了注解功能,但是你必须启动注解。

有两种方式:

(一)使用标记注解 @EnableCaching

这种方式对于 Spring 或 Spring Boot 项目都适用。

1
2
3
4
@Configuration
@EnableCaching
public class AppConfig {
}

(二)在 xml 中声明

1
<cache:annotation-driven cache-manager="cacheManager"/>

spring 缓存注解 API

Spring 对缓存的支持类似于对事务的支持。

首先使用注解标记方法,相当于定义了切点,然后使用 Aop 技术在这个方法的调用前、调用后获取方法的入参和返回值,进而实现了缓存的逻辑。

@Cacheable

@Cacheable 用于触发缓存

表明所修饰的方法是可以缓存的:当第一次调用这个方法时,它的结果会被缓存下来,在缓存的有效时间内,以后访问这个方法都直接返回缓存结果,不再执行方法中的代码段。

这个注解可以用condition属性来设置条件,如果不满足条件,就不使用缓存能力,直接执行方法。

可以使用key属性来指定 key 的生成规则。

@CachePut

@CachePut 用于更新缓存

@Cacheable不同,@CachePut不仅会缓存方法的结果,还会执行方法的代码段。

它支持的属性和用法都与@Cacheable一致。

@CacheEvict

@CacheEvict 用于清除缓存

@Cacheable功能相反,@CacheEvict表明所修饰的方法是用来删除失效或无用的缓存数据。

下面是@Cacheable@CacheEvict@CachePut基本使用方法的一个集中展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Service
public class UserService {
// @Cacheable可以设置多个缓存,形式如:@Cacheable({"books", "isbns"})
@Cacheable(value={"users"}, key="#user.id")
public User findUser(User user) {
return findUserInDB(user.getId());
}

@Cacheable(value = "users", condition = "#user.getId() <= 2")
public User findUserInLimit(User user) {
return findUserInDB(user.getId());
}

@CachePut(value = "users", key = "#user.getId()")
public void updateUser(User user) {
updateUserInDB(user);
}

@CacheEvict(value = "users")
public void removeUser(User user) {
removeUserInDB(user.getId());
}

@CacheEvict(value = "users", allEntries = true)
public void clear() {
removeAllInDB();
}
}

@Caching

@Caching 用于组合定义多种缓存功能

如果需要使用同一个缓存注解(@Cacheable@CacheEvict@CachePut)多次修饰一个方法,就需要用到@Caching

1
2
@Caching(evict = { @CacheEvict("primary"), @CacheEvict(cacheNames="secondary", key="#p0") })
public Book importBooks(String deposit, Date date)

@CacheConfig

@CacheConfig 用于定义公共缓存配置

与前面的缓存注解不同,这是一个类级别的注解。

如果类的所有操作都是缓存操作,你可以使用@CacheConfig来指定类,省去一些配置。

1
2
3
4
5
@CacheConfig("books")
public class BookRepositoryImpl implements BookRepository {
@Cacheable
public Book findBook(ISBN isbn) {...}
}

三、Spring Boot Cache

详见:Spring Boot Cache 特性官方文档

Spring Boot Cache 是在 Spring Cache 的基础上做了封装,使得使用更为便捷。

Spring Boot Cache 快速入门

(1)引入依赖

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>

<!-- 按序引入需要的缓存库 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

(2)缓存配置

例如,选用缓存为 redis,则需要配置 redis 相关的配置项(如:数据源、连接池等配置信息)

1
2
3
4
5
6
7
8
9
10
# 缓存类型,支持类型:GENERIC、JCACHE、EHCACHE、HAZELCAST、INFINISPAN、COUCHBASE、REDIS、CAFFEINE、SIMPLE
spring.cache.type = redis
# 全局缓存时间
spring.cache.redis.time-to-live = 60s

# Redis 配置
spring.redis.database = 0
spring.redis.host = localhost
spring.redis.port = 6379
spring.redis.password =

(3)使用 @EnableCaching 开启缓存

1
2
3
4
5
@EnableCaching
@SpringBootApplication
public class Application {
// ...
}

(4)缓存注解(@Cacheable@CachePut@CacheEvit 等)使用方式与 Spring Cache 完全一样

四、JetCache

JetCache 是一个基于 Java 的缓存系统封装,提供统一的 API 和注解来简化缓存的使用。 JetCache 提供了比 SpringCache 更加强大的注解,可以原生的支持 TTL、两级缓存、分布式自动刷新,还提供了Cache接口用于手工缓存操作。 当前有四个实现,RedisCacheTairCache(此部分未在 github 开源)、CaffeineCache(in memory)和一个简易的LinkedHashMapCache(in memory),要添加新的实现也是非常简单的。

详见:jetcache Github

jetcache 快速入门

如果使用 Spring Boot,可以按如下的方式配置(这里使用了 jedis 客户端连接 redis,如果需要集群、读写分离、异步等特性支持请使用lettuce客户端)。

(1)引入 POM

1
2
3
4
5
<dependency>
<groupId>com.alicp.jetcache</groupId>
<artifactId>jetcache-starter-redis</artifactId>
<version>2.5.14</version>
</dependency>

(2)配置

配置一个 spring boot 风格的 application.yml 文件,把他放到资源目录中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
jetcache:
statIntervalMinutes: 15
areaInCacheName: false
local:
default:
type: linkedhashmap
keyConvertor: fastjson
remote:
default:
type: redis
keyConvertor: fastjson
valueEncoder: java
valueDecoder: java
poolConfig:
minIdle: 5
maxIdle: 20
maxTotal: 50
host: 127.0.0.1
port: 6379

(3)开启缓存

然后创建一个 App 类放在业务包的根下,EnableMethodCache,EnableCreateCacheAnnotation 这两个注解分别激活 Cached 和 CreateCache 注解,其他和标准的 Spring Boot 程序是一样的。这个类可以直接 main 方法运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.company.mypackage;

import com.alicp.jetcache.anno.config.EnableCreateCacheAnnotation;
import com.alicp.jetcache.anno.config.EnableMethodCache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@EnableMethodCache(basePackages = "com.company.mypackage")
@EnableCreateCacheAnnotation
public class MySpringBootApp {
public static void main(String[] args) {
SpringApplication.run(MySpringBootApp.class);
}
}

(4)API 基本使用

创建缓存实例

通过 @CreateCache 注解创建一个缓存实例,默认超时时间是 100 秒

1
2
@CreateCache(expire = 100)
private Cache<Long, UserDO> userCache;

用起来就像 map 一样

1
2
3
UserDO user = userCache.get(123L);
userCache.put(123L, user);
userCache.remove(123L);

创建一个两级(内存+远程)的缓存,内存中的元素个数限制在 50 个。

1
2
@CreateCache(name = "UserService.userCache", expire = 100, cacheType = CacheType.BOTH, localLimit = 50)
private Cache<Long, UserDO> userCache;

name 属性不是必须的,但是起个名字是个好习惯,展示统计数据的使用,会使用这个名字。如果同一个 area 两个 @CreateCache 的 name 配置一样,它们生成的 Cache 将指向同一个实例。

创建方法缓存

使用 @Cached 方法可以为一个方法添加上缓存。JetCache 通过 Spring AOP 生成代理,来支持缓存功能。注解可以加在接口方法上也可以加在类方法上,但需要保证是个 Spring bean。

1
2
3
4
public interface UserService {
@Cached(name="UserService.getUserById", expire = 3600)
User getUserById(long userId);
}

五、j2cache

六、总结

使用缓存框架,使得开发缓存功能非常便捷。

如果你的系统只需要使用一种缓存,那么推荐使用 Spring Boot Cache。Spring Boot Cache 在 Spring Cache 基础上做了封装,使用更简单、方便。

如果你的系统需要使用多级缓存,那么推荐使用 jetcache。

参考资料

Ehcache 快速入门

EhCache 是一个纯 Java 的进程内缓存框架,具有快速、精干等特点,是 Hibernate 中默认的 CacheProvider。

img

一、简介

Ehcache 虽然也支持分布式模式,但是分布式方案不是很好好,建议只将其作为单机的进程内缓存使用。

Ehcache 特性

优点

  • 快速、简单
  • 支持多种缓存策略:LRU、LFU、FIFO 淘汰算法
  • 缓存数据有两级:内存和磁盘,因此无需担心容量问题
  • 缓存数据会在虚拟机重启的过程中写入磁盘
  • 可以通过 RMI、可插入 API 等方式进行分布式缓存
  • 具有缓存和缓存管理器的侦听接口
  • 支持多缓存管理器实例,以及一个实例的多个缓存区域
  • 提供 Hibernate 的缓存实现

缺点

  • 使用磁盘 Cache 的时候非常占用磁盘空间
  • 不保证数据的安全
  • 虽然支持分布式缓存,但效率不高(通过组播方式,在不同节点之间同步数据)。

Ehcache 集群

Ehcache 目前支持五种集群方式:

  • RMI
  • JMS
  • JGroup
  • Terracotta
  • Ehcache Server

RMI

使用组播方式通知所有节点同步数据。

如果网络有问题,或某台服务宕机,则存在数据无法同步的可能,导致数据不一致。

Ehcache Image

JMS

JMS 类似 MQ,所有节点订阅消息,当某节点缓存发生变化,就向 JMS 发消息,其他节点感知变化后,同步数据。

Ehcache Image

Cache Server

Ehcache Image

二、快速入门

引入 Ehcache

如果你的项目使用 maven 管理,添加以下依赖到你的 pom.xml 中。

1
2
3
4
5
6
<dependency>
<groupId>net.sf.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>2.10.2</version>
<type>pom</type>
</dependency>

如果你的项目不使用 maven 管理,请在 Ehcache 官网下载地址 下载 jar 包。

Spring 提供了对于 Ehcache 接口的封装,可以更简便的使用其功能。接入方式如下:

如果你的项目使用 maven 管理,添加以下依赖到你的pom.xml中。

spring-context-support这个 jar 包中含有 Spring 对于缓存功能的抽象封装接口。

1
2
3
4
5
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
<version>4.1.4.RELEASE</version>
</dependency>

添加配置文件

(1)在 classpath 下添加 ehcache.xml
添加一个名为 helloworld 的缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd">

<!-- 磁盘缓存位置 -->
<diskStore path="java.io.tmpdir/ehcache"/>

<!-- 默认缓存 -->
<defaultCache
maxEntriesLocalHeap="10000"
eternal="false"
timeToIdleSeconds="120"
timeToLiveSeconds="120"
maxEntriesLocalDisk="10000000"
diskExpiryThreadIntervalSeconds="120"
memoryStoreEvictionPolicy="LRU"/>

<!-- helloworld缓存 -->
<cache name="helloworld"
maxElementsInMemory="1000"
eternal="false"
timeToIdleSeconds="5"
timeToLiveSeconds="5"
overflowToDisk="false"
memoryStoreEvictionPolicy="LRU"/>
</ehcache>

Ehcache 工作示例

Ehcache 会自动加载 classpath 根目录下名为 ehcache.xml 文件。

EhcacheDemo 的工作步骤如下:

  1. 在 EhcacheDemo 中,我们引用 ehcache.xml 声明的名为 helloworld 的缓存来创建Cache对象;
  2. 然后我们用一个键值对来实例化Element对象;
  3. Element对象添加到Cache
  4. 然后用Cache的 get 方法获取Element对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class EhcacheDemo {
public static void main(String[] args) throws Exception {
// Create a cache manager
final CacheManager cacheManager = new CacheManager();

// create the cache called "helloworld"
final Cache cache = cacheManager.getCache("helloworld");

// create a key to map the data to
final String key = "greeting";

// Create a data element
final Element putGreeting = new Element(key, "Hello, World!");

// Put the element into the data store
cache.put(putGreeting);

// Retrieve the data element
final Element getGreeting = cache.get(key);

// Print the value
System.out.println(getGreeting.getObjectValue());
}
}

输出

1
Hello, World!

三、Ehcache API

ElementCacheCacheManager是 Ehcache 最重要的 API。

  • Element - 缓存的元素,它维护着一个键值对。
  • Cache - 它是 Ehcache 的核心类,它有多个Element,并被CacheManager管理。它实现了对缓存的逻辑行为。
  • CacheManager - Cache的容器对象,并管理着Cache的生命周期。CacheManager 支持两种创建模式:单例(Singleton mode)和实例(InstanceMode)。

创建 CacheManager

下面的代码列举了创建 CacheManager 的五种方式。

使用静态方法create()会以默认配置来创建单例的CacheManager实例。

newInstance()方法是一个工厂方法,以默认配置创建一个新的CacheManager实例。

此外,newInstance()还有几个重载函数,分别可以通过传入StringURLInputStream参数来加载配置文件,然后创建CacheManager实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 使用Ehcache默认配置获取单例的CacheManager实例
CacheManager.create();
String[] cacheNames = CacheManager.getInstance().getCacheNames();

// 使用Ehcache默认配置新建一个CacheManager实例
CacheManager.newInstance();
String[] cacheNames = manager.getCacheNames();

// 使用不同的配置文件分别创建一个CacheManager实例
CacheManager manager1 = CacheManager.newInstance("src/config/ehcache1.xml");
CacheManager manager2 = CacheManager.newInstance("src/config/ehcache2.xml");
String[] cacheNamesForManager1 = manager1.getCacheNames();
String[] cacheNamesForManager2 = manager2.getCacheNames();

// 基于classpath下的配置文件创建CacheManager实例
URL url = getClass().getResource("/anotherconfigurationname.xml");
CacheManager manager = CacheManager.newInstance(url);

// 基于文件流得到配置文件,并创建CacheManager实例
InputStream fis = new FileInputStream(new File
("src/config/ehcache.xml").getAbsolutePath());
try {
CacheManager manager = CacheManager.newInstance(fis);
} finally {
fis.close();
}

添加缓存

需要强调一点,Cache对象在用addCache方法添加到CacheManager之前,是无效的。

使用 CacheManager 的 addCache 方法可以根据缓存名将 ehcache.xml 中声明的 cache 添加到容器中;它也可以直接将 Cache 对象添加到缓存容器中。

Cache有多个构造函数,提供了不同方式去加载缓存的配置参数。

有时候,你可能需要使用 API 来动态的添加缓存,下面的例子就提供了这样的范例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 除了可以使用xml文件中配置的缓存,你也可以使用API动态增删缓存
// 添加缓存
manager.addCache(cacheName);

// 使用默认配置添加缓存
CacheManager singletonManager = CacheManager.create();
singletonManager.addCache("testCache");
Cache test = singletonManager.getCache("testCache");

// 使用自定义配置添加缓存,注意缓存未添加进CacheManager之前并不可用
CacheManager singletonManager = CacheManager.create();
Cache memoryOnlyCache = new Cache("testCache", 5000, false, false, 5, 2);
singletonManager.addCache(memoryOnlyCache);
Cache test = singletonManager.getCache("testCache");

// 使用特定的配置添加缓存
CacheManager manager = CacheManager.create();
Cache testCache = new Cache(
new CacheConfiguration("testCache", maxEntriesLocalHeap)
.memoryStoreEvictionPolicy(MemoryStoreEvictionPolicy.LFU)
.eternal(false)
.timeToLiveSeconds(60)
.timeToIdleSeconds(30)
.diskExpiryThreadIntervalSeconds(0)
.persistence(new PersistenceConfiguration().strategy(Strategy.LOCALTEMPSWAP)));
manager.addCache(testCache);

删除缓存

删除缓存比较简单,你只需要将指定的缓存名传入removeCache方法即可。

1
2
CacheManager singletonManager = CacheManager.create();
singletonManager.removeCache("sampleCache1");

基本缓存操作

Cache 最重要的两个方法就是 put 和 get,分别用来添加 Element 和获取 Element。

Cache 还提供了一系列的 get、set 方法来设置或获取缓存参数,这里不一一列举,更多 API 操作可参考官方 API 开发手册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/**
* 测试:使用默认配置或使用指定配置来创建CacheManager
*
* @author Zhang Peng
*/
public class CacheOperationTest {
private final Logger log = LoggerFactory.getLogger(CacheOperationTest.class);

/**
* 使用Ehcache默认配置(classpath下的ehcache.xml)获取单例的CacheManager实例
*/
@Test
public void operation() {
CacheManager manager = CacheManager.newInstance("src/test/resources/ehcache/ehcache.xml");

// 获得Cache的引用
Cache cache = manager.getCache("userCache");

// 将一个Element添加到Cache
cache.put(new Element("key1", "value1"));

// 获取Element,Element类支持序列化,所以下面两种方法都可以用
Element element1 = cache.get("key1");
// 获取非序列化的值
log.debug("key:{}, value:{}", element1.getObjectKey(), element1.getObjectValue());
// 获取序列化的值
log.debug("key:{}, value:{}", element1.getKey(), element1.getValue());

// 更新Cache中的Element
cache.put(new Element("key1", "value2"));
Element element2 = cache.get("key1");
log.debug("key:{}, value:{}", element2.getObjectKey(), element2.getObjectValue());

// 获取Cache的元素数
log.debug("cache size:{}", cache.getSize());

// 获取MemoryStore的元素数
log.debug("MemoryStoreSize:{}", cache.getMemoryStoreSize());

// 获取DiskStore的元素数
log.debug("DiskStoreSize:{}", cache.getDiskStoreSize());

// 移除Element
cache.remove("key1");
log.debug("cache size:{}", cache.getSize());

// 关闭当前CacheManager对象
manager.shutdown();

// 关闭CacheManager单例实例
CacheManager.getInstance().shutdown();
}
}

四、Ehcache 配置

Ehcache 支持通过 xml 文件和 API 两种方式进行配置。

详情参考:Ehcache 官方 XML 配置手册

xml 配置方式

Ehcache 的CacheManager构造函数或工厂方法被调用时,会默认加载 classpath 下名为ehcache.xml的配置文件。如果加载失败,会加载 Ehcache jar 包中的ehcache-failsafe.xml文件,这个文件中含有简单的默认配置。
ehcache.xml 配置参数说明:

  • name:缓存名称。
  • maxElementsInMemory:缓存最大个数。
  • eternal:缓存中对象是否为永久的,如果是,超时设置将被忽略,对象从不过期。
  • timeToIdleSeconds:置对象在失效前的允许闲置时间(单位:秒)。仅当 eternal=false 对象不是永久有效时使用,可选属性,默认值是 0,也就是可闲置时间无穷大。
  • timeToLiveSeconds:缓存数据的生存时间(TTL),也就是一个元素从构建到消亡的最大时间间隔值,这只能在元素不是永久驻留时有效,如果该值是 0 就意味着元素可以停顿无穷长的时间。
  • maxEntriesLocalDisk:当内存中对象数量达到 maxElementsInMemory 时,Ehcache 将会对象写到磁盘中。
  • overflowToDisk:内存不足时,是否启用磁盘缓存。
  • diskSpoolBufferSizeMB:这个参数设置 DiskStore(磁盘缓存)的缓存区大小。默认是 30MB。每个 Cache 都应该有自己的一个缓冲区。
  • maxElementsOnDisk:硬盘最大缓存个数。
  • diskPersistent:是否在 VM 重启时存储硬盘的缓存数据。默认值是 false。
  • diskExpiryThreadIntervalSeconds:磁盘失效线程运行时间间隔,默认是 120 秒。
  • memoryStoreEvictionPolicy:当达到 maxElementsInMemory 限制时,Ehcache 将会根据指定的策略去清理内存。默认策略是 LRU(最近最少使用)。你可以设置为 FIFO(先进先出)或是 LFU(较少使用)。
  • clearOnFlush:内存数量最大时是否清除。

API 配置方式

xml 配置的参数也可以直接通过编程方式来动态的进行配置(dynamicConfig 没有设为 false)。

1
2
3
4
5
6
Cache cache = manager.getCache("sampleCache");
CacheConfiguration config = cache.getCacheConfiguration();
config.setTimeToIdleSeconds(60);
config.setTimeToLiveSeconds(120);
config.setmaxEntriesLocalHeap(10000);
config.setmaxEntriesLocalDisk(1000000);

也可以通过disableDynamicFeatures()方式关闭动态配置开关。配置以后你将无法再以编程方式配置参数。

1
2
Cache cache = manager.getCache("sampleCache");
cache.disableDynamicFeatures();

五、Spring 集成 Ehcache

Spring3.1 开始添加了对缓存的支持。和事务功能的支持方式类似,缓存抽象允许底层使用不同的缓存解决方案来进行整合。

Spring4.1 开始支持 JSR-107 注解。

注:我本人使用的 Spring 版本为 4.1.4.RELEASE,目前 Spring 版本仅支持 Ehcache2.5 以上版本,但不支持 Ehcache3。

绑定 Ehcache

org.springframework.cache.ehcache.EhCacheManagerFactoryBean这个类的作用是加载 Ehcache 配置文件。
org.springframework.cache.ehcache.EhCacheCacheManager这个类的作用是支持 net.sf.ehcache.CacheManager。

spring-ehcache.xml的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:cache="http://www.springframework.org/schema/cache"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
http://www.springframework.org/schema/cache
http://www.springframework.org/schema/cache/spring-cache-3.2.xsd">

<description>ehcache缓存配置管理文件</description>

<bean id="ehcache" class="org.springframework.cache.ehcache.EhCacheManagerFactoryBean">
<property name="configLocation" value="classpath:ehcache/ehcache.xml"/>
</bean>

<bean id="cacheManager" class="org.springframework.cache.ehcache.EhCacheCacheManager">
<property name="cacheManager" ref="ehcache"/>
</bean>

<!-- 启用缓存注解开关 -->
<cache:annotation-driven cache-manager="cacheManager"/>
</beans>

使用 Spring 的缓存注解

开启注解

Spring 为缓存功能提供了注解功能,但是你必须启动注解。
你有两个选择:
(1) 在 xml 中声明
像上一节 spring-ehcache.xml 中的做法一样,使用<cache:annotation-driven/>

1
<cache:annotation-driven cache-manager="cacheManager"/>

(2) 使用标记注解
你也可以通过对一个类进行注解修饰的方式在这个类中使用缓存注解。
范例如下:

1
2
3
4
@Configuration
@EnableCaching
public class AppConfig {
}

注解基本使用方法

Spring 对缓存的支持类似于对事务的支持。
首先使用注解标记方法,相当于定义了切点,然后使用 Aop 技术在这个方法的调用前、调用后获取方法的入参和返回值,进而实现了缓存的逻辑。
下面三个注解都是方法级别:

@Cacheable

表明所修饰的方法是可以缓存的:当第一次调用这个方法时,它的结果会被缓存下来,在缓存的有效时间内,以后访问这个方法都直接返回缓存结果,不再执行方法中的代码段。
这个注解可以用condition属性来设置条件,如果不满足条件,就不使用缓存能力,直接执行方法。
可以使用key属性来指定 key 的生成规则。

@CachePut

@Cacheable不同,@CachePut不仅会缓存方法的结果,还会执行方法的代码段。
它支持的属性和用法都与@Cacheable一致。

@CacheEvict

@Cacheable功能相反,@CacheEvict表明所修饰的方法是用来删除失效或无用的缓存数据。
下面是@Cacheable@CacheEvict@CachePut基本使用方法的一个集中展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Service
public class UserService {
// @Cacheable可以设置多个缓存,形式如:@Cacheable({"books", "isbns"})
@Cacheable(value={"users"}, key="#user.id")
public User findUser(User user) {
return findUserInDB(user.getId());
}

@Cacheable(value = "users", condition = "#user.getId() <= 2")
public User findUserInLimit(User user) {
return findUserInDB(user.getId());
}

@CachePut(value = "users", key = "#user.getId()")
public void updateUser(User user) {
updateUserInDB(user);
}

@CacheEvict(value = "users")
public void removeUser(User user) {
removeUserInDB(user.getId());
}

@CacheEvict(value = "users", allEntries = true)
public void clear() {
removeAllInDB();
}
}

@Caching

如果需要使用同一个缓存注解(@Cacheable@CacheEvict@CachePut)多次修饰一个方法,就需要用到@Caching

1
2
@Caching(evict = { @CacheEvict("primary"), @CacheEvict(cacheNames="secondary", key="#p0") })
public Book importBooks(String deposit, Date date)

@CacheConfig

与前面的缓存注解不同,这是一个类级别的注解。
如果类的所有操作都是缓存操作,你可以使用@CacheConfig来指定类,省去一些配置。

1
2
3
4
5
@CacheConfig("books")
public class BookRepositoryImpl implements BookRepository {
@Cacheable
public Book findBook(ISBN isbn) {...}
}

参考资料

Java 进程内缓存

关键词:ConcurrentHashMap、LRUHashMap、Guava Cache、Caffeine、Ehcache

一、ConcurrentHashMap

最简单的进程内缓存可以通过 JDK 自带的 HashMapConcurrentHashMap 实现。

适用场景:不需要淘汰的缓存数据

缺点:无法进行缓存淘汰,内存会无限制的增长。

二、LRUHashMap

可以通过**继承 LinkedHashMap 来实现一个简单的 LRUHashMap**,即可完成一个简单的 LRU (最近最少使用)算法。

缺点:

  • 锁竞争严重,性能比较低。
  • 不支持过期时间
  • 不支持自动刷新

【示例】LRUHashMap 的简单实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class LRUCache extends LinkedHashMap {

private final int max;
private Object lock;

public LRUCache(int max) {
//无需扩容
super((int) (max * 1.4f), 0.75f, true);
this.max = max;
this.lock = new Object();
}

/**
* 重写LinkedHashMap的removeEldestEntry方法即可 在Put的时候判断,如果为true,就会删除最老的
*
* @param eldest
* @return
*/
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > max;
}

public Object getValue(Object key) {
synchronized (lock) {
return get(key);
}
}

public void putValue(Object key, Object value) {
synchronized (lock) {
put(key, value);
}
}

public boolean removeValue(Object key) {
synchronized (lock) {
return remove(key) != null;
}
}

public boolean removeAll() {
clear();
return true;
}

}

三、Guava Cache

Guava Cache 解决了 LRUHashMap 中的几个缺点。

Guava Cache 提供了基于容量,时间和引用的缓存回收方式。基于容量的方式内部实现采用 LRU 算法,基于引用回收很好的利用了 Java 虚拟机的垃圾回收机制。

其中的缓存构造器 CacheBuilder 采用构建者模式提供了设置好各种参数的缓存对象。缓存核心类 LocalCache 里面的内部类 Segment 与 jdk1.7 及以前的 ConcurrentHashMap 非常相似,分段加锁,减少锁竞争,并且都继承于 ReetrantLock,还有六个队列,以实现丰富的本地缓存方案。Guava Cache 对于过期的 Entry 并没有马上过期(也就是并没有后台线程一直在扫),而是通过进行读写操作的时候进行过期处理,这样做的好处是避免后台线程扫描的时候进行全局加锁。

直接通过查询,判断其是否满足刷新条件,进行刷新。

Guava Cache 缓存回收

Guava Cache 提供了三种基本的缓存回收方式。

基于容量回收

maximumSize(long):当缓存中的元素数量超过指定值时触发回收。

基于定时回收

  • expireAfterAccess(long, TimeUnit):缓存项在给定时间内没有被读/写访问,则回收。请注意这种缓存的回收顺序和基于大小回收一样。
  • expireAfterWrite(long, TimeUnit):缓存项在给定时间内没有被写访问(创建或覆盖),则回收。如果认为缓存数据总是在固定时候后变得陈旧不可用,这种回收方式是可取的。

如下文所讨论,定时回收周期性地在写操作中执行,偶尔在读操作中执行。

基于引用回收

  • CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。
  • CacheBuilder.weakValues():使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收。
  • CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。

Guava Cache 核心 API

CacheBuilder

缓存构建器。构建缓存的入口,指定缓存配置参数并初始化本地缓存。
主要采用 builder 的模式,CacheBuilder 的每一个方法都返回这个 CacheBuilder 知道 build 方法的调用。
注意 build 方法有重载,带有参数的为构建一个具有数据加载功能的缓存,不带参数的构建一个没有数据加载功能的缓存。

LocalManualCache

作为 LocalCache 的一个内部类,在构造方法里面会把 LocalCache 类型的变量传入,并且调用方法时都直接或者间接调用 LocalCache 里面的方法。

LocalLoadingCache

可以看到该类继承了 LocalManualCache 并实现接口 LoadingCache。
覆盖了 get,getUnchecked 等方法。

LocalCache

Guava Cache 中的核心类,重点了解。

LocalCache 的数据结构与 ConcurrentHashMap 很相似,都由多个 segment 组成,且各 segment 相对独立,互不影响,所以能支持并行操作。每个 segment 由一个 table 和若干队列组成。缓存数据存储在 table 中,其类型为 AtomicReferenceArray。

四、Caffeine

caffeine 是一个使用 JDK8 改进 Guava 缓存的高性能缓存库。

Caffeine 实现了 W-TinyLFU(LFU + LRU 算法的变种),其命中率和读写吞吐量大大优于 Guava Cache

其实现原理较复杂,可以参考你应该知道的缓存进化史

五、Ehcache

参考:Ehcache

六、进程内缓存对比

常用进程内缓存技术对比:

比较项 ConcurrentHashMap LRUMap Ehcache Guava Cache Caffeine
读写性能 很好,分段锁 一般,全局加锁 好,需要做淘汰操作 很好
淘汰算法 LRU,一般 支持多种淘汰算法,LRU,LFU,FIFO LRU,一般 W-TinyLFU, 很好
功能丰富程度 功能比较简单 功能比较单一 功能很丰富 功能很丰富,支持刷新和虚引用等 功能和 Guava Cache 类似
工具大小 jdk 自带类,很小 基于 LinkedHashMap,较小 很大,最新版本 1.4MB 是 Guava 工具类中的一个小部分,较小 一般,最新版本 644KB
是否持久化
是否支持集群
  • ConcurrentHashMap - 比较适合缓存比较固定不变的元素,且缓存的数量较小的。虽然从上面表格中比起来有点逊色,但是其由于是 JDK 自带的类,在各种框架中依然有大量的使用,比如我们可以用来缓存我们反射的 Method,Field 等等;也可以缓存一些链接,防止其重复建立。在 Caffeine 中也是使用的 ConcurrentHashMap 来存储元素。
  • LRUMap - 如果不想引入第三方包,又想使用淘汰算法淘汰数据,可以使用这个。
  • Ehcache - 由于其 jar 包很大,较重量级。对于需要持久化和集群的一些功能的,可以选择 Ehcache。需要注意的是,虽然 Ehcache 也支持分布式缓存,但是由于其节点间通信方式为 rmi,表现不如 Redis,所以一般不建议用它来作为分布式缓存。
  • Guava Cache - Guava 这个 jar 包在很多 Java 应用程序中都有大量的引入,所以很多时候其实是直接用就好了,并且其本身是轻量级的而且功能较为丰富,在不了解 Caffeine 的情况下可以选择 Guava Cache。
  • Caffeine - 其在命中率,读写性能上都比 Guava Cache 好很多,并且其 API 和 Guava cache 基本一致,甚至会多一点。在真实环境中使用 Caffeine,取得过不错的效果。

总结一下:**如果不需要淘汰算法则选择 ConcurrentHashMap,如果需要淘汰算法和一些丰富的 API,推荐选择 Caffeine**。

参考资料

Http 缓存

HTTP 缓存分为 2 种,一种是强缓存,另一种是协商缓存。主要作用是可以加快资源获取速度,提升用户体验,减少网络传输,缓解服务端的压力。

Http 强缓存

不需要发送请求到服务端,直接读取浏览器本地缓存,在 Chrome 的 Network 中显示的 HTTP 状态码是 200 ,在 Chrome 中,强缓存又分为 Disk Cache (存放在硬盘中)和 Memory Cache (存放在内存中),存放的位置是由浏览器控制的。是否强缓存由 ExpiresCache-ControlPragma 3 个 Header 属性共同来控制。

Expires

Expires 的值是一个 HTTP 日期,在浏览器发起请求时,会根据系统时间和 Expires 的值进行比较,如果系统时间超过了 Expires 的值,缓存失效。由于和系统时间进行比较,所以当系统时间和服务器时间不一致的时候,会有缓存有效期不准的问题。Expires 的优先级在三个 Header 属性中是最低的。

Cache-Control

Cache-Control 是 HTTP/1.1 中新增的属性,在请求头和响应头中都可以使用,常用的属性值如有:

  • max-age:单位是秒,缓存时间计算的方式是距离发起的时间的秒数,超过间隔的秒数缓存失效
  • no-cache:不使用强缓存,需要与服务器验证缓存是否新鲜
  • no-store:禁止使用缓存(包括协商缓存),每次都向服务器请求最新的资源
  • private:专用于个人的缓存,中间代理、CDN 等不能缓存此响应
  • public:响应可以被中间代理、CDN 等缓存
  • must-revalidate:在缓存过期前可以使用,过期后必须向服务器验证

Pragma

Pragma 只有一个属性值,就是 no-cache ,效果和 Cache-Control 中的 no-cache 一致,不使用强缓存,需要与服务器验证缓存是否新鲜,在 3 个头部属性中的优先级最高。

协商缓存

当浏览器的强缓存失效的时候或者请求头中设置了不走强缓存,并且在请求头中设置了 If-Modified-Since 或者 If-None-Match 的时候,会将这两个属性值到服务端去验证是否命中协商缓存,如果命中了协商缓存,会返回 304 状态,加载浏览器缓存,并且响应头会设置 Last-Modified 或者 ETag 属性。

ETag/If-None-Match

Etag: 服务器响应请求时,通过此字段告诉浏览器当前资源在服务器生成的唯一标识(生成规则由服务器决定)

If-None-Match: 再次请求服务器时,浏览器的请求报文头部会包含此字段,后面的值为在缓存中获取的标识。服务器接收到次报文后发现 If-None-Match 则与被请求资源的唯一标识进行对比。

  1. 不同,说明资源被改动过,则响应整个资源内容,返回状态码 200。
  2. 相同,说明资源无心修改,则响应 header,浏览器直接从缓存中获取数据信息。返回状态码 304.

但是实际应用中由于 Etag 的计算是使用算法来得出的,而算法会占用服务端计算的资源,所有服务端的资源都是宝贵的,所以就很少使用 Etag 了。

Last-Modified/If-Modified-Since

Last-Modified: 服务器在响应请求时,会告诉浏览器资源的最后修改时间。

if-Modified-Since: 浏览器再次请求服务器的时候,请求头会包含此字段,后面跟着在缓存中获得的最后修改时间。服务端收到此请求头发现有 if-Modified-Since,则与被请求资源的最后修改时间进行对比,如果一致则返回 304 和响应报文头,浏览器只需要从缓存中获取信息即可。 从字面上看,就是说:从某个时间节点算起,是否文件被修改了

  1. 如果真的被修改:那么开始传输响应一个整体,服务器返回:200 OK
  2. 如果没有被修改:那么只需传输响应 header,服务器返回:304 Not Modified

if-Unmodified-Since: 从字面上看, 就是说: 从某个时间点算起, 是否文件没有被修改

  1. 如果没有被修改:则开始`继续’传送文件: 服务器返回: 200 OK
  2. 如果文件被修改:则不传输,服务器返回: 412 Precondition failed (预处理错误)

这两个的区别是一个是修改了才下载一个是没修改才下载。 Last-Modified 说好却也不是特别好,因为如果在服务器上,一个资源被修改了,但其实际内容根本没发生改变,会因为 Last-Modified 时间匹配不上而返回了整个实体给客户端(即使客户端缓存里有个一模一样的资源)。为了解决这个问题,HTTP1.1 推出了 Etag。

参考资料

Hystrix 快速入门

Hystrix 简介

Hystrix 是什么

Hystrix 是由 Netflix 开源,用于处理分布式系统的延迟和容错的一个开源组件。在分布式系统里,许多依赖不可避免的会调用失败,比如超时、异常等。Hystrix 采用断路器模式来实现服务间的彼此隔离,从而避免级联故障,以提高分布式系统整体的弹性。

“断路器”本身是一种开关装置,当某个服务单元发生故障之后,通过断路器的故障监控(类似熔断保险丝),向调用方返回一个符合预期的、可处理的备选响应(FallBack),而不是长时间的等待或者抛出调用方无法处理的异常,这样就保证了服务调用方的线程不会被长时间、不必要地占用,从而避免了故障在分布式系统中的蔓延,乃至雪崩。

Hystrix 官方已宣布不再发布新版本。但是,Hystrix 的断路器设计理念,有非常高的学习价值。

为什么需要 Hystrix

复杂的分布式系统架构中的应用程序往往具有数十个依赖项,每个依赖项都会不可避免地在某个时刻失败。 如果主机应用程序未与这些外部故障隔离开来,则可能会被波及。

例如,对于依赖于 30 个服务的应用程序,假设每个服务的正常运行时间为 99.99%,则可以期望:

99.9930 = 99.7% 的正常运行时间

10 亿个请求中的 0.3%= 3,000,000 个失败

即使所有依赖项都具有出色的正常运行时间,每月也会有 2 个小时以上的停机时间。

然而,现实情况一般比这种估量情况更糟糕。


当一切正常时,整体系统如下所示:

img

在高并发场景,这些依赖的稳定性与否对系统的影响非常大,但是依赖有很多不可控问题:如网络连接、资源繁忙、服务宕机等。例如:下图中有一个 QPS 为 50 的依赖 I 出现不可用,但是其他依赖服务是可用的。

img

但是,在高并发场景下,当依赖 I 阻塞时,大多数服务器的线程池就出现阻塞(BLOCK)。当这种级联故障愈演愈烈,就可能造成整个线上服务不可用的雪崩效应,如下图:

img

Hystrix 就是为了解决这类问题而应运而生。

Hystrix 的功能

Hystrix 具有以下功能:

  • 避免资源耗尽:阻止任何一个依赖服务耗尽所有的资源,比如 tomcat 中的所有线程资源。
  • 避免请求排队和积压:采用限流和 fail fast 来控制故障。
  • 支持降级:提供 fallback 降级机制来应对故障。
  • 资源隔离:比如 bulkhead(舱壁隔离技术)、swimlane(泳道技术)、circuit breaker(断路技术)来限制任何一个依赖服务的故障的影响。
  • 统计/监控/报警:通过近实时的统计/监控/报警功能,来提高故障发现的速度。
  • 通过近实时的属性和配置热修改功能,来提高故障处理和恢复的速度。
  • 保护依赖服务调用的所有故障情况,而不仅仅只是网络故障情况。

如果使用 Hystrix 对每个基础依赖服务进行过载保护,则整个系统架构将会类似下图所示,每个依赖项彼此隔离,受到延迟时发生饱和的资源的被限制访问,并包含 fallback 逻辑(用于降级处理),该逻辑决定了在依赖项中发生任何类型的故障时做出对应的处理。

img

Hystrix 原理

如下图所示,Hystrix 的工作流程大致可以分为 9 个步骤。

img

(一)构建一个 HystrixCommand 或 HystrixObservableCommand 对象

Hystrix 进行资源隔离,其实是提供了一个抽象,叫做命令模式。这也是 Hystrix 最基本的资源隔离技术。

在使用 Hystrix 的过程中,会对依赖服务的调用请求封装成命令对象,Hystrix 对 命令对象抽象了两个抽象类:HystrixCommandHystrixObservableCommand

  • HystrixCommand 表示的命令对象会返回一个唯一返回值。
  • HystrixObservableCommand 表示的命令对象 会返回多个返回值。
1
2
HystrixCommand command = new HystrixCommand(arg1, arg2);
HystrixObservableCommand command = new HystrixObservableCommand(arg1, arg2);

(二)执行命令

Hystrix 中共有 4 种方式执行命令,如下所示:

执行方式 说明 可用对象
execute() 阻塞式同步执行,返回依赖服务的单一返回结果(或者抛出异常) HystrixCommand
queue() 异步执行,通过 Future 返回依赖服务的单一返回结果(或者抛出异常) HystrixCommand
observe() 基于 Rxjava 的 Observable 方式,返回通过 Observable 表示的依赖服务返回结果。代调用代码先执行(Hot Obserable) HystrixObservableCommand
toObservable() 基于 Rxjava 的 Observable 方式,返回通过 Observable 表示的依赖服务返回结果。执行代码等到真正订阅的时候才会执行(cold observable) HystrixObservableCommand

这四种命令中,exeucte()queue()observe() 的表示其实是通过 toObservable() 实现的,其转换关系如下图所示:

img

HystrixCommand 执行方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
K value   = command.execute();
// 等价语句:
K value = command.execute().queue().get();


Future<K> fValue = command.queue();
//等价语句:
Future<K> fValue = command.toObservable().toBlocking().toFuture();


Observable<K> ohValue = command.observe(); //hot observable,立刻订阅,命令立刻执行
//等价语句:
Observable<K> ohValue = command.toObservable().subscribe(subject);

// 上述执行最终实现还是基于 toObservable()
Observable<K> ocValue = command.toObservable(); //cold observable,延后订阅,订阅发生后,执行才真正执行

(三)是否缓存

如果当前命令对象启用了请求缓存,并且请求的响应存在于缓存中,则缓存的响应会立刻以 Observable 的形式返回。

(四)是否开启断路器

如果第三步没有缓存没有命中,则判断一下当前断路器的断路状态是否打开。如果断路器状态为打开状态,则 Hystrix 将不会执行此 Command 命令,直接执行步骤 8 调用 Fallback;

如果断路器状态是关闭,则执行步骤 5 检查是否有足够的资源运行 Command 命令

(五)信号量、线程池是否拒绝

当您执行该命令时,Hystrix 会检查断路器以查看电路是否打开。

如果电路开路(或“跳闸”),则 Hystrix 将不会执行该命令,而是将流程路由到 (8) 获取回退。

如果电路闭合,则流程前进至 (5) 以检查是否有可用容量来运行命令。

如果当前要执行的 Command 命令 先关连的线程池 和队列(或者信号量)资源已经满了,Hystrix 将不会运行 Command 命令,直接执行 步骤 8的 Fallback 降级处理;如果未满,表示有剩余的资源执行 Command 命令,则执行步骤 6

(六)construct() 或 run()

当经过步骤 5 判断,有足够的资源执行 Command 命令时,本步骤将调用 Command 命令运行方法,基于不同类型的 Command,有如下两种两种运行方式:

运行方式 说明
HystrixCommand.run() 返回一个处理结果或者抛出一个异常
HystrixObservableCommand.construct() 返回一个 Observable 表示的结果(可能多个),或者 基于onError的错误通知

如果run() 或者construct()方法 的真实执行时间超过了 Command 设置的超时时间阈值, 则当前则执行线程(或者是独立的定时器线程)将会抛出TimeoutException。抛出超时异常 TimeoutException,后,将执行步骤 8的 Fallback 降级处理。即使run()或者construct()执行没有被取消或中断,最终能够处理返回结果,但在降级处理逻辑中,将会抛弃run()construct()方法的返回结果,而返回 Fallback 降级处理结果。

注意事项
需要注意的是,Hystrix 无法强制 将正在运行的线程停止掉–Hystrix 能够做的最好的方式就是在 JVM 中抛出一个InterruptedException。如果 Hystrix 包装的工作不抛出中断异常InterruptedException, 则在 Hystrix 线程池中的线程将会继续执行,尽管调用的客户端已经接收到了TimeoutException。这种方式会使 Hystrix 的线程池处于饱和状态。大部分的 Java Http Client 开源库并不会解析 InterruptedException。所以确认 HTTP client 相关的连接和读/写相关的超时时间设置。
如果 Command 命令没有抛出任何异常,并且有返回结果,则 Hystrix 将会在做完日志记录和统计之后会将结果返回。 如果是通过run()方式运行,则返回一个Obserable对象,包含一个唯一值,并且发送一个onCompleted通知;如果是通过consturct()方式运行 ,则返回一个Observable对象

(七)健康检查

Hystrix 会统计 Command 命令执行执行过程中的成功数失败数拒绝数超时数,将这些信息记录到断路器(Circuit Breaker)中。断路器将上述统计按照时间窗的形式记录到一个定长数组中。断路器根据时间窗内的统计数据去判定请求什么时候可以被熔断,熔断后,在接下来一段恢复周期内,相同的请求过来后会直接被熔断。当再次校验,如果健康监测通过后,熔断开关将会被关闭。

(八)获取 Fallback

当以下场景出现后,Hystrix 将会尝试触发 Fallback:

  • 步骤 6 Command 执行时抛出了任何异常;
  • 步骤 4 断路器已经被打开
  • 步骤 5 执行命令的线程池、队列或者信号量资源已满
  • 命令执行的时间超过阈值

(九)返回结果

如果 Hystrix 命令对象执行成功,将会返回结果,或者以Observable形式包装的结果。根据步骤 2的 command 调用方式,返回的Observable 会按照如下图说是的转换关系进行返回:

img

  • execute() — 用和 .queue() 相同的方式获取 Future,然后调用 Futureget() 以获取 Observable 的单个值。
  • queue() —将 Observable 转换为 BlockingObservable,以便可以将其转换为 Future 并返回。
  • watch() —订阅 Observable 并开始执行命令的流程; 返回一个 Observable,当订阅该 Observable 时,它会重新通知。
  • toObservable() —返回不变的 Observable; 必须订阅它才能真正开始执行命令的流程。

断路器工作原理

img

  1. 断路器时间窗内的请求数 是否超过了请求数断路器生效阈值circuitBreaker.requestVolumeThreshold,如果超过了阈值,则将会触发断路,断路状态为开启
    例如,如果当前阈值设置的是20,则当时间窗内统计的请求数共计 19 个,即使 19 个全部失败了,都不会触发断路器。
  2. 并且请求错误率超过了请求错误率阈值errorThresholdPercentage
  3. 如果两个都满足,则将断路器由关闭迁移到开启
  4. 如果断路器开启,则后续的所有相同请求将会被断路掉;
  5. 直到过了沉睡时间窗sleepWindowInMilliseconds后,再发起请求时,允许其通过(此时的状态为半开起状态)。如果请求失败了,则保持断路器状态为开启状态,并更新沉睡时间窗。如果请求成功了,则将断路器状态改为关闭状态;

核心的逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public void onNext(HealthCounts hc) {
// check if we are past the statisticalWindowVolumeThreshold
if (hc.getTotalRequests() < properties.circuitBreakerRequestVolumeThreshold().get()) {
// we are not past the minimum volume threshold for the stat window,
// so no change to circuit status.
// if it was CLOSED, it stays CLOSED
// if it was half-open, we need to wait for a successful command execution
// if it was open, we need to wait for sleep window to elapse
} else {
if (hc.getErrorPercentage() < properties.circuitBreakerErrorThresholdPercentage().get()) {
//we are not past the minimum error threshold for the stat window,
// so no change to circuit status.
// if it was CLOSED, it stays CLOSED
// if it was half-open, we need to wait for a successful command execution
// if it was open, we need to wait for sleep window to elapse
} else {
// our failure rate is too high, we need to set the state to OPEN
if (status.compareAndSet(Status.CLOSED, Status.OPEN)) {
circuitOpened.set(System.currentTimeMillis());
}
}
}
}

系统指标

Hystrix 对系统指标的统计是基于时间窗模式的:

时间窗:最近的一个时间区间内,比如前一小时到现在,那么时间窗的长度就是1小时
:桶是在特定的时间窗内,等分的指标收集的统计集合;比如时间窗的长度为1小时,而桶的数量为10,那么每个桶在时间轴上依次排开,时间由远及近,每个桶统计的时间分片为 1h / 10 = 6 min 6 分钟。一个桶中,包含了成功数失败数超时数拒绝数 四个指标。

在系统内,时间窗会随着系统的运行逐渐向前移动,而时间窗的长度和桶的数量是固定不变的,那么随着时间的移动,会出现较久的过期的桶被移除出去,新的桶被添加进来,如下图所示:

img

资源隔离技术

线程池隔离

如下图所示,由于计算机系统的基本执行单位就是线程,线程具备独立的执行能力,所以,为了做到资源保护,需要对系统的线程池进行划分,对于外部调用方

1
User Request

的请求,调用各个线程池的服务,各个线程池独立完成调用,然后将结果返回

1
调用方

。在调用服务的过程中,如果

1
服务提供方

执行时间过长,则

1
调用方

可以直接以超时的方式直接返回,快速失败。

img

线程池隔离的几点好处

  1. 使用超时返回的机制,避免同步调用服务时,调用时间过长,无法释放,导致资源耗尽的情况
  2. 服务方可以控制请求数量,请求过多,可以直接拒绝,达到快速失败的目的;
  3. 请求排队,线程池可以维护执行队列,将请求压到队列中处理

举个例子,如下代码段,模拟了同步调用服务的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//服务提供方,执行服务的时候模拟2分钟的耗时
Callable<String> callableService = ()->{
long start = System.currentTimeMillis();
while(System.currentTimeMillis()-start> 1000 * 60 *2){
//模拟服务执行时间过长的情况
}
return "OK";
};

//模拟10个客户端调用服务
ExecutorService clients = Executors.newFixedThreadPool(10);
//模拟给10个客户端提交处理请求
for (int i = 0; i < 20; i++) {
clients.execute(()->{
//同步调用
try {
String result = callableService.call();
System.out.println("当前客户端:"+Thread.currentThread().getName()+"调用服务完成,得到结果:"+result);
} catch (Exception e) {
e.printStackTrace();
}
});
}

在此环节中,客户端 clients必须等待服务方返回结果之后,才能接收新的请求。如果用吞吐量来衡量系统的话,会发现系统的处理能力比较低。为了提高相应时间,可以借助线程池的方式,设置超时时间,这样的话,客户端就不需要必须等待服务方返回,如果时间过长,可以提前返回,改造后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//服务提供方,执行服务的时候模拟2分钟的耗时
Callable<String> callableService = ()->{
long start = System.currentTimeMillis();
while(System.currentTimeMillis()-start> 1000 * 60 *2){
//模拟服务执行时间过长的情况
}
return "OK";
};

//创建线程池作为服务方
ExecutorService executorService = Executors.newFixedThreadPool(30);


//模拟10个客户端调用服务
ExecutorService clients = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
clients.execute(()->{
//同步调用
//将请求提交给线程池执行,Callable 和 Runnable在某种意义上,也是Command对象
Future<String> future = executorService.submit(callableService::call);
//在指定的时间内获取结果,如果超时,调用方可以直接返回
try {
String result = future.get(1000, TimeUnit.SECONDS);
//客户端等待时间之后,快速返回
System.out.println("当前客户端:"+Thread.currentThread().getName()+"调用服务完成,得到结果:"+result);
}catch (TimeoutException timeoutException){
System.out.println("服务调用超时,返回处理");
} catch (InterruptedException e) {

} catch (ExecutionException e) {
}
});
}

如果我们将服务方的线程池设置为:

1
2
3
4
ThreadPoolExecutor executorService = new ThreadPoolExecutor(10,1000,TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.DiscardPolicy() // 提交请求过多时,可以丢弃请求,避免死等阻塞的情况。
)

线程池隔离模式的弊端

线程池隔离模式,会根据服务划分出独立的线程池,系统资源的线程并发数是有限的,当线程数过多,系统话费大量的 CPU 时间来做线程上下文切换的无用操作,反而降低系统性能;如果线程池隔离的过多,会导致真正用于接收用户请求的线程就相应地减少,系统吞吐量反而下降;
在实践上,应当对像远程方法调用,网络资源请求这种服务时间不太可控的场景下使用线程池隔离模式处理
如下图所示,是线程池隔离模式的三种场景:

img

信号量隔离

由于基于线程池隔离的模式占用系统线程池资源,Hystrix 还提供了另外一个隔离技术:基于信号量的隔离。

基于信号量的隔离方式非常地简单,其核心就是使用共用变量

1
semaphore

进行原子操作,控制线程的并发量,当并发量达到一定量级时,服务禁止调用。如下图所示:信号量本身不会消耗多余的线程资源,所以就非常轻量。

img

基于信号量隔离的利弊

利:基于信号量的隔离,利用 JVM 的原子性 CAS 操作,避免了资源锁的竞争,省去了线程池开销,效率非常高;
弊:本质上基于信号量的隔离是同步行为,所以无法做到超时熔断,所以服务方自身要控制住执行时间,避免超时。
应用场景:业务服务上,有并发上限限制时,可以考虑此方式 > Alibaba Sentinel开源框架,就是基于信号量的熔断和断路器框架。

Hystrix 应用

  • Hystrix 配置无法动态调节生效。Hystrix 框架本身是使用的Archaius框架完成的配置加载和刷新,但是集成自 Spring Cloud 下,无法有效地根据实时监控结果,动态调整熔断和系统参数
  • 线程池和 Command 之间的配置比较复杂,在 Spring Cloud 在做 feigin-hystrix 集成的时候,还有些 BUG,对 command 的默认配置没有处理好,导致所有 command 占用公共的 command 线程池,没有细粒度控制,还需要做框架适配调整
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public interface SetterFactory {

/**
* Returns a hystrix setter appropriate for the given target and method
*/
HystrixCommand.Setter create(Target<?> target, Method method);

/**
* Default behavior is to derive the group key from {@link Target#name()} and the command key from
* {@link Feign#configKey(Class, Method)}.
*/
final class Default implements SetterFactory {

@Override
public HystrixCommand.Setter create(Target<?> target, Method method) {
String groupKey = target.name();
String commandKey = Feign.configKey(target.type(), method);
return HystrixCommand.Setter
.withGroupKey(HystrixCommandGroupKey.Factory.asKey(groupKey))
.andCommandKey(HystrixCommandKey.Factory.asKey(commandKey));
//没有处理好default配置项的加载
}
}
}

Hystrix 配置

详细配置可以参考 Hystrix 官方配置手册,这里仅介绍比较核心的配置

执行配置

以下配置用于控制 HystrixCommand.run() 如何执行。

配置项 说明 默认值
execution.isolation.strategy 线程隔离(THREAD)或信号量隔离(SEMAPHORE) THREAD
execution.isolation.thread.timeoutInMilliseconds 方法执行超时时间 1000(ms)
execution.isolation.semaphore.maxConcurrentRequests 信号量隔离最大并发数 10

断路配置

以下配置用于控制 HystrixCircuitBreaker 的断路处理。

配置项 说明 默认值
circuitBreaker.enabled 是否开启断路器 true
circuitBreaker.requestVolumeThreshold 断路器启用请求数阈值 20
circuitBreaker.sleepWindowInMilliseconds 断路器启用后的休眠时间 5000(ms)
circuitBreaker.errorThresholdPercentage 断路器启用失败率阈值 50(%)
circuitBreaker.forceOpen 是否强制将断路器设置成开启状态 false
circuitBreaker.forceClosed 是否强制将断路器设置成关闭状态 false

指标配置

以下配置用于从 HystrixCommand 和 HystrixObservableCommand 执行中捕获相关指标。

配置项 说明 默认值
metrics.rollingStats.timeInMilliseconds 时间窗的长度 10000(ms)
metrics.rollingStats.numBuckets 桶的数量,需要保证timeInMilliseconds % numBuckets =0 10
metrics.rollingPercentile.enabled 是否统计运行延迟的占比 true
metrics.rollingPercentile.timeInMilliseconds 运行延迟占比统计的时间窗 60000(ms)
metrics.rollingPercentile.numBuckets 运行延迟占比统计的桶数 6
metrics.rollingPercentile.bucketSize 百分比统计桶的容量,桶内最多保存的运行时间统计 100
metrics.healthSnapshot.intervalInMilliseconds 统计快照刷新间隔 500 (ms)

线程池配置

以下配置用于控制 Hystrix Command 执行所使用的线程池。

配置项 说明 默认值
coreSize 线程池核心线程数 10
maximumSize 线程池最大线程数 10
maxQueueSize 最大 LinkedBlockingQueue 的大小,-1 表示用 SynchronousQueue -1
queueSizeRejectionThreshold 队列大小阈值,超过则拒绝 5
allowMaximumSizeToDivergeFromCoreSize 此属性允许 maximumSize 的配置生效。该值可以等于或大于 coreSize。设置 coreSize <maximumSize 使得线程池可以维持 maximumSize 并发性,但是会在相对空闲时将线程回收。(取决于 keepAliveTimeInMinutes) false

其他限流技术

  • resilience4j
    Hystrix 虽然官方宣布不再维护,其推荐另外一个框架:resilience4j, 这个框架是是为 Java 8 和 函数式编程设计的一个轻量级的容错框架,该框架充分利用函数式编程的概念,为函数式接口lamda表达式方法引用高阶函数进行包装,(本质上是装饰者模式的概念),通过包装实现断路限流重试舱壁功能。
    这个框架整体而言比较轻量,没有控制台,不太好做系统级监控;

  • Alibaba Sentinel

    1
    Sentinel

    是 阿里巴巴开源的轻量级的流量控制、熔断降级 Java 库,该库的核心是使用的是信号量隔离的方式做流量控制和熔断,其优点是其集成性和易用性,几乎能和当前主流的 Spring Cloud, dubbo ,grpc ,nacos, zookeeper 做集成,如下图所示:

    img

    sentinel-features-overview-en.png

    1
    Sentinel

    的目标生态圈:

    img

    1
    sentinel

    一个强大的功能,就是它有一个流控管理控制台,你可以实时地监控每个服务的流控情况,并且可以实时编辑各种流控、熔断规则,有效地保证了服务保护的及时性。下图是内部试用的 sentinel 控制台:

    img另外,

    1
    sentinel

    还可以和

    1
    ctrip apollo

    分布式配置系统进行集成,将流控规降级等各种规则先配置在 apollo 中,然后服务启动自动加载流控规则。

参考资料