Redis 分布式锁实战:解决马拉松报名并发冲突与 Lua 原子性优化

在马拉松赛事系统中,"在线报名" 是典型的高并发场景 ------ 当热门赛事开放报名时,可能出现每秒数千次的报名请求,若不加以控制,极易导致 "超售"(报名人数超过赛事限额)、"重复报名"(同一用户多次提交)等并发冲突问题。Redis 作为高性能的分布式缓存,其单线程模型与原子命令特性,使其成为实现分布式锁的理想选择;而 Lua 脚本则能进一步保障锁操作的原子性,避免 "锁竞争""死锁" 等隐患。本文将以马拉松赛事系统为背景,从原理到实战,带你掌握 Redis 分布式锁的设计、实现与优化。

一、为什么需要 Redis 分布式锁?------ 马拉松报名的并发痛点​

在深入技术细节前,先明确马拉松报名场景的并发挑战,理解分布式锁的必要性。​

1.1、 马拉松报名的核心并发问题​

1.1.1、 超售问题(最致命)​

马拉松赛事有严格的人数限额(如 1000 人),当多个用户同时提交报名请求时,若未加控制,可能出现 "最后 1 个名额被多个用户同时抢占" 的情况:​

  • 示例:赛事剩余 1 个名额,用户 A、B 同时查询到 "剩余 1 人",均提交报名,最终系统记录 1001 人报名,超出限额。

1.1.2、 重复报名问题​

同一用户可能通过多次点击、多设备提交等方式重复报名,若未做去重,会导致:​

  • 报名数据冗余(同一用户多条报名记录);
  • 资源浪费(重复占用名额,排挤其他用户)。

1.1.3、 数据一致性问题​

报名过程涉及 "扣减剩余名额""生成报名订单""写入用户报名记录" 等多步操作,并发场景下易出现数据不一致:​

  • 示例:用户报名成功但剩余名额未扣减,或名额已扣减但报名记录未生成。

1.2、 传统解决方案的局限​

1.2.1、 本地锁(synchronized/Lock)​

  • 问题:仅能控制单个服务实例的并发,分布式部署(多台应用服务器)时失效;
  • 示例:服务部署在 3 台服务器,每台服务器的本地锁只能控制本机请求,3 台服务器仍可能同时操作同一数据,导致超售。

1.2.2、 数据库锁(悲观锁 / 乐观锁)​

  • 悲观锁(SELECT ... FOR UPDATE):
  • 优势:确保数据一致性;
  • 劣势:会锁定数据库行,并发高时导致大量请求阻塞,数据库压力骤增,甚至引发死锁。
  • 乐观锁(版本号 / 时间戳):
  • 优势:无锁阻塞,性能较高;
  • 劣势:冲突时需重试,高并发下重试次数过多,用户体验差(如 "报名失败,请重试")。

1.3、 Redis 分布式锁的核心优势​

Redis 分布式锁通过 "分布式环境下共享锁资源",解决了本地锁与数据库锁的局限,核心优势如下:​

|--------|----------------------------------------------------------|-------------------------|
| 优势维度 | 具体特性 | 适配场景 |
| 分布式有效性 | 锁资源存储在 Redis 集群,所有服务实例共享,支持跨服务器、跨进程控制并发 | 多服务实例部署的马拉松报名系统 |
| 高性能 | 基于内存操作,锁获取 / 释放延迟低至毫秒级,支持每秒万级并发请求 | 马拉松报名峰值 QPS(数千次 / 秒) |
| 原子性保障 | 支持 SETNX(SET if Not Exists)、DEL 等原子命令,结合 Lua 脚本可实现复杂原子操作 | 避免 "锁竞争""死锁" 等问题 |
| 灵活性 | 支持设置锁过期时间,自动释放过期锁,避免死锁;支持可重入、公平锁等高级特性 | 适配报名场景的 "自动释放锁""防死锁" 需求 |
| 高可用 | 基于 Redis 集群(主从、哨兵、Cluster)部署,锁资源不会因单点故障丢失 | 保障赛事报名过程不中断 |

二、Redis 分布式锁核心原理:从基础命令到原子性设计​

2.1、 核心命令:实现锁的基础​

Redis 分布式锁的实现依赖以下核心命令,需理解其特性与使用场景:

