1. 捕獲和處理異常的常見錯誤
錯誤1 統(tǒng)一異常處理
統(tǒng)一異常處理的表現(xiàn)形式是不在業(yè)務層面進行異常處理,而是在框架層面粗獷的捕獲和處理異常。
一般大多數(shù)業(yè)務應用采用 Controller - Service - RePository 進行業(yè)務處理,分層負責不同的處理。
- Controller 層負責信息收集、參數(shù)校驗、轉換服務層處理的數(shù)據適配前端,輕業(yè)務邏輯;
- Service 層負責核心業(yè)務邏輯,包括各種外部服務調用、訪問數(shù)據庫、緩存處理、消息處理等;
- Repository 層負責數(shù)據訪問實現(xiàn),一般沒有業(yè)務邏輯。
每層架構的工作性質不同,且從業(yè)務性質上異??赡芊譃闃I(yè)務異常和系統(tǒng)異常兩大類,這就決定了很難進行統(tǒng)一的異常處理。
Repository 層出現(xiàn)異?;蛟S可以忽略,或許可以降級,或許需要轉化為一個友好的異常。如果一律捕獲異常僅記錄日志,很可能業(yè)務邏輯已經出錯,而用戶和程序本身完全感知不到。
Service 層往往涉及數(shù)據庫事務,出現(xiàn)異常同樣不適合捕獲,否則事務無法自動回滾。此外 Service 層涉及業(yè)務邏輯,有些業(yè)務邏輯執(zhí)行中遇到業(yè)務異常,可能需要在異常后轉入分支業(yè)務流程。如果業(yè)務異常都被框架捕獲了,業(yè)務功能就會不正常。
如果下層異常上升到 Controller 層還是無法處理的話,Controller 層往往會給予用戶友好提示,或是根據每一個 API 的異常表返回指定的異常類型,同樣無法對所有異常一視同仁。
因此,我不建議在框架層面進行異常的自動、統(tǒng)一處理,尤其不要隨意捕獲異常。但,框架可以做兜底工作。如果異常上升到最上層邏輯還是無法處理的話,可以以統(tǒng)一的方式進行異常轉換,比如通過 @RestControllerAdvice + @ExceptionHandler,來捕獲這些“未處理”異常:
對于自定義的業(yè)務異常,以 Warn 級別的日志記錄異常以及當前 URL、執(zhí)行方法等信息后,提取異常中的錯誤碼和消息等信息,轉換為合適的 API 包裝體返回給 API 調用方;
對于無法處理的系統(tǒng)異常,以 Error 級別的日志記錄異常和上下文信息(比如 URL、參數(shù)、用戶 ID)后,轉換為普適的“服務器忙,請稍后再試”異常信息,同樣以 API 包裝體返回給調用方。
錯誤2 捕獲異常后直接生吞
在任何時候,我們捕獲了異常都不應該生吞,也就是直接丟棄異常不記錄、不拋出。這樣的處理方式還不如不捕獲異常,因為被生吞掉的異常一旦導致 Bug,就很難在程序中找到蛛絲馬跡,使得 Bug 排查工作難上加難。
錯誤3 丟棄異常的原始信息
示例1:
有這么一個會拋出受檢異常的方法 readFile:
private void readFile() throws IOException {
Files.readAllLines(Paths.get("a_file"));
}
像這樣調用 readFile 方法,捕獲異常后,完全不記錄原始異常,直接拋出一個轉換后異常,導致出了問題不知道 IOException 具體是哪里引起的:
@GetMapping("wrong1")
public void wrong1(){
try {
readFile();
} catch (IOException e) {
//原始異常信息丟失
throw new RuntimeException("系統(tǒng)忙請稍后再試"); // 沒有記錄原始異常
}
}
或者是這樣,只記錄了異常消息,卻丟失了異常的類型、棧等重要信息:
catch (IOException e) {
//只保留了異常消息,棧沒有記錄
log.error("文件讀取錯誤, {}", e.getMessage());
throw new RuntimeException("系統(tǒng)忙請稍后再試");
}
留下的日志是這樣的,看完一臉茫然,只知道文件讀取錯誤的文件名,至于為什么讀取錯誤、是不存在還是沒權限,完全不知道。
正確處理方式
catch (IOException e) {
log.error("文件讀取錯誤", e); // 使用日志進行記錄
throw new RuntimeException("系統(tǒng)忙請稍后再試");
}
或者,把原始異常作為轉換后新異常的 cause,原始異常信息同樣不會丟:
catch (IOException e) {
throw new RuntimeException("系統(tǒng)忙請稍后再試", e); // 異常拋出的時候帶上原始信息
}
錯誤4 拋出異常時不指定任何消息
一些代碼中的偷懶做法,直接拋出沒有 message 的異常:
throw new RuntimeException();
總之,如果你捕獲了異常打算處理的話,除了通過日志正確記錄異常原始信息外,通常還有三種處理模式:
- 轉換,即轉換新的異常拋出。對于新拋出的異常,最好具有特定的分類和明確的異常消息,而不是隨便拋一個無關或沒有任何信息的異常,并最好通過 cause 關聯(lián)老異常。
- 重試,即重試之前的操作。比如遠程調用服務端過載超時的情況,盲目重試會讓問題更嚴重,需要考慮當前情況是否適合重試。
- 恢復,即嘗試進行降級處理,或使用默認值來替代原始數(shù)據。
注意1 finally 中的異常捕獲
在一些資源釋放中,我們通常選擇在 finally 中對資源進行釋放,但是 finally 中也會存在拋出異常的情況。
示例:
@GetMapping("wrong")
public void wrong() {
try {
log.info("try");
//異常丟失
throw new RuntimeException("try");
} finally {
log.info("finally");
throw new RuntimeException("finally");
}
}
最后在日志中只能看到 finally 中的異常,雖然 try 中的邏輯出現(xiàn)了異常,但卻被 finally 中的異常覆蓋了。這是非常危險的,特別是 finally 中出現(xiàn)的異常是偶發(fā)的,就會在部分時候覆蓋 try 中的異常,讓問題更不明顯。
至于異常為什么被覆蓋,原因也很簡單,因為一個方法無法出現(xiàn)兩個異常。修復方式是,finally 代碼塊自己負責異常捕獲和處理:
@GetMapping("right")
public void right() {
try {
log.info("try");
throw new RuntimeException("try");
} finally {
log.info("finally");
try {
throw new RuntimeException("finally");
} catch (Exception ex) {
log.error("finally", ex);
}
}
}
或者可以把 try 中的異常作為主異常拋出,使用 addSuppressed 方法把 finally 中的異常附加到主異常上:
@GetMapping("right2")
public void right2() throws Exception {
Exception e = null; // 定義異常
try {
log.info("try");
throw new RuntimeException("try");
} catch (Exception ex) {
e = ex;
} finally {
log.info("finally");
try {
throw new RuntimeException("finally");
} catch (Exception ex) {
if (e!= null) {
e.addSuppressed(ex); // 添加異常
} else {
e = ex;
}
}
}
throw e;
}
運行方法可以得到如下異常信息,其中同時包含了主異常和被屏蔽的異常:
java.lang.RuntimeException: try
at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.right2(FinallyIssueController.java:69)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
...
Suppressed: java.lang.RuntimeException: finally
at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.right2(FinallyIssueController.java:75)
... 54 common frames omitted
其實這正是 try-with-resources 語句的做法,對于實現(xiàn)了 AutoCloseable 接口的資源,建議使用 try-with-resources 來釋放資源,否則也可能會產生剛才提到的,釋放資源時出現(xiàn)的異常覆蓋主異常的問題。比如如下我們定義一個測試資源,其 read 和 close 方法都會拋出異常:
public class TestResource implements AutoCloseable {
public void read() throws Exception{
throw new Exception("read error");
}
@Override
public void close() throws Exception {
throw new Exception("close error");
}
}
使用傳統(tǒng)的 try-finally 語句,在 try 中調用 read 方法,在 finally 中調用 close 方法:
@GetMapping("useresourcewrong")
public void useresourcewrong() throws Exception {
TestResource testResource = new TestResource();
try {
testResource.read();
} finally {
testResource.close();
}
}
可以看到,同樣出現(xiàn)了 finally 中的異常覆蓋了 try 中異常的問題:
java.lang.Exception: close error
at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.close(TestResource.java:10)
at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.useresourcewrong(FinallyIssueController.java:27)
而改為 try-with-resources 模式之后:
@GetMapping("useresourceright")
public void useresourceright() throws Exception {
try (TestResource testResource = new TestResource()){
testResource.read();
}
}
try 和 finally 中的異常信息都可以得到保留:
java.lang.Exception: read error
at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.read(TestResource.java:6)
...
Suppressed: java.lang.Exception: close error
at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.close(TestResource.java:10)
at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.useresourceright(FinallyIssueController.java:35)
... 54 common frames omitted
注意2 線程池任務異常處理
我們來看一個例子:提交 10 個任務到線程池異步處理,第 5 個任務拋出一個 RuntimeException,每個任務完成后都會輸出一行日志:
@GetMapping("execute")
public void execute() throws InterruptedException {
String prefix = "test";
ExecutorService threadPool = Executors.newFixedThreadPool(1, new ThreadFactoryBuilder().setNameFormat(prefix+"%d").get());
//提交10個任務到線程池處理,第5個任務會拋出運行時異常
IntStream.rangeClosed(1, 10).forEach(i -> threadPool.execute(() -> {
if (i == 5) throw new RuntimeException("error");
log.info("I'm done : {}", i);
}));
threadPool.shutdown();
threadPool.awaitTermination(1, TimeUnit.HOURS);
}
觀察日志可以發(fā)現(xiàn)兩點:
...
[14:33:55.990] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:26 ] - I'm done : 4
Exception in thread "test0" java.lang.RuntimeException: error
at org.geekbang.time.commonmistakes.exception.demo3.ThreadPoolAndExceptionController.lambda$null$0(ThreadPoolAndExceptionController.java:25)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
[14:33:55.990] [test1] [INFO ] [e.d.ThreadPoolAndExceptionController:26 ] - I'm done : 6
...
任務 1 到 4 所在的線程是 test0,任務 6 開始運行在線程 test1。由于我的線程池通過線程工廠為線程使用統(tǒng)一的前綴 test 加上計數(shù)器進行命名,因此從線程名的改變可以知道因為異常的拋出老線程退出了,線程池只能重新創(chuàng)建一個線程。如果每個異步任務都以異常結束,那么線程池可能完全起不到線程重用的作用。
因為沒有手動捕獲異常進行處理,ThreadGroup 幫我們進行了未捕獲異常的默認處理,向標準錯誤輸出打印了出現(xiàn)異常的線程名稱和異常信息。顯然,這種沒有以統(tǒng)一的錯誤日志格式記錄錯誤信息打印出來的形式,對生產級代碼是不合適的,ThreadGroup 的相關源碼如下所示:
public void uncaughtException(Thread t, Throwable e) {
if (parent != null) {
parent.uncaughtException(t, e);
} else {
Thread.UncaughtExceptionHandler ueh =
Thread.getDefaultUncaughtExceptionHandler();
if (ueh != null) {
ueh.uncaughtException(t, e);
} else if (!(e instanceof ThreadDeath)) {
System.err.print("Exception in thread \""
\+ t.getName() + "\" ");
e.printStackTrace(System.err);
}
}
}
修復方式有 2 步:
以 execute 方法提交到線程池的異步任務,最好在任務內部做好異常處理;
設置自定義的異常處理程序作為保底,比如在聲明線程池時自定義線程池的未捕獲異常處理程序:
new ThreadFactoryBuilder()
.setNameFormat(prefix+"%d")
.setUncaughtExceptionHandler((thread, throwable)-> log.error("ThreadPool {} got exception", thread, throwable))
.get()
或者設置全局的默認未捕獲異常處理程序:
static {
Thread.setDefaultUncaughtExceptionHandler((thread, throwable)-> log.error("Thread {} got exception", thread, throwable));
}
通過線程池 ExecutorService 的 execute 方法提交任務到線程池處理,如果出現(xiàn)異常會導致線程退出,控制臺輸出中可以看到異常信息。那么,把 execute 方法改為 submit,線程還會退出嗎,異常還能被處理程序捕獲到嗎?
修改代碼后重新執(zhí)行程序可以看到如下日志,說明線程沒退出,異常也沒記錄被生吞了:
[15:44:33.769] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 1
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 2
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 3
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 4
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 6
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 7
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 8
[15:44:33.771] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 9
[15:44:33.771] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47 ] - I'm done : 10
為什么會這樣呢?
查看 FutureTask 源碼可以發(fā)現(xiàn),在執(zhí)行任務出現(xiàn)異常之后,異常存到了一個 outcome 字段中,只有在調用 get 方法獲取 FutureTask 結果的時候,才會以 ExecutionException 的形式重新拋出異常:
public void run() {
...
try {
Callable<V> c = callable;
if (c != null && state == NEW) {
V result;
boolean ran;
try {
result = c.call();
ran = true;
} catch (Throwable ex) {
result = null;
ran = false;
setException(ex);
}
...
}
protected void setException(Throwable t) {
if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
outcome = t;
UNSAFE.putOrderedInt(this, stateOffset, EXCEPTIONAL); // final state
finishCompletion();
}
}
public V get() throws InterruptedException, ExecutionException {
int s = state;
if (s <= COMPLETING)
s = awaitDone(false, 0L);
return report(s);
}
private V report(int s) throws ExecutionException {
Object x = outcome;
if (s == NORMAL)
return (V)x;
if (s >= CANCELLED)
throw new CancellationException();
throw new ExecutionException((Throwable)x);
}
修改后的代碼如下所示,我們把 submit 返回的 Future 放到了 List 中,隨后遍歷 List 來捕獲所有任務的異常。這么做確實合乎情理。既然是以 submit 方式來提交任務,那么我們應該關心任務的執(zhí)行結果,否則應該以 execute 來提交任務:
List<Future> tasks = IntStream.rangeClosed(1, 10).mapToObj(i -> threadPool.submit(() -> {
if (i == 5) throw new RuntimeException("error");
log.info("I'm done : {}", i);
})).collect(Collectors.toList());
tasks.forEach(task-> {
try {
task.get();
} catch (Exception e) {
log.error("Got exception", e);
}
});
執(zhí)行這段程序可以看到如下的日志輸出:
[15:44:13.543] [http-nio-45678-exec-1] [ERROR] [e.d.ThreadPoolAndExceptionController:69 ] - Got exception
java.util.concurrent.ExecutionException: java.lang.RuntimeException: error
總結
第一,注意捕獲和處理異常的最佳實踐。首先,不應該用 AOP 對所有方法進行統(tǒng)一異常處理,異常要么不捕獲不處理,要么根據不同的業(yè)務邏輯、不同的異常類型進行精細化、針對性處理;其次,處理異常應該杜絕生吞,并確保異常棧信息得到保留;最后,如果需要重新拋出異常的話,請使用具有意義的異常類型和異常消息。
第二,務必小心 finally 代碼塊中資源回收邏輯,確保 finally 代碼塊不出現(xiàn)異常,內部把異常處理完畢,避免 finally 中的異常覆蓋 try 中的異常;或者考慮使用 addSuppressed 方法把 finally 中的異常附加到 try 中的異常上,確保主異常信息不丟失。此外,使用實現(xiàn)了 AutoCloseable 接口的資源,務必使用 try-with-resources 模式來使用資源,確保資源可以正確釋放,也同時確保異??梢哉_處理。
第三,雖然在統(tǒng)一的地方定義收口所有的業(yè)務異常是一個不錯的實踐,但務必確保異常是每次 new 出來的,而不能使用一個預先定義的 static 字段存放異常,否則可能會引起棧信息的錯亂。
第四,確保正確處理了線程池中任務的異常,如果任務通過 execute 提交,那么出現(xiàn)異常會導致線程退出,大量的異常會導致線程重復創(chuàng)建引起性能問題,我們應該盡可能確保任務不出異常,同時設置默認的未捕獲異常處理程序來兜底;如果任務通過 submit 提交意味著我們關心任務的執(zhí)行結果,應該通過拿到的 Future 調用其 get 方法來獲得任務運行結果和可能出現(xiàn)的異常,否則異常可能就被生吞了。