处理线上故障久了我对“异常处理”这件事的看法变了很多。刚工作那会儿我以为异常处理就是 try-catch 包一层、日志打出来、别让系统崩就行。后来真正被线上问题磨过几轮才明白异常处理的核心根本不是“捕获”而是“恢复”——当某个操作失败之后系统怎么回到一个合法、可继续服务的状态。而“重试”只是恢复手段里最常见的一种远不是全部。写代码这么多年我最大的感受是异常处理写得好的代码和写得随便的代码差的不是代码量而是对“系统失败后该怎么办”这件事想得有多清楚。今天这篇就是想把这个话题彻底聊透从最基础的异常分类到重试机制的设计到异步场景的坑再到日志诊断和面试视角一次讲完。里面所有的结论都不是课本上的话而是我在真实系统里验证过、踩过坑之后的经验总结。1. 先搞懂异常恢复到底在恢复什么Java 里处理异常从面试题的角度大家都很熟受检异常checked exception、非受检异常unchecked exception、Error。数组越界是 ArrayIndexOutOfBoundsException属于非受检异常连接数据库失败可能抛 SQLException属于受检异常。这些分类背得滚瓜烂熟但面试很少问一个很实际的问题异常抛出来之后你的程序打算怎么办1.1 异常的三种命运中断、恢复、降级在我的经验里异常只有三种命运。第一种是直接中断。比如参数错误、状态非法这种异常继续往下走没有任何意义能做的就是记录日志、回收资源、把错误返回给调用方。我之前遇到过一个典型的例子用户传入的订单号格式不合法这种异常你重试一百次也不可能成功因为它不是临时故障而是调用方传错参数了。这种情况下快速失败比什么都重要让它尽早暴露给上游而不是在系统内部磨蹭半天。第二种是局部恢复。调用外部服务超时、网络抖动、数据库连接池暂时满这些异常不代表业务逻辑错了重试一下可能就好了。这种场景是“重试机制”的主场。去年我处理过一个线上问题某个第三方接口在高峰期偶尔出现 2~3 秒的响应延迟单个请求看概率不高但每天几百万次调用失败量就很可观了。加上重试之后成功率从 99.2% 直接拉到了 99.95% 以上效果非常明显。第三种是降级。主链路失败但系统不能挂于是走备选方案。比如远程配置中心挂了用本地缓存继续服务比如推荐算法服务超时先返回一个默认的热门列表。降级不是“不处理”而是用更低的成本保住核心体验。我经常跟团队强调不是所有问题都值得用重试去硬扛很多时候降级比重试更优雅。很多人把“异常恢复”等同于“重试成功”这其实是个误区。恢复是一个更宽泛的概念重试只是其中一种手段。降级也是恢复回滚也是恢复补偿也是恢复。你把概念理清了设计的时候思路才会打开。1.2 可恢复异常和不可恢复异常怎么判断你怎么判断一个异常是可恢复的我习惯问三个问题。第一个问题失败的原因是临时的还是永久的临时原因网络抖动、连接池满、服务重启中值得重试永久原因参数非法、数据不存在、权限不足重试一万次都一样。最典型的例子就是数组越界异常ArrayIndexOutOfBoundsException这是代码逻辑写错了不是外部的临时问题。在 catch 块里对数组越界做重试只会让错误被掩盖、排查难度翻倍。这类异常的正确处理方式是快速失败让监控系统报警让开发人员尽快修复代码。第二个问题失败之后系统状态还一致吗如果扣了钱但订单没创建这种状态不一致必须先恢复回滚或补偿再谈重试。我见过不少系统重试逻辑写得挺热闹但漏了状态一致性。比如账户服务调用积分服务失败重试三次第三次成功了但前面第一次请求其实已经部分成功了——积分加了账户却提示失败。这种问题重试解决不了需要的是对账、补偿、幂等设计。第三个问题重试的成本可接受吗一次重试如果是毫秒级、影响面小可以放心做如果一次重试要跑几分钟的大任务就得想清楚并发重试会不会把系统压垮。比如定时批处理任务里面有个步骤调外部接口失败了如果无脑重试 5 次每次间隔 2 秒整个批任务就会多出 10 秒的延迟。批任务多了调度队列就会积压。1.3 恢复的本质是回到合法状态数据库事务回滚是恢复分布式系统里的 Saga 补偿也是恢复消息队列的消息重投也是恢复本地缓存淘汰后重新加载也是恢复。它们的共同点是失败后系统要回到一个业务上合法的状态。这个“合法状态”很难定义但必须定义清楚。以电商下单为例用户提交订单后系统要做的事情包括扣库存、生成订单、发消息通知。如果扣库存成功但生成订单失败从业务上看库存被占用了但没有对应的订单这就是非法状态。怎么恢复要么回滚库存要么把订单补上。这两种操作都是“恢复”但方式完全不同。所以聊异常恢复与重试第一步不是学框架而是建立判断力什么样的失败可以重试什么样的失败必须回滚什么样的失败需要降级。判断力有了工具只是实现细节。2. 重试不是万能的动手之前先回答三个问题重试机制本身很简单就是个“失败了再试一次”的循环。但线上系统里重试真正难的是边界控制。我见过最惨的一次事故是这样的一个服务调用下游超时代码里重试 3 次结果下游已经半瘫痪了重试流量一上来直接把它打死了。事后复盘那 3 次重试不但没有起到恢复作用反而成了事故扩大的帮凶。2.1 先问自己这个操作幂等吗幂等是重试的第一前提没有幂等重试就是灾难。什么叫幂等同一个操作执行一次和执行 N 次结果是一样的。支付回调、订单创建、库存扣减……这些线上核心操作都必须支持幂等。举个例子用户下单支付支付回调里调用订单系统更新状态。如果更新状态这个操作不幂等回调超时后消息队列重投同一笔支付结果被处理两次就可能产生两笔订单、两次扣减。所以我在做这类系统时一律用业务唯一键比如支付流水号、订单号做幂等控制。每次处理前先查一下这个流水号是不是已经处理过处理过就直接返回成功不再重复执行业务逻辑。有了幂等基石重试才安全。这也是为什么我经常跟团队说先做幂等再做重试。顺序反了框架再高级也救不了你。很多公司做活动用户重复点击下单按钮就产生了重复订单核心原因不是重试而是下单接口本身不幂等。你按 F5 刷新一次系统就认为是新请求这哪是重试的问题这是设计缺陷。2.2 瞬时故障还是永久故障怎么区分第二个要回答的问题这个异常是瞬时故障还是永久故障这决定了重试有没有意义。我一般会看异常类型和错误码大致分成三类。第一类瞬时故障重试收益高。连接超时、SocketTimeoutException、ConnectException、502/503 这类网关错误故障是暂时的重试大概率能成功。尤其是外部接口偶尔抖动的情况加个重试效果立竿见影。第二类永久故障重试没意义。参数错误、业务状态不允许、404、400。不管重试多少次结果都是一样的。最怕的是有人图省事catch 到 Exception 就重试结果把参数校验失败这种错误也重试了 3 次白白增加系统开销。第三类是边界情况要谨慎处理。数据库连接池满、线程池拒绝、服务端限流比如返回“请求太频繁请稍后重试”。这类故障重试有意义但要思考是不是重试反而把服务打得更满了如果下游限流了说明它已经扛不住了你再重试只会让它更累。这时候更合理的策略是先退避更长的时间或者直接降级。我处理过一个真实案例某个接口偶尔会返回“网络异常”的错误码代码里配置了对所有 5xx 状态码重试 3 次但上游有一个 501 状态码表示“功能未实现”是代码 bug 导致的。结果每次这个 bug 一触发重试也会跟着触发 3 次把错误日志刷了一屏给排查造成了很大的干扰。后来我把重试条件收窄到 502、503、504 这几个具体的状态码问题才算解决。2.3 重试次数、超时和成本要算一笔账重试策略不是拍脑袋定的。我一般按“一次请求的预期耗时”来估算总成本。假设一次调用平均 200ms超时上限 1s那么重试 3 次理论上最多消耗 3s 左右。如果接口本身要求 2s 内响应这个重试策略就会把整个请求拖垮。所以实践中有个“最大等待时间”的概念把首次调用超时和重试等待都算进去得到一个请求可能的最长耗时然后问自己这个值能不能接受。举个例子首次超时 500ms指数退避重试 3 次等待时间分别是 200ms、400ms、800ms那总耗时上限就是 500 200 400 800 1900ms。如果这个值不能接受就减少重试次数或者缩短首次超时或者砍掉最后一次重试。另外还要考虑并发视角。100 个请求同时失败然后同时进入重试下游瞬间多出 300 个请求这个压力是翻倍再翻倍的。所以重试不是“越多越好”而是“够用就好”。我一般推荐核心链路重试最多 2~3 次非核心链路甚至可以不重试直接降级。重试的真正目的不是提高单次请求的成功率而是让系统在偶发故障面前保持整体可用性。3. 从手写重试到成熟框架演进过程就是一份教材讲完了设计的“道”接下来聊聊“术”。我最早写重试逻辑的时候项目里还没引入任何重试框架全靠自己写循环。后来用了 Spring Retry、Guava Retrying、Resilience4j才体会到成熟框架带来的便利。但我不建议一上来就堆框架先看看手写版本能帮你理解重试的本质再看框架解决的是哪些痛点。3.1 最基础的手写重试循环 计数 休眠不依赖任何框架最朴素的重试写法是这样的public String callWithRetry() throws IOException { int maxRetries 3; int retryCount 0; long waitMillis 200; while (retryCount maxRetries) { try { return callRemote(); } catch (IOException e) { retryCount; if (retryCount maxRetries) { throw e; } try { Thread.sleep(waitMillis); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new IOException(重试等待被中断, ie); } waitMillis * 2; // 指数退避 } } return null; // 实际不会走到 }仔细看这段代码其实问题不少。第一它对所有 IOException 都重试不管是不是临时故障。上面说过了这会导致对永久故障做无效重试。第二它是同步阻塞的Thread.sleep 会占用线程在高并发下线程池很容易被打满。第三它没有考虑 InterruptedException 的处理如果线程在 sleep 期间被中断中断状态会被吞掉这是个隐藏 bug。第四它没有重试次数之外的总耗时上限如果每次调用都卡到超时这个循环可能跑很久。但即便如此这个手写版本已经把重试的核心三要素都包含了重试次数、等待间隔、退避策略。理解了这三要素你再去用任何框架都只是换了个 API 而已。3.2 Spring Retry注解驱动适合多数业务场景如果项目用了 Spring最方便的是直接用 Spring Retry。它最大的优势是注解驱动代码侵入小。一个注解就能搞定大多数重试场景Service public class OrderService { Retryable(value {IOException.class, TimeoutException.class}, maxAttempts 4, backoff Backoff(delay 200, multiplier 2)) public String fetchOrderStatus(String orderId) { // 调用外部查询订单状态接口可能抛 IOException / TimeoutException return restTemplate.getForObject(url, String.class); } }Retryable 的几个关键参数value 指定哪些异常触发重试maxAttempts 包含首次调用在内所以配置 4 就是“首次 重试 3 次”这个细节我经常跟人强调很多人配置的时候会被绕进去backoff 里的 delay 是首次重试前的等待时间multiplier 是退避倍数2 表示每次等待时间翻倍。用 Spring Retry 有个隐藏的大坑它基于 AOP 实现方法不能通过 this 调用否则代理不生效。我踩过这个坑写了个 private 方法里面调用了带有 Retryable 注解的另一个方法结果重试完全不触发查了半天才发现是内部调用绕过了代理。解决办法是自注入在类里注入自己或者把重试逻辑抽到一个独立的 Service Bean 里保证调用入口走的是 Spring 代理。3.3 Resilience4j重试、熔断、限流一体化如果不想被 Spring 绑定或者需要更精细的控制可以用 Resilience4j。它对标的是曾经的 Hystrix但比 Hystrix 更轻量设计也更合理。Resilience4j 提供了重试、熔断、限流、舱壁隔离等功能而且都支持函数式编程可以在不依赖注解的情况下使用。一个典型的 Resilience4j 重试配置RetryConfig config RetryConfig.custom() .maxAttempts(4) .waitDuration(Duration.ofMillis(300)) .retryExceptions(IOException.class, TimeoutException.class) .ignoreExceptions(IllegalArgumentException.class) .build(); Retry retry Retry.of(externalApi, config); SupplierString supplier Retry.decorateSupplier(retry, () - callRemote()); String result supplier.get();Resilience4j 里我特别推荐的是重试和熔断的配合。重试解决的是“偶发失败”熔断解决的是“持续失败”。当重试多次仍然失败时熔断器会快速打开在一段时间内直接拒绝请求给下游喘息的时间。这个组合拳是微服务容错的关键。我举个具体例子。某个外部接口连续失败如果只有重试没有熔断每个请求都会卡在超时上然后重试 3 次等 1 分多钟才失败返回。但加上熔断之后熔断器打开后续请求在毫秒级直接失败不再打下游等冷却时间过了再放行少量请求试探。这比单纯的重试科学得多。不过用 Resilience4j 要注意一点如果项目里配置了多个容错组件合理的编排顺序是“重试在外、熔断在内”让重试的流量先经过熔断器的判断否则熔断器已经打开了你还在重试等于没熔断。3.4 退避策略为什么必须用指数退避加抖动退避策略是重试的核心细节。最简单的固定退避就是每次等一样的时长。但固定退避在大量请求同时失败时会带来“惊群效应”大家一起失败然后一起重试下游被打得更惨。指数退避合理得多第一次失败等 200ms第二次等 400ms第三次等 800ms。如果在此基础上加上一个随机抖动比如在退避时间上乘以一个 [0.8, 1.2] 的随机系数效果会更好。抖动的作用是打散重试请求的节奏避免所有客户端在同一时刻集中重试。我用一个对比表来说明不同策略的差异假设首次等待 200ms总共重试 5 次重试次数固定退避指数退避指数退避 抖动1200ms200ms约 180ms2200ms400ms约 450ms3200ms800ms约 750ms4200ms1600ms约 1700ms5200ms3200ms约 3000ms固定退避的问题是 1000 个请求同时在 200ms 后发起重试瞬间洪峰指数退避的问题是没有完全错开因为同样的指数序列客户端之间的重试时间点还是重合的加上随机抖动之后重试请求才会在时间轴上真正散开。关于退避公式我常用的是waitTime baseDelay * (multiplier ^ retryCount) * (1 random(-0.2, 0.2))。baseDelay 一般取 100~300msmultiplier 取 1.5~2random 范围别太大太大反而会让用户等待时间不可控。4. 异步与并发场景异常恢复的进阶战场讲到这里重试的基本功已经差不多了。但 Java 后端现在几乎离不开异步编程异步场景下的异常恢复和重试坑比同步场景多得多。热词里有一条“CompletableFuture 异常后不在执行其他的异步任务”我一看就很有共鸣这个坑我见过无数次了。4.1 CompletableFuture 异常传播一个任务失败会带崩整条链路CompletableFuture 的编排能力很强但异常处理写不对会把整条异步链路带崩。看这个例子CompletableFuture.supplyAsync(() - fetchOrder(orderId)) .thenApply(order - settle(order)) .thenApply(order - notifyCustomer(order));如果 fetchOrder 抛异常后面的 thenApply 都不会执行异常会沿着 CompletableFuture 链条一路传播到末端。如果不处理异常就“静默丢失”了——日志上看不到任务也不执行问题像是凭空消失。这是异步编程里最可怕的一种失败模式。正确的做法是在链路上加 exceptionally 或 handle。exceptionally 接收一个异常参数返回一个和上游同类型的结果它会把异常“消化”掉让后续链路继续执行CompletableFuture.supplyAsync(() - fetchOrder(orderId)) .thenApply(order - settle(order)) .exceptionally(ex - { log.error(异步链路执行失败进入降级兜底, ex); return defaultOrder(); });handle 则更灵活可以同时拿到结果和异常两个参数做更复杂的恢复逻辑。比如我可以根据异常类型决定是返回兜底数据还是抛出另一个业务异常或者记录一个指标。这里我要特别强调一个原则异步链路里的异常要么就地处理掉要么显式传递到调用方。最不能做的就是什么都不写让异常在 CompletableFuture 内部“消失”。排查这种问题极其痛苦因为代码看起来没什么毛病但结果就是不对。4.2 失败隔离别让一次异常拖垮整个线程池除了链路传播异步场景里另一个大坑是失败隔离。用 CompletableFuture 的 allOf 编排多个并发任务时如果一个任务失败其他任务可能已经启动但还没结束此时你直接抛出异常前面的任务结果就全丢了。我习惯的做法是每个子任务内部先自己做好异常处理和兜底保证任何情况下都能返回一个“有效值”。然后外层用 allOf 时再套一个大范围内的 exceptionally处理那些子任务内部都没兜住的极端情况。这样既能保证单个任务失败不影响整体又能兜住最外层不可预料的错误。还有个容易忽略的问题如果所有子任务共用一个线程池一个任务抛异常之后线程池里的线程会被释放回池中这个影响不大。但如果你在任务里用了 ThreadLocal、事务上下文、TraceId 之类的上下文信息异常后没有清理下一个任务就会拿到上一个任务的残留状态。我遇到过一次线上串号的问题查了好几天最后发现是线程池复用线程时 ThreadLocal 没清导致 A 用户的 TraceId 串到了 B 用户的日志里。从那以后凡是用了线程池的地方我都会要求任务结束前必须清理上下文。4.3 线程池拒绝与重试策略的配合重试在并发场景下有个隐形风险重试本身会占用线程池线程。比如线程池核心线程 10 个某个下游全挂了10 个线程都在执行第一次调用超时后抛异常然后所有线程同时进入重试逻辑——线程池很快被占满其他正常任务也进不来。应对办法有三个层次。第一给重试调用单独的线程池核心线程数不要太大队列也不宜太长宁可快速失败也不要让故障扩散到其他业务。第二用信号量限制并发重试的数量比如同时最多允许 5 个重试请求。第三配合熔断器下游持续失败时直接熔断不进入重试逻辑。我现在的项目里重试相关的最佳实践是重试和主调用放在不同的线程池重试线程池的核心线程数设为核心业务的 1/5 左右且必须配拒绝策略 CallerRunsPolicy 或者 AbortPolicy 并打告警。调用量大的时候宁可丢掉一些非核心的重试也不能影响核心业务。5. 异常日志与诊断恢复失败的现场不能丢重试多少次都没成功之后最终还是要靠日志去定位。但日志这个东西平时没人重视一出事就成了救命稻草。我见过太多系统日志只打了“error msg”没有上下文参数排查的时候只能靠猜。这一节我重点讲讲我在日志和镜像排查上积累的经验。5.1 异常日志要记哪些字段分布式环境里一个请求经过多个服务日志里必须有 traceId、请求参数、异常堆栈、重试次数这些关键信息。我会保证每个异常日志至少包含以下内容请求唯一标识traceId / 业务流水号用于串联整个调用链请求参数和关键路径比如订单号、用户 ID、外部接口地址方便复现问题异常类型和完整堆栈堆栈必须完整截断的堆栈会让人误判重试上下文第几次重试、等待了多久、上游状态码耗时信息首试耗时、各次重试耗时便于判断超时设置是否合理还有一个小技巧重试日志一定要和普通日志区分开。比如用单独的 MDC 字段标识重试次数或者单独打一个 retry_log 的 logger这样排查时可以直接过滤出所有重试失败的记录快速定位哪些接口在反复失败。5.2 容器部署场景下怎么快速找到 JVM 日志热词里有一条“docker 容器部署的 java 程序异常重启 jvm 日志在哪儿”这个太真实了。容器里 JVM 崩溃比如 OutOfMemoryError 导致进程退出日志可能不在容器内因为容器销毁后文件系统也没了。如果只靠 docker logs 看标准输出进程崩溃前的 GC 日志、Crash Dump 可能根本看不到。我的做法是几件事配合第一JVM 参数里显式指定 Crash Dump 路径和 GC 日志路径并挂载到宿主机或持久化存储。加在启动参数里java -Xmx2g \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/jvm/heapdump.hprof \ -Xlog:gc*:file/data/logs/jvm/gc.log:time,uptime,level,tags:filecount10,filesize50m \ -jar app.jar第二应用日志输出到 stdout/stderr这样 docker logs 能看到基础错误。但不要把日志全写进容器文件系统否则容器一销毁就全没了。第三配置日志采集如 Filebeat、Fluentd统一收集到日志中心崩溃后的日志依然可以从远端日志平台查到。如果遇到“OutOfMemoryError: Insufficient Memory”这类错误先看是不是容器内存限制和 JVM 堆设置不匹配。容器限制 1G但 -Xmx 设了 2GOOM 几乎必然发生。这种问题重试永远解决不了必须从资源配比上治。5.3 数据层异常恢复建表、误删、状态不一致数据层的恢复与重试是敏感话题。比如建表异常DDL 执行失败如果 DDL 不是幂等的重试反而会出问题——第一次执行一半失败第二次重试时发现表已经存在抛“Table already exists”。处理这类问题的原则是DDL 之前先检查元数据状态或者用版本化迁移工具比如 Flyway、Liquibase它们内部已经有幂等处理和版本控制比手工执行脚本靠谱得多。手工执行建表脚本重试前先查 information_schema 确认当前状态再决定是补跑还是回滚。误删数据这类场景与重试无关考验的是备份和恢复机制。我遇到过同事在生产库执行 delete 语句时忘加 where 条件一瞬间清空整张表。这种事故靠异常重试是救不回来的只能靠完善的备份策略和时间点恢复能力。但有一点值得提如果操作设计成“先备份、再修改、校验后删除”很多数据恢复场景根本不会发生。这其实是异常恢复的另一个层面预防性设计。6. 面试视角异常恢复与重试的高频考法与加分回答每年 Java 面试都会问到异常和重试相关的问题。结合我在面试中聊候选人的经验这个话题既能考察基础又能区分实战能力。热词里那些“java 面试八股文”其实最怕的就是只背概念不会应用。6.1 高频考点清单与回答框架常见的问题大概有这么几类受检异常和非受检异常的区别各自的典型例子try-catch-finally 中 return 的执行顺序finally 里修改返回值会生效吗为什么 Spring 事务注解加在内部方法调用上不生效这跟 Retryable 内部调用不生效是同一个原理。你会怎么设计一个重试机制需要重点考虑哪些因素重试和幂等的关系是什么为什么说幂等是重试的前提熔断和重试有什么区别为什么要配合使用指数退避是什么为什么要加随机抖动CompletableFuture 的异常传播机制是怎样的如何在异步链路里做降级恢复回答这些问题的框架我建议是“分类 场景 工程化”三步。比如问你“受检异常和非受检异常的区别”不要只回答定义要补充工程上我倾向于用非受检异常表达编程错误用受检异常表达可预期但需要调用方处理的外部故障。然后举一个自己项目里的例子说明什么时候用哪种异常。6.2 从“知道”到“能做”用案例谈取舍面试官最想看到的不是你背了多少概念而是你能不能结合实际场景谈出取舍。我举一个我曾用过的面试问题如果让你设计一个“订单状态同步”功能上游接口偶尔超时、偶尔 500你会怎么做初级回答加个 try-catch失败重试 3 次。中级回答接口要支持幂等重试次数不要过多用指数退避还要考虑超时时间。高级回答先按异常类型区分连接超时和业务异常分开处理重试用独立的线程池做隔离重试超过阈值后转入熔断同步失败进入 MQ 做异步补偿所有重试和补偿都有 traceId 串联还要有监控告警重试成功率异常时能及时发现。这个差别一目了然。真正的经验不在于会用哪个框架而在于你能不能在脑子里搭出整个系统的容错图景。面试前我建议每个后端开发都认真整理一下自己负责的系统里哪些失败靠重试解决哪些靠降级解决哪些靠补偿解决哪些靠告警和人工介入解决。想清楚这几个问题面试时聊到异常处理和容错设计你就能输出远超八股文的深度。最后分享一个我自己的小习惯。我写重试逻辑时永远把“第三次还是不成功”当作问题的真正开始前两次只是给系统一个自我修复的机会。真正值得花精力设计的往往不是把重试次数调大而是失败之后的降级路径和状态恢复方案。判断清楚哪些异常该重试、哪些该回滚、哪些该降级比学会十个重试框架都管用。希望你读完这篇之后处理异常时能多一分从容少踩几个我在线上踩过的坑。