Too many open files:明明用了 try-with-resources
那是个导出功能,把订单导成 CSV 文件。上线第二天下午,监控开始报 java.io.IOException: Too many open files,接口全挂。登上机器一看,重启就好了,过一天又来。
我先去查系统限制:
$ ulimit -n
1024
$ lsof -p 23871 | wc -l
1017
$ lsof -p 23871 | grep -c "\.csv"
963
1017 个句柄,其中 963 个是 CSV 文件。句柄快撞到 1024 的上限了,而实际泄漏的只有一类东西。
问题是我自认为写得很规范,用了 JDK 7 就有的 try-with-resources:
public void export(OutputStream out) throws IOException {
try (CSVWriter writer = new CSVWriter(new OutputStreamWriter(out, "GBK"))) {
for (Order o : orderMapper.selectAll()) {
writer.writeNext(buildRow(o));
}
}
}
lsof 里那些文件到底是谁打开的
我挑了一个具体的句柄看详情:
$ lsof -p 23871 | grep csv | head -3
java 23871 tomcat 412u REG 253,1 10485760 674321 /data/export/order_20180727_143022.csv
java 23871 tomcat 417u REG 253,1 8388608 674322 /data/export/order_20180727_143105.csv
文件名带时间戳,说明是每次导出新建的。顺着代码找,发现同一个类里还有个被调用的方法:
private File buildAttachment(OrderQuery query) throws IOException {
File tmp = new File(EXPORT_DIR, "order_" + DATE_FMT.format(new Date()) + ".csv");
FileOutputStream fos = new FileOutputStream(tmp); // 裸开,没关
CSVWriter writer = new CSVWriter(new OutputStreamWriter(fos, "GBK"));
writer.writeAll(queryOrders(query));
return tmp; // 把文件交给邮件模块发送
}
private List<String[]> queryOrders(OrderQuery query) {
// 这里每次都 new 一个连接,用完不关
Connection conn = DriverManager.getConnection(URL, USER, PWD);
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("select * from t_order where ...");
List<String[]> rows = new ArrayList<>();
while (rs.next()) {
rows.add(new String[]{rs.getString("order_no"), rs.getString("amount")});
}
return rows;
}
两个问题都在这一段:文件流裸开没关,数据库连接也没关。而上面那个 export 方法写得再漂亮也没用,因为泄漏点在别处。
AutoCloseable 到底是怎么办到的
我以前一直以为 try-with-resources 是"帮你调用 close()",看字节码才发现它比这复杂,多了对异常的处理。
try (Resource r = new Resource()) {
r.use();
}
编译后大致等价于:
Resource r = new Resource();
Throwable primary = null;
try {
r.use();
} catch (Throwable t) {
primary = t;
throw t;
} finally {
if (r != null) {
if (primary != null) {
try {
r.close();
} catch (Throwable suppressed) {
primary.addSuppressed(suppressed); // 关键
}
} else {
r.close();
}
}
}
两个细节值得记:
- close 的异常会被压到主异常里(
addSuppressed)。以前手写 finally 时,close 抛异常会把真正的业务异常覆盖掉,导致看不到堆栈。try-with-resources 解决了这个问题,打印主异常时会带一句Suppressed: java.io.IOException: ...。 - close 的顺序是反的,后声明的先关。这符合"先穿的外套后脱"的逻辑。
另外,JDK 9 允许在括号里放外部已经声明的 final 变量,JDK 8 不行,我们还在 8,只能老实写在括号里。
坑一:包装流只关外层就够,但别关错
这里我绕了很久。CSVWriter 包着 OutputStreamWriter,后者包着 FileOutputStream。因为装饰流(FilterOutputStream / Writer)的 close() 会调用被包装流的 close(),所以只要关最外层就行,不用层层 try。
但反过来,如果我只关了里层的 FileOutputStream,外层的 Writer 缓冲区里的数据就丢了——CSV 文件末尾少半行,这个 bug 我也遇到过。
坑二:连接池的 close 是"归还"不是"关闭"
修文件句柄时顺手把 JDBC 那段也改了,师傅看了眼说:"你们项目用 Druid 连接池,conn.close() 不是真的关连接,是把连接还给池子。不还,池子就空了。"
private List<String[]> queryOrders(OrderQuery query) throws SQLException {
List<String[]> rows = new ArrayList<>();
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("select order_no, amount from t_order where status = ?");) {
ps.setInt(1, query.getStatus());
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
rows.add(new String[]{rs.getString("order_no"), rs.getString("amount")});
}
}
}
return rows;
}
用连接池之后,不 close 的后果比文件句柄更隐蔽:不是报 Too many open files,而是报 Timeout: Pool empty. Unable to fetch a connection in 30 seconds,然后接口大面积超时。我们后来在 Druid 里加了泄漏检测:
# application.properties
spring.datasource.druid.remove-abandoned=true
spring.datasource.druid.remove-abandoned-timeout=180
spring.datasource.druid.log-abandoned=true
开完之后,控制台直接打印出没关闭连接的堆栈,一行就定位到具体代码位置:
abandoned connection, owner thread: http-nio-8080-exec-7,
connected at : 1532758822000, open stackTrace
at com.xxx.OrderExportService.queryOrders(OrderExportService.java:63)
at com.xxx.OrderExportService.buildAttachment(OrderExportService.java:41)
改完之后的数据
修完重新压测,用 50 并发连续跑 2000 次导出:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 运行 10 分钟后进程句柄数 | 986(持续增长) | 稳定在 78 |
| Druid 活跃连接数峰值 | 100(打满上限) | 12 |
| 接口报错率 | 约 15 分钟后 100% | 0 |
顺便把 ulimit -n 从 1024 调到 65535,在 /etc/security/limits.conf 里加了 tomcat 用户的配置。但这属于治标,不改代码调多大都会漏完。
自己实现 AutoCloseable 要注意什么
排查完之后我把项目里一个手写的资源包装类也改成了 AutoCloseable,踩到两个点。
一个是 close() 必须写成幂等的。编译器不保证它只被调用一次,上面那段等价代码里 finally 会调,手动提前关也会调。我第一版写完,重复关闭时抛了 NPE:
public class CsvExporter implements AutoCloseable {
private Writer writer;
@Override
public void close() throws IOException {
if (writer != null) {
writer.close();
writer = null; // 置空,保证第二次调用是 no-op
}
}
}
另一个是 close() 的异常声明。AutoCloseable 接口里声明的是 throws Exception,实现时可以收窄成 IOException 甚至不抛。收窄之后,调用方的 try 块就不用 catch 那么宽的异常了。不过反过来要注意:如果 try 块里的业务方法抛的是 SQLException,而 close 声明的是 IOException,调用方得两个都 catch,或者统一 catch Exception。
还有个容易忽略的地方:try-with-resources 括号里的资源,如果构造时就抛异常,已经构造好的那些会被正常关闭。比如下面这样,第二个资源构造失败,第一个照样会关:
try (FileInputStream in = new FileInputStream(a);
FileInputStream in2 = new FileInputStream(notExist)) { // 这里抛异常
// ...
}
// in 会被关闭,in2 因为没构造成功所以不需要关
我现在怎么写
给自己总结了三条:一是任何 new FileInputStream / getConnection 写出来,第一反应就是配 try-with-resources,不用 finally;二是方法之间传递流对象要谨慎,谁开谁关,别传出去让别人猜;三是连接池一定开 removeAbandoned,它能在上线前就把问题暴露在日志里,比等线上告警强太多。