分布式锁的工作原理
- JVM锁的局限性:synchronized锁只能保证单个JVM内部的线程互斥,无法实现集群模式下多JVM进程间的互斥
- 核心解决思路:需要让多个JVM进程都能访问同一个外部锁监视器
- 工作流程:
- 线程1从外部锁监视器获取锁成功并记录持有者信息
- 其他线程(无论是否同JVM)获取锁失败进入等待
- 线程1执行业务(查询订单→判断存在→插入新订单)
- 线程1释放锁后,等待线程获取锁并执行业务
- 由于订单已存在,后续线程查询后会直接报错
分布式锁的概念
- 基本定义:满足分布式系统或集群模式下多进程可见且互斥的锁
- 五大核心特性:
- 多进程可见:所有JVM都能访问同一个锁资源(如Redis、MySQL等)
- 互斥性:同一时刻只有一个线程能获取锁
- 高可用:锁服务应保持高可用性
- 高性能:获取/释放锁的操作要高效
- 安全性:异常情况下能自动释放锁,避免死锁
- 扩展特性(非必需):
- 可重入性
- 阻塞/非阻塞
- 公平/非公平锁
分布式锁的实现
| MySQL | Redis | Zookeeper | |
|---|---|---|---|
| 互斥 | 利用mysql本身的互斥锁机制 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
| 高可用 | 好 | 好 | 好 |
| 高性能 | 一般 | 好 | 一般 |
| 安全性 | 断开连接,自动释放锁 | 利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
- MySQL实现方案:
- 互斥原理:利用数据库事务的排他锁
- 优点:自动释放锁(连接断开时事务回滚)
- 缺点:性能一般,依赖数据库可用性
- Redis实现方案:
- 互斥原理:使用SETNX命令
- 优点:高性能,支持集群模式
- 安全缺陷:需配合过期时间避免死锁(expire)
- 挑战:过期时间设置需权衡业务执行时间
- Zookeeper实现方案:
- 互斥原理:利用节点唯一性和有序性
- 优点:自动释放(临时节点),高可用
- 缺点:性能低于Redis(强一致性导致)
- 方案选择建议:
- 优先考虑Redis方案(性能优势)
- 对安全性要求极高时考虑Zookeeper
- MySQL方案适合已有数据库依赖的场景
基于Redis实现分布式锁