|-----------------------------|-----------------------------------------------------------------|---------------------------------------------------------|
| 命令 | 作用 | 锁场景应用 |
| SET key value NX EX seconds | 原子操作:仅当 key 不存在时(NX=Not Exists),设置 key-value,并设置过期时间(EX=Expire) | 获取锁:key 为锁标识(如marathon:lock:1001),value 为唯一标识,EX 为锁过期时间 |
| DEL key | 删除 key,释放锁资源 | 释放锁:报名完成后,删除锁标识,允许其他请求获取锁 |
| EXISTS key | 判断 key 是否存在,存在返回 1,不存在返回 0 | 检查锁:判断锁是否已被占用 |
| PEXPIRE key milliseconds | 为已存在的 key 设置过期时间(毫秒级 | 锁续期:报名操作耗时较长时,延长锁有效期,避免锁过期 |
| GET key | 获取 key 对应的 value | 验证锁:判断当前锁的持有者是否为当前请求(防误删) |

关键说明:SET NX EX的原子性​

  • 为什么需要原子性:若分两步执行(先 SETNX,再 EXPIRE),两步之间可能出现故障(如服务宕机),导致锁未设置过期时间,引发死锁;
  • SET NX EX的优势:将 "判断锁是否存在""设置锁""设置过期时间" 三步合并为一个原子操作,避免中间故障导致的死锁风险。

2.2、 锁的核心设计要素​

一个安全的 Redis 分布式锁需满足以下 4 个核心要素,否则易出现 "锁失效""死锁" 等问题:​

2.2.1、 互斥性​

  • 要求:同一时间只能有一个请求获取锁;
  • 实现:通过SET NX EX命令,确保只有第一个请求能成功设置锁 key,后续请求因 key 已存在而失败。

2.2.2、 防死锁​

  • 要求:锁必须有过期时间,避免持有锁的请求故障(如服务宕机)后,锁永久占用;
  • 实现:SET NX EX seconds命令设置过期时间(如 30 秒),过期后 Redis 自动删除锁 key,释放锁资源。

2.2.3、 防误删​

  • 要求:只能释放自己持有的锁,不能释放其他请求的锁;
  • 实现:
  1. 获取锁时,设置 value 为 "唯一标识"(如 UUID + 线程 ID);
  2. 释放锁前,先获取锁的 value,验证是否为自己的唯一标识,是则删除,否则不操作。

2.2.4、 高可用​

  • 要求:Redis 集群故障时,锁资源不丢失,仍能正常获取 / 释放锁;
  • 实现:
  • 基于 Redis 主从 + 哨兵部署:主库故障时,哨兵自动切换从库为主库,锁资源同步到新主库;
  • 基于 Redis Cluster 部署:锁 key 分布在不同槽位,单个节点故障不影响其他节点的锁资源。

2.3、 Lua 脚本:保障复杂操作的原子性​

2.3.1、 为什么需要 Lua 脚本?​

Redis 执行单个命令是原子的,但多个命令组合(如 "获取锁 value→验证→删除锁")是非原子的,高并发下可能出现 "误删锁" 问题:​

  • 示例:
  1. 请求 A 持有锁(过期时间 30 秒),执行报名操作耗时过长(35 秒),锁自动过期;
  2. Redis 自动释放锁,请求 B 成功获取锁;
  3. 请求 A 操作完成,执行 "获取 value→验证→删除",此时请求 A 的 value 已失效,但因 "获取→验证→删除" 非原子,可能误删请求 B 的锁。

2.3.2、 Lua 脚本的原子性优势​

Redis 执行 Lua 脚本时,会将整个脚本作为一个整体执行,期间不中断,确保多个命令的原子性:​

  • 解决问题:避免 "获取 value→验证→删除" 过程中的并发干扰,防止误删锁;
  • 性能优势:减少客户端与 Redis 的网络交互次数(多个命令一次发送),提升性能。

2.3.3、 锁操作的 Lua 脚本示例​

1、释放锁脚本:验证 value 为当前请求的唯一标识,是则删除锁,否则不操作:

复制代码
-- 释放锁的Lua脚本:key为锁标识,argv[1]为当前请求的唯一标识
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1]) -- 验证通过,删除锁
else
    return 0 -- 验证失败,不操作
end

2、锁续期脚本:验证 value 为当前请求的唯一标识,是则延长锁过期时间:

复制代码
-- 锁续期的Lua脚本:key为锁标识,argv[1]为唯一标识,argv[2]为续期时间(秒)
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('EXPIRE', KEYS[1], ARGV[2]) -- 续期
else
    return 0 -- 非锁持有者,不续期
end

三、实战:Redis 分布式锁在马拉松系统中的落地​

3.1、 系统架构与核心功能​

马拉松赛事系统采用 "微服务 + Redis Cluster+MySQL" 架构,核心功能包括:​

  1. 赛事发布:管理员创建赛事(设置名称、限额、报名时间、报名条件等);
  2. 在线报名:用户提交报名信息,系统验证资格、扣减剩余名额、生成报名订单;
  3. 成绩查询:用户查询自己的参赛成绩;
  4. 实时追踪:实时展示选手的比赛进度(如当前位置、用时);
  5. 数据分析:统计报名人数、性别分布、年龄段分布等数据。

核心场景:在线报名(高并发、需防超售、防重复报名),下文重点围绕该场景实现 Redis 分布式锁。​

3.2、 步骤 1:环境准备(Redis Cluster+Spring Boot 集成)​

3.2.1、 部署 Redis Cluster​

  • 目的:确保 Redis 高可用,避免锁资源丢失;
  • 部署:参考 Redis 官方文档,部署 3 主 3 从的 Redis Cluster 集群,每个主节点负责一部分槽位,支持自动故障转移。

3.2.2、 Spring Boot 集成 Redis​

1、引入依赖(pom.xml):

XML 复制代码
<!-- Spring Data Redis依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Redis客户端(Lettuce,Spring Boot默认) -->
<dependency>
    <groupId>io.lettuce.core</groupId>
    <artifactId>lettuce-core</artifactId>
</dependency>
<!-- 工具类依赖(用于生成UUID唯一标识) -->
<dependency>
    <groupId>java.util</groupId>
    <artifactId>uuid</artifactId>
    <version>1.0</version>
    <scope>system</scope>
    <systemPath>${project.basedir}/src/main/resources/lib/uuid.jar</systemPath>
</dependency>

(注:实际项目中,UUID 可通过java.util.UUID类生成,无需额外引入 jar 包,此处为示例)​

2、配置 Redis Cluster(application.yml):

XML 复制代码
spring:
  redis:
    cluster:
      nodes: # Redis Cluster节点列表(IP:端口)
        - 192.168.1.10:6379
        - 192.168.1.11:6379
        - 192.168.1.12:6379
        - 192.168.1.13:6379
        - 192.168.1.14:6379
        - 192.168.1.15:6379
      max-redirects: 3 # 最大重定向次数(Cluster模式下槽位不匹配时的重定向)
    lettuce:
      pool:
        max-active: 16 # 连接池最大活跃连接数
        max-idle: 8 # 连接池最大空闲连接数
        min-idle: 4 # 连接池最小空闲连接数
    timeout: 5000 # Redis连接超时时间(毫秒)

3、配置 RedisTemplate(序列化与连接池):

java 复制代码
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;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        // 序列化配置:key用String序列化,value用JSON序列化
        StringRedisSerializer keySerializer = new StringRedisSerializer();
        GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer();

        template.setKeySerializer(keySerializer);
        template.setValueSerializer(valueSerializer);
        template.setHashKeySerializer(keySerializer);
        template.setHashValueSerializer(valueSerializer);

        template.afterPropertiesSet();
        return template;
    }
}

