🍓 前言
在Web表单提交、接口调用场景中,经常遇到用户重复点击、接口重复调用导致的问题:同一业务单号并发执行插入操作,事务未提交前查询无数据,最终触发数据库主键/唯一键冲突报错。
针对这类高并发重复提交问题,本文基于原生RedisTemplate封装通用非阻塞分布式锁工具类 ,实现锁逻辑与业务逻辑解耦 ,兼顾易用性与安全性,同时解决锁与事务配合、锁释放时机、失败重试等核心坑点。
📌 业务痛点回顾
-
场景:表单提交接口,根据业务单号执行插入操作
-
问题:第一次请求未执行完、事务未提交,第二次请求进来查询数据库无数据,也执行插入,导致主键冲突
-
需求:非阻塞获取锁(获取失败直接提示操作中)、执行成功禁止重复提交、执行失败释放锁允许重试、60秒兜底防死锁
🛠️ 前提准备
基于已有的RedisService工具类(包含setIfAbsent、Lua脚本释放锁),SpringBoot环境,JDK8+(函数式接口封装)
1. 原有RedisService核心锁方法(精简版)
java
@Slf4j
@Component
@SuppressWarnings(value = {"unchecked", "rawtypes"})
public class RedisService {
@Autowired
public RedisTemplate redisTemplate;
/**
* 非阻塞获取分布式锁(原子操作)
* @param lockKey 锁键
* @param requestId 唯一标识,防止误删锁
* @param expireTime 过期时间(秒)
* @return 获取结果
*/
public boolean tryLock(String lockKey, String requestId, long expireTime) {
try {
Boolean result = redisTemplate.opsForValue().setIfAbsent(
lockKey,
requestId,
expireTime,
TimeUnit.SECONDS
);
return Boolean.TRUE.equals(result);
} catch (Exception e) {
log.error("获取分布式锁失败, lockKey:{}", lockKey, e);
return false;
}
}
/**
* 安全释放锁(Lua脚本保证原子性)
* @param lockKey 锁键
* @param requestId 唯一标识
* @return 释放结果
*/
public boolean releaseLock(String lockKey, String requestId) {
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
try {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(luaScript);
redisScript.setResultType(Long.class);
Long result = (Long) redisTemplate.execute(
redisScript,
Collections.singletonList(lockKey),
requestId
);
return result != null && result > 0;
} catch (Exception e) {
log.error("释放分布式锁失败, lockKey:{}", lockKey, e);
return false;
}
}
// 省略其他缓存方法...
}
🚀 核心封装:通用分布式锁工具类
利用Supplier函数式接口,将锁的获取、释放、异常处理封装成公共方法,业务代码无需重复编写锁逻辑,只需关注核心业务。
RedisLockUtil 封装类
java
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.UUID;
import java.util.function.Supplier;
/**
* 通用Redis非阻塞分布式锁工具类
* 适配:表单防重、接口防重复提交、并发插入控制
*/
@Slf4j
@Component
public class RedisLockUtil {
@Autowired
private RedisService redisService;
/**
* 非阻塞锁执行通用方法
* @param lockKey 锁唯一键(建议:lock:业务模块:业务单号)
* @param expireSeconds 锁过期时间(秒,兜底防死锁)
* @param businessLogic 业务逻辑代码块
* @param <T> 返回值泛型
* @return 业务执行结果
*/
public <T> T executeWithLock(String lockKey, int expireSeconds, Supplier<T> businessLogic) {
// 生成唯一请求ID,防止误删其他线程的锁
String requestId = UUID.randomUUID().toString();
// 1. 非阻塞获取锁
boolean isLocked = redisService.tryLock(lockKey, requestId, expireSeconds);
if (!isLocked) {
log.warn("[分布式锁] 获取失败,锁键:{},操作进行中", lockKey);
throw new RuntimeException("操作进行中,请勿重复提交!");
}
try {
// 2. 执行核心业务逻辑
return businessLogic.get();
} catch (Exception e) {
log.error("[分布式锁] 业务执行失败,锁键:{},允许重试", lockKey, e);
throw new RuntimeException("提交失败:" + e.getMessage() + ",可重试!");
} finally {
// 3. 无论成功/失败,必须释放锁
boolean isReleased = redisService.releaseLock(lockKey, requestId);
if (!isReleased) {
log.warn("[分布式锁] 释放失败,锁键:{},requestId:{}(可能已过期自动释放)", lockKey, requestId);
}
}
}
}
🎯 实战调用:表单提交防重
调用封装好的工具类,代码极度简洁,锁逻辑完全剥离,业务代码更清晰。
业务层调用代码
java
@Service
@Slf4j
public class FormSubmitService {
@Autowired
private RedisLockUtil redisLockUtil;
// 业务Mapper,隐藏具体表信息
@Autowired
private BizFormMapper bizFormMapper;
/**
* 表单提交核心方法
* @param djbh 业务单号(唯一标识)
* @param formDTO 表单参数
* @return 接口返回结果
*/
public Result submitForm(String djbh, BizFormDTO formDTO) {
try {
// 构造锁键:隐藏具体业务模块,规范命名
String lockKey = "lock:biz_form_submit:" + djbh;
// 调用锁工具类,60秒过期兜底,执行业务逻辑
return redisLockUtil.executeWithLock(lockKey, 60, () -> {
// ========== 核心业务逻辑 ==========
// 1. 查询是否已存在(成功后禁止重复提交)
Object existData = bizFormMapper.selectByDjbh(djbh);
if (existData != null) {
return Result.fail("该单据已提交,请勿重复操作!");
}
// 2. 执行插入操作
bizFormMapper.insertForm(formDTO);
return Result.success("提交成功!");
// ================================
});
} catch (RuntimeException e) {
// 捕获锁异常、业务异常,统一返回
return Result.fail(e.getMessage());
}
}
// 省略Result成功/失败封装方法...
}
⚠️ 关键避坑点(必看)
1. 锁与事务的配合问题
错误做法:锁写在@Transactional事务内部,导致锁释放早于事务提交,仍有并发冲突风险。
正确做法:锁包裹事务,本文封装方式天然满足(锁在事务外层,事务提交后再释放锁)。
2. 锁释放时机
-
执行成功:finally块主动释放锁,后续请求进来会查询数据库已存在,直接拒绝,无风险
-
执行失败:释放锁,允许用户重试提交
-
服务宕机:Redis 60秒自动过期,避免死锁
3. 锁键设计规范
遵循 lock:业务模块:唯一单号 格式,保证锁的细粒度,避免全局锁影响性能。
4. 非阻塞特性
获取锁失败直接返回提示,不阻塞线程,提升用户体验和接口性能,适配表单提交场景。
📊 方案优势
-
解耦彻底:锁逻辑与业务代码分离,一处封装,多处复用
-
安全可靠:requestId防误删、Lua脚本释放、过期兜底,杜绝死锁
-
易用简洁:调用时只需传入锁键、过期时间、业务代码块,零冗余
-
适配场景:表单防重、接口限流、并发插入控制、幂等性保证
📝 总结
针对Java后端表单重复提交、并发主键冲突问题,通过Redis非阻塞分布式锁+函数式封装 ,既能从根源解决并发问题,又能保证代码优雅性。核心思路是锁控制并发,数据库状态控制幂等,双层保障杜绝重复提交。
本文封装的工具类可直接接入项目,适配绝大多数防重复提交场景,无需重复造轮子。