03_基于redis实现锁机制
基于redis实现锁机制
环境准备:
引入依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
配置redis连接信息:
spring: redis: host: 127.0.0.1 port: 6379
service层实现从redis中扣减库存:
@Autowired private StringRedisTemplate stringRedisTemplate; private final static String STOCK_KEY = "stock"; public void deduct() { String stock = stringRedisTemplate.opsForValue().get(STOCK_KEY); if (StringUtils.hasLength(stock)) { int value = Integer.parseInt(stock); if (value > 0) { stringRedisTemplate.opsForValue().set(STOCK_KEY, String.valueOf(value - 1)); } } }
初始化库存数量:
set stock 5000
使用压力测试工具进行测试:
出现了并发问题库存扣减出现异常。
解决方案:
1、使用jvm提供的本地锁机制与mysql使用jvm锁机制类似,同样存在问题,参考上篇文章。
2、使用redis提供的事务机制,redis乐观锁机制:
watch stock multi set stock 5000 exec
在 Redis 中使用 watch 命令可以决定事务是执行还是回滚。一般而言,可以在 multi 命令之前使用 watch 命令监控某些键值对,然后使用 multi 命令开启事务,执行各类对数据结构进行操作的命令,这个时候这些命令就会进入队列。
当 Redis 使用 exec 命令执行事务的时候,它首先会去比对被 watch 命令所监控的键值对,如果没有发生变化,那么它会执行事务队列中的命令,提交事务;如果发生变化,那么它不会执行任何事务中的命令,而去事务回滚。无论事务是否回滚,Redis 都会去取消执行事务前的 watch 命令。
Redis 参考了多线程中使用的 CAS(比较与交换,Compare And Swap)去执行的。在数据高并发环境的操作中,我们把这样的一个机制称为乐观锁。
代码中实现redis事务机制:
public void deduct() { stringRedisTemplate.execute(new SessionCallback() { @Override public Object execute(RedisOperations operations) throws DataAccessException { operations.watch(STOCK_KEY); // 1. 查询库存信息 Object stock = operations.opsForValue().get(STOCK_KEY); // 2. 判断库存是否充足 int st = 0; if (stock != null && (st = Integer.parseInt(stock.toString())) > 0) { // 3. 扣减库存 operations.multi(); operations.opsForValue().set(STOCK_KEY, String.valueOf(--st)); List exec = operations.exec(); if (exec.size() == 0) { try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } deduct(); } return exec; } return null; } }); }
使用jmeter进行压力测试:
测试结果:库存扣减正常,但是性能下降明显,吞吐量降低,且连接数有限,不推荐使用。
3、使用redis分布式锁:
分布式锁:跨进程、跨服务、跨集群
使用场景:超卖现象、缓存击穿
特征:独占排他使用(使用setnx指令实现)
代码的方式实现redis分布式锁:
/** 使用redis的setnx命令实现分布式锁 **/ public void deduct() { // 加锁setnx Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_KEY, LOCK_YES_KEY); // 获取锁失败重试:递归调用 if (lock != null && !lock) { try { Thread.sleep(50); this.deduct(); } catch (InterruptedException e) { e.printStackTrace(); } } else { try { // 1. 查询库存信息 String stock = stringRedisTemplate.opsForValue().get(STOCK_KEY); // 2. 判断库存是否充足 if (StringUtils.hasLength(stock)) { int st = Integer.parseInt(stock); if (st > 0) { // 3.扣减库存 stringRedisTemplate.opsForValue().set(STOCK_KEY, String.valueOf(st - 1)); } } } finally { // 解锁 this.stringRedisTemplate.delete(LOCK_KEY); } } }
注意点:库存的扣减情况一定要在else中进行处理,因为递归调用的情况下,调用栈递归调用可能导致一次调用库存扣减出现了多次调用执行。
使用jmeter进行压力测试:
测试结果库存扣减情况正常,吞吐量一般。
获取锁失败使用递归调用存在一定的栈内存溢出风险,故进行优化:
/** 使用redis的setnx命令实现分布式锁 **/ public void deduct() {// 获取锁失败重试:递归调用 while (!Objects.requireNonNull(stringRedisTemplate.opsForValue().setIfAbsent(LOCK_KEY, LOCK_YES_KEY))) { try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } } try { // 1. 查询库存信息 String stock = stringRedisTemplate.opsForValue().get(STOCK_KEY); // 2. 判断库存是否充足 if (StringUtils.hasLength(stock)) { int st = Integer.parseInt(stock); if (st > 0) { // 3.扣减库存 stringRedisTemplate.opsForValue().set(STOCK_KEY, String.valueOf(st - 1)); } } } finally { // 解锁 this.stringRedisTemplate.delete(LOCK_KEY); } }
测试结果:库存扣减正常。
设置过期时间两种方式:
-
通过expire设置过期时间(缺乏原子性:如果在setnx和expire之间出现异常,锁也无法释放)
-
使用set指令设置过期时间:set key value ex 3 nx(既达到setnx的效果,又设置了过期时间)
stringRedisTemplate.opsForValue().setIfAbsent(LOCK_KEY, LOCK_YES_KEY,3, TimeUnit.SECONDS)
问题:可能会释放其他服务器的锁。
场景:如果业务逻辑的执行时间是7s。执行流程如下
-
-
index2获取到锁,执行业务逻辑,3秒后锁被自动释放。
-
index3获取到锁,执行业务逻辑
-
index1业务逻辑执行完成,开始调用del释放锁,这时释放的是index3的锁,导致index3的业务只执行1s就被别人释放。
最终等于没锁的情况。
解决:setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁。
防止误删代码实现方式:
public void deduct() { String LOCK_VALUE = UUID.randomUUID().toString(); // 获取锁失败重试:递归调用 while (!Objects.requireNonNull(stringRedisTemplate.opsForValue().setIfAbsent(LOCK_KEY, LOCK_VALUE, 3, TimeUnit.SECONDS))) { try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } } try { // 1. 查询库存信息 String stock = stringRedisTemplate.opsForValue().get(STOCK_KEY); // 2. 判断库存是否充足 if (StringUtils.isNotBlank(stock)) { int st = Integer.parseInt(stock); if (st > 0) { // 3.扣减库存 stringRedisTemplate.opsForValue().set(STOCK_KEY, String.valueOf(st - 1)); } } } finally { // 解锁 if (StringUtils.equals(LOCK_VALUE, stringRedisTemplate.opsForValue().get(LOCK_KEY))) { this.stringRedisTemplate.delete(LOCK_KEY); } } }
问题:删除操作和校验操作缺乏原子性。
- index1执行删除时,查询到的lock值确实和uuid相等
- index1执行删除前,lock刚好过期时间已到,被redis自动释放
- index2获取了lock
- index1执行删除,此时会把index2的lock删除
解决方案:没有一个命令可以同时做到判断 + 删除,所有只能通过其他方式实现(LUA脚本)
现实问题
redis采用单线程架构,可以保证单个命令的原子性,但是无法保证一组命令在高并发场景下的原子性。例如:
在串行场景下:A和B的值肯定都是3
在并发场景下:A和B的值可能在0-6之间。
极限情况下1:
则A的结果是0,B的结果是3
极限情况下2:
则A和B的结果都是6
如果redis客户端通过lua脚本把3个命令一次性发送给redis服务器,那么这三个指令就不会被其他客户端指令打断。Redis 也保证脚本会以原子性(atomic)的方式执行: 当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。 这和使用 MULTI/ EXEC 包围的事务很类似。
lua介绍
Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。
设计目的
其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。
Lua 特性
-
轻量级:它用标准C语言编写并以源代码形式开放,编译后仅仅一百余K,可以很方便的嵌入别的程序里。
-
可扩展:Lua提供了非常易于使用的扩展接口和机制:由宿主语言(通常是C或C++)提供这些功能,Lua可以使用它们,就像是本来就内置的功能一样。
-
其它特性:
-
支持面向过程(procedure-oriented)编程和函数式编程(functional programming);
-
自动内存管理;只提供了一种通用类型的表(table),用它可以实现数组,哈希表,集合,对象;
-
语言内置模式匹配;闭包(closure);函数也可以看做一个值;提供多线程(协同进程,并非操作系统所支持的线程)支持;
-
-
redis执行lua脚本 - EVAL指令
在redis中需要通过eval命令执行lua脚本。
格式:
EVAL script numkeys key [key ...] arg [arg ...]
script:lua脚本字符串,这段Lua脚本不需要(也不应该)定义函数。
numkeys:lua脚本中KEYS数组的大小
key [key ...]:KEYS数组中的元素
arg [arg ...]:ARGV数组中的元素
案例1:基本案例
EVAL "return 10" 0
输出:(integer) 10
案例2:动态传参
EVAL "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 5 10 20 30 40 50 60 70 80 90 # 输出:10 20 60 70 EVAL "if KEYS[1] > ARGV[1] then return 1 else return 0 end" 1 10 20 # 输出:0 EVAL "if KEYS[1] > ARGV[1] then return 1 else return 0 end" 1 20 10 # 输出:1
set aaa 10 -- 设置一个aaa值为10 EVAL "return redis.call('get', 'aaa')" 0 # 通过return把call方法返回给redis客户端,打印:"10"
EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 bbb 20
案例5:pcall函数的使用(了解)
-- 当call() 在执行命令的过程中发生错误时,脚本会停止执行,并返回一个脚本错误,输出错误信息 EVAL "return redis.call('sets', KEYS[1], ARGV[1]), redis.call('set', KEYS[2], ARGV[2])" 2 bbb ccc 20 30 -- pcall函数不影响后续指令的执行 EVAL "return redis.pcall('sets', KEYS[1], ARGV[1]), redis.pcall('set', KEYS[2], ARGV[2])" 2 bbb ccc 20 30
注意:set方法写成了sets,肯定会报错。
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
代码实现删除操作的原子性:
public void deduct() { String LOCK_VALUE = UUID.randomUUID().toString(); // 获取锁失败重试:递归调用 while (!Objects.requireNonNull(stringRedisTemplate.opsForValue().setIfAbsent(LOCK_KEY, LOCK_VALUE, 3, TimeUnit.SECONDS))) { try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } } try { // 1. 查询库存信息 String stock = stringRedisTemplate.opsForValue().get(STOCK_KEY); // 2. 判断库存是否充足 if (StringUtils.isNotBlank(stock)) { int st = Integer.parseInt(stock); if (st > 0) { // 3.扣减库存 stringRedisTemplate.opsForValue().set(STOCK_KEY, String.valueOf(st - 1)); } } } finally { // 解锁 // 先判断是否自己的锁,再解锁 String script = "if redis.call('get', KEYS[1]) == ARGV[1] " + "then return redis.call('del', KEYS[1]) " + "else return 0 " + "end"; stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Collections.singletonList(LOCK_KEY), LOCK_VALUE); // if (StringUtils.equals(LOCK_VALUE, stringRedisTemplate.opsForValue().get(LOCK_KEY))) { // this.stringRedisTemplate.delete(LOCK_KEY); // } } }
使用jmeter进行压力测试,库存扣减情况正常,防误删操作的原子性完成:
public synchronized void a() { b(); } public synchronized void b() { // pass }
假设 X 线程在 a 方法获取锁之后,继续执行 b 方法,如果此时不可重入,线程就必须等待锁释放,再次争抢锁。
锁明明是被 X 线程拥有,却还需要等待自己释放锁,然后再去抢锁,这看起来就很奇怪,我释放我自己
可重入性就可以解决这个尴尬的问题,当线程拥有锁之后,往后再遇到加锁方法,直接将加锁次数加 1,然后再执行方法逻辑。退出加锁方法之后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释放。
可以看到可重入锁最大特性就是计数,计算加锁的次数。所以当可重入锁需要在分布式环境实现时,我们也就需要统计加锁次数。
解决方案:redis + Hash
ReentrantLock可重入锁加锁流程:
ReentrantLock.lock() --> NonfairSync.lock() --> AQS.acquire(1) --> NonfairSync.tryAcquire(1) --> Sync.nonfairTryAcquire(1)
1.CAS获取锁,如果没有线程占用锁(state==0),加锁成功并记录当前线程是有锁线程(两次)
2.如果state的值不为0,说明锁已经被占用。则判断当前线程是否是有锁线程,如果是则重入(state + 1)
3.否则加锁失败,入队等待
可重入锁解锁流程:
ReentrantLock.unlock() --> AQS.release(1) --> Sync.tryRelease(1)
1.判断当前线程是否是有锁线程,不是则抛出异常
2.对state的值减1之后,判断state的值是否为0,为0则解锁成功,返回true
3.如果减1后的值不为0,则返回false
参照ReentrantLock中的非公平可重入锁实现分布式可重入锁:
hash + lua脚本
加锁:
1.判断锁是否存在(exists),不存在则直接获取锁 hset key field value
2.如果锁存在则判断是否自己的锁(hexists),如果是自己的锁则重入:hincrby key field increment
3.否则重试:递归 循环
if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 then redis.call('hincrby', KEYS[1], ARGV[1], 1) redis.call('expire', KEYS[1], ARGV[2]) return 1 else return 0 end key: lock arg: uuid 30
放到一行:
if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 then redis.call('hincrby', KEYS[1], ARGV[1], 1) redis.call('expire', KEYS[1], ARGV[2]) return 1 else return 0 end
解锁:
1.判断自己的锁是否存在(hexists),不存在则返回nil
2.如果自己的锁存在,则减1(hincrby -1),判断减1后的值是否为0,为0则释放锁(del)并返回1
3.不为0,返回0
if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then return nil elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 then return redis.call('del', KEYS[1]) else return 0 end key: lock arg: uuid
放到一行:
if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then return nil elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 then return redis.call('del', KEYS[1]) else return 0 end
可重入性代码实现:
封装到工具类中实现Lock接口:
public class DistributedRedisLock implements Lock { private final StringRedisTemplate redisTemplate; private final String lockName; private final String uuid; private long expire = 30; public DistributedRedisLock(StringRedisTemplate redisTemplate, String lockName, String uuid) { this.redisTemplate = redisTemplate; this.lockName = lockName; this.uuid = uuid; } @Override public void lock() { this.tryLock(); } @Override public void lockInterruptibly() throws InterruptedException { } @Override public boolean tryLock() { try { return this.tryLock(-1L, TimeUnit.SECONDS); } catch (InterruptedException e) { e.printStackTrace(); } return false; } @Override public boolean tryLock(long time, TimeUnit unit) throws InterruptedException { if (time != -1) { this.expire = unit.toSeconds(time); } String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 " + "then " + " redis.call('hincrby', KEYS[1], ARGV[1], 1) " + " redis.call('expire', KEYS[1], ARGV[2]) " + " return 1 " + "else " + " return 0 " + "end"; while (!Objects.requireNonNull(this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Collections.singletonList(lockName), getId(), String.valueOf(expire)))) { Thread.sleep(50); } return true; } @Override public void unlock() { String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 " + "then " + " return nil " + "elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 " + "then " + " return redis.call('del', KEYS[1]) " + "else " + " return 0 " + "end"; Long flag = this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockName), getId()); if (flag == null) { throw new IllegalMonitorStateException("this lock doesn't belong to you!"); } } @Override public Condition newCondition() { return null; } /** * 给线程拼接唯一标识 */ String getId() { return uuid + ":" + Thread.currentThread().getId(); } }
为了方便使用分布式锁封装一个工厂的工具类:
@Component public class DistributedLockClient { @Autowired private StringRedisTemplate redisTemplate; private final String uuid; public DistributedLockClient() { this.uuid = UUID.randomUUID().toString(); } public DistributedRedisLock getRedisLock(String lockName) { return new DistributedRedisLock(redisTemplate, lockName, uuid); } }
可重入锁Service层实现:
public void deduct() { DistributedRedisLock redisLock = distributedLockClient.getRedisLock(LOCK_KEY); if (redisLock == null) { return; } redisLock.lock(); try { // 1. 查询库存信息 String stock = stringRedisTemplate.opsForValue().get(STOCK_KEY); // 2. 判断库存是否充足 if (StringUtils.isNotBlank(stock)) { int st = Integer.parseInt(stock); if (st > 0) { // 3.扣减库存 stringRedisTemplate.opsForValue().set(STOCK_KEY, String.valueOf(st - 1)); } } // 测试可重入性 // test(); } catch (Exception ignore) { } finally { redisLock.unlock(); } } public void test() { DistributedRedisLock redisLock = distributedLockClient.getRedisLock(LOCK_KEY); if (redisLock == null) { return; } redisLock.lock(); System.out.println("测试可重入性"); redisLock.unlock(); }
使用jemeter测试,库存扣减情况正常:
测试可重入性,注意传入的uuid每台机器要随机生成一个uuid+当前线程ID作为map的key:
锁的释放没有问题。
if(redis.call('hexists', KEYS[1], ARGV[1]) == 1) then redis.call('expire', KEYS[1], ARGV[2]); return 1; else return 0; end
放置在一行:
if(redis.call('hexists', KEYS[1], ARGV[1]) == 1) then redis.call('expire', KEYS[1], ARGV[2]); return 1; else return 0; end
需要注意的是定时任务与当前执行加锁的线程并非同一个线程,故当getId()时,自动续期的key与加锁的key不一致。
故需要改造构造方法,固定uuid:
public DistributedRedisLock(StringRedisTemplate redisTemplate, String lockName, String uuid) { this.redisTemplate = redisTemplate; this.lockName = lockName; this.uuid = uuid + ":" + Thread.currentThread().getId(); }
在加分布式锁时,开启定时任务自动续期:
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException { if (time != -1) { this.expire = unit.toSeconds(time); } String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 " + "then " + " redis.call('hincrby', KEYS[1], ARGV[1], 1) " + " redis.call('expire', KEYS[1], ARGV[2]) " + " return 1 " + "else " + " return 0 " + "end"; while (!Objects.requireNonNull(this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Collections.singletonList(lockName), uuid, String.valueOf(expire)))) { Thread.sleep(50); } renewExpire(); return true; }
自动续期代码实现:
private void renewExpire() { String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 1 " + "then " + " return redis.call('expire', KEYS[1], ARGV[2]) " + "else " + " return 0 " + "end"; // 定时任务仅仅设置延迟时间,递归调用让其自动续期,续期失败后自动结束 new Timer().schedule(new TimerTask() { @Override public void run() { if (Objects.requireNonNull(redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Collections.singletonList(lockName), uuid, String.valueOf(expire)))) { renewExpire(); } } }, this.expire * 1000 / 3); }
测试自动续期,在业务代码中增加线程休眠时间:
Thread.sleep(100*1000);
过期时间不断的自动重置:
使用jmeter进行压力测试库存扣减情况正常: