# 面经手册 · 第35篇《Mapper 接口没有实现类,怎么执行的?动态代理源码解析》
作者:小傅哥
博客:https://bugstack.cn (opens new window)
沉淀、分享、成长,让自己和他人都能有所收获!😄
# 一、前言
如果你用过 MyBatis,一定写过这样的代码:
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
User user = userMapper.findById(1L);
2
看起来平平无奇,但仔细想想——UserMapper 是一个接口,没有实现类,findById 方法也没有方法体,那这行代码到底是怎么执行的?谁来实现了这个接口?方法调用怎么就变成了 SQL 执行?
这就是 MyBatis 最巧妙的设计:用 JDK 动态代理为 Mapper 接口生成了实现类,让你以面向接口的方式操作数据库,而不需要写任何实现代码。
但面试官不会只问"动态代理"四个字就放过你。MapperProxy 是什么时候创建的?invoke 方法里做了什么?MapperMethod 又是怎么路由到 SqlSession 的?方法能不能重载?namespace 到底绑定了什么?——这些才是区分"背过八股"和"真看过源码"的分水岭。
本文从源码出发,把 Mapper 接口的执行链路彻底讲透。
# 二、面试题
谢飞机,小记!,上次背了 #{} 和 ${} 的区别,这次信心满满又来了。
面试官:谢飞机,MyBatis 的 Mapper 接口没有实现类,它是怎么工作的?
谢飞机:动态代理!MyBatis 用 JDK 动态代理给 Mapper 接口生成了代理对象。
面试官:嗯,那代理对象的 invoke 方法里做了什么?
谢飞机:呃... 好像是调了 SqlSession 的方法?
面试官:中间还有 MapperMethod,你知道它的作用吗?
谢飞机:MapperMethod... 就是封装了一下?
面试官:封装了什么?SqlSession 那么多方法,它怎么知道该调 select 还是 insert?
谢飞机:这个... 根据方法名?
面试官:Mapper 接口的方法能重载吗?为什么?
谢飞机:应该... 不能?因为... 同名方法会冲突?
面试官:冲突在哪?你说清楚。
谢飞机:我... 再见!ヾ( ̄▽ ̄)
# 三、Mapper 接口工作原理全链路
# 1. 从 getMapper 说起
一切从 sqlSession.getMapper(UserMapper.class) 开始:
SqlSession.getMapper(UserMapper.class)
→ Configuration.getMapper(UserMapper.class, sqlSession)
→ MapperRegistry.getMapper(UserMapper.class, sqlSession)
→ MapperProxyFactory.newInstance(sqlSession)
→ Proxy.newProxyInstance(...) ← JDK 动态代理创建代理对象
2
3
4
5
最终返回的不是 UserMapper 的实现类实例,而是一个 JDK 动态代理对象,它的 InvocationHandler 是 MapperProxy。
# 2. 调用链路总览
userMapper.findById(1L)
→ MapperProxy.invoke() ← 代理拦截
→ MapperMethod.execute() ← 方法路由
→ SqlSession.selectOne() ← 具体 SQL 执行
→ Executor.query() ← 执行器
→ StatementHandler.query() ← 语句处理器
→ PreparedStatement.execute() ← JDBC 执行
2
3
4
5
6
7
一句话概括:接口方法调用 → 代理拦截 → 方法路由 → SqlSession 执行 SQL。
# 四、源码追踪:从注册到执行
# 1. MapperRegistry — Mapper 的注册中心
MyBatis 启动时,会解析所有的 Mapper 接口和 XML 映射文件,把每个接口注册到 MapperRegistry 中:
// org.apache.ibatis.binding.MapperRegistry
public class MapperRegistry {
// 核心:接口 → MapperProxyFactory 的映射
private final Map<Class<?>, MapperProxyFactory<?>> knownMappers = new HashMap<>();
public <T> void addMapper(Class<T> type) {
if (type.isInterface()) {
if (hasMapper(type)) {
throw new BindingException("Type " + type + " is already known to the MapperRegistry.");
}
try {
// 为每个接口创建一个 MapperProxyFactory
knownMappers.put(type, new MapperProxyFactory<>(type));
} catch (Exception e) {
throw new BindingException("Error adding mapper.", e);
}
}
}
@SuppressWarnings("unchecked")
public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
final MapperProxyFactory<T> mapperProxyFactory = (MapperProxyFactory<T>) knownMappers.get(type);
if (mapperProxyFactory == null) {
throw new BindingException("Type " + type + " is not known to the MapperRegistry.");
}
try {
// 通过工厂创建代理实例
return mapperProxyFactory.newInstance(sqlSession);
} catch (Exception e) {
throw new BindingException("Error getting mapper instance.", e);
}
}
}
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
关键发现:MapperRegistry 内部维护了一个 knownMappers 集合,Key 是 Mapper 接口的 Class 对象,Value 是 MapperProxyFactory。每个 Mapper 接口对应一个工厂,由工厂负责创建代理对象。
# 2. MapperProxyFactory — 代理对象的工厂
// org.apache.ibatis.binding.MapperProxyFactory
public class MapperProxyFactory<T> {
private final Class<T> mapperInterface;
private final Map<Method, MapperMethod> methodCache = new ConcurrentHashMap<>();
public MapperProxyFactory(Class<T> mapperInterface) {
this.mapperInterface = mapperInterface;
}
@SuppressWarnings("unchecked")
protected T newInstance(MapperProxy<T> mapperProxy) {
// JDK 动态代理:创建实现 mapperInterface 接口的代理对象
return (T) Proxy.newProxyInstance(
mapperInterface.getClassLoader(),
new Class[]{mapperInterface},
mapperProxy
);
}
public T newInstance(SqlSession sqlSession) {
// 创建 MapperProxy(InvocationHandler)
final MapperProxy<T> mapperProxy = new MapperProxy<>(sqlSession, mapperInterface, methodCache);
// 创建代理对象
return newInstance(mapperProxy);
}
}
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
关键发现:
methodCache是一个ConcurrentHashMap,缓存了Method → MapperMethod的映射。同一个方法不会重复创建MapperMethod对象。Proxy.newProxyInstance()是 JDK 动态代理的核心 API,它要求目标必须是接口,这正是 Mapper 只能是接口的原因。
# 3. MapperProxy — 代理拦截的核心
// org.apache.ibatis.binding.MapperProxy
public class MapperProxy<T> implements InvocationHandler, Serializable {
private final SqlSession sqlSession;
private final Class<T> mapperInterface;
private final Map<Method, MapperMethod> methodCache;
public MapperProxy(SqlSession sqlSession, Class<T> mapperInterface, Map<Method, MapperMethod> methodCache) {
this.sqlSession = sqlSession;
this.mapperInterface = mapperInterface;
this.methodCache = methodCache;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
// 如果调用的是 Object 的方法(toString、hashCode、equals 等),直接执行
if (Object.class.equals(method.getDeclaringClass())) {
return method.invoke(this, args);
}
// 从缓存获取或创建 MapperMethod,然后执行
return cachedMapperMethod(method).execute(sqlSession, args);
} catch (Throwable t) {
throw ExceptionUtil.unwrapThrowable(t);
}
}
private MapperMethod cachedMapperMethod(Method method) {
return methodCache.computeIfAbsent(method,
k -> new MapperMethod(mapperInterface, method, sqlSession.getConfiguration()));
}
}
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
关键发现:
invoke是代理拦截的入口。每次调用 Mapper 接口的方法,都会走进这个方法。- 如果调用的是
Object类的方法(如toString()),不走代理逻辑,直接反射调用。 - 核心逻辑就一行:
cachedMapperMethod(method).execute(sqlSession, args)——找到对应的MapperMethod,把SqlSession和参数传给它执行。 MapperMethod有缓存,不会每次调用都新建。
# 4. MapperMethod — 方法路由器
// org.apache.ibatis.binding.MapperMethod
public class MapperMethod {
private final SqlCommand command;
private final MethodSignature method;
public MapperMethod(Class<?> mapperInterface, Method method, Configuration config) {
this.command = new SqlCommand(config, mapperInterface, method);
this.method = new MethodSignature(config, mapperInterface, method);
}
public Object execute(SqlSession sqlSession, Object[] args) {
Object result;
switch (command.getType()) {
case INSERT:
Object param = method.convertArgsToSqlCommandParam(args);
result = sqlSession.insert(command.getName(), param);
break;
case UPDATE:
param = method.convertArgsToSqlCommandParam(args);
result = sqlSession.update(command.getName(), param);
break;
case DELETE:
param = method.convertArgsToSqlCommandParam(args);
result = sqlSession.delete(command.getName(), param);
break;
case SELECT:
if (method.returnsVoid() && method.hasResultHandler()) {
executeWithResultHandler(sqlSession, args);
result = null;
} else if (method.returnsMany()) {
result = executeForMany(sqlSession, args);
} else if (method.returnsMap()) {
result = executeForMap(sqlSession, args);
} else if (method.returnsCursor()) {
result = executeForCursor(sqlSession, args);
} else {
// 默认:返回单个对象
param = method.convertArgsToSqlCommandParam(args);
result = sqlSession.selectOne(command.getName(), param);
}
break;
case FLUSH:
result = sqlSession.flushStatements();
break;
default:
throw new BindingException("Unknown execution method for: " + command.getName());
}
// 返回值校验
if (result == null && method.getReturnType().isPrimitive() && !method.returnsVoid()) {
throw new BindingException("Mapper method '" + command.getName()
+ "' attempted to return null from a method with a primitive return type (" + method.getReturnType() + ").");
}
return result;
}
}
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
54
55
关键发现:
MapperMethod内部有两个重要组件:SqlCommand(SQL 命令信息)和MethodSignature(方法签名信息)。execute方法根据command.getType()决定调用SqlSession的哪个方法(select/insert/update/delete)。- 对于 SELECT,还要根据返回值类型(单个对象、列表、Map、游标、ResultHandler)做进一步路由。
command.getName()返回的是namespace.id格式的全限定名,这就是定位MappedStatement的唯一标识。
# 5. SqlCommand — SQL 命令的元信息
// org.apache.ibatis.binding.MapperMethod.SqlCommand
public static class SqlCommand {
private final String name; // MappedStatement 的 ID = namespace + "." + 方法名
private final SqlCommandType type; // SQL 类型:SELECT / INSERT / UPDATE / DELETE
public SqlCommand(Configuration configuration, Class<?> mapperInterface, Method method) {
final String methodName = method.getName();
final String declaringClass = method.getDeclaringClass().getName();
String statementName = declaringClass + "." + methodName; // ← 关键:全限定名
if (configuration.hasStatement(statementName)) {
this.name = statementName;
} else {
// 兜底:用接口全限定名 + 方法名
String debugStatementName = mapperInterface.getName() + "." + methodName;
this.name = debugStatementName;
}
this.type = configuration.getMappedStatement(this.name).getSqlCommandType();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
关键发现:statementName 的构造方式是 接口全限定名 + "." + 方法名,这正好对应 XML 中 <select id="findById"> 和 namespace="com.example.UserMapper" 的组合。这就是 Mapper 接口方法和 XML 映射 SQL 的绑定纽带。
# 五、方法重载问题
# 1. 为什么 Mapper 接口方法不能重载?
Java 允许接口方法重载:
public interface UserMapper {
List<User> findByIds(List<Long> ids);
List<User> findByIds(Long[] ids); // 编译通过,但运行时会出问题
}
2
3
4
但 MyBatis 中不允许方法重载。原因要从 MappedStatement 的 ID 构造规则说起。
# 2. 根因分析
MappedStatement 的唯一标识 = namespace + "." + 方法名。
接口:com.example.UserMapper
方法1:List<User> findByIds(List<Long> ids) → MappedStatement ID: com.example.UserMapper.findByIds
方法2:List<User> findByIds(Long[] ids) → MappedStatement ID: com.example.UserMapper.findByIds
↑ 两个方法生成了相同的 ID!
2
3
4
MyBatis 在解析 XML 映射文件时,每个 <select>/<insert>/<update>/<delete> 标签都会生成一个 MappedStatement 对象,以 namespace.id 作为唯一 Key 存入 Configuration 的 mappedStatements 集合(StrictMap):
// org.apache.ibatis.session.Configuration
protected final Map<String, MappedStatement> mappedStatements = new StrictMap<>("Mapped Statements collection");
public void addMappedStatement(MappedStatement ms) {
mappedStatements.put(ms.getId(), ms); // 如果 ID 重复,StrictMap 会抛异常
}
2
3
4
5
6
StrictMap 不允许重复 Key:
// org.apache.ibatis.session.Configuration.StrictMap
@Override
public V put(String key, V value) {
if (containsKey(key)) {
throw new IllegalArgumentException(name + " already contains value for " + key);
}
return super.put(key, value);
}
2
3
4
5
6
7
8
两种冲突场景:
- XML 方式:两个重载方法对应同一个
<select id="findByIds">,ID 重复 → 启动时抛异常。 - 注解方式:两个重载方法都加了
@Select,生成的MappedStatementID 相同 → 启动时抛异常。
# 3. 结论
Mapper 接口方法不能重载,因为 MappedStatement 的 ID 只由 namespace + 方法名 组成,不包含参数签名。重载方法同名会导致 ID 冲突。
# 六、接口绑定的两种方式
# 1. XML 绑定
通过 XML 映射文件的 namespace 指定 Mapper 接口的全限定名:
<!-- UserMapper.xml -->
<mapper namespace="com.example.mapper.UserMapper">
<select id="findById" resultType="User">
SELECT * FROM user WHERE id = #{id}
</select>
<insert id="insert">
INSERT INTO user(name, age) VALUES(#{name}, #{age})
</insert>
</mapper>
2
3
4
5
6
7
8
9
10
// UserMapper.java
public interface UserMapper {
User findById(Long id);
int insert(User user);
}
2
3
4
5
绑定规则:namespace = 接口全限定名,id = 方法名。
# 2. 注解绑定
通过在接口方法上添加注解直接编写 SQL:
// UserMapper.java
public interface UserMapper {
@Select("SELECT * FROM user WHERE id = #{id}")
User findById(Long id);
@Insert("INSERT INTO user(name, age) VALUES(#{name}, #{age})")
int insert(User user);
@Update("UPDATE user SET name = #{name} WHERE id = #{id}")
int update(User user);
@Delete("DELETE FROM user WHERE id = #{id}")
int delete(Long id);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
四个核心注解:
| 注解 | 对应 SQL 类型 | 示例 |
|---|---|---|
@Select | SELECT | @Select("SELECT * FROM user WHERE id =#{id}") |
@Insert | INSERT | @Insert("INSERT INTO user(name) VALUES(#{name})") |
@Update | UPDATE | @Update("UPDATE user SET name =#{name}") |
@Delete | DELETE | @Delete("DELETE FROM user WHERE id =#{id}") |
注解方式还支持 @Options(配置主键回写等)和 @ResultMap(引用结果映射)。
# 3. 注解 vs XML:怎么选?
| 对比项 | 注解方式 | XML 方式 |
|---|---|---|
| SQL 可见性 | SQL 和接口在一起,一目了然 | SQL 集中在 XML,需要切换查看 |
| 动态 SQL | 支持(<script> 标签),但很丑 | 天然支持(if/choose/foreach/where 等) |
| 复杂查询 | 多表关联、嵌套查询写起来痛苦 | 动态 SQL 标签灵活组合,可读性好 |
| SQL 长度 | 短 SQL 简洁,长 SQL 臃肿 | 长短都无影响 |
| 维护性 | 改 SQL 需改 Java 文件,重新编译 | 改 SQL 只改 XML,热更新方便 |
| 调试 | 不方便断点 | 可以查看解析后的完整 SQL |
实际项目推荐:
- 简单的单表 CRUD → 可以用注解,减少 XML 文件数量。
- 复杂的多表关联、动态条件查询 → 必须用 XML。
- 企业项目中绝大多数用 XML,因为业务 SQL 通常较复杂,动态 SQL 是刚需。
注解中的动态 SQL(不推荐,但确实支持):
@Select("<script>" +
"SELECT * FROM user" +
"<where>" +
" <if test='name != null'>AND name = #{name}</if>" +
" <if test='age != null'>AND age = #{age}</if>" +
"</where>" +
"</script>")
List<User> findUsers(@Param("name") String name, @Param("age") Integer age);
2
3
4
5
6
7
8
这种写法可读性极差,不如直接用 XML。
# 七、namespace 的作用
# 1. namespace 的三重身份
namespace 不仅仅是一个"命名空间",它在 MyBatis 中扮演三个重要角色:
角色一:SQL 隔离
不同 namespace 下可以有同名的 SQL ID,互不冲突:
<!-- UserMapper.xml -->
<mapper namespace="com.example.mapper.UserMapper">
<select id="findById">SELECT * FROM user WHERE id = #{id}</select>
</mapper>
<!-- OrderMapper.xml -->
<mapper namespace="com.example.mapper.OrderMapper">
<select id="findById">SELECT * FROM order WHERE id = #{id}</select>
</mapper>
2
3
4
5
6
7
8
9
两个 findById 不冲突,因为完整 ID 分别是 com.example.mapper.UserMapper.findById 和 com.example.mapper.OrderMapper.findById。
角色二:绑定 Mapper 接口
当 namespace 等于某个 Mapper 接口的全限定名时,XML 中的 SQL 就和该接口的方法绑定:
namespace = "com.example.mapper.UserMapper"
+ id = "findById"
→ MappedStatement ID = "com.example.mapper.UserMapper.findById"
→ 对应 UserMapper.findById() 方法
2
3
4
如果 namespace 写错了(和接口全限定名不一致),启动不会报错,但运行时 getMapper 调用方法会抛 BindingException,找不到对应的 MappedStatement。
角色三:唯一标识 MappedStatement
Configuration 中的 mappedStatements 集合以 namespace.id 作为 Key,namespace 保证了全局唯一性。
# 2. namespace 的常见错误
<!-- ❌ 错误:namespace 和接口全限定名不匹配 -->
<mapper namespace="com.example.dao.UserDao">
<select id="findById">...</select>
</mapper>
<!-- 接口是 com.example.mapper.UserMapper -->
<!-- 调用 userMapper.findById() → 找不到 MappedStatement → BindingException -->
2
3
4
5
6
7
<!-- ❌ 错误:namespace 重复 -->
<!-- 两个 XML 文件 namespace 相同,ID 重复会报错 -->
2
<!-- ✅ 正确:namespace = 接口全限定名 -->
<mapper namespace="com.example.mapper.UserMapper">
<select id="findById">...</select>
</mapper>
2
3
4
# 3. namespace 与跨 namespace 引用
MyBatis 支持跨 namespace 引用结果映射和 SQL 片段:
<!-- CommonMapper.xml -->
<mapper namespace="com.example.mapper.CommonMapper">
<sql id="userColumns">id, name, age, email</sql>
</mapper>
<!-- UserMapper.xml -->
<mapper namespace="com.example.mapper.UserMapper">
<select id="findAll" resultType="User">
SELECT <include refid="com.example.mapper.CommonMapper.userColumns"/> FROM user
</select>
</mapper>
2
3
4
5
6
7
8
9
10
11
跨 namespace 引用使用完整的 namespace.sqlId 格式。
# 八、常见面试追问
# Q1:Mapper 接口能通过 new 创建实例吗?
不能。Mapper 接口是纯接口,没有实现类,new UserMapper() 会编译报错。只能通过 sqlSession.getMapper() 或 @Autowired(Spring 集成后)获取代理对象。
如果强行 new 了一个实现类,那也不是 MyBatis 管理的,无法执行 SQL。
# Q2:@Mapper 和 @MapperScan 有什么区别?
@Mapper:标注在单个 Mapper 接口上,告诉 MyBatis-Spring 这个接口需要注册为 Bean。每个接口都要加,接口多了很繁琐。
@Mapper
public interface UserMapper { ... }
2
@MapperScan:标注在 Spring Boot 启动类或配置类上,指定包路径,自动扫描该包下所有 Mapper 接口并注册。推荐方式,一个注解搞定所有 Mapper。
@MapperScan("com.example.mapper")
@SpringBootApplication
public class Application { ... }
2
3
本质:@MapperScan 是批量版的 @Mapper,底层都调用了 MapperFactoryBean 来创建代理对象并注册到 Spring 容器。
# Q3:一个 Mapper 接口能对应多个 XML 文件吗?
不能。MyBatis 不允许同一个 namespace 出现在多个 XML 文件中。如果两个 XML 的 namespace 相同,启动时会因为 MappedStatement ID 冲突报错。
如果一个接口的 SQL 太多,建议按业务拆分成多个接口(如 UserReadMapper 和 UserWriteMapper),而不是一个接口对应多个 XML。
# Q4:MapperProxy 为什么用 JDK 动态代理而不是 CGLIB?
因为 Mapper 是接口,JDK 动态代理专门用于接口代理,是 Java 原生支持的方案,不需要额外依赖。CGLIB 用于代理类(代理没有接口的类),而 Mapper 接口本身没有实现类,CGLIB 反而用不上。
MyBatis 的设计理念就是面向接口编程,所以 JDK 动态代理是最自然的选择。
# Q5:getMapper 每次调用都创建新的代理对象吗?
是的。每次调用 sqlSession.getMapper() 都会通过 MapperProxyFactory.newInstance() 创建一个新的代理对象。但 MapperMethod 有缓存(methodCache),不会重复创建。
在 Spring 集成中,MapperFactoryBean 是单例的,Spring 容器中每个 Mapper 只有一个代理实例。
# 九、总结
记住三个核心要点:
1. Mapper 接口的执行链路:
接口方法调用 → MapperProxy.invoke()(JDK动态代理)
→ MapperMethod.execute()(方法路由,根据SQL类型分发)
→ SqlSession.selectXxx/insert/update/delete(执行SQL)
2. Mapper 接口方法不能重载:
MappedStatement 的 ID = namespace + 方法名
重载方法同名 → ID 冲突 → 启动报错
3. namespace 的三重作用:
SQL 隔离(不同namespace可以有同名ID)
+ 绑定接口(namespace = 接口全限定名)
+ 唯一标识(namespace.id 是 MappedStatement 的全局唯一Key)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
面试回答模板:
Mapper 接口没有实现类,MyBatis 通过 JDK 动态代理为它生成代理对象。调用流程是:
SqlSession.getMapper()从MapperRegistry中找到对应的MapperProxyFactory,由工厂创建MapperProxy代理实例。当调用接口方法时,MapperProxy.invoke()拦截调用,根据 Method 对象从缓存获取或创建MapperMethod,MapperMethod内部的SqlCommand持有 SQL 类型和MappedStatement的 ID,execute方法根据 SQL 类型路由到SqlSession对应的方法执行。Mapper 接口方法不能重载,因为
MappedStatement的 ID 只由 namespace + 方法名组成,不包含参数签名,重载方法会生成相同 ID 导致冲突。接口绑定有 XML 和注解两种方式,XML 通过 namespace 绑定接口,注解通过
@Select/@Insert等直接在方法上定义 SQL。实际项目推荐 XML,因为动态 SQL 支持更好,复杂查询更易维护。

