Hibernate乐观锁实战:@Version配置、冲突处理与重试机制
1. 一次库存超卖先说清楚乐观锁到底拦的是哪一环我在之前的项目里遇到过这么一件事某电商系统的库存表两个用户几乎同时下单买同一件只剩1件的商品。两个请求都先查询库存发现还剩1件于是各自扣减库存各自创建订单。最后数据库里的库存写成了0但订单却生成了两笔。从业务报表上看商品超卖了。问题出在哪出在这段代码上Transactional public void createOrder(Long productId) { Product product productDao.findById(productId); if (product.getStock() 0) { throw new BusinessException(库存不足); } product.setStock(product.getStock() - 1); productDao.update(product); orderDao.insert(createOrder(productId)); }两个事务并发执行时事务A查到stock1事务B也查到stock1。A把stock改成0后提交B接着把它自己查到的那个stock值依然是1减一也提交成0。两个事务并没有互相覆盖字段但业务结果错了——这就是典型的“丢失更新”问题。乐观锁要解决的正是这种“读到了旧数据却拿着旧数据覆盖新数据”的并发写冲突。它的思路不像悲观锁那样“读之前先锁住行不让别人碰”而是“只管放开了读写的时候拿版本号对一对如果版本对不上说明这期间有人改过了那就放弃这次提交”。在Hibernate里配置乐观锁本质上就是给实体类加一个版本字段让Hibernate在更新语句的where条件里自动带上版本号做校验。接下来这套配置的三步走我会直接展开。2. 配置乐观锁的最小改动Version注解挂到实体类的哪里2.1 实体字段与数据库字段的对应Hibernate里最主流的乐观锁做法就是使用Version注解。以一个典型的商品实体为例Entity Table(name t_product) public class Product { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name product_name) private String productName; Column(name stock) private Integer stock; Version Column(name version) private Integer version; // getter / setter 省略 }对应的建表语句建议按下面的方式定义CREATE TABLE t_product ( id bigint NOT NULL AUTO_INCREMENT, product_name varchar(64) DEFAULT NULL, stock int DEFAULT NULL, version int NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里最核心的就是那个version int NOT NULL DEFAULT 0字段。为什么建议用Integer/Long而不用时间戳后面第5章我会专门讲这里先按整数处理。这里我多说一句配置细节Version可以放在字段上也可以放在属性的getter方法上。放在字段上是直接访问字段放在getter上是走属性访问。我个人的建议是跟项目里的Id放法保持一致不要一个实体类里一半字段访问、一半属性访问那样Hibernate在解析字节码增强时容易出一些很隐蔽的怪问题。配置完成之后Hibernate会自动接手version字段的全部读写。你完全不需要在业务代码里手动去setVersion()也不需要自己在更新SQL里写where version ?。这些事Hibernate会在生成SQL时自动加上。2.2 让Hibernate生成版本管理的SQL细节配置好Version后一个普通的更新操作Hibernate生成的SQL会从update t_product set product_name?, stock? where id?变成类似这样update t_product set product_name?, stock?, version? where id? and version?还是拿上面那个库存超卖的案例来说。事务A和事务B都读到version0。A先提交A执行的更新是update t_product set stock0, version1 where id100 and version0这条语句命中一行提交成功。B后来执行更新时它拿的还是自己事务里缓存的那个version0但此时数据库里这行的version已经是1了于是update t_product set stock0, version1 where id100 and version0这条语句实际影响行数为0。Hibernate一看到更新影响行数为0就会抛异常告诉你“有别的会话修改了这条数据”。这正是乐观锁的核心机制——用版本号作为where条件的一部分把并发冲突在校验阶段拦截下来。这里有一个很多人刚开始容易忽略的点乐观锁不像悲观锁那样在“读数据”的时候就把别人拦住它是到了“写数据”的那一刻才来做校验。所以两个事务可以同时读、同时做业务计算只有到提交更新时才会有一个失败。这个差异直接决定了一旦失败你不可能通过“等一等再查一次”来避免异常而必须设计重试或者让用户重新操作。3. 版本号会如何变化初始值、自增时机、绕过方式3.1 插入时的版本初值上面那张DDL表里version字段设置了NOT NULL DEFAULT 0这是数据库层面的保障。而在Hibernate层面当执行insert时Version字段会参与insert语句insert into t_product (product_name, stock, version) values (?, ?, 0)即使实体对象创建时没有给version赋过值Hibernate也会按0作为初始版本写入。这一点让version字段天然就是“从0开始每成功更新一次就加1”。这里值得注意的一个细节是数据库默认值和Hibernate插入的0两边是“双保险”关系。如果你在代码里手动给version赋一个非0值然后执行insertHibernate会尊重你赋的值把这个值作为初始版本。所以业务代码里不该碰version字段这一点不是“规范建议”而是“如果乱碰就会出并发事故”的硬约束。我见过一个真实翻车现场有人为了“记录修改次数”在更新数据前手动把version加1再调用save。结果version被手改之后Hibernate的乐观锁校验完全失去意义两个并发事务都能各自把version改成自己想要的值丢失更新照常发生而且异常还不报出来排查时看版本号又是乱的极其难受。3.2 更新时校验与自增的真实链路实体加载进持久化上下文后Hibernate在flush阶段会做一次“脏检查”把实体的当前状态和快照状态做对比。只要有任何字段发生变化就会生成一条update语句。这条update语句里set部分会包含version字段的“旧值1”where部分会包含id和version的“旧值”。接着执行器把SQL发到数据库数据库根据影响行数决定是否成功。如果影响行数为0Hibernate会抛出StaleObjectStateException随后在外层被转换为OptimisticLockException或Spring Data JPA里的ObjectOptimisticLockingFailureException。如果你手头有数据库慢查询日志或者开启了show_sql把两个并发事务的update都打印出来看就能很直观地看到后提交的那个事务where条件里带的version值已经对不上了。这个链路清楚了后面排查异常时才不会一脸懵。3.3 HQL批量更新与StatelessSession的绕过风险乐观锁不是“自动生效”的它只对entity state的变更路径生效。以下几个场景Version是管不了的需要你自己处理。第一个是HQL的update或delete语句也就是所谓的“bulk update”。entityManager.createQuery(update Product p set p.stock p.stock - 1 where p.id :id) .setParameter(id, productId) .executeUpdate();这种写法直接由HQL翻译成update SQL执行完全绕过了乐观锁的version校验。它不会帮你把version加1也不会在where里带version条件。如果你的业务里用了这种批量更新要么自己在SQL里拼where version ?并手动set version version 1要么干脆放弃批量更新改用实体加载加修改的方式让Version机制重新接管。第二个是StatelessSession。它没有持久化上下文、没有快照、没有级联操作用它做update就真的只是执行一条update SQL同样不会触发乐观锁校验。如果你的项目里用了StatelessSession做批量数据搬运需要自己补版本校验逻辑。第三个是一对多级联的场景。保存父实体并级联保存子实体时父实体和子实体各自的version会各自维护。如果两个并发请求同时给同一个订单追加不同的明细子实体的version会发生冲突父实体可能完全感知不到。这种情况不能只盯着“哪个实体有Version”而要分析清楚并发冲突到底发生在哪张表的哪一行上。4. 冲突不是靠“不报错”解决的异常捕获与重试设计4.1 异常是从哪一层抛出来的先确认一下异常体系。Hibernate内部抛的是org.hibernate.StaleObjectStateExceptionorg.hibernate.StaleStateException批量操作时在JPA规范里统一包装为javax.persistence.OptimisticLockException新版本包名改为jakarta.persistence.OptimisticLockException。如果你用的是Spring Data JPA通常还会看到Spring包装后的org.springframework.orm.ObjectOptimisticLockingFailureException这个时序很重要异常不是在你调用update/save的那一刻抛出来的而是在事务提交前的flush阶段抛出来的。换句话说你的service方法里执行完save时可能还没有异常要等事务提交到commit时才会爆出来。4.2 为什么直接在Service里catch没有用很多人踩过这个坑在service方法里try-catch了OptimisticLockException想打印个日志继续执行后面代码结果发现事务整体还是回滚了后面代码虽然没抛异常但数据根本没落库。原因在于事务边界。一旦flush阶段检测到版本冲突整个事务已经被标记为rollback-only。这时候哪怕你捕获异常、继续执行后续代码等到方法返回、代理开始commit时事务仍然会被强制回滚。有不少同事在这种场景下折腾了很长时间最后结论是乐观锁冲突的捕获位置必须在事务边界之外不能放到事务方法内部去处理。正确的处理方式是让冲突异常从service方法抛出由上一层比如Controller层、定时任务调度层、消息队列消费层去捕获然后决定要不要重试或如何反馈给用户。4.3 一个能落地的重试流程重试的核心是“重新读取最新版本的数据再基于最新数据重放业务操作”。注意不是简单地把同一个save再执行一遍——那样用的还是旧的version必然还是失败的。可以这样设计public OrderResult createOrderWithRetry(OrderCreateRequest request, int maxRetry) { for (int attempt 1; attempt maxRetry; attempt) { try { return createOrderInternal(request); } catch (ObjectOptimisticLockingFailureException ex) { if (attempt maxRetry) { throw new BusinessException(系统繁忙请稍后重试); } // 稍等片刻让并发写入完成 Thread.sleep(50L * attempt); } } throw new BusinessException(系统繁忙请稍后重试); }createOrderInternal还是那个基于实体的扣减库存逻辑。每次重试都会新开一个事务重新查询一次最新数据所以下一轮循环里拿到的version就是最新的。这里要注意的是重试不能无限进行一般3次以内就够了。如果连续3次都冲突说明这个商品正处于极高并发的写入中与其无限重试不如直接告诉用户“当前操作的人太多请重新确认后再试”避免系统被重试请求拖垮。对于前端用户遇到这类冲突时最有价值的反馈是“页面数据已经变化请刷新后重试”。这不是技术上的退让而是让用户感知到数据冲突的真实存在减少因“操作没生效”产生的投诉。在异步消息场景下如果消费者获取到乐观锁异常我建议用消息队列自带的重试机制把消息重新投递而不是在消费逻辑里做复杂的手写重试循环。道理是一样的重新投递相当于重新读最新数据、重新执行业务。5. 版本字段该选什么类型Integer、Long还是时间戳5.1 时间戳版本字段的暗坑Hibernate官方文档里Version支持的字段类型包括int、Integer、long、Long、short、Short以及java.sql.Timestamp。但不建议用Timestamp做版本号原因有三个。第一是精度问题。MySQL的timestamp在旧版本中秒级精度两个事务在同一秒内先后提交时间戳字段根本没有变化版本校验形同虚设。DATETIME(3)或DATETIME(6)能解决一部分问题但如果数据库和应用服务器的时区设置不一致写入的时间戳会有偏移版本号变得不可预期。第二是Hibernate的版本机制期望的是“每次更新必然产生一个不同的值”。整数型版本通过version 1保证严格递增而时间戳即使精度做到毫秒仍有可能因为并发落点相同而产生相同值。一旦相同值出现后提交者就有可能在where条件里匹配成功乐观锁被悄悄绕过。第三是从可读性角度版本号字段本身有业务价值——它能告诉你这条记录被改了多少次、厂里排障时看数据变化频率也很方便。时间戳只能告诉“最后一次改动时间”丢失掉“更新次数”这个信息。所以新项目里我统一劝大家用Integer或Long。5.2 字段由谁来维护才安全实战中version字段的安全边界有两层。第一层是数据库层字段设为NOT NULL DEFAULT 0不允许业务代码直接更新它。第二层是应用层实体类的version字段不对外提供修改入口即使提供了也只能由Hibernate在flush时写入。有一些团队会写一个“实体基类”把id和version统一放进去MappedSuperclass public abstract class BaseEntity { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Version private Integer version; // getter 只读setter 不允许外部调用 public Integer getVersion() { return version; } protected void setVersion(Integer version) { this.version version; } }子实体继承这个基类就能让全库所有表都带上乐观锁能力。对于中小型项目这个做法能明显降低“新增实体忘了加Version”的概率。不过要注意如果某些表是流水表比如操作日志、状态变更历史它们本身只会插入不会更新就不需要version字段没必要让它们继承这个基类。另外提醒一件事当你用DTO接收前端传值然后BeanUtils.copyProperties到实体时千万别把前端传来的version直接覆盖到实体上。前端传的版本号只能作为“复查参数”使用真正驱动更新的版本号应该以Hibernate从数据库加载出来的快照为准。这个错误相当隐蔽一旦中招轻则版本号错乱重则直接把并发保护搞失效。6. 乐观锁和悲观锁、分布式锁的选型边界6.1 三把锁的分工对比很多开发者对锁的理解比较笼统容易把乐观锁、悲观锁、分布式锁混为一谈。这里我列个对比表维度数据库乐观锁数据库悲观锁分布式锁实现位置应用层数据库version字段数据库行锁Redis/ZooKeeper等独立组件加锁时机提交更新时校验查询时立即锁行业务操作前主动获取冲突处理抛异常需要重试阻塞等待等同事务释放获取失败可轮询或快速失败适用场景读多写少、冲突概率低写冲突频繁、需要排队多实例部署、跨数据库对数据库压力每次更新多一次版本校验锁等待会拉长事务依赖中间件可用性乐观锁的优势是“不加锁、不阻塞”读操作完全不受影响数据库连接不会因为等待锁而被长时间占用。所以对于读多写少、并发冲突偶发的业务乐观锁是首选。悲观锁适合写冲突非常频繁的场景。比如两个人同时编辑同一条财务凭证几乎每次都会撞上用乐观锁的话会一直有人重试失败体验极差。这时候用SELECT ... FOR UPDATE让事务排队执行反而更顺。分布式锁解决的是另一个维度的问题多个应用实例共同操作同一个共享资源时需要一个跨数据库的互斥机制。比如定时任务在一个分布式部署集群里只能有一个实例执行单靠数据库乐观锁是管不住“多实例都去抢同一个job”这种问题的。此时要用分布式锁来保护“抢占执行权”这个动作。6.2 实际项目里的取舍建议结合我遇到过的业务形态可以给出几条比较实在的选型参考订单创建、库存扣减这类高频操作优先用乐观锁。因为大部分时候并发并不激烈乐观锁零开销即使偶尔冲突配合重试也足够。后台管理系统的同一条数据编辑比如同时在编辑一篇配置、一条商品信息直接用悲观锁更省心因为后台并发量不高锁等待带来的影响可忽略。跨库、跨服务的资源互斥别指望数据库乐观锁能全局生效上分布式锁。但要注意把分布式锁和乐观锁结合起来用分布式锁管“这个任务只能一个实例做”乐观锁管“同一实例内不要覆盖别人的修改”。优惠券秒杀这种瞬间流量极高的场景如果用一个数据库行做计数器乐观锁的重试代价很大。此时通常做法是异步化先写入Redis扣减额度再异步对账落库数据库层只做最终校验。乐观锁不是一个“配置上就一劳永逸”的功能。它真正解决的问题是防止覆盖写但业务上“数据是否最新”“操作是否允许重试”仍然需要你根据场景设计。版本字段的价值说穿了就是把并发冲突从“静默丢失”变成“显式报错”让系统有机会去处理也让人有机会去复盘。每次遇到并发相关的事故先问一句这里做的是高频写还是低频写能容忍排队还是必须快速失败版本号能不能扛住当前的并发量答案自然会指向合适的方案。

