使用@OptimisticLock(excluded=false)后MySQL死锁日志问题排查
我正在开发基于Spring Boot、JPA和Hibernate、使用MySQL数据库的项目。近期对OneToMany集合映射重构,将父实体单向映射改为多端双向映射,以消除关联表并提升增改效率。父实体通过@Version字段实现乐观锁。
修改映射后发现,乐观锁不再将集合增删视为父实体变更,异步请求并发操作集合时出现数据不一致。给集合添加@OptimisticLock(excluded = false)注解后,恢复了集合编辑的锁定功能,但并发冲突时不再抛出ObjectOptimisticLockingFailureException,而是抛出CannotAcquireLockException。更新@Retryable注解包含该异常后,重试功能正常,但仍产生死锁错误日志:
WARN 19872 --- [demo] [nio-8080-exec-7] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 1213, SQLState: 40001 ERROR 19872 --- [demo] [nio-8080-exec-7] o.h.engine.jdbc.spi.SqlExceptionHelper : Deadlock found when trying to get lock; try restarting transaction
目前未发现数据不一致,但该日志会干扰真实错误排查,且使用H2内存数据库时无此问题。
我想了解这种方式使用@OptimisticLock(excluded=false)是否不被推荐,或是OneToMany映射存在问题,亦或是框架层面的原因?
相关代码
Holder 实体类
@Entity public class Holder { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Version private Long version; @OneToMany( fetch = FetchType.LAZY, mappedBy = "holder", cascade = CascadeType.ALL, orphanRemoval = true ) @OrderColumn(name = "pos") @OptimisticLock(excluded = false) @JsonIgnore private SortedSet<Element> elements; @OneToMany( fetch = FetchType.LAZY, cascade = CascadeType.ALL ) @OrderColumn(name = "pos") @JsonIgnore private SortedSet<ElementByParent> elementByParentSet; public void setId(Long id) { this.id = id; } public Long getId() { return id; } public SortedSet<Element> getElements() { return elements; } public Long getCurrentVersion() { return version; } public void addElement(Element element) { element.setPos(this.elements.size()); element.setHolder(this); elements.add(element); } public void addElementByParent(ElementByParent element) { element.setPos(this.elementByParentSet.size()); elementByParentSet.add(element); } }
Element 实体类
@Entity public class Element implements Comparable<Element> { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long storageId; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "holder_id") private Holder holder; private Integer pos; public Long getStorageId() { return storageId; } public void setStorageId(Long storageId) { this.storageId = storageId; } public Holder getHolder() { return holder; } public void setHolder(Holder holder) { this.holder = holder; } public int getPos() { return pos; } public void setPos(int pos) { this.pos = pos; } @Override public int compareTo(Element o) { return pos - o.pos; } }
ElementByParent类结构与Element类似,仅缺少Holder引用。
HolderRepo 仓库接口
public interface HolderRepo extends JpaRepository<Holder, Long> { }
Controller 控制层
@RestController public class Controller { private final HolderRepo holderRepo; @Autowired public Controller(HolderRepo holderRepo) { this.holderRepo = holderRepo; } @RequestMapping(value = "/add-element/{holderId}", method = RequestMethod.POST) @Retryable(maxAttempts = 10, backoff = @Backoff(delay = 100, maxDelay = 1000), retryFor = {CannotAcquireLockException.class}) public Holder addElement(@PathVariable long holderId, @RequestBody Element element) { Holder holder = holderRepo.findById(holderId).get(); holder.addElement(element); holderRepo.save(holder); return holder; } @RequestMapping(value = "/add-element-by-parent/{holderId}", method = RequestMethod.POST) @Retryable(maxAttempts = 10, backoff = @Backoff(delay = 100, maxDelay = 1000), retryFor = {ObjectOptimisticLockingFailureException.class}) public Holder addElementByParent(@PathVariable long holderId, @RequestBody ElementByParent element) { Holder holder = holderRepo.findById(holderId).get(); holder.addElementByParent(element); holderRepo.save(holder); return holder; } }
1. @OptimisticLock(excluded=false)的合理性
这种用法合法且符合设计意图:
- 默认情况下,Hibernate对双向
@OneToMany集合会忽略其变更对乐观锁版本号的影响(因为关联变更由多端维护),设置excluded=false就是明确告知Hibernate,集合的增删改需要触发父实体版本号递增,从而实现对集合操作的乐观锁控制。 - 这不算是不推荐的用法,只是需要注意它带来的副作用——父实体版本号会在每次集合变更时更新,同时Hibernate执行操作的SQL逻辑会发生变化。
2. 死锁与异常类型变化的原因
为何抛出CannotAcquireLockException而非乐观锁异常?
当标记集合参与乐观锁后,Hibernate在更新父实体时,会先尝试锁定父实体(通过版本号校验+更新或SELECT ... FOR UPDATE),同时因为要维护集合元素的pos字段(@OrderColumn)和关联外键,会对元素表执行批量更新/插入操作。高并发场景下:
- 两个请求同时加载同一个Holder,各自添加元素,更新Holder版本号的同时,还要修改元素的
pos值(addElement中用elements.size()设置pos,并发时会出现多个元素争抢同一个pos值,或后续元素的pos需要调整)。 - MySQL的InnoDB引擎会在这些操作中加行锁,当两个请求的锁顺序不一致时(比如A先锁Holder行,再锁Element行;B先锁Element行,再锁Holder行),就会触发死锁,最终抛出
CannotAcquireLockException(这是数据库层面的死锁,而非应用层的乐观锁冲突)。
而H2内存数据库没有InnoDB的行锁机制(或锁策略更宽松),所以不会出现死锁日志。
映射与代码中的潜在问题
@OrderColumn的风险:使用@OrderColumn时,每次集合元素位置变化(新增、删除元素),Hibernate会批量更新后续所有元素的pos字段。这会导致大量行锁,大幅提升死锁概率——这是你遇到死锁的核心诱因之一。SortedSet与@OrderColumn的冲突:SortedSet是内存中排序,@OrderColumn是数据库层面维护顺序,两者结合会导致Hibernate在保存时需要额外的排序和更新逻辑,加剧锁竞争。
3. 解决方案
方案一:移除@OrderColumn,改用内存排序或数据库查询排序
如果不需要在数据库层面维护固定顺序,直接去掉@OrderColumn:
- 集合改用
LinkedHashSet维护插入顺序,或者在查询时通过ORDER BY pos排序。 - 这样避免了批量更新
pos字段的操作,从根源减少锁竞争和死锁概率。
方案二:调整乐观锁触发逻辑,避免集合关联影响父实体版本
如果必须保留@OrderColumn,可以放弃让集合变更触发父实体乐观锁,转而在多端(Element)添加@Version字段,实现对元素操作的乐观锁控制:
- 这样并发操作元素时,会触发元素本身的乐观锁异常,避免父实体层面的锁竞争。
- 但需要注意,这种方式无法防止“两个请求同时添加元素导致pos重复”的问题,需要额外逻辑(比如用数据库自增字段作为pos,或在插入时通过数据库层面的唯一约束+重试来避免)。
方案三:优化并发操作的锁顺序
如果必须保留现有映射,可以调整代码逻辑,让所有并发请求的锁顺序一致:
- 比如先通过
findById(id, LockModeType.PESSIMISTIC_WRITE)显式加悲观锁,再执行元素添加操作。 - 这样所有请求都会先锁定Holder行,再操作Element行,避免死锁。但悲观锁会降低并发性能,需要权衡。
方案四:抑制死锁日志(临时方案)
如果只是不想看到死锁日志,且确认重试机制能保证数据一致性,可以在Hibernate配置中调整日志级别:
- 将
org.hibernate.engine.jdbc.spi.SqlExceptionHelper的日志级别设为ERROR以上(比如FATAL),但这会隐藏其他SQL错误日志,不推荐长期使用。
总结
你遇到的核心问题是@OrderColumn导致的批量行更新加剧了锁竞争,结合@OptimisticLock(excluded=false)后的父实体锁操作,最终触发MySQL死锁。@OptimisticLock(excluded=false)的用法是合理的,但需要结合集合的排序策略一起优化。优先考虑移除@OrderColumn或者改用元素自身的乐观锁,能有效解决死锁问题。
内容的提问来源于stack exchange,提问作者andr

