redis客户端、分布式锁及数据一致性
Redis Java客户端有很多的开源产品比如Redission、Jedis、lettuce等。
Jedis:
Jedis是Redis的Java实现的客户端,其API提供了比较全面的Redis命令的支持;Jedis中的方法调用是比较底层的暴露的Redis的API,也即Jedis中的Java方法基本和Redis的API保持着一致,了解Redis的API,也就能熟练的使用Jedis。使用阻塞的I/O,且其方法调用都是同步的,程序流需要等到sockets处理完I/O才能执行,不支持异步。Jedis客户端实例不是线程安全的,所以需要通过连接池来使用Jedis。Jedis仅支持基本的数据类型如:String、Hash、List、Set、Sorted Set。
1.导入依赖 pom
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
2. 注入配置类
@Configuration
@PropertySource("classpath:application.properties")
public class RedisConfig {
@Value("${redis.host}")
private String host;
@Value("${redis.password}")
private String password;
@Value("${redis.port}")
private int port;
@Value("${redis.timeout}")
private int timeout;
@Value("${redis.maxIdle}")
private int maxIdle;
@Value("${redis.maxWaitMillis}")
private int maxWaitMillis;
@Value("${redis.blockWhenExhausted}")
private Boolean blockWhenExhausted;
@Value("${redis.JmxEnabled}")
private Boolean JmxEnabled;
@Bean
public JedisPool jedisPoolFactory() {
System.out.println("JedisPool注入开始...");
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxIdle(maxIdle);
jedisPoolConfig.setMaxWaitMillis(maxWaitMillis);
// 连接耗尽时是否阻塞, false报异常,true阻塞直到超时, 默认true
jedisPoolConfig.setBlockWhenExhausted(blockWhenExhausted);
// 是否启用pool的jmx管理功能, 默认tru
jedisPoolConfig.setJmxEnabled(JmxEnabled);
// JedisPool jedisPool1 = new JedisPool(jedisPoolConfig, host, port, timeout, password, 0);
JedisPool jedisPool = new JedisPool(jedisPoolConfig, host, port, timeout, password);
System.out.println("JedisPool注入成功...");
return jedisPool;
}
}
3.设置配置属性 application.properties
# jedis
redis.host=192.168.1.101
redis.port=6379
redis.password=wuzhenzhao
redis.timeout=30000
redis.maxTotal=30
redis.maxIdle=10
redis.numTestsPerEvictionRun=1024
redis.timeBetweenEvictionRunsMillis=30000
redis.minEvictableIdleTimeMillis=1800000
redis.softMinEvictableIdleTimeMillis=10000
redis.maxWaitMillis=1500
redis.testOnBorrow=true
redis.testWhileIdle=true
redis.blockWhenExhausted=false
redis.JmxEnabled=true
4.example:
@Autowired
private JedisPool jedisPool;
/**
* 根据传入Key获取指定Value
*/
public String get(String key) {
Jedis jedis = null;
String value;
try {
jedis = jedisPool.getResource();
value = jedis.get(key);
} catch (Exception e) {
e.printStackTrace();
} finally {
returnResource(jedisPool, jedis);
}
return value;
}
Redission:
Redisson实现了分布式和可扩展的Java数据结构,和Jedis相比,功能较为简单,不支持字符串操作,不支持排序、事务、管道、分区等Redis特性。Redisson主要是促进使用者对Redis的关注分离,从而让使用者能够将精力更集中地放在处理业务逻辑上。Redisson中的方法则是进行比较高的抽象,每个方法调用可能进行了一个或多个Redis方法调用。基于Netty框架的事件驱动的通信层,其方法调用是异步的。Redisson的API是线程安全的,所以可以操作单个Redisson连接来完成各种操作。Redisson不仅提供了一系列的分布式Java常用对象,基本可以与Java的基本数据结构通用,还提供了许多分布式服务,其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service)。
1.导入依赖 pom
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.16.0</version>
</dependency>
2. 配置有两种方式,利用自动配置,通过配置文件来实现:
#redisson
spring.redis.redisson.file= classpath:redisson.yml
新建 redisson.yml
singleServerConfig:
address: redis://192.168.1.101:6379
password: wuzhenzhao
#---------------------------------------------
# 连接空闲超时,单位:毫秒
idleConnectionTimeout: 10000
# 连接超时,单位:毫秒
connectTimeout: 10000
# 命令等待超时,单位:毫秒
timeout: 3000
# 命令失败重试次数,如果尝试达到 retryAttempts(命令失败重试次数) 仍然不能将命令发送至某个指定的节点时,将抛出错误。
# 如果尝试在此限制之内发送成功,则开始启用 timeout(命令等待超时) 计时。
retryAttempts: 3
# 命令重试发送时间间隔,单位:毫秒
retryInterval: 1500
codec: !<org.redisson.client.codec.StringCodec> {}
另一种方式,自己写配置类,注意,也可以配置 RedisTemplate。自动配置类源码中也会注入:
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
Config config = new Config();
//单机模式 依次设置redis地址和密码
config.useSingleServer().
setAddress("redis://192.168.1.101:6379").
setPassword("wuzhenzhao");
config.setCodec(new StringCodec());
return Redisson.create(config);
}
}
3.example:
@RestController
public class RedissonController {
@Autowired
RedissonClient redissonClient;
@GetMapping("/redisson")
public String get(){
return redissonClient.getBucket("name").get().toString();
}
}
lettuce:
Lettuce:高级Redis客户端,用于线程安全同步,异步和响应使用,支持集群,Sentinel,管道和编码器。目前springboot默认使用的客户端(RedisTemplate)。
1.导入依赖 pom
<!-- 引入 redis 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2.配置文件:
#RedisTemplate
spring.redis.host=192.168.1.101
spring.redis.port=6379
#会有分区,默认是0
spring.redis.database=0
spring.redis.password=wuzhenzhao
spring.redis.jedis.pool.max-active=8
spring.redis.jedis.pool.max-wait=-1ms
spring.redis.jedis.pool.max-idle=500
spring.redis.jedis.pool.min-idle=0
spring.redis.timeout=5000ms
3.配置 RedisTemplate:
@Component
public class RedisTemplateConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
// 配置连接工厂
template.setConnectionFactory(factory);
// 序列化
template.setValueSerializer(new StringRedisSerializer());
//使用StringRedisSerializer来序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
// 设置hash key 和value序列化模式
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
4.example:
@RestController
public class RedisTemplateController {
@Autowired
RedisTemplate redisTemplate;
@GetMapping("/redisTemplate")
public Object get() {
return redisTemplate.opsForValue().get("name");
}
}
Redis的请求通信协议探秘:
我们的所有操作都是基于 redis-cli 来完成的,那么我们要在Java中操作Redis,怎么做呢?首先我们 先来了解一下Redis Serialization Protocol(Redis序列化协议),这个是Redis提供的一种,客户端和 Redis服务端通信传输的编码协议,服务端收到数据包,会基于这个约定编码进行解码。
- 打开Wireshark工具,对VMnet8这个网络进行抓包
- 增加过滤条件:ip.dst_host==192.168.1.101 and tcp.port in {6379}
- 使用RDM工具连接到Redis Server进行key-value操作,比如执行 set name wuzhenzhao
- 通过Wireshark工具监控数据包内容,如图3-3所示,可以看到实际发出的数据包是:
其中 *3 代表参数个数,set name wuzhenzhao, 表示三个参数。$3 表示属性长度, $ 表示包含3个字符。客户端和服务器发送的命令或数据一律以 \r\n (CRLF回车+换行)结尾。基于这样一个特性,我们可以自己实现一个Java客户端(注:简单演示,先把服务端的密码取消 config set requirepass ''):
1.先定义可能用到的常量:
public class CommandConstant {
public static final String START="*";
public static final String LENGTH="$";
public static final String LINE="\r\n";
public enum CommandEnum{
SET,
GET
}
}
2. 基本 Sokect 通信:
public class CustomerRedisClientSocket {
private Socket socket;
private InputStream inputStream;
private OutputStream outputStream;
public CustomerRedisClientSocket(String ip,int port){
try {
socket=new Socket(ip,port);
inputStream=socket.getInputStream();
outputStream=socket.getOutputStream();
} catch (IOException e) {
e.printStackTrace();
}
}
public void send(String cmd){
try {
outputStream.write(cmd.getBytes());
} catch (IOException e) {
e.printStackTrace();
}
}
public String read(){
byte[] bytes=new byte[1024];
int count=0;
try {
count=inputStream.read(bytes);
} catch (IOException e) {
e.printStackTrace();
}
return new String(bytes,0,count);
}
}
3.报文转换类 :
public class CustomerRedisClient {
private CustomerRedisClientSocket customerRedisClientSocket;
public CustomerRedisClient(String host,int port) {
customerRedisClientSocket=new CustomerRedisClientSocket(host,port);
}
public String set(String key,String value){
String s = convertToCommand(CommandConstant.CommandEnum.SET, key.getBytes(), value.getBytes());
System.out.println("start: "+ s + " end");
customerRedisClientSocket.send(convertToCommand(CommandConstant.CommandEnum.SET,key.getBytes(),value.getBytes()));
return customerRedisClientSocket.read(); //在等待返回结果的时候,是阻塞的
}
public String get(String key){
customerRedisClientSocket.send(convertToCommand(CommandConstant.CommandEnum.GET,key.getBytes()));
return customerRedisClientSocket.read();
}
public static String convertToCommand(CommandConstant.CommandEnum commandEnum,byte[]... bytes){
StringBuilder stringBuilder=new StringBuilder();
stringBuilder.append(CommandConstant.START).append(bytes.length+1).append(CommandConstant.LINE);
stringBuilder.append(CommandConstant.LENGTH).append(commandEnum.toString().length()).append(CommandConstant.LINE);
stringBuilder.append(commandEnum.toString()).append(CommandConstant.LINE);
for (byte[] by:bytes){
stringBuilder.append(CommandConstant.LENGTH).append(by.length).append(CommandConstant.LINE);
stringBuilder.append(new String(by)).append(CommandConstant.LINE);
}
return stringBuilder.toString();
}
}
4. 测试:
public class MainClient {
public static void main(String[] args) {
CustomerRedisClient customerRedisClient=new CustomerRedisClient("192.168.1.101",6379);
System.out.println(customerRedisClient.set("customer","Define"));
System.out.println(customerRedisClient.get("customer"));
}
}
这样就完成了基本的数据通信,Redis 服务端返回报文 +OK 代表数据传输成功。此时可以结合上述3种客户端进行思考相关优化方案:
- 通信层面的优化(NIO
- 连接的优化(池的优化)
- 是否采用异步通信(多线程)
- 针对key和value的序列化
- java8的特性
- 解决方案(基于特性的解决方案)
jedis-sentinel原理:
客户端通过连接到哨兵集群,通过发送Protocol.SENTINEL_GET_MASTER_ADDR_BY_NAME 命令,从哨兵机器中询问master节点的信息,拿到master节点的ip和端口号以后,再到客户端发起连接。连接以后,需要在客户端建立监听机制,当master重新选举之后,客户端需要重新连接到新的master节点。
构造器代码如下:
public JedisSentinelPool(String masterName, Set<String> sentinels, final GenericObjectPoolConfig poolConfig, int timeout, final String password, final int database) {
this.poolConfig = poolConfig;
this.timeout = timeout;
this.password = password;
this.database = database;
HostAndPort master = initSentinels(sentinels, masterName);
initPool(master);
}
其中 masterName 为配置 sentinels的时候再sentinel.conf 所配置的master的名称。
initSentinels方法:
private HostAndPort initSentinels(Set<String> sentinels, final String masterName) {
HostAndPort master = null;
boolean sentinelAvailable = false;
log.info("Trying to find master from available Sentinels...");
// 有多个sentinels,遍历这些个sentinels
for (String sentinel : sentinels) {
// host:port表示的sentinel地址转化为一个HostAndPort对象。
final HostAndPort hap = HostAndPort.parseString(sentinel);
log.debug("Connecting to Sentinel {}", hap);
Jedis jedis = null;
try {
// 连接到sentinel
jedis = new Jedis(hap);
// 根据masterName得到master的地址,返回一个list,host= list[0], port =// list[1]
List<String> masterAddr = jedis.sentinelGetMasterAddrByName(masterName);
// connected to sentinel...
sentinelAvailable = true;
if (masterAddr == null || masterAddr.size() != 2) {
log.warn("Can not get master addr, master name: {}. Sentinel: {}", masterName, hap);
continue;
}
// 如果在任何一个sentinel中找到了master,不再遍历sentinels
master = toHostAndPort(masterAddr);
log.debug("Found Redis master at {}", master);
break;
} catch (JedisException e) {
// resolves #1036, it should handle JedisException there's another chance
// of raising JedisDataException
log.warn(
"Cannot get master address from sentinel running @ {}. Reason: {}. Trying next one.", hap,
e.toString());
} finally {
if (jedis != null) {
jedis.close();
}
}
}
// 到这里,如果master为null,则说明有两种情况,一种是所有的sentinels节点都down掉了,一种是master节点没有被存活的sentinels监控到
if (master == null) {
if (sentinelAvailable) {
// can connect to sentinel, but master name seems to not
// monitored
throw new JedisException("Can connect to sentinel, but " + masterName
+ " seems to be not monitored...");
} else {
throw new JedisConnectionException("All sentinels down, cannot determine where is "
+ masterName + " master is running...");
}
}
//如果走到这里,说明找到了master的地址
log.info("Redis master running at " + master + ", starting Sentinel listeners...");
//启动对每个sentinels的监听为每个sentinel都启动了一个监听者MasterListener。MasterListener本身是一个线程,它会去订阅sentinel上关于master节点地址改变的消息。
for (String sentinel : sentinels) {
final HostAndPort hap = HostAndPort.parseString(sentinel);
MasterListener masterListener = new MasterListener(masterName, hap.getHost(), hap.getPort());
// whether MasterListener threads are alive or not, process can be stopped
masterListener.setDaemon(true);
masterListeners.add(masterListener);
masterListener.start();
}
return master;
}
可以看到initSentinels
方法的参数有一个masterName,就是我们所需要查找的master的名字。一开始,遍历多个sentinels,一个一个连接到sentinel,去询问关于masterName的消息,可以看到是通过jedis.sentinelGetMasterAddrByName()
方法去连接sentinel,并询问当前的master的地址。点进这个方法去看看,源代码是这样写的:从哨兵节点获取master信息的方法:调用的是与Jedis绑定的client去发送一个"get-master-addr-by-name"命令
public List<String> sentinelGetMasterAddrByName(final String masterName) {
client.sentinel(Protocol.SENTINEL_GET_MASTER_ADDR_BY_NAME, masterName);
final List<Object> reply = client.getObjectMultiBulkReply();
return BuilderFactory.STRING_LIST.build(reply);
}
调用initPool
方法(构造函数中调用),那么会初始化Jedis实例创建工厂,如果不是第一次调用(MasterListener
中调用),那么只对已经初始化的工厂进行重新设置。Jedis的JedisSentinelPool的实现仅仅适用于单个master-slave。
Jedis-cluster原理:
先来看一下他的连接方式:
Set<HostAndPort> hostAndPorts=new HashSet<>();
HostAndPort hostAndPort=new HostAndPort("192.168.11.153",7000);
HostAndPort hostAndPort1=new HostAndPort("192.168.11.153",7001);
HostAndPort hostAndPort2=new HostAndPort("192.168.11.154",7003);
HostAndPort hostAndPort3=new HostAndPort("192.168.11.157",7006);
hostAndPorts.add(hostAndPort);
hostAndPorts.add(hostAndPort1);
hostAndPorts.add(hostAndPort2);
hostAndPorts.add(hostAndPort3);
JedisCluster jedisCluster=new JedisCluster(hostAndPorts,6000);
jedisCluster.set("wuzz","hello");
程序启动初始化集群环境:
1)、读取配置文件中的节点配置,无论是主从,无论多少个,只拿第一个,获取redis连接实例
2)、用获取的redis连接实例执行clusterNodes()方法,实际执行redis服务端cluster nodes命令,获取主从配置信息
3)、解析主从配置信息,先把所有节点存放到nodes的map集合中,key为节点的ip:port,value为当前节点的jedisPool
4)、解析主节点分配的slots区间段,把slot对应的索引值作为key,第三步中拿到的jedisPool作为value,存储在slots的map集合中就实现了slot槽索引值与jedisPool的映射,这个jedisPool包含了master的节点信息,所以槽和几点是对应的,与redis服务端一致
从集群环境存取值:
1)、把key作为参数,执行CRC16算法,获取key对应的slot值
2)、通过该slot值,去slots的map集合中获取jedisPool实例
3)、通过jedisPool实例获取jedis实例,最终完成redis数据存取工作
分布式锁的实现:
分布式锁一般有三种实现方式:1. 数据库乐观锁;2. 基于Redis的分布式锁;3. 基于ZooKeeper的分布式锁。本篇博客将介绍第二种方式,基于Redis实现分布式锁。
关于锁,其实我们或多或少都有接触过一些,比如synchronized、 Lock这些,这类锁的目的很简单,在多线程环境下,对共享资源的访问造成的线程安全问题,通过锁的机制来实现资源访问互斥。那么什么是分布式锁呢?或者为什么我们需要通过Redis来构建分布式锁,其实最根本原因就是Score(范围),因为在分布式架构中,所有的应用都是进程隔离的,在多进程访问共享资源的时候我们需要满足互斥性,就需要设定一个所有进程都能看得到的范围,而这个范围就是Redis本身。所以我们才需要把锁构建到Redis中。Redis里面提供了一些比较具有能够实现锁特性的命令,比如SETEX(在键不存在的情况下为键设置值),那么我们可以基于这个命令来去实现一些简单的锁的操作.
首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
- 互斥性。在任意时刻,只有一个客户端能持有锁。
- 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
- 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
- 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
1.引入依赖:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.0.0</version>
</dependency>
2.来一个连接 redis 的工具类:
public class JedisConnectionUtils {
private static JedisPool pool=null;
static {
JedisPoolConfig jedisPoolConfig=new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(100);
pool=new JedisPool(jedisPoolConfig,"192.168.254.136",6399,5000,"wuzhenzhao");
}
public static Jedis getJedis(){
return pool.getResource();
}
}
3.加锁:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:
- 第一个为key,我们使用key来当锁,因为key是唯一的。
- 第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
- 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
- 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
- 第五个为time,与第四个参数相呼应,代表key的过期时间。
private final String LOCK_NAME = "DISTRIBUTEDLOCK";
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
/**
* @param acquireTimeout 获得锁的超时时间
* @param lockTimeout 锁本身的过期时间
* @return
*/
public String acquireLock(long acquireTimeout, long lockTimeout) {
String identifier = UUID.randomUUID().toString();//保证释放锁的时候是同一个持有锁的人
String lockKey = "lock:" + LOCK_NAME;
int lockExpire = (int) (lockTimeout / 1000);
Jedis jedis = null;
try {//获取连接
jedis = JedisConnectionUtils.getJedis();
long end = System.currentTimeMillis() + acquireTimeout;
//获取锁的限定时间
while (System.currentTimeMillis() < end) {
String result = jedis.set(lockKey, identifier, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, lockTimeout);
if (LOCK_SUCCESS.equals(result)) {
return identifier;
}
//表示没有超时时间
if (jedis.ttl(lockKey) == -1) {
jedis.expire(lockKey, lockExpire); //设置超时时间
}
try {
//等待片刻后进行获取锁的重试
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} finally {
jedis.close(); //回收
}
return null;
}
3.释放锁
public boolean releaseLock(String lockName,String identifier){
System.out.println(lockName+"开始释放锁:"+identifier);
String lockKey="lock:"+lockName;
Jedis jedis=null;
boolean isRelease=false;
try{
jedis=JedisConnectionUtils.getJedis();
while(true){
//Watch 命令用于监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断
jedis.watch(lockKey);
//判断是否为同一把锁
if(identifier.equals(jedis.get(lockKey))){
//标记事务开始
Transaction transaction=jedis.multi();
transaction.del(lockKey);
if(transaction.exec().isEmpty()){
continue;
}
isRelease=true;
}else {
//TODO 异常
}
jedis.unwatch();
break;
}
}finally {
jedis.close();
}
return isRelease;
}
5.测试:
public class UnitTest extends Thread{
@Override
public void run() {
while(true){
RedisDemo distributedLock=new RedisDemo();
String rs=distributedLock.acquireLock("updateOrder",
2000,5000);
if(rs!=null){
System.out.println(Thread.currentThread().getName()+"-> 成功获得锁:"+rs);
try {
Thread.sleep(1000);
distributedLock.releaseLock("updateOrder",rs);
} catch (InterruptedException e) {
e.printStackTrace();
}
break;
}
}
}
public static void main(String[] args) {
UnitTest unitTest=new UnitTest();
for(int i=0;i<10;i++){
new Thread(unitTest,"tName:"+i).start();
}
}
}
如果你的项目中Redis是多机部署的,那么可以尝试使用Redisson
实现分布式锁,这是Redis官方提供的Java组件
Redisson 实现分布式锁:
Redisson 主要是基于 Lua 脚本、pub/sub 机制 以及时间轮机制来实现,其中 Lua脚本、Pub/Sub 请参考 https://www.cnblogs.com/wuzhenzhao/p/9482426.html, 时间轮请参考 :https://www.cnblogs.com/wuzhenzhao/p/13784469.html
public class RedissionDistributedlock {
private static RedissonClient redissonClient;
static {
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.1.101:6379");
redissonClient = Redisson.create(config);
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(5);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
countDownLatch.await();
RLock rLock = redissonClient.getLock("updateOrder");
//最多等待100秒、上锁10s以后自动解锁
if (rLock.tryLock(100, 10, TimeUnit.SECONDS)) {
System.out.println(Thread.currentThread().getName() + "获取锁成功");
}
Thread.sleep(2000);
rLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放锁成功");
} catch (InterruptedException e) {
e.printStackTrace();
}
// redissonClient.shutdown();
}
).start();
countDownLatch.countDown();
}
}
}
通过redisson,非常简单就可以实现我们所需要的功能,当然这只是redisson的冰山一 角,redisson最强大的地方就是提供了分布式特性的常用工具类。使得原本作为协调单机多线程并发程 序的并发程序的工具包获得了协调分布式多级多线程并发系统的能力,降低了程序员在分布式环境下解 决分布式问题的难度,下面分析一下RedissonLock的实现原理。
RedissonLock.tryLock :
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
//通过tryAcquire方法尝试获取锁
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) { //表示成功获取到锁,直接返回
return true;
}
//省略部分代码....
}
tryAcquire :
private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
return get(tryAcquireAsync(waitTime, leaseTime, unit, threadId));
}
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture<Long> ttlRemainingFuture;
//leaseTime就是租约时间,就是redis key的过期时间。
if (leaseTime != -1) { //如果设置过期时间
ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {//如果没设置了过期时间,则从配置中获取key超时时间,默认是30s过期
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
//当tryLockInnerAsync执行结束后,触发下面回调
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) { //说明出现异常,直接返回
return;
}
// lock acquired
if (ttlRemaining == null) { //表示第一次设置锁键
if (leaseTime != -1) { //表示设置过超时时间,更新internalLockLeaseTime,并返回
internalLockLeaseTime = unit.toMillis(leaseTime);
} else { //leaseTime=-1,启动Watch Dog
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
tryLockInnerAsync : 通过lua脚本来实现加锁的操作
- 判断lock键是否存在,不存在直接调用hset存储当前线程信息并且设置过期时间,返回nil,告诉客 户端直接获取到锁。
- 判断lock键是否存在,存在则将重入次数加1,并重新设置过期时间,返回nil,告诉客户端直接获 取到锁。
- 被其它线程已经锁定,返回锁有效期的剩余时间,告诉客户端需要等待。
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}
unlock释放锁流程:
释放锁的流程,脚本看起来会稍微复杂一点
- 如果lock键不存在,通过 publish 指令发送一个消息表示锁已经可用。
- 如果锁不是被当前线程锁定,则返回nil
- 由于支持可重入,在解锁时将重入次数需要减1
- 如果计算后的重入次数>0,则重新设置过期时间
- 如果计算后的重入次数<=0,则发消息说锁已经可用
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
RedissonLock有竞争的情况:
有竞争的情况在redis端的lua脚本是相同的,只是不同的条件执行不同的redis命令。
当通过tryAcquire 发现锁被其它线程申请时,需要进入等待竞争逻辑中
- this.await返回false,说明等待时间已经超出获取锁最大等待时间,取消订阅并返回获取锁失败
- this.await返回true,进入循环尝试获取锁。
继续看RedissonLock.tryLock后半部分代码如下:
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
//省略部分代码
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
current = System.currentTimeMillis();
// 订阅监听redis消息,并且创建RedissonLockEntry
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
// 阻塞等待subscribe的future的结果对象,如果subscribe方法调用超过了time,说明已经超过了客户端设置的最大wait time,则直接返回false,取消订阅,不再继续申请锁了。
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) {//取消订阅
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
unsubscribe(subscribeFuture, threadId);
}
});
}
acquireFailed(waitTime, unit, threadId);//表示抢占锁失败
return false;
}
try {
//判断是否超时,如果等待超时,返回获的锁失败
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
//通过while循环再次尝试竞争锁
while (true) {
long currentTime = System.currentTimeMillis();
//竞争锁,返回锁超时时间
ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {//如果超时时间为null,说明获得锁成功
return true;
}
//判断是否超时,如果超时,表示获取锁失败
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
// waiting for message
// 通过信号量(共享锁)阻塞,等待解锁消息. (减少申请锁调用的频率)
// 如果剩余时间(ttl)小于wait time ,就在 ttl 时间内,从 Entry的信号量获取一个许可(除非被中断或者一直没有可用的许可)。
// 否则就在wait time 时间范围内等待可以通过信号量
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
// 更新等待时间(最大等待时间-已经消耗的阻塞时间)
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) { //获取锁失败
acquireFailed(waitTime, unit, threadId);
return false;
}
}
} finally {
unsubscribe(subscribeFuture, threadId);//取消订阅
}
// return get(tryLockAsync(waitTime, leaseTime, unit));
}
锁过期了怎么办?
一般来说,我们去获得分布式锁时,为了避免死锁的情况,我们会对锁设置一个超时时间,但是有一种 情况是,如果在指定时间内当前线程没有执行完,由于锁超时导致锁被释放,那么其他线程就会拿到这 把锁,从而导致一些故障。
为了避免这种情况,Redisson引入了一个Watch Dog机制,这个机制是针对分布式锁来实现锁的自动续 约,简单来说,如果当前获得锁的线程没有执行完,那么Redisson会自动给Redis中目标key延长超时时 间。 默认情况下,看门狗的续期时间是30s,也可以通过修改Config.lockWatchdogTimeout来另行指 定。
@Override
public boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException {
return tryLock(waitTime, -1, unit);
}
实际上,当我们通过tryLock方法没有传递超时时间时,默认会设置一个30s的超时时间,避免出现死锁 的问题。
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture<Long> ttlRemainingFuture;
//leaseTime就是租约时间,就是redis key的过期时间。
if (leaseTime != -1) { //如果设置过期时间
ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {//如果没设置了过期时间,则从配置中获取key超时时间,默认是30s过期
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
//当tryLockInnerAsync执行结束后,触发下面回调
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) { //说明出现异常,直接返回
return;
}
// lock acquired
if (ttlRemaining == null) { //表示第一次设置锁键
if (leaseTime != -1) { //表示设置过超时时间,更新internalLockLeaseTime,并返回
internalLockLeaseTime = unit.toMillis(leaseTime);
} else { //leaseTime=-1,启动Watch Dog
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
由于默认设置了一个30s的过期时间,为了防止过期之后当前线程还未执行完,所以通过定时任务对过 期时间进行续约。
- 首先,会先判断在expirationRenewalMap中是否存在了entryName,这是个map结构,主要还 是判断在这个服务实例中的加锁客户端的锁key是否存在,
- 如果已经存在了,就直接返回;主要是考虑到RedissonLock是可重入锁。
protected void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {// 第一次加锁的时候会调用,内部会启动WatchDog
entry.addThreadId(threadId);
renewExpiration();
}
}
定义一个定时任务,该任务中调用 renewExpirationAsync 方法进行续约。
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
//用到了时间轮机制
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
// renewExpirationAsync续约租期
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getRawName() + " expiration", e);
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
return;
}
if (res) {
// reschedule itself
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);//每次间隔租期的1/3时间执行
ee.setTimeout(task);
}
执行Lua脚本,对指定的key进行续约。
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.singletonList(getRawName()),
internalLockLeaseTime, getLockName(threadId));
}
管道模式:
Redis服务是一种C/S模型,提供请求-响应式协议的TCP服务,所以当客户端发起请求,服务端处理并返回结果到客户端,一般是以阻塞形式等待服务端的响应,但这在批量处理连接时延迟问题比较严重,所以Redis为了提升或弥补这个问题,引入了管道技术:可以做到服务端未及时响应的时候,客户端也可以继续发送命令请求,做到客户端和服务端互不影响,服务端并最终返回所有服务端的响应,大大提高了C/S模型交互的响应速度上有了质的提高。
使用方法:
Jedis jedis=new Jedis("192.168.254.136",6399);
Pipeline pipeline=jedis.pipelined();
for(int i=0;i<1000;i++){
pipeline.incr("test");
}
pipeline.sync();
Redis缓存与数据一致性问题:
对于读多写少的高并发场景,我们会经常使用缓存来进行优化。比如说支付宝的余额展示功能,实际上99%的时候都是查询,1%的请求是变更(除非是土豪,每秒钟都有收入在不断更改余额),所以,我们在这样的场景下,可以加入缓存,用户->余额
那么基于上面的这个出发点,问题就来了,当用户的余额发生变化的时候,如何更新缓存中的数据,也就是说。我是先更新缓存中的数据再更新数据库的数据;还是修改数据库中的数据再更新缓存中的数据
数据库的数据和缓存中的数据如何达到一致性?首先,可以肯定的是,redis中的数据和数据库中的数据不可能保证事务性达到统一的,这个是毫无疑问的,所以在实际应用中,我们都是基于当前的场景进行权衡降低出现不一致问题的出现概率。
更新缓存还是让缓存失效?
更新缓存表示数据不但会写入到数据库,还会同步更新缓存; 而让缓存失效是表示只更新数据库中的数据,然后删除缓存中对应的key。那么这两种方式怎么去选择?这块有一个衡量的指标。
1. 如果更新缓存的代价很小,那么可以先更新缓存,这个代价很小的意思是我不需要很复杂的计算去获得最新的余额数字。
2. 如果是更新缓存的代价很大,意味着需要通过多个接口调用和数据查询才能获得最新的结果,那么可以先淘汰缓存。淘汰缓存以后后续的请求如果在缓存中找不到,自然去数据库中检索。
先操作数据库还是先操作缓存?
当客户端发起事务类型请求时,假设我们以让缓存失效作为缓存的的处理方式,那么又会存在两个情况,
1. 先更新数据库再让缓存失效
2. 先让缓存失效,再更新数据库
前面我们讲过,更新数据库和更新缓存这两个操作,是无法保证原子性的,所以我们需要根据当前业务的场景的容忍性来选择。也就是如果出现不一致的情况下,哪一种更新方式对业务的影响最小,就先执行影响最小的方案。
最终一致性的解决方案:
对于分布式系统的数据最终一致性问题,我们可以引入消息中间件,对于失败的缓存更新存入对应的 broker,并对其进行订阅,当有消息来了,我们可以对由于网络等非程序错误的异常缓存更新进行重试更新:
关于缓存雪崩的解决方案:
当缓存大规模渗透在整个架构中以后,那么缓存本身的可用性讲决定整个架构的稳定性。那么接下来我们来讨论下缓存在应用过程中可能会导致的问题。
缓存雪崩:
缓存雪崩是指设置缓存时采用了相同的过期时间,导致缓存在某一个时刻同时失效,或者缓存服务器宕机宕机导致缓存全面失效,请求全部转发到了DB层面,DB由于瞬间压力增大而导致崩溃。缓存失效导致的雪崩效应对底层系统的冲击是很大的。
解决方式:
- 对过期时间增加一个随机值,避免同一时刻大量key失效。
- 对于热点数据,不设置过期时间。
- 当从redis中获取数据为空时,去数据库查询数据的地方互斥锁,这种方式会造成性能下降。
- 增加二级缓存,以及缓存和二级缓存的过期时间不同,当一级缓存失效后,可以再通过二级缓存获 取。
缓存穿透:
缓存穿透是指查询一个根本不存在的数据,缓存和数据源都不会命中。出于容错的考虑,如果从数据层查不到数据则不写入缓存,即数据源返回值为 null 时,不缓存 null。缓存穿透问题可能会使后端数据源负载加大,由于很多后端数据源不具备高并发性,甚至可能造成后端数据源宕掉。
解决方式
- 如果查询数据库也为空,直接设置一个默认值存放到缓存,这样第二次到缓冲中获取就有值了,而不会继续访问数据库,这种办法最简单粗暴。比如,”key” , “&&”。在返回这个&&值的时候,我们的应用就可以认为这是不存在的key,那我们的应用就可以决定是否继续等待继续访问,还是放弃掉这次操作。如果继续等待访问,过一个时间轮询点后,再次请求这个key,如果取到的值不再是&&,则可以认为这时候key有值了,从而避免了透传到数据库,从而把大量的类似请求挡在了缓存之中。
- 根据缓存数据Key的设计规则,将不符合规则的key进行过滤采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的BitSet中,不存在的数据将会被拦截掉,从而避免了对底层存储系统的查询压力。
关于key过期的实现原理:
Redis使用一个过期字典(Redis字典使用哈希表实现,可以将字典看作哈希表)存储键的过期时间,字 典的键是指向数据库键的指针(使用指针可以避免浪费内存空间),字典的值是一个毫秒时间戳,所以 在当前时间戳大于字典值的时候这个键就过期了,就可以对这个键进行删除(删除一个键不仅要删除数 据库中的键,也要删除过期字典中的键)。 设置过期时间的命令都是使用 pexpireat 命令实现的,其他命令也会转换成 pexpireat 。给一个键设 置过期时间,就是将这个键的指针以及给定的到期时间戳添加到过期字典中。比如,执行命令 pexpireat key 1608290696843 ,那么过期字典结构将如图所示。
过期键的删除有两种方法。 被动方式删除 被动方式的核心原理是,当客户端尝试访问某个key时,发现当前key已经过期了,就直接删除这 个key。 当然,有可能会存在一些key,一直没有客户端访问,就会导致这部分key一直占用内存,因此加 了一个主动删除方式。 主动方式删除 主动删除就是Redis定期扫描国期间中的key进行删除,它的删除策略是: 从过期键中随机获取20个key,删除这20个key中已经过期的key。 如果在这20个key中有超过25%的key过期,则重新执行当前步骤。实际上这是利用了一种概 率算法。 Redis结合这两种设计很好的解决了过期key的处理问题。
布隆过滤器:
布隆过滤器是Burton Howard Bloom在1970年提出来的,一种空间效率极高的概率型算法和数据结构,主要用来判断一个元素是否在集合中存在。因为他是一个概率型的算法,所以会存在一定的误差,如果传入一个值去布隆过滤器中检索,可能会出现检测存在的结果但是实际上可能是不存在的,但是肯定不会出现实际上不存在然后反馈存在的结果。因此,Bloom Filter不适合那些“零错误”的应用场合。而在能容忍低错误率的应用场合下,Bloom Filter通过极少的错误换取了存储空间的极大节省。
bitmap:
所所谓的BitMap就是用一个bit位来标记某个元素所对应的value,而key即是该元素,由于BitMap使用了bit位来存储数据,因此可以大大节省存储空间.
基本思想:
这此我用一个简单的例子来详细介绍BitMap算法的原理。假设我们要对0-7内的5个元素(4,7,2,5,3)进行排序(这里假设元素没有重复)。我们可以使用BitMap算法达到排序目的。要表示8个数,我们需要8个byte。
1.首先我们开辟一个字节(8byte)的空间,将这些空间的所有的byte位都设置为0
2.然后便利这5个元素,第一个元素是4,因为下边从0开始,因此我们把第五个字节的值设置为1
3.然后再处理剩下的四个元素,最终8个字节的状态如下图
4.现在我们遍历一次bytes区域,把值为1的byte的位置输出(2,3,4,5,7),这样便达到了排序的目的
从上面的例子我们可以看出,BitMap算法的思想还是比较简单的,关键的问题是如何确定10进制的数到2进制的映射图
假设需要排序或则查找的数的总数N=100000000,BitMap中1bit代表一个数字,1个int = 4Bytes = 4*8bit = 32 bit,那么N个数需要N/32 int空间。所以我们需要申请内存空间的大小为int a[1 + N/32],其中:a[0]在内存中占32为可以对应十进制数0-31,依次类推:
a[0]-----------------------------> 0-31
a[1]------------------------------> 32-63
a[2]-------------------------------> 64-95
a[3]--------------------------------> 96-127
......................................................
那么十进制数如何转换为对应的bit位,下面介绍用位移将十进制数转换为对应的bit位:
1.求十进制数在对应数组a中的下标
十进制数0-31,对应在数组a[0]中,32-63对应在数组a[1]中,64-95对应在数组a[2]中………,使用数学归纳分析得出结论:对于一个十进制数n,其在数组a中的下标为:a[n/32]
2.求出十进制数在对应数a[i]中的下标
例如十进制数1在a[0]的下标为1,十进制数31在a[0]中下标为31,十进制数32在a[1]中下标为0。 在十进制0-31就对应0-31,而32-63则对应也是0-31,即给定一个数n可以通过模32求得在对应数组a[i]中的下标。
3.位移
对于一个十进制数n,对应在数组a[n/32][n%32]中,但数组a毕竟不是一个二维数组,我们通过移位操作实现置1
a[n/32] |= 1 << n % 32
移位操作:
a[n>>5] |= 1 << (n & 0x1F)
n & 0x1F 保留n的后五位 相当于 n % 32 求十进制数在数组a[i]中的下标
布隆过滤器就是基于这么一个原理来实现的。假设集合里面有3个元素{x, y, z},哈希函数的个数为3。首先将位数组进行初始化,将里面每个位都设置位0。对于集合里面的每一个元素,将元素依次通过3个哈希函数进行映射,每次映射都会产生一个哈希值,这个值对应位数组上面的一个点,然后将位数组对应的位置标记为1。查询W元素是否存在集合中的时候,同样的方法将W通过哈希映射到位数组上的3个点。如果3个点的其中有一个点不为1,则可以判断该元素一定不存在集合中。反之,如果3个点都为1,则该元素可能存在集合中
接下来按照该方法处理所有的输入对象,每个对象都可能把bitMap中一些位置设置为1,也可能会遇到已经是1的位置,遇到已经为1的让他继续为1即可。处理完所有的输入对象之后,在bitMap中可能已经有相当多的位置已经被为1。至此,一个布隆过滤器生成完成,这个布隆过滤器代表之前所有输入对象组成的集合。
如何去判断一个元素是否存在bit array中呢? 原理是一样,根据k个哈希函数去得到的结果,如果所有的结果都是1,表示这个元素可能(假设某个元素通过映射对应下标为4,5,6这3个点。虽然这3个点都为1,但是很明显这3个点是不同元素经过哈希得到的位置,因此这种情况说明元素虽然不在集合中,也可能对应的都是1)存在。 如果一旦发现其中一个比特位的元素是0,表示这个元素一定不存在.