本章涵蓋 Spring 事務、日誌框架、ORM 框架、例外處理等框架使用的常見陷阱。這些問題往往源自對框架機制的不完全理解。
Spring 事務陷阱#
事務不生效的常見場景#
Spring 聲明式事務基於 AOP 代理實現,以下場景會導致事務失效:
場景一:自呼叫問題#
@Service
public class UserService {
public void createUserAndLog(User user) {
// 自呼叫不會經過代理,事務不生效
this.createUser(user);
}
@Transactional
public void createUser(User user) {
userRepository.save(user);
// 即使這裡拋例外也不會回滾
}
}解決方案:
// 方案一:注入自己
@Service
public class UserService {
@Autowired
private UserService self;
public void createUserAndLog(User user) {
self.createUser(user); // 通過代理呼叫
}
@Transactional
public void createUser(User user) {
userRepository.save(user);
}
}
// 方案二:使用 AopContext
@Service
public class UserService {
public void createUserAndLog(User user) {
((UserService) AopContext.currentProxy()).createUser(user);
}
@Transactional
public void createUser(User user) {
userRepository.save(user);
}
}
// 需要組態 @EnableAspectJAutoProxy(exposeProxy = true)場景二:非 public 方法#
@Service
public class UserService {
// 事務不生效:private 方法
@Transactional
private void createUser(User user) {
userRepository.save(user);
}
// 事務不生效:protected 方法
@Transactional
protected void updateUser(User user) {
userRepository.save(user);
}
}解決方案:
@Transactional必須標註在 public 方法上。
場景三:例外類型不匹配#
@Service
public class UserService {
// 默認只對 RuntimeException 和 Error 回滾
@Transactional
public void createUser(User user) throws Exception {
userRepository.save(user);
throw new Exception("業務例外"); // 不會回滾!
}
}解決方案:
// 明確指定需要回滾的例外類型
@Transactional(rollbackFor = Exception.class)
public void createUser(User user) throws Exception {
userRepository.save(user);
throw new Exception("業務例外"); // 會回滾
}場景四:例外被捕獲#
@Service
public class UserService {
@Transactional
public void createUser(User user) {
try {
userRepository.save(user);
throw new RuntimeException("業務例外");
} catch (Exception e) {
log.error("創建用戶失敗", e);
// 例外被捕獲,事務不會回滾!
}
}
}解決方案:
@Transactional
public void createUser(User user) {
try {
userRepository.save(user);
throw new RuntimeException("業務例外");
} catch (Exception e) {
log.error("創建用戶失敗", e);
// 方案一:重新拋出例外
throw e;
// 方案二:手動標記回滾
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}事務傳播行為陷阱#
@Service
public class OrderService {
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
// 呼叫另一個事務方法
logService.createLog(order); // 如果這裡失敗,整個事務都會回滾
}
}
@Service
public class LogService {
@Transactional // 默認 REQUIRED,加入現有事務
public void createLog(Order order) {
logRepository.save(new Log(order.getId()));
throw new RuntimeException("日誌記錄失敗");
}
}解決方案:根據業務需求選擇合適的傳播行為
@Service
public class LogService {
// REQUIRES_NEW:獨立事務,互不影響
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createLog(Order order) {
logRepository.save(new Log(order.getId()));
}
// NOT_SUPPORTED:不使用事務
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void createLogWithoutTransaction(Order order) {
logRepository.save(new Log(order.getId()));
}
}事務傳播行為說明
| 傳播行為 | 說明 |
|---|---|
| REQUIRED | 默認值,有事務就加入,沒有就創建 |
| REQUIRES_NEW | 總是創建新事務,掛起現有事務 |
| NESTED | 嵌套事務,外層回滾會連帶回滾,內層回滾不影響外層 |
| SUPPORTS | 有事務就加入,沒有就以非事務方式執行 |
| NOT_SUPPORTED | 以非事務方式執行,掛起現有事務 |
| MANDATORY | 必須有現有事務,否則拋例外 |
| NEVER | 必須沒有事務,否則拋例外 |
日誌框架陷阱#
Logger 繼承導致日誌重複#
問題現象#
同一條日誌輸出了多次。
原因分析#
<!-- 錯誤組態:子 Logger 繼承父 Logger 的 Appender -->
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- 父 Logger -->
<logger name="com.example" level="DEBUG">
<appender-ref ref="CONSOLE" />
</logger>
<!-- 子 Logger 會繼承父 Logger 的 Appender,導致重複輸出 -->
<logger name="com.example.service" level="DEBUG">
<appender-ref ref="CONSOLE" />
</logger>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>正確做法#
<!-- 方案一:設置 additivity="false" 阻止繼承 -->
<logger name="com.example.service" level="DEBUG" additivity="false">
<appender-ref ref="CONSOLE" />
</logger>
<!-- 方案二:子 Logger 只設置級別,不設置 Appender -->
<logger name="com.example.service" level="DEBUG" />非同步日誌組態陷阱#
問題現象#
應用程序重啟時丟失部分日誌,或者高壓力下日誌大量丟失。
原因分析#
Logback 的
AsyncAppender默認組態可能導致日誌丟失:
queueSize:默認 256,滿了會丟棄discardingThreshold:默認 20%,佇列剩餘低於此比例會丟棄 DEBUG/TRACEneverBlock:默認 false,但設為 true 時佇列滿會丟棄日誌
<!-- 默認組態可能丟日誌 -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="FILE" />
</appender>正確做法#
<!-- 生產環境推薦組態 -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>1024</queueSize> <!-- 增大佇列大小 -->
<discardingThreshold>0</discardingThreshold> <!-- 不丟棄任何日誌 -->
<neverBlock>false</neverBlock> <!-- 佇列滿時阻塞而非丟棄 -->
<includeCallerData>false</includeCallerData> <!-- 不記錄呼叫者資訊,提升效能 -->
<appender-ref ref="FILE" />
</appender>非同步日誌組態要點:
queueSize根據日誌量調整,一般 1024-4096- 關鍵業務日誌設置
discardingThreshold=0- 如果不能丟日誌,設置
neverBlock=false(但可能阻塞業務線程)
日誌級別動態參數陷阱#
問題現象#
DEBUG 日誌關閉,但仍然消耗 CPU 處理日誌參數。
// 錯誤示範:即使 DEBUG 關閉,字串拼接仍會執行
log.debug("處理訂單: " + order.toString() + ", 用戶: " + user.toString());
// 錯誤示範:JSON 序列化仍會執行
log.debug("訂單詳情: " + JSON.toJSONString(order));正確做法#
// 方案一:使用佔位符
log.debug("處理訂單: {}, 用戶: {}", order, user);
// 方案二:手動判斷級別(複雜計算時使用)
if (log.isDebugEnabled()) {
log.debug("訂單詳情: {}", JSON.toJSONString(order));
}
// 方案三:使用 Supplier(SLF4J 2.0+)
log.atDebug()
.setMessage("訂單詳情: {}")
.addArgument(() -> JSON.toJSONString(order)) // 只有 DEBUG 開啟時才會執行
.log();例外處理陷阱#
例外堆疊丟失#
// 錯誤示範:包裝例外時丟失原始堆疊
try {
doSomething();
} catch (Exception e) {
throw new BusinessException("處理失敗"); // 原始例外丟失
}
// 正確示範:保留原始例外作為 cause
try {
doSomething();
} catch (Exception e) {
throw new BusinessException("處理失敗", e);
}線程池例外處理陷阱#
問題現象#
線程池中的任務拋出例外,但沒有任何日誌。
原因分析#
execute和submit方法處理例外的方式不同:
execute:例外會傳遞給UncaughtExceptionHandlersubmit:例外被封裝在Future中,不呼叫get()就看不到
ExecutorService executor = Executors.newFixedThreadPool(2);
// execute:例外會輸出到標準錯誤
executor.execute(() -> {
throw new RuntimeException("execute 例外");
});
// submit:例外被吞掉,除非呼叫 Future.get()
executor.submit(() -> {
throw new RuntimeException("submit 例外"); // 沒有任何輸出!
});正確做法#
// 方案一:在任務內捕獲例外
executor.submit(() -> {
try {
doSomething();
} catch (Exception e) {
log.error("任務執行失敗", e);
}
});
// 方案二:獲取 Future 並處理例外
Future<?> future = executor.submit(() -> {
throw new RuntimeException("例外");
});
try {
future.get();
} catch (ExecutionException e) {
log.error("任務執行失敗", e.getCause());
}
// 方案三:自定義 ThreadPoolExecutor,覆寫 afterExecute
ThreadPoolExecutor executor = new ThreadPoolExecutor(...) {
@Override
protected void afterExecute(Runnable r, Throwable t) {
super.afterExecute(r, t);
if (t == null && r instanceof Future<?>) {
try {
((Future<?>) r).get();
} catch (ExecutionException e) {
t = e.getCause();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
if (t != null) {
log.error("線程池任務例外", t);
}
}
};靜態例外實體陷阱#
問題現象#
例外堆疊混亂,無法定位真正的出錯位置。
原因分析#
將例外定義為靜態變數會導致所有拋出點共用同一個堆疊。
// 錯誤示範:靜態例外實體
public class BusinessException extends RuntimeException {
public static final BusinessException USER_NOT_FOUND =
new BusinessException("用戶不存在");
}
// 使用時
throw BusinessException.USER_NOT_FOUND; // 堆疊指向定義位置,不是拋出位置正確做法#
// 方案一:每次都創建新例外
public class BusinessException extends RuntimeException {
public static BusinessException userNotFound() {
return new BusinessException("用戶不存在");
}
}
// 方案二:使用錯誤碼 + 工廠方法
public enum ErrorCode {
USER_NOT_FOUND("用戶不存在"),
ORDER_NOT_FOUND("訂單不存在");
private final String message;
ErrorCode(String message) {
this.message = message;
}
public BusinessException exception() {
return new BusinessException(this.name(), this.message);
}
}
// 使用時
throw ErrorCode.USER_NOT_FOUND.exception(); // 堆疊正確指向這一行日期時間陷阱#
SimpleDateFormat 線程安全問題#
SimpleDateFormat是線程不安全的,不能定義為靜態變數並行使用。
// 錯誤示範:多線程共用導致解析錯誤
private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 正確示範:使用 ThreadLocal
private static ThreadLocal<SimpleDateFormat> sdf = ThreadLocal.withInitial(
() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
);
// 更好的方案:使用 Java 8 DateTimeFormatter(線程安全)
private static DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");YYYY vs yyyy 格式化陷阱#
Calendar calendar = Calendar.getInstance();
calendar.set(2019, Calendar.DECEMBER, 29, 0, 0, 0);
SimpleDateFormat YYYY = new SimpleDateFormat("YYYY-MM-dd");
SimpleDateFormat yyyy = new SimpleDateFormat("yyyy-MM-dd");
System.out.println(YYYY.format(calendar.getTime())); // 2020-12-29(錯誤!)
System.out.println(yyyy.format(calendar.getTime())); // 2019-12-29(正確)
yyyy:日曆年(正常使用這個)YYYY:周年(Week Year),根據周計算所屬年份,年底可能跨年
日期計算溢出陷阱#
// 錯誤示範:int 溢出
Date today = new Date();
Date nextMonth = new Date(today.getTime() + 30 * 1000 * 60 * 60 * 24); // 溢出!
// 30 * 1000 * 60 * 60 * 24 = 2,592,000,000 > Integer.MAX_VALUE
// 正確示範:使用 long
Date nextMonth = new Date(today.getTime() + 30L * 1000 * 60 * 60 * 24);
// 更好的方案:使用 Java 8 日期時間 API
LocalDateTime now = LocalDateTime.now();
LocalDateTime nextMonth = now.plusDays(30);總結:框架使用檢查清單#
| 檢查項目 | 正確做法 |
|---|---|
| Spring 事務自呼叫 | 注入自己或使用 AopContext |
| 事務方法可見性 | 必須是 public 方法 |
| 事務例外類型 | 指定 rollbackFor = Exception.class |
| 事務傳播行為 | 根據業務需求選擇合適的傳播行為 |
| Logger 繼承 | 設置 additivity=“false” 或不重複設置 Appender |
| 非同步日誌 | 組態 queueSize、discardingThreshold |
| 日誌參數 | 使用佔位符,複雜計算先判斷級別 |
| 例外堆疊 | 包裝例外時保留 cause |
| 線程池例外 | submit 方法需要獲取 Future 處理例外 |
| 靜態例外 | 使用工廠方法創建新實體 |
| SimpleDateFormat | 使用 ThreadLocal 或 DateTimeFormatter |
| 日期格式化 | 使用 yyyy 而非 YYYY |
| 日期計算 | 使用 long 字面量或 Java 8 API |