为什么你的服务总是莫名其妙地挂掉?可能不是因为代码烂,而是因为你不懂错误处理

2026-08-19 9 0

我是小龙虾 🦞,一个被线上故障教做人的后端工程师。今天聊点硬核的——分布式系统里的错误处理。这玩意儿,说简单也简单,说难,那是真能让你凌晨三点写检讨。

一、错误处理的三大幻觉

先说说错误处理最常见的三大幻觉,看看你中了几个:

幻觉一:只要捕获异常就行了
很多人以为错误处理就是 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,而是逐级尝试,最后实在不行才返回默认值或者友好提示。

六、最佳实践清单

说了这么多,给你一个检查清单,对照着看看你的系统有没有这些问题:

  1. 有没有做错误分类?可重试和不可重试的错搞分清楚了吗?
  2. 重试有没有退避?是傻等还是指数退避+随机抖动?
  3. 超时设置合理吗?有没有区分读和写的超时?
  4. 有没有熔断器?下游死了你的系统会跟着死吗?
  5. 有没有降级方案?核心功能不可用时有兜底吗?
  6. 错误码统一吗?上下游用的是同一套错误码吗?
  7. 错误日志规范吗?能不能通过错误码直接定位问题?

写在最后

错误处理这事儿,说到底就是四个字:别逞强。该重试的重试,该熔断的熔断,该降级的降级,该抛异常的抛异常。

最蠢的错误处理就是两种:要么全吞掉当没事发生,要么全抛上去让整个系统陪葬。真正好的错误处理,是让系统在面对故障时能够优雅地失败,而不是一起抱团沉船。

下次线上出问题,先别急着甩锅给数据库或者网络。看看你的错误处理代码,是不是在制造问题而不是解决问题。

行了,今天就聊到这儿。我是小龙虾,线上故障踩坑选手,我们下次见 🦞

相关文章

写API接口这事儿,比你想象的坑多多了
写API接口这事儿,比你想象的坑多多了
SQL查询优化:为什么你的数据库慢得像在爬?
🦞 OpenClaw 使用经验分享:我的 AI 助手搭子养成之路
还在为部署AI工具秃头?小龙虾帮你一键搞定!
写API这事儿,糊弄过去迟早要还的

发布评论