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

生产与测试环境中用户Upsert方法行为差异问题求助

解决并发场景下Upsert用户的唯一键冲突问题

问题根源

本地和生产环境的差异核心在于并发竞态条件:生产环境下两个请求几乎同时执行,第一个事务还未提交时,第二个事务因数据库隔离级别(如PostgreSQL默认的READ COMMITTED)无法看到未提交的新增用户,因此进入创建分支,最终触发唯一键约束冲突。当前代码的「先查询再创建/更新」逻辑是非原子的,高并发下必然出现问题。

可行解决方案

1. 利用数据库原生UPSERT语法(推荐)

将Upsert逻辑交给数据库执行,依靠数据库的原子性操作避免竞态,这是最可靠且性能最优的方案。

以PostgreSQL为例,在Repository中定义自定义SQL:

@Repository
public interface BuyerRepository extends JpaRepository<Buyer, Long> {
    @Modifying
    @Transactional
    @Query(value = """
        INSERT INTO buyer (email, iam_number, username, ...) 
        VALUES (:email, :iamNumber, :username, ...)
        ON CONFLICT (email) DO UPDATE 
        SET iam_number = :iamNumber, username = :username, ...
        """, nativeQuery = true)
    void upsertByEmail(@Param("email") String email, 
                       @Param("iamNumber") String iamNumber, 
                       @Param("username") String username, ...);

    // 执行UPSERT后查询最新数据
    Optional<Buyer> findByEmail(String email);
}

然后在Service中调用:

@Transactional
public Buyer upsertUser(User user) {
    String userEmail = StringUtils.deleteWhitespace(user.getUsername().toLowerCase());
    buyerRepository.upsertByEmail(userEmail, user.getIamNumber(), user.getUsername(), ...);
    return buyerRepository.findByEmail(userEmail)
            .orElseThrow(() -> new RuntimeException("Upsert用户失败"));
}

MySQL则使用INSERT ... ON DUPLICATE KEY UPDATE语法,逻辑类似。

2. 给查询加悲观锁

通过在查询阶段加行锁,阻塞后续并发请求直到第一个事务提交,避免进入创建分支。

在Repository中添加带锁的查询方法:

@Repository
public interface BuyerRepository extends JpaRepository<Buyer, Long> {
    Optional<Buyer> findByEmailForUpdate(String email);
}

修改Service中的查询逻辑,使用带锁的查询:

@Transactional
public Buyer upsertUser(User user) {
    String userEmail = StringUtils.deleteWhitespace(user.getUsername().toLowerCase());
    return findByIamNumberAndUpdate(user, userEmail)
            .or(() -> findByEmailAndUpdateWithLock(user, userEmail)) // 使用带锁的查询
            .map(userRepository::save)
            .orElseGet(() -> newUser(user, userEmail));
}

private Optional<Buyer> findByEmailAndUpdateWithLock(User user, String userEmail) {
    return buyerRepository.findByEmailForUpdate(userEmail)
            .map(existing -> {
                // 执行更新逻辑
                existing.setUsername(user.getUsername());
                existing.setIamNumber(user.getIamNumber());
                return existing;
            });
}

注意:悲观锁会降低并发吞吐量,适合并发量不高的场景。

3. 捕获唯一键异常并重试

在Service层捕获唯一键冲突异常,重试查询更新逻辑,利用重试时已提交的数据避免重复创建。

@Transactional
public Buyer upsertUser(User user) {
    String userEmail = StringUtils.deleteWhitespace(user.getUsername().toLowerCase());
    try {
        return findByIamNumberAndUpdate(user, userEmail)
                .or(() -> findByEmailAndUpdate(user, userEmail))
                .map(userRepository::save)
                .orElseGet(() -> newUser(user, userEmail));
    } catch (DataIntegrityViolationException e) {
        // 判断是否为唯一键冲突异常
        if (e.getCause() instanceof SQLIntegrityConstraintViolationException && 
            e.getMessage().contains("duplicate key")) {
            // 重试一次,此时用户已存在
            return findByEmailAndUpdate(user, userEmail)
                    .map(userRepository::save)
                    .orElseThrow(() -> new RuntimeException("用户Upsert重试失败"));
        }
        throw e;
    }
}

注意:限制重试次数(建议1-2次),确保更新逻辑是幂等的,避免重复更新导致数据异常。

额外检查点

  • 确认生产环境数据库的事务隔离级别,若使用PostgreSQL,默认READ COMMITTED会导致未提交数据不可见,这是触发问题的常见原因;
  • 检查@Transactional注解的传播行为,确保方法在独立事务中执行,避免调用方事务影响隔离性。

内容的提问来源于stack exchange,提问作者mihu

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.21 13:52:40