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

Grails中如何避免Ajax周期性调用时的乐观锁失败异常?

解决Grails邮件草稿定时保存的乐观锁异常

问题场景

在Grails 4.0.10中实现了邮件草稿定时保存功能:前端通过Ajax每隔1秒调用MessageController的saveDraft方法,定期保存用户撰写的邮件内容,避免意外丢失。

控制器代码如下:

def saveDraft(){

    if (request.xhr) {

        def event_only = params['event_only'].toBoolean()
        def event_id = params['event_id'].toLong()

        def event

        if(event_only) {
            event = CompositeEvent.get(event_id)
        }


        EventEmailDraft.withTransaction {


            def user = User.get(springSecurityService.principal.id)

            def draft

            if(event_only){
                draft = EventEmailDraft.findByEvent(event)
            }
            else{
                draft = MassEmailDraft.findByUser(user)
            }


            if(draft) {
                draft.subject = params['subject']
                draft.body = params['body']
                draft.emails = params['add_emails']

                draft.save()
            }

            render "ok"
        }

    }
    else{
        render "invalid"
    }
}

运行过程中偶尔出现乐观锁异常,错误指向EventEmailDraft.withTransaction {这一行,堆栈信息如下:

2023-08-04 06:14:53.790 ERROR --- [io-8080-exec-12] StackTrace                               : Full Stack Trace:

org.springframework.orm.hibernate5.HibernateOptimisticLockingFailureException: Object of class [com.company.EventEmailDraft] with identifier [334]: optimistic locking failed; nested exception is org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.company.EventEmailDraft#334]
    ...(省略重复堆栈)
Caused by: org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.company.EventEmailDraft#334]
    ...(省略重复堆栈)

原因分析

这个异常是典型的乐观锁冲突:

  • 前端每隔1秒发起请求,当服务器处理第一个请求还未完成事务提交时,第二个请求已经查询到了旧版本的EventEmailDraft对象;
  • 第一个请求提交后,数据库中记录的版本号(乐观锁字段,通常是version)已经更新;
  • 第二个请求尝试提交时,Hibernate发现当前对象的版本号和数据库中的不一致,就会抛出StaleObjectStateException。

简单说就是两个并发请求同时修改同一条草稿记录,后提交的请求发现数据已经被修改,触发乐观锁校验失败。

解决方案

1. 降低请求频率(最简单的临时方案)

把前端的保存间隔从1秒延长到3-5秒,大幅降低并发请求的概率,减少冲突发生的可能。

2. 使用悲观锁锁定记录

查询草稿时加上悲观锁,确保同一时间只有一个事务能修改这条记录:

// 修改查询逻辑,添加[lock: true]参数
if(event_only){
    draft = EventEmailDraft.findByEvent(event, [lock: true])
}
else{
    draft = MassEmailDraft.findByUser(user, [lock: true])
}

悲观锁会在查询时给数据库记录加行锁,后续请求必须等待当前事务提交后才能获取到最新数据,避免版本冲突。

3. 捕获异常并重试

在保存时捕获乐观锁异常,重试几次操作,确保最终能保存成功:

if(draft) {
    boolean saved = false
    int retryCount = 0
    while (!saved && retryCount < 3) { // 最多重试3次
        try {
            draft.subject = params['subject']
            draft.body = params['body']
            draft.emails = params['add_emails']
            saved = draft.save(failOnError: true)
        } catch (org.springframework.orm.hibernate5.HibernateOptimisticLockingFailureException e) {
            retryCount++
            // 重新查询最新版本的草稿
            if(event_only){
                draft = EventEmailDraft.findByEvent(event)
            }
            else{
                draft = MassEmailDraft.findByUser(user)
            }
        }
    }
}

4. 前端防抖优化

前端不要固定每隔1秒发送请求,而是采用防抖策略:用户停止输入后再触发保存(比如停止输入1秒后再发请求),这样可以避免频繁的无效请求,从根源减少并发。

5. 使用merge操作更新记录

如果不想用锁,也可以通过merge方法直接更新数据库中的最新记录,不过这种方式需要确保参数是最新的:

if(draft) {
    // 创建一个临时对象,设置id和要更新的字段
    def updateDraft = new EventEmailDraft(id: draft.id)
    updateDraft.subject = params['subject']
    updateDraft.body = params['body']
    updateDraft.emails = params['add_emails']
    updateDraft.merge() // merge会自动处理版本冲突,以数据库最新版本为基础更新
}

推荐方案

如果希望最小化代码改动,优先选择延长保存间隔+前端防抖;如果必须保持1秒的保存频率,推荐使用悲观锁或者异常重试的方案,确保数据能稳定保存。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.13 11:35:53