目录
全局唯一ID:
为什么 count 不可能为 null?
为什么返回值是 timestamp << COUNT_BITS | count?
整体的逻辑
(1) 生成时间戳
(2) 生成序列号
(3) 拼接时间戳和序列号
超卖问题:
基于版本号的乐观锁
CAS思想
一人一单:
不建议在方法上直接加锁
更合理的加锁方式
问题:toString 无法保证锁唯一性
解决方法:使用 String.intern()
将锁移动到外部
注意事务失效问题
分布式锁:
什么是分布式锁?
分布式锁的关键特性
分布式锁的选择
ILOCK接口:
锁类的代码:
为什么需要面向接口编程?
解耦,提高代码灵活性
为什么不能仅仅使用 Thread.currentThread().getId() 作为锁的唯一标识存储在 Redis 中?
1. 存在冲突和混淆的风险
2. 不同 JVM 的线程 ID 可能重复
解决方案
问题描述
解决Redis误删锁的问题:
用 Lua 解决锁判断和释放的原子性问题
背景问题
Lua 脚本解决
Redisson
异步秒杀
全局唯一ID:
为什么采用64-bit long 类型,时间戳为 32-bit,序列号为 31-bit?
采用 64-bit long 类型:
- Java 的
long
类型是 64-bit,能够表示的整数范围非常大,适合生成全局唯一的 ID。 - 使用一个整数类型作为唯一 ID,比字符串等其他类型更加高效,节省存储空间和计算资源。
时间戳 32-bit:
- 时间戳占据 32 位,足够表示未来很长一段时间的秒数。以
BEGIN_TIMESTAMP = 1736121600
为起点(约为 2024 年 12 月 1 日),加上 2³² 秒(约 136 年),可以覆盖到 2160 年。
序列号 31-bit:
- 序列号占据 31 位,支持在同一秒内生成 2³² - 1个唯一的 ID(约 21 亿个),足够应对大多数高并发场景。
保留 1-bit 未使用:
- 通常,最高位(第 64 位)保留不用,以防止负数的情况(符号位)。如果需要,也可以将它用于其他目的。一般第一位为0保证ID值不为负数
使用Redis实现全局唯一ID:
package com.hmdp.utils;import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;@Component
@RequiredArgsConstructor
public class RedisIdWorker {final StringRedisTemplate stringRedisTemplate;/*** 开始时时间戳**/private static final long BEGIN_TIMESTAMP = 1736121600L;private static final int COUNT_BITS = 32; // 表示时间戳需要往左移动几位public long nextId(String keyPrefix){// 生成时间戳LocalDateTime now = LocalDateTime.now();long nowEpochSecond = now.toEpochSecond(ZoneOffset.UTC);// 表示和初始时间相差多少long timestamp = nowEpochSecond - BEGIN_TIMESTAMP;// 生成序列号// 获取生成ID号的时间String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + data);// 为什么count不可能是null呢?// 如果不加上时间作为key的话,Redis的自增值会达到上限// 拼接并且返回return timestamp << COUNT_BITS | count;}public static void main(String[] args) {LocalDateTime time = LocalDateTime.of(2025, 1, 6, 0, 0, 0);long epochSecond = time.toEpochSecond(ZoneOffset.UTC);System.out.println(epochSecond);}}
为什么 count
不可能为 null
?
count
的值来源于 stringRedisTemplate.opsForValue().increment()
方法:
increment()
是 Redis 的自增操作:
- 如果指定的键不存在,Redis 会自动初始化键值为 0,然后执行自增操作,结果是 1。
- 因此,
increment()
的返回值不可能为null
。
为什么返回值是 timestamp << COUNT_BITS | count
?
timestamp << COUNT_BITS
:
- 将时间戳左移 32 位(
COUNT_BITS
),腾出低 32 位给序列号。时间戳占高位,保证生成的 ID 随时间递增。
| count
:
- 使用按位或操作,将序列号填入低 32 位。序列号是每秒递增的,用于区分同一秒内的多个 ID。
- 按位或操作(
|
)在二进制中逐位比较两个数的每一位,只要有一个为1
,结果就为1
。这个特性使得它可以将某些位的值“合并”到一个数字中,而不改变原来其他位的值。 - 这两个部分结合起来形成一个唯一的 64-bit 整数 ID。
整体的逻辑
(1) 生成时间戳
long nowEpochSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowEpochSecond - BEGIN_TIMESTAMP;
- 当前时间
now
转换为秒级时间戳。 - 减去初始时间戳
BEGIN_TIMESTAMP
,得到相对时间戳timestamp
。
(2) 生成序列号
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + data);
- 基于 Redis 的
increment()
操作生成序列号。 - 每秒一个 Redis 键(
"icr:<keyPrefix>:<data>"
),保证同一秒内序列号从 1 开始递增。 - 如果 Redis 键不存在,
increment()
会初始化为 0,然后返回 1。
(3) 拼接时间戳和序列号
return timestamp << COUNT_BITS | count;
- 将时间戳左移 32 位(
COUNT_BITS
),腾出低 32 位。 - 通过按位或操作将序列号填入低 32 位。
- 得到一个 64-bit 的唯一整数 ID。
UUID:
雪花算法(snowflake) :
超卖问题:
基于版本号的乐观锁
原理:在数据表中新增一个版本号字段(如 version
),每次更新数据时,要求 version
字段值与当前数据库中的版本号匹配,只有匹配成功时才允许更新。更新后,version
自动加一。
流程:
- 读取数据时,获取当前版本号。
- 更新数据时,带上该版本号作为条件。
- 数据库执行更新时会检查版本号是否一致,若一致,则更新成功;否则更新失败。
-- 查询数据和版本号
SELECT id, name, version FROM user WHERE id = 1;-- 更新数据时,使用版本号作为条件
UPDATE user
SET name = 'new_name', version = version + 1
WHERE id = 1 AND version = 1;
优点:简单直观,可防止并发修改。
缺点:需要在表中增加版本号字段。
CAS思想
CAS 是一种基于比较和交换的机制,用于确保数据的原子性更新。
基于版本号的乐观锁虽然是一种常见实现方式,但其缺点是需要在数据库表中额外新增一个字段(如 version
字段)。然而,我们可以利用数据库中已有的字段(例如价格、库存数量、更新时间等),通过在执行 SQL 语句时将这些字段的值与执行前查询到的值进行比较,实现类似的乐观锁功能,从而避免新增字段的开销。
CAS 操作包含以下三个核心要素:
执行前查询到的值
- 这是在操作开始之前,从数据库中读取的字段值(例如,某个商品的库存数量
stock = 100
)。 - 它反映了我们未被修改之前数据的状态,在后续更新时用于与数据库中的当前值进行比较。
当前值(Current Value)
- 这是在执行 SQL 更新时,数据库中该字段的实际值。
- 如果数据库中的当前值与预期值一致,说明数据在操作期间未被其他事务修改,可以安全地执行更新操作。
判断之前是否已经被修改了
- 如果预期值与当前值相等,则执行更新操作,将字段值更新为目标值。
- 如果不相等,说明数据在操作期间已经被其他事务修改,放弃修改数据库,返回失败。
使用乐观锁存在一个缺陷,就是只要发现别人修改了就放弃执行sql语句,会导致请求的大量失败。
所以还有一个更简单的办法,就是在更新的时候,查看库存是否大于0即可
@Service
@RequiredArgsConstructor
@Transactional
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;final RedisIdWorker redisIdWorker;@Overridepublic Result seckillVoucher(Long voucherId) {// 查询优惠券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 判断秒杀是否开始LocalDateTime beginTime = voucher.getBeginTime();if(LocalDateTime.now().isBefore(beginTime)){return Result.fail("秒杀还未开始");}// 判断秒杀是否已经结束LocalDateTime endTime = voucher.getEndTime();if(LocalDateTime.now().isAfter(endTime)){return Result.fail("秒杀已经结束");}// 判断库存是否充足if (voucher.getStock() < 1) {return Result.fail("库存不足!");}// 扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).
// eq("stock",voucher.getStock()). // 加个乐观锁,如果现在的库存和我之前查询的库存不相同,说明在我之前就有线程修改了数据库gt("stock", 0).update();if (!success) {return Result.fail("库存不足!");}// 创建订单VoucherOrder voucherOrder = new VoucherOrder();// 订单idlong orderID = redisIdWorker.nextId("order");voucherOrder.setId(orderID);// 用户idvoucherOrder.setUserId(UserHolder.getUser().getId());// 代金券idvoucherOrder.setVoucherId(voucherId);// 写入数据库save(voucherOrder);// 返回订单idreturn Result.ok(orderID);}
}
一人一单:
也就是需要在在判断库存充足之后还需要根据优惠券的id和用户的id来查询订单。如果订单存在就返回异常(说明该用户之前已经购买过了) 如果订单不存在我们就可以扣减库存并且可以创建订单。
// 前面代码一致。。。。
// 实现一人一单,我们需要先判断该用户是否已经抢过了// 根据优惠券id和用户id查询订单Long userId = UserHolder.getUser().getId();int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();if (count > 0) {return Result.fail("已经购买过,不可重复购买!");}// 扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).
// eq("stock",voucher.getStock()). // 加个乐观锁,如果现在的库存和我之前查询的库存不相同,说明在我之前就有线程修改了数据库gt("stock", 0).update();if (!success) {return Result.fail("库存不足!");}// 创建订单VoucherOrder voucherOrder = new VoucherOrder();// 订单idlong orderID = redisIdWorker.nextId("order");voucherOrder.setId(orderID);// 用户idvoucherOrder.setUserId(UserHolder.getUser().getId());// 代金券idvoucherOrder.setVoucherId(voucherId);// 写入数据库save(voucherOrder);// 返回订单idreturn Result.ok(orderID);
在以上我们在查询用户是否已经下单在并发的情况下存在问题:
当线程 A 查询到 count
的值为 0
(表示用户之前没有下过单),但还未完成插入订单数据的操作时,线程 B 可能在此时抢到了 CPU 的执行权,并且同样查询到 count
的值为 0
。由于这两个线程的操作是并发的,线程 B 也会插入订单数据,导致用户可以多次下单。这种情况违反了“一人一单”的业务需求。
为了避免这种问题的发生,我们需要在 查询用户是否已经下单 到 插入订单数据 这整个过程中使用一把锁进行保护,确保同一时间只有一个线程能够执行这一逻辑。这样,即使多个线程同时进入方法,也只有第一个线程能够完成操作,其余线程会被阻塞或直接返回,达到线程安全的目的。
可以将这段逻辑提取成一个方法,使用事务管理和锁机制,保证查询和插入操作的原子性。具体代码实现如下:
@Transactionalpublic synchronized Result createVoucherOrder(Long voucherId) {// 实现一人一单,我们需要先判断该用户是否已经抢过了// 根据优惠券id和用户id查询订单Long userId = UserHolder.getUser().getId();int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();if (count > 0) {return Result.fail("已经购买过,不可重复购买!");}// 扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).
// eq("stock",voucher.getStock()). // 加个乐观锁,如果现在的库存和我之前查询的库存不相同,说明在我之前就有线程修改了数据库gt("stock", 0).update();if (!success) {return Result.fail("库存不足!");}// 创建订单VoucherOrder voucherOrder = new VoucherOrder();// 订单idlong orderID = redisIdWorker.nextId("order");voucherOrder.setId(orderID);// 用户idvoucherOrder.setUserId(UserHolder.getUser().getId());// 代金券idvoucherOrder.setVoucherId(voucherId);// 写入数据库save(voucherOrder);// 返回订单idreturn Result.ok(orderID);}
不建议在方法上直接加锁
将锁对象加在方法上会导致以下问题:
- 锁的范围过大:锁定整个方法,降低并发性能。
- 锁对象为
this
:无论哪个用户访问,都需要获取同一把锁,导致线程串行执行,性能较差。
更合理的加锁方式
加锁的对象应该基于业务需求选择更细粒度的对象,例如用户ID(userId
),以减小锁的范围。
@Transactionalpublic Result createVoucherOrder(Long voucherId) {// 实现一人一单,我们需要先判断该用户是否已经抢过了// 根据优惠券id和用户id查询订单Long userId = UserHolder.getUser().getId();synchronized (userId.toString()) {//具体的业务逻辑}}
问题:toString
无法保证锁唯一性
userId.toString()
每次都会生成一个新的字符串对象,因此不能保证锁定的是同一个对象,这会导致 synchronized
失效,从而无法有效控制并发。
解决方法:使用 String.intern()
可以通过 userId.toString().intern()
来保证锁的唯一性。intern()
方法会将字符串存储到 JVM 的字符串常量池中,相同的字符串值会返回相同的引用,确保锁对象唯一。
synchronized (userId.toString().intern()) { //具体的业务逻辑}
调用 intern()
方法后,userId.toString().intern()
能够保证唯一性,是因为 String.intern()
方法将字符串存储到 JVM 的字符串常量池(String Pool)中。对于相同的字符串值,intern()
方法确保返回的引用是同一个对象,从而实现锁对象的唯一性。
在方法内部加锁还存在一个问题:
将锁移动到外部
在方法内部加锁存在另一个问题:
方法结束时锁会释放,但事务提交是延后的。如果在事务提交前,其他线程查询到数据库发现没有订单,仍可能导致“一人多单”的问题。
因此,锁应放在方法外部:
// seckillVoucher方法
public Result seckillVoucher(Long voucherId) {// 查询优惠券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 判断秒杀是否开始LocalDateTime beginTime = voucher.getBeginTime();if(LocalDateTime.now().isBefore(beginTime)){return Result.fail("秒杀还未开始");}// 判断秒杀是否已经结束LocalDateTime endTime = voucher.getEndTime();if(LocalDateTime.now().isAfter(endTime)){return Result.fail("秒杀已经结束");}// 判断库存是否充足if (voucher.getStock() < 1) {return Result.fail("库存不足!");}Long userId = UserHolder.getUser().getId(); synchronized (userId.toString().intern()){return createVoucherOrder(voucherId);}
}
注意事务失效问题
在上述代码中,直接调用 createVoucherOrder(voucherId)
可能导致事务失效问题。这是因为 Spring 的事务管理基于动态代理,直接调用会绕过代理,导致事务功能失效。
解决方法是通过 AopContext
获取当前类的代理对象来调用方法:
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);
如果有多个JVM的存在,每个JVM都会有自己的锁导致每一个锁,因此对于集群来说,还是会导致一人多单的。(还是锁对象不唯一导致的)
在分布式系统中,每个 JVM 都有自己的内存空间,因此即使在单机环境中通过 synchronized
和 String.intern()
保证了锁对象的唯一性,到了集群环境中,不同的 JVM 实例仍然可能生成不同的锁对象,从而导致 分布式环境下并发控制失效。
分布式锁:
基于Redis的分布式锁:
什么是分布式锁?
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁是一种跨多个进程或服务器实例的机制,用于在分布式系统中对共享资源实现同步控制。它是为了解决多个进程或服务访问同一资源时的并发问题而设计的,确保在同一时间只有一个进程能够访问某个共享资源。
在单机环境中,synchronized
和 ReentrantLock
等 JVM 内部的锁机制可以很好地解决并发问题,但在分布式系统中,多个实例运行在不同的 JVM 上,内存不共享,传统的锁机制就无法满足需求。这时,就需要 分布式锁
分布式锁的关键特性
- 互斥性:同一时间只有一个客户端能够获得锁,确保对共享资源的独占访问。
- 容错性:即使一个客户端因故障未释放锁,系统也能通过机制(如超时)保证锁最终释放。
- 高可用性:分布式锁的获取和释放需要快速响应,且系统中的单点故障不应导致锁机制不可用
分布式锁的选择
实现方式 | 适用场景 | 优点 | 缺点 |
---|---|---|---|
Redis 锁 | 高性能、高并发场景 | 简单高效,性能优越 | 锁过期或释放需要谨慎处理 |
ZooKeeper 锁 | 高可靠性和一致性要求场景 | 可靠性高,支持自动释放 | 性能较低,维护成本高 |
数据库锁 | 并发量小的场景 | 简单易用,无需额外基础设施 | 性能受限,可能出现锁争用 |
Redis 储存的实际上是锁的唯一值。这个唯一值的主要作用是区分哪个客户端线程持有了锁
ILOCK接口:
package com.hmdp.utils;public interface ILock {/*** 尝试获取锁* @param timeoutSec 锁持有的超时时间,过期后自动释放* @return true代表获取锁成功;false代表获取锁失败*/boolean tryLock(long timeoutSec);/*** 释放锁*/void unlock();
}
锁类的代码:
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;import java.util.concurrent.TimeUnit;public class SimpleRedisLock implements ILock{public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {this.name = name;this.stringRedisTemplate = stringRedisTemplate;}private static final String KEY_PREFIX = "lock";private String name; // 锁的名称private StringRedisTemplate stringRedisTemplate;@Overridepublic boolean tryLock(long timeoutSec) {Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, Thread.currentThread().getId()+"",timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(success) ; // 自动拆箱可能会存在空指针}@Overridepublic void unlock() {stringRedisTemplate.delete(KEY_PREFIX + name);}
}
public Result seckillVoucher(Long voucherId) {// 查询优惠券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 判断秒杀是否开始LocalDateTime beginTime = voucher.getBeginTime();if(LocalDateTime.now().isBefore(beginTime)){return Result.fail("秒杀还未开始");}// 判断秒杀是否已经结束LocalDateTime endTime = voucher.getEndTime();if(LocalDateTime.now().isAfter(endTime)){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(1200);if(! isLock){// 说明没有获取到锁return Result.fail("不可重复下单!");}// 我们需要获取代理(事务)对象才行//try finally 事务可以生效,因为没有捕获异常。// 如果catch捕获了异常,需要抛出RuntimeException类型异常,不然事务失效。// 这里加了catch事务也能生效。因为事务失效的场景是在事务方法内部try catch消化掉异常,而这里try catch是在事务方法外部(可以自己抛异常测试一下)try {IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);} finally {lock.unlock();}}
为什么需要面向接口编程?
解耦,提高代码灵活性
-
问题:
如果直接依赖具体类(例如SimpleRedisLock
),系统会被绑定到 Redis 实现上。如果未来需求变化(如改用 ZooKeeper 或数据库实现分布式锁),就需要修改所有依赖锁的代码,增加维护成本。 -
解决:
使用接口(例如ILock
)定义分布式锁的行为。业务代码只依赖ILock
接口,与具体实现无关。这样,当需要更换锁的实现时,只需新增实现类(如ZookeeperLock
),无需修改调用代码,极大提升了灵活性和可扩展性。
为什么不能仅仅使用 Thread.currentThread().getId()
作为锁的唯一标识存储在 Redis 中?
1. 存在冲突和混淆的风险
- 线程 ID 是数字,直接使用
Thread.currentThread().getId()
可能导致锁值与其他业务逻辑的 Redis 键值冲突或混淆,特别是在调试和监控时,难以区分锁的来源。
2. 不同 JVM 的线程 ID 可能重复
- 在分布式系统中,不同 JVM 实例可能生成相同的线程 ID。例如,一个 JVM 的线程 ID 为
1
,另一个 JVM 的线程也可能是1
,这会导致锁的唯一性失效,从而引发并发问题。
解决方案
使用 UUID + 线程 ID 的组合作为锁的唯一值:
- UUID 保证跨 JVM 的唯一性。
- 线程 ID 提供额外的信息,用于定位锁的具体来源。
String threadId = UUID.randomUUID().toString() + "-" + Thread.currentThread().getId();
以上代码还存在一个问题,让我们来看以下的场景
问题描述
- 线程 A 获取锁:线程 A 成功获取锁并设置了过期时间。
- 线程 A 阻塞:由于业务逻辑执行时间较长,线程 A 被阻塞,锁的过期时间已到,Redis 自动释放了锁。
- 线程 B 获取锁:此时线程 B 成功获取到锁并开始执行逻辑。
- 线程 A 释放锁:线程 A 在执行完逻辑后尝试释放锁,但此时锁已经被线程 B 重新获取,A 无法确认锁是否属于自己,直接删除了锁。
- 线程 C 获取锁:因为锁被 A 误删,线程 C 也获得锁,导致锁的控制完全失效,进而出现并发问题。
为了解决锁误删问题,我们需要在释放锁时确认锁的持有者,确保只有锁的持有者才能释放锁。也就是需要在释放锁之前还需要判断是否是自己的锁。这可以通过以下方法实现:
解决Redis误删锁的问题:
添加线程标识,并在删除之前判断线程标识
package com.hmdp.utils;
import cn.hutool.core.lang.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;import java.util.concurrent.TimeUnit;public class SimpleRedisLock implements ILock{public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {this.name = name;this.stringRedisTemplate = stringRedisTemplate;}private static final String KEY_PREFIX = "lock";private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";private String name; // 锁的名称private StringRedisTemplate stringRedisTemplate;@Overridepublic boolean tryLock(long timeoutSec) {// 不能使用Thread.currentThread().getId()+""作为唯一的key的标识,因为不同的JVM可能会创建出相同的idString threadId = ID_PREFIX + Thread.currentThread().getId(); // 使用当前线程的唯一标识(线程ID)作为锁的归属标识。Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name,threadId,timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(success) ; // 自动拆箱可能会存在空指针}
// 这里还有问题:get和delete不是原子性的。
// 假如时间到期了,另一个线程获取到了锁,这时候你又给删除了@Overridepublic void unlock() {// 判断锁是不是自己的String threadId = ID_PREFIX + Thread.currentThread().getId();if ( stringRedisTemplate.opsForValue().get(KEY_PREFIX+name).equals(threadId)) {// 是自己的锁stringRedisTemplate.delete(KEY_PREFIX + name);}}
}
用 Lua 解决锁判断和释放的原子性问题
背景问题
在 Redis 分布式锁中,可能会出现以下场景:
- 线程 A 持有锁并因阻塞导致锁超时。
- 线程 B 获得了锁。
- 线程 A 恢复运行,并认为锁仍是它的(因为判断逻辑和释放逻辑不是原子性的)。
- 线程 A 错误地释放了线程 B 的锁。
Lua 脚本解决
解决方案是针对Java jvm 的阻塞问题的解决。以下是一个示例脚本:
-- Lua 脚本用于判断锁是否属于当前线程并释放
local lockKey = KEYS[1] -- 锁的 key
local lockValue = ARGV[1] -- 当前线程的唯一标识-- 判断锁是否属于当前线程
if redis.call("GET", lockKey) == lockValue then-- 如果是,释放锁return redis.call("DEL", lockKey)
else-- 如果不是,返回 0,不执行删除return 0
end
-- 比较线程标示与锁中的标示是否一致
if(redis.call('get',KEYS[1])==ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
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 void unlock() {/**
// 判断锁是不是自己的String threadId = ID_PREFIX + Thread.currentThread().getId();if ( stringRedisTemplate.opsForValue().get(KEY_PREFIX+name).equals(threadId)) {// 是自己的锁stringRedisTemplate.delete(KEY_PREFIX + name);}*/// 调用lua脚本stringRedisTemplate.execute(UNLOCK_SCRIPT,Collections.singletonList(KEY_PREFIX + name),ID_PREFIX + Thread.currentThread().getId());}
DefaultRedisScript
是 Spring Data Redis 提供的类,用于封装 Lua 脚本。
setLocation
指定 Lua 脚本文件的位置,这里脚本文件名为 unlock.lua
,存放在类路径(classpath)下。
setResultType
设置 Lua 脚本的返回值类型,此处为 Long
,表示 Lua 脚本会返回一个整数(如 0
或 1
)。
unlock.lua
脚本文件的作用应该是:
- 判断当前线程是否持有锁。
- 如果是,则释放锁。
- 如果不是,则不进行任何操作。
通过 Lua 脚本,将锁的判断和释放操作合并为一个原子性操作,能有效解决线程 A 错误释放线程 B 的锁的问题。Redis 的单线程特性保证了脚本的执行顺序和一致性,是解决此类问题的最佳选择之一。
Redisson
详细的Redisson的介绍请看
分布式锁Redisson详解,Redisson如何解决不可重入,不可重试,超时释放,主从一致问题的分析解决(包括源码简单分析)-CSDN博客
异步秒杀
我们还可以使用异步秒杀来优化秒杀。详细在以下博客
Redis 优化秒杀(异步秒杀)-CSDN博客