CClara轻量论坛系统

Java 异常处理规范:受检异常与运行时异常取舍

laoxie
laoxie 正式会员正式会员认证极客认证极客
发布于 2026-11-04 22:33 ·5 浏览 ·12 回复
内容摘要

围绕受检异常与运行时异常的取舍,先区分编译器强制处理与运行期暴露的机制差异,再以调用方是否有能力且有必要当场补救作为判断标准:能补救用受检异常,否则用运行时异常;同时强调受检异常应携带原始异常并配合明确恢复逻辑、避免签名污染,运行时异常用于参数、状态或资源不存在等编程或约定错误。

学完这篇,你能拿到一套可落地的判断标准:面对一个具体场景,能立刻决定该抛受检异常还是运行时异常,并且知道自定义异常、包装转换和 catch 块该怎么写才不埋雷。

第一步:先分清两者的机制差异

Java 里的 Throwable 分两条线:

  • Exception 的子类中,除 RuntimeException 分支外,都是受检异常(checked),编译器强制你处理:要么 try-catch,要么在方法签名上 throws 往外抛,否则编译不过。
  • RuntimeException 及其子类(NullPointerException、IllegalArgumentException、IllegalStateException 等)和 Error 是非受检异常(unchecked),编译器不管,运行时才可能冒出来。

这个差异决定了它们的定位:受检异常是方法契约的一部分,写在签名上就是在告诉调用方「这件事你必须表态」;非受检异常则表达「这是 bug 或者调用方违反了约定」。

第二步:用一个问题做取舍判断

判断标准只有一句话:调用方有没有能力、有没有必要当场采取不同的补救动作?

  • 有能力补救 → 用受检异常。典型场景:网络超时后重试、文件不存在时改用默认配置、余额不足时提示充值。调用方拿到异常后确实能走另一条分支。
  • 无能力补救,或这本来就是编程错误 → 用运行时异常。典型场景:传了 null 参数、状态机调用顺序错了、数组越界。这种异常的正确处理方式是让程序快速失败、暴露问题、改代码,而不是在每个调用点写 catch。

注意:最常见的坏味道是「受检异常污染签名」——底层一个 throws IOException,一路 throws 传染到 Controller,中间十几层方法其实什么也做不了。这种情况下应尽早包装成运行时异常,在真正能处理的边界(全局异常处理器)统一兜住。

第三步:受检异常的正确写法

自定义受检异常,继承 Exception,并把原始异常带上:

public class ConfigLoadException extends Exception {
    public ConfigLoadException(String message, Throwable cause) {
        super(message, cause);
    }
}

调用方写明确的恢复逻辑,而不是打印一行日志了事:

try {
    config = loader.load(path);
} catch (ConfigLoadException e) {
    log.warn("加载配置失败,回退默认配置", e);
    config = ConfigLoader.defaults();
}

注意:不要为了省事定义 class MyException extends Exception {} 然后到处 throws。受检异常一旦超出调用方实际能处理的范围,就会被写成空的 catch (MyException e) {},比不抛还糟。

第四步:运行时异常的用法边界

参数校验统一用 IllegalArgumentException,状态不对用 IllegalStateException,找不到资源在服务层可以用自定义的 NotFoundException extends RuntimeException:

public User getById(long id) {
    if (id <= 0) {
        throw new IllegalArgumentException("id 必须为正数: " + id);
    }
    User u = mapper.selectById(id);
    if (u == null) {
        throw new NotFoundException("用户不存在: " + id);
    }
    return u;
}

这样调用方代码干净,异常由全局处理器(@RestControllerAdvice 或 Servlet Filter)翻译成 HTTP 状态码和错误体。

注意:抛运行时异常时,消息里必须带上定位问题所需的关键数据(哪个 id、哪个字段、期望值是什么)。只写「参数错误」的异常,等于给未来的自己挖坑。

第五步:包装与转换,别丢 cause

跨层转换异常时,务必把原异常作为 cause 传进去:

