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

前言

🧓我是[提前退休的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 目前正在和厂家沟通解决中,解决之后会公布解决方案!

相关推荐
tryxr1 分钟前
MySQL 之索引为什么选择B+树
数据库·mysql·b+树·索引
魔术师卡颂3 分钟前
不就写提示词?提示词工程为啥是工程?
前端·人工智能·后端
聪明的笨猪猪9 分钟前
Java JVM “内存(1)”面试清单(含超通俗生活案例与深度理解)
java·经验分享·笔记·面试
程序员清风25 分钟前
快手二面:乐观锁是怎么用它来处理多线程问题的?
java·后端·面试
IT_陈寒39 分钟前
《Redis性能翻倍的7个冷门技巧,90%开发者都不知道!》
前端·人工智能·后端
曦樂~39 分钟前
【Qt】信号与槽(Signal and Slot)- 简易计算器
开发语言·数据库·qt
一线大码40 分钟前
SpringBoot 优雅实现接口的多实现类方式
java·spring boot·后端
花伤情犹在1 小时前
Java Stream 高级应用:优雅地扁平化(FlatMap)递归树形结构数据
java·stream·function·flatmap
yaoxin5211231 小时前
212. Java 函数式编程风格 - Java 编程风格转换:命令式 vs 函数式(以循环为例)
java·开发语言
ZYMFZ1 小时前
python面向对象
前端·数据库·python