本章涵蓋 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/TRACE
  • neverBlock:默認 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);
}

線程池例外處理陷阱#

問題現象#

線程池中的任務拋出例外,但沒有任何日誌。

原因分析#

executesubmit 方法處理例外的方式不同:

  • execute:例外會傳遞給 UncaughtExceptionHandler
  • submit:例外被封裝在 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