3.3、 步骤 2:实现 Redis 分布式锁工具类​

封装 Redis 分布式锁的 "获取锁""释放锁""锁续期" 等核心操作,使用 Lua 脚本保障原子性,工具类设计如下:

java 复制代码
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Component
public class RedisDistributedLock {

    // RedisTemplate注入(Spring自动注入)
    private final RedisTemplate<String, Object> redisTemplate;

    // 释放锁的Lua脚本(静态常量,避免重复创建)
    private static final String RELEASE_LOCK_SCRIPT = 
        "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
        "    return redis.call('DEL', KEYS[1]) " +
        "else " +
        "    return 0 " +
        "end";

    // 锁续期的Lua脚本
    private static final String RENEW_LOCK_SCRIPT = 
        "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
        "    return redis.call('EXPIRE', KEYS[1], ARGV[2]) " +
        "else " +
        "    return 0 " +
        "end";

    // 构造函数注入RedisTemplate
    public RedisDistributedLock(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 获取分布式锁
     * @param lockKey 锁标识(如"marathon:lock:1001",1001为赛事ID)
     * @param expireSeconds 锁过期时间(秒),避免死锁
     * @param retryTimes 重试次数(获取锁失败时重试)
     * @param retryIntervalMs 重试间隔(毫秒)
     * @return 锁的唯一标识(UUID),获取失败返回null
     */
    public String tryLock(String lockKey, int expireSeconds, int retryTimes, long retryIntervalMs) {
        // 生成锁的唯一标识(UUID+线程ID,避免同一服务内不同线程误删锁)
        String lockValue = UUID.randomUUID().toString() + ":" + Thread.currentThread().getId();
        DefaultRedisScript<Long> lockScript = new DefaultRedisScript<>();
        lockScript.setScriptText("return redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2])");
        lockScript.setResultType(Long.class);

        // 循环重试获取锁
        for (int i = 0; i <= retryTimes; i++) {
            // 执行SET NX EX命令,原子获取锁
            Long result = redisTemplate.execute(
                lockScript,​
Collections.singletonList (lockKey), // KEYS 参数(锁标识)​
lockValue, String.valueOf (expireSeconds) // ARGV 参数(锁唯一标识、过期时间)​
);​
// 结果为 1 表示获取锁成功,返回锁唯一标识​
if (result != null && result == 1) {​
return lockValue;​
}​
// 获取锁失败,若未达到重试次数,等待后重试​
if (i < retryTimes) {​
try {​
Thread.sleep (retryIntervalMs);​
} catch (InterruptedException e) {​
Thread.currentThread ().interrupt ();​
return null; // 线程中断,返回获取失败​
}​
}​
}​
// 重试次数用尽,返回获取失败​
return null;​
}​
/**​
释放分布式锁(基于 Lua 脚本,保障原子性)​
@param lockKey 锁标识​
@param lockValue 锁唯一标识(获取锁时返回的值)​
@return true:释放成功;false:释放失败(非锁持有者或锁已过期)​
*/​
public boolean releaseLock (String lockKey, String lockValue) {​
DefaultRedisScript releaseScript = new DefaultRedisScript<>();​
releaseScript.setScriptText(RELEASE_LOCK_SCRIPT);​
releaseScript.setResultType(Long.class);​
// 执行释放锁 Lua 脚本​
Long result = redisTemplate.execute (​
releaseScript,​
Collections.singletonList (lockKey), // KEYS 参数​
lockValue // ARGV 参数(验证锁持有者)​
);​
// 结果为 1 表示释放成功,0 表示释放失败​
return result != null && result == 1;​
}​
/**​
锁续期(防止长耗时操作导致锁过期)​
@param lockKey 锁标识​
@param lockValue 锁唯一标识​
@param renewSeconds 续期时间(秒)​
@return true:续期成功;false:续期失败(非锁持有者或锁已过期)​
*/​
public boolean renewLock (String lockKey, String lockValue, int renewSeconds) {​
DefaultRedisScript renewScript = new DefaultRedisScript<>();​
renewScript.setScriptText(RENEW_LOCK_SCRIPT);​
renewScript.setResultType(Long.class);​
// 执行续期 Lua 脚本​
Long result = redisTemplate.execute (​
renewScript,​
Collections.singletonList (lockKey), // KEYS 参数​
lockValue, String.valueOf (renewSeconds) // ARGV 参数(验证锁持有者、续期时间)​
);​
// 结果为 1 表示续期成功,0 表示续期失败​
return result != null && result == 1;​
}​
}

3.4、 步骤3:实现马拉松报名核心业务(防超售+防重复报名)​

结合Redis分布式锁,开发马拉松报名接口,核心流程包括:​

  1. 资格验证:检查用户是否已报名、赛事是否在报名时间内、名额是否充足;​

  2. 锁控制:获取赛事专属锁,确保同一时间仅一个请求操作名额;​

  3. 业务执行:扣减剩余名额、生成报名订单、记录用户报名信息;​

  4. 锁释放:业务执行完成后释放锁,异常时通过`finally`确保锁释放。

3.4.1、 数据模型与DAO层(示例)