获取锁
- 互斥机制: 利用Redis的SETNX命令实现,当多个线程同时执行时,只有第一个执行成功的线程能获取锁
- 业务隔离: 不同业务可以使用不同的key作为锁标识,例如”lock:order”和”lock:payment”
- 示例操作: SETNX lock thread1,成功返回1,失败返回0
释放锁
- 手动释放: 使用DEL命令删除对应的key,如DEL lock
- 释放效果: 删除后其他线程可以再次获取该锁
- 示例流程:
- 线程1执行SETNX lock thread1获取锁
- 线程2尝试获取相同锁会失败
- 线程1执行DEL lock释放锁
- 线程2可以重新尝试获取锁
超时释放
- 问题场景: 服务获取锁后宕机,导致锁无法释放形成死锁
- 解决方案: 使用EXPIRE命令设置锁的过期时间,如EXPIRE lock 10
- 时间设置原则: 应比实际业务执行时间长,通常设置为10秒左右
- 自动释放: 过期后Redis会自动删除key,其他线程可重新获取
原子性操作
- 问题场景: SETNX和EXPIRE两个命令之间服务宕机,仍会导致死锁
- 解决方案: 使用Redis的SET命令同时实现SETNX和EXPIRE功能
- 原子命令: SET lock thread1 EX 10 NX
- 参数说明:
- EX: 设置过期时间(秒)
- NX: 仅当key不存在时设置
- 执行效果: 要么全部成功,要么全部失败,保证原子性
获取锁失败的处理
- 返回结果: 成功返回”OK”,失败返回nil
- 非阻塞机制: 获取失败立即返回false,不进行重试
- 优点: 避免CPU资源浪费,实现简单
- 业务决策: 由调用方决定失败后的处理策略(重试/放弃)
- 适用场景: 适用于对实时性要求不高或可降级的业务场景
代码实现
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 尝试获取锁
*
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功,false代表获取锁失败
*/
@Override
public boolean tryLock(Long timeoutSec) {
String key = KEY_PREFIX + name;
// 获取线程标识
String value = Thread.currentThread().getId() + "";
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeoutSec, TimeUnit.SECONDS);
// 排除空指针的风险
return Boolean.TRUE.equals(flag);
}
/**
* 释放锁
*/
@Override
public void unLock() {
String key = KEY_PREFIX + name;
stringRedisTemplate.delete(key);
}
}然后就可以更新核心代码中的代码来解决 集群下的线程安全并发问题
public Result seckillVoucher(Long voucherId) {
// 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if (voucher == null) {
return Result.fail("优惠券不存在!");
}
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("优惠券尚未开始!");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("优惠券已过期!");
}
// 判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
// 创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
// 获取锁
boolean isLock = lock.tryLock(10L);
if (!isLock) {
return Result.fail("不允许重复下单!");
}
try {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
lock.unLock();
}
}Redis分布式锁误删问题
极端情况
- 业务阻塞导致锁超时:当线程1业务执行时间超过锁的超时时间(10秒),锁会被自动释放
- 误删他人锁:线程1恢复后,会误删此时持有锁的线程2的锁
- 并发安全问题:线程3趁机获取锁,导致线程2和线程3同时持有锁,出现并发问题

问题根源
- 直接删除机制:释放锁时未验证锁的归属权
- 无状态判断:线程无法识别当前锁是否属于自己
解决方案
- 获取锁时存储标识:将线程唯一标识存入锁
- 释放锁前验证:
- 获取锁中存储的线程标识
- 与当前线程标识比较
- 只有匹配时才执行删除操作

Warning
但这里依然存在不足,因为线程1阻塞恢复正常后会继续执行,此时线程2获取到锁也在执行,也就是说同时有两个线程在执行。参考功能介绍中超时释放的描述。解决方案建议如下:
- 增加锁的超时时间 :确保业务执行时间不会超过锁的超时时间
- 使用看门狗机制 :定期续期锁的超时时间,Redisson分布式锁源码分析中有所体现
- 业务幂等性设计 :即使出现并发,也要保证业务结果的正确性
- 数据库层面的约束 :利用数据库的唯一约束来防止重复数据

Redis分布式锁改进实现
需求分析
- 改进需求:
- 获取锁时存入线程标识(使用UUID)
- 释放锁时先获取锁中的线程标识进行比对
- 一致则释放锁
- 不一致则不释放锁
- 线程标识设计:
- 避免直接使用线程ID(JVM内部递增数字,集群环境下可能冲突)
- 采用UUID+线程ID组合方式:
- UUID区分不同JVM实例
- 线程ID区分同一JVM内的不同线程
- 确保不同线程标识唯一,相同线程标识一致
Tip
这里UUID来自
cn.hutool.core.lang.UUID;
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 尝试获取锁
*
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功,false代表获取锁失败
*/
@Override
public boolean tryLock(Long timeoutSec) {
String key = KEY_PREFIX + name;
// 获取线程标识
String value = ID_PREFIX + Thread.currentThread().getId() + "";
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeoutSec, TimeUnit.SECONDS);
// 排除空指针的风险
return Boolean.TRUE.equals(flag);
}
/**
* 释放锁
*/
@Override
public void unLock() {
// 获取线程标识
String value = ID_PREFIX + Thread.currentThread().getId() + "";
// 获取锁中的值
String key = KEY_PREFIX + name;
String currentValue = stringRedisTemplate.opsForValue().get(key);
// 判断是否一致
if (value.equals(currentValue)) {
stringRedisTemplate.delete(key);
}
}
}分布式锁的原子性问题
极端场景问题
- 典型场景:
- 线程1获取锁成功并完成业务
- 判断锁标识通过后,在释放前发生JVM垃圾回收(FGC)阻塞
- 阻塞期间锁超时释放,线程2成功获取锁
- 线程1恢复后继续执行释放操作,误删线程2的锁
- 根本原因:判断标识和释放锁两个操作非原子性,中间可能被阻塞
- 关键发现:即使添加了标识验证,操作间隔仍可能导致并发问题

