资讯动态

Hibernate乐观锁实战:@Version配置、原理与并发冲突处理

发布时间:2026/10/6 8:27:54 来源:尧图企业网站定制
老规矩这一篇继续聊 Hibernate 并发控制里的重头戏乐观锁。很多人刚接触 Hibernate 时不理解明明数据库已经有了事务隔离级别为什么还需要在实体上加 version 字段来防并发其实你去任何一个写订单、扣库存、改账户余额的真实系统里看乐观锁配置基本都是标配。今天这篇就把 Hibernate 中乐观锁的配置方式、工作原理、常见坑和实战验证一次说透看完你不仅能配还能知道它到底是怎么替你挡住并发冲突的。1. 为什么需要乐观锁先搞懂它解决的到底是什么问题1.1 先从一次真实的脏更新说起我最早被并发问题咬到是在一个账户余额更新的功能上。两个用户几乎同一时间点击了“提现”和“消费”服务端各自查到了余额是 100 元A 线程把它扣成 50B 线程把它扣成 30两个事务都提交成功。但实际正确结果应该是 100 减两笔最终余额 20。这 30 那个线程因为在提交时用的是自己缓存里的旧余额直接把对方扣减的结果覆盖掉了。这种问题叫“丢失更新”是典型的并发覆盖。数据库默认的事务隔离级别能挡掉脏读、不可重复读但挡不住两个事务各自基于旧值更新同一行。REPEATABLE READ 保证你一个事务内多次查询结果一致可它保证不了这个一致性是基于最新数据的。要真正解决得靠应用层显式地加控制或者在更新 SQL 里带上条件。乐观锁解决的就是这类问题谁先提交谁成功后提交的人发现自己手里的数据版本已经过期直接失败重来而不是默默覆盖别人的结果。1.2 悲观锁与乐观锁的取舍逻辑处理并发更新数据库层面其实有两条路。悲观锁走的是 SELECT ... FOR UPDATE直接把这一行锁住别人想读想改都得等。优点是很“硬”缺点也明显占用数据库连接时间长长事务下性能掉得厉害而且在高并发读多写少的场景里为了偶尔的写冲突让所有读都排队完全不划算。乐观锁的思路正相反它不加锁谁都能读、都能改只在最后更新那一刻校验“版本”是否对得上。对得上就成功对不上就抛异常。它把冲突检测的代价从“每次操作都支付”变成了“只在冲突发生时支付”非常契合读多写少、冲突概率不高的互联网场景。如果换成一个库存扣减极度频繁、一次扣减失败影响巨大的系统悲观锁反而可能更合适因为乐观锁冲突后反复重试的成本更高。没有绝对的银弹关键看你业务里写冲突的概率高不高。1.3 Hibernate 里乐观锁的落点Version 注解在 Hibernate 里配置乐观锁正统且主流的方式只需要两步实体类加一个版本属性属性上标注 Version 注解。就这一个注解剩下的比如新增时初始化版本号、更新时版本号自增、UPDATE 语句里自动携带版本条件全部由 Hibernate 帮你完成。很多人会疑惑Version 到底是不是 JPA 标准是的javax.persistence.Version 和 jakarta.persistence.Version 都是标准注解Hibernate 对它做了完整实现。这意味着你以后从 Hibernate 换成别的 JPA 实现这段代码也能原样带过去。除了注解方案Hibernate 还有一套基于“脏检查”的乐观锁策略用 org.hibernate.annotations.OptimisticLocking DynamicUpdate更新时把被修改字段的旧值拼到 WHERE 里当条件。但我个人建议团队新项目直接用 Version简单、标准、好理解后面展开讲原理时你也能直观感受到它的可靠。2. Hibernate 配置乐观锁的正统姿势Version 注解详解2.1 实体类改造一个字段搞定先看一个最常见的改造示例。假设有一张账户表 t_account原本实体长这样Entity Table(name t_account) public class Account { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name balance) private BigDecimal balance; }要启用乐观锁只需增加一个 version 字段Entity Table(name t_account) public class Account { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name balance) private BigDecimal balance; Version Column(name version) private Integer version; }就这一个改动后面的所有并发防护就生效了。数据库表里对应加一列即可ALTER TABLE t_account ADD COLUMN version INT NOT NULL DEFAULT 0;需要注意 version 字段建议声明成包装类型 Integer 而不是基本类型 int。因为 Hibernate 在保存新实体时如果发现 version 是 null会自动把它初始化为 0。如果用了基本类型 int实体对象的初始值永远不是 null某些场景下比如先 new 出来、再手动序列化再反序列化容易把版本值弄丢。2.2 支持的类型与字段命名约定很多文档只写了一个 Integer 示例实际 Hibernate 对 Version 的类型支持是有限的这点务必记清楚。官方明确支持以下类型类型说明推荐度int / Integer版本号递增最简单直观强烈推荐long / Long版本号递增适合将来版本号很大的场景强烈推荐short / Short同上但范围小一些一般java.sql.Timestamp以数据库时间戳做版本不推荐java.util.Date时间戳版本Hibernate 会映射为数据库时间不推荐java.time.Instant新版 Hibernate 支持的高精度时间点看精度需求字段命名上 Hibernate 没有任何硬性规定version、optlock、rev 都可以列名自己定只要 Column(name ...) 和数据库一致。很多人为了省事直接不写 Column让 Hibernate 默认把属性名当成列名这样也不算错但团队规范里最好显式写明列名免得将来改字段名时把数据库列也带偏。另外提醒一句Version 字段不能和 GeneratedValue 一起用。版本号是 Hibernate 自己管理的它的自增逻辑跟数据库主键的自增完全是两回事你一旦手动指定生成策略Hibernate 内部的版本管理会被绕开并发检查直接失效而且行为会很诡异。2.3 新增记录时 version 的初值问题新插入一条记录时version 的值从哪里来如果数据库列设置 DEFAULT 0那你什么都不用管如果用包装类型并且实体里 version 为 nullHibernate 也会在 insert 时把它当成 0 处理。两者结果相同但有个隐蔽坑如果实体里给了 version 一个非 null 的初始值比如手动 setVersion(1)Hibernate 会尊重这个名字插入时就把版本写成 1。这种“尊重用户赋值”的特性在批量导入、数据迁移场景很有用。比如你把 A 库的数据搬到 B 库想保留原版本号直接 setVersion(旧值) 再 persist版本就保住了。但正常业务开发时我强烈建议你不要手动给 version 赋值尤其是不要在更新前“顺便”把 version 改一下因为 Hibernate 更新时会拿实体当前持有的 version 去拼 UPDATE 条件你一旦手贱改了它要么造成无意义的冲突要么绕过真正的版本校验。3. 版本号机制在 SQL 层面是如何拦截并发冲突的3.1 一条 UPDATE 语句里的三重条件要理解乐观锁为什么可靠得把 Hibernate 生成的 SQL 拿出来看。开启 hibernate.show_sqltrue 后你更新一个带有 Version 的实体时实际发出的 SQL 长这样update t_account set balance ?, version 1 where id ? and version 0这条语句里藏着三个关键点第一set 部分除了业务字段外还有 version 旧版本1版本递增和业务字段修改在同一个原子操作里完成第二where 部分除了主键 id还额外带了 version 0也就是当前实体持有的版本号第三整个“检查版本 更新版本”是在数据库行锁的保护下完成的不会被两个事务穿插执行。为什么这样就能防并发假设事务 A 和事务 B 同时读到 version0。A 先提交成功把 version 改成 1。B 再提交时它的 SQL 还是 update ... where id? and version0但数据库里这行 version 已经是 1条件匹配不上影响行数为 0。Hibernate 发现预期更新 1 行实际更新 0 行就知道有别人动过数据于是抛出乐观锁冲突异常。这个设计本质上就是把“版本检查”和“版本递增”压进了同一条 UPDATE既不需要额外加锁也不需要事务隔离级别帮忙数据库自身的行锁和 UPDATE 原子性就保证了判断不会被并发钻空子。这也解释了为什么一个 Version 注解就能解决丢失更新因为它让检查与更新天然不可分割。3.2 后提交者会收到什么异常当影响行数为 0 时Hibernate 会抛出 org.hibernate.StaleObjectStateException。如果你是 Spring 环境Spring 通常会把异常翻译成 ObjectOptimisticLockingFailureException。不管哪个异常语义都一样你手上这版数据已经过期请重新查询最新数据再处理。这里有个很实用的细节DELETE 操作同样会被乐观锁保护。Hibernate 对带 Version 的实体执行删除时生成的 DELETE 语句也会携带 version 条件delete from t_account where id ? and version 2如果删除的版本对不上同样抛冲突异常。这能防止“用户看到旧页面时删掉了一条别人刚刚改过数据的记录”这种操作。很多开发只知道 update 会被保护不知道 delete 也有算是容易被忽视的隐藏收益。3.3 为什么 Hibernate 推荐版本号而非时间戳Version 类型虽然支持 Timestamp但实际生产环境我强烈建议用 Integer/Long。原因有两个。第一是时间精度导致的漏检风险。如果版本列是 Timestamp两个事务在同一毫秒内先后提交数据库里生成的时间戳可能相同后提交的那个事务 update 时拿到的旧时间戳可能跟当前时间戳一致冲突就被漏过去了。虽然某些数据库支持纳秒精度但跨数据库、跨配置之后你根本不敢保证精度达标用整型递增就没有这个顾虑每成功提交一次版本号就确定性地加 1不可能出现两个版本相等的情况。第二是排查问题不直观。版本号是 0、1、2、3一眼能看出这条记录被改过多少次出问题时对账非常方便。时间戳则要每次都换算时间而且不同数据库对时间戳的存储格式有差异日志排查时多一道换算成本。所以结论很直接默认选 Integer版本量特别大选 Long时间戳方案留给特殊情况。4. 实操全过程搭环境、写 Demo、验证并发冲突4.1 把环境和依赖准备好为了让你直接复现我给出一个最简 Maven Hibernate 6 的依赖组合注意这里用的是 jakarta.persistence 命名空间dependency groupIdorg.hibernate.orm/groupId artifactIdhibernate-core/artifactId version6.4.0.Final/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.3.0/version /dependencyhibernate.cfg.xml 里的配置就不完整贴了核心是这几项property namehibernate.connection.driver_classcom.mysql.cj.jdbc.Driver/property property namehibernate.connection.urljdbc:mysql://localhost:3306/testdb/property property namehibernate.connection.usernameroot/property property namehibernate.connection.passwordyourpassword/property property namehibernate.hbm2ddl.autoupdate/property property namehibernate.show_sqltrue/property然后准备一张表 t_account最简单的结构就是三列id 主键自增、balance DECIMAL(10,2)、version INT NOT NULL DEFAULT 0插入一条测试数据 id1、balance100、version0。4.2 写一个能稳定触发冲突的 Demo关键点是让两个事务在“读取”之后、“提交”之前都停顿一下保证双方拿到的都是同一个 version0。我用一个在两个线程之间放行的手动闸门来实现这样冲突触发非常稳定。public class OptimisticLockDemo { public static void main(String[] args) throws Exception { AtomicInteger versionSnapshot new AtomicInteger(); CountDownLatch readDone new CountDownLatch(2); CountDownLatch go new CountDownLatch(1); Runnable task () - { try (Session session HibernateUtil.getSessionFactory().openSession()) { Transaction tx session.beginTransaction(); Account account session.get(Account.class, 1L); if (versionSnapshot.get() 0) { versionSnapshot.set(account.getVersion()); } account.setBalance(account.getBalance().add(new BigDecimal(10))); readDone.countDown(); go.await(); // 两个线程都读完再一起提交 tx.commit(); } catch (Exception e) { // 乐观锁冲突就在这里抛出来 e.printStackTrace(); } }; Thread t1 new Thread(task); Thread t2 new Thread(task); t1.start(); t2.start(); readDone.await(); go.countDown(); t1.join(); t2.join(); } }实际跑起来后日志里会看到两条几乎一样的 UPDATE 语句update t_account set balance 110.00, version 1 where id 1 and version 0 update t_account set balance 110.00, version 1 where id 1 and version 0第一条成功影响行数为 1第二条执行时数据库里 version 已经是 1影响行数为 0紧接着抛出 StaleObjectStateException。4.3 实测结果解读谁赢了谁输了我实际跑了几次行为完全一致总有一个线程提交成功另一个线程拿冲突异常。数据库里 balance 最终是 110而 version 从 0 变成了 1没有出现“两个线程都提交成功变成 120”的覆盖场景。这正是乐观锁要的效果——宁可让一个请求失败也不能让数据被默默覆盖。你可能会问失败的那个请求怎么办这属于业务层兜底范畴可以捕获异常后重新查询最新数据、合并业务操作重试也可以直接返回前端提示“数据已被他人更新请刷新后重试”。千万不要做无条件的自动重试因为如果冲突源是同一个用户反复改同一条数据无限重试只会放大压力而且重试前不重新读取也是白搭。如果你把代码里的 go.await() 去掉两个线程大概率会错开提交冲突就不一定触发这也是很多同学“照着配置了乐观锁但测不出效果”的常见原因——并发窗口太小两个事务根本没有同时基于旧版本操作。5. 常见问题与排查技巧实录5.1 更新 SQL 里竟然没有 version 条件这是最让人抓狂的一种情况Version 明明加了show_sql 里 UPDATE 语句却干干净净只有 where id ?。碰到这个问题优先检查实体类是不是被 Hibernate 正确扫描到了。如果是 Spring Boot JPA实体类所在包没被 EntityScan 扫到那 Hibernate 走的根本不是带注解的映射而是反射出另一套默认映射Version 自然失效。另一个容易被忽略的原因是你用了 bulk update。比如通过 JPQL 执行 update Account set balance ? where id ? 然后用 executeUpdate()这种批量更新完全绕过了实体的生命周期Hibernate 不会自动拼接 version 条件。此时乐观锁形同虚设必须手动在语句里写 version version 1或者改用先查实体再修改的方式。还有一类隐蔽问题出现在 merge() 上。你把一个脱管实体 merge 回 Persistence Context 时Hibernate 会先去数据库加载最新状态再用传入实体覆盖。如果传入实体的 version 比数据库旧它会直接抛 ObjectOptimisticLockingFailureException如果传入实体的 version 是 null合并行为就变得不可控很多场景下表现为“明知不该冲突却总是冲突”。5.2 高并发下反而出现版本重复更新如果你把版本字段设成了 Timestamp高并发下可能会出现“两个事务都成功”的诡异现象。根本原因就是我前面提到的时间精度问题同一毫秒内两个事务提交数据库给两次更新生成了相同的时间戳后一个事务的 where 条件 version 旧时间戳 依然能匹配上于是版本检查被绕过去。排查方法也很简单把 version 列的值打印出来看看是不是出现了重复值如果是直接改成 Integer/Long 递增方案这个问题就根治了。在代码审查环节看到有人把 Version 声明成 {link Date} 或 {link Timestamp}我一般都会多问一句业务里并发量大不大量大的直接建议改类型。还有一个高频误区是把版本号暴露给前端后没做校验。很多管理系统会把 version 作为 hidden field 带到页面提交时原样传回来。这本身没毛病但如果你在后台把传入的 version 直接 set 到实体上而前端又因为某些缓存逻辑传了一个旧版本就会误报冲突。稳妥的做法是后端只把前端传来的 version 当成“参与比较的期望值”不直接覆盖实体上管理着的版本属性如果不确定前端版本可靠就在更新前按 id 重新查询一次最新实体把最新 entity 的版本作为 UPDATE 比较条件。5.3 关于乐观锁的配置与使用心得下面这些经验是我在项目里踩过几次坑之后沉淀下来的场景建议新项目选版本类型一律 Integer避免时间精度问题已有表的迁移给表加 version 列时务必设默认值 0否则存量数据为 null 时更新会出问题与其他系统对接对方只传业务数据不带 version我方不做乐观锁强校验否则接口会天天报冲突多实例部署不用担心乐观锁的版本判断发生在数据库 SQL 层多实例下同样有效二级缓存开启实体假如启用了二级缓存冲突后要谨慎必要时清缓存或对热点表禁用缓存另外批量导入时不要偷懒。如果导入工具是直接拼 SQL 插数据版本列可能被数据库默认值填充为 0这没问题但如果导入工具走的是实体 persist而且实体从 Excel 解析时 version 字段被赋了 nullHibernate 反而会认为 version 是 0也没问题。唯一需要警惕的是你手动给导入实体 setVersion 了一个统一值这会让不同行的初始版本都相同后续各自更新时竞争关系会被集中放大。线上排查乐观锁问题时我习惯先开 show_sql 把 UPDATE 语句打出来看 where 里有没有 version 条件再看提交顺序和影响行数。代码层面再确认实体有没有被正确扫描、有没有走 bulk update、有没有用 merge 传了一个版本号异常的脱管对象。绝大多数问题都逃不出这三个方向。6. 实际工程里乐观锁的落地经验6.1 典型业务场景怎么用订单、库存、余额账户余额这种场景是最典型的乐观锁使用场景。每次扣款、加款都让 version1任何一笔基于旧余额的计算全部失败从源头杜绝了金额错乱。订单状态变更同样适合用户在下单页面看到了“待支付”管理员在后台把它改成了“已取消”用户这时再提交支付操作乐观锁直接报冲突防止了把钱收进已取消的订单里。库存扣减就要谨慎一点。如果秒杀场景下所有请求都读同一个商品库存冲突会非常频繁乐观锁的重试成本反而不如一个 FOR UPDATE 干脆但如果库存请求量不大乐观锁完全够用而且不会把数据库连接长时间压住。我个人的经验是是否用乐观锁不完全看业务技术选型还看冲突概率和重试成本。冲突概率小于 5%、重试成本低就放心用乐观锁冲突概率高、每次重试都要重新查一堆关联数据那宁愿加悲观锁或者用 Redis 分布式锁先挡一层。6.2 失败之后的业务兜底比配置本身更重要配置乐观锁只是一小步真正决定项目质量的是冲突发生后的处理策略。我的建议是定义一个统一的业务异常比如 OptimisticLockException 映射成“数据已被其他人修改请刷新后重试”然后在 Controller 层统一捕获返回明确的提示文案而不是让用户看到一堆堆栈。重试逻辑要设计得克制一点。比较稳妥的做法是捕获冲突后最多自动重试 1 到 2 次每次重试前重新加载实体并重新执行业务计算如果重试还失败直接返回提示让用户手动操作。为什么不能一直重试因为如果冲突源头是别的请求不释放资源你的重试永远不会成功只会白白消耗数据库资源和用户耐心。另一个容易忽略的兜底是消息推送、异步任务里的并发。假如两个定时任务同时处理同一笔订单乐观锁冲突后任务可能静默失败表面上日志只有一条 StaleObjectStateException但业务结果已经偏了。这种场景我习惯在捕获异常后记录重试队列安排补偿重试并打上明显的 WARN 日志方便值班同学一眼看出是并发冲突而不是逻辑 Bug。6.3 与其他并发方案的配合与取舍乐观锁和数据库悲观锁并不互斥可以按需混用。比如查询阶段用普通查询更新阶段如果发现冲突频繁就降级成 SELECT FOR UPDATE 重试。Hibernate 6 里提供的 OptimisticLocking(type OptimisticLockType.DIRTY) 就是一种更灵活的乐观锁变体它不靠 version 字段而是把被修改的字段旧值作为 WHERE 条件适合某些无法加版本列的遗留表。不过它的缺点是 WHERE 条件里的字段一多性能损耗和索引利用率都会打折扣所以只在特殊场景下用。从更宏观的角度看你会发现配置乐观锁的核心其实不是“学会写 Version”而是理解“版本条件必须和更新操作绑在同一个原子 SQL 里”这条原则。理解了这一点就算哪天你要脱离 Hibernate 手写 SQL也知道该怎么设计并发控制这就是这一篇真正希望带给你的收获。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