分布式锁分布式锁满足分布式系统或集群模式下多进程可见并且互斥的锁。分布式锁的实现分布式锁的核心是实现多进程之间互斥而满足这一点的方式有很多常见的有三种MySQLRedisZookeeper互斥利用mysql本身的互斥锁机制利用setnx这样的互斥命令利用节点的唯一性和有序性实现互斥高可用好好好高性能一般好一般安全性断开连接自动释放锁利用锁超时时间到期释放临时节点断开连接自动释放基于Redis实现分布式锁实现分布式锁时需要实现的两个基本方法获取锁# 添加锁, NX是互斥、EX是设置超时时间 SET lock thread1 NX EX 10互斥确保只能有一个线程获取锁非阻塞尝试一次成功返回true失败返回false释放锁# 释放锁, 删除即可 DEL key手动释放超时释放获取锁时添加一个超时时间Redis的Lua脚本Redis提供了Lua脚本功能在一个脚本中编写多条Redis命令确保多条命令执行时的原子性。Lua是一种编程语言它的基本语法可以参考网站https://www.runoob.com/lua/lua-tutorial.html这里重点介绍Redis提供的调用函数语法如下# 执行redis命令 redis.call(命令名称, key, 其它参数, ...)例如我们要执行set name jack则脚本是这样# 执行 set name jack redis.call(set, name, jack)例如我们要先执行set name Rose再执行get name则脚本如下# 先执行 set name jack redis.call(set, name, jack) # 再执行 get name local name redis.call(get, name) # 返回 return name调用脚本写好脚本以后需要用Redis命令来调用脚本调用脚本的常见命令如下127.0.0.1:6379 help scripting EVAL script numkeys key [key ...] arg [arg ...] summary: Execute a Lua script server side since: 2.6.0例如我们要执行redis.call(set, name, jack)这个脚本语法如下# 调用脚本 EVAL return redis.call(set, name, jack) 0 # ↑脚本内容 ↑脚本需要的key类型的参数个数如果脚本中的key、value不想写死可以作为参数传递。key类型参数会放入KEYS数组其它参数会放入ARGV数组在脚本中可以从KEYS和ARGV数组获取这些参数# 调用脚本 EVAL return redis.call(set, KEYS[1], ARGV[1]) 1 name Rose # ↑脚本内容 ↑参数个数 ↑KEYS[1] ↑ARGV[1]lua语言数组角标是从1开始例子释放锁的业务流程是这样的获取锁中的线程标示判断是否与指定的标示当前线程标示一致如果一致则释放锁删除如果不一致则什么都不做调用以及使用脚本this.stringRedisTemplate stringRedisTemplate; } // 分布式锁key的前缀 private static final String KEY_PREFIX lock:; // 分布式锁持有者标识前缀UUID前缀保证不同实例的线程ID全局唯一 private static final String ID_PREFIX UUID.randomUUID().toString(true) -; // 定义解锁用的Lua脚本对象返回值类型为Long private static final DefaultRedisScriptLong UNLOCK_SCRIPT; // 静态代码块在类加载时初始化解锁Lua脚本 static { UNLOCK_SCRIPT new DefaultRedisScript(); // 指定Lua脚本文件路径从classpath下加载unlock.lua文件 UNLOCK_SCRIPT.setLocation(new ClassPathResource(unlock.lua)); // 指定Lua脚本执行后返回值的类型 UNLOCK_SCRIPT.setResultType(Long.class); } Override // 尝试获取分布式锁参数为锁的超时时间秒 public boolean tryLock(long timeoutSec) { // 获取当前线程的唯一标识UUID前缀 线程ID String threadId ID_PREFIX Thread.currentThread().getId(); // 尝试获取锁SET key value NX EX timeoutSec Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(KEY_PREFIX name, threadId, timeoutSec, TimeUnit.SECONDS); // 返回获取锁是否成功防止NPE用Boolean.TRUE.equals判断 return Boolean.TRUE.equals(success); } Override // 释放分布式锁方法 public void unlock() { // 调用RedisTemplate执行Lua脚本解锁 stringRedisTemplate.execute( UNLOCK_SCRIPT, // 传入Lua脚本中用到的KEYS参数锁的key Collections.singletonList(KEY_PREFIX name), // 传入Lua脚本中用到的ARGS参数当前线程的唯一标识 ID_PREFIX Thread.currentThread().getId() ); }静态变量初始化KEY_PREFIX给所有锁key加统一前缀避免Redis中key冲突。ID_PREFIX用UUID作为前缀避免不同JVM实例的线程ID重复保证锁的持有者标识全局唯一。UNLOCK_SCRIPT预加载解锁用的Lua脚本通过DefaultRedisScript配置脚本路径和返回值类型后续直接复用。tryLock方法核心逻辑是调用setIfAbsentRedis的SET ... NX EX命令实现原子性的“锁不存在则创建设置过期时间”防止死锁。threadId作为锁的值记录锁的持有者为后续“仅持有者可释放锁”做准备。unlock方法调用stringRedisTemplate.execute执行Lua脚本传入锁的key和当前线程标识。Lua脚本会原子性地校验“锁的持有者是否为当前线程”校验通过才删除锁避免误删其他线程的锁。基于Redis的分布式锁实现思路利用set nx ex获取锁并设置过期时间保存线程标示释放锁时先判断线程标示是否与自己一致一致则删除锁特性利用set nx满足互斥性利用set ex保证故障时锁依然能释放避免死锁提高安全性利用Redis集群保证高可用和高并发特性Redisson基于setnx实现的分布式锁存在下面的问题Redisson是一个在Redis的基础上实现的Java驻内存数据网格In-Memory Data Grid。它不仅提供了一系列的分布式的Java常用对象还提供了许多分布式服务其中就包含了各种分布式锁的实现。分布式锁Lock和同步器Synchronizer8.1. 可重入锁Reentrant Lock8.2. 公平锁Fair Lock8.3. 联锁MultiLock8.4. 红锁RedLock8.5. 读写锁ReadWriteLock8.6. 信号量Semaphore8.7. 可过期性信号量PermitExpirableSemaphore8.8. 闭锁CountDownLatch官网地址 https://redisson.orgGitHub地址 https://github.com/redisson/redisson使用Redisson的分布式锁Resource private RedissonClient redissonClient; Test void testRedisson() throws InterruptedException { // 获取锁可重入指定锁的名称 RLock lock redissonClient.getLock(anyLock); // 尝试获取锁参数分别是获取锁的最大等待时间期间会重试锁自动释放时间时间单位 boolean isLock lock.tryLock(1, 10, TimeUnit.SECONDS); // 判断释放获取成功 if(isLock){ try { System.out.println(执行业务); }finally { // 释放锁 lock.unlock(); } } }Redisson可重入锁原理与实现通过lua脚本实现获取锁释放锁的lua脚本Redisson的锁重试和WatchDog机制Redisson分布式锁原理可重入利用hash结构记录线程id和重入次数可重试利用信号量和PubSub功能实现等待、唤醒获取锁失败的重试机制超时续约利用watchDog每隔一段时间releaseTime / 3重置超时时间Redisson的multiLock原理不可重入Redis分布式锁原理利用setnx的互斥性利用ex避免死锁释放锁时判断线程标示缺陷不可重入、无法重试、锁超时失效可重入的Redis分布式锁原理利用hash结构记录线程标示和重入次数利用watchDog延续锁时间利用信号量控制锁重试等待缺陷redis宕机引起锁失效问题Redisson的multiLock原理多个独立的Redis节点必须在所有节点都获取重入锁才算获取锁成功缺陷运维成本高、实现复杂Redis优化秒杀改进秒杀业务提高并发性能需求① 新增秒杀优惠券的同时将优惠券信息保存到Redis中② 基于Lua脚本判断秒杀库存、一人一单决定用户是否抢购成功③ 如果抢购成功将优惠券id和用户id封装后存入阻塞队列④ 开启线程任务不断从阻塞队列中获取信息实现异步下单功能1. 新增秒杀优惠券并同步到Redisreturn Result.ok(vouchers); } Override Transactional public void addSeckillVoucher(Voucher voucher) { // 保存优惠券到数据库 save(voucher); // 保存秒杀信息到数据库 SeckillVoucher seckillVoucher new SeckillVoucher(); seckillVoucher.setVoucherId(voucher.getId()); seckillVoucher.setStock(voucher.getStock()); seckillVoucher.setBeginTime(voucher.getBeginTime()); seckillVoucher.setEndTime(voucher.getEndTime()); seckillVoucherService.save(seckillVoucher); // 保存秒杀库存到Redis中key为seckill:stock:优惠券ID值为库存数量 stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY voucher.getId(), voucher.getStock().toString()); }作用新增秒杀优惠券时同时将库存信息同步到Redis为后续秒杀请求提供快速库存校验能力避免直接访问数据库。2. 秒杀Lua脚本seckill.lua-- 1.参数列表 -- 1.1.优惠券id local voucherId ARGV[1] -- 1.2.用户id local userId ARGV[2] -- 2.数据key -- 2.1.库存key local stockKey seckill:stock: .. voucherId -- 2.2.订单key记录已下单用户 local orderKey seckill:order: .. voucherId -- 3.脚本业务 -- 3.1.判断库存是否充足 get stockKey if(tonumber(redis.call(get, stockKey)) 0) then -- 3.2.库存不足返回1 return 1 end -- 3.2.判断用户是否下单 SISMEMBER orderKey userId if(redis.call(sismember, orderKey, userId) 1) then -- 3.3.存在说明是重复下单返回2 return 2 end -- 3.4.扣库存 incrby stockKey -1 redis.call(incrby, stockKey, -1) -- 3.5.下单保存用户sadd orderKey userId redis.call(sadd, orderKey, userId) -- 3.6.返回0表示成功 return 0作用通过Lua脚本实现原子性操作同时完成库存校验、一人一单校验、扣减库存和记录用户下单信息避免高并发下的超卖和重复下单问题。3. 秒杀业务处理Java代码Override public Result seckillVoucher(Long voucherId) { // 获取当前登录用户ID Long userId UserHolder.getUser().getId(); // 1.执行lua脚本校验库存和用户下单资格 Long result stringRedisTemplate.execute( SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString() ); // 2.判断脚本执行结果 int r result.intValue(); if(r ! 0){ // 2.1.结果不为0代表没有购买资格1库存不足2不能重复下单 return Result.fail(r 1 ? 库存不足 : 不能重复下单); } // 2.2.结果为0用户有购买资格生成订单ID并准备异步下单 long orderId redisIdWorker.nextId(order); // TODO 保存到阻塞队列由异步线程处理数据库下单逻辑 // 3.返回订单ID给前端 return Result.ok(orderId); }作用接收用户秒杀请求调用Lua脚本完成资格校验通过后生成订单ID并将下单信息存入阻塞队列实现异步下单提升接口响应速度和并发能力。整体流程说明数据预热新增秒杀优惠券时将库存信息同步到Redis。前置校验用户发起秒杀请求时通过Lua脚本原子性完成库存和用户资格校验。异步处理校验通过后将下单信息存入阻塞队列由后台线程异步完成数据库订单创建避免高并发下数据库压力过大。4.阻塞队列异步下单// 1. 定义阻塞队列用于缓存秒杀订单任务实现异步削峰 private BlockingQueueVoucherOrder orderTasks new ArrayBlockingQueue(1024 * 1024); // 2. 定义单线程池专门异步消费阻塞队列中的订单任务 private static final ExecutorService SECKILL_ORDER_EXECUTOR Executors.newSingleThreadExecutor(); // 3. 项目启动后立即启动异步线程开始监听队列 PostConstruct private void init() { SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler()); } // 4. 异步任务处理器不断从阻塞队列取出订单并执行下单 private class VoucherOrderHandler implements Runnable { Override public void run() { while (true) { try { // 从队列取出订单没有订单时会阻塞等待 VoucherOrder voucherOrder orderTasks.take(); // 执行真正的数据库下单逻辑 handleVoucherOrder(voucherOrder); } catch (Exception e) { log.error(处理订单异常, e); } } } } // 5. 秒杀接口只做两件事校验资格 → 把订单放入阻塞队列异步 Override public Result seckillVoucher(Long voucherId) { // 1. 获取用户ID Long userId UserHolder.getUser().getId(); // 2. 执行Redis校验库存、重复下单 Long result stringRedisTemplate.execute( SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString() ); int r result.intValue(); if (r ! 0) { return Result.fail(r 1 ? 库存不足 : 不能重复下单); } // 3. 封装订单对象 VoucherOrder voucherOrder new VoucherOrder(); long orderId redisIdWorker.nextId(order); voucherOrder.setId(orderId); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); // 核心异步逻辑 // 订单放入阻塞队列立即返回不等待数据库执行 orderTasks.add(voucherOrder); // // 4. 直接返回订单ID实现异步下单 return Result.ok(orderId); }阻塞队列用来临时存放秒杀订单高并发下不会直接冲击数据库起到削峰、限流作用。异步下单用户请求进来 → 校验资格通过 →订单丢进队列→立刻返回结果后台线程慢慢从队列取出订单执行数据库下单接口响应极快不会被数据库IO拖慢单线程消费保证订单顺序执行避免并发超卖、重复下单问题。想看其他技术文章请访问 主页fzktwnd.cn