Old-School Backend / 2026 ・ No.07

古法后端入门

第7讲 异常与回复 ── 从“系统发生错误”毕业

发布

终端画面。用 Accept-Language: ja 发送项目为空、金额为负数的申请,返回 HTTP/1.1 400、Content-Type: application/problem+json。正文是 detail 直してから、もう一度送ってください,title 入力に誤りがあります,type https://expenses.example/problems/invalid-request,errors 里有 item 空白は許可されていません、amount 0 より大きな値にしてください
加上异常处理员之后的 400。哪个项目、为什么不行,都用用户的语言返回了(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)

上一讲,服务器已经能拒绝申请了。可是回复只有“Bad Request”或“500”。画面不知道该对用户说什么。
这次决定在哪里接住异常、用哪个状态码、写什么返回。实测什么都不加的 Spring Boot、ProblemDetail(RFC 9457)、异常处理员(@RestControllerAdvice)、吞掉异常、查询编号,以及不该返回的东西。

一

“系统发生错误”

新井出错的时候显示“系统发生错误,请联系管理员”就行了吧。

古井看到那个画面的用户,该改什么?

新井……去问管理员?

古井那作为管理员的我,该看哪条日志?古时候的现场,就因为那一句话,收到了几百封询问。

全 15 讲路线图(当前:第 7 讲)

1 全景图 2 Java 准备 3 裸写 HTTP 4 裸写 JDBC 5 Spring Boot 6 输入检查 7 异常与回复 8 数据库访问 9 事务 10 状态流转 11 认证与授权 12 文件 13 日志与监控 14 测试 15 运行起来 当前位置 基础 徒手 入口 数据 业务与防护 运维
  • 什么都不加 ── “不存在的受理编号”和“连点两下”都是 500。明明是用户的原因,却像故障(实测)
  • ProblemDetail ── 一个设置就能统一形式,但内容是“Invalid request content.”(实测)
  • 异常处理员 ── 400 带逐项理由,404,409,500 带查询编号(实测)
  • 吞掉异常 ── 明明没写进去,却返回了 201 和受理编号(实测)
  • 不该返回的东西 ── 一个设置,SQL 和行的内容都上了回复(实测)

应用是在上一讲的 Spring Boot 4.1.2 上,加了实验用的窗口(/api/v3)、吞掉异常的负责人,以及异常处理员(ApiErrors)。数据库里加了唯一索引,同一申请人・日期・项目・金额的申请只能有一件(用于连点两下的实验)。

二

什么都不加的回复

先给什么都不加的 Spring Boot 发送 5 种麻烦的请求。

# 什么都不加的 Spring Boot 的回复
## 1 不符合标记(项目为空、金额为负数)
HTTP/1.1 400   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.420Z","status":400,"error":"Bad Request","path":"/api/v3/expenses"}
## 2 JSON 坏了
HTTP/1.1 400   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.519Z","status":400,"error":"Bad Request","path":"/api/v3/expenses"}
## 3 不存在的受理编号
HTTP/1.1 500   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.609Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses/EXP-2026-9999"}
## 4 同样的申请发 2 次(连点两下)
HTTP/1.1 201   Content-Type: application/json
{"id":"EXP-2026-2894"}
HTTP/1.1 500   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.763Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses"}
## 5 数据库拒绝(从没有 @Valid 的窗口发送负数金额)
HTTP/1.1 500   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.824Z","status":500,"error":"Internal Server Error","path":"/api/novalid/expenses"}

不符合标记的申请和坏掉的 JSON 是 400。到这里还好,但都只写着“Bad Request”。而“不存在的受理编号”和“同样的申请发 2 次”是 500。负责人抛出的“不存在”异常,以及数据库唯一索引拒绝的连点两下,都没人接住,于是被当作“服务器故障”返回了。用户造成的失败,看起来像服务器故障。监控系统会数 500 并报警,运维的人也会半夜被叫醒(第 13 讲)。

三

启用 ProblemDetail

HTTP API 返回失败时的形式,有一个叫 ProblemDetail(RFC 9457,以前是 RFC 7807)的规范。在 Spring Boot 中一个设置就能启用。