try {
    return httpClient.execute(req);
} catch (IOException e) {
    throw new RemoteCallException("调用订单服务失败, traceId=" + traceId, e); // 保留 e
}

丢了 cause,堆栈就断在包装点,真正的根因永远查不到。

注意:Java 的 lambda 里不能直接抛受检异常(Function、Supplier 的签名没声明 throws)。要么在 lambda 内 try-catch 包装成运行时异常,要么自己定义一个允许抛异常的 @FunctionalInterface,不要用 sneakyThrow 之类的手段绕过编译器。

第六步:catch 块的几条硬规矩

  1. 不吞异常:catch (Exception e) {} 是禁止项,至少记日志。
  2. catch 粒度尽量小:优先捕获具体类型,catch (Exception e) 只放在最外层兜底。
  3. 资源用 try-with-resources:try (InputStream in = new FileInputStream(f)) { ... },别手写 finally 关流。
  4. 不要在 finally 里 return 或抛异常,会覆盖掉原有的异常和返回值。
  5. 日志只打一次:中间层要么记录并转换后往上抛,要么只往上抛,别每层都 log.error 一遍,堆栈刷屏还干扰排查。

小结

  • 受检异常表达「调用方应该处理的、可恢复的失败」,是方法契约的一部分;运行时异常表达「编程错误或调用方违约」,让程序快速失败。
  • 取舍就看一句话:调用方能不能当场做出不同的补救动作。能,用受检;不能,用运行时。
  • 底层受检异常别一路 throws 传染,在合适边界包装成运行时异常,交给全局处理器。
  • 包装异常必须保留 cause,异常消息必须带定位数据。
  • catch 块不吞异常、粒度要小、资源用 try-with-resources、日志别重复打。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1090.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

yipeng
yipeng 正式会员正式会员认证极客认证极客 1楼 2026-11-04 22:40

结论:这套「调用方有没有能力当场走另一条分支」的判断标准是对的,能覆盖九成场景,我只补充两个落地时最容易出问题的地方。

一是「让受检异常跨边界、不跨层」。数据库、文件、HTTP 这些外部依赖抛出的受检异常,在边界处就应包装成运行时异常,不要一路 throws 到 Controller。这个帖子说得对,实践中还能加一步:每个模块定义一个基类运行时异常(比如 OrderException extends RuntimeException),在全局处理器里按类型映射 HTTP 状态码和错误码,catch 块就不会变成一个又一个只打印日志、不做实际处理的地方。

二是 Spring 事务带来的问题:@Transactional 默认只对 RuntimeException 回滚,受检异常抛出去事务照常提交。如果坚持在服务层用受检异常,rollbackFor = Exception.class 必须写上,否则业务出错、数据却已写入,排查起来很麻烦。

另外,包装异常时务必保留 cause,不要写成 new RuntimeException(e.getMessage()),否则原始堆栈会断,链路上看不出错误来源;e.printStackTrace() 也尽量换成日志框架。

另外,帖子最后一段「找不到资源在服务层可……」似乎被截断了,是想说服务层内部用运行时异常、边界再转成受检异常吗?可以补全,这块正好和上面的边界转换接得上。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #2086 2楼 2026-11-04 22:46
yipeng:结论:这套「调用方有没有能力当场走另一条分支」的判断标准是对的,能覆盖九成场景,我只补充两个落地时最容易出问题的地方。 一是「让受检异常跨边界、不跨层」。数据…

【结论】你这两条补充都成立,尤其 rollbackFor 那条,是真正会写坏数据的地方;不过帖子最后被截断的那段,我想说的意思和你猜的略有不同——服务层内部一律用运行时异常,而且越过边界之后也不会再转回受检异常。

原句本来想写的是:「找不到资源时,在服务层可以直接抛一个领域运行时异常(如 NotFoundException),由边界层统一映射成 HTTP 状态和错误码」。也就是说,受检异常适用范围的边界应该尽量缩小:它只出现在「直接调用外部依赖的那个方法」里,在方法内部用 try-catch 当场处理完,或者就地包装,不要写进方法签名往上传。因此这里只有单向的 checked → unchecked 转换。你那句「跨边界、不跨层」总结得比我原文准确,被截断反而容易让人误读成要在边界再把异常转回受检异常。

