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

Spring Data JPA插入后冗余更新触发乐观锁异常问题排查

问题现象

Kotlin技术栈下使用Spring Data JPA开发时,遇到如下乐观锁报错:

org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed

涉及的实体定义省略非核心代码后如下:
Parent实体:

@Entity
class Parent(
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  val id: Long = 0L,

  @OneToMany(mappedBy = "parent", cascade = [CascadeType.ALL], orphanRemoval = true)
  val children: MutableList<Child> = mutableListOf()
) {

  fun addChild(child: Child) {
    this.children.add(child)
    child.parent = this
  }
}

Child实体:

@Entity
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["parent_id"])])
class Child(
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  val id: Long = 0L,
) {

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "parent_id")
  lateinit var parent: Parent
}

核心事务方法逻辑:

@Transactional
fun saveChildren() {
  ...
  val parent: Parent = parentRepo.findById(parentId)
  parent.addChild(Child())
  parent.addChild(Child())
  validateSavedChildren()   // 全量子节点查询校验业务规则
  parentRepo.save(parent)
}

校验方法逻辑:

fun validateSavedChildren() {
  val parents: List<Parent> = parentRepo.findAll()  // 查询库中全量Parent数据
  var childrenCount = 0
  parents.forEach { parent -> 
    childrenCount += parent.children.size
  }
  if (childrenCount > CHILDREN_LIMIT) {
    throw ChildrenCountExceededLimitException()
  }
}

排查过程中观测到的现象:

  • 开启SQL日志后,执行顺序为:先插入parent_id=4的子记录,再执行全量Parent查询做业务校验,最后执行一条冗余update语句,将刚插入的子记录的parent_id字段再次设置为4,参数与插入时完全一致,异常由这条update触发
  • 逐行Debug模式下执行代码,异常不会复现
  • 将parentRepo.save(parent)替换为parentRepo.saveAndFlush(parent)后,冗余update语句消失,异常不再复现
  • 移除validateSavedChildren()方法调用后,冗余update语句同样不会执行
根本原因

这个问题是JPA持久化上下文的状态管理、自动flush机制与双向关联配置共同作用导致的,核心逻辑如下:

  1. JPA默认的flush模式为AUTO,事务内对实体的变更不会立刻同步为数据库SQL,只会在两个时机触发flush:一是事务提交前,二是执行查询操作前——JPA会先把待同步的变更刷入数据库,避免查询读到未提交的脏数据。
  2. 本次配置的双向关联中,Child持有外键parent_id,是关联的所属端(owning side),Parent的children字段标记了mappedBy = "parent",是反向端(inverse side)。JPA只会根据所属端的属性状态判定关联关系,反向端的集合修改本身不会被直接识别为关联变更。虽然addChild方法里同步设置了child.parent = this,但此时两个新创建的Child实例只是被标记为待持久化,状态快照还未完成同步。
  3. 调用validateSavedChildren()执行findAll()全量查询时,触发了JPA的自动flush:JPA先执行两个新Child的insert语句,但flush完成后没有立刻更新持久化上下文内新Child实例的状态快照。紧接着findAll()将数据库中全量的Parent、Child实体查询回来,重新加载到持久化上下文时,JPA比对内存中托管的Child实例和刚查询到的快照,判定parent属性存在“变更”(实际为同一引用,但快照未同步导致比对失败),因此给这两个Child标记了待更新状态。
  4. 后续调用parentRepo.save(parent)或者事务即将提交时,JPA会执行标记好的update语句,这就是那条参数完全一致的冗余SQL的来源。
  5. 异常触发的直接原因是Child表给parent_id配置了唯一约束,意味着一个Parent最多只能关联一个Child,批量执行insert+update时,唯一约束冲突+批处理返回行数与预期不符,最终抛出乐观锁异常。逐行Debug时执行间隔长,当前事务内插入的数据已完成可见性同步,状态快照比对一致,因此不会生成冗余update,异常也不会复现。
解决方案与底层原理
  • 方案1:使用saveAndFlush()替代普通save()
    普通save()仅将实体纳入持久化上下文托管,不会立刻触发SQL同步;saveAndFlush()会在保存后立刻强制将持久化上下文内所有待执行的SQL同步到数据库,同步完成后会更新所有托管实体的状态快照,后续执行全量查询时,实体状态与快照完全一致,不会生成冗余update,自然不会触发异常。
  • 方案2:调整校验逻辑,避免加载全量实体
    冗余update的直接触发点是findAll()全量查询触发的自动flush与实体重载。做子节点数量校验时,直接使用count聚合查询统计数量即可,不需要将全量Parent和Child实体加载到持久化上下文,既规避了状态不一致问题,也能大幅提升查询性能。
  • 方案3:修正关联状态维护逻辑
    新增子节点时,优先通过所属端Child实体主动维护关联关系,在持久化前确保parent属性已被JPA纳入状态快照,避免反向端集合操作带来的状态识别偏差。另外需要确认Child表parent_id的唯一约束是否符合业务预期——当前约束下一个Parent只能关联一个Child,和代码中一次添加两个子节点的逻辑本身存在冲突。

内容的提问来源于stack exchange,提问作者ParkCheolu

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.28 10:18:19