Spring Boot事务无法阻止重复邮箱用户创建,如何实现邮箱唯一性?
Let's break down the issues with your current approach and walk through reliable solutions step by step.
1. Your Test Code Has Critical Race Conditions
First, your test logic is inconsistent and causing false failures/passes:
- You’re calling
userRepository.deleteAll()immediately after starting a background thread. This means the thread might still process its request after the main thread deletes all users—so when it runs the email check, the user is already gone, allowing a duplicate to be created. - You’re starting threads one by one in a loop instead of triggering them simultaneously. This makes the test behave unpredictably, depending on thread scheduling.
- The main thread is also making a POST request before each background thread, adding unnecessary variability to the test.
2. @Transactional(isolation = Isolation.SERIALIZABLE) Doesn’t Fix the Race Condition
Serializable isolation ensures transactions run as if they’re sequential, but your logic has a gap:
The findByEmailIgnoreCase check and save operation are two separate database calls inside the transaction. Even with Serializable, two concurrent transactions can both run the find query (and find no user), then proceed to save. The isolation level doesn’t prevent this because the check and insert aren’t atomic.
3. synchronized Failed Because of Spring Transaction Proxies
Spring wraps your service bean in a transaction proxy. When you add synchronized to the saveUser method, the lock is on the target bean—but the proxy releases the lock before the transaction commits. This lets other threads enter the method while the first transaction is still in the process of saving, defeating the lock’s purpose.
Fixes to Stop Duplicate Users
A. Use Database Unique Constraints (The Only Foolproof Guard)
Your @Column(unique = true) is a good start, but you need to handle the database’s duplicate constraint exception properly:
- Verify your database has a unique index on the
emailcolumn (JPA should create it, but double-check with your DB tooling). - Modify your service method to catch the persistence exception and convert it to your business exception:
@Transactional public User saveUser(String email) { String lowerCaseEmail = email.toLowerCase(); try { // Optional: Keep the find check as an early optimization to avoid hitting DB exceptions if (userRepository.findByEmailIgnoreCase(lowerCaseEmail) != null) { throw new UserAlreadyExistException(format("User with email %s already exists.", lowerCaseEmail)); } var user = new User(); user.setEmail(lowerCaseEmail); return userRepository.save(user); } catch (DataIntegrityViolationException e) { // Check if the violation is due to the unique email constraint if (e.getRootCause() instanceof SQLIntegrityConstraintViolationException) { throw new UserAlreadyExistException(format("User with email %s already exists.", lowerCaseEmail)); } throw e; // Re-throw other integrity violations } }
This works because the database’s unique index check is atomic—when two transactions try to insert the same email, one succeeds and the other immediately throws a constraint violation, eliminating the race condition entirely.
B. Atomic Query + Insert (Complementary Optimization)
If you want to avoid hitting database exceptions entirely, turn the check and insert into a single atomic operation with a custom query. For example, in PostgreSQL:
@Repository public interface UserRepository extends JpaRepository<User, String> { @Query(value = "INSERT INTO user (email) VALUES (?1) WHERE NOT EXISTS (SELECT 1 FROM user WHERE LOWER(email) = LOWER(?1))", nativeQuery = true) @Modifying int insertIfNotExists(String email); User findByEmailIgnoreCase(String email); }
Then update your service:
@Transactional public User saveUser(String email) { String lowerCaseEmail = email.toLowerCase(); int rowsInserted = userRepository.insertIfNotExists(lowerCaseEmail); if (rowsInserted == 0) { throw new UserAlreadyExistException(format("User with email %s already exists.", lowerCaseEmail)); } return userRepository.findByEmailIgnoreCase(lowerCaseEmail); }
This ensures no two transactions can insert the same email at the same time.
C. Fix Your Test Code
Use CountDownLatch to trigger all threads simultaneously and clean up data consistently:
@Test public void shouldNotCreateNewUserWithSameEmail() throws Exception { String requestPayload = objectMapper.writeValueAsString(UserCreateRequest.builder() .email(EMAIL1) .rolesName(Set.of(role.getName())).build()); int threadCount = 15; CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch finishLatch = new CountDownLatch(threadCount); for (int i = 0; i < threadCount; i++) { new Thread(() -> { try { startLatch.await(); // Wait for all threads to be ready mockMvc.perform(MockMvcRequestBuilders.post(MAIN_REQUEST_URL) .contentType(MediaType.APPLICATION_JSON) .content(requestPayload)) .andExpect(result -> { // Either 201 (first successful) or 400 (duplicates) int status = result.getResponse().getStatus(); assertTrue(status == HttpStatus.CREATED.value() || status == HttpStatus.BAD_REQUEST.value()); }); } catch (Exception e) { e.printStackTrace(); } finally { finishLatch.countDown(); } }).start(); } // Clean up before starting all threads userRepository.deleteAll(); // Trigger all threads to run at once startLatch.countDown(); // Wait for all threads to finish finishLatch.await(); // Verify only one user was created assertEquals(1, userRepository.countByEmailIgnoreCase(EMAIL1)); }
This test ensures all threads hit the endpoint simultaneously and verifies the final state of the database, making it far more reliable.
内容的提问来源于stack exchange,提问作者Valeriy K.

