SSM 源码拆解:从一行 XML 到一次事务提交,全程大白话

你有没有过这样的经历:照着教程把 applicationContext.xml 抄下来,项目能跑,但心里发虚——这几十行 XML 到底在干什么?为什么有的项目要写 SqlSessionTemplate,有的项目不用?@Transactional 又是怎么"自动"开事务、自动回滚的?

今天这篇,我把这些配置背后的源码逻辑拆开揉碎,用"超市 + 机器人 + 储物柜"的比喻,配上一行行能直接复制的代码示例,讲给你听。

目录

  1. 一个 Bean 的诞生:Spring 怎么"组装"一个 DAO
  2. 两种 DAO 写法:手写实现类 vs 扫描接口
  3. SqlSessionTemplate 源码:看不见的"机器人服务员"
  4. ThreadLocal:人手一个专属"储物柜"
  5. 声明式事务 XML 版:四段配置各司其职
  6. 声明式事务注解版:一行配置顶四段
  7. 事务回滚的四个坑:附正确/错误代码对比

一、一个 Bean 的诞生:Spring 怎么"组装"一个 DAO

先看这段最原始的配置:

<bean id="providerDao6" class="com.kgc.dao.impl.ProviderDaoImpl6">
    <property name="sqlSession" ref="sqlSessionTemplate"/>
    <property name="sqlSessionFactory" ref="sqlSessionFactory"/>
</bean>

大白话翻译:Spring 说——“我要创建一个 ProviderDaoImpl6 的对象,名字叫 providerDao6。建好之后,把容器里那个叫 sqlSessionTemplate 的对象塞进它的 sqlSession 属性里,把 sqlSessionFactory 塞进它的 sqlSessionFactory 属性里。”

Spring 干活的依据是 JavaBean 规范:属性名叫 sqlSession,容器就去找 setSqlSession(...) 这个 setter 方法,把参数传进去。所以你的 Java 类必须长这样:

public class ProviderDaoImpl6 implements ProviderDao {
    private SqlSessionTemplate sqlSession;      // 主用:日常 SQL 全靠它
    private SqlSessionFactory sqlSessionFactory; // 备用:特殊场景自己开会话

    public void setSqlSession(SqlSessionTemplate sqlSession) {
        this.sqlSession = sqlSession;
    }

    public void setSqlSessionFactory(SqlSessionFactory sqlSessionFactory) {
        this.sqlSessionFactory = sqlSessionFactory;
    }
}

要点:

  • class 必须写实现类,不能写接口——接口没法 new。
  • 默认是单例,全容器只有一个实例。
  • 两个 setter 缺一个,启动直接报错。

二、两种 DAO 写法:手写实现类 vs 扫描接口

上面那种是"老风格",而现在主流是"新风格"。先看对比表:

对比项老风格(手写模板)新风格(扫描器)
DAO 长什么样接口 + 实现类 ProviderDaoImpl6只有接口 ProviderDao
SQL 执行能力谁提供手动配置 SqlSessionTemplate 注入实现类扫描器内部自动建模板 + 生成代理
你写不写实现类写不用写
Service 注入谁注入实现类 bean注入接口(拿到的是代理)

老风格配置:

<bean id="sqlSessionTemplate" class="org.mybatis.spring.SqlSessionTemplate">
    <constructor-arg ref="sqlSessionFactory"/>
</bean>

新风格配置:

<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
    <property name="basePackage" value="com.kgc.dao"/>
</bean>

新风格背后发生了什么:MapperScannerConfigurer 扫描 com.kgc.dao 包,把每个 Mapper 接口(如 ProviderDao)变成一个动态代理(MapperProxy)。每个代理内部偷偷 new 了一个 SqlSessionTemplate——所以你看不见它,但它一直在。Service 里直接注入接口即可:

@Service
public class ProviderServiceImpl implements ProviderService {

    @Autowired
    private ProviderDao providerDao;   // 注入的是代理对象

    public void add(Provider p) {
        providerDao.insert(p);
    }
}

总结一句:新老两种写法殊途同归,最终都走 SqlSessionFactory → DataSource;区别只是"写实现类 + 注册模板"这两步,新风格交给框架自动完成了。


三、SqlSessionTemplate 源码:看不见的"机器人服务员"

SqlSessionTemplate 是 MyBatis-Spring 最核心的类。它本质是一个动态代理:

// 源码简化版
public class SqlSessionTemplate implements SqlSession, DisposableBean {
    private final SqlSessionFactory sqlSessionFactory;
    private final SqlSessionProxy sqlSessionProxy;   // 代理