解决方案
- 核心要求:必须保证判断标识和释放锁的原子性
- 技术本质:需要将两个操作合并为一个不可分割的执行单元
- 待解决问题:如何在分布式环境下实现多操作的原子性保证
Lua脚本解决多条命令原子性问题
Lua脚本介绍
- 功能特点:在一个脚本中编写多条Redis命令,确保多条命令执行的原子性。
- 语言特性:Lua是一种轻量级脚本语言,语法简单易学,适合嵌入应用程序中。
- 学习资源:基本语法可参考Lua教程网站


业务流程
- 获取锁中的线程标识
- 将获取的标识与当前线程标识比较
- 若一致则释放锁,不一致则不执行任何操作
-- 锁的jey
local key = KEYS[1]
-- 当前线程标识
local threadId = ARGV[1]
-- 获取锁的线程标识
local id = redis.call('get', key)
-- 比较线程标识与锁中是否一致
if id == threadId then
-- 释放锁
return redis.call('del', key)
end
return 0代码实现
- 脚本位置:存放在
resources/unlock.lua文件中,便于维护修改 - 核心方法:
stringRedisTemplate.execute() - 参数配置:
- 脚本对象:
DefaultRedisScript<Long>类型- key列表:
Collections.singletonList()包装单个key - 参数数组:线程标识作为可变参数传入
- key列表:
性能优化
使用静态代码块预加载脚本,避免每次释放锁都读取文件
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 尝试获取锁
*
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功,false代表获取锁失败
*/
@Override
public boolean tryLock(Long timeoutSec) {
String key = KEY_PREFIX + name;
// 获取线程标识
String value = ID_PREFIX + Thread.currentThread().getId() + "";
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeoutSec, TimeUnit.SECONDS);
// 排除空指针的风险
return Boolean.TRUE.equals(flag);
}
/**
* 释放锁
*/
@Override
public void unLock() {
// 调用lua脚本
stringRedisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId()
);
}
}Redisson
基于setnx实现的分布式锁存在的问题
- 不可重入问题:同一个线程无法多次获取同一把锁,会导致死锁。例如方法A调用方法B时,若方法A已获取锁,方法B再次尝试获取同一把锁就会阻塞等待。
- 不可重试问题:当前实现是非阻塞式的,获取锁失败会立即返回false,缺乏重试机制。实际业务中往往需要等待或重试获取锁。
- 超时释放问题:虽然通过Lua脚本解决了误删问题,但业务执行时间超过锁超时时间仍会导致并发风险。设置时间过长又会影响故障恢复效率。
- 主从一致性问题:主从同步延迟可能导致主节点宕机时,从节点未同步锁信息,其他线程可能获取到同一把锁。
功能介绍
- 核心定位:基于Redis实现的Java驻内存数据网格(In-Memory Data Grid),提供分布式Java对象和服务集合。
- 功能特点:不仅包含分布式锁,还提供分布式集合、对象、服务等完整解决方案。
- 架构优势:支持独立节点、主从、哨兵、集群等多种部署模式,兼容AWS、Azure等云服务。
快速入门
- 锁特性:
- 可重入性:支持同一线程多次获取同一把锁
- 自动释放:防止死锁机制
- tryLock参数:
- 等待时间:最大等待获取锁时间(期间会重试)
- 释放时间:锁自动释放时间(默认30秒)
- 时间单位:灵活指定时间单位
- 使用规范:
- 获取锁:
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS) - 业务处理:在try块中执行业务代码
- 释放锁:必须在finally块中调用lock.unlock()
- 获取锁:
添加依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>配置类
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
}修改秒杀业务代码
@Resource
private RedissonClient redissonClient;
public Result seckillVoucher(Long voucherId) {
// 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if (voucher == null) {
return Result.fail("优惠券不存在!");
}
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("优惠券尚未开始!");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("优惠券已过期!");
}
// 判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
// 创建锁对象
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 获取锁
boolean isLock = lock.tryLock();
if (!isLock) {
return Result.fail("不允许重复下单!");
}
try {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}可重入锁原理
获取锁的流程
- 基础实现:采用Redis的string数据类型,通过SET命令加NX(互斥)、EX(超时)参数实现
- 线程标识:获取锁时存入线程标识,释放锁时验证标识避免误删
- 缺陷分析:无法实现可重入,因为NX参数导致同一线程二次获取必然失败
- 案例演示:method1获取锁后调用method2,同一线程内二次获取锁失败
- 执行流程:
- method1执行SET lock thread1 NX EX 10成功
- method2执行相同SET命令因NX参数失败
- 证明string结构无法实现重入
原理分析

