作為 Java 後端開發,你是否曾經糾結過:查詢用户信息時,要不要把用户關聯的訂單、地址一起查出來?全部查詢性能肯定受影響,可不查又怕後面用到時反覆訪問數據庫。這種"查不查"的兩難抉擇,其實可以通過 MyBatis 的延遲加載機制漂亮解決。那麼問題來了,MyBatis 到底支持延遲加載嗎?它背後的實現原理又是什麼?
MyBatis 的延遲加載支持情況
MyBatis 確實支持延遲加載(Lazy Loading)功能,這是一種按需加載的策略,可以有效減輕系統負擔,提高查詢效率。
簡單來説,當我們查詢一個實體時,對於它的關聯對象,不立即從數據庫中加載,而是在第一次真正使用到關聯對象時才去數據庫查詢。這樣做可以避免一次性加載過多數據,尤其是在關聯關係較多或數據量較大的情況下。
延遲加載的配置方式
MyBatis 提供了兩個全局參數來控制延遲加載:
<settings>
<!-- 開啓延遲加載功能 -->
<setting name="lazyLoadingEnabled" value="true"/>
<!-- 設置激進延遲加載策略 -->
<setting name="aggressiveLazyLoading" value="false"/>
</settings>
lazyLoadingEnabled:設置為 true 時開啓延遲加載功能aggressiveLazyLoading:設置為 false 時,按需加載對象屬性(只有當調用該屬性的 getter 方法時才加載);設置為 true 時,任何對對象方法的調用都會觸發所有標記為延遲加載的屬性加載
舉個簡單例子,當aggressiveLazyLoading=true時:
User user = userMapper.getUserById(1);
user.getUsername(); // 僅想獲取用户名,但會觸發orderList等所有延遲加載屬性的加載
// 或者
System.out.println(user); // 調用toString()方法,卻觸發了所有延遲屬性的加載
因此,生產環境中通常建議保持aggressiveLazyLoading=false,避免不必要的性能損耗。
除了全局配置外,還可以在關聯查詢中單獨設置:
<!-- association關聯查詢時使用延遲加載 -->
<association property="author" column="author_id" select="selectAuthor" fetchType="lazy"/>
<!-- collection集合查詢時使用延遲加載 -->
<collection property="posts" ofType="Post" column="id" select="selectPostsForBlog" fetchType="lazy"/>
通過fetchType屬性可以覆蓋全局的延遲加載設置,值為lazy表示使用延遲加載,eager表示立即加載。
延遲加載的觸發條件
延遲加載並非任何操作都會觸發,具體的觸發條件包括:
- 調用延遲屬性的 getter 方法:如
user.getOrderList() - 對延遲集合屬性進行操作:如
orderList.size()、orderList.isEmpty()、遍歷操作等 - 僅獲取代理對象引用不會觸發加載:必須調用其方法才會觸發
User user = userMapper.getUserById(1);
// 以下操作不會觸發延遲加載
List<Order> orderList = null;
orderList = user.getOrderList(); // 僅獲取引用,不會觸發加載
// 以下操作會觸發延遲加載
int size = user.getOrderList().size(); // 調用size()方法觸發加載
boolean isEmpty = user.getOrderList().isEmpty(); // 調用isEmpty()方法觸發加載
for (Order order : user.getOrderList()) { // 遍歷觸發加載
// 處理訂單
}
延遲加載的實現原理
MyBatis 的延遲加載主要是通過動態代理實現的。這裏涉及兩種代理模式:
- JDK 動態代理
- CGLIB 動態代理
字節碼層面的代理原理
理解代理選擇的核心,需要了解底層實現原理:
- JDK 動態代理:基於接口實現,通過
java.lang.reflect.Proxy類在運行時生成接口的代理類。它要求目標類必須實現至少一個接口。 - CGLIB 動態代理:基於字節碼生成技術,通過創建目標類的子類來實現代理。CGLIB 在運行時動態修改字節碼,重寫目標類的方法以插入延遲加載邏輯。
簡單理解:JDK 代理是"實現接口",CGLIB 代理是"繼承類"。這就是為什麼實現了接口的類優先使用 JDK 代理,而普通類只能用 CGLIB 代理。
代理機制的選擇
MyBatis 會根據目標類是否實現接口選擇使用不同的代理機制:
// MyBatis ProxyFactory選擇邏輯(簡化版)
public class ProxyFactory {
private ProxyFactory() {
// Prevent Instantiation
}
public static Object createProxy(Object target, ResultLoaderMap lazyLoader, Configuration configuration,
ObjectFactory objectFactory, List<Class<?>> constructorArgTypes,
List<Object> constructorArgs) {
// target: 真實對象(如User實例)
// lazyLoader: 存儲延遲加載任務的映射(屬性名→加載器)
// 判斷目標類是否為接口或者代理類
boolean isJdkProxy = target.getClass().getInterfaces().length > 0
&& !Proxy.isProxyClass(target.getClass());
if (isJdkProxy) {
// 使用JDK動態代理(優先選擇,性能略優且符合Java標準)
return JdkProxyFactory.createProxy(target, lazyLoader, configuration, objectFactory,
constructorArgTypes, constructorArgs);
} else {
// 使用CGLIB動態代理(目標是非接口的普通類時)
return CglibProxyFactory.createProxy(target, lazyLoader, configuration, objectFactory,
constructorArgTypes, constructorArgs);
}
}
}
- 如果目標類實現了接口,MyBatis 會優先使用 JDK 動態代理(性能更好且符合 Java 標準)
- 如果目標類沒有實現接口,則使用 CGLIB 動態代理
注意:MyBatis 3.2.8+完全支持 JDK/CGLIB 代理自動切換,早期版本可能需要手動配置代理工廠。MyBatis 自 3.3.0 起,若檢測到 classpath 中無 CGLIB 依賴,會自動引入mybatis-cglib-proxy模塊(基於 CGLIB 3.2.5),因此 Maven 項目通常無需額外配置。若使用 Gradle 或手動管理依賴,需確保相關 jar 包存在。
動態代理實現優化
JDK 和 CGLIB 代理處理邏輯中有很多相似部分,可以抽取公共方法處理:
// 公共方法處理邏輯
private Object handleSpecialMethods(Object target, Method method, Object[] args) throws Throwable {
final String methodName = method.getName();
if (methodName.equals("equals")) {
return target.equals(args[0]);
} else if (methodName.equals("hashCode")) {
return target.hashCode();
} else if (methodName.equals("toString")) {
return target.toString();
}
return null; // 不是特殊方法,返回null
}
// 然後在代理處理器中調用
Object result = handleSpecialMethods(target, method, args);
if (result != null) {
return result;
}
// 處理其他方法...
ResultLoaderMap:延遲加載的核心容器
ResultLoaderMap是 MyBatis 用於管理延遲加載任務的容器,它存儲了屬性名與對應的ResultLoader的映射關係。每個延遲屬性對應一個ResultLoader,當屬性被訪問時,通過ResultLoader執行對應的子查詢並填充數據。
ResultLoaderMap是會話級(SqlSession)容器,線程安全由SqlSession的線程隔離性保證,無需額外同步。在高併發場景下,每個請求使用獨立SqlSession,避免線程間數據污染。
// ResultLoaderMap簡化概念示意
public class ResultLoaderMap {
// 存儲屬性名到ResultLoader的映射
private final Map<String, LoadPair> loaderMap = new HashMap<>();
// 檢查是否有指定屬性的加載器
public boolean hasLoader(String property) {
return loaderMap.containsKey(property);
}
// 觸發指定屬性的加載
public void load(String property) throws SQLException {
LoadPair pair = loaderMap.get(property);
if (pair != null) {
pair.load(); // 執行SQL查詢並填充結果
loaderMap.remove(property); // 加載後移除該加載器
}
}
}
// 加載器,包含了執行查詢所需的全部信息
class LoadPair {
private final String property;
private final MetaObject metaResultObject;
private final ResultLoader resultLoader;
public void load() throws SQLException {
// 執行SQL查詢獲取結果
Object value = resultLoader.loadResult();
// 將結果設置到目標對象的屬性上
metaResultObject.setValue(property, value);
}
}
延遲加載的實際案例
讓我們通過一個用户(User)和訂單(Order)的例子來看看延遲加載如何工作:
實體類定義
public class User implements Serializable { // 實現Serializable接口避免序列化問題
private Integer id;
private String username;
private List<Order> orderList;
// getter和setter方法
}
public class Order implements Serializable {
private Integer id;
private String orderNo;
private Double amount;
private Integer userId;
// getter和setter方法
}
MyBatis 配置
- 首先在 MyBatis 全局配置中啓用延遲加載:
<settings>
<setting name="lazyLoadingEnabled" value="true"/>
<setting name="aggressiveLazyLoading" value="false"/>
</settings>
- 然後在 Mapper 文件中配置:
<mapper namespace="com.example.mapper.UserMapper">
<!-- 查詢用户,延遲加載訂單信息 -->
<select id="getUserById" resultMap="userResultMap" parameterType="int">
SELECT id, username FROM user WHERE id = #{id}
</select>
<!-- 根據用户ID查詢訂單列表 -->
<select id="getOrdersByUserId" resultType="com.example.entity.Order" parameterType="int">
SELECT id, order_no, amount, user_id FROM orders WHERE user_id = #{userId}
</select>
<resultMap id="userResultMap" type="com.example.entity.User">
<id property="id" column="id"/>
<result property="username" column="username"/>
<!-- 配置延遲加載 -->
<collection property="orderList" ofType="com.example.entity.Order"
column="id" select="getOrdersByUserId" fetchType="lazy"/>
</resultMap>
</mapper>
執行過程與事務
工具類及代碼演示
首先,需要一個 MyBatis 工具類來獲取 SqlSession:
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import java.io.IOException;
import java.io.InputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class MyBatisUtil {
private static final Logger log = LoggerFactory.getLogger(MyBatisUtil.class);
private static final SqlSessionFactory sqlSessionFactory;
static {
try (InputStream inputStream = Resources.getResourceAsStream("mybatis-config.xml")) {
sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
} catch (IOException e) {
log.error("MyBatis配置加載失敗", e);
throw new RuntimeException("MyBatis配置加載失敗", e);
}
}
public static SqlSession getSqlSession() {
return sqlSessionFactory.openSession();
}
}
注意:需要在類路徑下添加mybatis-config.xml配置文件,配置數據源和 Mapper 掃描。
然後,使用這個工具類編寫延遲加載示例:
public class LazyLoadingDemo {
public static void main(String[] args) {
// 使用try-with-resources確保SqlSession正確關閉
try (SqlSession sqlSession = MyBatisUtil.getSqlSession()) {
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
// 查詢用户信息
User user = userMapper.getUserById(1);
System.out.println("用户名: " + user.getUsername());
// 此時還沒有執行訂單查詢的SQL
System.out.println("=== 分割線,以上SQL不包含訂單查詢 ===");
// 訪問訂單信息時,才會觸發延遲加載,執行訂單查詢SQL
// 注意:延遲加載依賴活動的SqlSession,建議在會話關閉前完成所有延遲屬性的訪問
List<Order> orderList = user.getOrderList();
System.out.println("訂單數量: " + orderList.size());
// 後續再次訪問不會觸發SQL查詢,因為已緩存在一級緩存中
System.out.println("再次訪問訂單: " + user.getOrderList().size());
} // SqlSession自動關閉
// 注意:在此處訪問user.getOrderList()會拋出異常
// 因為延遲加載依賴活動的SqlSession
}
}
延遲加載的優缺點
優點
- 性能提升:避免一次性加載過多不必要的數據,減少內存佔用
- 按需加載:只有真正需要使用關聯數據時才會查詢,減少不必要的 IO 操作
- 降低系統壓力:特別是在複雜關聯關係或大數據量場景下,可以顯著降低系統負擔
缺點
- N+1 問題:當需要遍歷一個集合並訪問每個元素的延遲加載屬性時,會導致主查詢 1 次+每個對象的延遲查詢 N 次,總共 N+1 次查詢
- 代理對象序列化問題:延遲加載的代理對象序列化時可能會出現問題,尤其是 CGLIB 代理對象
- 會話關閉後無法加載:延遲加載依賴活動的數據庫會話,SqlSession 關閉後無法再加載
解決 N+1 問題的方法
延遲加載可能導致的 N+1 問題可以通過以下方式解決:
1. 使用顯式即時加載
在明確需要關聯數據的場景下,可以顯式指定即時加載:
<collection property="orderList" ofType="Order" column="id"
select="getOrdersByUserId" fetchType="eager"/>
需要注意的是,fetchType="eager"並不是在 SQL 層面使用 JOIN 查詢,而是在主查詢完成後立即執行關聯查詢。本質上是"分步加載",但不需要等到屬性被訪問時才加載。
2. 使用 MyBatis 的批量查詢功能
MyBatis 提供了多種批量查詢方式來解決 N+1 問題:
a) 使用 multiple column 參數傳遞多個值進行批量查詢
<!-- 配置批量查詢的映射 -->
<collection property="orders" ofType="Order"
column="{userId=id, userName=username}" select="getOrdersByUserParams"/>
<!-- 批量查詢方法接收多個參數 -->
<select id="getOrdersByUserParams" resultType="Order">
SELECT * FROM orders
WHERE user_id = #{userId}
AND create_by = #{userName}
</select>
b) 手動批量查詢優化
// 手動批量查詢優化示例
List<User> users = userMapper.getAllUsers();
List<Integer> userIds = users.stream().map(User::getId).collect(Collectors.toList());
List<Order> allOrders = orderMapper.getOrdersByUserIds(userIds); // 1次批量查詢
// 建立用户-訂單映射關係
Map<Integer, List<Order>> orderMap = allOrders.stream()
.collect(Collectors.groupingBy(Order::getUserId));
// 處理用户和訂單
for (User user : users) {
List<Order> userOrders = orderMap.getOrDefault(user.getId(), Collections.emptyList());
System.out.println("用户" + user.getUsername() + "的訂單數量: " + userOrders.size());
}
注意:雖然 MyBatis 提供了batchSize配置,但它主要用於優化批量插入/更新操作,對延遲加載的 N+1 問題沒有直接幫助。延遲加載的子查詢仍然是單條執行的,需要通過上述手動批量查詢方式優化。
3. N+1 問題的監控與預防
可以通過以下方式監控和預防 N+1 問題:
// 配置SQL監控
@Aspect
@Component
public class LazyLoadingMonitor {
private static final Logger log = LoggerFactory.getLogger(LazyLoadingMonitor.class);
// 可通過配置調整閾值
@Value("${mybatis.lazy.threshold:10}")
private long threshold;
@Around("execution(* com.example.entity.*.get*(..))")
public Object monitorLazyLoading(ProceedingJoinPoint pjp) throws Throwable {
String methodName = pjp.getSignature().getName();
Object target = pjp.getTarget();
// 判斷是否可能觸發延遲加載的getter方法
if (methodName.startsWith("get") && !methodName.equals("getClass")) {
// 記錄方法調用前的時間
long start = System.currentTimeMillis();
Object result = pjp.proceed();
long end = System.currentTimeMillis();
// 如果執行時間過長,可能觸發了延遲加載
long duration = end - start;
if (duration > threshold) {
log.warn("可能的延遲加載: 類={}, 方法={}, 執行時間={}ms",
target.getClass().getSimpleName(),
methodName,
duration);
}
return result;
}
return pjp.proceed();
}
}
也可以使用成熟的監控工具,如 MyBatis Plus 的性能分析插件來監控 SQL 執行。
代理對象序列化問題及解決方案
延遲加載使用的代理對象在序列化時可能會遇到問題,尤其是 CGLIB 代理類。CGLIB 生成的代理類名稱類似$$EnhancerByCGLIB$$xxx,反序列化時需要相同的類路徑和類定義。在分佈式系統中(如微服務架構),這種代理類可能無法在不同節點間正確反序列化,導致ClassNotFoundException異常。
解決方案包括:
1. 確保實體類實現 Serializable 接口
所有實體類都應該實現java.io.Serializable接口,包括關聯實體類。
2. 在序列化前觸發延遲加載
確保在序列化前已經訪問過延遲加載屬性,將代理對象轉換為真實對象:
// 引入Jackson依賴
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
public class SerializationHelper {
private static final Logger log = LoggerFactory.getLogger(SerializationHelper.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
public static String prepareForSerialization(User user) {
try {
// 在序列化前觸發所有延遲加載
if (user.getOrderList() != null) {
user.getOrderList().size(); // 觸發延遲加載
}
// 現在user中的orderList已經是真實數據,可以安全序列化
return objectMapper.writeValueAsString(user);
} catch (JsonProcessingException e) {
log.error("序列化失敗", e);
throw new RuntimeException("序列化失敗", e);
}
}
}
3. 使用自定義序列化策略
使用 Jackson 或其他序列化工具的自定義序列化功能:
// 使用Jackson註解忽略代理相關屬性
@JsonIgnoreProperties({"handler", "hibernateLazyInitializer"})
public class User implements Serializable {
// 實體類定義
}
延遲加載與事務的關係
延遲加載依賴的SqlSession需與事務作用域一致。如果事務提前提交或回滾,會導致後續的延遲加載無法執行:
// 正確示例:在同一事務中完成延遲加載
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Transactional
public int getUserOrderCount(int userId) {
User user = userMapper.getUserById(userId);
// 在同一事務中訪問延遲加載屬性
return user.getOrderList().size();
}
}
在 Spring 環境中,可以使用OpenSessionInView模式延長會話生命週期,但這可能導致數據庫連接長時間佔用,高併發系統中要謹慎使用。
延遲加載與緩存結合使用
MyBatis 的延遲加載與緩存機制可以協同工作,進一步提升性能:
一級緩存(會話級)
- 默認開啓,作用域為 SqlSession
- 延遲加載的結果會存入一級緩存,同一會話內重複訪問不會觸發數據庫查詢
- 當執行 update、delete、insert 或調用 clearCache()時,一級緩存會被清空
二級緩存(全局)
- 需手動配置
<cache/>或<cache-ref/> - 延遲加載查詢的結果也會被二級緩存緩存
- 跨會話訪問時可以直接從二級緩存獲取
<mapper namespace="com.example.mapper.UserMapper">
<!-- 啓用二級緩存 -->
<cache eviction="LRU"
flushInterval="60000" <!-- 刷新間隔,單位毫秒 -->
size="1024" <!-- 引用數量 -->
readOnly="true"/> <!-- 只讀設置 -->
<!-- 映射器配置 -->
</mapper>
readOnly=true表示緩存對象不可變,MyBatis 會直接返回緩存對象引用,提升性能;readOnly=false則返回對象副本,保證線程安全。
二級緩存存儲的是完整對象(包括延遲加載後的數據),因此需確保延遲加載觸發後的數據會被正確序列化並緩存。建議在getUserById等主查詢上配置緩存,延遲加載的子查詢(如getOrdersByUserId)可通過flushCache="true"保證數據一致性。
延遲加載的適用場景
適合使用延遲加載的場景
- 關聯數據使用頻率低:如用户詳情頁的歷史訂單,只有用户點擊"查看訂單"時才需要加載
- 大數據量列表查詢:只加載主數據,關聯數據按需加載,避免一次性加載過多數據
- 層級數據結構:如樹形結構,只需要加載當前節點數據,子節點按需加載
- 統計報表的明細數據:報表頁面通常只展示彙總數據,詳情數據按需加載
不適合使用延遲加載的場景
- 頻繁訪問關聯數據:如訂單詳情頁需同時展示用户和商品信息,此時即時加載更高效
- 批量數據處理:需要處理大量關聯數據的場景,延遲加載會導致 N+1 問題
- 無狀態服務:如 REST API,每個請求都會創建新的 Session,延遲加載可能導致會話關閉問題
- 高併發系統:延遲加載依賴會話,可能導致數據庫連接長時間佔用
複雜關聯關係處理
多對多和嵌套加載處理
在處理複雜關聯關係如多對多(用户-角色)或嵌套關係(用户-訂單-商品)時,配置原理相似,但需要注意關聯條件和層級結構:
<!-- 用户與角色的多對多關係 -->
<resultMap id="userWithRolesMap" type="com.example.entity.User">
<id property="id" column="id"/>
<result property="username" column="username"/>
<!-- 通過中間表查詢關聯角色 -->
<collection property="roles" ofType="com.example.entity.Role"
column="id" select="getRolesByUserId" fetchType="lazy"/>
</resultMap>
<!-- 嵌套延遲加載:訂單-商品 -->
<resultMap id="orderMap" type="com.example.entity.Order">
<id property="id" column="id"/>
<result property="orderNo" column="order_no"/>
<!-- 嵌套層級的延遲加載 -->
<collection property="products" ofType="com.example.entity.Product"
column="id" select="getProductsByOrderId" fetchType="lazy"/>
</resultMap>
在處理複雜關係時要點:
- 對於多對多關係:通常需要一個額外查詢處理中間表連接
- 對於嵌套層級:需確保每層都正確配置延遲加載,並且會話保持活動狀態直到所有層級都訪問完畢
MyBatis 與 Hibernate 延遲加載對比
對於熟悉 Hibernate 的開發者,瞭解兩者差異有助於更好地使用 MyBatis 的延遲加載:
| 特性 | MyBatis | Hibernate |
|---|---|---|
| 代理實現與性能 | 基於動態代理(JDK/CGLIB),代理對象創建速度快,但功能相對簡單 | 基於字節碼增強(Javassist/ByteBuddy),初始化較慢但運行性能好 |
| 加載方式 | 通過單獨的 select 查詢(需手動配置) | 支持 JOIN 方式和單表查詢兩種延遲加載 |
| 會話管理 | 需手動管理 SqlSession 生命週期 | 通過 Session/EntityManager 自動處理 |
| 配置方式 | XML 或註解,需明確設置 fetchType | 通過映射關係直接控制(如@OneToMany(fetch=FetchType.LAZY)) |
| N+1 解決 | 需手動批量查詢或配置關聯查詢 | 提供批處理機制(batch fetching)自動優化 |
實際應用建議
- 選擇性啓用:不是所有場景都適合使用延遲加載,需要根據業務特點選擇
- 合理設置全局配置:
- 開發環境可以設置
lazyLoadingEnabled=true方便調試 - 生產環境根據實際性能測試結果決定
- 儘量保持
aggressiveLazyLoading=false,避免非預期的性能問題
- 結合緩存機制:MyBatis 的一級緩存、二級緩存與延遲加載配合使用,可以進一步提升性能
- 在 Service 層管理好會話:確保訪問延遲加載屬性時 SqlSession 仍然處於打開狀態,或考慮使用 Spring 的
OpenSessionInView模式 - 性能測試:在生產環境部署前,對延遲加載的性能影響進行充分測試,包括高併發場景
總結
我們來用表格總結一下 MyBatis 的延遲加載特性:
| 特性 | 描述 |
|---|---|
| 支持情況 | MyBatis 完全支持延遲加載功能 |
| 實現原理 | 基於動態代理機制(JDK 代理或 CGLIB 代理) |
| 延遲容器 | 使用 ResultLoaderMap 存儲延遲加載任務 |
| 全局配置 | lazyLoadingEnabled和aggressiveLazyLoading控制 |
| 局部控制 | 通過fetchType屬性覆蓋全局設置 |
| 觸發條件 | 調用 getter 方法、集合操作方法(size/isEmpty)、遍歷等 |
| 會話依賴 | 延遲加載依賴活動的 SqlSession 和事務 |
| N+1 優化 | 批量查詢、multiple columns 傳參 |
| 序列化處理 | 實現 Serializable 接口、預先觸發延遲加載、自定義序列化策略 |
| 與緩存結合 | 延遲加載結果會進入一/二級緩存,提升後續訪問性能 |
| 適用場景 | 關聯數據使用頻率低、大數據量列表查詢、層級數據結構 |