#
引子
说到异常处理,无非就是 try-catch / throws
,Golang 中就是 if err != nil
,然后记下错误信息 log.error(...)
,下面是个很常见的异常处理的示例
1
2
3
4
5
6
7
8
9
10
11
| @Scheduled(fixedDelay = 5, timeUnit = TimeUnit.MINUTES)
public void scheduledTask() {
long a = System.currentTimeMillis();
log.debug("开始执行定时任务");
try {
performTask();
} catch (Exception e) {
log.error("定时任务执行失败", e);
}
log.info("定时任务完成,耗时 {}ms", System.currentTimeMillis() - a);
}
|
但是,这样处理就够了吗?
在聊下面的话题之前,我们先来思考一个问题:
Q:在实际工程中,高性能与高可用哪个更难做到?
A:毫无疑问是高可用。越是庞大的系统,能发生异常的点就越多,想让它保持全年 100%可用,几乎就是不可能的事情,哪怕是 SLA 的 4 个 9、3 个 9,对很多业务来说都是非常勉强的。
#
让我们再认识一下异常处理
#
语言层面
不同的语言采用了不同的异常处理机制。
C++、Python、JavaScript 支持了抛出异常,但不强制捕获,放在 Java 的概念中,就都属于 Unchecked Exceptions。
Java 区分了 Checked Exception 和 Unchecked Exception,程序员需要对不同分类的异常进行不同的处理。
Golang 将异常处理直接纳入到了函数的返回值中,没有 try-catch
机制,期望你进行显式的判断处理。
Q:不同的异常处理机制是否一定带来更强的健壮性?
A:不会。不同的异常处理机制只能体现设计者不同的设计哲学。实际项目是否健壮,更多的还是依赖你如何使用这些机制来构建和维护应用程序。
#
编码层面
为了让概念更加清晰,我这边将不同的异常处理的编码能力分成了 7 个等级,可能不太严谨,不必严格比较。
#
基础的异常捕获
最基础的等级,人人都知道有潜在的异常就要 try-catch
,我们简单看 2 个示例。
1
2
3
4
5
6
7
8
9
| public void loadData() {
try {
// 尝试加载数据
loadFromFile();
} catch (Exception e) {
// 异常处理,这里仅做打印处理
System.out.println("加载数据失败");
}
}
|
上面是一个典型示例,被这样的代码坑过的人可能很快就看出来,catch
中的处理纯属害人,我这里就不过多吐槽了,我们继续向下看。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| public void updateUserStats() {
try {
// 步骤 1: 从数据库获取用户信息
UserInfo userInfo = database.getUserInfo(userId);
// 步骤 2: 对用户信息计算一系列统计数据
UserStats stats = calculateStats(userInfo);
// 步骤 3: 更新用户统计数据到数据库
database.updateUserStats(userId, stats);
// 步骤 4: 记录操作成功日志
logger.info("User stats updated successfully for userId: " + userId);
} catch (Exception e) {
// 对所有可能的异常情况进行一个 catch 操作不处理
logger.error("Failed to update user stats for userId: " + userId, e);
}
}
|
这在业务代码中也是很常见的,因为害怕出异常,直接 try 一个大括号从头包到尾,满满的安全感。
我甚至见过有团队按这种方式作为规范。
#
类型区分
可以根据不同的异常类型进行捕获和处理。
1
2
3
4
5
6
7
8
9
10
11
| def read_file(file_name):
try:
with open(file_name, 'r') as file:
print(file.read())
except FileNotFoundError:
print("The file was not found.")
except IOError:
print("There was an IO error.")
# 尝试读取一个不存在的文件
read_file("nonexistent.txt")
|
#
上下文相关处理和异常传递
Q:作为异常处理者,何时该 try-catch ,何时该向上抛?
A:取决于上下文。
到了这一级,最关键的是弄清楚自己代码所处的上下文,自己应该扮演的角色。
1
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
| package main
import (
"errors"
"fmt"
)
// 是一个获取数据的函数,它返回数据和可能发生的错误
func fetchData() (string, error) {
// 假装数据获取过程失败了,返回错误
// 因为fetchData暂时不会发生错误,所以返回错误信息
return "", errors.New("data source is unavailable")
}
// 主处理函数,尝试获取数据并处理
func processData() {
data, err := fetchData()
if err != nil {
// 错误处理逻辑,进行错误处理
fmt.Println("Failed to fetch data:", err)
// 在这里可以决定如何处理这个错误,例如重试或停用服务
return
}
// 如果没有错误发生,继续处理数据
fmt.Println("Processing data:", data)
}
func main() {
processData()
}
|
上面的示例代码中,fetchData
函数清楚知道自己是不应该吞下「获取数据失败」这个异常的,所以向上抛出。而 processData
函数明白自己有权力也有能力对「获取数据失败」 这个异常进行处理,所以它不再向上抛。
#
使用自定义异常和精细化处理
- 特点:定义自定义异常类,根据业务逻辑进行细致的异常处理。
- 技能:设计并使用自定义异常,区分检查型和非检查型异常。
Q:作为异常抛出者,该抛 Unchecked Exception 还是 Checked Exception?
A:问题的关键在于上一级中提到的【上下文】
- Unchecked Exception:无需调用方显式处理,或有统一的拦截器处理即可
- Checked Exception: 可以预料,期望调用方高度注意
- 不抛异常:(没错,我们还有第三种选择)避免因为较小的错误而中断用户的主要工作流程,例如
log.info
函数通常就不会抛出异常。
1
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
| public class Product {
private int stock;
// 构造函数,初始化库存量
public Product(int initialStock) {
this.stock = initialStock;
}
// 购买商品的方法,接受购买数量作为参数
public void purchase(int quantity) {
// 当请求购买的数量超过当前库存时抛出异常
if (quantity > stock) {
// 如果请求的数量超过现有库存,则抛出一个不足异常
// 这里抛出自定义的异常有三个原因:
// 1. 库存不足本身就是一个运行时的问题,通常不是由编程错误导致的。
// 2. 这种情况下的异常处理通常需要特定的业务逻辑来解决,如通知用户、调整订单数量
// 或者通知仓库补货,等等。
// 3. 使用户体验更丰富且以错误消息和异常调用栈信息而不仅仅是返回一个简单的错误代码。
throw new InsufficientStockException("Attempted to purchase " +
quantity + " units, but only " + stock + " units are available.");
}
// 如果库存足够,则减少相应数量的库存
stock -= quantity;
}
}
// 自定义的库存不足异常类,用于表示库存不足的情况
class InsufficientStockException extends RuntimeException {
public InsufficientStockException(String message) {
super(message);
}
}
|
上下文就需要根据实际代码来判断,最终也是考验个人的边界划分能力。但是有一个有意思的问题我们可以拿出来思考下:
Q:为什么 NullPointException 被设计为 Unchecked Exception?
或者说
Q:为什么 FileNotFoundException 被设计为 Checked Exception?
答案就不贴了,希望大家能从这个问题里欣赏到一点 Java 的异常处理机制的设计哲学。
#
预防、日志记录和恢复策略
- 特点:预防异常的发生,记录详细的错误日志,设计错误恢复策略。
- 技能:有效记录错误信息,实现错误的容错和回滚机制。
这里就不贴大段代码做详细的示范了,相信不少朋友都能做好。但是这里想要留一个问题给大家思考:
Q:如何平衡异常处理和代码清晰度?
下面这段示例代码,的确严谨,但这么长一串就为了实现一个 GetUserByID
的功能,让人看了实在抬不起兴致。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // FindUserByID 通过ID查找用户
func (repo *UserRepository) FindUserByID(id uint) (*User, error) {
var user User
err := repo.db.First(&user, id).Error
if err != nil {
if gorm.IsRecordNotFoundError(err) {
return nil, nil // 没记录表示此操作结果为空
}
return nil, err
}
return &user, nil
}
// GetUserByID 通过ID获取用户
func (service *UserService) GetUserByID(id uint) (*User, error) {
user, err := service.userRepository.FindUserByID(id)
if err != nil {
return nil, err // 处理数据库层面的错误
}
if user == nil {
fmt.Println("No user found") // 指定业务逻辑处理
// 可能需要进一步处理,例如创建新用户等
}
return user, nil
}
|
#
监控、告警
监控,告警大家可能每天都接触,但我这里还是要重点拿出来讲一下。我们现在回过头来看【引子】里的这段代码:
1
2
3
4
5
6
7
8
9
10
11
| @Scheduled(fixedDelay = 5, timeUnit = TimeUnit.MINUTES)
public void scheduledTask() {
long a = System.currentTimeMillis();
log.debug("开始执行定时任务");
try {
performTask();
} catch (Exception e) {
log.error("定时任务执行失败", e);
}
log.info("定时任务完成,耗时 {}ms", System.currentTimeMillis() - a);
}
|
Q:这样处理就够了吗?
A:实际生产中,【定时任务执行失败】这种问题如果没有加上监控,没有告警告到负责人那边,这里的 log.error
一定是石沉大海。等到某一天业务反馈 Bug 了,才回过头来找到这边。
监控提供了分析能力,有了监控可以做出告警。
告警提供了主动发现问题的能力,你不需要也不可能 24 小时盯着日志滚动。
不要因为有 log.error
就放松了警惕!
#
优雅的异常处理策略
特点:综合运用异常处理最佳实践,实现优雅的错误处理,不仅考虑技术面,还考虑用户体验。
这里重点讲到的用户友好的反馈范围其实很广,包括但不限于:
- C 端用户:引导、弹窗、跳转
- API 接入方:文档、SDK、详尽的错误提示
- 程序维护人员:维护手册、一键诊断工具
从反过来的角度可能更好理解,就是一句话:想想你这么处理异常,有没有人骂你。
#
业务/架构层面
最后再提一下,我们在业务/架构层面也有很多的机制都是为了做异常处理,例如重试、限流、熔断、功能降级、异地多活,甚至数据手动修复、健壮的发布机制都是为了应对各种异常情况。
编码层面如果能做好,相信到了这个层面你也一定能做得很不错。
#
为什么异常处理是关于爆炸的艺术?
我认为已经不需要专门回答这个问题,但还是想在文章最后吐槽下:
异常处理人人都能做,但很多人做出来的就是 💩,挖坑,辣眼睛!
没错,我就是为了这碟醋包的这顿饺子!
希望大家重视起这件小事。