相关新闻

SpringBoot+Vue+MySQL健康打卡系统:从设计到部署全解析

SpringBoot+Vue+MySQL健康打卡系统:从设计到部署全解析

最近好几届毕业生都在找我聊毕设选题,问来问去,出镜率最高的还是那类“管理系统”。之前有个学生拿了个题目过来——SpringBootVueMySQL的疫情打卡健康评测系统,源码、数据库、论文、部署文档全都带。我当时就跟他讲,这套组合拳打…

2026/10/11 2:46:14 阅读更多 →
11条降AI味提示词:让AI写出像人话的正式文本

11条降AI味提示词:让AI写出像人话的正式文本

写这段话之前,我刚把一段AI生成的总结扔进回收站。那篇总结每个字都工整、每段都对称,连“综上所述”都用了三遍——你挑不出什么大毛病,但你就是能一眼认出来这是机器写的。做内容这行久了,“机械输出”四个字其实很好辨认&#…

2026/10/11 2:46:14 阅读更多 →
百度AI情感倾向分析实战:微博评论批量处理与避坑指南

百度AI情感倾向分析实战:微博评论批量处理与避坑指南

简介:调用百度云API并用Python对微博评论进行情感偏向分析,是一套面向数据分析入门者与开发者的完整参考资源。资源内含可直接演练的微博评论数据文件,并配有调用百度API获取情感得分的程序代码,以及将原始得分标准化、映射到统一…

