Spring Boot 高并发实战:Redis 分布式锁 + 接口幂等性,搞定秒杀超卖与重复提交

内容分享2天前发布
0 0 0

前面咱们搞定了异步处理、监控、数据导出、文件操作,但若遇到 “图书限时秒杀”“高并发下单减库存”“用户重复提交订单” 这类场景,很容易出现两个致命问题:① 超卖(10 本库存被抢 15 本);② 重复提交(用户连续点击导致多下单)—— 就像奶茶店 “限量 10 杯爆款奶茶秒杀”,前台同时接 50 个订单,后厨没做好库存同步导致超卖,或用户连续点两次下单按钮导致重复下单,既亏成本又影响用户体验。

Spring Boot 生态搭配Redis 分布式锁(解决超卖)+ 接口幂等性设计(解决重复提交),就是应对高并发的 “双保险”:分布式锁像奶茶店的 “库存管理员”,同一时间只允许一个人操作库存;幂等性设计像 “订单唯一凭证”,同一用户同一请求只能生效一次,两者结合确保高并发下数据一致。

今天以图书管理系统的 “限时图书秒杀” 为案例,手把手实现 Redis 分布式锁(可重入、防死锁)和接口幂等性(Token+Redis),解决超卖、重复提交、并发安全等核心痛点,全程 Spring Boot+Redis 实战,代码可直接复制落地!

一、先搞懂:核心概念与企业需求(奶茶店类比)

1. 核心概念拆解(高并发 = 奶茶店秒杀)

技术 类比奶茶店 作用说明(Spring Boot 中)
Redis 分布式锁 库存管理员(管爆款奶茶库存) 高并发下独占库存操作(如减库存),避免多人同时操作导致超卖
接口幂等性 订单唯一凭证(一人一次秒杀资格) 同一用户同一请求(如重复点击下单)仅生效一次,避免重复提交

2. 解决的企业核心痛点

超卖问题:图书库存 10 本,100 个用户并发抢购,最终下单 15 本,库存出现负数;重复提交:用户网络卡顿或连续点击,导致同一订单提交多次,重复扣库存、重复生成订单;并发安全:高并发下库存计数混乱(如 A 用户减库存还没完成,B 用户已读取旧库存);分布式部署适配:多台 Spring Boot 服务器集群部署时,本地锁(synchronized)失效,需分布式锁跨服务器同步。

3. 技术选型优势(Spring Boot+Redis)

轻量高效:Redis 基于内存操作,分布式锁和幂等性验证响应时间均为毫秒级,不影响接口性能;无缝整合:Spring Boot 提供
spring-boot-starter-data-redis
依赖,配合
RedisTemplate
快速操作 Redis;可靠性高:Redis 支持持久化,配合锁超时释放机制,避免死锁导致库存冻结;通用性强:一套方案可复用在秒杀、下单、支付等所有高并发场景。

二、实操 1:Redis 分布式锁实现(解决超卖)

分布式锁的核心逻辑:同一时间只有一个线程能获取锁,操作完成后释放锁,超时未释放自动失效(避免死锁)。基于 Redis 的
SET NX EX
命令实现(原子操作,确保加锁和设置超时原子性)。

步骤 1:环境准备(Redis 依赖 + 配置)

1. 加 Redis 依赖

xml



<!-- Spring Boot整合Redis依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <version>2.7.10</version>
</dependency>
 
<!-- 连接池依赖(提升Redis性能) -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>2.11.1</version>
</dependency>
2. Redis 配置(application-dev.yml)

yaml



spring:
  redis:
    host: localhost # Redis地址(服务器填IP)
    port: 6379      # 端口
    password:       #  Redis密码(默认无)
    database: 0     # 数据库索引
    lettuce:
      pool:
        max-active: 8 # 最大连接数
        max-idle: 8   # 最大空闲连接数
        min-idle: 2   # 最小空闲连接数
        max-wait: 1000ms # 连接超时时间
 
# 分布式锁配置
distributed-lock:
  prefix: "book:lock:" # 锁Key前缀(区分不同业务)
  expire-seconds: 30   # 锁默认超时时间(30秒,避免死锁)
3. RedisTemplate 配置(序列化 + 工具类)

新建
config/RedisConfig.java
,配置 Redis 序列化(避免 Key/Value 乱码):

java

运行



package com.example.springbootdemo.config;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
 
/**
 * Redis配置(序列化+模板)
 */
