环境: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=?
正确执行。
总结:在不同的线程上下文中对同一数据操作,要确保上一个事务正确的提交。否则会出现数据不一致的情况。在本例中是插入后再更新。如果是对已存在的数据做更新操作情况是一样的出现数据不一致的情况。
完毕!!!