被简单的用户注册坑了!出现用户重复
创始人
2025-07-10 19:40:17
0

环境:SpringBoot3.0.9

1. 背景介绍

简单介绍下出现问题的场景;用户注册后,系统需要发送一封确认邮件。一旦邮件发送成功,用户的状态应更新为“已发送”。但是,在使用Spring Data JPA时,出现了重复数据的问题,注册的用户有2条。

2. 问题代码

@Service
public class UserService {


  @Resource
  private UserRepository userRepository ;
  
  private static final ThreadPoolExecutor POOL = new ThreadPoolExecutor(2, 2, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>()) ;
  private final Function action = user -> () -> {
    System.out.printf("给【%s】发送邮件%n", user.getEmail()) ;
    user.setState(1) ;
    userRepository.save(user) ;
  } ;
  @Transactional
  public void saveUser(User user) {
    this.userRepository.save(user) ;
    POOL.execute(action.apply(user)) ;
    // 模拟其它操作
    TimeUnit.SECONDS.sleep(1) ;
  }
  
}

测试

@Resource
private UserService userService ;


@Test
public void testSave() {
  User user = new User() ;
  user.setName("张三") ;
  user.setEmail("zs@qq.com") ;
  userService.saveUser(user) ;
}

控制台输出

Hibernate: insert into t_user (email, name, state) values (?, ?, ?)
给【zs@qq.com】发送邮件
Hibernate: select u1_0.id,u1_0.email,u1_0.name,u1_0.state from t_user u1_0 where u1_0.id=?
Hibernate: insert into t_user (email, name, state) values (?, ?, ?)
Hibernate: update t_user set email=?, name=?, state=? where id=?

输出2条insert,数据库中有2条结果

图片图片

3. 原因分析

在保存用户后打印User对象,同时在发邮件处再次查询数据

this.userRepository.save(user) ;
System.out.println(user.getId() + " ---- ")  ;
// 发送邮件处查询数据
user.setState(1) ;
System.out.println(userRepository.findById(user.getId()).orElseGet(() -> null)) ;

执行结果

Hibernate: insert into t_user (email, name, state) values (?, ?, ?)
22 ---- 
给【zs@qq.com】发送邮件
Hibernate: select u1_0.id,u1_0.email,u1_0.name,u1_0.state from t_user u1_0 where u1_0.id=?
null

打印出了User的id值,但是在发送邮件再次查询时打印的null,数据库并没有数据。既然没有数据,那么调用save方法当然会执行insert操作。也就是说在发送邮件操作时,上一步的保存用户的事务并没有提交。

4. 解决办法

在一个事务中如果你调用save方法,这时候并不会里面将数据插入到数据库中,而是会等到事务提交以后。

解决方法1:

