redis原理简单介绍
redis 3x版本已支持集群模式,集群之间的各个节点至少需要两个TCP连接:一个是为客户端服务的监听端口,一个用于集群之间各个节点的通信。集群版本并没有用到普通的hash算法来对数据进行分区存储,因为当集群中节点有变动(增加节点或者删除节点)的时候会做rehash,rehash会移动集群中很多的数据,这样当集群中数据较多的时候,效率会比较低(hashmap的实现原理中,如果需要扩容的话就需要rehash),redis采用的是hash slot(hash槽,这里并没有用一致性hash算法)来实现的,部署redis集群的时候,会对集群中的每一个节点的ip做运算:CRC16(ip) % 16384 (CRC算法见:[https://wenku.baidu.com/view/85758f36eefdc8d376ee3256.html][1]),得到一个值后将这个值映射到hash环上。当我们要往redis里面存储一个k-v的时候,redis会对key做计算:CRC16(key) % 16384,计算出来的值也会映射到hash环上的某一个slot,这个数据会存储到其顺时针方向的第一个节点上,获取数据的时候也是先对key做运算,找到其对应的hash slot位置,然后顺时针第一个节点就是其存储节点。
一致性hash算法
对机器ip进行hash(hash(IP) % N,这里的指的是节点IP,N指的是集群节点数量)之后得到一个值,将这个值映射到hash环上面,然后将数据对象的key也做同样的hash算法,不过IP换成对象的key,同样将值映射到hash环上,然后顺时针找到第一个节点进行存储,当有节点增加时,将当前节点逆时针方向的数据转移到新增节点上,当有节点删除时,将当前节点逆时针方向的数据转移到其顺时针的下一个节点。
hash环
hash环其实是一个环形的hash空间,对key做一定的hash算法,然后映射到一个0~(2^32)-1的数字环形空间中
虚拟节点
我们考虑一个问题,当集群中节点数量较少时,比如只有A,B两个节点,这时候很有可能会导致很多的数据存储在某一个节点上。为了解决这个问题,引入了虚拟节点,对A和B各自虚拟出A1,A2,A3,B1,B2,B3节点(一般是在节点IP后面再加上数字来表示虚拟节点),将虚拟节点进行hash计算后分布在hash环上,这样,数据就会更加均匀的分布了(跟买股票基金差不多一个道理,鸡蛋不要放在一个篮子里,均摊风险)
redis过期key的处理
当一个redis的key被设置了过期时间,这个key过期了之后,redis服务器是如何处理这个key的呢?有时候redis服务器内存占用率过高,可能会是什么原因引起的呢?
第二个问题可能原因之一就是因为redis服务器中过期key过多导致。redis key过期的三种删除策略:
1. 被动删除(也成为惰性删除):当读/写一个已过期的key时,会出发惰性删除策略,直接删除掉这个过期key
2. 主动删除:由于惰性删除策略无法保证冷数据被及时删除掉,所以redis会定期删除掉一批已经过期的key
3. 当前已用内存超过maxmemory限定时,出发主动清除策略
redis实际用法
如何运维部署这里不多提及,简单说一下在java中的实际用法。redis支持k-v形式的存储,value可以是String,Hash,List,Set,Sorted set等,我们自己实际项目中用到的是jedis客户端。
1、 生成订单号 & 计数器
如果在同一个jvm中,大并发的情况下生成订单号,我们可以利用加锁(synchronized或者Lock接口或者并发包中的Automatic原子操作都可以)的机制来生成订单号(时间戳+随机数),或者使用线程号+时间戳+随机数也可行。但是如果是集群环境呢?使用synchronized或者Lock接口或者并发包中的Automatic在单个jvm里面都不能达到要求,这时候可以使用redis的原子递增方法来实现:incr,incr方法对key的value进行+1操作,如果key不存在,则value为1,很重要的一点是incr方法的原子性(参照AtomicInteger),如果不能保证原子性,那么会发生什么情况呢?对redis的key做+1操作:
@Test
public void test() throws InterruptedException {
jedisUtil.set("index", "0");
Thread[] threads = new Thread[1000];
for(int i=0;i<1000;i++){
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
int index = Integer.parseInt(jedisUtil.get("index"));
index++;
jedisUtil.set("index", String.valueOf(index));
}
});
threads[i] = thread;
}
for(int i=0;i<1000;i++){
logger.info("当前启动线程为:{}", threads[i].getName());
threads[i].start();
}
Thread.sleep(1000 * 10);
logger.info("缓存中index的最终值为:{}", jedisUtil.get("index"));
}
以上代码在1000个线程中同时运行,最终index的值不一定是1000,极有可能是一个小于1000的值。其中一个原因是,set和get操作并非一组原子性的操作,当线程A get(index)之后对index值做了加1操作,并未set的时候,这时候线程B也get(index),这时候他get的值还是未加1的值,所以出现了小于1000的情况(这里的index++操作也不是原子性的,也会导致index的最终值变小)。解决这个问题的办法就是使用redis的incr或者incrBy方法,incrBy方法可以自定义加多少。代码修改如下:
@Test
public void test() throws InterruptedException {
jedisUtil.set("index", "0");
Thread[] threads = new Thread[1000];
for(int i=0;i<1000;i++){
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
jedisUtil.incrBy("index", 1l); // 修改此处
}
});
threads[i] = thread;
}
for(int i=0;i<1000;i++){
logger.info("当前启动线程为:{}", threads[i].getName());
threads[i].start();
}
Thread.sleep(1000 * 10);
logger.info("缓存中index的最终值为:{}", jedisUtil.get("index"));
}
这样修改之后我们得到的最终值就是1000啦~~~!所以在集群中,并发量高的情况下,可以使用incr或者incrBy方法来生成订单号(订单号的随机数部分),或者作为计数器
2、 分布式锁
何谓分布式锁,在分布式应用中,不同系统分布在不同的主机中,当他们需要争抢同一个资源的时候,且必须互斥的时候,可以设定一把锁,谁拿到了这把锁,谁就可以获得资源,这个锁在分布式应用中一般称为分布式锁。比如我们在调度系统中,集群环境中的任务调度系统在执行某个任务的时候,只能有一台服务器执行成功,这时候我们就用到了分布式锁。redis采用单进程单线程的方式处理客户端请求,将所有客户端请求变成串行执行,3X的集群版本也能保证一致性,所以redis的这个特性就非常适合提供分布式锁的功能。redis提供了setnx方法,全称是set if not exists,即如果不存在就set,当且仅当key不存在的时候才能set成功,并返回1,如果key已经存在了,则返回0,setnx是原子性的操作。比如我们需要在晚上11点由定时任务去拉取保险公司给的保单收益文件,如果多台服务器同时去拉取收益文件做计算是浪费资源,且业务上也可能出错,这时候可以使用setnx方法,设定一个key,比如是syncpolicytask,setnx这个key的时候(value随意设置),哪个应用设置成功了,哪个系统就来执行task,其他应用不执行。伪代码如下:
public static final String KEY = "syncghpolicytask";
if(jedisUtil.setnx(KEY_ORDER_SYNCGHPOLICYTASK, "true") == 0){
return;
}
doSyncghPolicyTask...
<font color=’red’>生产上发生的一个bug:</font>
做保险的时候,有一个赠险的业务需求,就是赠送客户一些一元意外险,旅游险等这样的小保险。当时所有的赠送保险列表是存在数据库中的,前端显示的时候:
a. 先去缓存中查询是否有赠险列表,如果有,直接返回
b. 没有的话就从数据库去查询
c. 将数据库中查询出来的赠险列表更新到缓存中
以上其实是一个缓存使用的基本3个步骤。初期赠险的部分代码如下:
String key = "赠险列表key";
// 如果缓存中存在赠险列表,直接获取返回
if(jedis.exists(key)) {
// jedis客户端返回的是List<String>,需要自己转换成对象,这里省略了
return jedis.lrange(key, 0, -1);
}
// 如果缓存中没有,去数据库里面获取
List<Insurance> insuranceList = getInsuranceListFromDb();
// 把查询出来的赠险列表更新到缓存中
for(Insurance insurance : insuranceList) {
jedis.rpush(key, JSON.toJSONString(insurance));
}
return insuranceList;
以上代码在测试环境没有问题,但是公司线下活动给客户领取赠险的时候发生了非常尴尬的事情,赠险列表出现了多条同样的赠险,排查后发现以上代码在并发量高的情况下会导致缓存并发,设想当A服务器的线程A从数据库中查询出来了赠险列表,但是还未更新到redis中,这时候B服务器的线程B也来了,他去redis查询也是查不到列表的,这时候也会去DB中查,最终导致两个线程都把赠险列表更新到了redis中。由于是集群环境,所以不能使用jdk提供的锁机制来控制并发,在这种情况下,A和B肯定只能有一个线程去更新缓存中的值,所以我们可以用到上面提到的分布式锁来做控制,修改后代码如下:
String key = "赠险列表key";
String lockKey = "分布式锁key";
// 如果缓存中存在赠险列表,直接获取返回
if(jedis.exists(key)) {
// jedis客户端返回的是List<String>,需要自己转换成对象,这里省略了
return jedis.lrange(key, 0, -1);
}
// 如果缓存中没有,去数据库里面获取
List<Insurance> insuranceList = getInsuranceListFromDb();
// 把查询出来的赠险列表更新到缓存中
if(jedisUtil.setnx(lockKey, "true" , 1) == 0) { // 抢夺锁成功才能更新缓存
try {
// 抢夺锁成功之后还需要判断一次,这里是防止在A线程判断完缓存中没有列表然后查询完数据库
// 之后,B线程往缓存中更新了赠险列表,且刚好释放了锁,这时候A拿到了锁又会去更新一次缓
// 存,其实和单例模式中的"双重检测"机制是一样的道理
if(jedis.exists(key)) {
return insuranceList;
}
for(Insurance insurance : insuranceList) {
jedis.rpush(key, JSON.toJSONString(insurance));
}
} finally {
jedis.delnx(lockKey); // 删除key,释放分布式锁
}
}
return insuranceList;
分布式锁不仅仅只有redis能提供,zookeeper其实也能提供,参见zookeeper一章。
3、 实现简单队列功能
消息队列一般采用rabbitmq或者activemq(关于mq的信息可以看下”我用过的rabbitmq”一章),<font color=’red’>消息队列一个非常重要的特点就是不阻塞请求,服务端异步处理,个人觉得可以理解成为一个缓冲的作用</font>,当前端请求量过大的时候,可以使用消息队列来缓冲某一时刻请求量过大对数据库造成的压力。redis提供的队列功能主要靠其list结构的数据存储,通过lpush方法往队列头部塞一个消息数据(即生产者消费者模式中的消息生产者),通过rpop方法从队列尾部获取一个消息并删除该消息,或者使用rpoplpush来消费一个消息,并缓存这个消息,如果消息处理失败,可以从缓存的队列中获取这个消息重新消费。简单代码如下:
producer:
String key = "order:pay";
String msg = " 'orderId':'12345678', 'amount':'1.00' ";
jedis.lpush(key, msg);
consumer:
String key = "order:pay";
while(true) {
String msg = jedis.rpop(key);
// 处理msg逻辑略
}
其实redis的队列功能是非常简单的,轻量级的,健壮性不太好(比如没有做负载,没有监控等等),所以生产上基本都是使用rabbitmq来作为消息队列。
SOA服务中,redis用途广泛,还有很多有趣的用法等着我们去发掘。
[1]: https://wenku.baidu.com/view/85758f36eefdc8d376ee3256.html