2026/10/11 2:46:14 阅读更多 →

最新新闻

Buzz 离线语音转文字完整指南:本地 Whisper 转录与字幕生成

Buzz 离线语音转文字完整指南:本地 Whisper 转录与字幕生成

Buzz 离线语音转文字完整指南:本地 Whisper 转录与字幕生成 【免费下载链接】buzz Buzz transcribes and translates audio offline on your personal computer. Powered by OpenAIs Whisper. 项目地址: https://gitcode.com/GitHub_Trending/buz/buzz 给两小…

2026/10/11 5:02:30 阅读更多 →
Vibe Coding 实操复盘:60 分钟用 AI 写完 Excel 报表 + FastAPI 记账接口

Vibe Coding 实操复盘:60 分钟用 AI 写完 Excel 报表 + FastAPI 记账接口

本文记录我在 60 分钟限时考核中,用上述方法完成两个 Python 任务的完整过程。二、考题概览三、考题一:Excel 报表3.1 需求澄清(交给 AI 之前)我先把需求写成一段"契约":text复制下载输入:sales_…

2026/10/11 5:02:30 阅读更多 →
宁波大学渔业发展复试备考全攻略:排名跃升20+的实战经验

宁波大学渔业发展复试备考全攻略:排名跃升20+的实战经验