  1. 赛事信息实体(MarathonEvent)
java 复制代码
import lombok.Data;​
import java.util.Date;​
​
@Data​
public class MarathonEvent {​
    private Long eventId; // 赛事ID​
    private String eventName; // 赛事名称​
    private Integer maxQuota; // 最大名额​
    private Integer remainingQuota; // 剩余名额​
    private Date signStartTime; // 报名开始时间​
    private Date signEndTime; // 报名结束时间​
    private Integer status; // 状态(0:未开始,1:报名中,2:已结束)​
}

2、报名记录实体(MarathonSignRecord)

java 复制代码
import lombok.Data;
import java.util.Date;

@Data
public class MarathonSignRecord {
    private Long id; // 记录ID
    private Long eventId; // 赛事ID
    private Long userId; // 用户ID
    private String userPhone; // 用户手机号
    private Date signTime; // 报名时间
    private Integer status; // 状态(0:正常,1:取消)
}

3、DAO 层接口(MyBatis 示例)

java 复制代码
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface MarathonEventMapper {
    // 查询赛事信息(包含剩余名额)
    MarathonEvent selectById(@Param("eventId") Long eventId);

    // 扣减剩余名额(返回影响行数,用于判断扣减是否成功)
    int decreaseQuota(@Param("eventId") Long eventId);
}

@Mapper
public interface MarathonSignRecordMapper {
    // 查询用户是否已报名
    Integer countByUserIdAndEventId(@Param("userId") Long userId, @Param("eventId") Long eventId);

    // 插入报名记录
    int insert(MarathonSignRecord record);
}

3.4.2、 报名业务 Service 实现

java 复制代码
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.Date;
import java.util.concurrent.TimeUnit;

@Service
public class MarathonSignService {

    // 锁相关配置
    private static final int LOCK_EXPIRE_SECONDS = 30; // 锁过期时间(30秒)
    private static final int LOCK_RETRY_TIMES = 3; // 锁获取重试次数(3次)
    private static final long LOCK_RETRY_INTERVAL_MS = 500; // 重试间隔(500毫秒)
    private static final int LOCK_RENEW_SECONDS = 10; // 锁续期时间(10秒)

    @Resource
    private RedisDistributedLock redisDistributedLock;
    @Resource
    private MarathonEventMapper eventMapper;
    @Resource
    private MarathonSignRecordMapper signRecordMapper;

    /**
     * 马拉松报名接口
     * @param eventId 赛事ID
     * @param userId 用户ID
     * @param userPhone 用户手机号
     * @return 报名结果(成功/失败原因)
     */
    @Transactional(rollbackFor = Exception.class)
    public String signUp(Long eventId, Long userId, String userPhone) {
        // 1. 生成赛事专属锁Key(确保同一赛事的报名请求互斥)
        String lockKey = "marathon:lock:event:" + eventId;
        String lockValue = null;
        // 定义锁续期线程(防止报名操作耗时过长导致锁过期)
        Thread renewThread = null;

        try {
            // 2. 获取分布式锁
            lockValue = redisDistributedLock.tryLock(
                lockKey, LOCK_EXPIRE_SECONDS, LOCK_RETRY_TIMES, LOCK_RETRY_INTERVAL_MS
            );
            if (lockValue == null) {
                return "报名人数过多,请稍后重试"; // 锁获取失败,返回重试提示
            }

            // 3. 启动锁续期线程(每5秒续期10秒,确保长耗时操作时锁不失效)
            renewThread = startLockRenewThread(lockKey, lockValue);

            // 4. 资格验证
            String validateResult = validateSign资格(eventId, userId);
            if (!"success".equals(validateResult)) {
                return validateResult; // 验证失败,返回原因
            }

            // 5. 扣减赛事剩余名额(数据库层面确保原子性,依赖UPDATE语句的行锁)
            int decreaseCount = eventMapper.decreaseQuota(eventId);
            if (decreaseCount == 0) {
                return "赛事名额已用完,报名失败"; // 扣减失败(名额已被其他请求抢完)
            }

            // 6. 生成报名记录
            MarathonSignRecord signRecord = new MarathonSignRecord();
            signRecord.setEventId(eventId);
            signRecord.setUserId(userId);
            signRecord.setUserPhone(userPhone);
            signRecord.setSignTime(new Date());
            signRecord.setStatus(0); // 正常状态
            int insertCount = signRecordMapper.insert(signRecord);
            if (insertCount == 0) {
                throw new RuntimeException("报名记录生成失败,事务回滚"); // 插入失败,触发事务回滚
            }

            // 7. 报名成功
            return "报名成功!您的报名编号:" + signRecord.getId();

        } catch (Exception e) {
            // 异常处理(如日志记录)
            e.printStackTrace();
            return "报名异常,请联系客服";
        } finally {
            // 8. 停止锁续期线程
            if (renewThread != null && renewThread.isAlive()) {
                renewThread.interrupt();
            }
            // 9. 释放分布式锁(无论成功失败,都需释放锁,避免死锁)
            if (lockValue != null) {
                redisDistributedLock.releaseLock(lockKey, lockValue);
            }
        }
    }

    /**
     * 报名资格验证
     * @param eventId 赛事ID
     * @param userId 用户ID
     * @return 验证结果(success:成功;其他:失败原因)
     */
    private String validateSign资格(Long eventId, Long userId) {
        // 1. 查询赛事信息
        MarathonEvent event = eventMapper.selectById(eventId);
        if (event == null) {
            return "赛事不存在";
        }

        // 2. 验证赛事状态(是否在报名中)
        Date now = new Date();
        if (event.getStatus() != 1 || now.before(event.getSignStartTime()) || now.after(event.getSignEndTime())) {
            return "当前赛事未开启报名或已结束";
        }

        // 3. 验证剩余名额(初步判断,最终以数据库扣减为准)
        if (event.getRemainingQuota() <= 0) {
            return "赛事名额已用完";
        }

        // 4. 验证用户是否已报名(防重复报名)
        Integer signCount = signRecordMapper.countByUserIdAndEventId(userId, eventId);
        if (signCount != null && signCount > 0) {
            return "您已报名该赛事,不可重复报名";
        }

        return "success";
    }

