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、 防误删
- 要求:只能释放自己持有的锁,不能释放其他请求的锁;
- 实现:
- 获取锁时,设置 value 为 “唯一标识”(如 UUID + 线程 ID);
- 释放锁前,先获取锁的 value,验证是否为自己的唯一标识,是则删除,否则不操作。
2.2.4、 高可用
- 要求:Redis 集群故障时,锁资源不丢失,仍能正常获取 / 释放锁;
- 实现:
- 基于 Redis 主从 + 哨兵部署:主库故障时,哨兵自动切换从库为主库,锁资源同步到新主库;
- 基于 Redis Cluster 部署:锁 key 分布在不同槽位,单个节点故障不影响其他节点的锁资源。
2.3、 Lua 脚本:保障复杂操作的原子性
2.3.1、 为什么需要 Lua 脚本?
Redis 执行单个命令是原子的,但多个命令组合(如 “获取锁 value→验证→删除锁”)是非原子的,高并发下可能出现 “误删锁” 问题:
- 示例:
- 请求 A 持有锁(过期时间 30 秒),执行报名操作耗时过长(35 秒),锁自动过期;
- Redis 自动释放锁,请求 B 成功获取锁;
- 请求 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” 架构,核心功能包括:
- 赛事发布:管理员创建赛事(设置名称、限额、报名时间、报名条件等);
- 在线报名:用户提交报名信息,系统验证资格、扣减剩余名额、生成报名订单;
- 成绩查询:用户查询自己的参赛成绩;
- 实时追踪:实时展示选手的比赛进度(如当前位置、用时);
- 数据分析:统计报名人数、性别分布、年龄段分布等数据。
核心场景:在线报名(高并发、需防超售、防重复报名),下文重点围绕该场景实现 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; // 赛事IDprivate 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、 解决方案:锁续期 + 业务超时控制
- 锁续期:如 3.4.2 节中startLockRenewThread方法,启动守护线程每 5 秒续期 10 秒,确保业务未完成时锁不失效;
- 业务超时控制:在报名业务中添加超时时间(如 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
- Redis Cluster:相比主从架构,Cluster 模式下锁 key 分布在不同主节点,单个节点故障仅影响部分锁,降低整体风险;
- 使用 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、 压测结果分析
- 超售验证:压测后数据库查询报名记录数为 1000,无超售,锁有效阻止了并发冲突;
- 重复报名验证:单个用户仅 1 次报名成功,其余 9 次返回重复提示,防重复逻辑生效;
- 性能验证:平均响应时间 420ms,成功率 99.95%,未出现 Redis 超时或数据库压力过高(CPU 使用率≤70%)。
5.3、 问题定位与优化
压测中若出现 “响应时间过长”,可通过以下方式定位:
- Redis 监控:查看 Redis 的used_cpu_user(CPU 使用率)、keyspace_hits(缓存命中率),若 CPU 过高,需优化 Lua 脚本或增加 Redis 节点;
- 数据库监控:查看 MySQL 的Threads_running(运行线程数)、Slow_queries(慢查询数),若慢查询多,需优化decreaseQuota语句的索引(如给event_id加主键索引);
- 业务监控:查看锁获取成功率,若重试次数过多,需调整锁重试间隔或增加分段锁数量。
六、总结与扩展:分布式锁的适用场景与未来方向
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 分布式锁还可应用于以下场景:
- 秒杀系统:控制商品库存扣减,防止超售;
- 分布式任务调度:确保同一任务仅一个节点执行,避免重复调度;
- 订单支付:防止同一订单被多次支付,确保支付状态一致性。
6.3、 未来方向
- 云原生适配:结合 K8s 的 ConfigMap 动态配置锁参数(如过期时间、重试次数),无需重启服务;
- 智能锁优化:通过 AI 学习历史并发数据,自动调整锁粒度与限流阈值(如大促期间自动增加分段锁数量);
- 多模式锁支持:根据场景自动切换锁模式(如低并发用本地锁,高并发用分布式锁),进一步提升性能。
七、结语
在马拉松赛事系统中,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 分布式锁通过 “原子性设计 + 性能优化 + 运维保障”,成为解决高并发冲突的核心技术。其成功落地的关键在于:
- 贴合业务场景:针对 “超售”“重复报名” 等核心痛点,设计 “赛事分段锁 + 防误删验证 + 锁续期” 的完整方案;
- 平衡性能与可靠性:通过分段锁提升并发性能,通过 Redis Cluster 与过期时间保障可靠性,避免 “唯性能论” 或 “唯可靠性论”;
- 全链路保障:从开发(Lua 脚本原子性)、测试(压测验证)到运维(监控与灾备),形成全链路的质量保障体系。
对于开发者而言,Redis 分布式锁不仅是一项技术工具,更是分布式系统设计中 “一致性与性能平衡” 思想的体现。未来,随着马拉松系统向 “高并发、高可用、智能化” 方向发展,Redis 分布式锁也将持续迭代,结合云原生、AI 等技术,为更复杂的业务场景提供高效的并发控制方案。