炫酷响应式网站设计,抖音广告投放收费标准,做网站开发的,wordpress下载站批量#想cry 好想cry
目录
1 全局唯一id
1.1 自增ID存在的问题
1.2 分布式ID的需求
1.3 分布式ID的实现方式
1.4 自定义分布式ID生成器#xff08;示例#xff09;
1.5 总结
2 优惠券秒杀接口实现
3 单体系统下一人多单超卖问题及解决方案
3.1 问题背景
3.2 超卖问题的…#想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 {Resourceprivate 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. 拼接并返回IDreturn timestamp COUNT_BITS | count;}
}
1.5 总结 自增ID的局限性 规律性明显安全性差。 扩展性受限不适合高并发和分库分表场景。 分布式ID的优势 全局唯一、高性能、高可用。 支持复杂业务场景如高并发、分库分表。 实现建议 优先选择Snowflake算法或自定义实现。 结合时间戳和序列号确保ID的唯一性和递增性。 测试高并发场景下的性能和稳定性。
2 优惠券秒杀接口实现 /*** 抢购秒杀券** param voucherId* return*/TransactionalOverridepublic 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 LambdaUpdateWrapperSeckillVoucher().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(创建秒杀券订单失败);}// 返回订单idreturn 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法
CASCompare and Swap是一种并发编程中常用的原子操作用于解决多线程环境下的数据竞争问题。它是乐观锁算法的一种实现方式。
CAS操作包含三个参数内存地址V、旧的预期值A和新的值B。CAS的执行过程如下
比较Compare将内存地址V中的值与预期值A进行比较。 判断Judgment如果相等则说明当前值和预期值相等表示没有发生其他线程的修改。 交换Swap使用新的值B来更新内存地址V中的值。 CAS操作是一个原子操作意味着在执行过程中不会被其他线程中断保证了线程安全性。如果CAS操作失败即当前值与预期值不相等通常会进行重试直到CAS操作成功为止。
业务核心逻辑 更新库存时检查库存是否大于0。 如果库存大于0则扣减库存否则操作失败。
代码示例
boolean flag seckillVoucherService.update(new LambdaUpdateWrapperSeckillVoucher().eq(SeckillVoucher::getVoucherId, voucherId).gt(SeckillVoucher::getStock, 0) // 检查库存是否大于0.setSql(stock stock - 1)); // 扣减库存
优化 初始实现库存不一致时直接终止操作导致异常率高。 优化后只要库存大于0就允许扣减降低异常率。
3.6 CAS的优缺点
优点 无锁操作性能高。 适合高并发场景。
缺点
1ABA问题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*/TransactionalOverridepublic 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*/Transactionalpublic Result createVoucherOrder(Long userId, Long voucherId) {
// synchronized (userId.toString().intern()) {// 1、判断当前用户是否是第一单int count this.count(new LambdaQueryWrapperVoucherOrder().eq(VoucherOrder::getUserId, userId));if (count 1) {// 当前用户不是第一单return Result.fail(用户已购买);}// 2、用户是第一单可以下单秒杀券库存数量减一boolean flag seckillVoucherService.update(new LambdaUpdateWrapperSeckillVoucher().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、返回订单idreturn Result.ok(orderId);
// }}4.3.3 实现细节重要
1锁的范围尽量小。synchronized尽量锁代码块而不是方法锁的范围越大性能越低
2锁的对象一定要是一个不变的值。我们不能直接锁 Long 类型的 userId每请求一次都会创建一个新的 userId 对象synchronized 要锁不变的值所以我们要将 Long 类型的 userId 通过 toString()方法转成 String 类型的 userIdtoString()方法底层可以点击去看源码是直接 new 一个新的String对象显然还是在变所以我们要使用 intern() 方法从常量池中寻找与当前 字符串值一致的字符串对象这就能够保障一个用户 发送多次请求每次请求的 userId 都是不变的从而能够完成锁的效果并行变串行
3我们要锁住整个事务而不是锁住事务内部的代码。如果我们锁住事务内部的代码会导致其它线程能够进入事务当我们事务还未提交锁一旦释放仍然会存在超卖问题
4Spring的Transactional注解要想事务生效必须使用动态代理。Service中一个方法中调用另一个方法另一个方法使用了事务此时会导致Transactional失效所以我们需要创建一个代理对象使用代理对象来调用方法。
4.3.4 让代理对象生效的步骤
①引入AOP依赖动态代理是AOP的常见实现之一
dependencygroupIdorg.aspectj/groupIdartifactIdaspectjweaver/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*/Overridepublic boolean tryLock(long timeoutSec) {String id Thread.currentThread().getId() ;// SET lock:name id EX timeoutSec NXBoolean result stringRedisTemplate.opsForValue().setIfAbsent(lock: name, id, timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(result);}/*** 释放锁*/Overridepublic 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...确保发生异常时锁能够释放注意这给地方不要使用catchA事务方法内部调用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*/Overridepublic boolean tryLock(long timeoutSec) {String threadId ID_PREFIX Thread.currentThread().getId() ;// SET lock:name id EX timeoutSec NXBoolean result stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX name, threadId, timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(result);}/*** 释放锁*/Overridepublic 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 DefaultRedisScriptLong 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引入依赖
dependencygroupIdorg.redisson/groupIdartifactIdredisson/artifactIdversion3.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;Beanpublic 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 节点包括节点的部署、监控、故障处理等。在实现方面代码逻辑会变得更加复杂需要考虑多个节点的状态和交互。而且由于要在多个节点上获取锁会增加锁获取的时间开销降低系统的性能。