加了锁,加了事务 还是重复报名❓

前言

🧓我是[提前退休的java猿],一名7年java开发经验的开发组长,分享工作中的各种问题!

加了锁和事务还是重复报名了。这个问题只看代码的话,90% 的后端程序员都定位不到问题。甚至整个职业生涯你们都碰不到这个问题!

上周五同事小李哥遇到一个活动重复报名的生产问题,始终找不到原因。最后就过来问我,我看了一下代码是没啥大问题的(没有并发量)。

既然代码没有问题,其实我已经知道问题出在哪儿了。

重复报名

示例代码

大家先看一下controller的实现,锁的是活动的ID,就是同一活动同一时刻只能有一个用户能够报名成功! 同一时刻其他用户就会阻塞获取锁?

java 复制代码
@AutoLog(value = "活动报名")
@ApiOperation(value = "活动报名")
@PostMapping("enroll")
public Result enroll(@RequestBody EnrollParameReq req) {
    RLock lock = redissonClient.getLock(RedisKeyConstant.ENROLL_LOCK_KEY+req.getActivityId());
    try {
        lock.lock();
        // ---- Transaction标记的方法:(查询没有报过名的才能报名,插入报名记录 剩余报名人数-1)
        String msg = service.enroll(req);
        if(oConvertUtils.isNotEmpty(msg)){
            return Result.error(msg);
        }
        return Result.OK();
    } finally {
        lock.unlock();
    }
}

锁没有设置过期时间,并且service.enrolle里面的业务逻辑,以及当前业务根本没啥并发量。所以这个代码至少来说不会出现重复报名的情况。

定位问题

看了代码之后我就猜到是什么原因了,就是我们的数据库环境迁移之后一直存在一个问题: com.kingbase8.util.KSQLException: This _connection has been closed. 此前定时任务执行一段时间就停止了,也是这个问题引起的。

✔产生重复报名的执行逻辑:

当程序在执行完成业务逻辑之后,代理类在commit事务的时候,程序和数据库之间就断开连接了。程序员就直接抛异常了,这时候rollback也没用了,分布式锁也释放了!

此时数据库实际上还在执行commit操作,同样的请求又进来获取到锁,又执行同样逻辑。发现没有报名经,又提交报名信息。所以就引发了重复报名的问题

执行流程如下图:

证明问题

既然是commit异常了导致的重复报名,那么肯定有异常的日志。当时就让小李哥 看了日志。果然我预料的一样commit 事务的时候异常了。日志简化如下:

java 复制代码
2025-07-22 18:01:03.504 [http-nio-8016-exec-4] ERROR     
com.alibaba.druid.pool.DruidDataSource:2146 - recycle error com.kingbase8.util.KSQLException: This _connection has been closed. at 
com.kingbase8.jdbc.KbConnection.checkIsClosed(KbConnection.java:1401) at
com.kingbase8.jdbc.KbConnection.setAutoCommit(KbConnection.java:1288) at 
com.kingbase8.dispatcher.entity.DispatchConnection.setAutoCommit(DispatchConnection.java:1115) 
at com.alibaba.druid.filter.FilterChainImpl.connection_setAutoCommit(FilterChainImpl.java:699) 
at com.alibaba.druid.filter.logging.LogFilter.connection_setAutoCommit(LogFilter.java:467) at 
com.alibaba.druid.filter.FilterChainImpl.connection_setAutoCommit(FilterChainImpl.java:694) at 
..............................................................
org.jeecg.modules.activity.service.impl.OaActivityEnrollServiceImpl$$EnhancerBySpringCGLIB$$80154743.activityEnroll(<generated>) at 
org.jeecg.modules.activity.controller.app.OaActivityAppController.enroll(OaActivityAppController.java:136) at 
org.jeecg.modules.activity.controller.app.OaActivityAppController$$FastClassBySpringCGLIB$$5019 
..........................................................
org.jeecg.modules.activity.controller.app.OaActivityAppController$$EnhancerBySpringCGLIB$$56f17
e58.enroll(<generated>) ......................................................
org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) at
org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) at
java.lang.Thread.run(Thread.java:748)

