Why Does Spring Data JPA Insert Duplicate Users? Transaction & Lock Solutions
This article explains why a SpringBoot 3.0.9 application creates duplicate user rows when sending confirmation emails, analyzes the transactional behavior causing the issue, and presents three solutions—including pessimistic locking, narrowing transaction scope, and an event‑driven approach—to ensure data consistency.
Environment: SpringBoot 3.0.9
1. Background
A user registers, the system sends a confirmation email and should set the user state to "sent". Using Spring Data JPA, two identical rows are inserted for the same user.
2. Problem Code
<code>@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));
// simulate other operations
TimeUnit.SECONDS.sleep(1);
}
}
</code> <code>@Resource
private UserService userService;
@Test
public void testSave() {
User user = new User();
user.setName("张三");
user.setEmail("[email protected]");
userService.saveUser(user);
}
</code>3. Observation
Console output shows two INSERT statements and an UPDATE, meaning the second save occurs after the first transaction has not been committed when the email‑sending thread queries the data.
4. Solution 1 – Pessimistic Read Lock
Add a shared lock to findById so the email‑sending thread waits for the first transaction to finish.
<code>public interface UserRepository extends JpaRepository<User, Long> {
@Lock(LockModeType.PESSIMISTIC_READ)
Optional<User> findById(Long id);
}
</code>Use the locked method in the email task:
<code>private final Function<User, Runnable> action = user -> () -> {
System.out.printf("给【%s】发送邮件%n", user.getEmail());
// wait for the other transaction because of the lock
User ret = userRepository.findById(user.getId()).get();
ret.setState(1);
userRepository.save(ret);
};
</code>5. Solution 2 – Reduce Transaction Scope
Remove @Transactional from saveUser . The underlying save method in SimpleJpaRepository is already transactional, so a single INSERT and a single UPDATE are executed correctly.
6. Solution 3 – Event‑Driven Decoupling
Publish an event after the user is persisted and handle the email sending asynchronously after the transaction commits.
<code>// Event object
class UserCreatedEvent extends ApplicationEvent {
private static final long serialVersionUID = 1L;
private final User source;
public UserCreatedEvent(User user) {
super(user);
this.source = user;
}
public User getUser() { return source; }
}
// Listener (executed after transaction completion)
@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);
}
// In saveUser
@Transactional
public void saveUser(User user) {
this.userRepository.save(user);
eventMulticaster.multicastEvent(new UserCreatedEvent(user));
}
</code>7. Result
All three approaches ensure that the user row is inserted once and updated correctly, eliminating duplicate data caused by premature reads in another thread.
Summary: In multi‑threaded contexts, make sure the previous transaction is committed before other threads access the same data.
Spring Full-Stack Practical Cases
Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.