@Configuration
public class RedisConfig {
 
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
 
        // Key序列化:String序列化(避免Key乱码)
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
 
        // Value序列化:JSON序列化(支持对象存储,可读性强)
        GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
        template.setValueSerializer(jsonSerializer);
        template.setHashValueSerializer(jsonSerializer);
 
        template.afterPropertiesSet();
        return template;
    }
}

步骤 2:实现分布式锁工具类(可重入 + 自动释放)

新建
utils/RedisDistributedLock.java
,封装加锁、释放锁、续期逻辑,支持可重入(同一线程可多次获取锁):

java

运行



package com.example.springbootdemo.utils;
 
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
 
import javax.annotation.Resource;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
 
/**
 * Redis分布式锁(可重入、超时自动释放、防死锁)
 */
@Component
public class RedisDistributedLock implements Lock {
 
    @Resource
    private RedisTemplate<String, Object> redisTemplate;
 
    @Value("${distributed-lock.prefix}")
    private String lockPrefix;
 
    @Value("${distributed-lock.expire-seconds}")
    private long defaultExpireSeconds;
 
    // 线程本地存储:存储当前线程获取的锁的计数(支持可重入)
    private final ThreadLocal<LockInfo> lockInfoThreadLocal = new ThreadLocal<>();
 
    // 解锁Lua脚本(原子操作,避免误解锁)
    private static final String UNLOCK_SCRIPT = """
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end
            """;
 
    /**
     * 锁信息(存储锁Key、锁值、超时时间)
     */
    private static class LockInfo {
        String lockKey;
        String lockValue;
        long expireSeconds;
        int reentrantCount; // 重入计数
 
        public LockInfo(String lockKey, String lockValue, long expireSeconds) {
            this.lockKey = lockKey;
            this.lockValue = lockValue;
            this.expireSeconds = expireSeconds;
            this.reentrantCount = 1;
        }
    }
 
    /**
     * 加锁(阻塞式,直到获取锁成功)
     */
    @Override
    public void lock() {
        lock(defaultExpireSeconds);
    }
 
    /**
     * 加锁(指定超时时间)
     */
    public void lock(long expireSeconds) {
        String lockKey = getCurrentLockKey();
        String lockValue = generateLockValue();
 
        // 1. 检查当前线程是否已持有锁(可重入)
        LockInfo existingLock = lockInfoThreadLocal.get();
        if (existingLock != null && existingLock.lockKey.equals(lockKey) && existingLock.lockValue.equals(lockValue)) {
            existingLock.reentrantCount++;
            return;
        }
 
        // 2. 循环获取锁(阻塞式)
        while (!tryLock(expireSeconds)) {
            // 短暂休眠,减少Redis压力
            try {
                TimeUnit.MILLISECONDS.sleep(50);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException("获取分布式锁失败", e);
            }
        }
    }
 
    /**
     * 尝试加锁(非阻塞式,获取成功返回true,失败返回false)
     */
    @Override
    public boolean tryLock() {
        return tryLock(defaultExpireSeconds);
    }
 
    /**
     * 尝试加锁(指定超时时间)
     */
    public boolean tryLock(long expireSeconds) {
        String lockKey = getCurrentLockKey();
        String lockValue = generateLockValue();
 
        // 检查可重入
        LockInfo existingLock = lockInfoThreadLocal.get();
        if (existingLock != null && existingLock.lockKey.equals(lockKey) && existingLock.lockValue.equals(lockValue)) {
            existingLock.reentrantCount++;
            return true;
        }
 
        // Redis SET NX EX命令:原子操作(不存在则设置,同时设置过期时间)
        Boolean success = redisTemplate.opsForValue().setIfAbsent(
                lockKey, lockValue, expireSeconds, TimeUnit.SECONDS
        );
 
        if (Boolean.TRUE.equals(success)) {
            // 加锁成功,存储锁信息到ThreadLocal
            lockInfoThreadLocal.set(new LockInfo(lockKey, lockValue, expireSeconds));
            return true;
        }
        return false;
    }
 