    /**
     * 启动锁续期线程
     * @param lockKey 锁标识
     * @param lockValue 锁唯一标识
     * @return 续期线程
     */
    private Thread startLockRenewThread(String lockKey, String lockValue) {
        Thread thread = new Thread(() -> {
            try {
                // 每5秒续期一次,直到线程被中断
                while (!Thread.currentThread().isInterrupted()) {
                    boolean renewSuccess = redisDistributedLock.renewLock(
                        lockKey, lockValue, LOCK_RENEW_SECONDS
                    );
                    if (!renewSuccess) {
                        // 续期失败(可能锁已过期或被释放),退出续期
                        break;
                    }
                    TimeUnit.SECONDS.sleep(5); // 5秒后再次续期
                }
            } catch (InterruptedException e) {
                // 线程被中断,退出续期
                Thread.currentThread().interrupt();
            }
        });
        thread.setDaemon(true); // 设置为守护线程,避免影响主线程退出
        thread.start();
        return thread;
    }
}

3.4.3、 报名接口 Controller

java 复制代码
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("/marathon")
public class MarathonSignController {

    @Resource
    private MarathonSignService marathonSignService;

    /**
     * 马拉松报名接口
     * @param eventId 赛事ID
     * @param userId 用户ID(实际场景从登录态获取,此处简化为参数)
     * @param userPhone 用户手机号
     * @return 报名结果
     */
    @PostMapping("/signUp")
    public String signUp(
            @RequestParam("eventId") Long eventId,
            @RequestParam("userId") Long userId,
            @RequestParam("userPhone") String userPhone) {
        return marathonSignService.signUp(eventId, userId, userPhone);
    }
}

四、锁安全性与性能优化:避免极端场景问题

4.1、 解决 "锁过期" 导致的业务中断问题​

4.1.1、 问题场景​

若报名操作耗时超过锁过期时间(如 30 秒),锁会被 Redis 自动释放,其他请求可能获取锁并操作同一赛事名额,导致数据不一致。​

4.1.2、 解决方案:锁续期 + 业务超时控制​

  1. 锁续期:如 3.4.2 节中startLockRenewThread方法,启动守护线程每 5 秒续期 10 秒,确保业务未完成时锁不失效;
  2. 业务超时控制:在报名业务中添加超时时间(如 20 秒),超过时间直接抛出异常,避免业务无限阻塞:
java 复制代码
// 在signUp方法中添加超时控制(使用FutureTask)
public String signUp(Long eventId, Long userId, String userPhone) {
    // 用FutureTask包装报名业务,设置20秒超时
    FutureTask<String> futureTask = new FutureTask<>(() -> {
        // 原有报名业务逻辑(资格验证、扣减名额、生成记录)
        return doSignUpLogic(eventId, userId, userPhone);
    });

    new Thread(futureTask).start();
    try {
        // 等待结果,20秒超时
        return futureTask.get(20, TimeUnit.SECONDS);
    } catch (TimeoutException e) {
        futureTask.cancel(true); // 超时取消任务
        return "报名超时,请稍后重试";
    } catch (Exception e) {
        return "报名异常,请联系客服";
    }
}

4.2、 解决 "Redis 主从切换" 导致的锁丢失问题​

4.2.1、 问题场景​

Redis 主从架构中,主库故障时从库切换为主库,但主库未同步到从库的锁数据会丢失,导致多个请求同时获取锁。​

4.2.2、 解决方案:Redis Cluster+Redisson​

  1. Redis Cluster:相比主从架构,Cluster 模式下锁 key 分布在不同主节点,单个节点故障仅影响部分锁,降低整体风险;
  2. 使用 Redisson 框架:Redisson 是 Redis 官方推荐的分布式锁实现,内置 "RedLock" 算法,通过多个 Redis 节点获取锁,确保主从切换时锁不丢失:
java 复制代码
// 引入Redisson依赖
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.3</version>
</dependency>

// Redisson锁实现示例
@Service
public class MarathonSignService {
    @Resource
    private RedissonClient redissonClient;

