JPA EntityManager悲观锁在H2与PostgreSQL中的异常问题排查
让我们一步步拆解你的问题,先搞定最棘手的H2连接异常,再解释不同数据库的锁行为差异,最后给出异常处理的最佳实践。
一、修复H2下的连接关闭与AsyncContext异常
你遇到的java.sql.SQLException: Connection is closed和Cannot dispatch without an AsyncContext异常,主要是两个核心问题导致的:
1. 异步请求上下文未正确收尾
从异常信息来看,你应该是在Servlet异步处理中执行下载逻辑。如果异常没有被捕获并处理,异步上下文会处于未完成状态,容器可能会提前回收连接,导致第一个线程在事务收尾时找不到可用连接。
解决方法:
在异步处理代码中,务必在finally块里确保AsyncContext被正确完成,无论成功还是失败:
AsyncContext asyncContext = request.startAsync(); try { foobar(); // 调用你的下载方法 asyncContext.complete(); } catch (PessimisticLockException e) { // 告知用户资源正在被占用 asyncContext.getResponse().getWriter().write("当前资源正在被下载,请稍后重试"); asyncContext.complete(); } catch (Exception e) { // 处理其他异常 log.error("下载出错", e); asyncContext.complete(); }
2. 连接池配置导致连接被提前回收
如果使用了第三方连接池(比如HikariCP),H2默认的连接闲置超时可能短于你设置的10秒sleep时间,导致连接被提前回收。你需要调整连接池参数:
- 增加闲置超时时间(比如设置为15秒,大于sleep的10秒)
- 添加连接有效性验证,确保连接在使用时是可用的
如果是Spring Boot项目,在application.properties里配置:
spring.datasource.hikari.idle-timeout=15000 spring.datasource.hikari.validation-query=SELECT 1 spring.datasource.hikari.max-lifetime=60000
另外,检查@Transactional注解的传播行为,确保事务在sleep期间保持活跃,不会因为异常传播导致事务提前终止。
二、为什么H2和PostgreSQL的锁行为不一样?
这是因为不同数据库对悲观锁超时策略的默认配置不同:
H2的默认行为
H2在使用PESSIMISTIC_WRITE锁时,默认锁超时时间为0秒——也就是说,当另一个事务尝试获取已锁定的行时,会立即抛出PessimisticLockException,不会等待,这符合你最初的预期。
PostgreSQL的默认行为
PostgreSQL的SELECT ... FOR UPDATE(对应JPA的PESSIMISTIC_WRITE)默认是无限等待,直到锁被释放或者数据库连接超时。这就是为什么第二个线程会阻塞,而不是抛出异常。
让PostgreSQL和H2行为一致的方法:
在调用entityManager.lock()时,指定锁超时时间为0,强制PostgreSQL立即返回失败:
Map<String, Object> lockProperties = new HashMap<>(); // 设置锁超时为0毫秒,立即触发异常 lockProperties.put("javax.persistence.lock.timeout", 0); try { entityManager.lock(nachricht, LockModeType.PESSIMISTIC_WRITE, lockProperties); } catch (PessimisticLockException e) { // 处理锁冲突逻辑 }
这个属性是JPA标准属性,主流数据库都支持。
三、正确处理PessimisticLockException
要友好告知用户下载冲突,你需要在合适的层级捕获异常,并确保事务正确回滚:
1. 在事务方法内捕获异常(推荐)
你的foobar()方法标注了@Transactional,默认会在RuntimeException(包括PessimisticLockException)发生时回滚事务。你可以在方法内捕获异常,记录日志后抛出自定义业务异常,再在控制器层处理:
@Transactional public void foobar() throws DownloadConflictException { log.debug("Thread " + Thread.currentThread().getName() + " Trying to lock"); try { Map<String, Object> lockProps = new HashMap<>(); lockProps.put("javax.persistence.lock.timeout", 0); entityManager.lock(nachricht, LockModeType.PESSIMISTIC_WRITE, lockProps); entityManager.refresh(nachricht); } catch (PessimisticLockException e) { log.warn("Thread " + Thread.currentThread().getName() + " 无法获取锁,资源正在被下载"); throw new DownloadConflictException("当前资源正在被其他用户下载,请稍后重试"); } log.debug("Thread " + Thread.currentThread().getName() + " 获取锁成功"); log.debug("Thread " + Thread.currentThread().getName() + " Löschdatun: " + nachricht.getLoeschDatum()); log.debug("Thread " + Thread.currentThread().getName() + " start download"); try { Thread.sleep(10000); // 执行你的下载逻辑 } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error("下载线程被中断", e); throw new RuntimeException("下载被中断"); } log.debug("Thread " + Thread.currentThread().getName() + " end download"); }
2. 在控制器层处理异常
定义全局异常处理器,捕获自定义的DownloadConflictException,返回友好的HTTP响应:
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(DownloadConflictException.class) public ResponseEntity<String> handleDownloadConflict(DownloadConflictException e) { return ResponseEntity.status(HttpStatus.CONFLICT).body(e.getMessage()); } }
这样,第二个请求的用户会收到409 Conflict状态码和清晰的提示信息,第一个线程也能正常完成事务,不会再出现连接关闭的问题。
内容的提问来源于stack exchange,提问作者Jerome F

