当前位置: 首页 > news >正文

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] thenreturn redis.call('DEL', KEYS[1]) -- 验证通过,删除锁
elsereturn 0 -- 验证失败,不操作
end

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

-- 锁续期的Lua脚本:key为锁标识,argv[1]为唯一标识,argv[2]为续期时间(秒)
if redis.call('GET', KEYS[1]) == ARGV[1] thenreturn redis.call('EXPIRE', KEYS[1], ARGV[2]) -- 续期
elsereturn 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):

<!-- 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):

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:6379max-redirects: 3 # 最大重定向次数(Cluster模式下槽位不匹配时的重定向)lettuce:pool:max-active: 16 # 连接池最大活跃连接数max-idle: 8 # 连接池最大空闲连接数min-idle: 4 # 连接池最小空闲连接数timeout: 5000 # Redis连接超时时间(毫秒)

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

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 {@Beanpublic 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 脚本保障原子性,工具类设计如下:

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";// 构造函数注入RedisTemplatepublic 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)

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)

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

3、DAO 层接口(MyBatis 示例)

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 实现

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秒)@Resourceprivate RedisDistributedLock redisDistributedLock;@Resourceprivate MarathonEventMapper eventMapper;@Resourceprivate 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

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 {@Resourceprivate 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 秒),超过时间直接抛出异常,避免业务无限阻塞:
// 在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 节点获取锁,确保主从切换时锁不丢失:
// 引入Redisson依赖
<dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><version>3.23.3</version>
</dependency>// Redisson锁实现示例
@Service
public class MarathonSignService {@Resourceprivate 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 个名额为一段),使用 “分段锁”:

// 分段锁实现:按名额段生成锁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,减少报名时的数据库查询:

// 缓存预热方法(报名开始前调用)​
@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 数据):

// 本地缓存已报名用户(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 限流(令牌桶算法)

// 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 秒)的锁强制删除:
@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 脚本,确保语法正确,示例:
# 测试释放锁脚本
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 次):
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 映射到同一槽位(如通过{}包裹哈希前缀),避免槽位迁移时同一赛事的锁分散到不同节点:
// 锁Key设计:用{}包裹固定前缀,确保同一赛事的锁Key映射到同一槽位
private String getSlotFixedLockKey(Long eventId) {return "marathon:lock:{event}:" + eventId; // {event}为固定哈希前缀
}

九、最终总结​

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

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

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

http://www.dtcms.com/a/490514.html

相关文章:

  • 光刻胶化学基础:聚合物分子量分布对分辨率的影响与控制,以及国内技术突破
  • Lua C API 中的注册表介绍
  • 广州做网站哪家公司最好wordpress html调用php
  • 神经网络之计算图
  • Hatch 故障排除指南
  • 神经网络之计算图分支节点
  • 【表格对比分析】Java集合体系、Java并发编程、JVM核心知识、Golang go-zero微服务框架
  • 【任务管理软件】实用工具之ToDoList 9.0.6 详细图文安装教程:高效任务管理的完美起点
  • Linux中zonelist分配策略初始化
  • hadoop的三副本数据冗余策略
  • 岳阳网站建设企业足球比赛直播app下载
  • React 三元运算符页面切换:完整进出流程
  • NumPy zeros_like() 函数详解
  • 网站建设要后台吗公司网页制作哪家好
  • 天津网站建设优化网页设计图片代码
  • CXR SDK实战指南:跨设备AR应用开发
  • 已知明文攻击(Known plaintext):原理、方法与防御体系深度剖析
  • ​SPI四种工作模式
  • 深度学习------YOLOV1和YOLOV2
  • 最小二乘问题详解5:非线性最小二乘求解实例
  • 算法入门数学基础
  • 错误边界:用componentDidCatch筑起React崩溃防火墙
  • 网站备案提交管局原创软文
  • 成都比较好的网站建设公司视频制作和剪辑软件
  • 如何从电脑上卸载安卓应用程序
  • 每日手撕算法--哈希映射/链表存储数求和
  • k8s的pvc和pv
  • RK3562核心板/开发板RT-Linux系统实时性及硬件中断延迟测试
  • node.js把webp,gif格式图片转换成jpg格式图片
  • 不能识别adb/usb口记录