Redis学习
一:分布式锁
1.起源
在传统单机部署的情况下,可以使用Java并发处理,使用Lock/sync进行互斥控制,但注意这是单机应用,也就是所有的请求都会分配到当前服务器的JVM内部,然后映射为操作系统的线程进行处理!而这个共享变量只是在这个JVM内部的一块内存空间!
随着业务的发展,需要集群,搭建分布式之后, 一个共享变量就会在不同的JVM中分配内存.多个请求会分别操作不同的JVM内存区域的数据,变量之间不存在共享,也不具备可见性,处理的结果显而易见有问题.因此引入了分布式锁;
2.分布式锁应该具备哪些条件
可见性: 多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思;
互斥: 互斥是分布式锁的最基本的条件,使得程序串行执行;
高可用: 程序不易崩溃,时时刻刻都保证较高的可用性;
高性能: 由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能;
安全性: 安全也是程序中必不可少的一环,具备锁失效机制,防止死锁,具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败;
3.分布式锁常见实现
分布式锁的核心是实现多进程之间的互斥
3.1 数据库实现方式
数据库,如mysql,本身具有互斥锁机制;
3.1.1 思路
实现方式:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。
3.1.2 逻辑
- 1.创建一个表:
DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',
`desc` varchar(255) NOT NULL COMMENT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
- 2.想要执行某个方法,就使用这个方法名向表中插入数据:
INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');
因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
- 3.成功插入则获取锁,执行完成后删除对应的行数据释放锁:
delete from method_lock where method_name ='methodName';
3.1.3 问题
3.1.3.1 可用性和性能
因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主备切换;
3.1.3.2 不具备可重入的特性
因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁;
3.1.3.3 没有锁失效机制
有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;
3.1.3.4 不具备阻塞锁特性
获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取。
3.2 Redis实现方式
利用redis 里面的setnx 和 过期时间来实现分布式锁,利用过期时间来保证安全
具体内容参考Redis分布式锁
3.3 Zookeeper实现方式
ZooKeeper是一个为分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名.
3.3.1 思路
- 创建一个目录mylock;
- 线程A想获取锁就在mylock目录下创建临时顺序节点;
- 获取mylock目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;
- 线程B获取所有节点,判断自己不是最小节点,设置监听比自己次小的节点;
- 线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。
这里推荐一个Apache的开源库Curator,它是一个ZooKeeper客户端,Curator提供的InterProcessMutex是分布式锁的实现,acquire方法用于获取锁,release方法用于释放锁。
3.3.2 优缺点
优点:具备高可用、可重入、阻塞锁特性,可解决失效死锁问题。
缺点:因为需要频繁的创建和删除节点,性能上不如Redis方式。
二:Redis分布式锁
1.基本原理
实现分布式锁时需要实现的两个基本方法:
1.1 获取锁
[*] 形式为变量;
互斥: 确保只能有一个线程获取锁
# 添加锁,利用setnx的互斥性
setnx [key] [value]
# 添加锁过期时间,避免服务器宕机引起的死锁问题
expire [key] [time]
# 若setnx 成功,expire 失败,这个时候导致锁一直存在怎么处理?
set [key] [value] ex [time] nx
非阻塞: 尝试一次,成功返回true,失败返回false
1.2 释放锁
手动释放
超时释放: 获取锁时添加一个超时时间
del [key]
2.redis分布式锁1.0
interface
package com.***.flow.service;
/**
* 自定义锁接口
*/
public interface ILock {
/**
* 尝试获得锁
* 锁持有超时时间,过期后自动释放锁
* true 代表获取锁成功 flase 代表获取表失败
* @return
*/
boolean tryLock();
/**
* 释放锁
*/
void unLock();
}
service
package com.***.flow.impl;
import com.***.flow.service.ILock;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
public class RedisLock implements ILock {
//谁注入
private StringRedisTemplate stringRedisTemplate;
//业务
private String key;
//过期时间
private Long time;
//时间单位
private TimeUnit unit;
//前缀
private static final String prefix = "lock:";
public RedisLock(StringRedisTemplate stringRedisTemplate, String key, Long time, TimeUnit unit) {
this.stringRedisTemplate = stringRedisTemplate;
this.key = key;
this.time = time;
this.unit = unit;
}
@Override
public boolean tryLock() {
//获取线程id
long id = Thread.currentThread().getId();
String lockKey = prefix+key;
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, id + "", time, unit);
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
//释放锁
String lockKey = prefix+key;
stringRedisTemplate.delete(lockKey);
}
}
service
public String get(String id) {
//获取锁对象
RedisLock redisLock = new RedisLock(stringRedisTemplate,"user:"+id,10L, TimeUnit.MINUTES);
//获取到锁对象
if (!redisLock.tryLock()) {
// 失败,重试或处理异常
return "";
}
try{
// todo:
}catch (Exception e){
}finally{
// TODO 释放锁
redisLock.unLock();
}
return "success";
}
3.redis分布式锁1.0的问题和更正
3.1 问题:
锁误删: [a,b] ->(开始时间,结束时间)
举例:同时有三个线程并发
线程1获取锁 [0,1],持有锁[1,5],业务运行阻塞[1,6],这时锁由于设置了超时时间已经被释放了;
线程2 趁虚而入在[5,6]时刻,获取了锁; 线程1继续执行[6,7],把线程2的锁进行了删除. 这个行为是锁误删除
线程3在[7,+] 又可以获取到锁;就会导致线程锁被同时持有的情况;
3.2 解决方案
判断属于自己的线程才能删除;
3.2.1 逻辑
- 尝试获取锁
- T:获取成功,执行业务,获取锁时,存入线程标识(UUID)
- 这个过程中发生业务超时或者宕机,自动释放锁;
- 正常执行,判断锁标记是否属于自己?
- T 释放锁 return true;
- F 继续执行 return true;
- F:return errMsg;
- T:获取成功,执行业务,获取锁时,存入线程标识(UUID)
3.2.2 redis分布式锁1.0优化
package com.***.flow.impl;
import cn.hutool.core.lang.UUID;
import com.***.flow.service.ILock;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
public class RedisLock implements ILock {
//谁注入
private StringRedisTemplate stringRedisTemplate;
//业务
private String key;
//过期时间
private Long time;
//时间单位
private TimeUnit unit;
private static final String sign_prefix = UUID.randomUUID().toString(true);
//前缀
private static final String prefix = "lock:";
public RedisLock(StringRedisTemplate stringRedisTemplate, String key, Long time, TimeUnit unit) {
this.stringRedisTemplate = stringRedisTemplate;
this.key = key;
this.time = time;
this.unit = unit;
}
@Override
public boolean tryLock() {
//获取线程id
long id = Thread.currentThread().getId();
String lockKey = prefix+key;
String sign = sign_prefix+"_"+ id;
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, sign, time, unit);
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
//释放锁
String sign = sign_prefix+"_"+ Thread.currentThread().getId();
String value = stringRedisTemplate.opsForValue().get(prefix + key);
if (sign.equals(value)){
stringRedisTemplate.delete(prefix + key);
}
}
}
3.3 分布式锁原子性问题
3.3.1 起因
原子性问题属于极端情况:在上述过程中,比较锁成功之后删除锁过程中出现问题;
线程1:比较锁内容成功[0,1],锁过期,但是代码会继续执行删除锁的逻辑;
线程2:在[1,2]时候,获取了锁,[2,3] 时,线程1 删除锁,删除的锁依旧是线程2的锁;
出现这种情况的原因:线程1的获得锁,比较锁,删除锁都不是一个原子性的操作;
3.3.2 处理方式
3.3.2.1 Lua脚本
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法大家可以参考网站:https://www.runoob.com/lua/lua-tutorial.html,我们可以使用lua去操作redis,又能保证他的原子性,这样就可以实现拿锁比锁删锁是一个原子性动作.
3.3.2.2 基本使用
这里重点介绍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', 'Rose')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name
写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:
// EVAL "脚本" 参数个数 0或多个key 0或多个参数
EVAL script numkeys key [key ...] arg [arg ...]
使用示例
//执行 redis.call('set', 'name', 'jack') 这个脚本
EVAL "return redis.call('set','name','jack')" 0
//如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会
//放入KEYS数组,其它参数会放入ARGV数组.
//在脚本中可以从KEYS和ARGV数组获取这些参数;
EVAL "return redis.call('set',KEYS[1],ARGV[1])" 1 name jack
3.3.2.3 redis + Lua处理分布式锁
3.3.2.3.1 锁释放逻辑
释放锁的业务流程是这样的
1、获取锁中的线程标示
2、判断是否与指定的标示(当前线程标示)一致
3、如果一致则释放锁(删除)
4、如果不一致则什么都不做
3.3.2.3.2 Lua 脚本表达
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
三:Java 使用Redis Lua
1.相关源码
public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {
return this.scriptExecutor.execute(script, keys, args);
}
2.使用
2.1 编写脚本
该脚本放入resource路径下,名称定义为unlock.lua
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
2.2 锁代码
package com.***.flow.impl;
import cn.hutool.core.lang.UUID;
import com.gisinfo.flow.service.ILock;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
public class RedisLock implements ILock {
//谁注入谁提供
private StringRedisTemplate stringRedisTemplate;
//业务
private String key;
//过期时间
private Long time;
//时间单位
private TimeUnit unit;
//标识
private static final String sign_prefix = UUID.randomUUID().toString(true);
//前缀
private static final String prefix = "lock:";
//脚本类
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 RedisLock(StringRedisTemplate stringRedisTemplate, String key, Long time, TimeUnit unit) {
this.stringRedisTemplate = stringRedisTemplate;
this.key = key;
this.time = time;
this.unit = unit;
}
@Override
public boolean tryLock() {
//获取线程id
long id = Thread.currentThread().getId();
String lockKey = prefix+key;
String sign = sign_prefix+"_"+ id;
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, sign, time, unit);
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(prefix+key),
sign_prefix + Thread.currentThread().getId());
}
}
四.总结
- 分布式的由来,三种实现方式
- db,zk不是文章核心,只是简单说明
- redis 每种解决方案的异常情况与分析思路
- lua 脚本的基本使用
- 引入:redisson
- 异常情况和思路分析值得复习
- 碎碎念:学习果然是一件很痛苦的事情!!!