1. 问题背景:为什么突然聊乐观锁,Hibernate现在还值不值得学
先说个热词相关的话题。最近总有人问“Hibernate还有人用吗”,尤其是Spring Boot 3.x时代,大家默认JPA就是Spring Data JPA,而Spring Data JPA底层就是Hibernate。哪怕你换到MyBatis,只要做JPA规范相关的项目、或者接那些老系统,Hibernate依然是绕不开的默认实现。更关键的一点是,Hibernate的乐观锁机制反应的是整个持久层设计的通用思路,搞懂了它对理解MyBatis、Spring JDBC、乃至自己封装一套ORM都有帮助。
所以这个内容不是讲老古董,而是讲一种仍然在生产环境大量使用的并发控制方案。乐观锁本身解决的场景很具体:多个事务或者多个业务操作,同时对同一行数据做修改,如果不加控制,后提交的人会把先提交的人覆盖掉。Hibernate里的乐观锁功能,核心就是通过版本号、时间戳或者字段快照来避免这种丢失更新问题。本文适合正在用JPA/Hibernate做业务系统、对并发控制只有模糊概念、或者面试被问到“乐观锁和悲观锁区别”想搞得更通透的读者,我会从原理、代码、执行细节、踩坑经验四个方向把它拆干净。
2. Hibernate乐观锁的思路拆解:它到底在锁什么
2.1 乐观锁和悲观锁的本质差别
悲观锁的做法是“先锁后做”。数据库层面用SELECT ... FOR UPDATE,或者直接同步代码块,先把资源占住,别人等我做完了才能进来。这适合并发冲突概率极高的场景,但问题是锁等待和死锁风险永远伴随,吞吐量会下降。
乐观锁则是“先做后验”。业务读数据的时候拿一个版本标记,事务提交那一刻去比对当前数据库里的版本标记是不是还是原来那个。如果变了,说明你操作的这一段时间里别人已经改过这行数据,此时你的更新直接失败,由业务层决定是提示用户、重试还是丢弃。Hibernate默认并不是说每次都开启乐观锁,而是你可以在实体上声明一个@Version字段,Hibernate一旦检测到这个字段,就会把对应的更新语句自动升级成带版本条件的UPDATE。
需要注意的一点是,乐观锁本身并没有真锁。它是通过条件UPDATE来模拟“锁”的效果。比如:
UPDATE account SET balance = balance - 100, version = version + 1 WHERE id = 1 AND version = 3;这条SQL影响行数为0,说明version已经不是3了,也就是数据被其他事务改过。Hibernate发现影响行数为0就会抛OptimisticLockException。你要知道,Start Transaction、读取、计算、Update这条链路里,数据库层面最多存在一瞬间的锁,大部分时间资源都是开放的,并发度自然高。
2.2 Hibernate锁实现选择的三种方式
Hibernate实现乐观锁有几种具体做法,老版本里也提供了不同的配置选项,全局配置里有hibernate.dialect相关的lock_option,实体层面主要能看到这三种:
- 版本号机制:实体里加一个Integer或Long类型的version属性,每次update都+1。这是Hibernate最推荐的方式,因为整数类型在数据库里存储、索引、比较的成本都极低,不存在精度问题。JPA规范里对应的就是@Version注解。
- 时间戳机制:实体里加一个java.util.Date、java.sql.Timestamp类型的属性,每次update把时间更新为当前时间。这是很多老项目的做法,看起来直观,但坑非常明显——同一毫秒内的两个事务,如果依赖数据库的timestamp精度,很可能会得到相同值,导致版本比对失效。现代数据库的datetime精度一般到微秒甚至更低,但如果老库表结构是datetime(0),直接报废。
- 全部字段快照比对:Hibernate的旧版本配置里,如果开启optimistic-lock="all",生成UPDATE时where条件会包含所有持久化字段,拿到旧快照去拼条件。这种做法的好处是不用额外加字段,坏处是SQL极其冗长,而且任何一个无关字段被变更都会导致本次更新失败,误伤率很高。现在基本没人这样用,了解一下即可。
为什么业界最终都收敛到版本号?因为版本号的语义最干净,它只表达“这行数据被改过几次”,不依赖系统时钟,不受精度影响。字段快照会受无关更新干扰,时间戳受时钟和精度限制。版本号只要递增,就不会重复,而且在数据库层面做条件判断极其高效。
2.3 版本字段本身的数据库设计
版本号字段设计上,我见过不少人直接在实体里加一个Integer的@Version,但是数据库表里居然没有这个列,或者有列但默认值是null,这样的实现跑起来会出各种诡异问题。设计正确的做法是:
- 字段类型在数据库里用INT或BIGINT,不建议用TINYINT,并发量稍大就会溢出。
- 默认值必须为0,不能为null。Hibernate在update时会无条件在set部分写version = version + 1,如果读到null,生成出来的SQL直接报错或者版本比对失效。
- 如果是已存在的存量表,加这一列时要考虑全表默认值的回填,否则历史数据全部是null,第一次更新必然出问题。
这套设计不光Hibernate用,你以后在任何ORM框架里做乐观锁,核心逻辑都长这样:一个有默认值、非空、单调递增的字段,在UPDATE里同时作为set项和where条件。
3. 第一次配置乐观锁的正确姿势
3.1 实体类和注解的完整写法
常用的方式是JPA标准注解,即便你直接使用Hibernate原生API,JPA注解也能被完全识别。实体类大致长这样:
@Entity @Table(name = "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; // getter setter 省略 }这段代码里最核心的就是@Version这个注解。Hibernate一旦在实体上发现它,会在以下操作中自动加入版本控制逻辑:
- 新增保存时,插入记录的version值为0;
- 更新数据时,自动把SQL变成update ... set version = version + 1 where id = ? and version = 老版本;
- 如果更新所影响行数为0,立即抛出OptimisticLockException;
- 删除数据时同样会带上version条件。
这就是为什么你不需要自己在业务代码里写任何比对逻辑,只要版本字段声明正确,Hibernate的底层事件监听器就会自动处理。我不建议在实体里同时加多个@Version字段,Hibernate对这种场景会直接启动报错,提示Multiple version properties,一个实体只允许一个版本属性。
3.2 更新逻辑执行时,Hibernate到底干了什么
我们用一个具体的转账场景来说明执行流程,假设A账户要给B账户转100元。
第一步,业务开始,先读取两个账户实体,此时Hibernate管理的accountA.version是3,accountB.version是7。
第二步,业务减少accountA的余额,增加accountB的余额,这个过程中并没有任何update SQL发出,Hibernate只是在当前持久化上下文里标记这两个对象的属性为dirty。
第三步,事务提交。Hibernate做脏检查,flush。此时生成的SQL大致是:
update account set balance=?, version=3+1 where id=? and version=3如果这个where条件命中的行数为1,事务正常提交;如果影响行数为0,说明数据库里这行的version已经不是3,Hibernate会抛出OptimisticLockException,并且整个事务标记为rollback-only。注意这个rollback-only的状态,意味着你即使catch了异常,再去执行其他SQL或者提交事务,最终也会得到强制回滚的通知。关于这个坑,后面第5节单独讲。
从这个流程可以看到,Hibernate并不是在读取时就加锁,它相当于是业务代码无锁执行,提交时利用一条条件UPDATE做冲突检测。这也是乐观锁并发性能高的原因,冲突概率低的情况下,几乎不会产生锁等待。
3.3 不写@Version怎么手动拼条件
有些老项目代码里没有加@Version,但你看到update语句执行时带了一个status条件,其实那也是乐观锁思路,只是没用到Hibernate内置能力。假定你的业务有一个state字段,只允许从草稿状态改为生效状态,并且不允许并发修改,你可以这样做:
@Modifying @Query("update Document d set d.status='EFFECTIVE', d.version=d.version+1 " + "where d.id=?1 and d.status='DRAFT' and d.version=?2") int updateDocumentStatus(Long id, Integer oldVersion);这里返回值int就代表影响的行数,如果为0,就可以理解为乐观锁冲突——有人在你之前把它改成其他状态了。这是Hibernate之外的一种兼容手法,想绕开JPA的自动版本管理时可以用。
但我强烈不建议你手动维护version,因为这会导致事务隔离级别、缓存、flush模式各种组合下出现匪夷所思的并发问题,而Hibernate里只需要加一个注解,后面全部自动处理,没必要重复造轮子。
4. 配置细节和复杂场景实操
4.1 用原生Session配置和使用乐观锁
如果你不用Spring Data JPA,而是直接用Hibernate的Session操作,其实也是支持的,只是写法略有不同。老的Hibernate配置项叫做hibernate.dialect,乐观锁相关的全局配置在以前版本里有hibernate.lock.insert_lock、hibernate.lock.update_lock这些,但现代版本根本不需要你去配这些。
直接用原生Session的代码大致如下:
Session session = sessionFactory.openSession(); Transaction tx = session.beginTransaction(); Account account = session.get(Account.class, 1L); account.setBalance(account.getBalance().subtract(new BigDecimal("100"))); tx.commit(); session.close();这段代码只要你实体类上带了@Version,commit时Hibernate会自动走版本化更新。不需要你调用session.lock,也不需要在SQL里写任何condition。真正的锁操作比如LockMode.PESSIMISTIC_WRITE是另一种用法,乐观锁在Hibernate里基本是透明的。
当多个事务同时读取同一个Account对象时,Hibernate的Session缓存会保证各自拿到的是自己当前版本。提交顺序决定胜者:先提交的成功,后提交的抛OptimisticLockException。数据库的行锁在这个瞬间只是短暂保护行本身的更新,不承担业务层的冲突检测。
4.2 Spring Data JPA下的重试机制怎么设计
日常开发里,就算加了乐观锁,业务上还是需要给一个重试的可能性。比如外卖场景里用户连续点了两次“提交”,第一个请求成功,第二个请求如果直接返回“操作失败”,体验会很糟糕。合理设计是捕获OptimisticLockException后,重新加载实体或重放操作。
在Spring项目里,推荐的方式是用Spring Retry或者AOP来做。简单的写法可以是:
@Override @Transactional public OrderResult createOrder(Long orderId, OrderCreateCommand cmd) { try { Order order = orderRepository.findById(orderId).orElseThrow(); order.apply(cmd); orderRepository.save(order); return OrderResult.success(order); } catch (OptimisticLockException ex) { throw new RetryableOrderConflictException(orderId, ex); } }然后配合Retryable注解做有限次数重试,建议最多重试3次,每次间隔不要太长,因为乐观锁冲突本质是高频短操作,重试2到3次就能大概率成功。重试时要注意必须重新查询实体,不要用已经托管在persistence context里那个脱管对象。很多人踩的坑是catch里直接把同一个entity调save方法,但此时实体的version还是旧值,持久化上下文状态已经混乱,重试只会再次失败。
对于不适合重试的场景,比如财务扣款,就不能盲目重试,而是应该把冲突信息返回给调用方,让人工决定。乐观锁不解决业务重试策略的问题,它只是给你一个明确的冲突信号,策略还是要基于具体业务设计。
4.3 乐观锁和一级缓存、flush模式的关系
Hibernate默认的FlushMode是AUTO,事务提交时会自动flush,所以上面说的流程成立。但如果你的项目改过flush模式,比如改成COMMIT或者MANUAL,同样的代码行为会有微妙差异。
FlushMode.AUTO下,commit之前如果检测到实体属性和数据库不一致,会自动发送update。FlushMode.COMMIT则可能在某些先查询后修改的场景下延迟flush到真正执行查询或者commit的时候。如果后者配置不合理,你的UPDATE可能没有提交就开始下一个操作,导致乐观锁判断的时机偏移。
最可靠的排查方式是在日志里打开SQL输出,看UPDATE语句是否在正确的时间点出现version条件。Hibernate配置:
spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.use_sql_comments=true看到生成的update语句是update account set balance=?, version=? where id=? and version=?才算乐观锁真正生效。如果日志里只有update account set balance=? where id=?,那大概率是你实体类没有声明@Version,或者你使用的是native SQL。
5. 常见问题与排查方法
5.1 萌新最常见的几个异常
先说问题池里出现频率最高的一类异常,这里整理成表格方便查阅。
| 问题现象 | 典型原因 | 处理思路 |
|---|---|---|
| 抛OptimisticLockException且事务回滚 | 版本比对失败,数据已被其他事务修改 | 检查冲突策略,决定重试还是提示用户 |
| JPA启动报Multiple version properties | 实体上写了多个@Version字段 | 一个实体只能保留一个版本属性 |
| update语句没有version条件 | 实体没有加载@Version注解,或使用native SQL | 检查注解和调用方式 |
| 执行更新时version字段一直是null | 数据库列允许null或者insert语句未赋默认值 | 建表时指定not null default 0 |
| 使用BigDecimal做版本字段 | 版本字段类型设计不合理 | 换成Integer、Long或Timestamp |
| 首次插入数据成功,第二次更新立即失败 | version字段唯一或默认值缺失 | 检查数据库约束和实体映射 |
第二个常见问题是关于时间戳方案导致的误判。曾经有个老项目用Timestamp做版本字段,数据库column定义是datetime,精度只到秒,两个并发请求在同一秒内提交,时间戳一模一样,UPDATE条件直接命中两条记录的同值版本,结果后提交的人照样覆盖了前一个事务的修改。这种现象在测试环境里很难复现,一旦生产环境QPS上来就容易出现。改用Long类型版本号之后,再没发生过类似问题。
5.2 乐观锁和缓存组合时的坑
很多项目在Hibernate之上还会套一层Redis缓存或者Spring Cache。假设你把Account对象缓存到了Redis,直接把这个缓存对象反序列化后传给Hibernate做save操作,由于缓存对象里的version字段可能是几秒前的值,在提交的那一刻,条件比对自然失败。这不是乐观锁实现的问题,而是你绕过了Hibernate的持久化上下文,把脱管对象当成托管对象用。
正确的缓存设计思路是:缓存适合放只读的数据,比如商品详情、配置项、热榜排行。对于会被更新且需要乐观锁保护的数据,尽量直接走Hibernate查询,让版本字段在本次事务里保持最新值。如果实在要缓存,也应该缓存业务结果DTO,而不是缓存可更新的实体对象。
同时要注意二级缓存。Hibernate自带的二级缓存如果配置了read-write模式,并且缓存了带版本字段的实体,那么缓存的过期与失效策略必须匹配版本变化,否则从二级缓存读出来的实例可能带的是旧版本。我不建议在并发更新频繁的实体上开二级缓存,开了等于给自己埋雷。
5.3 关联级联更新场景下的版本失效问题
还有一个非常隐蔽的坑,就是级联保存(CascadeType.ALL)下的乐观锁失效。假设你有一个Order实体,它有一个List 子集合,Order上有@Version。这时你只修改OrderItem的属性,然后通过order保存级联更新,Hibernate可能只更新OrderItem列表,而不会把Order本身的version字段递增。原因是Order的字段并没有产生任何脏数据,Hibernate的脏检查粒度到属性层面的,而级联更新子表不影响父表自身属性。
这个场景下,如果两个事务同时往同一个Order里加不同的OrderItem,最后可能两个都成功,数据从业务逻辑上也能接受。但如果你希望Order的任何变化——包括子集合变化——都触发版本递增,就需要手动修改父实体上的某个字段来促使version更新,或者自己重写VersionPropertyListener。对这个需求,大多数开发者的建议是:不要把业务上的“整体已变化”强加给Hibernate的version机制,它只代表一行记录的字段变化;子表变更带来的父表版本变化,可以用数据库端触发器或者显示逻辑处理。
5.4 批量更新和乐观锁的矛盾
间歇性批处理任务,比如定时任务里对一批数据进行统一修改,这是乐观锁最容易爆发的地方。原因很简单,批处理加载实体后要花很长时间处理,处理完的瞬间再批量update,期间这条数据很可能已经被其他请求改了,版本冲突出现概率极高。
解决思路有两个。第一个是把批量的粒度缩小,每次加载数据后立刻处理立刻提交,不要让大量实体在持久化上下文里积压过久。第二个是对于确实批处理专用的数据,可以通过数据库端的UPDATE直接改,不走Hibernate实体更新,比如用JPQL里的@Modifying批量更新,这时候版本条件可以选择不生效。
@Modifying @Query("update Message m set m.status='SENT' where m.id in :ids") int batchMarkSent(@Param("ids") List<Long> ids);这样的批量操作天然绕过乐观锁,因为粒度层面你自己控制了冲突风险。不要试图把每个实体都加载再save,那样既慢又容易触发冲突。
6. 乐观锁之外,Hibernate还有哪些并发方案需要知道
Hibernate除了乐观锁,还提供了悲观锁支持。悲观锁的锁模式包含PESSIMISTIC_READ和PESSIMISTIC_WRITE,前者用于共享读,后者用于排他写。Repository层可以用@Lock注解:
@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select a from Account a where a.id = :id") Optional<Account> findByIdWithPessimisticLock(@Param("id") Long id);这段代码最终会在数据库层面生成SELECT ... FOR UPDATE,事务提交前其他事务无法更新这行数据。选择乐观锁还是悲观锁,直接看冲突概率和业务容忍度。像库存扣减这种“知道一定会冲突”的短线操作,悲观锁更直截了当;像文章点赞这种大部分时候都不会冲突的场景,乐观锁更合适,不然为了一个点赞操作持有行锁,纯属浪费数据库资源。
我也见过很多复杂的方案,比如用数据库的UPDATE先扣库存再查询,配合Redis计数器做预扣,最后用乐观锁兜底。这种组合模式在实践中确实能应付超高并发,但前提是你理解Hibernate乐观锁是在提交时机做检测,而不是在做计算时做检测,所有前置预扣逻辑需要考虑和事务边界的一致性。
7. 版本字段带来的额外收益:审计与数据诊断
聊点经验之外的东西。带@Version的字段还有个意外好处,就是在排查数据问题时非常有用。假如有人质疑某条数据被更新过,直接看version字段的增长次数就能判断这行记录经历了多少次成功的更新。而时间戳也可以辅助判断每次更新的时间。
生产环境里我习惯在建表时同时维护version和update_time两个字段,前者给Hibernate乐观锁使用,后者给业务审计使用。两者职责不同——version不关心具体时间,update_time不参与版本判断。如果你把update_time直接当版本字段,一旦业务上需要手工修正时间或者因为时钟同步产生毫秒级回退,就可能引发误判,而独立的version就没有这个风险。
这套字段设计在MyBatis里同样适用。无非是MyBatis需要你手动在update标签里写version = version + 1 where version = #{oldVersion},而Hibernate自动完成了这些事。理解了底层,换任何框架都一样。
8. 实际项目中的一点体会
拿我最近维护的一个订单服务来说,调单、支付回调、后台人工改价、对账补偿四个系统都可能在同一个订单行上做更新,如果没有乐观锁,光是“客服改完价格被支付回调覆盖”这一个问题就够喝一壶。上了Hibernate的@Version之后,冲突时写日志、做重试,冲突信息落到审计表里,线上基本就不再有静默丢失更新了。表面上是加了一个字段、一个注解,实际是给业务层面加了一层清晰可靠的准入控制。
最后补一句建议:如果你的项目还在用Hibernate 3或者4,升级到5.6+之后,锁相关的行为已经规范化了很多,旧的XML映射方式也不再推荐。对还在纠结“Hibernate还有没有人用”的读者,我更愿意说——框架轮换来来去去,但并发控制的这几个核心思想不会过时,你在这里花上两小时踩明白的坑,以后换任何ORM都省得再踩一遍。