MyBatis 中接口代理机制及使用 - sqlSession.getMapper() 的使用问题

写在前面

做该笔记时,我还不了解 Java 的动态代理技术,也没有系统地追一遍 sqlSession.getMapper() 以及 mapper 调用接口方法的底层源码

如果已经了解这些知识,就明白接口代理类 mapper 本质上是对 sqlSession 进行代理,sqlSession 就是目标对象,诸如 sqlSession.insert() 之类的方法就是目标方法

由于 sqlSession 存在线程安全的问题,自然对其进行代理的接口代理类 mapper 也会存在线程安全的问题,毕竟代理方法底层还是需要调用目标对象的目标方法。这就把问题从根本上解释清楚了,而不是像下文这样解释得有些隔靴搔痒。

另外,Mybatis 的接口代理机制实际是用 Java 的动态代理技术来实现的,并没有直接使用到 javassist。Mybatis 中内置的 javassist 应该是用在了其他地方。

关于 MyBatis 的接口代理机制的源码分析,补充写了一篇笔记,如果可以的话,建议先阅读源码分析,本笔记相对而言,就没那么重要了。

sqlSession.getMapper() 的使用问题

AccountServiceImpl 中处理业务逻辑时,我们需要调 Dao 层来操作数据库。为了解耦,在 AccountServiceImpl 中是面向 AccountDao 接口来操作数据库。对于 AccountDao 接口属性,我们需要赋予接口对应的实现类,才能使用。

第 8 章中,老杜提到了下面三种方式,来给 AccountDao 接口提供对应的实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class AccountServiceImpl implements AccountService {
// 方式一:手动编码实现 AccountDao 接口
private AccountDao accountDao = new AccountDaoImpl();

// 方式二:使用自己实现的接口代理机制,在内存中生成 AccountDao 接口代理类
private AccountDao accountDao = GenerateDaoProxy.generate(SqlSessionUtil.openSession(), AccountDao.class);

// 方式三:使用 Mybatis 中提供的接口代理机制,在内存中生成 AccountDao 接口代理类
private AccountDao accountDao = SqlSessionUtil.openSession().getMapper(AccountDao.class);

@Override
public void transfer(String fromActno, String toActno, double money) throws MoneyNotEnoughException, TransferException {
SqlSession sqlSession = SqlSessionUtil.openSession();
Account fromAct = accountDao.selectByActno(fromActno);

// more code ...
}
}

其中方式三,测试后发现存在问题:只能转账一次,第二次转账就会报错

问题溯源

accountDao 属性所在行和 transfer() 中的首行打上断点,然后以 Debug 模式启动 Tomcat:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class AccountServiceImpl implements AccountService {
// 打上断点
private AccountDao accountDao = SqlSessionUtil.openSession().getMapper(AccountDao.class);

@Override
public void transfer(String fromActno, String toActno, double money) throws MoneyNotEnoughException, TransferException {
// 打上断点
SqlSession sqlSession = SqlSessionUtil.openSession();
Account fromAct = accountDao.selectByActno(fromActno);

// more code ...
}
}

Tomcat 服务器启动后,此时 accountDao 属性所在行的断点未被触发,说明还未执行初始化操作。

这与 Servlet 的生命周期有关,如果不做明确配置,默认情况下,Servlet 创建于用户第一次调用对应于该 Servlet 的 URL 时。

目前还未访问 /bank/transfer,所以 AccountServlet 还未被创建,自然 accountDao 属性还未被初始化。

1
2
3
4
5
6
7
@WebServlet("/transfer")
public class AccountServlet extends HttpServlet {

private AccountService accountService = new AccountServiceImpl();

// more code ...
}

在转账页面输入信息并点击转账后,第一次访问 /bank/transfer,此时需要创建 AccountServlet,其内部的 accountService 属性需要先创建 AccountServiceImpl,其内部的 accountDao 属性又需要先通过接口代理机制,获取到接口代理实现类。

因此 accountDao 属性所在行被触发,执行完当前行后,发现 accountDao 的接口代理类中,实际上持有一个 SqlSession 对象,这个对象,就是调用 getMapper() 方法的 SqlSession 实例对象。

直接跳到 transfer() 首行的断点处,执行完该行后,发现 transfer() 首行获取到的 sqlSessionaccountDao 接口代理类中持有的 sqlSession 是同一个

这一点不难解释,第一次访问 /bank/transfer 时,AccountServlet 才被初始化,从而 accountDao 接口属性在第一次访问时,才通过接口代理机制后获取代理类,之后又需要执行 transfer() 方法,两个断点所在行的代码,是在一个线程下执行的,自然获取到的是同一个 SqlSession 对象

第一次 transfer() 方法执行完以后,sqlSession 会被关闭,这意味着 accountDao 接口代理类中持有的 sqlSession 也被关闭了。执行完成后,页面跳转到转账成功页面,同时数据库中表也相应完成了转账的数据修改。

紧接着,回到转账页面,第二次发起转账请求,第二次执行 transfer() 方法时,首行获取到的 sqlSession 是新的刚开启的 SqlSession 对象,而此时 accountDao 属性中持有的 sqlSession 是第一次请求获取到的且已经被关闭,在执行 accountDao.selectByActno(fromActno) 时,程序抛出异常,转账失败:

1
Error querying database.  Cause: org.apache.ibatis.executor.ExecutorException: Executor was closed.

问题发生的原因和解决方案

直接说原因:在第二次转账的时候,transfer()accountDao.selectByActno 方法底层需要使用 accountDao 内部持有的 sqlSession 来执行数据库操作(而不是 transfer() 首行创建的新 sqlSession),但是 accountDao 持有的 sqlSession 在第一次转账完成后就被关闭了。

