Java实战:封装Redis非阻塞分布式锁,彻底解决表单重复提交主键冲突

🍓 前言

在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非阻塞分布式锁+函数式封装 ,既能从根源解决并发问题,又能保证代码优雅性。核心思路是锁控制并发,数据库状态控制幂等,双层保障杜绝重复提交。

本文封装的工具类可直接接入项目,适配绝大多数防重复提交场景,无需重复造轮子。

相关推荐
尽兴-1 小时前
超越缓存:Redis Stack 如何将 Redis 打造成全能实时数据平台
数据库·redis·缓存·redis stack
启山智软1 小时前
【使用 Java(JSP)实现的简单商城页面前端示例】
java·前端·商城开发
一个有温度的技术博主2 小时前
Redis系列七:Java客户端Jedis的入门
java·数据库·redis
霖霖总总2 小时前
[Redis小技巧21]从 Binlog 到缓存:Canal + Redis 同步架构全解
redis·缓存
LSL666_2 小时前
BaseMapper——新增和删除
java·开发语言·mybatis·mybatisplus
傻啦嘿哟2 小时前
Python操作Redis:高效缓存设计与实战
redis·python·缓存
后端AI实验室2 小时前
我让AI模拟面试官考了我一个小时,然后我沉默了
java·ai
金銀銅鐵2 小时前
Byte Buddy 生成的类的结构如何?(第二篇)
java·后端
StackNoOverflow2 小时前
Spring MVC零散知识点记录
java·spring·mvc