# 用 spring.mvc.problemdetails.enabled=true 启动时
## 1 不符合标记(项目为空、金额为负数)
HTTP/1.1 400   Content-Type: application/problem+json
{"detail":"Invalid request content.","instance":"/api/v3/expenses","status":400,"title":"Bad Request"}
## 2 JSON 坏了
HTTP/1.1 400   Content-Type: application/problem+json
{"detail":"Failed to read request","instance":"/api/v3/expenses","status":400,"title":"Bad Request"}
## 3 不存在的受理编号
HTTP/1.1 500   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:13.943Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses/EXP-2026-9999"}
## 4 同样的申请发 2 次(连点两下)
HTTP/1.1 201   Content-Type: application/json
{"id":"EXP-2026-2897"}
HTTP/1.1 500   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:14.102Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses"}
## 5 数据库拒绝(从没有 @Valid 的窗口发送负数金额)
HTTP/1.1 500   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:14.161Z","status":500,"error":"Internal Server Error","path":"/api/novalid/expenses"}

400 变成了 application/problem+json,title・detail・instance 都有了。但内容是“Invalid request content.”,看不出哪个项目不对。而本该是 404、409 的,依然是 500。统一形式的机制是有的,但哪个异常对应什么只能由应用决定。

四

设一个异常处理员

负责人只是把申请“不存在”作为业务异常抛出。它不知道 HTTP。

@Transactional(readOnly = true)
public Expense find(String id) {
    return repo.findById(id).orElseThrow(() -> new ExpenseNotFoundException(id));  // 不存在 → 业务异常
}

然后,在整个应用里设一个接住异常、做成回复的角色。加了 @RestControllerAdvice 的类里的 @ExceptionHandler,按异常种类决定状态码和文字。

@RestControllerAdvice
@ConditionalOnProperty("demo.advice")
public class ApiErrors {
    private static final Logger log = LoggerFactory.getLogger(ApiErrors.class);

    @ExceptionHandler(MethodArgumentNotValidException.class)  // 不符合标记 → 400,逐项给出理由
    ProblemDetail invalid(MethodArgumentNotValidException e) {
        ProblemDetail p = problem(HttpStatus.BAD_REQUEST, "invalid-request", "入力に誤りがあります", "直してから、もう一度送ってください");
        List<Map<String, String>> errors = e.getBindingResult().getFieldErrors().stream()
                .map(f -> Map.of("field", f.getField(), "message", String.valueOf(f.getDefaultMessage()))).toList();
        p.setProperty("errors", errors);
        return p;
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)  // 无法作为 JSON 读取 → 400
    ProblemDetail unreadable(HttpMessageNotReadableException e) {
        return problem(HttpStatus.BAD_REQUEST, "unreadable", "申請の形が読めません", "JSON の書き方か、項目の型が違います");
    }

    @ExceptionHandler(ExpenseNotFoundException.class)  // 不存在 → 404
    ProblemDetail notFound(ExpenseNotFoundException e) {
        return problem(HttpStatus.NOT_FOUND, "expense-not-found", "申請が見つかりません", "受付番号 " + e.id() + " の申請はありません");
    }

    @ExceptionHandler(DuplicateKeyException.class)  // 已经有同样的申请 → 409
    ProblemDetail duplicate(DuplicateKeyException e) {
        return problem(HttpStatus.CONFLICT, "duplicate", "同じ申請がすでにあります", "同じ日・品目・金額の申請は、1回だけ出せます");
    }

