Administrator
发布于 2018-07-28 / 1382 阅读
30

try-with-resources 真的能关掉所有资源吗?一次文件句柄泄漏排查

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,它能在上线前就把问题暴露在日志里,比等线上告警强太多。

参考