宁波大学渔业发展专业复试备考资料|上岸学长亲整理,复试排名跃升20名去年这个时候,我正蹲在宁波大学梅山校区复试候考区外面的台阶上,手心里全是汗。那年初试我的排名大概在四十名开外,而招生计划只有三十多个名额。说…

2026/10/11 5:02:30 阅读更多 →
国产CAD软件哪个上手快?应届生适配参考

国产CAD软件哪个上手快?应届生适配参考

刚接触CAD的新手,打开软件后常面对这样的场景:工具栏、命令行、图层管理器都在眼前,却不知道先点哪个;想画一条直线,找不到命令入口;看到别人的图纸里图层、标注、块井井有条,自己却不知道从何建…

2026/10/11 5:02:30 阅读更多 →
MindSpore并行训练Loss剧烈波动?梯度同步排查指南

MindSpore并行训练Loss剧烈波动?梯度同步排查指南

做MindSpore并行训练的小伙伴丢给我一段运行日志,日志里别的都正常,唯独Loss曲线刺眼:10个step里,从0.25抖到1.56,来回跳,就像踩了弹簧。这种波动绝对不是正常训练该有的样子——如果你也在MindSpore并行训…

2026/10/11 5:02:30 阅读更多 →
Java Web进阶之路:从Servlet原理到Spring Boot实践

Java Web进阶之路:从Servlet原理到Spring Boot实践

搞 Java Web 这个方向,很多人最大的误区就是一上来捧着 Spring Boot 啃,结果连 Servlet 是什么、请求是怎么走到 Controller 的都没搞明白。等真去面试或者接手一个老项目的时候,问一个“Cookie 和 Session 到底怎么回事”就直接卡壳&#xf…

2026/10/11 5:01:29 阅读更多 →

日新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/11 0:00:27 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/11 0:00:27 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/11 0:00:27 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 5:23:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/9 21:32:20 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 10:38:42 阅读更多 →