在对应的UserRepository中重写findById的方法,然后在方法上添加共享锁    (lock in share mode

public interface UserRepository extends JpaRepository {


  @Lock(LockModeType.PESSIMISTIC_READ)
  Optional findById(Long id);
}

接下来在发送邮件的方法出调用上面的findById方法重新从数据库中拉取数据

private final Function action = user -> () -> {
  System.out.printf("给【%s】发送邮件%n", user.getEmail()) ;
  // 由于加了锁,所以这里会一直等待另外一个线程的事务结束或才会继续执行
  User ret = userRepository.findById(user.getId()).get() ;
  ret.setState(1) ;
  userRepository.save(ret) ;
}

控制台输出

Hibernate: insert into t_user (email, name, state) values (?, ?, ?)
26 ---- 
给【zs@qq.com】发送邮件
Hibernate: select u1_0.id,u1_0.email,u1_0.name,u1_0.state from t_user u1_0 where u1_0.id=? lock in share mode
Hibernate: select u1_0.id,u1_0.email,u1_0.name,u1_0.state from t_user u1_0 where u1_0.id=?
Hibernate: update t_user set email=?, name=?, state=? where id=?

执行的sql上自动添加了共享锁lock in share mode

解决办法2:

缩小事务范围,不要在saveUser方法上加事务;调用的save方法内部实现是已经带有了@Transactional注解,如下:

SimpleJpaRepository

@Transactional
@Override
public  S save(S entity) {
  // ...
}

去掉了saveUser方法上的事务后,数据正常insert了一条,update一条。

该种方法实现非常的简单,但是如果saveUser方法中有多个事务操作,这时候你的通过别的方式实现。

解决方法3:

通过事件机制,该种方式有如下优点:

  • 解耦:通过事件,你可以将用户注册与发送邮件两个操作分离,使它们之间不存在直接的依赖关系。这样,如果以后需要更改邮件发送逻辑或替换为其他服务,只需要修改事件监听器,而不需要修改用户注册的代码。
  • 灵活性:事件机制提供了高度的灵活性。你可以在用户注册成功后触发多个不同的事件,每个事件可以有不同的处理逻辑。这样,你可以很容易地扩展功能,例如除了发送邮件外,还可以触发其他相关的业务逻辑。
  • 异步处理:事件处理通常是异步的,这意味着用户注册后,不需要等待邮件发送完成。这种异步处理可以提高应用的响应速度和吞吐量。
  • 可扩展性:由于事件处理是基于发布-订阅模式的,因此你可以轻松地添加新的事件监听器来扩展功能。如果以后需要集成其他服务或功能,例如发送短信、推送通知等,只需要创建相应的事件监听器即可。

实现方式如下

// 定义事件对象
class UserCreatedEvent extends ApplicationEvent {
  private static final long serialVersionUID = 1L;
  private User source ;
  public UserCreatedEvent(User user) {
    super(user);
    this.source = user ;
  }
}
// 定义事件监听器
// 在事务提交完成以后执行
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
@Async
public void sendMail(UserCreatedEvent event) {
  User user = event.getUser();
  System.out.printf("%s - 给【%s】发送邮件%n", Thread.currentThread().getName(), user.getEmail()) ;
  user.setState(1);
  userRepository.save(user) ;
}
// 在saveUser方法中需要发送事件
@Transactional
public void saveUser(User user) {
  this.userRepository.save(user) ;
  eventMulticaster.multicastEvent(new UserCreatedEvent(user)) ;
}

测试

Hibernate: insert into t_user (email, name, state) values (?, ?, ?)
40 ---- 
task-1 - 给【zs@qq.com】发送邮件
Hibernate: select u1_0.id,u1_0.email,u1_0.name,u1_0.state from t_user u1_0 where u1_0.id=?
Hibernate: update t_user set email=?, name=?, state=? where id=?

正确执行。

总结:在不同的线程上下文中对同一数据操作,要确保上一个事务正确的提交。否则会出现数据不一致的情况。在本例中是插入后再更新。如果是对已存在的数据做更新操作情况是一样的出现数据不一致的情况。

完毕!!!

相关内容

热门资讯

PHP新手之PHP入门 PHP是一种易于学习和使用的服务器端脚本语言。只需要很少的编程知识你就能使用PHP建立一个真正交互的...
网络中立的未来 网络中立性是什... 《牛津词典》中对“网络中立”的解释是“电信运营商应秉持的一种原则,即不考虑来源地提供所有内容和应用的...
各种千兆交换机的数据接口类型详... 千兆交换机有很多值得学习的地方,这里我们主要介绍各种千兆交换机的数据接口类型,作为局域网的主要连接设...
什么是大数据安全 什么是大数据... 在《为什么需要大数据安全分析》一文中,我们已经阐述了一个重要观点,即:安全要素信息呈现出大数据的特征...
如何允许远程连接到MySQL数... [[277004]]【51CTO.com快译】默认情况下,MySQL服务器仅侦听来自localhos...
如何利用交换机和端口设置来管理... 在网络管理中,总是有些人让管理员头疼。下面我们就将介绍一下一个网管员利用交换机以及端口设置等来进行D...
P2P的自白|我不生产内容,我... 现在一提起P2P,人们就会联想到正在被有关部门“围剿”的互联网理财服务。×租宝事件使得劳...
Intel将Moblin社区控... 本周二,非营利机构Linux基金会宣布,他们将担负起Moblin社区的管理工作,而这之前,Mobli...
施耐德电气数据中心整体解决方案... 近日,全球能效管理专家施耐德电气正式启动大型体验活动“能效中国行——2012卡车巡展”,作为该活动的...
Windows恶意软件20年“... 在Windows的早期年代,病毒游走于系统之间,偶尔删除文件(但被删除的文件几乎都是可恢复的),并弹...