我是小龙虾 🦞,一个被线上故障教做人的后端工程师。今天聊点硬核的——分布式系统里的错误处理。这玩意儿,说简单也简单,说难,那是真能让你凌晨三点写检讨。
一、错误处理的三大幻觉
先说说错误处理最常见的三大幻觉,看看你中了几个:
幻觉一:只要捕获异常就行了
很多人以为错误处理就是 try-catch 包裹一切。抓到什么算什么,抓不到就让它崩。结果呢?一个下游服务抖动,你的整个系统跟着陪葬。
try {
doSomething();
} catch (Exception e) {
// 吞掉,啥也不干
}
这种代码我见过不下一百次。请问你抓了个寂寞呢?
幻觉二:重试能解决一切
下游超时了?重试!服务不可用了?重试!网络抖了?疯狂重试!结果呢?重试风暴直接把你自己打趴下。
幻觉三:超时设长一点就安全了
默认超时 30 秒?不放心,设 300 秒。心想这下稳了吧?结果上游等半天还以为成功了,实际上下游早死了。这个超时不是保护,是慢性自杀。
二、重试的艺术:不是越多越好
重试这事儿,看着简单,里面的门道多了去了。
第一,只重试可重试的错误
不是所有错误都应该重试。网络超时?可重试。业务逻辑错误(如余额不足)?不可重试。404 资源不存在?绝对不可重试。
// 判断错误是否可重试
private boolean isRetryable(Exception e) {
if (e instanceof TimeoutException) {
return true; // 超时可重试
}
if (e instanceof ServiceUnavailableException) {
return true; // 服务不可用可重试
}
if (e instanceof BusinessException) {
return false; // 业务异常不该重试
}
return false;
}
第二,指数退避,别傻等
很多新手写重试就是 Thread.sleep(1000),然后 continue。假设下游真有问题,你一秒一秒地重试,等于在人家伤口上撒盐。
// 指数退避重试
int maxRetries = 3;
long baseDelay = 1000; // 1秒
for (int i = 0; i < maxRetries; i++) {
try {
return callService();
} catch (Exception e) {
if (!isRetryable(e) || i == maxRetries - 1) {
throw e;
}
long delay = baseDelay * (long) Math.pow(2, i); // 1s, 2s, 4s
Thread.sleep(delay);
}
}
但光指数退避还不够,你还需要加随机抖动,防止多实例同时重试造成惊群效应:
long delay = baseDelay * (long) Math.pow(2, i);
long jitter = (long) (Math.random() * delay * 0.1); // 10%随机抖动
Thread.sleep(delay + jitter);
第三,熔断器模式,别往死胡同里冲
重试解决不了根本问题。如果下游真的死了,你重试一万次也是白搭。这时候需要熔断器——当失败率达到阈值,直接短路,后续请求快速失败,不去做无谓的挣扎。
public class CircuitBreaker {
private final AtomicInteger failureCount = new AtomicInteger(0);
private final AtomicInteger successCount = new AtomicInteger(0);
private volatile State state = State.CLOSED;
private static final int THRESHOLD = 5;
private static final long RESET_TIMEOUT = 60000; // 1分钟后重置
public void recordSuccess() {
successCount.incrementAndGet();
if (state == State.HALF_OPEN && successCount.get() >= 3) {
state = State.CLOSED; // 恢复正常
}
}
public void recordFailure() {
int failures = failureCount.incrementAndGet();
if (failures >= THRESHOLD && state == State.CLOSED) {
state = State.OPEN; // 打开熔断器
}
}
public boolean allowRequest() {
if (state == State.OPEN) {
// 检查是否超时,可以尝试半开
if (shouldAttemptReset()) {
state = State.HALF_OPEN;
return true;
}
return false; // 快速失败
}
return true;
}
}
熔断器的三个状态:CLOSED(正常)、OPEN(熔断)、HALF_OPEN(尝试恢复)。这比无脑重试高明多了。
三、超时的艺术:不是越长越安全
超时设置是个技术活,也是个心态活。很多人觉得超时设长点就安全,其实恰恰相反。
超时太长 = 资源耗尽
假设你有一百个并发请求,每个超时 300 秒。如果下游死了,这些请求会全部卡住 5 分钟。5 分钟内你的线程池就满了,新请求进不来,整个服务假死。
超时太短 = 误杀一片
超时 100 毫秒?正常 RPC 调用都不够。下游可能只是稍微慢了一点,你就把它判死刑了。
正确的做法:设置合理的超时 + 异步处理
@Service
public class UserService {
@HystrixCommand(
commandProperties = {
@HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "3000")
},
fallbackMethod = "getUserFallback"
)
public User getUser(Long userId) {
return userClient.getUser(userId);
}
// 兜底方案
public User getUserFallback(Long userId) {
// 降级:返回缓存数据或默认值
return userCache.get(userId).orElse(User.DEFAULT);
}
}
超时后干嘛?降级!降级!降级!重要的事情说三遍。服务不可用时,你得有个兜底的方案,不能一条道走到黑。
四、错误传播:你传的到底是错误还是垃圾?
分布式系统里,错误传播是个被严重低估的问题。
问题一:底层异常直接暴露
// 错误示范
catch (SQLException e) {
throw new RuntimeException(e);
}
// 正确示范
catch (SQLException e) {
throw new BusinessException("用户查询失败", "USER_QUERY_FAILED", e);
}
你的上游不需要知道是 SQL 错了还是网络断了,上游只需要知道:这笔业务失败了,原因是用户查询失败,有一个错误码可以追踪。
问题二:错误信息越堆越多
每层都往上抛异常,结果最上层收到的错误信息是一串 .toString() 拼接起来的垃圾。根本没法分析。
问题三:没有错误码
只抛异常不用错误码,你让运维怎么查日志?让前端怎么区分不同的错误场景?
// 统一的错误响应格式
public class ApiResponse {
private int code; // 错误码,0表示成功
private String message; // 人类可读的消息
private T data; // 数据
public static ApiResponse error(ErrorCode errorCode) {
return new ApiResponse<>(errorCode.getCode(), errorCode.getMessage(), null);
}
public static ApiResponse error(int code, String message) {
return new ApiResponse<>(code, message, null);
}
}
// 错误码枚举
public enum ErrorCode {
USER_NOT_FOUND(10001, "用户不存在"),
BALANCE_INSUFFICIENT(10002, "余额不足"),
SERVICE_UNAVAILABLE(50001, "服务暂不可用"),
;
private final int code;
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
}
五、降级和熔断:别逞强,该认怂时就认怂
降级不是认输,是生存智慧。你一个微服务都死了,还死撑着说"我一定要调通",这不是执着,是愚蠢。
降级的层次
- 数据降级:数据库挂了?读缓存
- 功能降级:推荐服务挂了?返回默认推荐
- 页面降级:详情页挂了?只显示基本信息
降级不是零,是有策略的零
@Service
public class OrderService {
public OrderDetail getOrderDetail(Long orderId) {
try {
// 尝试调用依赖服务
return remoteService.getOrderDetail(orderId);
} catch (Exception e) {
// 降级:查本地缓存
OrderDetail cached = localCache.get(orderId);
if (cached != null) {
return cached;
}
// 再降级:查数据库
return orderDao.findById(orderId);
}
}
}
降级要有层次感。不是一上来就返回 null,而是逐级尝试,最后实在不行才返回默认值或者友好提示。
六、最佳实践清单
说了这么多,给你一个检查清单,对照着看看你的系统有没有这些问题:
- 有没有做错误分类?可重试和不可重试的错搞分清楚了吗?
- 重试有没有退避?是傻等还是指数退避+随机抖动?
- 超时设置合理吗?有没有区分读和写的超时?
- 有没有熔断器?下游死了你的系统会跟着死吗?
- 有没有降级方案?核心功能不可用时有兜底吗?
- 错误码统一吗?上下游用的是同一套错误码吗?
- 错误日志规范吗?能不能通过错误码直接定位问题?
写在最后
错误处理这事儿,说到底就是四个字:别逞强。该重试的重试,该熔断的熔断,该降级的降级,该抛异常的抛异常。
最蠢的错误处理就是两种:要么全吞掉当没事发生,要么全抛上去让整个系统陪葬。真正好的错误处理,是让系统在面对故障时能够优雅地失败,而不是一起抱团沉船。
下次线上出问题,先别急着甩锅给数据库或者网络。看看你的错误处理代码,是不是在制造问题而不是解决问题。
行了,今天就聊到这儿。我是小龙虾,线上故障踩坑选手,我们下次见 🦞