#想cry 好想cry
目录
1 全局唯一id
1.1 自增ID存在的问题
1.2 分布式ID的需求
1.3 分布式ID的实现方式
1.4 自定义分布式ID生成器(示例)
1.5 总结
2 优惠券秒杀接口实现
3 单体系统下一人多单超卖问题及解决方案
3.1 问题背景
3.2 超卖问题的原因(并发查询)
3.3 解决方案
方案一:悲观锁
方案二:乐观锁
3.4 悲观锁和乐观锁的比较
3.4.1 性能
3.4.2 冲突处理
3.4.3 并发度
3.4.4 应用场景
3.4.5 总结对比
3.4.6 选择建议
3.5 乐观锁的实现(CAS法)
3.6 CAS的优缺点
3.7 总结
4 单体下的一人一单超卖问题
4.1 问题描述
4.2 原因
4.3 解决方案——悲观锁
4.3.1 实现流程
4.3.2 代码实现
4.3.3 实现细节(重要)
4.3.4 让代理对象生效的步骤
5 集群下的一人一单超卖问题
6 分布式锁
6.1 简要原理
6.2 分布式锁的特点
6.3 分布式锁的常见实现方式
6.4 Redis分布式锁的实现
6.5 分布式锁解决超卖问题
(1)创建分布式锁
(2)使用分布式锁
(3)实现细节
6.6 分布式锁优化
(1)优化1 解决锁超时释放出现的超卖问题
(2)优化2 解决释放锁时的原子性问题
1 问题背景
2 问题的根本原因
3 解决方案
4 Lua脚本的优势
5 实现步骤
5.1 编写Lua脚本
5.2 在Java中加载Lua脚本
5.3 实现释放锁的逻辑
6.7 手写分布式锁的各种问题与Redission引入
6.8 Redisson分布式锁
6.8.1 使用步骤
tryLock 方法详解
6.8.2 Redisson 可重入锁原理
6.8.3 Redisson 可重入锁原理
可重入问题解决
可重试问题解决
超时续约问题解决
主从一致性问题解决
6.9 看门狗机制的详细解剖
6.10 主从一致性问题的深入探讨——MultiLock
1 全局唯一id
1.1 自增ID存在的问题
-
规律性太明显:
-
容易被猜测,导致信息泄露或伪造请求。
-
攻击者可能通过规律推测其他用户的ID,造成安全风险。
-
-
分库分表限制:
-
MySQL单表存储量有限(约500万行或2GB),超过后需分库分表。
-
自增ID在分库分表后无法保证全局唯一性。
-
-
扩展性差:
-
高并发场景下,自增ID可能导致性能瓶颈。
-
维护复杂,需额外机制保证ID的唯一性和安全性。
-
1.2 分布式ID的需求
分布式ID需满足以下特点:
-
全局唯一性:整个系统中ID不重复。
-
高可用性:支持水平扩展和冗余备份。
-
安全性:ID生成独立于业务逻辑,避免规律性。
-
高性能:低延迟生成ID。
-
递增性:ID可按时间顺序排序,便于索引和检索。
1.3 分布式ID的实现方式
-
UUID:
-
优点:简单,全局唯一。
-
缺点:无序,存储空间大,不适合索引。
-
-
Redis自增:
-
优点:高性能,支持分布式。
-
缺点:依赖Redis,需考虑Redis的高可用性。
-
-
数据库自增:
-
优点:简单易用。
-
缺点:性能瓶颈,扩展性差。
-
-
Snowflake算法:
-
优点:高性能,ID有序。
-
缺点:依赖系统时钟,时钟回拨可能导致ID重复。
-
-
自定义实现:
-
结合时间戳、序列号和数据库自增,生成高安全性ID。
-
1.4 自定义分布式ID生成器(示例)
核心逻辑:
时间戳:31bit,表示秒级时间,支持69年。
序列号:32bit,表示每秒内的计数器,支持每秒生成2^32个ID。
拼接方式:时间戳左移32位后与序列号按位或运算。
代码实现:
@Component
public class RedisIdWorker {
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final long BEGIN_TIMESTAMP = 1640995200; // 起始时间戳
private static final int COUNT_BITS = 32; // 序列号位数
public long nextId(String keyPrefix) {
// 1. 生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2. 生成序列号(以当天日期为key,防止序列号溢出)
String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
Long count = stringRedisTemplate.opsForValue().increment("id:" + keyPrefix + ":" + date);
// 3. 拼接并返回ID
return timestamp << COUNT_BITS | count;
}
}
1.5 总结
-
自增ID的局限性:
-
规律性明显,安全性差。
-
扩展性受限,不适合高并发和分库分表场景。
-
-
分布式ID的优势:
-
全局唯一、高性能、高可用。
-
支持复杂业务场景,如高并发、分库分表。
-
-
实现建议:
-
优先选择Snowflake算法或自定义实现。
-
结合时间戳和序列号,确保ID的唯一性和递增性。
-
测试高并发场景下的性能和稳定性。
-
2 优惠券秒杀接口实现
/**
* 抢购秒杀券
*
* @param voucherId
* @return
*/
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
// 1、查询秒杀券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2、判断秒杀券是否合法
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("秒杀券已抢空");
}
// 5、秒杀券合法,则秒杀券抢购成功,秒杀券库存数量减一
boolean flag = seckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>()
.eq(SeckillVoucher::getVoucherId, voucherId)
.setSql("stock = stock -1"));
if (!flag){
throw new RuntimeException("秒杀券扣减失败");
}
// 6、秒杀成功,创建对应的订单,并保存到数据库
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId(SECKILL_VOUCHER_ORDER);
voucherOrder.setId(orderId);
voucherOrder.setUserId(ThreadLocalUtls.getUser().getId());
voucherOrder.setVoucherId(voucherOrder.getId());
flag = this.save(voucherOrder);
if (!flag){
throw new RuntimeException("创建秒杀券订单失败");
}
// 返回订单id
return Result.ok(orderId);
}
3 单体系统下一人多单超卖问题及解决方案
3.1 问题背景
在高并发场景下,优惠券秒杀功能可能出现超卖问题:
现象:库存为负数,订单数量超过实际库存。
原因:多个线程同时查询库存,发现库存充足后同时扣减库存,导致库存被多次扣减。
3.2 超卖问题的原因(并发查询)
线程1查询库存,发现库存充足,准备扣减。
线程2和线程3同时查询库存,也发现库存充足。
线程1扣减库存后,库存变为0,但线程2和线程3继续扣减,导致库存为负数。
3.3 解决方案
方案一:悲观锁
-
原理:认为线程安全问题一定会发生,操作前先加锁,确保线程串行执行。
-
实现方式:
-
synchronized
、Lock
等。
-
-
优点:简单直接,保证数据安全。
-
缺点:
-
性能低,加锁会导致线程阻塞。
-
并发度低,锁粒度大时影响系统性能。
-
-
适用场景:写入操作多、冲突频繁的场景。
方案二:乐观锁
-
原理:认为线程安全问题不一定发生,更新时判断数据是否被修改。
-
实现方式:
-
版本号法:
-
添加
version
字段,更新时检查版本号是否一致。 -
不一致则重试或抛异常。
-
-
CAS法:
-
使用库存字段代替版本号,更新时检查库存是否与查询时一致。
-
不一致则重试或抛异常。
-
-
-
优点:
-
性能高,无锁操作。
-
并发度高,适合读多写少的场景。
-
-
缺点:
-
冲突时需重试,可能增加CPU开销。
-
需处理ABA问题(版本号法)。
-
-
适用场景:读多写少、冲突较少的场景。
3.4 悲观锁和乐观锁的比较
3.4.1 性能
-
悲观锁:
-
需要先加锁再操作,加锁过程会消耗资源。
-
性能较低,尤其是在高并发场景下,锁竞争会导致线程阻塞。
-
-
乐观锁:
-
不加锁,只在提交时检查冲突。
-
性能较高,适合读多写少的场景。
-
3.4.2 冲突处理
-
悲观锁:
-
冲突发生时直接阻塞其他线程,确保数据安全。
-
冲突处理能力较低,可能导致大量线程等待。
-
-
乐观锁:
-
冲突发生时通过重试机制解决(如版本号法、CAS)。
-
冲突处理能力较高,适合低冲突场景。
-
3.4.3 并发度
-
悲观锁:
-
锁粒度较大,可能限制并发性能。
-
并发度较低,尤其是在锁竞争激烈时。
-
-
乐观锁:
-
无锁操作,支持高并发。
-
并发度较高,适合高并发场景。
-
3.4.4 应用场景
-
悲观锁:
-
适合写入操作多、冲突频繁的场景。
-
例如:银行转账、库存扣减等强一致性要求的场景。
-
-
乐观锁:
-
适合读取操作多、冲突较少的场景。
-
例如:秒杀系统、评论系统等高并发读场景。
-
3.4.5 总结对比
特性 | 悲观锁 | 乐观锁 |
---|---|---|
性能 | 较低(加锁开销大) | 较高(无锁操作) |
冲突处理 | 直接阻塞线程 | 通过重试机制解决冲突 |
并发度 | 较低(锁粒度大) | 较高(无锁,支持高并发) |
适用场景 | 写多读少、冲突频繁 | 读多写少、冲突较少 |
实现复杂度 | 简单(直接加锁) | 较复杂(需处理重试、ABA问题) |
3.4.6 选择建议
-
如果需要强一致性且冲突频繁,选择悲观锁。
-
如果需要高并发且冲突较少,选择乐观锁。
3.5 乐观锁的实现(CAS法)
CAS(Compare and Swap)是一种并发编程中常用的原子操作,用于解决多线程环境下的数据竞争问题。它是乐观锁算法的一种实现方式。
CAS操作包含三个参数:内存地址V、旧的预期值A和新的值B。CAS的执行过程如下:
比较(Compare):将内存地址V中的值与预期值A进行比较。
判断(Judgment):如果相等,则说明当前值和预期值相等,表示没有发生其他线程的修改。
交换(Swap):使用新的值B来更新内存地址V中的值。
CAS操作是一个原子操作,意味着在执行过程中不会被其他线程中断,保证了线程安全性。如果CAS操作失败(即当前值与预期值不相等),通常会进行重试,直到CAS操作成功为止。
业务核心逻辑:
-
更新库存时,检查库存是否大于0。
-
如果库存大于0,则扣减库存;否则,操作失败。
代码示例
boolean flag = seckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>()
.eq(SeckillVoucher::getVoucherId, voucherId)
.gt(SeckillVoucher::getStock, 0) // 检查库存是否大于0
.setSql("stock = stock - 1")); // 扣减库存
优化:
-
初始实现:库存不一致时直接终止操作,导致异常率高。
-
优化后:只要库存大于0就允许扣减,降低异常率。
3.6 CAS的优缺点
优点:
-
无锁操作,性能高。
-
适合高并发场景。
缺点:
(1)ABA问题:CAS操作无法感知到对象值从A变为B又变回A的情况,可能会导致数据不一致。为了解决ABA问题,可以引入版本号或标记位等机制。
(2)自旋开销:当CAS操作失败时,需要不断地进行重试,会占用CPU资源。如果重试次数过多或者线程争用激烈,可能会引起性能问题。
(3)并发性限制:如果多个线程同时对同一内存地址进行CAS操作,只有一个线程的CAS操作会成功,其他线程需要重试或放弃操作。
3.7 总结
-
超卖问题的本质:
-
高并发下,多个线程同时操作共享资源(库存),导致数据不一致。
-
-
解决方案对比:
-
悲观锁:简单但性能低,适合写多读少的场景。
-
乐观锁:性能高但需处理冲突,适合读多写少的场景。
-
-
推荐方案:
-
使用CAS法实现乐观锁,避免额外字段开销。
-
优化判断条件(库存>0),降低异常率。
-
4 单体下的一人一单超卖问题
4.1 问题描述
-
一个用户多次下单,导致超卖问题。
4.2 原因
-
多个线程同时查询用户订单状态,发现用户未下单后同时创建订单。
4.3 解决方案——悲观锁
使用synchronized
锁住用户ID,确保同一用户串行执行。
4.3.1 实现流程
4.3.2 代码实现
/**
* 抢购秒杀券
*
* @param voucherId
* @return
*/
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
// 1、查询秒杀券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2、判断秒杀券是否合法
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("秒杀券已抢空");
}
// 3、创建订单
Long userId = ThreadLocalUtls.getUser().getId();
synchronized (userId.toString().intern()) {
// 创建代理对象,使用代理对象调用第三方事务方法, 防止事务失效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(userId, voucherId);
}
}
/**
* 创建订单
*
* @param userId
* @param voucherId
* @return
*/
@Transactional
public Result createVoucherOrder(Long userId, Long voucherId) {
// synchronized (userId.toString().intern()) {
// 1、判断当前用户是否是第一单
int count = this.count(new LambdaQueryWrapper<VoucherOrder>()
.eq(VoucherOrder::getUserId, userId));
if (count >= 1) {
// 当前用户不是第一单
return Result.fail("用户已购买");
}
// 2、用户是第一单,可以下单,秒杀券库存数量减一
boolean flag = seckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>()
.eq(SeckillVoucher::getVoucherId, voucherId)
.gt(SeckillVoucher::getStock, 0)
.setSql("stock = stock -1"));
if (!flag) {
throw new RuntimeException("秒杀券扣减失败");
}
// 3、创建对应的订单,并保存到数据库
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId(SECKILL_VOUCHER_ORDER);
voucherOrder.setId(orderId);
voucherOrder.setUserId(ThreadLocalUtls.getUser().getId());
voucherOrder.setVoucherId(voucherOrder.getId());
flag = this.save(voucherOrder);
if (!flag) {
throw new RuntimeException("创建秒杀券订单失败");
}
// 4、返回订单id
return Result.ok(orderId);
// }
}
4.3.3 实现细节(重要)
(1)锁的范围尽量小。synchronized尽量锁代码块,而不是方法,锁的范围越大性能越低
(2)锁的对象一定要是一个不变的值。我们不能直接锁 Long 类型的 userId,每请求一次都会创建一个新的 userId 对象,synchronized 要锁不变的值,所以我们要将 Long 类型的 userId 通过 toString()方法转成 String 类型的 userId,toString()方法底层(可以点击去看源码)是直接 new 一个新的String对象,显然还是在变,所以我们要使用 intern() 方法从常量池中寻找与当前 字符串值一致的字符串对象,这就能够保障一个用户 发送多次请求,每次请求的 userId 都是不变的,从而能够完成锁的效果(并行变串行)
(3)我们要锁住整个事务,而不是锁住事务内部的代码。如果我们锁住事务内部的代码会导致其它线程能够进入事务,当我们事务还未提交,锁一旦释放,仍然会存在超卖问题
(4)Spring的@Transactional注解要想事务生效,必须使用动态代理。Service中一个方法中调用另一个方法,另一个方法使用了事务,此时会导致@Transactional失效,所以我们需要创建一个代理对象,使用代理对象来调用方法。
4.3.4 让代理对象生效的步骤
①引入AOP依赖,动态代理是AOP的常见实现之一
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
②暴露动态代理对象,默认是关闭的,在启动类上开启
@EnableAspectJAutoProxy(exposeProxy = true)
5 集群下的一人一单超卖问题
在集群部署的情况下,请求访问到不同的服务器,这个synchronized锁形同虚设,这是由于synchronized是本地锁,只能提供线程级别的同步,每个JVM中都有一把synchronized锁,不能跨 JVM 进行上锁,当一个线程进入被 synchronized 关键字修饰的方法或代码块时,它会尝试获取对象的内置锁(也称为监视器锁)。如果该锁没有被其他线程占用,则当前线程获得锁,可以继续执行代码;否则,当前线程将进入阻塞状态,直到获取到锁为止。而现在我们是多台服务器,也就意味着有多个JVM,所以synchronized会失效!
从而会出现超卖问题!
6 分布式锁
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁
6.1 简要原理
前面sychronized锁失效的原因是由于每一个JVM都有一个独立的锁监视器,用于监视当前JVM中的sychronized锁,所以无法保障多个集群下只有一个线程访问一个代码块。所以我们直接将使用一个分布锁,在整个系统的全局中设置一个锁监视器,从而保障不同节点的JVM都能够识别,从而实现集群下只允许一个线程访问一个代码块
6.2 分布式锁的特点
-
多线程可见:分布式锁存储在共享存储(如Redis)中,所有线程和节点都能看到锁的状态。
-
互斥性:任何时候只有一个线程或节点能持有锁,其他线程或节点必须等待。
-
高可用性:
-
即使部分节点故障,锁服务仍能正常工作。
-
具备容错性,锁持有者故障时能自动释放锁。
-
-
高性能:
-
锁的获取和释放操作要快,减少对共享资源的等待时间。
-
减少锁竞争带来的开销。
-
-
安全性:
-
可重入性:同一线程可多次获取同一把锁。
-
锁超时机制:避免锁被长时间占用,设置超时时间自动释放锁。
-
6.3 分布式锁的常见实现方式
-
基于关系数据库
-
利用数据库的唯一约束和事务特性实现锁。通过向数据库插入一条具有唯一约束的记录作为锁,其他进程在获取锁时会受到数据库的并发控制机制限制。
-
优点:简单易实现。
-
缺点:性能较低,不适合高并发场景。
-
-
基于缓存(如Redis)
-
使用Redis的
setnx
指令实现锁。通过将锁信息存储在缓存中,其他进程可以通过检查缓存中的锁状态来判断是否可以获取锁。 -
优点:性能高,适合高并发场景。
-
缺点:需处理锁超时、可重入等问题。
-
-
基于ZooKeeper
-
ZooKeeper是一个分布式协调服务,可以用于实现分布式锁。通过创建临时有序节点,每个请求都会尝试创建一个唯一的节点,并检查自己是否是最小节点,如果是,则表示获取到了锁。
-
优点:高可用,支持可重入锁。
-
缺点:性能较低,实现复杂。
-
-
基于分布式算法
-
使用Chubby、DLM等分布式算法实现锁。这些算法通过在分布式系统中协调进程之间的通信和状态变化,实现分布式锁的功能。
-
优点:适用于复杂分布式系统。
-
缺点:实现复杂,运维成本高。
-
-
setnx
指令的特点:setnx只能设置key不存在的值,值不存在设置成功,返回 1 ;值存在设置失败,返回 0
6.4 Redis分布式锁的实现
-
获取锁:
-
使用
setnx
指令设置锁,确保锁的唯一性。 -
为锁设置超时时间,避免死锁。
-
#保障指令的原子性 # 添加锁 set [key] [value] ex [time] nx
-
代码示例:
Boolean result = stringRedisTemplate.opsForValue() .setIfAbsent("lock:" + name, threadId, timeoutSec, TimeUnit.SECONDS);
-
-
释放锁:
-
使用
del
指令删除锁。 -
代码示例:
stringRedisTemplate.delete("lock:" + name);
-
6.5 分布式锁解决超卖问题
(1)创建分布式锁
public class SimpleRedisLock implements Lock {
/**
* RedisTemplate
*/
private StringRedisTemplate stringRedisTemplate;
/**
* 锁的名称
*/
private String name;
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
/**
* 获取锁
*
* @param timeoutSec 超时时间
* @return
*/
@Override
public boolean tryLock(long timeoutSec) {
String id = Thread.currentThread().getId() + "";
// SET lock:name id EX timeoutSec NX
Boolean result = stringRedisTemplate.opsForValue()
.setIfAbsent("lock:" + name, id, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
}
/**
* 释放锁
*/
@Override
public void unlock() {
stringRedisTemplate.delete("lock:" + name);
}
}
(2)使用分布式锁
改造前面VoucherOrderServiceImpl中的代码,将之前使用sychronized
锁的地方,改成我们自己实现的分布式锁:
// 3、创建订单(使用分布式锁)
Long userId = ThreadLocalUtls.getUser().getId();
SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);
boolean isLock = lock.tryLock(1200);
if (!isLock) {
// 索取锁失败,重试或者直接抛异常(这个业务是一人一单,所以直接返回失败信息)
return Result.fail("一人只能下一单");
}
try {
// 索取锁成功,创建代理对象,使用代理对象调用第三方事务方法, 防止事务失效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(userId, voucherId);
} finally {
lock.unlock();
}
(3)实现细节
try...finally...
确保发生异常时锁能够释放,注意这给地方不要使用catch
,A事务方法内部调用B事务方法,A事务方法不能够直接catch,否则会导致事务失效。
6.6 分布式锁优化
(1)优化1 解决锁超时释放出现的超卖问题
问题
当线程1获取锁后,由于业务阻塞,线程1的锁超时释放了,这时候线程2趁虚而入拿到了锁,然后此时线程1业务完成了,然后把线程2刚刚获取的锁给释放了,这时候线程3又趁虚而入拿到了锁,这就导致又出现了超卖问题!(但是这个在小项目(并发数不高)中出现的概率比较低,在大型项目(并发数高)情况下是有一定概率的)
如何解决呢?
我们为分布式锁添加一个线程标识,在释放锁时判断当前锁是否是自己的锁,是自己的就直接释放,不是自己的就不释放锁,从而解决多个线程同时获得锁的情况导致出现超卖
只需要改一下锁的实现:
package com.hmdp.utils.lock.impl;
import cn.hutool.core.lang.UUID;
import com.hmdp.utils.lock.Lock;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
/**
* @author ghp
* @title
* @description
*/
public class SimpleRedisLock implements Lock {
/**
* RedisTemplate
*/
private StringRedisTemplate stringRedisTemplate;
/**
* 锁的名称
*/
private String name;
/**
* key前缀
*/
public static final String KEY_PREFIX = "lock:";
/**
* ID前缀
*/
public static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
/**
* 获取锁
*
* @param timeoutSec 超时时间
* @return
*/
@Override
public boolean tryLock(long timeoutSec) {
String threadId = ID_PREFIX + Thread.currentThread().getId() + "";
// SET lock:name id EX timeoutSec NX
Boolean result = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
}
/**
* 释放锁
*/
@Override
public void unlock() {
// 判断 锁的线程标识 是否与 当前线程一致
String currentThreadFlag = ID_PREFIX + Thread.currentThread().getId();
String redisThreadFlag = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if (currentThreadFlag != null || currentThreadFlag.equals(redisThreadFlag)) {
// 一致,说明当前的锁就是当前线程的锁,可以直接释放
stringRedisTemplate.delete(KEY_PREFIX + name);
}
// 不一致,不能释放
}
}
(2)优化2 解决释放锁时的原子性问题
1 问题背景
在高并发场景下,分布式锁可能会出现以下问题:
-
锁超时释放:线程1获取锁后,因业务阻塞导致锁超时释放,线程2趁机获取锁并执行业务。此时线程1恢复执行,误删线程2的锁,导致线程3也能获取锁,从而引发超卖问题。
2 问题的根本原因
-
锁超时机制:
-
锁设置了超时时间,防止死锁。
-
但业务执行时间可能超过锁的超时时间,导致锁被提前释放。
-
-
非原子操作:
-
判断锁和释放锁是两个独立的操作,中间可能被其他线程插入。
-
3 解决方案
使用Lua脚本确保判断锁和释放锁的原子性。
4 Lua脚本的优势
-
原子性:
-
Redis执行Lua脚本时,会阻塞其他命令和脚本,确保脚本内的操作是原子的。
-
类似于事务的
MULTI
/EXEC
,但Lua脚本更轻量。
-
-
高性能:
-
Lua脚本在Redis中执行,避免了多次网络通信的开销。
-
-
简单易用:
-
Lua脚本可以直接嵌入Java代码中,通过Redis执行。
-
5 实现步骤
5.1 编写Lua脚本
-
释放锁的Lua脚本:
-
检查锁的线程标识是否与当前线程一致。
-
如果一致,则删除锁;否则,不做任何操作。
-
脚本内容:
-- 比较缓存中的线程标识与当前线程标识是否一致 if (redis.call('get', KEYS[1]) == ARGV[1]) then -- 一致,直接删除 return redis.call('del', KEYS[1]) end -- 不一致,返回0 return 0
-
-
脚本说明:
-
KEYS[1]
:锁的Key(如lock:order:1
)。 -
ARGV[1]
:当前线程的标识(如UUID-线程ID
)。
-
5.2 在Java中加载Lua脚本
-
定义Lua脚本:
-
将Lua脚本保存为文件(如
unlock.lua
),并放在resources/lua
目录下。
-
-
加载Lua脚本:
-
使用
DefaultRedisScript
加载Lua脚本。 -
代码示例:
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT; static { UNLOCK_SCRIPT = new DefaultRedisScript<>(); UNLOCK_SCRIPT.setLocation(new ClassPathResource("lua/unlock.lua")); UNLOCK_SCRIPT.setResultType(Long.class); }
-
5.3 实现释放锁的逻辑
-
释放锁的Java代码:
-
使用
stringRedisTemplate.execute
执行Lua脚本。 -
代码示例:
@Override public void unlock() { // 执行Lua脚本 stringRedisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name), // KEYS[1] ID_PREFIX + Thread.currentThread().getId() // ARGV[1] ); }
-
-
关键点:
-
线程标识:使用
UUID + 线程ID
作为线程的唯一标识,确保不同线程的锁不会冲突。 -
原子性:Lua脚本确保判断锁和释放锁的操作是原子的。
-
6.7 手写分布式锁的各种问题与Redission引入
在分布式系统中,为保证数据一致性和线程安全,常需要使用分布式锁。但自己实现的分布式锁存在诸多问题,难以达到生产可用级别:
- 不可重入:同一线程无法重复获取同一把锁,易造成死锁。例如在嵌套方法调用中,若方法 A 和方法 B 都需获取同一把锁,线程 1 在方法 A 获取锁后,进入方法 B 再次获取时会失败,导致死锁。
- 不可重试:获取锁仅尝试一次,失败即返回 false,无重试机制。若线程 1 获取锁失败后直接结束,会导致数据丢失,比如线程 1 要将数据写入数据库,因锁被线程 2 占用而放弃,数据无法正常写入。
- 超时释放问题:虽超时释放机制能降低死锁概率,但有效期设置困难。有效期过短,业务未执行完锁就释放,存在安全隐患;有效期过长,易出现死锁。
- 主从一致性问题:在 Redis 主从集群中,主从同步存在延迟。若线程 1 在主节点获取锁后,主节点故障,从节点未及时同步该锁信息,其他线程可能在从节点再次获取到该锁,导致数据不一致。
Redisson 是成熟的 Redis 框架,提供分布式锁和同步器、分布式对象、分布式集合、分布式服务等多种分布式解决方案,可有效解决上述问题,因此可直接使用 Redisson 优化分布式锁。
6.8 Redisson分布式锁
6.8.1 使用步骤
(1)引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
(2)配置 Redisson 客户端
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private String port;
@Value("${spring.redis.password}")
private String password;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress("redis://" + this.host + ":" + this.port)
.setPassword(this.password);
return Redisson.create(config);
}
}
注:也可引入 Redisson 的 starter 依赖并在 yml
文件中配置,但不推荐,因其会替换 Spring 官方提供的 Redisson 配置。
(3)修改使用锁的代码
在业务代码中,使用 Redisson 客户端获取锁并尝试加锁:
Long userId = ThreadLocalUtls.getUser().getId();
RLock lock = redissonClient.getLock(RedisConstants.LOCK_ORDER_KEY + userId);
boolean isLock = lock.tryLock();
tryLock
方法详解
tryLock()
:使用默认的超时时间和等待机制,具体超时时间由 Redisson 配置文件或自定义配置决定。tryLock(long time, TimeUnit unit)
:在指定的time
时间内尝试获取锁,若成功则返回true
;若在指定时间内未获取到锁,则返回false
。tryLock(long waitTime, long leaseTime, TimeUnit unit)
:指定等待时间waitTime
,若超过leaseTime
仍未获取到锁,则直接返回失败。 无参的tryLock
方法中,waitTime
默认值为 -1,表示不等待;leaseTime
默认值为 30 秒,即锁超过 30 秒未释放会自动释放。自上而下,tryLock
方法的灵活性逐渐提高。
6.8.2 Redisson 可重入锁原理
Redisson 内部将锁以 hash 数据结构存储在 Redis 中,每次获取锁时,将对应线程的 value 值加 1;每次释放锁时,将 value 值减 1;只有当 value 值归 0 时,才真正释放锁,以此确保锁的可重入性。
6.8.3 Redisson 可重入锁原理
可重入问题解决
利用 hash 结构记录线程 ID 和重入次数。每次线程获取锁时,检查 hash 结构中该线程 ID 对应的重入次数,若不存在则初始化重入次数为 1,若已存在则将重入次数加 1。
可重试问题解决
利用信号量和 PubSub(发布 - 订阅)功能实现等待、唤醒机制。当线程获取锁失败时,将其放入等待队列,通过 PubSub 监听锁释放的消息,一旦锁释放,唤醒等待队列中的线程重试获取锁。
超时续约问题解决
利用看门狗(WatchDog)机制,每隔一段时间(releaseTime / 3
)重置锁的超时时间。若线程持有锁的时间超过预设的有效时间,看门狗会自动延长锁的有效期,确保业务执行完毕后再释放锁。
主从一致性问题解决
利用 Redisson 的 MultiLock
机制,多个独立的 Redis 节点必须都获取到重入锁,才算获取锁成功。这样即使主从节点同步存在延迟,也能保证锁的一致性。但此方法存在运维成本高、实现复杂的缺陷。
6.9 看门狗机制的详细解剖
- 工作原理:看门狗机制是 Redisson 解决锁超时释放问题的关键。当一个线程成功获取锁后,看门狗会启动一个定时任务,每隔
releaseTime / 3
的时间就会去重置锁的过期时间。例如,如果锁的初始有效期是 30 秒,那么看门狗会每隔 10 秒就去将锁的有效期重新设置为 30 秒,直到线程主动释放锁。 - 取消任务的情况:虽然看门狗机制可以确保业务执行过程中锁不会过期,但也不能让锁永不过期。当线程调用
unlock()
方法释放锁时,看门狗的定时任务会被取消。另外,如果在获取锁时指定了leaseTime
(锁的有效期),那么当到达leaseTime
时,锁会自动释放,看门狗也不会再去续约。
6.10 主从一致性问题的深入探讨——MultiLock
- MultiLock 机制的工作流程:当使用 Redisson 的
MultiLock
时,它会尝试在多个独立的 Redis 节点上同时获取锁。只有当所有节点都成功获取到锁时,才认为整个锁获取成功。例如,假设有三个 Redis 节点 A、B、C,线程尝试获取锁时,会依次向这三个节点发送获取锁的请求。如果三个节点都返回获取锁成功,那么线程才真正获得了锁;只要有一个节点获取锁失败,整个获取锁的操作就失败。 - 运维成本和复杂度分析:使用
MultiLock
虽然可以解决主从一致性问题,但会带来较高的运维成本和实现复杂度。在运维方面,需要管理多个独立的 Redis 节点,包括节点的部署、监控、故障处理等。在实现方面,代码逻辑会变得更加复杂,需要考虑多个节点的状态和交互。而且,由于要在多个节点上获取锁,会增加锁获取的时间开销,降低系统的性能。