    @ExceptionHandler(Exception.class)  // 其他 → 500。不显示内容,用编号关联
    ProblemDetail unexpected(Exception e) {
        String no = "ERR-" + HexFormat.of().toHexDigits(ThreadLocalRandom.current().nextInt());
        log.error("問い合わせ番号 {}", no, e);
        ProblemDetail p = problem(HttpStatus.INTERNAL_SERVER_ERROR, "unexpected", "処理できませんでした", "時間をおいてもう一度お試しください。続くときは問い合わせ番号をお知らせください");
        p.setProperty("errorId", no);
        return p;
    }
# 加上接住异常的角色(ApiErrors)后
## 1 不符合标记(项目为空、金额为负数)
HTTP/1.1 400   Content-Type: application/problem+json
{"detail":"直してから、もう一度送ってください","instance":"/api/v3/expenses","status":400,"title":"入力に誤りがあります","type":"https://expenses.example/problems/invalid-request","errors":[{"field":"item","message":"must not be blank"},{"field":"amount","message":"must be greater than 0"}]}
## 2 JSON 坏了
HTTP/1.1 400   Content-Type: application/problem+json
{"detail":"JSON の書き方か、項目の型が違います","instance":"/api/v3/expenses","status":400,"title":"申請の形が読めません","type":"https://expenses.example/problems/unreadable"}
## 3 不存在的受理编号
HTTP/1.1 404   Content-Type: application/problem+json
{"detail":"受付番号 EXP-2026-9999 の申請はありません","instance":"/api/v3/expenses/EXP-2026-9999","status":404,"title":"申請が見つかりません","type":"https://expenses.example/problems/expense-not-found"}
## 4 同样的申请发 2 次(连点两下)
HTTP/1.1 201   Content-Type: application/json
{"id":"EXP-2026-2900"}
HTTP/1.1 409   Content-Type: application/problem+json
{"detail":"同じ日・品目・金額の申請は、1回だけ出せます","instance":"/api/v3/expenses","status":409,"title":"同じ申請がすでにあります","type":"https://expenses.example/problems/duplicate"}
## 5 数据库拒绝(从没有 @Valid 的窗口发送负数金额)
HTTP/1.1 500   Content-Type: application/problem+json
{"detail":"時間をおいてもう一度お試しください。続くときは問い合わせ番号をお知らせください","instance":"/api/novalid/expenses","status":500,"title":"処理できませんでした","type":"https://expenses.example/problems/unexpected","errorId":"ERR-6ec9cea0"}

什么都不加的 Spring Boot,与加上处理员之后(实测)

请求 什么都不加 加上处理员之后 不符合标记 400 Bad Request 400+逐项理由 JSON 坏了 400 Bad Request 400“申请的形式读不了” 不存在的受理编号 500 404“找不到申请” 同样的申请发 2 次 500 409“已经有同样的…” 数据库拒绝 500 500+查询编号 “不存在”和“连点两下”都是 500。明明是用户的原因,看起来却像服务器故障 只启用 ProblemDetail,只是让 400 的形式统一而已(实测) 哪个异常对应哪个状态码,是应用的规则。在处理员那里一处写好

400 带上了逐项理由,不存在的受理编号变成 404,连点两下变成 409(Conflict,与当前状态冲突)。只有数据库拒绝的是 500,并且附上了查询编号。窗口的代码里一个 try-catch 都没有。

ProblemDetail 的项目与读者

ProblemDetail(RFC 9457)的项目与读者 type …/problems/duplicate 程序用来分支 title 同じ申請がすでにあります 给人读 status 409 程序用来分支 detail 同じ日・品目・金額の申請は、1回だけ出せます 给人读 instance /api/v3/expenses 程序用来分支 errors [{field, message}…] 画面显示在项目旁 errorId ERR-6ec9cea0 客服用来查日志 以下是自己加的项目(扩展) type 是问题种类的名字。文字变了,也能按 type 分支 画面看 status 和 type,决定怎么显示
五

用用户的语言说明理由

刚加上处理员时,400 的理由是英文的“must not be blank”。换一个语言偏好(Accept-Language)发送同样的申请。

# 同样的申请,换一个 Accept-Language
## 1 不符合标记(Accept-Language: ja)
HTTP/1.1 400   Content-Type: application/problem+json
{"detail":"直してから、もう一度送ってください","instance":"/api/v3/expenses","status":400,"title":"入力に誤りがあります","type":"https://expenses.example/problems/invalid-request","errors":[{"field":"item","message":"空白は許可されていません"},{"field":"amount","message":"0 より大きな値にしてください"}]}
## 1 不符合标记(Accept-Language: en)
HTTP/1.1 400   Content-Type: application/problem+json
{"detail":"直してから、もう一度送ってください","instance":"/api/v3/expenses","status":400,"title":"入力に誤りがあります","type":"https://expenses.example/problems/invalid-request","errors":[{"field":"amount","message":"must be greater than 0"},{"field":"item","message":"must not be blank"}]}

Accept-Language: ja 时是“空白は許可されていません”。Spring 会配合请求的语言选择标记的文字。浏览器会自动发送用户的语言,所以从画面来的申请会得到对应语言的理由。不过这句话有点生硬,生产环境里要在标记上写自己的文字(message = "品目を入れてください"),或者准备文字清单(ValidationMessages.properties)。

六

不要吞掉异常

这是设处理员之前,古老代码里常见的写法:捕获异常,只打印出来,然后继续往下走。

# 古老的负责人(SwallowService 的要点)
public String submit(ExpenseRequest req) {
    String id = "EXP-2026-%04d".formatted(repo.nextNumber());
    try {
        repo.insert(id, "新井", req);
    } catch (Exception e) {
        e.printStackTrace();  // 画面上什么都不知道
    }
    return id;  // 即使没写进去,也返回受理编号
}
## 6 给吞掉异常的负责人发送负数金额
HTTP/1.1 201   Content-Type: application/json
{"id":"EXP-2026-2903"}
# 去查看这个受理编号
HTTP/1.1 404   Content-Type: application/problem+json
{"detail":"受付番号 EXP-2026-2903 の申請はありません","instance":"/api/v3/expenses/EXP-2026-2903","status":404,"title":"申請が見つかりません","type":"https://expenses.example/problems/expense-not-found"}
# 应用日志(输出到标准错误的堆栈的一部分)
    at java.base/java.lang.Thread.run(Thread.java:1474)
Caused by: org.postgresql.util.PSQLException: ERROR: new row for relation "expense" violates check constraint "amount_positive"
  Detail: Failing row contains (EXP-2026-2903, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 01:08:22.039223+09, 新井).
    at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2993)