-
JDK实现参考:ReentrantLock通过state计数器记录重入次数
- 获取锁:同一线程重入时state++
- 释放锁:每次释放state—,归零时才真正释放
-
Redis改造方案:
- 数据结构:改用hash结构,field存线程ID,value存重入次数
- 获取锁流程:
- 判断key是否存在(EXISTS命令)
- 不存在时:HSET key threadID 1 + 设置过期时间
- 存在时:验证线程ID,相同则HINCRBY + 重置过期时间
- 释放锁流程:
- 验证线程ID
- HINCRBY -1
- 判断值是否为0,是则DEL键
-
原子性保障:必须使用Lua脚本保证多步骤操作的原子性
-
关键改进点:
- 过期时间重置:每次重入操作都需要重置有效期
- 释放时机:只有最外层释放时才实际删除key
- 错误处理:非持有线程尝试释放应抛出异常
获取锁的lua
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断是否存在
if(redis.call('exists', key) == 0) then
-- 不存在, 获取锁
redis.call('hset', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
-- 锁已经存在,判断threadId是否是自己
if(redis.call('hexists', key, threadId) == 1) then
-- 不存在, 获取锁,重入次数+1
redis.call('hincrby', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败释放锁的lua
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断当前锁是否还是被自己持有
if (redis.call('HEXISTS', key, threadId) == 0) then
return nil; -- 如果已经不是自己,则直接返回
end;
-- 是自己的锁,则重入次数-1
local count = redis.call('HINCRBY', key, threadId, -1);
-- 判断是否重入次数是否已经为0
if (count > 0) then
-- 大于0说明不能释放锁,重置有效期然后返回
redis.call('EXPIRE', key, releaseTime);
return nil;
else -- 等于0说明可以释放锁,直接删除
redis.call('DEL', key);
return nil;
end;可重入问题解决方案
- 哈希结构实现: 采用哈希结构替代原始的String结构,能够同时保存线程标识和重入次数两个关键信息。
- 重入判断逻辑:
- 获取锁时先判断锁是否存在
- 若不存在则直接获取
- 若存在则检查线程标识是否为当前线程
- 重入计数机制:
- 当确认是当前线程时,将重入次数加一
- 释放锁时每次将重入次数减一
- 当重入次数减至零时才真正释放锁
- 与JDK对比: 该实现原理与JDK中的ReentrantLock基本一致。
不可重试问题
- 问题表现:当前自定义分布式锁在获取锁失败时会立即结束,缺乏重试机制
- Redisson解决方案:通过
tryLock(long waitTime, TimeUnit unit)方法实现- 参数说明:第一个参数waitTime表示最大等待时长,在该时间段内会持续尝试获取锁
- 工作机制:首次获取失败不会立即返回,而是在等待时间内不断重试,超时后才返回false
- 使用示例:
lock.tryLock(1L, TimeUnit.SECONDS)表示最长等待1秒
底层原理可参考Redisson分布式锁源码分析
2)锁失败重试问题解决方案
- 信号量机制: 基于Redis的发布订阅(pub/sub)功能实现
- 等待唤醒流程:
- 首次获取锁失败后不立即返回失败
- 转为等待释放锁的消息通知
- 通过订阅释放锁的频道进行监听
- 消息触发机制:
- 获取锁成功的线程在释放时会发布消息
- 等待线程收到消息后重新尝试获取锁
- 重试限制:
- 设置最大等待时间
- 超时后停止重试
- 性能优势: 采用等待唤醒方案,不会过多占用CPU资源
超时释放问题
- 问题表现:锁超时自动释放时若业务尚未完成,会导致线程安全问题
- 产生原因:业务执行时间过长或被阻塞,超过预设的锁有效期
- 风险场景:其他线程在业务未完成时获取到锁,造成数据不一致
- Redisson参数:leaseTime参数控制锁自动释放时间
- 注意事项:该参数可不传,系统会提供默认值
3)锁超时释放问题解决方案
- 看门狗机制: 通过定时任务自动续期锁的超时时间
- 实现细节:
- 获取锁成功后启动定时任务
- 定期重置锁的过期时间(expire)
- 使锁的生存时间得到延续
- 效果描述: 该机制使锁能够”满血复活”,避免业务未完成时锁自动释放的问题
主从一致性问题
- 问题本质:分布式环境下保证数据一致性的挑战
- 研究方法:需要通过跟踪Redisson源码分析其实现机制
- 关键方法:重点关注tryLock方法的不同重载形式
- 参数组合:最简实现只需传入等待时间和时间单位两个参数
- 参数选择建议:
- 必须指定waitTime以实现重试机制
- leaseTime可根据业务场景选择是否显式设置
- 时间单位需与业务时间尺度匹配(秒/毫秒等)
产生原因
- 单节点风险:采用单节点Redis时,若该节点发生故障,所有依赖Redis的业务(包括分布式锁)都会出现问题
- 核心业务需求:在核心业务场景中,这种单点故障的情况是不可接受的,因此需要提高Redis的可用性
主从模式介绍
- 角色划分:由多台Redis组成,其中一台作为主节点(master),其余作为从节点(slave)
- 职责分离:
- 主节点:处理所有写操作(增删改)
- 从节点:只处理读操作
- 数据同步机制:主节点会持续将自己的数据同步给从节点,确保主从数据一致性
延时问题
- 同步延迟:由于主从节点不在同一台机器,数据同步存在一定延迟
- 故障场景示例:
- 线程A执行set lock thread1 ax EX命令获取锁
- 主节点保存锁标识后,在同步完成前发生故障
- 哨兵选举新主节点时,因同步未完成导致锁丢失
- 其他线程可以获取相同锁,导致并发安全问题
解决方法
- 核心思路:取消主从关系,所有节点独立运行
- 获取锁机制:
- 必须依次向多个Redis节点获取锁
- 所有节点都保存锁标识才算获取成功
- 优势分析:
- 消除主从同步延迟问题
- 提高可用性:部分节点宕机不影响整体功能
- 可扩展性:节点数量越多,可用性越高
Multi Lock方案介绍
特点总结:
- 原子性保证:必须获取所有锁才算成功,否则释放已获取的锁
- 重试机制:在waitTime内会不断重试获取失败的锁
- 有效期同步:当指定leaseTime时,会统一所有锁的有效期
- 失败限制:通过failedLocksLimit控制允许失败的锁数量