坑惨了!Hibernate NonUniqueObjectException 偶发报错,最后一条明细必现?
关于《运维踩坑记》
这是一个没有固定更新计划的系列。每一次遇到值得记录的异常、报错或诡异现象,处理完之后就随手记下来——可能是一个 SQL 的语法陷阱,可能是一次网络抖动的排查,也可能是一个配置参数的误解。没有刻意安排,遇到了就写,写完了就沉淀。
如果这些记录能帮你在未来的某个深夜少走一段弯路,那这个系列就有了它存在的意义。
本期是第 13 期:Hibernate NonUniqueObjectException 偶发报错的“排雷”实录。欢迎阅读,也欢迎交流。
摘要:生产环境 WMS 系统,收货作业平时一切正常,却在最后一条明细时偶发抛出
NonUniqueObjectException,日志中还夹杂着第三方接口的 WSDL 警告,极易误判方向。本文完整记录了从现象复现、日志分析、Hibernate Session 缓存机制解读、到业务代码逐层排查的全过程。最终定位根因:同一个 Session 中两个 ID 相同的实体实例导致saveOrUpdate失败。解决方案未改动底层 DAO,而是巧妙地将风险操作替换为HQL 直接更新,既保留了原有的父子级联完成业务,又彻底绕开了缓存冲突。文章适合所有使用 Hibernate/Spring 生态进行企业级开发的技术人员阅读。
一、事发背景
某仓储管理系统的RF 手持终端收货功能,平时运行基本正常。但近期测试人员反馈:个别单据在收货到最后一条明细时,后台偶尔会抛出异常,前台表现为操作失败。由于是偶发,开发组一开始并未重视,直到生产环境也出现了同样的报错日志。
二、案发现场:日志分析
以下是从日志文件中截取的关键片段(敏感信息已脱敏):
[system][WARN] 2026-08-03 08:55:43 - Could not find a matching method for operation {http://xxx.com/service}GetContainerBankLocation. Operation will be unavailable. [system][WARN] 2026-08-03 08:55:43 - Could not find a matching method for operation {http://xxx.com/service}GetContainerBankInfoAndAuthorization. Operation will be unavailable. [system][ERROR] 2026-08-03 08:55:55 - start execute rule 批次号规则 [system][ERROR] 2026-08-03 08:55:55 - 批次号规则end, use time 41 [system][ERROR] 2026-08-03 08:55:55 - start execute rule 库位包装级别规则 [system][ERROR] 2026-08-03 08:55:55 - 库位包装级别规则end, use time 23 org.springframework.orm.hibernate3.HibernateSystemException: a different object with the same identifier value was already associated with the session: [com.xxx.model.receiving.BookingOrder#123196]; nested exception is org.hibernate.NonUniqueObjectException: a different object with the same identifier value was already associated with the session: [com.xxx.model.receiving.BookingOrder#123196]初步观察:
- 前面几条 WSDL 警告来自第三方系统接口(后来确认与此异常无关)。
- 真正的异常是 Hibernate 抛出的
NonUniqueObjectException。 - 异常指向实体
BookingOrder,ID =123196。
三、第一次误判:是数据库主键冲突吗?
很多同学第一反应是:数据库里是不是已经有这条记录了?
但仔细检查后发现:
- 数据库中存在
id = 123196的BookingOrder记录(这是正常的预约单主数据)。 - 而且业务语义是“更新这条记录的状态为已完成”,并不是插入新数据。
所以,问题不发生在数据库层,而是发生在 Hibernate 的 Session 缓存层。
四、核心原理:Hibernate Session 缓存机制
NonUniqueObjectException的官方解释是:
在同一个 Session 中,尝试保存/更新一个标识符(ID)相同但实例不同的对象时抛出。
简单理解就是:
- Session 是一个一级缓存。
- 同一个 Session 中,不允许存在两个ID 相同但内存地址不同的实体对象。
- 当调用
saveOrUpdate()时,Hibernate 会检查缓存中是否已存在该 ID 的对象。如果存在且不是当前这个实例,就直接报错。
五、代码追踪:找到「作案现场」
通过分析堆栈,调用链如下:
TerminalReceiveShell.mainProcess → PutawayManager.addReceiveRecord → ReceivingManager.detailReceive → OrderManager.receive → OrderManager.completeBooking ← 异常抛出点关键代码(原始版本,已脱敏):
privatevoidcompleteBooking(OrderDetaildetail){BookingOrderbooking=detail.getBooking();if(booking==null)return;// 特殊业务处理(略)if(someSpecialCondition){booking=this.commonDao.get(BookingOrder.class,booking.getId());}// 查询是否还有未收完的明细Stringhql="SELECT count(*) FROM OrderDetail detail "+"WHERE detail.booking.id = :bookingId "+"AND detail.expectedQuantity > detail.receivedQuantity";Longcount=(Long)commonDao.findByQueryUniqueResult(hql,"bookingId",booking.getId());if(count==null||count.equals(0L)){// ⚠️ 问题就出在这里!booking.setFinishTime(newDate());booking.setStatus(BookingStatus.FINISH);commonDao.store(booking);// 底层是 saveOrUpdate// 同时更新所有关联的子单hql="UPDATE BookingOrder SET finishTime = :finishTime, status = :status "+"WHERE preId = :preId";commonDao.executeByHql(hql,...);}}问题点分析:
count == 0意味着所有明细都已收货完成,此时需要标记预约单为FINISH。- 但在调用
completeBooking之前,同一个事务中,booking对象可能已经被规则引擎(批次号规则、库位包装级别规则)或其他查询加载过,并存在于 Session 缓存中。 - 而当前传入的
booking可能是通过detail.getBooking()获取的另一个实例(脱管态),与缓存中的实例不是同一个对象。 - 因此
saveOrUpdate执行时,Hibernate 检测到两个 ID 相同的实例,直接抛出异常。
六、为什么「最后一条」才触发?
因为completeBooking中的if (count == null || count.equals(0L))分支:
- 只要还有任意一条明细
expectedQuantity > receivedQuantity,count > 0,不会进入完成逻辑。 - 只有最后一条明细处理完后,
count == 0,才会进入该分支执行store。 - 所以,异常只在最后一条明细时出现,前 N-1 条永远不会触发。
为什么是偶发?因为:
- 不是每次收货都会刚好满足“全部完成”条件(可能一次性全收完,可能分批收货,也可能有驳回/异常)。
- 只有业务上触发完成逻辑时才会复现。
七、表结构辅助分析
为了验证preId的含义,查看表结构(已脱敏):
commentontableBOOKING_ORDERis'预约表';commentoncolumnBOOKING_ORDER.idis'ID';commentoncolumnBOOKING_ORDER.pre_idis'原拆分ID';commentoncolumnBOOKING_ORDER.statusis'状态';commentoncolumnBOOKING_ORDER.finish_timeis'完成时间';-- 其他字段省略确认pre_id为自关联外键,指向父预约单 ID。因此原代码中UPDATE ... WHERE preId = :preId的目的是将当前单及其所有子单一并标记为完成。
八、解决方案:不改底层 DAO,用 HQL 绕开缓存
核心思路:既然
saveOrUpdate会触发缓存冲突,那我们直接绕过 Session 缓存,使用 HQL 进行数据库更新。
修改前(有风险的代码)
booking.setFinishTime(newDate());booking.setStatus(BookingStatus.FINISH);commonDao.store(booking);// ← 这里抛出 NonUniqueObjectExceptionStringhql="UPDATE BookingOrder SET finishTime = :finishTime, status = :status "+"WHERE preId = :preId";commonDao.executeByHql(hql,...);修改后(安全的代码)
// 1. 更新当前预约单本身(替代有风险的 store)StringhqlSelf="UPDATE BookingOrder SET finishTime = :finishTime, status = :status WHERE id = :id";commonDao.executeByHql(hqlSelf,newString[]{"finishTime","status","id"},newObject[]{newDate(),BookingStatus.FINISH,booking.getId()});// 2. 更新所有 preId 指向当前单的子预约单(保留原业务逻辑)StringhqlPre="UPDATE BookingOrder SET finishTime = :finishTime, status = :status WHERE preId = :preId";commonDao.executeByHql(hqlPre,newString[]{"finishTime","status","preId"},newObject[]{newDate(),BookingStatus.FINISH,booking.getId()});为什么这样改是安全的?
| 对比项 | saveOrUpdate | HQL 直接更新 |
|---|---|---|
| 是否经过 Session 缓存检查 | ✅ 是,会检查并可能抛出异常 | ❌ 否,直接生成 SQL 执行 |
是否可能触发NonUniqueObjectException | ✅ 可能 | ❌绝对不会 |
| 是否更新内存对象状态 | ✅ 会同步更新缓存 | ❌ 不会,但后续不再使用该对象 |
| 是否触发拦截器/监听器 | ✅ 会触发 | ❌ 绕过 |
由于代码执行到此处时,整个收货流程即将结束,内存中的booking对象后续不再有任何操作,因此内存状态与数据库短暂不一致是完全可接受的。
九、验证结果
修改后经过多轮测试:
- ✅ 正常收货流程无影响。
- ✅ 最后一条明细触发完成逻辑时,异常不再复现。
- ✅ 数据库状态更新正确(当前单及所有关联单均置为完成)。
- ✅ 对原有业务逻辑零侵入。
十、经验总结与避坑指南
理解 Hibernate Session 生命周期
同一个事务中,尽量避免对同一个 ID 的实体进行多次加载,尤其要避免人为new出脱管态对象再saveOrUpdate。mergevssaveOrUpdate
如果必须处理脱管对象,优先使用merge(),它会自动合并缓存中的数据,避免冲突。HQL 是绕过缓存的利器
在批量更新、状态标记等“终点操作”场景中,使用 HQL 直接更新数据库可以规避很多缓存问题。不要被无关日志干扰
本例中 WSDL 警告虽然出现在异常附近,但经过分析确认无关。排查问题时,要紧盯堆栈,分清主次。偶发≠不严重
偶发异常往往是最隐蔽的,因为它们只在特定条件下触发,容易被忽略。一定要结合业务逻辑,找到“触发条件”,才能精准定位。
写在最后
“当 Session 缓存跟你较劲的时候,别硬扛——绕开它,用 HQL 直捣数据库,世界瞬间清净。”
《运维踩坑记》系列索引
- 排查 2 小时,改代码 5 分钟:一行沉睡 10 年的 Log4j 配置,差点让我怀疑人生
- 别让一个空格搞垮你的 WMS 报表——ORA-01722“无效数字”排查实战与终极防御
- 能 ping 通却端口不通?跨网段虚拟机故障复盘,别只会重启救急
- 别被 Excel“骗”了!明明显示整数,导入系统却报错?原来是它在捣鬼!
- 跨越数据库的“隐形地雷”:一次 ORA-22992 引发的跨库 LOB 问题彻底剖析
- JUnit 测试中的常见异常(一):
@Before/@After方法为何导致“No tests found”? - 悲剧,就因为一个“yyyy-MM-dd”,我的跨年加班费没了!——日期格式化的那些天坑
- 一次Oracle会话爆满的惊魂时刻:Spring Boot + MyBatis连接池配置救场
- WMS 拣货任务“投线”之谜:从一次诡异的 Bug 到架构重构
- Tomcat 严重警告:JDBC 驱动未注销 + 工作线程泄漏 —— 原因、影响与彻底修复
- 一条 SQL 的“CASE 陷阱”与跨库优化实践
- 一次 Oracle 复杂 SQL 的“排雷”实录
- Hibernate NonUniqueObjectException 偶发报错的“排雷”实录 (本文)
折哥于 2026年8月 记录
本文属于《运维踩坑记》系列第 13 期,希望这篇文章能帮助到正在被
NonUniqueObjectException折磨的你。如果这些记录能帮你在未来的某个深夜少走一段弯路,那这个系列就有了它存在的意义。如果觉得有用,欢迎点赞、收藏、评论交流!