Backend Development 8 min read

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.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Why Does Spring Data JPA Insert Duplicate Users? Transaction & Lock Solutions

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.

transactionSpringBootEventDrivenJPAPessimisticLock
Spring Full-Stack Practical Cases
Written by

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.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.