那我们自己实现的接口代理机制 GenerateDaoProxy.generate() 为什么第二次转账依然正常?

因为 GenerateDaoProxy.generate() 生成的接口代理,其内部并不持有一个 SqlSession 对象,每次调用接口的方法,其方法实现中都是先 SqlSessionUtil.openSession() 重新获取 SqlSession 对象来操作数据库。

1
2
// 拼接方法体内容的首行:SqlSessionUtil.openSession() 重新获取 SqlSession 对象
methodCode.append("org.apache.ibatis.session.SqlSession sqlSession = com.powernode.bank.utils.SqlSessionUtil.openSession();");

至于最开始调用 GenerateDaoProxy.generate() 方法传入的 SqlSession,也是用来实现完所有的接口方法后,就不再使用了。

所以,老杜的 GenerateDaoProxy.generate() 方法还需要一点小改进:就是在方法最后,要手动关闭方法传入的 SqlSession 参数。


那解决方案是什么呢?

我先说一种错误的解决方案:修改 SqlSessionUtil,让 SqlSessionUtil 内部长期持有一个 SqlSession 静态对象,该 SqlSession 对象不会放到 ThreadLocal 中,而是让各种接口代理类去长期持有。

这样的解决方案错在哪里?

回忆上面说的问题的原因,transfer()accountDao.selectByActno 方法底层需要使用 accountDao 内部持有的 sqlSession 来执行数据库操作。

如果采用这种解决方案,那么 accountDao 调用任何接口方法,使用的永远是 SqlSessionUtil 中的 SqlSession 静态对象,而不是 transfer() 方法首行创建的 SqlSession 对象。这就导致了:

  • SqlSessionUtil 中的 SqlSession 静态对象在每次 Dao 接口代理类调用接口方法时,都执行了数据库操作,但永远不做提交。
  • transfer() 方法首行创建的 SqlSession 对象没有做任何操作,就提交了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class AccountServiceImpl implements AccountService {

// 获取专门为 Dao 接口代理类提供的 SqlSession 对象
private AccountDao accountDao = SqlSessionUtil.getDaoSqlSession().getMapper(AccountDao.class);

@Override
public void transfer(String fromActno, String toActno, double money) throws MoneyNotEnoughException, TransferException {

// 获取新的 SqlSession
SqlSession sqlSession = SqlSessionUtil.openSession();

// 接口底层执行使用的是 SqlSessionUtil 中的 SqlSession
// 每次调用接口的方法,SqlSessionUtil 中的 SqlSession 就做一次操作
// 但无论做多少次操作,该 SqlSession 但始终没有提交
Account fromAct = accountDao.selectByActno(fromActno);

// more code ...

sqlSession.commit(); // 没有做任何操作,就提交了
SqlSessionUtil.close(sqlSession); // 没有做任何操作,就关闭了
}
}

另外,各种 Dao 接口代理类共用同一个 SqlSession 的设计,还存在一个问题:每次调用方法时,都是通过同一个 SqlSession 对象做数据库操作,这在并发场景下显然存在线程安全问题!


那么正确的解决方案是什么呢?

能想到的正确的解决方案还是有的,但是都需要生成多次 AccountDao 接口的代理类,而且,这似乎是无法避免的。

比如说,每次执行转账操作,都使用 transfer() 首行获取到的 sqlSession 来重新生成一次 AccountDao 接口的代理类。如下所示:

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

// 不再提供 accountDao 属性,直接做成 transfer 方法的局部变量
// 因为做成 accountDao 属性的话,每次 transfer 都重新对其赋值,该属性会有线程安全问题。
// private AccountDao accountDao;

@Override
public void transfer(String fromActno, String toActno, double money) throws MoneyNotEnoughException, TransferException {

// 获取新的 SqlSession
SqlSession sqlSession = SqlSessionUtil.openSession();
AccountDao accountDao = sqlSession.getMapper(AccountDao.class);

Account fromAct = accountDao.selectByActno(fromActno);

// more code ...

sqlSession.commit();
SqlSessionUtil.close(sqlSession);
}
}

每次转账操作,accountDao 持有的 sqlSession 总是本次转账操作中新获取的 sqlSession,这样问题就得到解决了。

但不优雅的地方在于,每次调用 transfer() 方法都需要重新生成接口代理。

当然有解决方案,可以减少重新生成接口代理类的次数。但我认为,需要生成多次接口代理似乎是无法避免的,因为接口的代理类从设计上来看就是线程不安全的

sqlSession.getMapper() 生成的接口代理类内部,会持有一个 sqlSession 对象,该 sqlSession 对象就是生成接口代理时所用到的 sqlSession 对象,而每次调用接口方法,都会使用代理类内部的 sqlSession 来执行数据库操作。

SqlSession 是线程不安全的,而 Mybatis 生成的接口代理类又和 SqlSession 强绑定,所以接口代理类也是有线程安全问题的。

既然存在线程安全问题,就需要像 SqlSession 那样,在多线程环境下,做线程的隔离,每个线程一个 SqlSession,每个线程生成一次接口代理类!

遗留问题

在学习完 Spring 之后,再回过头来想想 Mybatis 的接口代理机制生成的接口代理类,是如何保证线程安全的?

已解决:参考 SSM 整合 - Spring 与 MyBatis 的整合细节 > 细节五:在 Spring 声明式事务下,Spring 和 Mybatis 的两个事务管理器是如何协同工作的?