
写这篇之前我先说个背景。Hibernate这个系列前面聊了不少基础功夫这次讲乐观锁配置。很多兄弟一听到“乐观锁”第一反应就是“加个 Version 不就行了”真到线上出问题版本号不更新、批量更新绕过检查、异常类型 catch 不到一个比一个扎心。这篇文章不是简单贴注解而是把乐观锁从原理到配置、从客户端锁模式到生产环境排障整个链路都过一遍无论你是直接用 Hibernate 原生 API还是 Spring Data JPA 封装的仓库接口只要底层是 Hibernate这篇内容都适用。1. 乐观锁的定位与使用场景1.1 并发控制的两条路线做后端开发的朋友都清楚多用户同时改同一行数据是个绕不开的问题。两个请求同时读到库存量 100一个要改成 90另一个要改成 80如果都不做控制最终结果取决于谁后写另一个人的操作就白干了这就是典型的“丢失更新”。常见的处理思路无非两条。一条是悲观锁直接对数据库里那行记录加锁SELECT ... FOR UPDATE别人想读都读不了直到你提交事务释放锁。另一条就是乐观锁它不锁数据库资源而是借助一个版本号或者时间戳在提交更新的时候检查一下“我手上的数据还是不是最新的”。这两种方案对应了完全不同的并发场景悲观锁适合写多读少、冲突严重的业务乐观锁适合读多写少、冲突不频繁的业务。乐观锁本质上是 CAS 思想在数据库层面的落地——Compare And Swap先比较后交换。流程很朴素更新之前检查版本号版本一致才更新并递增版本号版本不一致就直接拒绝操作。这里用一个生活化的例子你去银行柜台取款大堂经理会先看存折上最后打印的那行余额对照系统里的最新余额一致才给你办业务办完顺手把新的余额和流水刷在存折上。如果中间有人已经取过一笔系统余额已经变了你再拿着旧存折去办经理就发现对不上让你先补登再办。这个“存折余额”就是实体的版本号。1.2 为什么我推荐你在 Hibernate 里优先用乐观锁现在有个热搜词叫“hibernate还有人用吗”每次刷到都觉得很有意思。Spring Data JPA 确实把开发体验做得很舒服但是 JPA 规范下面跑的还是 Hibernate 那一套尤其到了并发控制这种底层问题上你绕不开它。老项目在维护、新项目在选型只要还在 Java 生态里做关系型数据库持久层Hibernate 的这套机制就依然值得吃透。我在实际项目里优先推荐乐观锁理由很简单悲观锁持有数据库锁的时间太长高并发下会把数据库连接池和行锁队列全部拖垮。一个事务里如果还有远程调用、消息发送甚至报表计算整个过程都握着那行锁别的请求全堵在锁等待上吞吐量直接往下掉。乐观锁的好处是读操作完全无锁只在提交更新那一刻做一次版本比对和递增绝大多数场景下开销可以忽略不计。当然乐观锁也不是银弹它有个天然短板——冲突多了之后大量请求会做无用功读到旧版本提交时报错然后重新加载数据再重试。如果业务本身是秒杀抢购、热点账户扣减这种写非常密集的场景乐观锁注定会演变成重试风暴这时候老老实实回去用悲观锁或者上分布式锁别硬扛。乐观锁最适合的场景是一条数据经常被读偶尔被改改的时候又不想让并发写互相覆盖。订单状态流转、商品基础资料维护、配置管理这类业务用它都特别合适。2. 乐观锁配置的三种方式拆解2.1 注解方式Version 一把梭先讲最主流的方式也就是 JPA 标准注解。给实体增加一个版本字段然后在这个字段上加上VersionHibernate 就会自动接管它的读取和递增逻辑。Entity Table(name t_stock) public class Stock { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name product_name, nullable false) private String productName; Column(name quantity, nullable false) private Integer quantity; Version Column(name version, nullable false) private Integer version; // getter 和 setter 省略 }关键点来了这个version字段你不用也不能手动去赋值Hibernate 会在实体首次持久化时把初始值写到数据库通常是 0 或 1之后每次更新都会自动执行“版本比对 版本递增”。具体到 SQL 层面更新语句会变成这样update t_stock set product_name ?, quantity ?, version ? where id ? and version ?where子句里带上旧版本号set子句里把版本号加一。如果执行后受影响行数是 0说明数据在你读取之后被别的会话改过了Hibernate 会直接抛异常把这个操作拦下来而不是默默覆盖。用Version有几个硬性要求要记牢版本字段必须是实体的基本类型属性不能是集合类型数字类型或者时间戳类型都可以用但后面会细说类型选择的坑字段不能标注updatable false否则 Hibernate 会认为这个列不允许更新版本机制直接失效。2.2 XML 映射文件方式老项目的标配虽然注解已经是主流但我敢打赌现在还有不少跑了好多年的老系统用着 XML 映射文件尤其是那些从 Hibernate 3.x 时代一路升级上来的项目。XML 方式的配置同样不复杂在class节点内部声明一个version子节点即可。class namecom.demo.entity.Stock tablet_stock id nameid columnid typelong generator classidentity/ /id property nameproductName columnproduct_name typestring/ property namequantity columnquantity typeinteger/ version nameversion columnversion typeinteger/ /class这里有个容易搞混的点version不是property即使它们都映射同一个列也不能用property来声明版本字段。用property声明的话Hibernate 只当它是普通业务字段不会做版本递增和并发检查。XML 方式下type属性同样可以写integer、long、timestamp等机制和注解完全一致。如果你还在维护老项目建议顺手检查一下映射文件里有没有把版本字段错写成property这个错误通常不会导致启动失败但会让乐观锁在不知不觉中失效。排查方法也简单打开 SQL 日志看更新语句where子句里没有version条件基本就是配置错了。2.3 版本字段类型选择与隐藏的坑版本字段选什么类型看起来是个小问题实际踩过坑的人才知道它能有多疼。JPA 规范允许的数字类型有short、int、long以及对应的包装类时间类型支持java.sql.TimestampHibernate 5.2 之后还支持java.time.Instant。我的建议非常直接能用Integer或Long就别用时间戳。时间戳做版本有个老生常谈的问题——精度。早期 MySQL 的TIMESTAMP类型默认只有秒级精度两个事务在同一秒内先后提交拿到的版本值完全一样版本比对就形同虚设。MySQL 5.6.4 之后虽然支持了DATETIME(6)微秒精度但前提是你建表时写对了而且数据库驱动、方言配置也得跟上。为了省这点麻烦不如直接上数字类型自增语义清晰排查问题也直观。还有个隐藏坑是初始化值。如果表里已经存在存量数据新增version列时一定要给默认值比如ALTER TABLE t_stock ADD COLUMN version INT NOT NULL DEFAULT 0;。如果列允许为空老记录的version全是NULLHibernate 在做版本比对时就会出各种莫名其妙的问题。我就见过有同事在测试环境表结构没处理好更新接口报错排查半天最后发现是version列存在NULL值。3. 客户端锁模式与冲突处理配置3.1 标准注解之外的 Hibernate 原生乐观锁配置Version是 JPA 规范的能力但 Hibernate 自己还提供了一个更细粒度的原生注解OptimisticLocking它允许你控制“到底比对哪些字段”。这个注解在标准 JPA 下不生效必须用 Hibernate 原生注解直接放在实体类上。Entity DynamicUpdate org.hibernate.annotations.OptimisticLocking(type OptimisticLockType.DIRTY) public class Stock { // 字段省略 }OptimisticLockType有四个取值含义差别很大类型作用适用场景VERSION只比对版本字段默认行为大部分业务场景ALL比对实体的所有字段表里没有版本列但需要防覆盖DIRTY只比对本次修改过的字段实体字段多希望减少冲突概率NONE不做任何乐观锁检查明确知道不需要并发保护ALL和DIRTY这两个模式有一个前提就是实体对应的表里没有version列。Hibernate 会把where条件扩展到所有字段比如一个Stock改了quantity更新 SQL 就会变成where id? and product_name? and quantity?。第一次见这个 SQL 的时候你可能觉得别扭但它确实能防住“读到的数据被整体改过”的场景。这里必须提醒一下DIRTY模式有个隐含风险。如果两个并发请求改的是同一个实体的不同字段DIRTY模式不会判定为冲突因为更新的字段集合不同。这在某些业务里可能是好事能减少无谓重试但在另一些业务里就会出问题——两个请求改不同字段都认为自己是成功的最终语义上还是交错覆盖了。用之前一定要想清楚业务到底需不需要字段级隔离。3.2 通过 LockMode 手动控制乐观锁行为除了加注解被动防御Hibernate 还允许你在代码里手动指定锁模式。常见的是LockModeHibernate 原生 API和LockModeTypeJPA API中的两个乐观锁枚举值OPTIMISTIC和OPTIMISTIC_FORCE_INCREMENT。OPTIMISTIC是普通乐观锁含义是“在事务提交前检查实体版本”。它适合那种“读取数据、长事务处理、最后提交时确认没人改过”的场景。代码这样写Transactional public void updateStock(Long id, Integer newQuantity) { Stock stock entityManager.find(Stock.class, id); // 事务操作中途显式声明这个实体需要乐观锁校验 entityManager.lock(stock, LockModeType.OPTIMISTIC); // 后续业务处理 stock.setQuantity(newQuantity); }OPTIMISTIC_FORCE_INCREMENT则更激进它在锁定成功后立即递增版本号不管当前实体有没有被修改它的用途之一是“防止父实体被子实体更新连带着丢更新”。举个例子订单主表和订单明细表你只改了明细想让订单主表的版本号也跟着变这样别的会话在改主表时能感知到“订单被碰过了”这时候就可以在锁定订单时用OPTIMISTIC_FORCE_INCREMENT。Transactional public void addOrderItem(Long orderId, Item item) { Order order entityManager.find(Order.class, orderId); // 强制订单版本递增确保并发修改能被感知 entityManager.lock(order, LockModeType.OPTIMISTIC_FORCE_INCREMENT); order.getItems().add(item); }3.3 冲突发生后怎么处理重试与提示乐观锁配置好了异常处理更不能省。JPA 标准下抛的是javax.persistence.OptimisticLockExceptionHibernate 原生 API 下抛的往往是org.hibernate.StaleObjectStateException而后者有时候会被包装成前者。写catch的时候建议两个都抓住或者直接捕获基类避免漏掉原生 API 场景导致异常裸奔。try { tx.commit(); } catch (StaleObjectStateException e) { tx.rollback(); // 记录日志用户ID、实体ID、版本号、冲突原因 throw new BizException(数据已被其他用户修改请刷新后再操作); }捕获异常之后业务上通常有两条路。一是直接给用户一个友好的提示让他刷新页面重新操作适合后台管理这类低频编辑场景。二是自动重试适合接口调用方不方便手动处理的场景。重试逻辑要控制次数和频率我的经验是最大次数不超过 3 次中间加一点随机退避避免多个失败请求重试时再次扎堆撞车。for (int i 0; i 3; i) { try { return doUpdate(id, newQuantity); } catch (OptimisticLockException e) { // 重新读取最新数据再次尝试 } } throw new BizException(系统繁忙请稍后重试);重试之前必须先重新查询实体拿到最新版本号不能拿着旧对象再 update 一次否则必然再次失败进入死循环。4. 实战验证把乐观锁完整跑起来4.1 准备表结构和工程依赖理论说了这么多还是得跑起来看效果。我用一个库存表做演示推荐你也照着在本地环境试一次感受会完全不一样。CREATE TABLE t_stock ( id BIGINT AUTO_INCREMENT PRIMARY KEY, product_name VARCHAR(50) NOT NULL, quantity INT NOT NULL, version INT NOT NULL DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;如果项目用的是 Maven依赖可以这样配。为了方便演示我直接用 Hibernate 原生 API不走 Spring Boot 的封装这样你能看清底层到底发生了什么。dependency groupIdorg.hibernate.orm/groupId artifactIdhibernate-core/artifactId version6.5.2.Final/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency4.2 模拟两个并发会话的更新流程核心实验思路很简单开两个Session都读取同一个Stock然后第一个事务先提交第二个事务再提交观察第二个事务会不会被拦截。写这段测试代码时最好在两个事务提交之间加个Thread.sleep确保第一个事务真正落库了再操作第二个。// 会话1读取并更新 Session session1 sessionFactory.openSession(); Transaction tx1 session1.beginTransaction(); Stock stock1 session1.get(Stock.class, 1L); System.out.println(会话1读到的版本: stock1.getVersion()); stock1.setQuantity(90); tx1.commit(); session1.close(); // 会话2在会话1提交后再提交模拟并发冲突 Session session2 sessionFactory.openSession(); Transaction tx2 session2.beginTransaction(); Stock stock2 session2.get(Stock.class, 1L); System.out.println(会话2读到的版本: stock2.getVersion()); stock2.setQuantity(80); try { tx2.commit(); System.out.println(会话2提交成功); } catch (StaleObjectStateException e) { System.out.println(会话2提交失败: e.getMessage()); tx2.rollback(); } finally { session2.close(); }跑这段代码你会看到控制台输出类似下面这样的结果会话1读到的版本: 0 会话2读到的版本: 0 会话1提交成功 会话2提交失败: Row was updated or deleted by another transaction这个实验结果已经说明问题会话 2 虽然读到的版本也是 0但它提交时 Hibernate 发现数据库里的版本已经变成了 1于是直接拒绝更新。你要是没用乐观锁会话 2 的quantity80会直接把会话 1 的quantity90覆盖掉数据最终停留在 80丢了 10 个库存的变更。4.3 从 SQL 日志看 Hibernate 怎么帮你比对版本光看结果还不够把hibernate.show_sql打开你会看到 Hibernate 实际执行的 SQL 长什么样。配置很简单在hibernate.cfg.xml里加一行property namehibernate.show_sqltrue/property property namehibernate.format_sqltrue/property会话 1 提交时Hibernate 生成的更新语句大概是这样的update t_stock set product_name 蓝牙耳机, quantity 90, version 1 where id 1 and version 0这条语句执行成功因为数据库里的version确实是 0。会话 2 提交时生成的更新语句几乎一模一样唯一的差别是quantity和version的值不同update t_stock set product_name 蓝牙耳机, quantity 80, version 1 where id 1 and version 0但这次执行后受影响的行数是 0因为此刻数据库里的version已经是 1where version 0匹配不到任何记录。Hibernate 执行更新后会用受影响行数做校验发现 0 行就知道自己手里的版本是过期数据马上抛异常终止事务。这就是乐观锁在底层最核心的机制——不是 Hibernate 主动查了一次版本号而是通过更新语句受影响的记录数来判断的。这里顺带讲一个很多人会忽略的细节事务提交前 Hibernate 默认会自动flush所以你在代码里没有手动调用任何方法提交时也能看到这条 SQL。如果你用的是 JPA 的EntityManager机制完全一样只是在异常类型上会表现为OptimisticLockException。5. 常见问题排查与避坑实录5.1 版本字段不更新多半是配置没生效线上遇到“我明明加了 Version为什么更新语句里没有版本条件”十有八九是下面这几个原因。第一字段上写了Column(updatable false)或者insertable false。这个配置会告诉 Hibernate“这列不由持久层管理”于是版本递增逻辑被静默跳过。第二自定义拦截器或事件监听器在onFlushDirty里修改了版本字段覆盖了 Hibernate 的默认行为。第三用了 JPA 的entityManager.merge()合并一个脱管实体而脱管实体的版本字段本身就是null也可能导致检查失效。排查思路很简单打开 SQL 日志重点看更新语句里有没有where ... version ?以及set ... version ?。没有的话实体上所有跟 version 相关的注解逐个检查一遍特别注意手滑把版本字段写成了普通Basic属性还忘了加Version。5.2 批量更新绕过了乐观锁怎么办这是个大坑。HQL 和 JPQL 的update语句默认走的是 Bulk Update 路径直接生成一条数据库级更新语句完全不会触发实体层面的版本检查和版本递增。换句话说你如果用Query的方式批量改了数据乐观锁在中间形同虚设。// 危险写法绕过版本检查 int rows session.createQuery(update Stock s set s.quantity :q where s.id :id) .setParameter(q, 100) .setParameter(id, 1L) .executeUpdate();Hibernate 面向这种需求提供了一个冷门但是好用的扩展语法在update后面加上versioned关键字就会强制批量更新参与版本管理和检查。int rows session.createQuery(update versioned Stock s set s.quantity :q where s.id :id) .setParameter(q, 100) .setParameter(id, 1L) .executeUpdate();注意这是 Hibernate 原生扩展语法JPQL 规范不认。而且它的语义是“把版本号也自动递增”并不能帮你拦截并发冲突——它只是让批量更新不丢失版本递增动作。如果你的批量更新需要完整的并发检查还是得老老实实把数据查出来逐条更新或者走数据库层面的事务补偿。5.3 级联操作与乐观锁的相互作用实体间有OneToMany、ManyToOne等关联关系时级联操作和版本检查之间经常出现误解。很多人以为级联更新子实体会自动检查父实体版本其实不一定要看你的锁模式和关联配置。比较有代表性的是删除场景对一个带Version字段的实体执行删除Hibernate 生成的删除语句也会带上版本条件。比如删除Stock时SQL 会是delete from t_stock where id? and version?。这样做的目的是防止“删一条已经被别人改过的记录”造成误删。第一次见到这个行为可能会觉得奇怪但这确实是有意设计的不要试图通过自定义SQLDelete把这个条件去掉除非你非常清楚自己在做什么。级联保存父实体和子实体时如果父实体版本递增了子实体的版本字段并不会跟着变化。这是正常的别去纠结。真正要注意的是父实体如果用OPTIMISTIC_FORCE_INCREMENT强制递增版本无论子实体改没改父实体都会发生变化这会引发额外的更新语句和锁竞争不适用的场景别乱用。5.4 数据库默认值、触发器与 version 字段的冲突最后说一个我最想吐槽的问题。有次排查一个线上更新频繁报错的功能最后发现是运维同学在数据库里加了个触发器每次更新t_stock都顺手把version字段加 1。看起来“很贴心”实际上直接摧毁了 Hibernate 的版本机制。为什么因为 Hibernate 更新流程是先执行update ... set version 1 where version 0然后用自己的版本比对和受影响行数逻辑判断结果。数据库触发器在 Hibernate 的 SQL 执行完之后又把version改成了 2Hibernate 认为版本是自己递增到 1 的但数据库幂等状态已经被破坏下一轮更新永远会基于错误的版本号进行比对冲突和异常就会反复出现。正确的做法是版本字段完全交给 Hibernate 管理数据库层面不做任何触发器、默认值逻辑DEFAULT 0这种静态默认值没问题但动态计算就免了。如果你接手了一个已经加了触发器的存量系统先把这个触发器搬走再对齐数据和程序里的版本号把每个实体的 version 修正到与数据库一致然后才能继续使用乐观锁。另外补充一个救命小技巧存量表初始化版本号时一定要保证全表所有记录的 version 非空且从同一个起点开始。我之前见过业务表几百万条数据新增 version 列时没注意历史数据默认值NULL和0混着来后来高并发更新时频繁出现StaleObjectStateException。这类问题排查起来非常痛苦因为错误只在特定记录上触发日志里看不出规律。最简单粗暴也是最好用的办法就是一条 SQL 把历史数据的版本全部刷成 0让所有实体站在同一起跑线上。乐观锁的配置说白了不难难的是理解它背后的机制和边界。Version加注解只是第一步版本字段类型选型、客户端锁模式选择、批量更新绕过问题、数据库触发器冲突这些才是生产环境真正的考验。我自己做过几个订单类系统最深刻的体会就是乐观锁一定要配合明确的冲突处理策略一起上线不管是重试还是提示总得有个兜底方案不然高并发一来异常日志铺天盖地业务方会一脸懵。最后再分享一个习惯每次配置完乐观锁我都先开 SQL 日志跑一遍并发用例亲眼看到where ... version ?出现再收工这个习惯帮我挡掉了至少七八次低级配置错误。