You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Boot事务无法阻止重复邮箱用户创建,如何实现邮箱唯一性?

Why Your Duplicate Email Prevention Isn't Working (And How to Fix It)

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:

  1. Verify your database has a unique index on the email column (JPA should create it, but double-check with your DB tooling).
  2. 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.

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.09 20:52:52