    public SqlSessionTemplate(SqlSessionFactory factory) {
        this.sqlSessionFactory = factory;
        this.sqlSessionProxy = (SqlSessionProxy) Proxy.newProxyInstance(
            SqlSessionFactory.class.getClassLoader(),
            new Class[]{SqlSessionProxy.class},
            new SqlSessionInterceptor()               // 拦截器是灵魂
        );
    }
}

当你调用 sqlSession.selectList(...) 时,实际上执行的是拦截器的 invoke() 方法:

// 源码简化版:核心判断逻辑
class SqlSessionInterceptor implements InvocationHandler {
    public Object invoke(Object proxy, Method method, Object[] args) {
        SqlSession sqlSession = getSqlSession(      // 关键:怎么拿到会话
            SqlSessionUtils.class,                  // 先问事务管理器
            this.sqlSessionFactory,
            TransactionSynchronizationManager.getResource(this.sqlSessionFactory)
        );
        try {
            return method.invoke(sqlSession, args); // 执行真正的 SQL
        } catch (Exception e) {
            rollbackIfNecessary(sqlSession);        // 出错回滚
            throw e;
        } finally {
            closeSqlSession(sqlSession);            // 收尾
        }
    }
}

大白话比喻:SqlSessionTemplate 是一个机器人服务员。你(DAO)跟它说"帮我查一下供应商列表",它不会自己闷头干,而是先扭头问总台(事务管理器):“现在这位顾客是不是在买单(事务)过程中?”

  • 在事务里 → 用总台指定的那个连接(复用);
  • 不在事务里 → 自己新开一个会话干完拉倒。

这就引出了下一个问题:总台凭什么能保证"同一个连接"?答案藏在 ThreadLocal。


四、ThreadLocal:人手一个专属"储物柜"

DataSourceTransactionManager 开事务时做的事,源码简化版如下:

// DataSourceTransactionManager.doBegin() 简化
protected void doBegin(Object transaction, TransactionDefinition definition) {
    DataSource dataSource = ((DataSourceTransactionObject) transaction).getDataSourceHolder().getDataSource();
    Connection connection = DataSourceUtils.getConnection(dataSource);   // 1. 从连接池拿连接
    // 2. 关键:把连接绑到 ThreadLocal 上,key 是 dataSource
    TransactionSynchronizationManager.bindResource(dataSource, connection);
    connection.setAutoCommit(false);                                      // 3. 关闭自动提交
}

提交/回滚后,事务管理器还会清理储物柜:

// doCleanupAfterCompletion() 简化
protected void doCleanupAfterCompletion(Object transaction) {
    TransactionSynchronizationManager.unbindResource(dataSource);   // 从柜子里拿出来
    DataSourceUtils.releaseConnection(connection, dataSource);      // 还回连接池
}

ThreadLocal 的原理:每个线程都有一个私有的 ThreadLocals Map,key 是 ThreadLocal 对象,value 是你存的值。TransactionSynchronizationManager 里就放了一个静态的 ThreadLocal,把"当前线程 → 当前事务的连接"存进去。

// 简化理解
public abstract class TransactionSynchronizationManager {
    private static final ThreadLocal<Map<Object, Object>> resources = new ThreadLocal<>();
}

大白话比喻:ThreadLocal 是人手一个的专属储物柜。线程 A 打开自己的柜子,放进自己的连接;线程 B 打开自己的柜子,放进自己的连接——谁都不碰谁的。同一个线程里,第一次放进连接后,后面的 DAO 一查柜子:"有!就是这个连接!"于是一个事务里的所有 SQL 就天然共用一个连接。

提醒:柜子用完必须清空(unbindResource)。如果不清,线程池里的线程被复用,下一个任务可能拿到上一个任务的旧连接——这就是"ThreadLocal 内存泄漏"的来源。


五、声明式事务 XML 版:四段配置各司其职

<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
    <property name="dataSource" ref="dataSource"/>
</bean>

<tx:advice id="txAdvice" transaction-manager="transactionManager">
    <tx:attributes>
        <tx:method name="find*" timeout="60"/>
        <tx:method name="*"/>
    </tx:attributes>
</tx:advice>

<aop:config>
    <aop:pointcut id="pointCut" expression="execution(* com.kgc.service..*.*(..))"/>
    <aop:advisor advice-ref="txAdvice" pointcut-ref="pointCut"/>
</aop:config>

三块分别是什么:

  1. transactionManager(总指挥):知道去哪个连接池拿连接、怎么开事务、怎么提交回滚。它不认识 MyBatis,只管理 JDBC Connection,但 SqlSessionTemplate 会自动配合它。
  2. tx:advice(规则说明书):按方法名匹配事务规则——find* 走事务且超时 60 秒;* 全部走事务,默认配置(RuntimeException 才回滚)。
  3. aop:config(自动拦截器):execution(* com.kgc.service..*.*(..)) 意思是"com.kgc.service 包及子包下所有类的所有方法"。advisor 把"规则说明书"和"拦截范围"绑在一起。

