被簡單的用戶注冊坑了!出現用戶重復
環境: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<User, Runnable> 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<User, Long> {
@Lock(LockModeType.PESSIMISTIC_READ)
Optional<User> findById(Long id);
}
接下來在發送郵件的方法出調用上面的findById方法重新從數據庫中拉取數據
private final Function<User, Runnable> 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 extends T> 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=?
正確執行。
總結:在不同的線程上下文中對同一數據操作,要確保上一個事務正確的提交。否則會出現數據不一致的情況。在本例中是插入后再更新。如果是對已存在的數據做更新操作情況是一樣的出現數據不一致的情況。
完畢!!!