聊到Hibernate的会话范围(Scope),很多同学的第一反应是:Session不就是开一次关一次吗?这句话只算答对了一半。Session的范围管理,本质上是在回答三个问题:这个Session什么时候创建、被谁共享、什么时候销毁。范围定大了,连接池、一级缓存、事务边界全都跟着失控;范围定小了,懒加载查不出来,事务一致性也保不住。Hibernate本身不强求你用某一种固定的Scope方式,但正因为没有强制,团队里一旦缺少一套统一约定,最容易写出“能跑但撑不住压”的代码。
这篇文章写给正在维护老项目、或者面试被追问到底层的Java开发者,尤其是那些用过Hibernate、但又说不清Session该放线程里、请求里还是事务里的朋友。我会把手动控制、ThreadLocal绑定、与Spring集成时的Scope管理,以及多线程场景下的注意事项逐一拆开讲,最后附上这些年我踩过的坑和排查套路。如果你正在从零选型,也可以把这篇文章当作判断“我还需不需要维护Hibernate代码”的参考——它是有点老,但存量市场比你想象的大得多。
1. 会话范围到底在管什么:先厘清概念与常见误区
1.1 Session是什么,为什么必须讨论“范围”
在Hibernate里,Session并不是“数据库连接”本身,它更接近一个“持久化上下文”(Persistence Context)。你可以把它理解成一块工作台:所有实体从new到managed、从managed到detached,再到remove,都是在这块工作台上完成的。Session同时管理一级缓存、脏检查机制、主键生成策略和级联操作。换句话说,你调用save、update、delete的时候,数据并不会立刻跑到数据库里,而是先在工作台上改一笔“草稿”,等到事务提交或显式flush,草稿才会被统一同步到库里。
正因如此,Session的生命周期直接决定了“草稿”保留多久。如果工作台摆得太久,草稿越积越多,内存和数据库连接都会被长期占用;如果工作台刚搭起来就拆掉,实体还没查完,懒加载就全废了。数据库事务本身也有自己的作用域,但要注意:事务作用域和Session作用域不是一回事。一个Session可以跨越多个事务,而一个事务也可以只使用Session生命周期的某一段。两者匹配得好,代码干净高效;匹配错了,就是各种No Session和连接耗尽。
从官方设计上看,Hibernate把Session的开闭权限完全交给了使用者,而不是像JDBC的Connection那样有一套强制的生命周期约定。这意味着Scope控制其实是你自己的责任,这也是“Hibernate难用”的一个根源:它给了你自由,也给了你自由犯错的空间。
1.2 范围失控的典型症状:从No Session到连接耗尽
我在排查别人项目的时候,Session范围出问题通常逃不出这四类症状:
第一类,No Session或LazyInitializationException。这个最常见,本质上是Session已经关了,但实体对象还在外面被访问,关联集合一旦懒加载就找不到工作台。
第二类,连接池耗尽。表现是系统运行一段时间后,突然所有请求都卡住,日志里报“Connection is not available, request timed out”。这类问题十有八九是Session开多了没关,或者Session被放到了长生命周期对象里,数据库连接被长期占住不还。
第三类,多线程并发操作异常。Hibernate官方文档明确写了:Session不是线程安全的。假如你把同一个Session丢给多个线程使用,很快会看到“Found shared references to a collection”或者各种ConcurrentModificationException,实体状态也会乱掉。
第四类,内存飙升。Session一级缓存的作用域就是Session本身,如果这个Session活得很长,缓存里累积的实体也就越来越多。典型场景是跑批任务时用同一个Session循环处理几万条数据,最后GC都救不回来。
如果你在项目里见过其中任意一个现象,基本可以确定Scope控制出了问题,只是严重程度不同。接下来我们把方向拆细,看看具体怎么控制才是合理的。
1.3 三种常见Scope模型,看完你就知道该选谁
从使用习惯来看,实际项目里的Session范围主要是三种模型,我整理成了一张对比表:
| Scope模型 | 生命周期 | 适用场景 | 优点 | 风险 |
|---|---|---|---|---|
| 程序化局部Scope | 方法级,用完立即关闭 | 批处理、工具类、独立脚本 | 生命周期清晰,不会串事务 | 代码重复,容易漏close |
| 线程绑定Scope | 一个线程/一次业务处理周期 | Web请求、用例级DAO组合 | 同线程内共享Session,事务可跨多个DAO | 线程复用会泄漏,长Session增加缓存压力 |
| 长对话Scope | 跨请求/跨线程,实体detached后merge | 多步业务审批、向导式页面 | 保持业务上下文,避免重复查询 | 合并冲突多,并发控制难 |
大多数Web应用,抛开框架层面,实际落到代码里就是前两种。第三种长对话看起来很美,但merge带来的坑比省下的查询还多,我不太建议新手直接上手。下面先从最基础的程序化局部Scope说起,把它搞透,后面的模型都是它的变形。
2. 手动控制Session:最基础也最可靠的Scope管理方式
2.1 标准生命周期:从openSession到close,一步都不能省
手动控制Session是理解Hibernate Scope的起点。流程并不复杂,但顺序和异常处理必须严格,我习惯写成这样:
Session session = sessionFactory.openSession(); Transaction tx = null; try { tx = session.beginTransaction(); session.save(user); tx.commit(); } catch (RuntimeException e) { if (tx != null) { tx.rollback(); } throw e; } finally { session.close(); }openSession每调用一次,就是创建一个新的持久化上下文。很多人担心频繁openSession会不会非常重,其实不会。Session本身是轻量级对象,真正昂贵的是SessionFactory——整个应用只需要一份。Hibernate设计上就默认你会频繁开关Session,所以每次new一个的成本远低于你的直觉。
这里有个关键原则:事务必须在Session之内开始和结束,但Session不一定只能开一个事务。你完全可以beginTransaction、commit,然后再次beginTransaction,多次使用同一个Session。手动控制时我一般会明确区分“Session级作用域”和“事务级作用域”。如果一段代码只查不改,我会只开Session而不开事务,查询在自动提交模式下完成,这样连接占用窗口最小。
不过在实际代码里,手动控制最容易翻车的点恰恰是“看起来很简单所以不写finally”。漏掉close的后果不是立刻爆发,而是等到连接池被打满才在凌晨报警。Hibernate从5.2开始让Session实现了AutoCloseable,所以你也可以用try-with-resources简化:
try (Session session = sessionFactory.openSession()) { Transaction tx = session.beginTransaction(); // 业务操作 tx.commit(); }注意,使用try-with-resources时,如果在commit之前抛异常,Session会自动关闭,但事务不会自动回滚,所以仍然需要捕获异常并手动rollback。简洁归简洁,异常路径还是不能省。
2.2 flush、clear与事务边界:这几个细节决定代码质量
手动控制Session的时候,很多同学会对flush感到困惑:我明明调了save,为什么数据库里没有?因为save只是把实体放入当前持久化上下文,真正生成SQL是在flush阶段。Hibernate默认在事务提交前自动flush,你可以不手动调用,但有些场景必须提前:
- 实体主键由数据库生成,比如IDENTITY或SEQUENCE,你希望在save之后立刻拿到主键值;
- 同一事务内后续操作依赖前面的INSERT结果,比如先插入主表再插入从表,外键需要主表ID;
- 批量处理大结果集时,你希望控制内存峰值,而不是等到提交时一次性flush。
手动flush之后,数据会发送到数据库,但事务还没提交,这意味着你仍然可以rollback。这个特性有时候被用来在事务里提前发现SQL错误,其实并不是好习惯,反而会延长锁持有时间,不是特别必要就不要手动flush。
另一个我实际用过很多的操作是clear。clear会把Session里所有托管状态的实体变成游离状态,并清空一级缓存。批量循环处理时,如果不定期clear,一级缓存就会越积越大。我之前维护过一个跑批程序,单批处理五万条数据,最初跑到最后越来越慢,甚至OOM。后来每处理两百条就session.clear()一次,内存曲线立刻平了,耗时也稳定了。
但clear是有代价的:之前查出来但还没保存到业务对象里的实体,会全部变成detached,后续如果要再修改,你得重新load或者merge。所以clear只适合那些“处理完就不再关心”的批量场景,不要随手乱清。flush和clear配合的正确姿势是:先flush,让当前变更入库,再clear,腾出一级缓存空间,进入下一批。
2.3 手动控制有短板,什么时候别硬用
手动控制最大的问题不是技术上的,而是工程上的:你会在每个方法里重复写样板代码,一旦业务方法多了,很难保证每个人都记得finally close。
更麻烦的是事务传播。假设ServiceA调用ServiceB,两个方法各自维护自己的Session和事务,那么ServiceB提交时,ServiceA的实体还在托管状态,但底层事务已经换了,中途一旦抛异常,两个事务的提交时间点不一致,数据就会出现部分成功部分回滚的尴尬局面。所以程序化局部Scope只适合两种场景:一是独立任务,比如定时任务、命令行工具;二是作为理解底层原理的练习。纯粹的Web业务层,我更推荐下一节讲的线程绑定方案,或者直接用Spring管事务。
顺带回应一个很多人在选型时问的问题:Hibernate还有人用吗?说实话,新项目我一般推荐JPA规范加Spring Data JPA,底层实现如果还是Hibernate,本质上没离开这个生态。但在大量存量系统和批处理框架里,原生Hibernate API依然随处可见。这些代码通常就是当年“手动控制Session”那一套写法,能跑,但维护成本很高。理解手动控制模型,你才有能力去改造它们。
3. 基于ThreadLocal的会话绑定:单请求内的隐式Scope
3.1 ThreadLocal方案设计思路:让同一个线程复用Session
手动控制Session虽然清晰,但Web请求往往要跨多个Service、多个DAO。如果每个方法都开一个新Session,同一个请求里就会出现多个持久化上下文,事务边界被切得支离破碎。这时候就有了第二招:把Session绑定到当前线程,让一个请求内所有代码共享同一个Session。
实现这个效果最简单的工具就是ThreadLocal。它的工作方式有点像每个线程自带一个储物柜:线程A放进去的东西,线程A自己随时能取,线程B完全看不到。我们把Session放进这个储物柜,业务代码在任何地方调用都可以拿回同一个Session,直到请求结束再关闭并清空。
这种方案其实是早期Hibernate Web应用的最佳实践,后来Spring的OpenSessionInView也是同样的思路,底层用TransactionSynchronizationManager实现资源绑定。如果你不想引入全套Spring,一个ThreadLocal工具类足够支撑小项目。
3.2 一个实用的Session工具类,维护老项目可以直接抄
下面这个工具类是我在维护一个老项目时用过的简化版本,核心逻辑就是“取Session时没有就创建,有就直接复用,请求结束时强制关闭并清理”:
public class HibernateSessionUtil { private static final ThreadLocal<Session> HOLDER = new ThreadLocal<>(); private static SessionFactory sessionFactory; public static void init(SessionFactory factory) { sessionFactory = factory; } public static Session getSession() { Session session = HOLDER.get(); if (session == null) { session = sessionFactory.openSession(); HOLDER.set(session); } return session; } public static void closeSession() { Session session = HOLDER.get(); if (session != null) { session.close(); HOLDER.remove(); } } }使用时的关键在于:必须在请求入口打开,在请求出口关闭。比如在Servlet的Filter里,doFilter前后包一层:
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { try { chain.doFilter(request, response); } finally { HibernateSessionUtil.closeSession(); } }这比每个方法都手动open和close优雅得多。业务代码里只需要调用getSession,不需要关心它从哪来。
这个方案和Hibernate原生API里的getCurrentSession很相似。只要你配置了current_session_context_class为thread,getCurrentSession就会自动绑定当前线程的Session:
<property name="current_session_context_class">thread</property>区别在于,getCurrentSession有更严格的语义:在本地事务环境下,Session从beginTransaction开始绑定,事务提交后就自动关闭。如果你只是查询不开启事务,getCurrentSession会抛异常提示“No current session”。ThreadLocal工具类则更宽松,可以一直保持到请求结束。两者无所谓绝对优劣,团队选一种并坚持最重要。
3.3 线程复用陷阱与Session泄漏排查
ThreadLocal方案最大的坑,是我自己在生产环境踩出来的:线程池复用导致Session泄漏。
逻辑很简单:Web服务器一般用线程池处理请求,线程处理完一个请求后不会销毁,而是放回池里等下一个请求。如果closeSession清理不够彻底,ThreadLocal里的Session引用就会一直留在线程上。下一次请求复用这个线程时,getSession发现已经有值,直接拿了一个早就过期的Session,里面还留着上一个请求未提交的实体和连接。结果就是诡异的脏数据、连接占用、甚至EntityManager都关不掉的怪现象。
所以closeSession里必须在close之后调用HOLDER.remove(),而不是仅仅close就完事。这一点很多教程都没强调,但恰恰是线程复用环境下绝对不能省的一步。排查这类问题有两个工具思路:
一是打开连接池监控,看每个线程持有了多少个连接,配合线程Dump找到持有连接但没释放线程的栈; 二是直接在ThreadLocal上做文章,重写initialValue并打印线程名和创建时间,看Session是哪个请求创建的。
另外一个容易被忽略的长生命周期问题是:Session绑定了整个HTTP请求,包括View渲染和JSON序列化。这意味着数据库连接也会持有到响应完全结束,接口并发量一大,连接池就危险了。这就是当年OpenSessionInView方案被诟病的核心原因,下一节细说。
4. 与Spring集成时的Scope管理:从OSIV到事务模板
4.1 Open Session in View:开发时真香,上线时真坑
Spring官方提供了一个叫OpenSessionInView的机制,用过滤器或拦截器保证一次HTTP请求对应一个Session,并且请求结束时才关闭。它的初衷是让View层可以继续懒加载关联数据,省去在Service层强制抓取的麻烦。开发阶段这确实很爽:写页面模板的时候,直接user.getOrders(),数据就出来了,不用考虑LAZY会不会报错。
但“真香”背后藏着一个大问题:数据库连接被绑定到了View层,而不是事务层。一旦请求处理时间变长,尤其是外部接口等待时间较长或模板渲染复杂,连接就一直被占着。在连接池只有二十个连接、线上百个并发请求的场景下,系统很快就会因为拿不到连接而雪崩。我在生产环境处理过一次事故,现象就是接口偶尔卡死,重启后恢复,过一阵又卡死,最后关掉OSIV之后立刻缓解。
所以我的建议非常直接:如果你做的是高并发的API服务,返回的是JSON而不是服务端渲染页面,请把OSIV关掉。Spring Boot下只需要加一行配置:
spring: jpa: open-in-view: false如果你的老项目不是Spring Boot,而是XML配置的Spring MVC,就把OpenSessionInViewFilter或OpenSessionInViewInterceptor从配置里移除。关掉之后,所有懒加载都必须在Service事务方法内完成,或者通过JOIN FETCH、@EntityGraph显式查询。这相当于把“什么时候加载数据”的责任交还给了业务代码,刚开始会不适应,但系统的连接水位会明显下降。
4.2 事务边界与Session范围的协同模式
在Spring体系里,最推荐的Scope组合是:HTTP请求级使用Session(如果非要OSIV),事务级使用事务边界,业务数据访问全部收敛在事务内。但更常见的严格模式是:Session生命周期和事务生命周期尽量对齐,事务结束,Session就释放。
Spring在底层用TransactionSynchronizationManager实现了类似ThreadLocal的资源绑定。当一个方法标了@Transactional,Spring会在进入方法前从事务同步管理器获取绑定到当前线程的Session,没有就创建,事务提交后再关闭。所以你看到的Spring管理Hibernate,并不神秘,本质上就是“ThreadLocal绑定加事务同步回调”。
我一般会在团队里定三条纪律,效果很好:
- Repository层只做数据访问,不开启事务,不关闭Session,由Service层统一控制;
- Service层入口方法标注@Transactional,一个业务用例一个事务边界;
- Controller层保持无状态,不持有Session、不持有托管实体,只接收和返回简单对象或Id。
这里有个容易踩坑的细节:@Transactional默认只对RuntimeException和Error回滚,对受检异常不生效。很多人以为加了事务注解就一定回滚,结果方法里抛了个IOException,数据已经提交,Session也关了,排查半天才发现是回滚策略问题。正确写法是显式指定rollbackFor:
@Transactional(rollbackFor = Exception.class)这样一来,Session和事务的Scope就完全统一了:事务开始,Session开始;事务提交或回滚,Session关闭。业务代码不需要关心Session本身,Scope控制被提升到了切面层面,这也是我最推荐的生产级做法。
4.3 TransactionTemplate与无状态Session:特殊场景的Scope控制
有时候你并不在Spring管理的Bean里执行代码,比如在工具类或者在启动后的某个线程里,但又想复用Spring的事务设施。这时候可以用TransactionTemplate,它是Spring提供的编程式事务模板:
@Autowired private TransactionTemplate transactionTemplate; transactionTemplate.execute(status -> { Session session = sessionFactory.getCurrentSession(); // 业务操作 return result; });TransactionTemplate内部同样会绑定Session到当前线程,并在事务结束后释放。它的好处是Scope清晰,又不用自己写try-catch。适合批任务、消息队列消费者、以及那些不方便继承事务注解的代码路径。
还有一个特殊Session类型:StatelessSession。它不做一级缓存,不执行脏检查,也不管理实体生命周期。它的Scope控制非常简单,因为每次操作都直接拼SQL执行,不缓存任何内容。批量插入大数据量时,StatelessSession比普通Session快得多,而且基本不会OOM:
try (StatelessSession session = sessionFactory.openStatelessSession()) { Transaction tx = session.beginTransaction(); for (DataItem item : dataList) { session.insert(item); } tx.commit(); }但它也有明显限制:没有懒加载,没有级联操作,没有快照比较。所以你只能在明确“只做批量增删改查”的场景使用它,一旦涉及复杂实体关系,还是要回到普通Session。
5. 分布式与多线程环境下的Scope控制
5.1 为什么Session不能跨线程共享
很多人一看到“Session不是线程安全的”,第一反应是“那我加锁同步不就行了”。这是一个必须纠正的想法。Session内部的一级缓存、持久化上下文快照、关联集合包装器都和当前线程的执行状态强相关。你就算用锁保证同一时间只有一个线程访问,也没法解决另一个线程在清理上下文时把实体状态搞乱的问题,而且加锁带来的性能损耗,远不如每个线程单独开一个Session。
多线程真正该采用的模式是:每个线程一个独立Session,线程之间只传递实体的Id或轻量级DTO。比如做后台并发统计,主线程拆任务给多个子线程,子线程各自拿到Session去查数据,结果汇总返回。实体对象绝不能直接塞进队列或Future里让另一个线程继续操作,那等于把一个已经和旧线程绑定的托管对象,强行交给一个没有上下文的新线程,等着报LazyInitializationException。
我见过一个很典型的设计错误:用户点击导出按钮后,系统复用了请求线程的Session去异步生成Excel,结果子线程开始执行时请求线程已经把Session交了,Excel里所有关联字段全部空指针。正确做法是Controller里只导出数据ID,异步任务用自己的Session重新查库。
5.2 JTA与跨资源事务下的Session范围
本地事务下,Session范围由事务或请求决定;在JTA环境中,规则稍有不同。Hibernate专门提供了一个上下文类型jta,配合配置current_session_context_class=jta使用。此时Session的生命周期会跟JTA全局事务绑定:全局事务开始,Session创建;全局事务提交或回滚,Session关闭。这样做的目的是让一个Session能够参与多个数据源资源的协调,保证跨库操作在同一个全局事务里要么全成要么全败。
在一个应用服务器里使用JTA时,你要特别注意SessionFactory的实现类,应该选择能够感知JTA事务的类型,比如JtaTransactionManager配合。否则可能出现“Session绑定到了本地事务,但JTA事务已经提交”的错位情况,最终导致实体状态和数据库不一致。
从实践角度看,我建议大部分单体项目不要主动引入JTA。它的复杂度来自分布式事务协调本身,而不是Hibernate。只有当业务确实要跨多个数据库、又必须保持强一致时,才需要考虑把Session范围提升到JTA级别。否则老老实实保持本地事务,哪怕多写几个分步处理,后面的排查成本低得多。
5.3 异步任务和消息消费:每个独立业务单元一个Session
异步任务和消息队列是Scope控制的重灾区,因为异步线程没有HTTP请求上下文,ThreadLocal方案在这里默认失效。比如@Async方法,即使方法内部调用getCurrentSession,也拿不到上一个请求绑定的Session,因为线程已经换了。
我的经验是给异步任务定一条简单规则:每个独立处理单元显式开启自己的Session和事务,处理完立即关闭。不要试图把请求线程的Session传递进来,更不要相信“异步任务发起前一定还在同一个线程”这种侥幸。
以消息消费为例,最干净的模式是这样:
public void onMessage(Message msg) { try (Session session = sessionFactory.openSession()) { Transaction tx = session.beginTransaction(); Order order = session.get(Order.class, msg.getOrderId()); // 处理业务 tx.commit(); } }消息里只传唯一标识,消费者拿到后重新load实体。这么做看起来很笨,但能避开大多数并发和上下文问题。因为你无法控制消息框架内部是否会复用线程,也无法保证事务性的异步处理一定落在同一个Session上,最安全的方式就是“谁的线程谁开Session”。
同样,Spring的@Scheduled定时任务也要遵循这个原则:任务方法内部自己管理Session,或者交给声明式事务切面来管。不要依赖类级别的静态Session,更不要在一个全局静态变量里保存Session给多个任务共用,那等于同时踩了“长生命周期”和“多线程共享”两个坑。
6. 常见问题速查与排查窍门
6.1 高频问题速查表
我把自己这些年帮人排查过的问题整理成了一张速查表,基本覆盖了Session范围相关的绝大多数症状:
| 现象 | 直接原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| LazyInitializationException,提示No Session | 实体在Session关闭后才访问懒加载属性 | 检查调用链是哪个方法在Session外访问实体 | 事务内join fetch;或延迟查询;或禁止返回托管实体 |
| 连接池等待超时,活跃连接数持续不降 | Session未关闭,连接被长期占用 | 查过滤器/拦截器是否finally close | 统一管理Session;关闭OSIV;确保finally释放 |
| ThreadLocal中的Session被复用,数据串号 | 线程池复用,ThreadLocal未remove | 检查close逻辑是否remove | close后必须remove |
| 批量任务内存飙升,速度越来越慢 | 一级缓存积累大量实体 | 查循环中是否clear | 定期flush并clear |
| 多个线程操作同一Session,出现集合共享异常 | Session跨线程使用 | 查静态Session/方法参数传递 | 每个线程独立Session |
| 事务提交了,但数据没生效 | flush时机未到 | 检查commit之前是否有异常被吞掉 | 不吞异常,确认flush和commit执行 |
| 查询很慢,但单个SQL不快 | Session缓存了旧数据 | 是否长事务长时间不提交 | 缩短事务,及时提交或清理 |
表中第一行是我排查工作量最大的问题,也是最常见的。典型场景是Service方法返回了实体对象,Controller模板渲染时调用user.getOrders(),而事务已经在Service方法返程时结束,Session随之关闭。解决思路永远是三个方向:要么让加载在事务内完成,要么就别把实体直接往外传,要么打开OSIV接受它的副作用。
6.2 排查步骤与工具:从日志到线程Dump的一整套流程
如果你拿到一个“疑似Session范围问题”的线上事故,我建议按下面的顺序来,不要一上来就抓瞎改代码。
第一步,先确认Hibernate的日志开关是否打开。至少配置Hibernate打印SQL和执行统计:
<property name="hibernate.show_sql">true</property> <property name="hibernate.format_sql">true</property>同时把log4j或logback中org.hibernate.resource.transaction和org.hibernate.engine.internal的日志级别调到DEBUG,可以看到事务何时开始、提交、回滚,以及Session关闭的痕迹。这一层日志能帮你快速判断Session到底是没开、晚关还是提前关。
第二步,打开连接池监控。如果是HikariCP,开启JMX或者直接看日志中的连接池wait时间;如果是Druid,监控页面能看到当前活跃连接数、物理连接数以及获取连接的调用栈。把活跃连接数和请求日志对应起来,就能定位到是哪一类请求把连接占住了。
第三步,做线程Dump。当连接池耗尽时,jstack一把梭,看哪些线程卡在“HikariCP getConnection”上,同时看它们之前持有什么锁资源。如果多个线程都卡在同一批业务代码上,那大概率是Session范围太大导致连接释放过慢,而不是线程真的死锁。
第四步,针对懒加载问题,用一个最小复现接口做验证:Controller返回实体对象还是Id列表?事务是否覆盖了所有查询?页面渲染发生在事务外还是事务内?只要把这三个问题问清楚,问题基本水落石出。
最后分享一个很有用的“笨办法”:在所有Dao查询方法的入口和出口打印当前线程名以及Session的hashCode。比如:
log.debug("thread={}, session={}, entity={}", Thread.currentThread().getName(), session.hashCode(), entity.getId());把一次请求产生的日志顺序排出来,就能看到Session是不是同一个、线程有没有切换。这种日志上线一两天就可以关掉,但排查效率极高。我个人是习惯把它写进Dao基类里,出问题就开DEBUG,没问题就保持INFO,成本很低,收益很大。
说到底,Session范围控制不是一个“用对某个API”就能解决的问题,它是从请求入口到事务边界、从线程模型到连接池配置的一整套设计决策。这个系列能写到这里,说明你已经不是单纯在背Hibernate API了,而是在真正思考一个系统运行时如何做到可控。技术换了一茬又一茬,真正有用的还是这种对生命周期和边界的敏锐度,它会帮你迁移到任何新框架。