You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用@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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.02 02:37:27