导出方法没有漏写 catch,最后也把任务改成了失败,为什么还要检查它?
因为最终是 FAILED,不代表过程中没有报过 SUCCESS。
下面是一个小程序的实际输出:三条虚构订单写进了真实临时文件,随后人为注入关闭异常。提前标成功的版本,状态走成了这样:
text
RUNNING > SUCCESS > FAILED
暂存文件里有完整的 60 个字符,最终结果引用却是空的。问题不在于文件一定没生成,而在于成功状态写早了。
本文只看这个顺序问题:写入、关闭资源、准备正式结果,哪一步之后才能更新成功。末尾有完整代码,不需要数据库、Spring 或网盘文件。
先排除一个直白的错误
如果成功状态写在 finally 里,失败就可能被覆盖。完整示例中的这两段足够说明问题:
java
} catch (IOException e) {
task.fail(e);
} finally {
if (mode == Mode.FINALLY_SUCCESS) {
task.success(target); // 故意保留的错误写法。
}
}
模拟写入失败后,实际轨迹是:
text
RUNNING > FAILED > SUCCESS
这版甚至返回了一个并不存在的正式文件路径。这个错误改掉不难,后面那种"看起来已经改好了"的写法更值得检查。
把成功移进 try,也可能早了一步
try-with-resources 会自动关闭资源,但不是在执行最后一条 write() 时就关闭。
下面这段摘自示例的 EARLY_SUCCESS 分支:
java
try (Writer out = openWriter(part, scenario)) {
writeCsv(out);
task.events.add("WRITTEN");
if (mode == Mode.EARLY_SUCCESS) {
task.success(target); // 这里执行时,close 还没调用。
}
}
执行顺序是:写数据、更新成功、退出代码块时关闭资源。关闭阶段也可能抛异常;带资源的 try 对应的 catch 在资源关闭之后执行。Oracle 对关闭顺序的说明
在这个例子里,catch 接到关闭异常后会清空结果引用,再把任务改成 FAILED。所以最终状态是失败,历史记录里却已经出现过一次成功。
如果你的系统只在这个方法返回后读取状态,本例不能证明前端一定看到了那次成功。这里记录的是单线程内的状态变更;并发读取是否会暴露它,需要针对实际系统再测。
还有一件事:关闭成功也不是本例的最后一步。暂存文件还要移到正式结果路径,移动失败,同样不能返回成功。
成功分支放在哪儿
这个小程序先写 orders.part,关闭后再移成 orders.csv。只有正式结果准备好,才把路径交给调用方。