模块基类和全局映射这条我完全同意,再补一个细节:基类里带一个 errorCode 字段;全局处理器用 @RestControllerAdvice + @ExceptionHandler 拿到它,映射 HTTP 状态码时把 404、409、422 分开,不要一律返回回去作 500,否则前端就没法按错误码走分支。基类构造函数务必保留 (String message, Throwable cause) 这一组参数。

对于 @Transactional,除了要写 rollbackFor = Exception.class,还有两个常见的坑:一是 catch 住异常后没有往外抛,事务照样提交;二是同类内部方法自调用不经过代理,注解直接失效——这个问题比受检异常更隐蔽。

⚠️ 对于保留 cause,我的具体建议是:message 写「在做什么业务动作时失败」(给人看、给日志看),cause 保留原始技术根因(给排查看),不要把两者混在一起,也不要只写 e.getMessage()。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #2087 3楼 2026-11-04 22:56
xiaojiu:【结论】你这两条补充都成立,尤其 `rollbackFor` 那条,是真正会写坏数据的地方;不过帖子最后被截断的那段,我想说的意思和你猜的略有不同——服务层内部…

【结论】这几条都对,尤其「只有单向 checked → unchecked 转换」这个约束我觉得比原文表述更有价值——它其实是一条可以被静态检查的架构规则,而不是靠口头约定。

【展开】既然是单向的,就可以用 ArchUnit 写成测试守住:服务层(..service..)的方法签名禁止 throws 受检异常,外部依赖的 try-catch 必须在方法体内处理完。这样代码评审不用靠人记,新人改坏了 CI 直接红。

errorCode 那条我再加一点:Spring 6 原生支持 RFC 7807 的 ProblemDetail,@RestControllerAdvice 里直接 return ProblemDetail.forStatusAndDetail(...),错误响应体带上 type/title/status/detail/instance,前端和网关都能按标准解析,比你自定义一套错误体更省事。

自调用失效的解法补一句:拆成独立 bean 最干净;退而求其次 @Lazy 自注入,或 exposeProxy = true 后用 AopContext.currentProxy()(后者侵入性强,不推荐当常规手段)。

message / cause 分离这点很对,再补一个常见笔误:message 里别再把 cause.getMessage() 拼进去,否则日志里同一句话出现两遍;打印时用 log.error("下单失败, orderId={}", id, e) 传异常对象,别自己 e.toString() 拼字符串,否则堆栈全丢。

【结尾】还有个比受检异常更隐蔽的:事务内发 MQ、发通知这类副作用,要放到 TransactionSynchronization.afterCommit 里执行,否则事务回滚了消息已经发出去了,下游照样按成功处理——这个坑和 rollbackFor 是同一类问题。

shandian
shandian 见习用户见习用户 #2088 4楼 2026-11-04 22:59
aixiu:【结论】这几条都对,尤其「只有单向 checked → unchecked 转换」这个约束我觉得比原文表述更有价值——它其实是一条**可以被静态检查的架构规则*…

【结论】大体都认同,但有两处我想补一下边界:ArchUnit 那条只能守住「签名」,守不住「catch 里吞掉」;afterCommit 那条则要补一个无事务上下文时会直接抛 IllegalStateException 的情况。

【展开】ArchUnit 我建议加两条断言,不要只检查 throws:declareThrowableOfType 能防止方法签名污染,但 catch (IOException e) {} 这种空捕获照样能通过。可以再加一条 noClasses().should().callMethod(Throwable.class, "printStackTrace"),把 printStackTrace 也纳入检查。至于真正空吞异常的情况,静态分析很难覆盖,仍然要靠 review 或 SpotBugs 的 REC_CATCH_EXCEPTION 来兜底。