    /**
     * 释放锁
     */
    @Override
    public void unlock() {
        LockInfo lockInfo = lockInfoThreadLocal.get();
        if (lockInfo == null) {
            throw new IllegalMonitorStateException("当前线程未持有锁");
        }
 
        // 重入计数减1,不为0则不释放锁
        if (--lockInfo.reentrantCount > 0) {
            return;
        }
 
        // 执行Lua脚本解锁(原子操作,避免误解锁其他线程的锁)
        DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
        Long result = redisTemplate.execute(
                script,
                Collections.singletonList(lockInfo.lockKey),
                lockInfo.lockValue
        );
 
        if (result == null || result == 0) {
            throw new RuntimeException("释放分布式锁失败,锁可能已过期或被其他线程持有");
        }
 
        // 清除ThreadLocal中的锁信息
        lockInfoThreadLocal.remove();
    }
 
    // 生成锁Key(业务Key+前缀)
    private String getCurrentLockKey() {
        // 从调用栈中获取业务Key(实际场景可通过参数传入,这里简化为固定业务Key)
        // 实际使用时建议:lockPrefix + 业务标识(如图书ID),如"book:lock:1001"
        StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
        for (StackTraceElement element : stackTrace) {
            if (element.getMethodName().startsWith("seckill")) { // 秒杀业务方法名
                return lockPrefix + "seckill:" + element.getFileName();
            }
        }
        throw new RuntimeException("无法获取业务锁Key");
    }
 
    // 生成锁Value(UUID+线程ID,确保唯一性)
    private String generateLockValue() {
        return UUIDUtil.fastUUID() + ":" + Thread.currentThread().getId();
    }
 
    // 以下方法为Lock接口必填实现,分布式场景暂不使用,直接抛出异常
    @Override
    public void lockInterruptibly() throws InterruptedException {
        throw new UnsupportedOperationException("不支持可中断锁");
    }
 
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        throw new UnsupportedOperationException("不支持超时可中断锁");
    }
 
    @Override
    public Condition newCondition() {
        throw new UnsupportedOperationException("不支持Condition");
    }
}

步骤 3:秒杀业务实现(结合分布式锁防超卖)

以 “图书限时秒杀” 为场景,实现减库存、创建订单逻辑,用分布式锁确保并发安全:

java

运行



package com.example.springbootdemo.service;
 
import com.example.springbootdemo.entity.Book;
import com.example.springbootdemo.entity.Order;
import com.example.springbootdemo.mapper.BookMapper;
import com.example.springbootdemo.mapper.OrderMapper;
import com.example.springbootdemo.utils.RedisDistributedLock;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import javax.annotation.Resource;
import java.util.Date;
 
@Service
public class SeckillService {
 
    @Resource
    private BookMapper bookMapper;
 
    @Resource
    private OrderMapper orderMapper;
 
    @Resource
    private RedisDistributedLock distributedLock;
 
    /**
     * 图书秒杀(核心业务:减库存+创建订单)
     * @param bookId 图书ID
     * @param userId 用户ID
     * @return 秒杀结果
     */
    @Transactional(rollbackFor = Exception.class)
    public String seckillBook(Long bookId, Long userId) {
        String lockKey = "book:lock:seckill:" + bookId; // 锁Key:按图书ID粒度(避免全局锁)
 
        try {
            // 1. 获取分布式锁(指定超时时间10秒)
            boolean locked = distributedLock.tryLock(10);
            if (!locked) {
                return "秒杀太火爆,请稍后再试!";
            }
 
            // 2. 查询图书库存(加锁后查询,确保库存是最新值)
            Book book = bookMapper.selectById(bookId);
            if (book == null) {
                return "图书不存在!";
            }
            if (book.getStock() <= 0) {
                return "秒杀已结束,库存不足!";
            }
 
            // 3. 检查用户是否已秒杀过(避免重复下单,配合幂等性)
            Order existingOrder = orderMapper.lambdaQuery()
                    .eq(Order::getBookId, bookId)
                    .eq(Order::getUserId, userId)
                    .one();
            if (existingOrder != null) {
                return "您已秒杀过该图书,不可重复购买!";
            }
 
            // 4. 减库存(数据库乐观锁或悲观锁,这里用悲观锁配合分布式锁)
            int updateCount = bookMapper.decrementStock(bookId);
            if (updateCount == 0) {
                return "库存已被抢完,请稍后再试!";
            }
 
            // 5. 创建秒杀订单
            Order order = new Order();
            order.setBookId(bookId);
            order.setUserId(userId);
            order.setOrderTime(new Date());
            order.setAmount(book.getPrice());
            order.setStatus(0); // 0=待支付
            orderMapper.insert(order);
 
            return "秒杀成功!订单ID:" + order.getId();
        } finally {
            // 6. 释放锁(必须在finally中,确保锁一定释放)
            distributedLock.unlock();
        }
    }
}