修复逻辑如下。这里去掉了演示用的模式判断,保留实际执行的位置:
java
try {
try (Writer out = openWriter(part, scenario)) {
writeCsv(out);
task.events.add("WRITTEN");
}
task.events.add("CLOSED");
Files.move(part, target);
task.events.add("MOVED");
task.success(target);
} catch (IOException e) {
task.fail(e);
}
注意外层 try 包住了关闭和移动操作。写入、关闭、移动中的任何一步抛出 IOException,都会进入失败分支,不会再执行这里的 success()。
不要把它简化成一句"把成功更新挪到 try 后面"。如果 catch 处理完异常后还能继续走到那个成功更新,仍然会报错状态。
失败处理也不只是写一个状态值。本例的处理是:
java
void fail(IOException e) {
error = e;
result = null;
status = "FAILED";
history.add(status);
events.add(status);
}
result = null 表示不提供成功结果引用,不表示磁盘上什么都没有。失败后的暂存文件可能是半成品,也可能已写完整,不能拿它的存在与否替代任务是否成功的判断。
五种情况,实际跑一遍
测试使用 UTF-8、固定的 \n 换行和三条虚构订单。写入与关闭异常由 Writer 包装器控制;文件写入和移动是真实操作。任务状态只存在内存里。
修复版在本机 JDK 21.0.11 上的结果如下。编译使用 --release 17,但没有另在 JDK 17 运行时上执行。
| 场景 | 状态轨迹 | 成功结果引用 | 本次文件与异常情况 |
|---|---|---|---|
| NORMAL:正常导出 | RUNNING → SUCCESS | 正式路径 | 文件完整,内容逐字匹配 |
| WRITE_FAIL:写入中途失败 | RUNNING → FAILED | 无 | 暂存内容 31 字符;写入异常 |
| CLOSE_FAIL:关闭失败 | RUNNING → FAILED | 无 | 暂存内容 60 字符;关闭异常 |
| BOTH_FAIL:写入和关闭都失败 | RUNNING → FAILED | 无 | 暂存内容 31 字符;写入主异常,关闭异常被保留 |
| MOVE_FAIL:移动失败 | RUNNING → FAILED | 无 | 暂存内容 60 字符;本机报 NoSuchFileException |
两个细节单独说一下。
写入失败不是只把一个布尔值改成失败。包装器先把表头和第一条订单刷新到真实文件,再在第二次写入前抛异常。因此测试能检查:只有 31 字符的暂存半成品,不应被当成完整结果返回。
关闭失败则是在底层文件正常关闭后,由包装器主动抛异常,避免为了演示而泄漏句柄。这也解释了为什么该场景文件内容完整。它验证的是收到关闭异常后的控制流程,不是在模拟真实磁盘损坏,也不代表所有关闭失败都会留下完整文件。
移动失败没有模拟返回值:程序真的调用了 Files.move,只是目标父目录不存在。具体异常文案可能因系统而异,检查重点是任务失败、不返回成功结果,并留下原始异常。
两个异常,别只留下最后一个
同时启用写入和关闭故障时,实际检查到:
text
主异常:injected write failure
suppressed[0]:injected close failure
try-with-resources 在这种情况下保留写入异常为主异常,将关闭异常放进 suppressed。可以用 getSuppressed() 取出它们。Oracle 对 suppressed 异常的说明
本例保存的是异常对象,不是仅保存 e.getMessage()。要是只留一句"导出失败",后面很难分清失败发生在写入还是关闭阶段。
自己运行时,先看这三列
把末尾源码保存为 ExportStateDemo.java,执行:
shell
javac -encoding UTF-8 --release 17 ExportStateDemo.java
java ExportStateDemo
程序会遍历三种写法、五种场景,共输出 15 行对照结果。先看 history、result 和 partChars:
history:检查过程中是否提前出现成功,不能只看最后一个状态。result:ready表示引用路径存在,missing表示返回了不存在的路径,none表示没有结果引用。正常用例另有完整内容校验,不能只凭ready判断内容正确。partChars:保留下来的暂存文件字符数;-表示此时没有暂存文件。
源码中的 verifyFixed() 会检查修复版的状态轨迹、正常文件内容、关键事件顺序和失败结果引用。不需要加 -ea,检查失败会直接抛出 AssertionError。
每次运行都使用新临时目录,最后会打印位置,里面的测试文件保留供核查,不覆盖已有文件。
这份代码没有替你解决什么
这里的"准备正式结果",只指本地文件移动完成,没有 HTTP 下载接口或对象存储。
普通 Files.move 不能直接说成任何环境下的原子操作;非原子移动发生 I/O 异常时,文件状态需要另行核实。Java 17 Files.move 文档
正常关闭、移动成功,也不等于验证了掉电持久性。如果任务状态存在数据库,还会有"文件准备好,状态写库失败"或进程中途退出的情况,需要恢复和核对机制。并发、重试、下载权限与性能都不在这次测试范围内。
回到自己的导出代码,可以先查五个位置:
- SUCCESS 写入时,资源是否已经正常关闭?
- 成功结果是否已经准备好,还是还指向暂存文件?
- 异常被 catch 后,会不会继续执行成功更新?
- 失败返回值里,有没有残留的"成功文件路径"?
- 测试有没有断言状态变更顺序,以及保留的原始异常?
先把这些具体位置查清,再决定是否需要改动更大的任务管理逻辑。
------ IT枫斗者枫哥
完整代码
保存为 ExportStateDemo.java。下面是本次实际运行的完整源码。
java
import java.io.IOException;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/** 教学示例:真实临时文件 + 人为注入的写入/关闭异常,不是生产事故记录。 */
public class ExportStateDemo {
static final String FIRST = "order_id,amount\nDEMO-001,12.50\n";
static final String REST = "DEMO-002,30.00\nDEMO-003,8.80\n";
enum Mode { FINALLY_SUCCESS, EARLY_SUCCESS, FIXED }
enum Scenario {
NORMAL(false, false), WRITE_FAIL(true, false),
CLOSE_FAIL(false, true), BOTH_FAIL(true, true),
MOVE_FAIL(false, false);
final boolean failWrite;
final boolean failClose;
Scenario(boolean failWrite, boolean failClose) {
this.failWrite = failWrite;
this.failClose = failClose;
}
}
// 这里只记录单次调用的状态,不模拟数据库、并发或重试。
static class Task {
String status = "RUNNING";
Path result;
IOException error;
final List<String> history = new ArrayList<>(List.of("RUNNING"));
final List<String> events = new ArrayList<>();
void success(Path path) {
result = path;
status = "SUCCESS";
history.add(status);
events.add(status);
// 不抹掉旧异常,便于观察错误版出现"有异常却成功"的矛盾。
}
void fail(IOException e) {
error = e; // 保留异常对象,suppressed 信息也仍在其中。
result = null;
status = "FAILED";
history.add(status);
events.add(status);
}
}
static Task export(Path dir, Scenario scenario, Mode mode) throws IOException {
// 调用者为每次测试传入新目录;创建失败直接终止测试,不覆盖旧产物。
Files.createDirectory(dir);
Path part = dir.resolve("orders.part");
Path target = scenario == Scenario.MOVE_FAIL
? dir.resolve("missing/orders.csv") : dir.resolve("orders.csv");
Task task = new Task();
try {
try (Writer out = openWriter(part, scenario)) {
writeCsv(out);
task.events.add("WRITTEN");
if (mode == Mode.EARLY_SUCCESS) {
task.success(target); // 错误:close 和 move 都还没完成。
}
}
task.events.add("CLOSED");
// 不替换已有文件;MOVE_FAIL 的目标父目录确实不存在。
Files.move(part, target);
task.events.add("MOVED");
if (mode == Mode.FIXED) {
task.success(target); // 只有这条成功分支已完成全部前置操作。
}
} catch (IOException e) {
task.fail(e);
} finally {
if (mode == Mode.FINALLY_SUCCESS) {
task.success(target); // 错误:失败状态也会被无条件覆盖。
}
}
return task;
}
static void writeCsv(Writer out) throws IOException {
out.write(FIRST);
out.write(REST);
}
static Writer openWriter(Path part, Scenario scenario) throws IOException {
Writer real = Files.newBufferedWriter(part, StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
return new FaultWriter(real, scenario);
}
static class FaultWriter extends Writer {
private final Writer delegate;
private final Scenario scenario;
private int writes;
FaultWriter(Writer delegate, Scenario scenario) {
this.delegate = delegate;
this.scenario = scenario;
}
@Override
public void write(char[] chars, int offset, int length) throws IOException {
if (++writes == 2 && scenario.failWrite) {
// 先让第一条订单进入真实文件,再模拟第二批写入失败。
delegate.flush();
throw new IOException("injected write failure");
}
delegate.write(chars, offset, length);
}
@Override
public void flush() throws IOException {
delegate.flush();
}
@Override
public void close() throws IOException {
// 先释放真实句柄,再注入异常,不为了演示而泄漏文件句柄。
delegate.close();
if (scenario.failClose) {
throw new IOException("injected close failure");
}
}
}
public static void main(String[] args) throws IOException {
Path root = Files.createTempDirectory("export-state-demo-");
System.out.println("mode | scenario | history | result | partChars | error | suppressed");
for (Mode mode : Mode.values()) {
for (Scenario scenario : Scenario.values()) {
Path dir = root.resolve(mode + "-" + scenario);
Task task = export(dir, scenario, mode);
if (mode == Mode.FIXED) verifyFixed(task, scenario);
String result = task.result == null ? "none"
: Files.exists(task.result) ? "ready" : "missing";
Path part = dir.resolve("orders.part");
String partChars = Files.exists(part)
? String.valueOf(Files.readString(part, StandardCharsets.UTF_8).length()) : "-";
String error = task.error == null ? "none"
: task.error.getClass().getSimpleName();
String suppressed = task.error == null ? "none"
: Arrays.stream(task.error.getSuppressed())
.map(Throwable::getMessage).collect(Collectors.joining(","));
if (suppressed.isEmpty()) suppressed = "none";
System.out.printf("%s | %s | %s | %s | %s | %s | %s%n",
mode, scenario, String.join(">", task.history),
result, partChars, error, suppressed);
}
}
System.out.println("PASS: FIXED self-checks for all 5 scenarios");
System.out.println("Artifacts: " + root); // 留下隔离的测试文件,方便自己核对。
}
static void verifyFixed(Task task, Scenario scenario) throws IOException {
boolean normal = scenario == Scenario.NORMAL;
require(task.history.equals(normal ? List.of("RUNNING", "SUCCESS")
: List.of("RUNNING", "FAILED")), "Unexpected state history: " + scenario);
if (normal) {
require(task.error == null && task.result != null, "Missing successful result");
require(Files.readString(task.result, StandardCharsets.UTF_8)
.equals(FIRST + REST), "CSV content mismatch");
require(task.events.equals(List.of("WRITTEN", "CLOSED", "MOVED", "SUCCESS")),
"Success happened before close/move");
} else {
require(task.result == null && task.error != null, "Failed export exposed a result");
int expected = scenario == Scenario.BOTH_FAIL ? 1 : 0;
require(task.error.getSuppressed().length == expected, "Suppressed exception lost");
}
}
static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}