文章目录
- [1. 前言](#1. 前言)
- [2. 问题描述](#2. 问题描述)
- [3. 问题分析](#3. 问题分析)
- [4. 解决方案](#4. 解决方案)
- [5. 结束](#5. 结束)
1. 前言
高并发场景下,数据库操作可能因线程竞争导致重复唯一键(DuplicateKey)异常。此类问题常出现在分布式系统或高流量业务中,需从代码、数据库、并发控制等多维度分析解决。
2. 问题描述
org.springframework.dao.DuplicateKeyException
是 Spring 对数据库唯一键冲突的封装异常,通常由以下操作触发:
- 插入或更新数据时违反唯一约束(如主键、唯一索引)。
- 并发请求同时插入相同唯一键值,数据库仅允许其中一个成功。
典型场景:用户注册时用户名唯一性检查与插入操作非原子性,导致多个线程同时通过检查后重复插入。
3. 问题分析
根本原因
- 非原子性操作:先查询后插入的分步操作,缺乏事务或锁保护。
- 数据库隔离级别不足 :如使用
READ_COMMITTED
隔离级别,无法防止幻读。 - 唯一索引设计缺陷:未覆盖业务场景的全部约束条件。
并发竞争逻辑
Thread A → 检查key不存在 → 准备插入
Thread B → 检查key不存在 → 插入成功
Thread A → 插入失败(DuplicateKeyException)
问题重现
模拟代码 1
java
@RestController
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/register")
public String register(String username) {
// 高并发时多个线程可能同时通过检查
if (!userService.existsByUsername(username)) {
userService.save(new User(username));
return "Success";
}
return "Username exists";
}
}
模拟代码 2---我自己遇到的问题
java
@Override
public R isExistByFile(String fileName) {
if (minioTemplate.isExist(fileProperties.getBucketName(),fileName)){
//文件路径----字词卡片子卡片需要有唯一id标识
if (fileName.contains("生成字词卡片音频")){
HashMap<Object, Object> result;
TeachExerciseFile exerciseFile = new TeachExerciseFile();
exerciseFile.setPath(fileName);
//文件名称
exerciseFile.setName(fileName.substring(fileName.lastIndexOf("/"), fileName.lastIndexOf(".")));
//文件后缀
String extend = fileName.substring(fileName.lastIndexOf(".") + 1);
exerciseFile.setExt(extend);
teachExerciseFileMapper.insert(exerciseFile);
result = new HashMap<>();
result.put("file_id", exerciseFile.getId());
result.put("file_url", exerciseFile.getPath());
return R.ok().setCode(CommonConstants.SUCCESS).setData(result);
}
return R.ok().setCode(CommonConstants.SUCCESS).setData(fileName);
}
return R.ok().setCode(CommonConstants.FAIL);
}
触发条件
- 使用 JMeter 或 Postman 模拟 100+ 并发注册同一用户名。
- 数据库日志显示多条
INSERT
语句尝试,最终抛出DuplicateKeyException
。
4. 解决方案
数据库层
- 唯一索引强制约束 :确保表字段有唯一索引,如
ALTER TABLE users ADD UNIQUE (username)
。 - ON DUPLICATE KEY UPDATE:MySQL 特有语法,冲突时转为更新。
应用层
- 悲观锁 :在查询前加锁(如
SELECT FOR UPDATE
),但影响性能。 - 乐观锁:通过版本号控制,但需重试机制。
- 分布式锁:Redis 或 Zookeeper 实现互斥锁,确保唯一性检查与插入原子性。
代码改造示例 1
java
@Transactional
public String registerWithLock(String username) {
// 先尝试插入,依赖数据库唯一索引
try {
userRepository.save(new User(username));
return "Success";
} catch (DuplicateKeyException e) {
return "Username exists";
}
}
代码改造示例 2 -- 我自己的解决方案
java
// -----------------------------------修改点---定义一个 ReentrantLock 对象
private static final ReentrantLock insertLock = new ReentrantLock();
@Override
public R isExistByFile(String fileName) {
if (minioTemplate.isExist(fileProperties.getBucketName(),fileName)){
//文件路径----字词卡片子卡片需要有唯一id标识
if (fileName.contains("生成字词卡片音频")){
insertLock.lock();//----------------------修改点---加锁
HashMap<Object, Object> result;
try {
TeachExerciseFile exerciseFile = new TeachExerciseFile();
exerciseFile.setPath(fileName);
//文件名称
exerciseFile.setName(fileName.substring(fileName.lastIndexOf("/"), fileName.lastIndexOf(".")));
//文件后缀
String extend = fileName.substring(fileName.lastIndexOf(".") + 1);
exerciseFile.setExt(extend);
teachExerciseFileMapper.insert(exerciseFile);
result = new HashMap<>();
result.put("file_id", exerciseFile.getId());
result.put("file_url", exerciseFile.getPath());
} finally {
insertLock.unlock(); //----------------------修改点---释放锁
}
return R.ok().setCode(CommonConstants.SUCCESS).setData(result);
}
return R.ok().setCode(CommonConstants.SUCCESS).setData(fileName);
}
return R.ok().setCode(CommonConstants.FAIL);
}
5. 结束
解决高并发重复键问题的核心在于 保证操作的原子性 。优先通过数据库约束拦截冲突,结合业务场景选择锁机制或重试策略。实际开发中需权衡性能与一致性需求。
任何疑问,欢迎私信指教!!!
分享:
对付敌人我们需要很大的勇气,但在朋友面前坚定立场需要更大的勇气。------阿不思 邓布利多