步骤 4:秒杀接口(暴露 HTTP 接口供前端调用)

java

运行



package com.example.springbootdemo.controller;
 
import com.example.springbootdemo.common.Result;
import com.example.springbootdemo.service.SeckillService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
 
import javax.annotation.Resource;
 
@RestController
@RequestMapping("/seckill")
public class SeckillController {
 
    @Resource
    private SeckillService seckillService;
 
    /**
     * 秒杀接口(需登录,配合JWT认证)
     */
    @PostMapping("/book")
    @PreAuthorize("isAuthenticated()") // 已登录用户可访问
    public Result<String> seckillBook(
            @RequestParam Long bookId,
            @RequestParam Long userId) { // 实际场景从JWT令牌中获取userId
        String result = seckillService.seckillBook(bookId, userId);
        return Result.success(result);
    }
}

步骤 5:测试分布式锁防超卖效果

准备数据:图书 ID=1001,库存 = 10;用 JMeter 模拟 100 个并发请求(调用
/seckill/book
接口,bookId=1001,userId=1~100);结果验证:
数据库
book
表库存 = 0(无超卖);
order
表新增 10 条订单(与库存一致);日志中无 “库存不足” 和 “重复购买” 的冲突报错。

三、实操 2:接口幂等性实现(解决重复提交)

分布式锁解决了 “多用户并发超卖”,但没解决 “同一用户重复提交”(如用户连续点击下单按钮)。接口幂等性通过 “Token 验证机制” 实现:用户下单前先获取 Token,提交订单时携带 Token,验证通过则处理业务,处理后失效 Token,重复提交则直接返回失败。

步骤 1:幂等性 Token 工具类

新建
utils/IdempotentTokenUtil.java
,封装 Token 生成、验证、失效逻辑:

java

运行



package com.example.springbootdemo.utils;
 
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
 
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
 
/**
 * 幂等性Token工具类(Redis存储Token)
 */
@Component
public class IdempotentTokenUtil {
 
    @Resource
    private RedisTemplate<String, Object> redisTemplate;
 
    // Token前缀(区分不同业务)
    private static final String TOKEN_PREFIX = "book:idempotent:token:";
    // Token有效期(30分钟,避免Token长期有效)
    private static final long TOKEN_EXPIRE_MINUTES = 30;
 
    /**
     * 生成幂等性Token
     * @param userId 用户ID(区分不同用户)
     * @param businessType 业务类型(如图书秒杀:"seckill_book")
     * @return Token字符串
     */
    public String generateToken(Long userId, String businessType) {
        String token = UUIDUtil.fastUUID();
        String tokenKey = getTokenKey(userId, businessType);
        // 存储Token到Redis(用户+业务类型唯一,避免同一用户同一业务重复获取Token)
        redisTemplate.opsForValue().set(tokenKey, token, TOKEN_EXPIRE_MINUTES, TimeUnit.MINUTES);
        return token;
    }
 
    /**
     * 验证Token有效性(验证通过后失效Token)
     * @param userId 用户ID
     * @param businessType 业务类型
     * @param token 待验证Token
     * @return 验证结果(true=有效,false=无效/已使用)
     */
    public boolean validateToken(Long userId, String businessType, String token) {
        String tokenKey = getTokenKey(userId, businessType);
        // Redis GETANDDELETE命令:原子操作(获取Token并删除,避免重复验证)
        Object storedToken = redisTemplate.opsForValue().getAndDelete(tokenKey);
        return storedToken != null && storedToken.equals(token);
    }
 
    // 生成Token的Redis Key(用户ID+业务类型)
    private String getTokenKey(Long userId, String businessType) {
        return TOKEN_PREFIX + userId + ":" + businessType;
    }
}

步骤 2:修改秒杀业务(整合幂等性 Token)

1. 新增获取 Token 接口

java

运行



@RestController
@RequestMapping("/idempotent")
public class IdempotentController {
 
    @Resource
    private IdempotentTokenUtil idempotentTokenUtil;
 
    /**
     * 获取秒杀Token(用户下单前必须调用)
     */
    @PostMapping("/token/seckill")
    @PreAuthorize("isAuthenticated()")
    public Result<String> getSeckillToken(@RequestParam Long userId) {
        // 业务类型:图书秒杀
        String token = idempotentTokenUtil.generateToken(userId, "seckill_book");
        return Result.success("Token获取成功", token);
    }
}
2. 修改秒杀接口(添加 Token 验证)

