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
相关产品推荐
相关产品推荐