负数金额被数据库拒绝了,画面却收到了 201 和受理编号。用户以为“提交成功”就回去了,月底被财务说“没有这件申请”。日志里虽然有异常,却没有任何东西把它和哪个请求联系起来。与其捕获了什么都不做,不如不捕获。把异常往上交给处理员,事务会回滚(第 5 讲),回复也会正确。

新井连受理编号都出来了,谁都会相信吧……

古井比错误更糟的,是虚假的成功。错误用户自己会发现,虚假的成功要一个月后财务才发现。

七

500 用查询编号关联

不属于任何一种异常的(数据库拒绝、对方系统宕机、程序错误),确实是服务器这边的问题,所以是 500。处理员不把内容写进回复,只返回编号,再用同一个编号把全部内容写进日志。

终端画面。下方发送被数据库拒绝的申请,500 的正文里有 title 処理できませんでした、errorId ERR-a6a0bb49。上方的应用日志里有 ERROR ApiErrors 問い合わせ番号 ERR-a6a0bb49,以及 DataIntegrityViolationException、SQL、Failing row contains 的行
返给用户的只有“处理不了”和查询编号(下)。用同一个编号,日志里连 SQL 和失败的行都完整留着(上)(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)
# 此时的应用日志(同一个查询编号,内容完整)
01:08:21.825 http-nio-8080-exec-7   ERROR ApiErrors                    問い合わせ番号 ERR-6ec9cea0
org.springframework.dao.DataIntegrityViolationException: PreparedStatementCallback; SQL [insert into expense (id, applicant, applied_on, item, amount, status) values (?, ?, ?, ?, ?, 'submitted')]; ERR
  Detail: Failing row contains (EXP-2026-2902, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 01:08:21.818124+09, 新井).
	at org.springframework.jdbc.support.SQLStateSQLExceptionTranslator.doTranslate(SQLStateSQLExceptionTranslator.java:140)

只要用户告诉我们“出现了 ERR-6ec9cea0”,就能找到日志的那一行。反过来,也有把内容写进回复的设置。

终端画面。对把 include-message 和 include-stacktrace 设成 always 的应用发送被数据库拒绝的申请,500 的 JSON 里以 trace 放入了堆栈,能看到 SQL insert into expense 的列名、CHECK 约束名 amount_positive、失败行的内容、Spring 和 PostgreSQL 驱动的类名与行号
不该用的设置。回复正文里出现了表名和列名、约束名、行的内容,连所用部件的名字和版本线索都有(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)
# 用 spring.web.error.include-message=always 和 include-stacktrace=always 启动时
## 5 数据库拒绝(include-message=always、include-stacktrace=always)
HTTP/1.1 500   Content-Type: application/json
{"timestamp":"2026-10-30T16:08:28.782Z","status":500,"error":"Internal Server Error","trace":"org.springframework.dao.DataIntegrityViolationException: PreparedStatementCallback; SQL [insert into expense (id, applicant, applied_on, item, amount, status) v…(91 行、8842 文字)","message":"PreparedStatementCallback; SQL [insert into expense (id, applicant, applied_on, item, amount, status) values (?, ?, ?, ?, ?, 'submitted')]; ERROR: new row for relation \"expense\" violates check constraint \"amount_positive\"\n  Detail: Failing row contains (EXP-2026-2904, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 01:08:28.733548+09, 新井).","path":"/api/novalid/expenses"}

回复里出现了 91 行、8,842 个字符的堆栈,还有 SQL、表名和列名、约束名、失败行的内容。所用部件的名字和行号,对攻击者来说是“该试哪个弱点”的线索(第 11 讲)。include-stacktrace 和 include-message,除了开发中的自己电脑,都不要用。

八

状态码的选法