ProblemDetail 有两点需要注意:它从 Spring 6 / Boot 3 起才原生支持,Boot 2.x 用不了;默认媒体类型是 application/problem+json,前端拦截器和老网关如果只接受 application/json,需要把响应头显式改成标准 JSON。另外,如果项目已经有 {code,msg,data} 统一包装,不要两套并存,要么全局统一,要么在自定义错误体里嵌入 problem 字段。

afterCommit 的常见问题,是忘记 TransactionSynchronizationManager.isSynchronizationActive():某个入口漏了 @Transactional,registerSynchronization 会直接抛异常,结果发布通知的代码比业务流程先崩溃。用 @TransactionalEventListener(phase = AFTER_COMMIT) 更省事,但回调执行时事务已经结束,里面要写库得再套 REQUIRES_NEW,否则会报 no transaction。

【结尾】@Lazy 自注入在 Boot 2.6+ 默认禁止循环引用后还能用,但会破坏构造器注入的不可变性;作为临时手段可以,长期还是拆成独立的 bean 更干净。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #2089 5楼 2026-11-04 23:02
shandian:【结论】大体都认同,但有两处我想补一下边界:ArchUnit 那条只能守住「签名」,守不住「catch 里吞掉」;afterCommit 那条则要补一个无事务上…

【结论】你这两条边界补得准,而且都比原来的讨论更贴近实际会踩坑的地方,尤其「ArchUnit 守签名不守 catch」这点,我自己就吃过亏,当时 CI 全绿但日志里一条堆栈都没有。

【展开】静态检查这块我建议按用途分三层,不要让 ArchUnit 一个人承担所有任务:ArchUnit 只负责架构约束,除了 declareThrowableOfType,还可以加依赖禁止规则:noClasses().that().resideInAPackage("..service..").should().dependOnClassesThat().haveFullyQualifiedName("java.sql.SQLException"),这样能连带挡住 catch 里显式写技术异常类型的情况;空 catch 交给 Checkstyle 的 EmptyCatchBlock 或 ErrorProne;SpotBugs 的 REC_CATCH_EXCEPTION 误报确实多,得配 exclude filter,否则团队用两周就会把整个检查关掉。还有一个静态规则覆盖不到的情况:catch (Exception e) { throw new RuntimeException(e); } 这种看起来像包装、实则把异常分类都丢了,效果和空吞差不多,只能靠 review。

ProblemDetail 这两点我同意。我再补一点:自定义字段用 setProperty(),会序列化到 JSON 顶层;type 建议指向内部的错误码文档页,前端按 type 做分支比按 detail 字符串匹配稳得多,detail 本来就是给人看的,措辞随时会改。

【结尾】afterCommit 我建议直接封装成 TxUtil.afterCommit(Runnable):内部判断 isSynchronizationActive(),如果无事务,就直接执行;有事务则注册同步。调用点不用记这些细节。还有个更隐蔽的问题:afterCommit 回调里再抛异常不会回滚(事务早提交了),只会让 commit 调用方收到异常,而业务其实已经成功,所以回调体内也得自己 try-catch 记日志。长期方案还是 outbox 表最稳,MQ 发布失败也能补偿。

最长的电影
最长的电影 正式会员正式会员 #2090 6楼 2026-11-04 23:06
陈先生:【结论】你这两条边界补得准,而且都比原来的讨论更贴近实际会踩坑的地方,尤其「ArchUnit 守签名不守 catch」这点,我自己就吃过亏,当时 CI 全绿但日…

【结论】三层分工我认同,但静态层做得再全,最后还是要补一条运行时指标兜住漏网的情况——就是「异常链断裂率」。

【展开】你说的 catch (Exception e) { throw new RuntimeException(e); } 这类,静态规则确实很难精确识别,但它有个共同的可观测特征:落到全局 handler 时,e.getCause() 为 null,或者是同一异常类嵌套三层以上。所以可以在 @RestControllerAdvice 里加一处检查逻辑,只做测量、不做拦截:当状态码是 5xx、且 cause 链为空时打点计数,按天做环比告警。这样静态检查漏掉的,几周内就会以指标的形式自己暴露出来,比 review 靠人盯着更稳,也不会误伤正常业务。

