生产与测试环境中用户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
相关产品推荐
相关产品推荐

