【问题标题】:Reading from a JDBC Blob after leaving the Spring transaction离开 Spring 事务后从 JDBC Blob 读取
【发布时间】:2015-05-20 17:46:07
【问题描述】:

我有以下 JAX-RS 服务端点的示意图实现:

@GET
@Path("...")
@Transactional
public Response download() {
    java.sql.Blob blob = findBlob(...);
    return Response.ok(blob.getBinaryStream()).build();
}

调用 JAX-RS 端点将从数据库中获取一个 Blob(通过 JPA)并将结果流式传输回 HTTP 客户端。使用 Blob 和流而不是例如JPA 朴素的 BLOB 到 byte[] 映射是为了防止所有数据都必须保存在内存中,而是直接从数据库流式传输到 HTTP 响应。

这按预期工作,我实际上不明白为什么。我从与底层 JDBC 连接和事务相关联的数据库中获得的 Blob 句柄不是吗?如果是这样,我会期望在我从 download() 方法返回时提交 Spring 事务,从而使 JAX-RS 实现以后无法访问 Blob 中的数据以将其流式传输回 HTTP 响应。

【问题讨论】:

    标签: jax-rs blob spring-transactions


    【解决方案1】:

    您确定事务通知正在运行吗? By default,Spring 使用“代理”建议模式。只有当您使用 JAX-RS Application 注册资源的 Spring 代理实例,或者您使用“aspectj”编织而不是默认的“代理”建议模式时,事务建议才会运行。

    假设 physical 事务由于事务传播而没有被重新使用,在此 download() 方法上使用 @Transactional 通常是不正确的。

    如果事务通知实际上正在运行,则事务在从 download() 方法返回时结束。 Blob Javadoc 表示:“Blob 对象在创建的事务期间有效。”但是,JDBC 4.2 规范的 §16.3.7 说:“BlobClobNClob 对象至少在创建它们的事务期间仍然有效。”因此,getBinaryStream() 返回的InputStream 不能保证对响应有效;有效性将取决于 JDBC 驱动程序提供的任何保证。为了获得最大的可移植性,您应该依赖 Blob 仅在交易期间有效。

    无论事务建议是否正在运行,您都可能存在竞争条件,因为用于检索 Blob 的底层 JDBC 连接可能会以使 Blob 无效的方式重复使用。

    编辑:测试 Jersey 2.17,似乎从 InputStream 构造 Response 的行为取决于指定的响应 MIME 类型。在某些情况下,InputStream 在发送响应之前首先被完全读入内存。在其他情况下,InputStream 会流回。

    这是我的测试用例:

    @Path("test")
    public class MyResource {
    
        @GET
        public Response getIt() {
            return Response.ok(new InputStream() {
                @Override
                public int read() throws IOException {
                    return 97; // 'a'
                }
            }).build();
        }
    }
    

    如果 getIt() 方法带有 @Produces(MediaType.TEXT_PLAIN) 注释或没有 @Produces 注释,则 Jersey 会尝试将整个(无限)InputStream 读入内存,并且应用程序服务器最终会因内存不足而崩溃。如果 getIt() 方法带有 @Produces(MediaType.APPLICATION_OCTET_STREAM) 注释,则响应会被流回。

    因此,您的 download() 方法可能只是因为 blob 没有被流式传输回来。 Jersey 可能会将整个 blob 读入内存。

    相关:How to stream an endless InputStream with JAX-RS

    EDIT2:我使用 Spring Boot 和 Apache CXF 创建了一个演示项目:
    https://github.com/dtrebbien/so30356840-cxf

    如果你运行项目并在命令行上执行:

    curl 'http://localhost:8080/myapp/test/data/1' >/dev/null

    然后你会看到如下日志输出:

    2015-06-01 15:58:14.573 调试 9362 --- [nio-8080-exec-1] org.apache.cxf.transport.http.Headers:请求标头:{Accept=[*/*],内容-类型=[null],主机=[localhost:8080],用户代理=[curl/7.37.1]} 2015-06-01 15:58:14.584 调试 9362 --- [nio-8080-exec-1] org.apache.cxf.jaxrs.utils.JAXRSUtils:尝试选择资源类,请求路径:/test/data /1 2015-06-01 15:58:14.585 调试 9362 --- [nio-8080-exec-1] org.apache.cxf.jaxrs.utils.JAXRSUtils:尝试在资源类 com.sample 上选择资源操作。资源.MyResource 2015-06-01 15:58:14.585 调试 9362 --- [nio-8080-exec-1] org.apache.cxf.jaxrs.utils.JAXRSUtils:资源操作 getIt 可能被选中 2015-06-01 15:58:14.585 调试 9362 --- [nio-8080-exec-1] org.apache.cxf.jaxrs.utils.JAXRSUtils:资源类 com.sample.resource.MyResource 上的资源操作 getIt已被选中 2015-06-01 15:58:14.585 调试 9362 --- [nio-8080-exec-1] o.a.c.j.interceptor.JAXRSInInterceptor:请求路径是:/test/data/1 2015-06-01 15:58:14.585 调试 9362 --- [nio-8080-exec-1] o.a.c.j.interceptor.JAXRSInInterceptor:请求 HTTP 方法是:GET 2015-06-01 15:58:14.585 调试 9362 --- [nio-8080-exec-1] o.a.c.j.interceptor.JAXRSInInterceptor:请求内容类型为:*/* 2015-06-01 15:58:14.585 调试 9362 --- [nio-8080-exec-1] o.a.c.j.interceptor.JAXRSInInterceptor:接受内容类型为:*/* 2015-06-01 15:58:14.585 调试 9362 --- [nio-8080-exec-1] o.a.c.j.interceptor.JAXRSInInterceptor:找到操作:getIt 2015-06-01 15:58:14.595 调试 9362 --- [nio-8080-exec-1] o.s.j.d.DataSourceTransactionManager:创建名称为 [com.sample.resource.MyResource.getIt] 的新事务:PROPAGATION_REQUIRED,ISOLATION_DEFAULT; '' 2015-06-01 15:58:14.595 调试 9362 --- [nio-8080-exec-1] o.s.j.d.DataSourceTransactionManager : 为 JDBC 事务获取连接 [ProxyConnection[PooledConnection[org.hsqldb.jdbc.JDBCConnection@7b191894]]] 2015-06-01 15:58:14.596 调试 9362 --- [nio-8080-exec-1] o.s.j.d.DataSourceTransactionManager:切换 JDBC 连接 [ProxyConnection[PooledConnection[org.hsqldb.jdbc.JDBCConnection@7b191894]]] 到手动提交 2015-06-01 15:58:14.602 DEBUG 9362 --- [nio-8080-exec-1] o.s.jdbc.core.JdbcTemplate:执行准备好的 SQL 查询 2015-06-01 15:58:14.603 调试 9362 --- [nio-8080-exec-1] os.jdbc.core.JdbcTemplate:执行准备好的 SQL 语句 [从图像中选择数据 WHERE id = ?] 2015-06-01 15:58:14.620 调试 9362 --- [nio-8080-exec-1] o.s.j.d.DataSourceTransactionManager:启动事务提交 2015-06-01 15:58:14.620 调试 9362 --- [nio-8080-exec-1] o.s.j.d.DataSourceTransactionManager:在连接 [ProxyConnection[PooledConnection[org.hsqldb.jdbc.JDBCConnection@7b191894]]] 上提交 JDBC 事务 2015-06-01 15:58:14.621 调试 9362 --- [nio-8080-exec-1] o.s.j.d.DataSourceTransactionManager :事务后释放 JDBC 连接 [ProxyConnection[PooledConnection[org.hsqldb.jdbc.JDBCConnection@7b191894]]] 2015-06-01 15:58:14.621 调试 9362 --- [nio-8080-exec-1] os.jdbc.datasource.DataSourceUtils:返回 JDBC 连接到数据源 2015-06-01 15:58:14.621 调试 9362 --- [nio-8080-exec-1] o.a.cxf.phase.PhaseInterceptorChain:在拦截器 org.apache.cxf.interceptor.OutgoingChainInterceptor@7eaf4562 上调用 handleMessage 2015-06-01 15:58:14.622 调试 9362 --- [nio-8080-exec-1] o.a.cxf.phase.PhaseInterceptorChain:添加拦截器 org.apache.cxf.interceptor.MessageSenderInterceptor@20ffeb47 到阶段准备发送 2015-06-01 15:58:14.622 调试 9362 --- [nio-8080-exec-1] o.a.cxf.phase.PhaseInterceptorChain :将拦截器 org.apache.cxf.jaxrs.interceptor.JAXRSOutInterceptor@5714d386 添加到阶段元帅 2015-06-01 15:58:14.622 调试 9362 --- [nio-8080-exec-1] o.a.cxf.phase.PhaseInterceptorChain:链 org.apache.cxf.phase.PhaseInterceptorChain@11ca802c 已创建。电流: 准备发送 [MessageSenderInterceptor] 元帅 [JAXRSOutInterceptor] 2015-06-01 15:58:14.623 调试 9362 --- [nio-8080-exec-1] o.a.cxf.phase.PhaseInterceptorChain:在拦截器 org.apache.cxf.interceptor.MessageSenderInterceptor@20ffeb47 上调用 handleMessage 2015-06-01 15:58:14.623 调试 9362 --- [nio-8080-exec-1] oacxf.phase.PhaseInterceptorChain:添加拦截器 org.apache.cxf.interceptor.MessageSenderInterceptor$MessageSenderEndingInterceptor@6129236d 到阶段准备 -发送结束 2015-06-01 15:58:14.623 调试 9362 --- [nio-8080-exec-1] o.a.cxf.phase.PhaseInterceptorChain:链 org.apache.cxf.phase.PhaseInterceptorChain@11ca802c 已修改。电流: 准备发送 [MessageSenderInterceptor] 元帅 [JAXRSOutInterceptor] 准备发送结束 [MessageSenderEndingInterceptor] 2015-06-01 15:58:14.623 调试 9362 --- [nio-8080-exec-1] o.a.cxf.phase.PhaseInterceptorChain:在拦截器 org.apache.cxf.jaxrs.interceptor.JAXRSOutInterceptor@5714d386 上调用 handleMessage 2015-06-01 15:58:14.627 调试 9362 --- [nio-8080-exec-1] o.a.c.j.interceptor.JAXRSOutInterceptor:响应内容类型为:应用程序/八位字节流 2015-06-01 15:58:14.631 调试 9362 --- [nio-8080-exec-1] o.apache.cxf.ws.addressing.ContextUtils:从上下文属性 javax.xml.ws.addressing.context 中检索 MAP .入站 2015-06-01 15:58:14.631 调试 9362 --- [nio-8080-exec-1] o.apache.cxf.ws.addressing.ContextUtils:WS-Addressing - 无法从上下文中检索消息寻址属性 2015-06-01 15:58:14.636 调试 9362 --- [nio-8080-exec-1] o.a.cxf.phase.PhaseInterceptorChain:在拦截器 org.apache.cxf.interceptor.MessageSenderInterceptor$MessageSenderEndingInterceptor@6129236d 上调用 handleMessage 2015-06-01 15:58:14.639 调试 9362 --- [nio-8080-exec-1] oacthttp.AbstractHTTPDestination:已完成线程上的 http 请求服务:线程 [http-nio-8080-exec-1,5,主要的] 2015-06-01 15:58:14.639 调试 9362 --- [nio-8080-exec-1] oactservlet.ServletController:已完成线程上的 http 请求服务:线程 [http-nio-8080-exec-1,5,主要的]

    为了便于阅读,我已经修剪了日志输出。需要注意的重要一点是事务已提交,并且在发送响应之前返回了 JDBC 连接。因此,blob.getBinaryStream() 返回的InputStream 不一定有效,getIt() resource method 可能正在调用未定义的行为。

    EDIT3:使用 Spring 的 @Transactional 注释的推荐做法是注释服务方法(参见 Spring @Transactional Annotation Best Practice)。您可以有一个服务方法来查找 blob 并将 blob 数据传输到响应 OutputStream。可以使用@Transactional 注释服务方法,以便创建Blob 的事务在传输期间保持打开状态。但是,在我看来,这种方法可能会通过"slow read" attack 引入拒绝服务漏洞。因为事务应该在传输期间保持打开状态以获得最大的可移植性,所以许多速度较慢的读取器可能会通过保持打开的事务来锁定您的数据库表。

    一种可能的方法是将 blob 保存到临时文件并流回文件。有关在同时写入文件时读取文件的一些想法,请参阅 How do I use Java to read from a file that is actively being written?,尽管这种情况更简单,因为可以通过调用 Blob#length() 方法来确定 blob 的长度。

    【讨论】:

    • 您提出了一些有效的观点,但我认为这不能解释我的行为。实现类是 Spring 托管的 bean,并且 JAX-RS 服务器使用 Spring 进行配置(在 XML 上下文定义中使用 jaxrs:server)。我也 100% 确定 HTTP 响应是从数据库流式传输的,因为我们在 DB 和 HTTP 服务器之间的网络连接非常慢,而在 HTTP 服务器和客户端之间的连接速度很快,所以很容易看到客户端接收到的方式数据,它在发送到客户端之前没有被 HTTP 服务器完全缓存。
    • @jarnbjo:我假设您使用 Apache CXF 来实现 JAX-RS?我刚刚在我的答案中添加了一个完整的示例。
    • 我现在有时间调试代码以了解实际发生的情况。即使当前的实现可能不是最聪明的方法并且可能有其他“推荐”的做法,它确实有效。正如我在自己的回答中指出的那样,关于 Spring 事务管理和响应流的 Apache CXF 处理的所有假设都是正确的。我错误地认为在提交事务后从 BLOB 流中读取会失败。
    【解决方案2】:

    我现在花了一些时间调试代码,我在问题中的所有假设或多或少都是正确的。 @Transactional 注解按预期工作,从下载方法返回后立即提交事务(Spring 和 DB 事务),物理 DB 连接返回到连接池,显然稍后读取了 BLOB 的内容并流式传输到 HTTP 响应。

    这仍然有效的原因是 Oracle JDBC 驱动程序实现了超出 JDBC 规范要求的功能。正如 Daniel 指出的那样,JDBC API 文档指出“Blob 对象在创建的事务期间有效。”文档仅声明 Blob 在交易期间有效,它确实 not 声明(如 Daniel 声称并最初由我假设),Blob 在之后 not 有效结束交易。

    使用普通 JDBC,从同一个物理连接的两个不同事务中的两个 Blob 中检索 InputStream,并且在事务提交之前不读取 Blob 数据表明了这种行为:

    Connection conn = DriverManager.getConnection(...);
    conn.setAutoCommit(false);
    
    ResultSet rs = conn.createStatement().executeQuery("select data from ...");
    rs.next();
    InputStream is1 = rs.getBlob(1).getBinaryStream();
    rs.close();
    conn.commit();
    
    rs = conn.createStatement().executeQuery("select data from ...");
    rs.next();
    InputStream is2 = rs.getBlob(1).getBinaryStream();
    rs.close();
    conn.commit();
    
    int b1 = 0, b2 = 0;
    while(is1.read()>=0) b1++;
    while(is2.read()>=0) b2++;
    
    System.out.println("Read " + b1 + " bytes from 1st blob");
    System.out.println("Read " + b2 + " bytes from 2nd blob");
    

    即使两个 Blob 都是从同一个物理连接和两个不同的事务中选择的,它们都可以被完全读取。

    但是,关闭 JDBC 连接 (conn.close()) 最终会使 Blob 流无效。

    【讨论】:

    • JDBC 4.2 规范的 §16.3.7 证实了您的解释,即 Blob 在事务之外可以是有效的(我已经相应地更新了我的答案)。但是,阅读 Oracle 的 JDBC Developer's Guide,我没有看到关于 Blob 在创建/生成它的事务之外的有效性的额外保证。我会有很多问题,例如当重新使用连接并修改 LOB 数据时会发生什么?删除 LOB 时会发生什么?这仅在 LOB 预取大小范围内有效吗?等等。
    【解决方案3】:

    我遇到了类似的相关问题,我可以确认至少在我的情况下,PostgreSQL 在使用StreamingOutput 方法时会抛出异常Invalid large object descriptor : 0 with autocommit。原因是当返回来自 JAX-RS 的 Response 时,事务已提交,流式处理方法稍后执行。与此同时,文件描述符不再有效。

    我创建了一些辅助方法,以便流式处理部分打开一个新事务并可以流式传输 Blob。 com.foobar.model.Blob 只是一个封装 blob 的返回类,因此不必获取完整的实体。 findByID 是一种在 blob 列上使用投影并且仅获取此列的方法。

    所以 JPA 和 Spring 事务下的 JAX-RS 和 Blob 的 StreamingOutput 正在工作,但必须对其进行调整。我猜这同样适用于 JPA 和 EJB。

    // NOTE: has to run inside a transaction to be able to stream from the DB
    @Transactional
    public void streamBlobToOutputStream(OutputStream outputStream, Class entityClass, String id, SingularAttribute attribute) {
        BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(outputStream);
        try {
            com.foobar.model.Blob blob = fooDao.findByID(id, entityClass, com.foobar.model.Blob.class, attribute);
            if (blob.getBlob() == null) {
                return;
            }
            InputStream inputStream;
            try {
                inputStream = blob.getBlob().getBinaryStream();
            } catch (SQLException e) {
                throw new RuntimeException("Could not read binary data.", e);
            }
            IOUtils.copy(inputStream, bufferedOutputStream);
            // NOTE: the buffer must be flushed without data seems to be missing
            bufferedOutputStream.flush();
        } catch (Exception e) {
            throw new RuntimeException("Could not send data.", e);
        }
    }
    
    /**
     * Builds streaming response for data which can be streamed from a Blob.
     *
     * @param contentType        The content type. If <code>null</code> application/octet-stream is used.
     * @param contentDisposition The content disposition. E.g. naming of the file download. Optional.
     * @param entityClass        The entity class to search in.
     * @param id                 The Id of the entity with the blob field to stream.
     * @param attribute          The Blob attribute in the entity.
     * @return the response builder.
     */
    protected Response.ResponseBuilder buildStreamingResponseBuilder(String contentType, String contentDisposition,
                                                                     Class entityClass, String id, SingularAttribute attribute) {
        StreamingOutput streamingOutput = new StreamingOutput() {
    
            @Override
            public void write(OutputStream output) throws IOException, WebApplicationException {
                streamBlobToOutputStream(output, entityClass, id, attribute);
            }
        };
        MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM_TYPE;
        if (contentType != null) {
            mediaType = MediaType.valueOf(contentType);
        }
        Response.ResponseBuilder response = Response.ok(streamingOutput, mediaType);
        if (contentDisposition != null) {
            response.header("Content-Disposition", contentDisposition);
        }
        return response;
    }
    
    /**
     * Stream a blob from the database.
     * @param contentType        The content type. If <code>null</code> application/octet-stream is used.
     * @param contentDisposition The content disposition. E.g. naming of the file download. Optional.
     * @param currentBlob The current blob value of the entity.
     * @param entityClass The entity class to search in.
     * @param id          The Id of the entity with the blob field to stream.
     * @param attribute   The Blob attribute in the entity.
     * @return the response.
     */
    @Transactional
    public Response streamBlob(String contentType, String contentDisposition,
                               Blob currentBlob, Class entityClass, String id, SingularAttribute attribute) {
        if (currentBlob == null) {
            return Response.noContent().build();
        }
        return buildStreamingResponseBuilder(contentType, contentDisposition, entityClass, id, attribute).build();
    }
    

    我还必须在我的答案中补充一点,Hibernate 下的 Blob 行为可能存在问题。默认情况下,Hibernate 会将整个实体与 DB 合并,如果仅更改了一个字段,即如果您更新字段 name 并且还有一个大 Blob image 未触及,则图像将被更新。更糟糕的是,如果实体分离,则在合并之前,Hibernate 必须从数据库中获取 Blob 以确定dirty 状态。因为 blob 不能按字节进行比较(太大),所以它们被认为是不可变的,并且相等比较仅基于 blob 的对象引用。从数据库中获取的对象引用将是不同的对象引用,因此尽管没有任何更改,但 blob 会再次更新。至少我的情况是这样。我在实体中使用了注解 @DynamicUpdate 并编写了一个用户类型以不同的方式处理 blob 并检查是否必须更新。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2011-07-25
      • 2018-08-02
      • 1970-01-01
      • 1970-01-01
      • 2018-10-14
      • 1970-01-01
      • 2014-08-28
      相关资源
      最近更新 更多