背景
@Resource
private TransactionTemplate transactionTemplate;@Override
public long addSpace(SpaceAddRequest spaceAddRequest, User loginUser) {// 在此处将实体类和 DTO 进行转换Space space = new Space();BeanUtils.copyProperties(spaceAddRequest, space);// 默认值if (StrUtil.isBlank(spaceAddRequest.getSpaceName())) {space.setSpaceName("默认空间");}if (spaceAddRequest.getSpaceLevel() == null) {space.setSpaceLevel(SpaceLevelEnum.COMMON.getValue());}// 填充数据this.fillSpaceBySpaceLevel(space);// 数据校验this.validSpace(space, true);Long userId = loginUser.getId();space.setUserId(userId);// 权限校验if (SpaceLevelEnum.COMMON.getValue() != spaceAddRequest.getSpaceLevel() && !userService.isAdmin(loginUser)) {throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权限创建指定级别的空间");}// 针对用户进行加锁String lock = String.valueOf(userId).intern();synchronized (lock) {Long newSpaceId = transactionTemplate.execute(status -> {boolean exists = this.lambdaQuery().eq(Space::getUserId, userId).exists();ThrowUtils.throwIf(exists, ErrorCode.OPERATION_ERROR, "每个用户仅能有一个私有空间");// 写入数据库boolean result = this.save(space);ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);// 返回新写入的数据 idreturn space.getId();});// 返回结果是包装类,可以做一些处理return Optional.ofNullable(newSpaceId).orElse(-1L);}
}
设计思路:使用本地 synchronized 锁对 userId 进行加锁,这样不同的用户可以拿到不同的锁,对性能的影响较低。在加锁的代码中,我们
使用 Spring 的 编程式事务管理器 transactionTemplate 封装跟数据库有关的查询和插入操作,而不是使用 @Transactional 注解来控制事务,这样可以保证事务的提交在加锁的范围内。
举例分析
一、首先通过JVM 级别的本地对象锁,锁的粒度是当前进程中该 userId 的字符串对象。如果两个不同的用户(比如 A 和 B),那么他们加锁的是两个完全不同的 userId,互不干扰,锁根本不会互斥。
加锁只针对同一个用户生效,不会导致 A 阻塞 B,也不会阻止 B 的事务影响 A 的事务。使得十个用户也可以同时创建自己的私有空间
二、为什么使用编程式事务呢?
注解式事务会将方法交给Spring去管理,注解会等到锁释后,再帮用户 A 提交事务。
用户 A 连点 2 次提交“创建空间”,这两个请求几乎同时进入后端。
因为 userId 相同,synchronized(userId.intern()) 生效:第一个线程拿到锁,第二个阻塞等待。
第一个线程开始执行,进入 @Transactional 注解控制的事务中,查数据库没有空间,就插入了空间,但此时事务还没提交!
锁释放后,第二个线程继续执行,此时它用的是注解事务,数据库还看不到刚插入的空间(因为第一个事务没提交),所以它也查出“用户没有空间”,也插入。
最后两个事务提交,两个空间都写入了。
⚠️ 本质问题:
@Transactional 的事务是 Spring 托管的,事务提交时机在方法执行结束之后。而锁(synchronized)控制的是代码段的执行顺序,锁和事务的生命周期是错位的。
也就是说:
●锁释放 ≠ 事务提交
●锁释放时,数据库中的数据还可能是旧的
所以即使用户A对 userId 加了锁,只要事务没有提交完,其他线程查到的数据库数据依旧不准确,导致并发问题。
为了使得事务完全在锁的掌控内,即事务提交了,数据库也有新数据更新,才释放锁。
✅ 编程式事务的好处:
transactionTemplate.execute(status -> { … }) 是编程式事务,它的控制范围是你写的代码块,事务提交一定在锁控制范围之内。
这样就避免了“事务没提交,锁先释放”的问题,保证了:
●每次进入临界区时,数据库一定是最新状态;
●同一个用户不会并发创建多个空间。