java

运行



@RestController
@RequestMapping("/seckill")
public class SeckillController {
 
    @Resource
    private SeckillService seckillService;
 
    @Resource
    private IdempotentTokenUtil idempotentTokenUtil;
 
    @PostMapping("/book")
    @PreAuthorize("isAuthenticated()")
    public Result<String> seckillBook(
            @RequestParam Long bookId,
            @RequestParam Long userId,
            @RequestParam String token) { // 前端携带Token提交
 
        // 1. 幂等性Token验证
        boolean tokenValid = idempotentTokenUtil.validateToken(userId, "seckill_book", token);
        if (!tokenValid) {
            return Result.error(4001, "重复提交或Token已失效,请刷新页面重试!");
        }
 
        // 2. 调用秒杀业务(已加分布式锁)
        String result = seckillService.seckillBook(bookId, userId);
        return Result.success(result);
    }
}

步骤 3:测试幂等性效果

用户先调用
/idempotent/token/seckill
获取 Token(如
token=xxx123
);第一次提交:携带
bookId=1001

userId=1001

token=xxx123
,秒杀成功;第二次提交:相同参数再次调用,返回 “重复提交或 Token 已失效”;结果验证:
order
表仅新增 1 条订单(无重复提交)。

四、分布式锁 + 幂等性的 6 个要小心

分布式锁粒度太粗,导致性能瓶颈

坑:用 “book:lock:seckill” 全局锁,所有图书秒杀共用一把锁,并发性能差;解决:锁粒度细化到 “图书 ID”(如
book:lock:seckill:1001
),不同图书秒杀互不影响。

锁超时时间设置不合理,导致死锁 / 业务中断

坑:锁超时设 5 秒,而秒杀业务(查库存 + 创建订单)需要 10 秒,锁提前释放导致并发问题;解决:超时时间略大于业务最大执行时间(如业务最长 8 秒,超时设 10 秒),或实现锁续期(如定时任务延长未释放锁的超时时间)。

误解锁其他线程的锁

坑:释放锁时直接
del key
,可能删除其他线程持有的锁(如当前线程锁超时,其他线程已获取锁);解决:用 Lua 脚本原子验证锁值(
get key == value
)后再删除,避免误解锁。

幂等性 Token 未绑定用户,导致 Token 被盗用

坑:Token 仅按业务类型生成,攻击者获取他人 Token 后可恶意提交;解决:Token 绑定 “用户 ID + 业务类型”,确保只有 Token 所属用户能使用。

Redis 宕机导致分布式锁失效

坑:单节点 Redis 宕机,分布式锁无法获取,秒杀业务中断;解决:生产环境用 Redis 集群(主从 + 哨兵),确保 Redis 高可用;或降级为本地锁(仅单机部署时)。

重复提交验证依赖前端,导致绕过

坑:仅依赖前端禁用按钮防止重复提交,攻击者直接调用接口可绕过;解决:前端禁用按钮(友好体验)+ 后端 Token 验证(核心防护),双重保障。

高并发场景的核心是 “锁 + 幂等” 双重保障

Spring Boot+Redis 实现的 “分布式锁 + 接口幂等性”,完美解决了高并发下的超卖和重复提交问题:分布式锁确保 “多用户并发安全”,幂等性确保 “单用户重复提交安全”,两者结合覆盖了高并发场景的核心风险点。

就像奶茶店的秒杀活动:分布式锁是 “库存管理员”,确保同一时间只有一个人操作库存;幂等性 Token 是 “唯一抢购凭证”,确保每个人只能抢购一次。掌握这两个技能,你就能应对秒杀、下单、支付等所有高并发业务场景,配合之前的系列实战技能,你的 Spring Boot 项目将具备 “高并发、高可用、高安全” 的企业级能力。

📦 给你的实战包:我整理了 “Spring Boot 高并发实战全量包”,包含:① Redis 分布式锁工具类;② 幂等性 Token 工具类;③ 秒杀业务完整代码;④ JMeter 压测脚本 + Postman 测试用例;⑤ Redis 集群配置指南,你不用手动写代码,解压后导入 IDEA 就能运行,需要评论区留言。

© 版权声明

相关文章

暂无评论

none
暂无评论...