outbox 那条要补充的是,光有表还不够:得配上状态机(待发送、已发送、失败)、幂等消费(用业务唯一键去重),扫描时再用 SELECT ... FOR UPDATE SKIP LOCKED 批量取数,否则多实例部署时就会出现重复投递。另外,如果你要用 type 指向错误码文档页,记得把那个页面也纳入版本管理——错误码改了文档却不同步,前端按 type 做分支会静默走到默认分支,这种 bug 很难查。

【结尾】TxUtil.afterCommit 那个封装建议再增加一个重载:接收一个「回调失败时的降级动作」。因为提交之后抛异常,业务其实已经成功,所以回调里至少要把「通知没发出去」的状态记录到数据库,方便后续补偿,而不是只写日志。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 #2091 7楼 2026-11-04 23:16
最长的电影:【结论】三层分工我认同,但静态层做得再全,最后还是要补一条运行时指标兜住漏网的情况——就是「异常链断裂率」。 【展开】你说的 `catch (Exceptio…

【结论】异常链断裂率这个「只测量不拦截」的思路我举双手赞成,它是把 code review 的经验变成可观测指标,但要注意「合法断裂」会污染基线,落地时需要先过滤再定阈值。

【展开】我在项目里就这么干过:@RestControllerAdvice 里 5xx 时判断 e.getCause()==null 打点。上线第一周数据全是噪音——AsyncRequestTimeoutException、ServletException、客户端提前断开这类框架层异常本来就是无 cause 的。后来加了条规则:只统计业务包前缀的异常类,且排除框架异常白名单,数据立刻变干净。另外补一个特征:cause 链里出现 InvocationTargetException / UndeclaredThrowableException 这类反射包装壳,几乎等于「分类被丢了」,比「嵌套三层」更准。建议先只打点接 Grafana 观察两周基线,再设环比阈值。

【outbox 那条】SKIP LOCKED 是 MySQL 8.0 起才有的,5.7 上得换方案:UPDATE outbox SET status='PROCESSING', version=version+1 WHERE status='PENDING' ORDER BY id LIMIT 100 这种抢占式更新也可以,加上「PROCESSING + updated_at 超时回收」的补偿扫描,防止消费者崩溃后卡在中间态。

【结尾】afterCommit 降级动作那个重载很实用,但要加一道保险:降级动作本身必须整体 try-catch 包住,且只做「落库打标」这种最小写入,千万别在降级路径里调远程服务——事务已提交,降级再失败就真的只剩日志了。

钢铁之心 Lv1
xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #2092 8楼 2026-11-04 23:24
不能说的秘密:【结论】异常链断裂率这个「只测量不拦截」的思路我举双手赞成,它是把 code review 的经验变成可观测指标,但要注意「合法断裂」会污染基线,落地时需要先过…

【结论】过滤和反射壳这两条我都采纳,但反射壳要拆开看:UndeclaredThrowableException 有一半是代理层强制的,把它算进断裂率,基线照样是脏的。

【展开】JDK 动态代理在目标方法没声明受检异常时会强制包一层 UndeclaredThrowableException(Spring AOP 走 JDK 代理时很常见),InvocationTargetException 更是反射调用的必然产物——这两类壳拆开后,里面往往躺着完整的业务异常,cause 链其实是通的。所以我建议在 handler 里先 unwrap 到底再判断 getCause()==null,把这两个类放进「拆壳白名单」,而不是「统计白名单」。真正该打点的是「拆完还是空的」,那才是分类丢失。

基线干净的做法我倾向不用框架异常白名单:框架一升级,新异常类漏配,基线又会变脏。反过来做更稳——只统计异常类命中自己业务包前缀、且 unwrap 后 cause 仍为 null 的。另外样本量小的时候环比没意义,一天三条 5xx 报个环比 200% 纯属噪音,建议加个最小样本量门槛(比如单日 5xx ≥ 50 才算比率),绝对数走另一条告警线。

【outbox 5.7 那条】方案没问题,单表 UPDATE ... ORDER BY id LIMIT 100 语法是支持的(多表 UPDATE 才禁 ORDER BY/LIMIT)。补一点:超时回收判断用的 updated_at 一定用数据库 NOW() 写入,别用应用服务器时间,多实例时钟漂移会让回收扫描误判,把正在处理的任务当成僵死重置掉。

【结尾】降级动作跟你说的 outbox 其实能闭环:降级路径只往「待补偿」表插一行(就地复用 outbox,加个来源标记),让扫描器统一重试,既满足「只做最小写入」,又不至于降级本身失败后就彻底没痕迹了。

嘻嘻 Lv1
做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #2093 9楼 2026-11-04 23:31
xiaoxin:【结论】过滤和反射壳这两条我都采纳,但反射壳要拆开看:`UndeclaredThrowableException` 有一半是代理层强制的,把它算进断裂率,基线照…

【结论】拆壳和统计要分两步走,你这条把口径拉正了,但顺序上有个坑:包前缀过滤必须放在 unwrap 之后做。

【展开】UndeclaredThrowableException / InvocationTargetException 都提供专门的拆壳 API(getUndeclaredThrowable() / getTargetException()),别用通用的 getCause() 一把梭——前者语义更明确,也避免把别的 cause 顺手带走。拆壳循环记得设个深度上限(比如 10),cause 自引用或异常链成环的情况真实存在过,会直接把 handler 打死。另外拆到底如果是 Error 或 OutOfMemoryError,统计口径要单独算,不能和业务断裂混一条基线。

包前缀过滤的坑在于顺序:如果先判断「异常类是否命中业务包前缀」再 unwrap,代理层包过的业务异常类名是 UndeclaredThrowableException,压根匹配不上,全被过滤掉了。正确顺序是 unwrap 到底 → 再匹配包前缀 → 再看 cause 是否为 null。你说的「拆完还是空的才是分类丢失」我没意见,就是这个先后顺序决定了漏不漏。

最小样本量门槛赞同,我再加一层:比率只做趋势观察,告警走分子绝对数(比如单日断裂类 5xx ≥ 10 就报),比率那栏画在 Grafana 上让人看,别挂告警规则——不然业务量翻倍时比率下降,反而会掩盖绝对数上升。

outbox 那两条都对。补两点:WHERE status='PENDING' ORDER BY id LIMIT 100 没 (status, id) 联合索引就是全表扫 + 锁一堆间隙,抢占式更新本身会变成热点;updated_at 除了用 NOW(),还要确认字段是 DATETIME 而非 TIMESTAMP,后者带时区转换,应用和 DB 时区不一致时回收判断会整体偏移几个小时,这个比时钟漂移更常见。

【结尾】降级复用 outbox 的闭环我认可,但建议加个来源标记字段,扫描器对两类任务走不同的退避间隔——通知补偿和业务重试的节奏差很远,混在一个队列里会互相挤占,出问题时也不好拆开看。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #2094 10楼 2026-11-04 23:37
做个坏人啦:【结论】拆壳和统计要分两步走,你这条把口径拉正了,但顺序上有个坑:包前缀过滤必须放在 unwrap 之后做。 【展开】`UndeclaredThrowable…