----------
2025-07-22 18:01:04.784 [http-nio-8016-exec-10] ERROR o.jeecg.common.exception.JeecgBootExceptionHandler:78 - JDBC rollback; This _connection has been closed.;.........

同时我们controller@AutoLog(value = "活动报名")日志注解,这个注解是实现AOP的环绕通知,执行完成之后会向数据库插入日志,同时记录用户的Id。 代码如下:

java 复制代码
@Around("logPointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
    long beginTime = System.currentTimeMillis();
    //执行方法
    Object result = point.proceed();
    //执行时长(毫秒)
    long time = System.currentTimeMillis() - beginTime;
    //保存日志
    insertLog(point, time, result);
    return result;
}

这个日志也是我们排查问题的重要方式,因为没有捕捉异常,所以日志中也是没有这个人报名的操作记录的。(可以优化前前置处理吧!!)

代码优化

引发这个问题,还有两处还有点问题,优化之后也能很大概率减少重复报名问题的发生:

  1. 前端没有做重复点击的控制。
  2. 后端实际上可以对锁进行优化,锁用户就行了;没有获取到锁直接返回失败,一直阻塞的获取锁很消耗资源。
  3. 细化事务的粒度,缩短事务的占用时间
java 复制代码
// 锁用户 (KEY:活动ID + 用户ID)----------------------
RLock lock = redissonClient.getLock("活动ID+userId");

try {
    //获取用户锁,不用等待
    Boolean lockRet = lock.tryLock(0, 10, TimeUnit.SECONDS);
    if(lockRet){
        // 执行业务逻辑:多个用户可能同时报名防止报超过了(活动人数通过 利用数据乐观锁 update t set num =  num -1 where id = #{id} num > 1 )
        // 更新失败则回滚,表示报名人数已满
        oaActivityEnrollService.activityEnroll(req);
        return Result.OK();
    }
    //----------- 没有获取到直接失败
    return Result.error("当前活动报名人数过多,请稍后再试");
}catch (Exception e){
    log.error("erorr",e);
    return Result.error("当前活动报名人数过多,请稍后再试");
}finally {
    if(lock.isHeldByCurrentThread()){
        lock.unlock();
    }
}

总结

本次通过重复报名的问题,定位到是就是数据的老毛病了。就是经常出现 java.net.SocketTimeoutException: Read timed out 的问题。这个问题导致我们程序员无法感知 事务的提交结果,从而引发的一系列问题。

当然,我们分析了代码,因为前端没有做重复提交的控制,后端也是锁的活动,并且会一直尝试获取锁。这就大大增加了重复报名的风险。虽然根本问题是数据库引起的,但是我们的代码也有很大的优化空间,优化之后,也能大大降低重复报名,以及提高并发,提升用户的体验。

关于如何解决 SocketTimeoutException 目前正在和厂家沟通解决中,解决之后会公布解决方案!

相关推荐
paopaokaka_luck1 小时前
基于SpringBoot+Uniapp的健身饮食小程序(协同过滤算法、地图组件)
前端·javascript·vue.js·spring boot·后端·小程序·uni-app
Villiam_AY1 小时前
Redis 缓存机制详解:原理、问题与最佳实践
开发语言·redis·后端
飛_2 小时前
解决VSCode无法加载Json架构问题
java·服务器·前端
柊二三3 小时前
XML的简略知识点
xml·数据库·oracle
木棉软糖5 小时前
一个MySQL的数据表最多能够存多少的数据?
java
魔尔助理顾问5 小时前
系统整理Python的循环语句和常用方法
开发语言·后端·python
程序视点5 小时前
Java BigDecimal详解:小数精确计算、使用方法与常见问题解决方案
java·后端
愿你天黑有灯下雨有伞5 小时前
Spring Boot SSE实战:SseEmitter实现多客户端事件广播与心跳保活
java·spring boot·spring
每天敲200行代码5 小时前
MySQL 事务管理
数据库·mysql·事务
你的人类朋友5 小时前
❤️‍🔥微服务的拆分策略
后端·微服务·架构