写一个迷你版MyBatis,是我近几年带新人时最喜欢安排的一个任务。原因很简单:Spring Boot加JDBC这套组合,几乎每个做Java后端的人每天都在用,但真正搞懂连接怎么获取、SQL怎么执行、结果怎么映射的人并不多。当你亲手把这几百行代码写出来,再去回头看MyBatis的源码,那种豁然开朗的感觉,是看多少篇源码分析都比不上的。
这篇博文会带你从零实现一个可用的ORM框架,包括注解定义、动态代理、参数绑定、结果集映射、事务整合,最后还能在Spring Boot里像MyBatis一样直接注入Mapper接口使用。麻雀虽小五脏俱全,写完后你就理解了大厂面试题里那些“MyBatis的工作原理”“JDBC批量插入为什么快”“@Transactional为什么生效”背后的答案。
1. 动手前先拆清楚:一个迷你ORM要解决什么问题
1.1 ORM的本质就是三件事
ORM(Object Relational Mapping)听上去很高大上,拆开看就是三件事:建立Java对象和数据库表之间的映射关系、把对象操作翻译成SQL执行、把查询结果重新组装成Java对象。Hibernate选择让你完全不用写SQL,通过注解或XML把实体类和表绑定;MyBatis选择让你写SQL,但帮你省掉JDBC那堆模板代码。我这个迷你版走的是MyBatis这条路,核心代码量控制在几百行,每个细节都能讲清楚。
JDBC的痛点用过都知道。每次查询都要手动获取连接,写PreparedStatement,塞参数,执行查询,再逐列从ResultSet里取数据往对象里set,最后还要关连接。这些代码和业务逻辑混在一起,一个简单查询写下来三四十行是常态,而且极易出错。尤其在团队协作时,每个人写的JDBC风格还不一样,代码review时看得人脑壳疼。
ORM框架解决的就是这个重复劳动问题。你定义一个Mapper接口,写上@Select注解和SQL语句,框架在运行时动态生成实现类,帮你完成从接口方法到SQL执行的整个过程。开发人员只需要关心SQL本身和参数,其他全交给框架。
1.2 技术选型:为什么走注解加动态代理
动手之前我先确定了两件事:映射关系用注解定义,Mapper实现用JDK动态代理生成。
注解方案的好处是直观,SQL就写在方法上面,看代码的时候不需要跳来跳去找XML文件。对于迷你框架来说,注解比XML解析省事很多,不需要写XML解析器,也不需要处理XML文件的位置和加载。
动态代理是MyBatis的灵魂。回想一下,MyBatis里的Mapper接口从来没有人写实现类,你用@Autowired注入一个接口,Spring就给你一个能用的对象,这个对象就是JDK动态代理生成的。MyBatis在代理的invoke方法里做了三件事:解析方法上的SQL注解或XML里的SQL、绑定参数、执行SQL并映射结果。
我在设计时也沿用了这个思路:只定义接口和注解,框架通过Proxy.newProxyInstance在运行时生成代理对象,所有方法调用统一走InvocationHandler的invoke方法,在那里完成查SQL、绑参数、执行、映射这一整套流程。
1.3 环境准备:依赖和数据库表结构
我用的环境是JDK 8加Spring Boot 2.7,数据库用MySQL 8.0。这里有一个需要注意的点:Spring Boot 3.x把javax包换成jakarta了,如果你用的是Spring Boot 3.x,代码里的javax.sql.DataSource等需要对应调整。我在写示例时用的还是javax版本,实际项目请根据自己环境来。
项目只需要一个核心依赖,Spring Boot的web starter和jdbc starter。如果你只想跑核心逻辑,连Spring Boot都不需要,但为了最后演示在Spring Boot里自动注入Mapper接口,我还是建了一个完整的Spring Boot工程。
数据库建一张最简单的用户表,字段包括id、user_name、age、email。注意这里的user_name用的是下划线命名,和Java类的userName对应,后面讲结果映射时会用到这个细节。
sql复制CREATE TABLE `t_user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_name` varchar(64) DEFAULT NULL,
`age` int(11) DEFAULT NULL,
`email` varchar(128) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
实体类就是四个字段加getter/setter,没有任何注解,纯POJO,这样最能验证ORM框架的通用性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一个核心点:用注解定义SQL映射关系
2.1 先定义@Select、@Insert、@Update、@Delete四个注解
注解是框架和开发者之间的约定。MyBatis有@Select、@Insert、@Update、@Delete,咱们也照做。这四个注解的代码结构一模一样,都是只含一个value属性,用于存放SQL语句。为了控制篇幅,下面以@Select和@Insert为例,另外两个复制即可。
java复制package com.mini.mybatis.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Select {
String value();
}
@Target(ElementType.METHOD)限定这个注解只能标在方法上,@Retention(RetentionPolicy.RUNTIME)保证运行时可以通过反射读到。反射读取是ORM框架的基础能力,所以保留策略必须是RUNTIME,如果写成CLASS或者SOURCE,等到JVM运行时就拿不到了。
这里我踩过一个坑:当时为了省事,给注解加上了@Inherited元注解,想着子接口能继承父接口的注解,结果发现MyBatis扫描Mapper时根本不需要这个,反而因为继承关系导致某些代理对象解析注解时行为异常。注解继承在Mapper这种场景下并不好用,直接不要加。
2.2 参数绑定:#{}占位符的替换逻辑
SQL注解里的语句不能直接执行,因为#{userId}这种MyBatis风格占位符,MySQL的JDBC驱动不认识,必须要转成标准的问号占位符,同时记录参数位置和对应的方法参数。我在实现时写了一个ParamNameResolver类,专门处理这个转换过程。
java复制public class ParameterBinder {
public BoundSql bind(String sql, Object[] args, Map<String, Object> paramMap) {
StringBuilder newSql = new StringBuilder();
List<Object> paramValues = new ArrayList<>();
int length = sql.length();
int i = 0;
while (i < length) {
char current = sql.charAt(i);
if (current == '#'
&& i + 1 < length
&& sql.charAt(i + 1) == '{') {
int end = sql.indexOf('}', i);
if (end == -1) {
throw new RuntimeException("SQL占位符缺少右花括号");
}
String paramName = sql.substring(i + 2, end).trim();
Object value = parseParamValue(paramName, args, paramMap);
paramValues.add(value);
newSql.append('?');
i = end + 1;
} else {
newSql.append(current);
i++;
}
}
return new BoundSql(newSql.toString(), paramValues);
}
private Object parseParamValue(String name, Object[] args, Map<String, Object> paramMap) {
if (paramMap.containsKey(name)) {
return paramMap.get(name);
}
for (Map.Entry<String, Object> entry : paramMap.entrySet()) {
if (entry.getKey().equals(name)) {
return entry.getValue();
}
}
throw new RuntimeException("无法解析参数: " + name);
}
}
这个解析器的核心逻辑是两个循环指针扫描SQL字符串,找到#和{就尝试取参数名。需要注意两个细节:第一,字符串中#后面不跟{的情况(比如注释里的#)要正常跳过,不做替换;第二,替换后的参数值必须按顺序存到List里,因为后面执行时PreparedStatement的占位符就是按下标从1开始塞参数的。
参数值的来源我设计成支持两种:方法注解里的参数名,以及通过@Param("xxx")指定的别名。这么做是为了解决Java反射拿不到方法参数名的问题。如果你用JDK 8加-parameters编译参数,理论上可以用反射拿到真实参数名,但为了兼容性和稳定性,显式用@Param还是最保险的方式。
2.3 结果映射:从ResultSet到实体对象的反射魔法
JDBC查询返回的ResultSet是一个二维表格结构,每一行对应一条记录,包含列名和值。ORM要做的就是把每一行数据转成对应的Java对象。我的处理思路是:遍历实体类的所有字段,按照字段名去ResultSet里取对应的列值,再用反射调用setter把值写进去。
java复制public <T> T mapRow(ResultSet rs, Class<T> clazz) throws Exception {
T object = clazz.getDeclaredConstructor().newInstance();
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
String fieldName = field.getName();
Object value = rs.getObject(fieldName);
if (value == null) {
continue;
}
field.setAccessible(true);
try {
field.set(object, value);
} catch (IllegalArgumentException ex) {
Class<?> targetType = field.getType();
Object converted = convertValue(value, targetType);
field.set(object, converted);
}
}
return object;
}
这个实现有一个不能忍的问题:数据库列名是user_name,Java字段是userName,直接rs.getObject("userName")是拿到数据的。因为JDBC驱动在查询时会返回真实的列名,查询时我们把这个字段值设进去之前,需要先做一个列名到字段名的映射转换,把下划线命名转成驼峰命名。
转换规则很直接:遇到下划线就跳过,并把下划线后面的字母转大写。user_name变成userName,nick_name变成nickName。实现时我在框架里加了一个CamelCaseUtils工具类,在结果映射之前先建立列名和字段名的对应关系。MyBatis里对应的配置叫mapUnderscoreToCamelCase,默认是false,但实际项目中基本都会打开。
3. 让Mapper“活”起来的灵魂:动态代理
3.1 代理的InvocationHandler是整条链路的指挥官
动态代理是整个框架最核心的部分。所有Mapper接口的调用都会进入同一个invoke方法,在这个方法里按顺序完成:解析方法注解里的SQL、创建参数映射、交给执行器执行、返回结果。因为代理类要在运行时处理多个接口、多个方法,所以invoke方法里第一步就是判断当前调用的是不是Object类的方法。
java复制public class MapperProxy<T> implements InvocationHandler, Serializable {
private final Class<T> mapperInterface;
private final JdbcExecutor executor;
public MapperProxy(Class<T> mapperInterface, JdbcExecutor executor) {
this.mapperInterface = mapperInterface;
this.executor = executor;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (Object.class.equals(method.getDeclaringClass())) {
return method.invoke(this, args);
}
Select select = method.getAnnotation(Select.class);
if (select != null) {
Object param = args != null && args.length == 1 ? args[0] : args;
return executor.query(select.value(), param, method);
}
Insert insert = method.getAnnotation(Insert.class);
if (insert != null) {
return executor.update(insert.value(), args);
}
Update update = method.getAnnotation(Update.class);
if (update != null) {
return executor.update(update.value(), args);
}
Delete delete = method.getAnnotation(Delete.class);
if (delete != null) {
return executor.update(delete.value(), args);
}
throw new RuntimeException("方法[" + method.getName() + "]上没有任何SQL注解");
}
}
这里有一个关键设计:判断Object类的方法要放在最前面。如果不加这个判断,调用toString()、hashCode()时会跑到SQL解析逻辑里去,因为toString()方法上当然没有@Select注解,框架就会抛异常。这个问题我在第一次测试时没注意,debug了半天才发现是toString的锅。
代理对象的创建由工厂类完成,用Proxy.newProxyInstance生成指定接口的实现。用泛型把创建过程封装好,调用方拿到的类型就是Mapper接口自己,不会暴露代理的细节。
java复制public class MapperProxyFactory<T> {
private final Class<T> mapperInterface;
public MapperProxyFactory(Class<T> mapperInterface) {
this.mapperInterface = mapperInterface;
}
public T newInstance(JdbcExecutor executor) {
MapperProxy<T> mapperProxy = new MapperProxy<>(mapperInterface, executor);
return (T) Proxy.newProxyInstance(
mapperInterface.getClassLoader(),
new Class[]{mapperInterface},
mapperProxy);
}
}
3.2 JdbcExecutor:真正执行SQL的地方
代理拿到SQL字符串和参数后,要把任务交给JdbcExecutor。这个类负责真正的数据库操作:获取连接、创建PreparedStatement、绑定参数、执行SQL、映射结果集。执行查询和更新是两条不同的路径,查询走query方法,更新走update方法。
java复制public class JdbcExecutor {
private DataSource dataSource;
public JdbcExecutor(DataSource dataSource) {
this.dataSource = dataSource;
}
public <T> List<T> query(String sql, Object param, Method method) throws Exception {
Connection connection = dataSource.getConnection();
try (PreparedStatement ps = connection.prepareStatement(sql)) {
if (param != null) {
if (param instanceof Map) {
// Map类型参数按#{key}绑定
} else {
ps.setObject(1, param);
}
}
try (ResultSet rs = ps.executeQuery()) {
List<T> resultList = new ArrayList<>();
Class<?> returnType = method.getReturnType();
// 确定实体类型,如果返回值是List则取泛型参数
while (rs.next()) {
Object row = mapRow(rs, entityClass);
resultList.add((T) row);
}
return resultList;
}
}
}
public int update(String sql, Object[] args) throws Exception {
Connection connection = dataSource.getConnection();
try (PreparedStatement ps = connection.prepareStatement(sql)) {
if (args != null) {
for (int i = 0; i < args.length; i++) {
ps.setObject(i + 1, args[i]);
}
}
return ps.executeUpdate();
}
}
}
query方法里有一个比较关键的点:返回值类型的判断。如果Mapper方法声明返回值是单个对象,MyBatis会优雅地处理selectOne和selectList两种语义。mybatis中你声明什么返回类型,它就尽量给你什么类型。我这里用了两个方法,queryList返回List,queryOne返回单条记录。框架内部根据方法是否有注解或者返回值类型来决定走哪条路。
如果你仔细看上面的代码会发现,我用try-with-resources管理了PreparedStatement和ResultSet,但连接没有在这里关闭。这里需要好好想想:如果在这个方法里调用了connection.close(),那Spring的@Transactional事务管理就废了,因为每次执行都会把连接关掉再重新获取,事务控制无从谈起。正确的做法是用Spring提供的DataSourceUtils,从当前事务上下文中获取绑定好的连接,事务提交后再统一释放。
java复制Connection connection = DataSourceUtils.getConnection(dataSource);
这行代码是事务配合的关键,后面第5部分会专门展开讲。
3.3 一个查询请求的完整生命周期
把上面的组件串起来,一个userMapper.findById(1L)的调用流程是这样的:Spring容器里注入的userMapper实际是一个JDK动态代理对象。调用findById时进入MapperProxy的invoke方法,解析出方法上的@Select注解拿到SQL字符串,创建参数集合,调用JdbcExecutor的query方法。执行器通过DataSourceUtils拿到连接,创建PreparedStatement,把参数值依次绑定到问号位置,执行查询。ResultSet返回后逐行解析,通过反射创建User对象并赋值。最终返回的是一个List还是单个对象,取决于方法声明的返回值类型。
MyBatis的整个核心链路和这个一模一样,只是增加了缓存、动态SQL、复杂结果映射等进阶能力。如果你已经把这里的每行代码都吃透了,看MyBatis源码时的障碍会小很多,因为主要的逻辑骨架你已经掌握了。
4. 真正搬进Spring Boot:注册与自动注入
4.1 注册SqlSession和MapperProxyFactory
单纯的JDK动态代理只能在手动写代码时使用,要融入Spring Boot让@Autowired能注入Mapper,必须把代理对象注册为Spring的Bean。这一步我用了Spring的ImportBeanDefinitionRegistrar和FactoryBean组合,原理和MyBatis的@MapperScan保持一致。
先定义一个@MapperScan注解,用于在启动类上开启扫描:
java复制@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Import(MiniMapperRegistrar.class)
public @interface MapperScan {
String value();
}
@Import是Spring Boot里一个很强大的扩展点,被@Import的类会在容器刷新时被实例化并处理。MiniMapperRegistrar实现ImportBeanDefinitionRegistrar接口,在registerBeanDefinitions方法里扫描指定包下的所有接口,为每个接口注册一个FactoryBean。
java复制public class MiniMapperRegistrar implements ImportBeanDefinitionRegistrar {
@Override
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata,
BeanDefinitionRegistry registry,
BeanNameGenerator importBeanNameGenerator) {
Map<String, Object> attributes = importingClassMetadata
.getAnnotationAttributes(MapperScan.class.getName());
String basePackage = (String) attributes.get("value");
ClassPathScanner scanner = new ClassPathScanner(importingClassMetadata);
scanner.registerFilters();
Set<BeanDefinition> candidateComponents = scanner.findCandidateComponents(basePackage);
for (BeanDefinition candidate : candidateComponents) {
String className = candidate.getBeanClassName();
if (className != null && className.contains(".")) {
Class<?> clazz = ClassUtils.resolveClassName(className, null);
String beanName = Introspector.decapitalize(clazz.getSimpleName());
BeanDefinitionBuilder builder = BeanDefinitionBuilder
.genericBeanDefinition(MiniMapperFactoryBean.class)
.addConstructorArgValue(className)
.addPropertyValue("dataSource", getDataSourceReference());
registry.registerBeanDefinition(beanName, builder.getBeanDefinition());
}
}
}
}
扫描器ClassPathScanner可以直接复用Spring的ClassPathBeanDefinitionScanner,不需要自己写文件遍历逻辑。给扫描器加上一个过滤器,只保留接口类型,因为Mapper就是接口。
java复制scanner.addIncludeFilter((metadataReader, metadataReaderFactory) -> {
ClassMetadata metadata = metadataReader.getClassMetadata();
return metadata.isInterface();
});
4.2 FactoryBean:创建代理对象的关键角色
FactoryBean是Spring里一个特别的设计。注册到容器里的类名是MiniMapperFactoryBean,但Spring容器里实际保存的是getObject()方法返回的对象。利用这个特性,在getObject()里调用MapperProxyFactory创建动态代理对象,就能让接口在Spring容器里拥有对应的Bean。
java复制public class MiniMapperFactoryBean<T> implements FactoryBean<T>, InitializingBean {
private Class<T> mapperInterface;
private DataSource dataSource;
private T mapperObject;
public MiniMapperFactoryBean(String mapperInterface) throws ClassNotFoundException {
this.mapperInterface = (Class<T>) Class.forName(mapperInterface);
}
@Override
public void afterPropertiesSet() {
JdbcExecutor executor = new JdbcExecutor(dataSource);
MapperProxyFactory<T> factory = new MapperProxyFactory<>(mapperInterface);
this.mapperObject = factory.newInstance(executor);
}
@Override
public T getObject() {
return mapperObject;
}
@Override
public Class<?> getObjectType() {
return mapperInterface;
}
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
}
注意afterPropertiesSet这个回调,它在Spring完成属性注入后执行,此时dataSource已经通过setter方法注入进来了,可以放心创建代理对象。为什么不在构造方法里创建?因为构造方法执行时dataSource还没注入,会拿到null。
这段实现里有个容易踩的坑:接口的全限定名通过constructor-arg传入,在构造器中用Class.forName加载。如果你用IDE直接new这个FactoryBean,第一个参数是Class类型,但在Spring的BeanDefinition里只能以字符串形式接收类名,注意这里的数据类型转换。
4.3 事务支持:让@Transactional自然生效
很多初学者会困惑,我的迷你框架自己getConnection,为什么Spring的@Transactional加了没反应,或者干脆报错?问题的核心在于连接管理。Spring事务管理器工作的前提是:同一个事务内所有的数据库操作必须使用同一个数据库连接。
如果我们每次执行SQL都自己dataSource.getConnection(),那不同Mapper方法拿到的连接就是不同的,事务管理器在连接A上开启事务,你的SQL却跑在连接B上,事务自然不生效。解决方式就是前面提到的DataSourceUtils.getConnection(dataSource),这个方法会先检查当前线程有没有绑定好的连接,如果有就直接复用,没有才重新获取。
更准确地说,Spring把连接绑在了TransactionSynchronizationManager的ThreadLocal里。当@Transactional标记的方法被调用时,事务管理器会从数据源获取一个连接,开启事务,然后把这个连接绑定到当前线程。之后不管哪个Mapper执行SQL,只要是通过DataSourceUtils获取连接,都能拿到这个已经被绑定的连接,从而参与到同一个事务中。
java复制Connection connection = DataSourceUtils.getConnection(dataSource);
这样一行改动,就让我们的迷你框架天然支持Spring的声明式事务。你只需要在Service方法上标注@Transactional,框架内部的所有Mapper调用都会自动加入到同一个事务中,异常时自动回滚。
这个知识点对应到MyBatis源码中就是SqlSessionTemplate对每个操作都调用SqlSessionUtils.getSqlSession来获取当前事务绑定的SqlSession,原理和我这里是一模一样的。
5. 批量插入与常用扩展:让迷你框架更实用
5.1 批量插入的两种实现方式
在热词里看到多次批量插入,顺手在这里把批量操作讲透。JDBC批量插入有两种层级:一种是应用层循环执行单条SQL,这叫逐条插入;另一种是通过PreparedStatement的addBatch和executeBatch批量提交,这才叫真正的批量插入。
java复制public int[] batchInsert(String sql, List<Object[]> batchArgs) throws Exception {
Connection connection = DataSourceUtils.getConnection(dataSource);
try (PreparedStatement ps = connection.prepareStatement(sql)) {
for (Object[] args : batchArgs) {
for (int i = 0; i < args.length; i++) {
ps.setObject(i + 1, args[i]);
}
ps.addBatch();
}
return ps.executeBatch();
}
}
批量插入快的原因在于减少了网络往返次数。如果你循环执行1000次单条插入,每次都要走一次网络通信;而批量插入一次把1000条SQL发给数据库,数据库端再优化执行。10万条以内的数据,批量插入的提升效果非常明显,执行时间往往能减少一个数量级。
这里还有一个MySQL特有的坑:JDBC连接串上要加上rewriteBatchedStatements=true参数。不加这个参数时,MySQL驱动会默认逐条执行你的addBatch,批量插入的效果完全体现不出来。加上之后驱动才会把多条SQL拼接成一条INSERT的VALUES多行形式发送给MySQL。实测,不加这个参数插10万条数据要1分多钟,加上后只需要2秒左右。
code复制jdbc:mysql://localhost:3306/test_db?rewriteBatchedStatements=true&useServerPrepStmts=false
5.2 驼峰下划线映射优化
如果你用的数据库表字段和实体类字段命名不一致,常见的做法有两种:一是在SQL里给列起别名,比如select user_name as userName;二是在框架里加一个映射转换工具。
MyBatis的默认行为是列名直接对应实体字段名,所以user_name对不上userName。通常项目里都会配置map-underscore-to-camel-case: true。框架实现上也不复杂,就是我之前提到的CamelCaseUtils,遍历实体字段时生成两种可能的名字去ResultSet里找。
java复制private String toCamelCase(String columnName) {
StringBuilder result = new StringBuilder();
boolean upperCase = false;
for (char c : columnName.toCharArray()) {
if (c == '_') {
upperCase = true;
} else if (upperCase) {
result.append(Character.toUpperCase(c));
upperCase = false;
} else {
result.append(c);
}
}
return result.toString();
}
这么做有个好处:数据库规范的列名设计保持下划线风格不变,Java代码里继续用驼峰命名,两边都不用将就。
5.3 类型转换器:解决数据库类型和Java类型不一致
JDBC驱动返回的数据库类型不一定和Java字段类型完全匹配。比如MySQL的TINYINT可能返回Integer或Boolean,DECIMAL可能返回BigDecimal,TIMESTAMP可能返回Timestamp。如果直接把值通过反射塞入字段,可能因为类型不匹配报IllegalArgumentException。
完美方案是类型处理器(TypeHandler),MyBatis里就是用TypeHandler处理类型转换,比如IntegerTypeHandler、StringTypeHandler、DateTypeHandler等。实现一个TypeHandler要定义两个方向:setParameter(写参数)和getResult(读结果)。平时我们感觉不到这些处理器存在,是因为MyBatis根据参数类型和数据库类型自动选择了对应的处理器。
迷你框架可以简化成:在结果映射时先把ResultSet的值转成字符串,再尝试转成目标字段类型。这种方案虽然牺牲了些性能,但对于学习型项目完全够用。性能敏感的场景还是要用完整的TypeHandler体系。
6. 踩坑实录:这些问题我调试了一整晚
6.1 参数绑定错位:#{name}变成了null
第一次写参数解析器时,我只把SQL里的#{xxx}替换成了问号,但参数值取不到,所有查询条件都变成了null。排查到最后发现,占位符解析时参数名的提取是多了一个空格。
常见的情况是SQL写成#{name },参数名解析出来是name ,多了尾部的空格,参数Map里拿不到对应的key。解决方法是解析时用trim()去掉首尾空格,同时对参数名做空字符串校验。不要小看这个空格问题,SQL字符串里稍微格式一乱就会触发。
6.2 proxy的toString坑
这个坑前面提过,动态代理里所有方法都会走invoke。你如果没有在最前面加Object类的判断,代理对象被日志打印或者被框架内部调用toString时,就会跑到SQL注解解析逻辑里,因为toString方法上没有@Select注解,直接抛异常。这种问题一旦出现,报错信息还特别迷惑,因为代理对象正常输出时会优先调用toString。
类似的方法还有equals和hashCode。如果你在Map里放了Mapper代理对象,或者用Set存储,就会触发这个问题。正确处理方式就是判断method.getDeclaringClass()是不是Object,是的话直接走method.invoke(this, args)。
6.3 连接泄漏与事务不生效
连接泄漏是刚写完框架时最容易犯的错。最开始我用完就关,导致Spring事务完全失效,排查半天才发现问题。后来用DataSourceUtils获取连接,但如果你以为DataSourceUtils获取的连接也要手动close,那就错了。DataSourceUtils.getConnection会判断当前连接是不是绑定了事务,如果绑定了,close时会调用releaseConnection,把连接归还给连接池而不是真正关闭。正确用法是你只管获取和用完关,DataSourceUtils会代理真正的事务性关闭。
如果发现@Transactional还是不生效,先检查两件事:第一,DataSource是不是同一个实例,如果自己在配置类里new了一个DataSource,又不放进Spring容器,那事务管理器拿到的连接和框架里用到的连接永远不是同一个;第二,Mapper的代理对象是不是通过Spring容器注入的,如果是自己手动创建的FactoryBean,Spring的AOP代理无法介入。
6.4 循环依赖:Mapper注入Service又依赖Mapper
在实际集成中还会遇到循环依赖的问题。假设ServiceA注入了MapperB,而MapperB的创建依赖ServiceA初始化完成,Spring就会报循环依赖错误。Spring Boot 2.6以后默认禁止循环依赖,遇到这个报错需要审视设计:是不是应该把公共方法抽到独立的Service里。大部分情况下循环依赖的出现都说明代码结构有优化的空间。
6.5 Spring Boot版本太高引发的兼容问题
热词里看到“springboot版本太高”的相关搜索。Spring Boot 3.x引入了Jakarta命名空间,如果你的老项目用了javax.sql.DataSource,直接升级之后编译都过不了。做这种学习型框架时,建议先用Spring Boot 2.7版本把核心逻辑跑通,再考虑升级。如果你遇到DataSourceUtils类找不到的情况,检查一下是不是数据结构导入了spring-jdbc的jar包。这个坑看起来低级,但在依赖冲突时特别容易发生。
7. 最后的建议:看完文章再动手,动手再看源码
写完这个迷你版MyBatis之后,我有一个特别深的感触:很多框架在你没有亲手写过一遍之前,看源码常常是走马观花,看完就忘。但当你自己设计过参数解析、动态代理、结果映射这套流程之后,再回看MyBatis的源码,会发现每个代码文件都能跟自己的实现对应上,看源码的速度和理解的深度完全不可同日而语。
如果你打算动手实现一遍,我给一个顺序建议:第一步只写注解和JdbcExecutor,用main函数手动创建代理对象测试;第二步引入Spring Boot和DataSource,用FactoryBean注册代理Bean;第三步再处理事务和批量插入。每一步都能独立验证,遇到问题时排查范围小,不会一头扎进十几处可能的bug里。
一个人如果能把MyBatis这种主流框架的核心原理亲手实现一遍,再去面试时被问到“MyBatis的工作原理”“MyBatis和JDBC有什么区别”这类问题时,你的回答会非常有底气。纸上得来终觉浅,绝知此事要躬行。代码已经在我的GitHub上开源,本地把项目拉下来直接跑,有不明白的地方欢迎留言交流。