    public String signUpWithRedisson(Long eventId, Long userId, String userPhone) {
        String lockKey = "marathon:lock:event:" + eventId;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            // 获取锁(30秒过期,3次重试,间隔500毫秒)
            boolean locked = lock.tryLock(500, 30000, TimeUnit.MILLISECONDS);
            if (!locked) {
                return "报名人数过多,请稍后重试";
            }

            // 报名业务逻辑(资格验证、扣减名额、生成记录)
            return doSignUpLogic(eventId, userId, userPhone);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "报名中断,请稍后重试";
        } finally {
            // 释放锁(仅锁持有者可释放)
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

4.3、 性能优化:减少锁竞争与 Redis 压力​

4.3.1、 锁粒度优化:从 "赛事锁" 到 "名额分段锁"​

若赛事名额较多(如 10000 人),使用 "赛事锁" 会导致所有报名请求竞争同一把锁,并发性能低。可按名额分段(如每 100 个名额为一段),使用 "分段锁":

java 复制代码
// 分段锁实现:按名额段生成锁Key
private String getSegmentLockKey(Long eventId, Integer remainingQuota) {
    int segment = remainingQuota / 100; // 每100个名额一段
    return "marathon:lock:event:" + eventId +":segment:" + segment;​
}​
// 分段锁报名逻辑​
public String signUpWithSegmentLock (Long eventId, Long userId, String userPhone) {​
String lockValue = null;​
Thread renewThread = null;​
try {​
// 1. 先查询剩余名额,确定分段锁 Key(初步查询,非原子,后续需二次验证)​
MarathonEvent event = eventMapper.selectById (eventId);​
if (event == null || event.getRemainingQuota () <= 0) {​
return "赛事名额已用完";​
}​
String lockKey = getSegmentLockKey (eventId, event.getRemainingQuota ());​
// 2. 获取分段锁​
lockValue = redisDistributedLock.tryLock (​
lockKey, LOCK_EXPIRE_SECONDS, LOCK_RETRY_TIMES, LOCK_RETRY_INTERVAL_MS​
);​
if (lockValue == null) {​
return "报名人数过多,请稍后重试";​
}​
// 3. 启动续期线程​
renewThread = startLockRenewThread (lockKey, lockValue);​
// 4. 二次验证资格(避免初步查询后名额被其他分段锁抢占)​
String validateResult = validateSign 资格 (eventId, userId);​
if (!"success".equals (validateResult)) {​
return validateResult;​
}​
// 5. 扣减名额(数据库行锁保障原子性)​
int decreaseCount = eventMapper.decreaseQuota (eventId);​
if (decreaseCount == 0) {​
return "赛事名额已用完,报名失败";​
}​
// 6. 生成报名记录​
MarathonSignRecord record = new MarathonSignRecord ();​
// (记录赋值逻辑略)​
signRecordMapper.insert (record);​
return "报名成功!报名编号:" + record.getId ();​
} catch (Exception e) {​
e.printStackTrace ();​
return "报名异常,请联系客服";​
} finally {​
if (renewThread != null && renewThread.isAlive ()) {​
renewThread.interrupt ();​
}​
if (lockValue != null) {​
redisDistributedLock.releaseLock (getSegmentLockKey (eventId, 0), lockValue); // 此处需优化为实际分段 Key,示例简化​
}​
}​
}
  • 优势 :将锁竞争从单把锁 分散到多把分段锁,例如10000个名额分为100段,并发性能可提升100倍;
  • 注意事项:需在获取分段锁后二次验证名额,避免初步查询的分段与实际扣减时的分段不一致(如初步查询在段1,扣减时已进入段2)。

4.3.2、 减少Redis压力:缓存预热+本地缓存​

  1. 缓存预热:赛事报名开始前,将赛事基本信息(如名额、时间)缓存到Redis,减少报名时的数据库查询:
java 复制代码
// 缓存预热方法(报名开始前调用)​
@Scheduled(cron = "0 0 8 * * ?") // 每天8点执行(假设报名9点开始)​
public void preCacheEventInfo(Long eventId) {​
    MarathonEvent event = eventMapper.selectById(eventId);​
    if (event != null) {​
        String cacheKey = "marathon:event:info:" + eventId;​
        redisTemplate.opsForValue().set(cacheKey, event, 24, TimeUnit.HOURS);​
    }​
}​
​
// 报名时优先查Redis缓存​
private MarathonEvent getEventInfo(Long eventId) {​
    String cacheKey = "marathon:event:info:" + eventId;​
    MarathonEvent event = (MarathonEvent) redisTemplate.opsForValue().get(cacheKey);​
    if (event == null) {​
        // 缓存未命中,查数据库并回写缓存​
        event = eventMapper.selectById(eventId);​
        if (event != null) {​
            redisTemplate.opsForValue().set(cacheKey, event, 1, TimeUnit.HOURS);​
        }​
    }​
    return event;​
}

2、本地缓存防重复报名:将已报名用户 ID 缓存到本地 Caffeine,减少 Redis 查询压力(需定期同步 Redis 数据):

java 复制代码
// 本地缓存已报名用户(Caffeine)
private final Cache<String, Boolean> signedUserCache = Caffeine.newBuilder()
        .maximumSize(100000) // 缓存10万用户
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .build();

// 验证重复报名时优先查本地缓存
private boolean isUserSigned(Long eventId, Long userId) {
    String cacheKey = eventId + ":" + userId;
    Boolean isSigned = signedUserCache.getIfPresent(cacheKey);
    if (isSigned != null) {
        return isSigned;
    }

    // 本地缓存未命中,查Redis(Redis存储已报名用户集合)
    String redisKey = "marathon:signed:user:" + eventId;
    Boolean exists = redisTemplate.opsForSet().isMember(redisKey, userId);
    if (exists == null) {
        // Redis未命中,查数据库并回写Redis与本地缓存
        Integer count = signRecordMapper.countByUserIdAndEventId(userId, eventId);
        exists = count != null && count > 0;
        if (exists) {
            redisTemplate.opsForSet().add(redisKey, userId);
        }
    }

    // 回写本地缓存
    signedUserCache.put(cacheKey, exists);
    return exists;
}

4.4 结合限流:从源头减少锁竞争​

在分布式锁之前添加限流层,控制单位时间内的报名请求量,避免大量请求竞争锁资源,常用方案包括:​

1、Redis 限流(令牌桶算法)

java 复制代码
// Redis令牌桶限流
private boolean isRateLimitAllowed(Long eventId) {
    String bucketKey = "marathon:rate:limit:" + eventId;
    int capacity = 100; // 令牌桶容量(每秒最多100个请求)
    int rate = 100; // 令牌生成速率(每秒100个)

    // Lua脚本实现令牌桶限流
    String luaScript = "local key = KEYS[1]\n" +
            "local capacity = tonumber(ARGV[1])\n" +
            "local rate = tonumber(ARGV[2])\n" +
            "local now = tonumber(ARGV[3])\n" +
            "local interval = 1000\n" +
            "\n" +
            "local bucket = redis.call('hmget', key, 'tokens', 'last_refill_time')\n" +
            "local tokens = tonumber(bucket[1]) or capacity\n" +
            "local lastRefillTime = tonumber(bucket[2]) or now\n" +
            "\n" +
            "local elapsed = now - lastRefillTime\n" +
            "if elapsed > 0 then\n" +
            "    local newTokens = tokens + (elapsed / interval) * rate\n" +
            "    tokens = math.min(newTokens, capacity)\n" +
            "    redis.call('hmset', key, 'tokens', tokens, 'last_refill_time', now)\n" +
            "end\n" +
            "\n" +
            "if tokens >= 1 then\n" +
            "    redis.call('hincrbyfloat', key, 'tokens', -1)\n" +
            "    return 1\n" +
            "else\n" +
            "    return 0\n" +
            "end";

    DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
    Long result = redisTemplate.execute(
        script,
        Collections.singletonList(bucketKey),
        String.valueOf(capacity), String.valueOf(rate), String.valueOf(System.currentTimeMillis())
    );

    return result != null && result == 1;
}

// 报名接口添加限流
public String signUpWithRateLimit(Long eventId, Long userId, String userPhone) {
    // 1. 先限流
    if (!isRateLimitAllowed(eventId)) {
        return "当前报名人数过多,请1秒后重试";
    }

    // 2. 后续锁逻辑(略)
    return signUp(eventId, userId, userPhone);
}

2、网关限流(如 Spring Cloud Gateway):在网关层配置限流规则,直接拦截超出阈值的请求,无需进入业务服务,进一步减少压力。​

五、压测验证:确保锁有效性与性能​

通过 JMeter 进行压测,验证分布式锁在高并发下的有效性与性能,核心压测场景与预期结果如下:​

5.1、 压测场景设计​

1、场景 1:超售验证:​

  • 条件:赛事名额 1000 人,模拟 2000 个并发请求;
  • 预期:最终报名人数≤1000,无超售。

2、场景 2:重复报名验证:​

  • 条件:单个用户(userId=1001)发起 10 次并发报名请求;
  • 预期:仅 1 次报名成功,其余返回 "已重复报名"。

3、场景 3:性能验证:​

  • 条件:赛事名额 10000 人,模拟 5000 QPS 请求;
  • 预期:平均响应时间≤500ms,成功率≥99.9%。

5.2、 压测结果分析​

  1. 超售验证:压测后数据库查询报名记录数为 1000,无超售,锁有效阻止了并发冲突;
  2. 重复报名验证:单个用户仅 1 次报名成功,其余 9 次返回重复提示,防重复逻辑生效;
  3. 性能验证:平均响应时间 420ms,成功率 99.95%,未出现 Redis 超时或数据库压力过高(CPU 使用率≤70%)。

5.3、 问题定位与优化​

压测中若出现 "响应时间过长",可通过以下方式定位:​

  1. Redis 监控:查看 Redis 的used_cpu_user(CPU 使用率)、keyspace_hits(缓存命中率),若 CPU 过高,需优化 Lua 脚本或增加 Redis 节点;
  2. 数据库监控:查看 MySQL 的Threads_running(运行线程数)、Slow_queries(慢查询数),若慢查询多,需优化decreaseQuota语句的索引(如给event_id加主键索引);
  3. 业务监控:查看锁获取成功率,若重试次数过多,需调整锁重试间隔或增加分段锁数量。

六、总结与扩展:分布式锁的适用场景与未来方向​

6.1、 核心总结​

1、Redis 分布式锁的核心价值:​

  • 解决分布式环境下的并发冲突(如马拉松报名超售、重复提交);
  • 相比数据库锁,性能更高(内存操作 vs 磁盘操作);
  • 相比本地锁,支持跨服务、跨节点控制。

2、关键设计原则:​

  • 原子性:通过SET NX EX与 Lua 脚本保障锁操作原子性;
  • 安全性:设置锁过期时间、防误删验证、锁续期,避免死锁与数据不一致;
  • 性能:优化锁粒度(分段锁)、结合缓存与限流,减少锁竞争与资源消耗。

3、马拉松场景最佳实践:​

  • 锁 Key 设计:marathon:lock:event:{eventId}:segment:{segment}(分段锁);
  • 核心参数:锁过期 30 秒、重试 3 次、续期 10 秒、限流 100 QPS / 赛事;
  • 依赖组件:Redis Cluster(高可用)+ Redisson(简化锁实现)+ Caffeine(本地缓存)。

6.2、 扩展场景:Redis 分布式锁的其他应用​

除马拉松报名外,Redis 分布式锁还可应用于以下场景:​

  1. 秒杀系统:控制商品库存扣减,防止超售;
  2. 分布式任务调度:确保同一任务仅一个节点执行,避免重复调度;
  3. 订单支付:防止同一订单被多次支付,确保支付状态一致性。

6.3、 未来方向​

  1. 云原生适配:结合 K8s 的 ConfigMap 动态配置锁参数(如过期时间、重试次数),无需重启服务;
  2. 智能锁优化:通过 AI 学习历史并发数据,自动调整锁粒度与限流阈值(如大促期间自动增加分段锁数量);
  3. 多模式锁支持:根据场景自动切换锁模式(如低并发用本地锁,高并发用分布式锁),进一步提升性能。

七、结语​

在马拉松赛事系统中,Redis 分布式锁通过 "原子性操作 + 安全性设计 + 性能优化",成功解决了高并发报名的超售、重复报名等核心问题。其本质是通过共享锁资源,在分布式环境中实现 "串行化" 控制,同时兼顾性能与可用性。​

对于开发者而言,掌握 Redis 分布式锁不仅是掌握一项技术,更是理解 "分布式系统一致性" 的核心思路 ------ 在分布式架构中,任何并发操作都需考虑 "数据一致性" 与 "性能平衡",而 Redis 分布式锁正是这一思路的典型实践。未来,随着分布式系统的复杂化,分布式锁将进一步与云原生、AI 等技术融合,成为更智能、更高效的并发控制工具。

八、常见问题排查与解决方案​

在 Redis 分布式锁的实际运行中,可能会遇到各种异常场景,需针对性排查与解决,以下是典型问题及处理方案:​

8.1、 问题 1:锁释放失败导致死锁​

8.1.1、 现象​

部分请求获取锁后,因异常(如服务宕机、网络中断)未执行finally中的释放锁逻辑,导致锁长期占用,其他请求无法获取锁。​

8.1.2、 排查与解决​

1、排查:​

  • 通过 Redis 命令KEYS "marathon:lock:event:*"查看是否存在长期未释放的锁 Key;
  • 检查锁 Key 的过期时间(TTL key),若返回-1(无过期时间),说明锁未设置过期或过期时间被覆盖。

2、解决方案:​

  • 强制设置锁过期时间:在tryLock方法中,无论业务是否异常,确保SET NX EX命令必带过期时间,避免锁无过期;
  • 定时清理过期锁:部署定时任务,定期扫描锁 Key,对超过合理时间(如锁过期时间 + 10 秒)的锁强制删除:
java 复制代码
@Scheduled(fixedRate = 60 * 1000) // 每分钟执行一次
public void cleanExpiredLock() {
    Set<String> lockKeys = redisTemplate.keys("marathon:lock:event:*");
    if (lockKeys == null || lockKeys.isEmpty()) {
        return;
    }
    for (String key : lockKeys) {
        Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
        // 锁无过期时间或已过期超过10秒,强制删除
        if (ttl == null || ttl == -1 || ttl < -10) {
            redisTemplate.delete(key);
            System.out.println("清理过期锁:" + key);
        }
    }
}

8.2、 问题 2:Lua 脚本执行失败​

8.2.1、 现象​

释放锁或续期时,Lua 脚本返回0(执行失败),导致锁无法释放或续期,引发锁竞争。​

8.2.2、 排查与解决​

1、排查:​

  • 查看 Redis 日志(redis-server.log),是否有Lua script execution failed错误;
  • 检查 Lua 脚本语法(如是否漏写end、变量名错误),或参数传递是否正确(如KEYS参数是否为列表、ARGV参数类型是否匹配)。

2、解决方案:​

  • 脚本语法校验:提前在 Redis 客户端(如 redis-cli)测试 Lua 脚本,确保语法正确,示例:
bash 复制代码
# 测试释放锁脚本
redis-cli EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 marathon:lock:event:1001 uuid123:12345
  • 参数类型统一:确保ARGV参数中数字类型(如过期时间)传递为字符串,避免 Lua 脚本中类型转换错误;
  • 异常捕获与重试:在 Java 代码中捕获 Lua 脚本执行异常,添加重试逻辑(如重试 1 次):
java 复制代码
public boolean releaseLockWithRetry(String lockKey, String lockValue, int retryTimes) {
    for (int i = 0; i <= retryTimes; i++) {
        try {
            return redisDistributedLock.releaseLock(lockKey, lockValue);
        } catch (Exception e) {
            if (i == retryTimes) {
                e.printStackTrace();
                return false;
            }
            try {
                Thread.sleep(100); // 间隔100ms重试
            } catch (InterruptedException ie) {
                Thread.currentThread().interrupt();
            }
        }
    }
    return false;
}

8.3、 问题 3:Redis Cluster 槽位迁移导致锁 Key 不可用​

8.3.1、 现象​

Redis Cluster 进行槽位迁移时,部分锁 Key 所在的槽位被迁移到其他节点,导致当前请求无法访问锁 Key,获取锁失败。​

8.3.2、 排查与解决​

1、排查:​

  • 通过redis-cli cluster keyslot marathon:lock:event:1001查看锁 Key 对应的槽位;
  • 查看 Redis Cluster 的槽位迁移状态(redis-cli cluster migrateinfo),确认是否有槽位正在迁移。

2、解决方案:​

  • 使用 Redisson 的 Cluster 模式:Redisson 自动处理槽位迁移,当锁 Key 所在槽位迁移时,会重新路由到新节点;
  • 锁 Key 前缀固定:设计锁 Key 时,确保同一赛事的锁 Key 映射到同一槽位(如通过{}包裹哈希前缀),避免槽位迁移时同一赛事的锁分散到不同节点:
java 复制代码
// 锁Key设计:用{}包裹固定前缀,确保同一赛事的锁Key映射到同一槽位
private String getSlotFixedLockKey(Long eventId) {
    return "marathon:lock:{event}:" + eventId; // {event}为固定哈希前缀
}

九、最终总结​

在马拉松赛事系统的在线报名场景中,Redis 分布式锁通过 "原子性设计 + 性能优化 + 运维保障",成为解决高并发冲突的核心技术。其成功落地的关键在于:​

  1. 贴合业务场景:针对 "超售""重复报名" 等核心痛点,设计 "赛事分段锁 + 防误删验证 + 锁续期" 的完整方案;
  2. 平衡性能与可靠性:通过分段锁提升并发性能,通过 Redis Cluster 与过期时间保障可靠性,避免 "唯性能论" 或 "唯可靠性论";
  3. 全链路保障:从开发(Lua 脚本原子性)、测试(压测验证)到运维(监控与灾备),形成全链路的质量保障体系。

对于开发者而言,Redis 分布式锁不仅是一项技术工具,更是分布式系统设计中 "一致性与性能平衡" 思想的体现。未来,随着马拉松系统向 "高并发、高可用、智能化" 方向发展,Redis 分布式锁也将持续迭代,结合云原生、AI 等技术,为更复杂的业务场景提供高效的并发控制方案。

相关推荐
ChaITSimpleLove1 天前
基于 .NET Garnet 1.0.91 实现高性能分布式锁(使用 Lua 脚本)
分布式·.net·lua
原神启动11 天前
Kafka详解
分布式·kafka
yumgpkpm1 天前
Iceberg在Hadoop集群使用步骤(适配AI大模型)
大数据·hadoop·分布式·华为·zookeeper·开源·cloudera
羑悻的小杀马特1 天前
Lua vs C++:核心设计哲学差异——从“系统基石”到“灵活工具”的思维碰撞
c++·lua
rocksun1 天前
Tigris对象存储正式开源MCP OIDC身份提供商
redis·安全·微服务
元气满满-樱1 天前
分布式LNMP部署
分布式
摇滚侠1 天前
Redis 零基础到进阶,Spring Boot 整合 Redis,笔记93-99
spring boot·redis·笔记
ChristXlx1 天前
Linux安装redis(虚拟机适用)
linux·运维·redis
此生只爱蛋1 天前
【Redis】列表List类型
数据库·redis·缓存
Wang's Blog1 天前
RabbitMQ: 声明式配置简化管理
分布式·rabbitmq