执行流程:

Service 方法被代理拦截
   ↓
事务管理器 doBegin:取连接 → 绑定 ThreadLocal
   ↓
方法内所有 DAO 调用 → SqlSessionTemplate 查柜子 → 复用同一连接
   ↓
正常结束 → commit;抛 RuntimeException → rollback

六、声明式事务注解版:一行配置顶四段

<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
    <property name="dataSource" ref="dataSource"/>
</bean>

<tx:annotation-driven transaction-manager="transactionManager"/>

和 XML 版的对应关系:

XML 版注解版
<tx:method name="find*" timeout="60"/>方法上 @Transactional(timeout=60)
<tx:method name="*"/>方法上 @Transactional
<aop:pointcut expression="..."/>不需要——注解标哪,就拦哪
advisor 绑定规则tx:annotation-driven 自动扫描

Service 里的用法:

@Service
public class ProviderServiceImpl implements ProviderService {

    @Autowired
    private ProviderDao providerDao;

    @Transactional(timeout = 60, readOnly = true)
    public List<Provider> findProviderList() {
        return providerDao.selectList(...);
    }

    @Transactional
    public void addProvider(Provider p) {
        providerDao.insert(p);
        // 抛 RuntimeException 会自动回滚
    }
}

执行流程和 XML 版完全一样,只有一点不同:代理是 tx:annotation-driven 在容器启动时扫描 @Transactional 注解自动生成的,而不是靠 pointcut 表达式指定的。


七、事务回滚的四个坑:附正确/错误代码对比

坑 1:受检异常默认不回滚

默认只对 RuntimeException/Error 回滚。你的方法 throws SQLException 时,事务不会回滚,直接提交了。

// ❌ 错:SQLException 是受检异常,默认不回滚
@Transactional
public void add(Provider p) throws SQLException {
    dao.insert(p);
}

// ✅ 对:明确指定回滚条件
@Transactional(rollbackFor = Exception.class)
public void add(Provider p) throws SQLException {
    dao.insert(p);
}

坑 2:catch 吞掉异常

把异常 catch 住又不重新抛,事务管理器根本看不到异常,照样提交。

// ❌ 错:异常被吞掉,事务照常提交
@Transactional
public void add(Provider p) {
    try {
        dao.insert(p);
        dao.updateStock(p);   // 这里抛异常
    } catch (Exception e) {
        log.error("出错了", e);   // 吞了!
    }
}

// ✅ 对:要么重新抛出,要么手动回滚
@Transactional(rollbackFor = Exception.class)
public void add(Provider p) {
    try {
        dao.insert(p);
        dao.updateStock(p);
    } catch (Exception e) {
        log.error("出错了", e);
        throw new RuntimeException("添加失败", e);   // 重新抛出
    }
}

坑 3:this.xxx() 自调用绕开代理

同一个类里,this.addProvider() 调用另一个 @Transactional 方法,走的是原始对象,不是代理——事务不生效。

// ❌ 错:this 自调用,第二个方法的事务不生效
@Service
public class ProviderServiceImpl {
    @Transactional
    public void addWithLog(Provider p) {
        this.addProvider(p);      // 直穿对象,跳过代理
    }
    @Transactional
    public void addProvider(Provider p) { ... }
}

// ✅ 对:注入自己的代理,或把事务方法拆到别的类
@Service
public class ProviderServiceImpl {
    @Autowired
    private ProviderService self;   // 注入代理(自己代理自己)
    @Transactional
    public void addWithLog(Provider p) {
        self.addProvider(p);        // 走代理,事务生效
    }
    @Transactional
    public void addProvider(Provider p) { ... }
}

坑 4:方法不是 public / 类不是 Spring Bean

  • @Transactional 只对 public 方法生效,private/protected 会被忽略。
  • 类上没有 @Service/@Component 等注解、没被扫描进容器,tx:annotation-driven 根本不认识它。

结尾互动

源码拆解最怕"背结论",不怕"看代码"。下次再看到 .xml 里的 bean,你可以在心里问自己三个问题:它 new 的是谁?它把什么塞给了谁?这套动作背后有没有代理在偷偷干活? 想通了这三个问题,SSM 的配置文件在你眼里就不再是"咒语",而是一张看得懂的装配图。

评论区聊聊:你项目里用的是老风格(SqlSessionTemplate)还是新风格(MapperScannerConfigurer)?遇到过哪个坑让你 debug 到崩溃?点赞收藏,下一篇我们拆 MyBatis 一级缓存和 MVCC,保证也这么通俗。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