为什么需要分布式锁
线程安全问题:多个线程同时共享一个全局变量,或者静态变量, 进行写的操作时, 可能会发生数据的冲突问题 ,也就是线程安全问题。
在单机服务架构中,我们可以使用同步机制, 使得在同一时间只能有一个线程修改共享数据,例如对代码块、方法等加锁,实现多线程触发写的操作时,只有一个线程能够进入指定区域实现写操作,但在分布式架构下,这样的处理方式是不能实现跨越JVM的锁机制,这时候,就需要一个方案来解决分布式架构下的锁问题。
要解决这个问题,就会出现与单机服务架构相似的加锁问题,在哪加锁,如何确定锁对象,以及解决分布式下出现的各种新的问题,例如说解决分布式下的非原子性操作等。
单机服务架构下的锁
特点:只支持单机服务下的并发访问安全问题
下面的例子,对num进行+1的操作,进行ab压测工具测试,5000次请求,修改结果235
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public synchronized void testLock() {
String value = this.stringRedisTemplate.opsForValue().get("num");
if (StringUtils.isBlank(value)) {
return;
}
// 有值就转成成int
int num = Integer.parseInt(value);
// 把redis中的num值+1
this.stringRedisTemplate.opsForValue().set("num", String.valueOf(++num));
}
synchronized的安全控制由JVM进程进行管理,速度快,但在集群下有安全问题
解决思想:跨越JVM的锁机制
由单机服务向转向集群服务
利用Redis的分布式锁机制,先上代码,再说代码细节问题
@Override
public void testLock() {
//设置唯一UUID,释放锁时 只能释放自己定义好的UUID
String uuid = UUID.randomUUID().toString().replaceAll("-", "");
//并设置超时时间
Boolean lock = stringRedisTemplate.opsForValue()
.setIfAbsent("lock", uuid, 7, TimeUnit.SECONDS);
if (lock) {
String value = stringRedisTemplate.opsForValue().get("num");
if (StringUtils.isBlank(value)) {
return;
}
// 有值就转成成int
int num = Integer.parseInt(value);
// 把redis中的num值+1
stringRedisTemplate.opsForValue().set("num", String.valueOf(++num));
String lua = "if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
"then\n" +
" return redis.call(\"del\",KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
DefaultRedisScript<Long> defaultRedisScript = new DefaultRedisScript<>();
defaultRedisScript.setScriptText(lua);
defaultRedisScript.setResultType(Long.class);
stringRedisTemplate.execute(defaultRedisScript, Arrays.asList("lock"), uuid);
//不是原子性操作
// if (uuid.equals(stringRedisTemplate.opsForValue().get("lock"))) {
// stringRedisTemplate.delete("lock");
// }
} else {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
testLock();//自旋
}
}
1.为什么要设置redis的超时时间
Boolean lock = stringRedisTemplate.opsForValue()
.setIfAbsent("lock", uuid, 7, TimeUnit.SECONDS);
setIfAbsent参数:key,value,过期时间,时间单位
作用1:预防网络抖动
客户端A中的一个线程获取到了锁,然后执行finally中的释放锁的代码时,这时候网络出问题了,导致客户端A没有成功释放锁。此时对于redis服务端来说,它会一直把锁给客户端A,这样的话其他客户端自然也就不能获取到这个锁。
作用2:redis宕机
客户端A获取到了锁,Redis服务器突然宕机,锁没有释放。等到Redis再次恢复的时候,Redis服务端还会把锁给到客户端A,这样也会发生锁死的情况。
2. 为什么要引入uuid?
我们先忽略uuid的以及lua脚本的代码,从if (lock)加锁开始到最后释放锁对象,有两种情况
情况1:在自动释放锁之前,任务执行完毕,由自己本身去释放锁
情况2:在自动释放锁之前,任务没有执行完毕,自动释放锁对象,等到任务执行完毕之后,调用del释放锁,这时释放的是并不是自己的锁,而是其他业务的锁,导致别人的锁被释放,出现没有上锁的情况
所以,我们要保证每次释放锁的时候,都是与自己匹配的锁对象,这时候uuid就是作为一个验证锁的身份对象
3. 为什么以下代码不是原子性操作?
if (uuid.equals(stringRedisTemplate.opsForValue().get("lock"))) {
stringRedisTemplate.delete("lock");
}
1.业务逻辑1执行删除时,查询到的lock值确实和uuid相等
uuid=v1
2.业务逻辑1执行删除前,lock刚好过期时间已到,被redis自动释放
在redis中没有了lock,没有了锁。
3.业务逻辑2获取了lock
index2线程获取到了cpu的资源,开始执行方法
uuid=v2
set(lock,uuid);
4.业务逻辑执行删除,此时会把业务逻辑2的lock删除
删除的业务逻辑2的锁
4.lua脚本的使用解决原子性问题
不使用固定的字符串作为键的值,而是设置一个不可猜测(non-guessable)的长随机字符串,作为口令串(token)。
不使用 DEL 命令来释放锁,而是发送一个 Lua 脚本,这个脚本只在客户端传入的值和键的口令串相匹配时,才对键进行删除。
大家可以参考文档Redis使用文档来查看具体的使用方式
public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {
return this.scriptExecutor.execute(script, keys, args);
}
stringRedisTemplate.execute执行需要以上几个参数
1.script,lua脚本,类型为RedisScript
RedisScript下有一个对应的DefaultRedisScript实现类,里面有两个参数,一个是脚本setScriptText,一个是返回类型setResultType(这里好像用不到,但是这里没有测试可不可以不传)
2.keys释放的key List类型
3.args可变参数的uuid,… 代表可变参数
String lua = "if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
"then\n" +
" return redis.call(\"del\",KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
DefaultRedisScript<Long> defaultRedisScript = new DefaultRedisScript<>();
defaultRedisScript.setScriptText(lua);
defaultRedisScript.setResultType(Long.class);
stringRedisTemplate.execute(defaultRedisScript, Arrays.asList("lock"), uuid);
5.使用redission优化
public void testLock() {
String s = "user:" + "1314" + ":Info";
RLock lock = redissonClient.getLock(s);
boolean tryLock = false;
try {
tryLock = lock.tryLock(101, 10, TimeUnit.SECONDS);
if (tryLock) {
String value = this.stringRedisTemplate.opsForValue().get("num");
if (StringUtils.isBlank(value)) {
return;
}
int num = Integer.parseInt(value);
this.stringRedisTemplate.opsForValue().set("num", String.valueOf(++num));
}
} catch (InterruptedException e) {
e.printStackTrace();
}
lock.unlock();
}
Redisson把获取锁+设置等待时间+设置过期时间、释放锁的各种操作封装成原子操作,并提供了自动续期的功能,此外还提供了可重入锁的功能。