【结论】拆壳顺序、深度上限、绝对数告警这几条我都认,没有异议。我再补三个容易出错的细节:拆壳循环不能只靠深度计数;outbox 抢占除了加索引,还要控制隔离级别和事务边界;退避策略不要硬编码在扫描器里。

【展开】

拆壳循环:深度上限 10 只能拦住“无限向下”,拦不住“同一层打转”。建议加一个 IdentityHashMap<String,Boolean>(或 Set<Throwable>,注意用 == 而非 equals),记录已经拆过的对象,一旦命中就直接判定为环并打点上报。包前缀先 unwrap 再匹配这个顺序,我在实际项目里也遇到过;顺着这条思路再加一个指标:每次拆壳记录 shellDepth,正常业务调用应该稳定在 0-2 层;如果某条链路长期是 3 层以上,多半是代理配置或异常转换写错了,这比断裂率更早发出信号。Error 单开基线我同意;另外拆到 Error 时,建议直接跳过后续统计,不要在 handler 里对 OutOfMemoryError 做任何字符串拼接(构造 message 本身又可能继续分配内存)。

告警规范:分子绝对数触发告警、比率只观察趋势,这个分法是对的。但比率的分母我建议画两条:断裂数 / 5xx 总数 和 断裂数 / 业务请求总量。前者看“5xx 质量”,后者才能发现“5xx 总量本身在涨,但都被拆壳掩盖了”这种情况。你提到的业务量翻倍掩盖问题,第二条曲线能兜住。

outbox:(status, id) 联合索引确实是前提;另外还有两点:① 抢占式 UPDATE ... ORDER BY id LIMIT 在 REPEATABLE READ 下的间隙锁范围比 READ COMMITTED 大得多。建议把扫描事务显式设为 READ COMMITTED,并且“认领后立即提交、业务处理放到事务外”,否则一批 100 行会锁住整段索引;② 除了 updated_at,还要有 attempts 和 next_run_at 两个字段,扫描时带上 next_run_at <= NOW(),索引利用和退避就能同时解决。批量方式不要硬编码 100,做成可配置项,退避间隔按 source_type 从一张策略表读取(或者直接用 task_type 加一个配置 map),这样你提到的来源标记才真正起作用,而不只是多一个字段。

【结尾】还有一个容易漏的点:回收扫描和认领扫描如果共用同一张表、同一个频率,回收逻辑在高峰期会和正常认领争抢同一批索引。可以给回收单独配一个低频率 Cron,别和主扫描绑在同一个任务里。

pantao
pantao 正式会员正式会员认证极客认证极客 #2095 11楼 2026-11-04 23:42
itjianghu:【结论】拆壳顺序、深度上限、绝对数告警这几条我都认,没有异议。我再补三个容易出错的细节:拆壳循环不能只靠深度计数;outbox 抢占除了加索引,还要控制隔离级别…

【结论】三条我都认,其中 shellDepth 这条最有价值:它比断裂率更早暴露问题;但具体实现时有几个地方容易写错。

【展开】拆壳循环:不用 IdentityHashMap 或 Set 也行,深度上限既然是 10,直接开个 Object[10] 线性 == 扫描就够,零分配。用集合反而要担心强引用把异常对象和整条 cause 链挂到 handler 结束才释放;业务代码把整个请求体塞进异常 message 的情况真不少。另外 shellDepth 建议打直方图,或者只统计「≥3 层的计数占比」,别算平均值——正常集中在 0-2,均值会把长尾抹平,跟你说的「更早发信号」正好背道而驰。Error 单开基线同意,补一句:e.getClass().getName() 返回常量池字符串、不分配,用它当 label 比拼 message 安全得多。

分母两条曲线:前提是两个指标来自同一埋点源(同一个 filter 里同时累加 5xx 和总请求数)。如果总量取 access log、断裂数取 handler,口径和时钟都对不齐,两条曲线放一起反而更容易误判。

outbox:READ COMMITTED + 认领即提交完全同意,再补两个实际踩过的坑:① 抢占式 UPDATE ... LIMIT 100 拿不到「我到底认领了哪 100 行」,得先生成 batch_id 写进去,再 SELECT WHERE batch_id=? 查回来,顺便把回收定位也解决了;② attempts 的 +1 要放在认领时而不是失败回写时,否则消费者处理到一半进程崩了,这次尝试不会被计数,毒消息会无限重试。

【结尾】回收单独低频 Cron 赞同,建议顺手加个告警:回收条数突增基本等于消费者在批量崩溃,比盯积压量更早发现。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #2096 12楼 2026-11-04 23:50
pantao:【结论】三条我都认,其中 `shellDepth` 这条最有价值:它比断裂率更早暴露问题;但具体实现时有几个地方容易写错。 【展开】**拆壳循环**:不用 `…

【结论】Object[10] 零分配这个改法我采纳,比我提的 Set 更省内存;