状态码含义报销系统里的例子谁来改
400请求的形式不对项目为空、JSON 坏了发送方(改了就能通过)
401 / 403不知道是谁/没有权限没登录、别人的申请(第 11 讲)发送方
404不存在不存在的受理编号发送方
409与当前状态冲突连点两下、修改已批准的申请(第 10 讲)发送方(重新确认状态)
500服务器这边的问题数据库拒绝、程序错误服务器这边(查询编号)
503现在无法受理停机替换中、太拥挤(第 15 讲)等一等就好

异常往上抛,在一个地方变成回复

仓库管理员 Repository 负责人 Service 窗口 Controller 异常处理员 ApiErrors 异常往上抛,在一个地方变成回复 把数据库的异常 翻译成 Spring 的异常 抛出业务异常 (代理人负责回滚) 不写 try-catch 按种类 选择状态码 application/problem+json 状态码+理由 日志:完整内容 堆栈、SQL、查询编号 中途不要捕获。捕获了却什么都不做,失败就会变成成功(第六章) 负责人只是把“不存在”“已经有了”作为业务异常抛出。它不知道 HTTP 处理员根据异常种类决定状态码和文字。不用每个窗口都写 给用户的是改法,给日志的是调查材料。用查询编号把两者连起来
POINT

4 开头的是“发送方能改”,5 开头的是“服务器这边的问题”。守住这个区分,画面就能选择是显示改法还是道歉,监控也只数真正的故障。拿不准时,就问“用户改点什么就能通过吗”。

九

画面怎么接收

前端篇第 9 讲做的“项目旁边的红字”,也能显示服务器给的理由。比如这样接收。

const res = await fetch("/api/v3/expenses", {
  method: "POST",
  headers: { "Content-Type": "application/json" },   // Accept-Language 由浏览器加上
  body: JSON.stringify(form),
});
if (res.ok) { /* 显示受理编号 */ }
else if (res.headers.get("Content-Type")?.startsWith("application/problem+json")) {
  const p = await res.json();
  (p.errors ?? []).forEach(e => showFieldError(e.field, e.message));   // 显示在项目旁
  if (p.status === 409) showMessage(p.title);                           // 连点两下
  if (p.errorId) showMessage(`${p.title}(查询编号 ${p.errorId})`);
}

画面按 status 和 type 选择显示方式,title 和 detail 可以直接给用户看。连点两下本身,画面这边也要让按钮不能再按(前端篇)。服务器的 409,是绕过它时的最后一道刹车。

十

在自己的电脑上验证

想试的事写法
做成 ProblemDetail 的形式spring.mvc.problemdetails.enabled=true
设异常处理员@RestControllerAdvice + @ExceptionHandler
增加项目problem.setProperty("errors", …)
换语言确认curl -H 'Accept-Language: ja' …
看回复的形式和状态码curl -si … | head
不能用(生产环境)spring.web.error.include-stacktrace=always

在 Spring Boot 4 中,在回复里包含堆栈等内容的设置名,从 server.error.* 移到了 spring.web.error.*。照抄旧文章里的设置名,有时会不起作用。这次实验里,用旧名字也什么都没变。

结 — 一页总结与下一讲

  1. 什么都不加,“不存在”和“连点两下”都是 500。用户造成的失败,看起来像故障。
  2. 用 ProblemDetail(RFC 9457)统一形式。但哪个异常对应什么由应用决定。
  3. 异常由负责人抛出,@RestControllerAdvice 的处理员在一处变成状态码和文字。窗口里不需要 try-catch。
  4. 400 用用户的语言给出逐项理由。
  5. 不要吞掉异常。没写进去却返回 201 的虚假成功最糟。
  6. 500 不显示内容,用查询编号和日志关联。不要返回堆栈。

新井这样就从“系统发生错误”毕业了。

古井毕业了。下一讲回到仓库管理员。手写 SQL 的 JdbcClient、JPA、MyBatis,把同一个申请列表用三种方式写,比较会发生什么。

参考

记录是笔者于 2026 年 10 月 31 日在云端 Linux(Ubuntu 24.04,内核 6.18)上,用 Spring Boot 4.1.2(Spring Framework 7、Tomcat 11、Hibernate Validator 9)、OpenJDK 25.0.4、PostgreSQL 16.15 取得的。PC・申请服务器・数据库服务器,是在一台机器里建立的不同网络空间(network namespace)。查询编号每次运行都会变。画面的代码是用于说明的例子。表中的数字和输出,全部是从当时命令输出中读取的实际值。登场人物均为虚构。

古法后端入门 第7讲 — 异常与回复 / 2026.10

← 返回文章列表