Redis总结

总体架构

Redis Cluster采用无中心结构,每个节点都保存数据和整个集群的状态
每个节点都和其他所有节点连接,这些连接保持活跃
使用gossip协议传播信息以及发现新节点
redis节点不作为client请求的代理,client根据节点返回的信息重定向请求

redis是单线程结构,避免了并发对数据结构加锁引起的额外性能损耗。而且redis的性能瓶颈是在网络上,单线程就够用。Redis采取纯内存操作,采用非阻塞IO多路复用(操作系统提供select, epoll, evport, kqueue等机制)。

Redis 提供了多种不同级别的持久化方式:

  • RDB持久化可以在指定的时间间隔内生成数据集的时间点快照(point-in-time snapshot)。RDB模式可能需要调整Linux的内核参数。这主要是因为fork子进程时,需要申请巨大的内存空间(大小和父进程一致),但是由于linux的写时复制机制,这些内存实际上都不需要被使用。所以内存不会被使用。但是内核不知道这件事情,会因为该内核参数向linux进程say no。
    https://blog.csdn.net/houjixin/article/details/46412557
    RDB生成的文件为dump.rdb。如果不作处理,下一次会redis先建立一个临时文件,然后将其替换。

  • AOF 持久化记录服务器执行的所有写操作命令,并在服务器启动时,通过重新执行这些命令来还原数据集。 AOF 文件中的命令全部以 Redis 协议的格式来保存,新命令会被追加到文件的末尾。 Redis 还可以在后台对 AOF 文件进行重写(rewrite),使得 AOF 文件的体积不会超出保存数据集状态所需的实际大小。

显然AOF更不容易丢失数据(一般认为最多只丢失最后一秒的数据),但是慢,占用空间大,。AOF文件也可以设置为同步更新到磁盘,但是这样速度太慢,一般不推荐。

Redis中的数据结构

Redis中包含5种数据类型:STRING、LIST、SET、HASH、ZSET。
5种数据结构最终存储的数据类型实际只有两种:字符和数值,Redis能够区分存储的值是字符还是数字;
key只能是STRING,不要使用特殊字符。

  • LIST的底层实现是ziplist或者linkedlist。
  • SET对象的编码可以是intset或者hashtable。
  • HASH的底层实现可以是ziplist或者hashtable。
  • ZSET使用跳跃表+dict或者是ziplist。

这五种数据结构的存储方式实际上非常重要,帮助redis在内存消耗和效率上达到平衡。

一致性HASH算法

最关键的区别就是,对节点和数据,都做一次哈希运算,然后比较节点和数据的哈希值,数据取和节点最相近的节点做为存放节点。这样就保证当节点增加或者减少的时候,影响的数据最少。

先构造一个长度为2的32次方的整数环(这个环被称为一致性Hash环),根据节点名称的Hash值(其分布为[0, 2的32次方-1])将服务器节点放置在这个Hash环上,然后根据数据的Key值计算得到其Hash值(其分布也为[0, 2的32次方-1]),接着在Hash环上顺时针查找距离这个Key值的Hash值最近的服务器节点,完成Key到服务器的映射查找。

一般的,在一致性哈希算法中,如果一台服务器不可用,则受影响的数据仅仅是此服务器到其环空间中前一台服务器(即沿着逆时针方向行走遇到的第一台服务器)之间数据(即本该映射到该台服务器上的数据被映射到了下一台),其它不会受到影响。

除了用于缓存,一致性hash算法也用于ngnix的负载均衡中。然而,REDIS并没有使用一致性hash算法

一致性HASH有如下缺点:

  • 比如说有Hash环上有A、B、C三个服务器节点,分别有100个请求会被路由到相应服务器上。现在在A与B之间增加了一个节点D,这导致了原来会路由到B上的部分节点被路由到了D上,这样A、C上被路由到的请求明显多于B、D上的,原来三个服务器节点上均衡的负载被打破了。某种程度上来说,这失去了负载均衡的意义,因为负载均衡的目的本身就是为了使得目标服务器均分所有的请求。(解决办法:解决这个问题的办法是引入虚拟节点,其工作原理是:将一个物理节点拆分为多个虚拟节点,并且同一个物理节点的虚拟节点尽量均匀分布在Hash环上。采取这样的方式,就可以有效地解决增加或减少节点时候的负载不均衡的问题。)
  • 为了解决上述问题,需要上千个虚拟节点。这就让客户端完成key到节点的映射难度很大。最合理的做法是采取查找树。

HASH槽

一个 Redis Cluster包含16384(0~16383)个哈希槽,存储在Redis Cluster中的所有键都会被映射到这些slot中,

集群中的每个键都属于这16384个哈希槽中的一个,集群使用公式slot=CRC16(key)/16384来计算key属于哪个槽,其中CRC16(key)语句用于计算key的CRC16 校验和。

例如当前集群有3个节点,槽默认是平均分的:
节点 A (6381)包含 0 到 5499号哈希槽.
节点 B (6382)包含5500 到 10999 号哈希槽.
节点 C (6383)包含11000 到 16383号哈希槽.
当新增或者减少节点时,调整槽的分布即可。虽然影响的数据比一致性HASH多,但是大大减少了数据管理的成本。

负载均衡

Redis 集群中内置了 16384 个哈希槽,当需要在 Redis 集群中放置一个 key-value时,redis 先对 key 使用 crc16 算法算出一个结果,然后把结果对 16384 取模,这样每个 key 都会对应一个编号在 0-16383 之间的哈希槽,redis 会根据节点数量大致均等的将哈希槽映射到不同的节点。

事务

它先以 MULTI 开始一个事务, 然后将多个命令入队到事务中, 最后由 EXEC 命令触发事务, 一并执行事务中的所有命令。
事务可以理解为一个打包的批量执行脚本。这个批量指令并非原子化的操作,中间某条指令的失败不会导致前面已做指令的回滚,也不会造成后续的指令不做。

事务中的常见命令:

  1. MULTI:使用该命令,标记一个事务块的开始,通常在执行之后会回复OK,(但不一定真的OK),这个时候用户可以输入多个操作来代替逐条操作,redis会将这些操作放入队列中。

  2. EXEC:执行这个事务内的所有命令

  3. DISCARD:放弃事务,即该事务内的所有命令都将取消

  4. WATCH:监控一个或者多个key,如果这些key在提交事务(EXEC)之前被其他用户修改过,那么事务将执行失败,需要重新获取最新数据重头操作(类似于乐观锁)。

  5. UNWATCH:取消WATCH命令对多有key的监控,所有监控锁将会被取消。

REDIS事务的特点

  • 不支持回滚!不保证隔离型!事实上,由于REDIS指令串行执行,如果一个事务脚本的所有命令全部在一个server上,也就根本没有什么隔离一说,手动回滚也没问题。但是,你需要保证一个事务脚本的所有命令全部在一个server上

Redis主从模式

一个主服务器可以有多个从服务器。
Redis的自身支持主从复制,只要更改下启动配置即可。从节点可以自动从主节点同步数据,主节点可以自动把读请求转给从节点从而实现读写分离减轻自身压力。但是,主节点如果完蛋了,从节点无法升级为主节点,client也无法自动切换,所以没有高可用的能力。

redis配置从服务器的过程
Redis总结_第1张图片

Redis哨兵

Redis-Sentinel是Redis官方推荐的高可用性(HA)解决方案,当用Redis做Master-slave的高可用方案时,假如master宕机了,Redis本身(包括它的很多客户端)都没有实现自动进行主备切换。Redis-sentinel本身也是一个独立运行的进程,它能监控多个master-slave集群,发现master宕机后能进行自动切换。

  • 监控(Monitoring): 哨兵(sentinel) 会不断地检查你的Master和Slave是否运作正常。
  • 提醒(Notification):当被监控的某个 Redis出现问题时, 哨兵(sentinel) 可以通过 API 向管理员或者其他应用程序发送通知。
  • 自动故障迁移(Automatic failover):当一个Master不能正常工作时,哨兵(sentinel) 会开始一次自动故障迁移操作,它会将失效Master的其中一个Slave升级为新的Master, 并让失效Master的其他Slave改为复制新的Master; 当客户端试图连接失效的Master时,集群也会向客户端返回新Master的地址,使得集群可以使用Master代替失效Master。

redis cluster

Redis3.0版本之后支持Cluster。Redis集群至少要三主三丛,主从不需要配置,系统自动选择。
每个Redis集群中的节点都需要打开两个TCP连接。一个连接用于正常的给Client提供服务,比如6379,还有一个额外的端口(通过在这个端口号上加10000)作为数据端口,比如16379。第二个端口(本例中就是16379)用于集群总线,

(1)领着选举过程是集群中所有master参与,如果半数以上master节点与master节点通信超过(cluster-node-timeout),认为当前master节点挂掉.

(2)下列情况下整个集群不可用(cluster_state:fail),当集群不可用时,所有对集群的操作做都不可用,收到((error) CLUSTERDOWN The cluster is down)错误

  • 如果集群任意master挂掉,且当前master没有slave,集群进入fail状。
  • 如果进群超过半数以上master挂掉,无论是否有slave,集群进入fail状态.

客户端会可以对任意一个redis实例去发送命令,每个redis实例接收到命令,都会计算key对应的hash slot。然后告诉客户端。如果使用下面的指令登陆redis的shell,会实现自动的重定向。

#查看key对应的hash slot
cluster keyslot key
 
#连接客户端时加-c参数可以自动重定向
redis-cli -c

Redis用作消息队列

第一种方案,利用redis的list数据结构。生产者lpush,消费者brpop。
brpop是RPOP命令的阻塞版本: 命令会以从左到右的顺序,访问给定的各个列表,并弹出首个非空列表最右端的项; 如果所有给定列表都为空,那么客户端将被阻塞,直到等待超时,或者有可弹出的项出现为 止;
设置 timeout参数为0表示永远阻塞。

第二种方案,利用redis的发布订阅机制,生产者发布,消费者订阅。但是这个一旦有网络抖动等问题就会丢数,仅使用于日志或者统计。

redis的消息队列没有ACK机制。处理失败要记得手动放回队列。

REDIS用做分布式锁

  • 乐观锁:其实说白了,就是好比一个健身房里只有一台跑步机,在健身房门口有个排号机,每个进健身房的人都得先领一个号码才能进入,如果跑步机上有人,则在一边做做热身、喝喝水,如果跑步机上没人,则确认跑步机上当前显示的号码(上一个用过跑步机的人的号码)是否比自己手持的小,如果小,则可以使用;否则,就意味着过号,而过号在现实中我们的都知道要么走,要么重排,就是不能插队,在系统中也是一样的,通常是返回错误。乐观锁往往用于秒杀抢红包等场景。

https://www.cnblogs.com/cww0814/p/7805854.html

乐观锁的一种方式是利用数据库。数据表中存储数据版本的字段(也就是上一个用过跑步机的人的号码)。我们也可以利用REDIS中的WATCH和事务来实现这个问题。以描述系统为例,首先先WATCH这个KEY(库存),然后读取KEY值,然后MULTI 开启一个事务,修改KEY值(库存-1),然后EXEC触发一个事务。如果期间库存被别人修改,那么就就没有抢到乐观锁。

  1. multi,开启Redis的事务,置客户端为事务态。
  2. exec,提交事务,执行从multi到此命令前的命令队列,置客户端为非事务态。
  3. discard,取消事务,置客户端为非事务态。
  4. watch,监视键值对,作用时如果事务提交exec时发现监视的监视对发生变化,事务将被取消。

下面是一个10件商品的秒杀流程。


public class MyRunnable implements Runnable {
 
    String watchkeys = "watchkeys";// 监视keys
    Jedis jedis = new Jedis("192.168.3.202", 6379);
 
    public MyRunnable() {
    }
 
    @Override
    public void run() {
        try {
            jedis.watch("watchkeys");// watchkeys
 
            String val = jedis.get(watchkeys);
            int valint = Integer.valueOf(val);
            String userifo = UUID.randomUUID().toString();
            if (valint < 10) {
                Transaction tx = jedis.multi();// 开启事务
 
                tx.incr("watchkeys");
 
                List<Object> list = tx.exec();// 提交事务,如果此时watchkeys被改动了,则返回null
                if (list != null) {
                    System.out.println("用户:" + userifo + "抢购成功,当前抢购成功人数:"
                            + (valint + 1));
                    /* 抢购成功业务逻辑 */
                    jedis.sadd("setsucc", userifo);
                } else {
                    System.out.println("用户:" + userifo + "抢购失败");
                    /* 抢购失败业务逻辑 */
                    jedis.sadd("setfail", userifo);
                }
 
            } else {
                System.out.println("用户:" + userifo + "抢购失败");
                jedis.sadd("setfail", userifo);
                // Thread.sleep(500);
                return;
            }
 
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            jedis.close();
        }
 
    }
 
}
  • 悲观锁:还是那个健身房。这次在门口不需要排号机了,而是挂着把钥匙(只有一把),想进去的人必须拿到这把钥匙才行,拿到钥匙的人可以进入,不管是热身、喝水还是跑步都可以,直到他出来把钥匙挂回墙上,下一个才能去争取,拿到的才可以再进去。听着好像有点不人性化,所以悲观锁比较适合强一致性的场景,但效率比较低,特别是读的并发低。乐观锁则适用于读多写少,并发冲突少的场景。
    REDIS的悲观锁主要是通过SETNX来实现。利用REDIS的单线程特性,所谓的加锁就是SETNX向REDIS中放一个key,如果已经存在则返回0,加锁失败。返回1则加锁成功。释放锁是通过删除这个KEY。
        System.out.println("_____________begin______________");
        RLock rLock1= redissonClient.getLock("GDL_HAHA");
        System.out.println(rLock1);
        RLock rLock2 = redissonClient.getLock("GDL_HAHAA");//注意,如果锁的名称相同,虽然getlock返回的是相同的对象,但是实际上是一把锁
        System.out.println(rLock2);
        boolean locked = rLock1.tryLock();
        System.out.println(rLock1.isLocked());
        System.out.println(rLock2.isLocked());
        System.out.println("_____________end______________");

如下代码会产生异常

    @Test
    public void testParallelRedisLock(){

        System.out.println("_____________begin______________");
        redissonClient.getLock("GDL_TEST");
        new Thread(new Runnable() {
            @Override
            public void run() {
                RLock rLock = redissonClient.getLock("GDL_HAHA");
                rLock.tryLock();
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                RLock rLock= redissonClient.getLock("GDL_HAHA");
                rLock.unlock();
            }
        }).start();
    }

Exception in thread "Thread-5" java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread by node id: c1493d67-873a-462f-b26e-4c63dd51736b thread-id: 37
	at org.redisson.RedissonLock.unlock(RedissonLock.java:366)

使用Redis分布式锁的的基本代码是

       String lockKey = key + LOCK_KEY_TAIL;
        RLock lock = redissonClient.getLock(lockKey);
        boolean locked = false;
        try {
            locked = lock.tryLock(15000, 7500, TimeUnit.MILLISECONDS);
            //Do some job here
        } catch (Exception e) {
            log.error("updateRedis Exception key: {} ,reason ,{}", key, reason);
        } finally {
            if (locked && lock.isHeldByCurrentThread()) {// 必须要加上判断是否被当前线程所有,不然超时释放锁后会产生IllegalMonitorStateException异常
                lock.unlock();
            }
        }

在上面的处理方式中,如果获取锁的客户端端执行时间过长,进程被kill掉,或者因为其他异常崩溃,导致无法释放锁,就会造成死锁。所以,需要对加锁要做时效性检测。如果不想等待redis中的内容超时的话,我们在加锁时,把当前时间戳作为value存入此锁中,通过当前时间戳和Redis中的时间戳进行对比,如果超过一定差值,认为锁已经时效,防止锁无限期的锁下去,但是,在大并发情况,如果同时检测锁失效,并简单粗暴的删除死锁,再通过SETNX上锁,可能会导致竞争条件的产生,即多个客户端同时获取锁。

1. C1获取锁,并崩溃。C2和C3调用SETNX上锁返回0后,获得foo.lock的时间戳,通过比对时间戳,发现锁超时。
2. C2 向foo.lock发送DEL命令。
3. C2 向foo.lock发送SETNX获取锁。
4. C3 向foo.lock发送DEL命令,此时C3发送DEL时,其实DEL掉的是C2的锁。
5. C3 向foo.lock发送SETNX获取锁。

所以这样做是不行的。如果锁超时,那我们必须要等待redis自动超时。

还有一种方法,就是利用getset命令。将给定 key 的值设为 value ,并返回 key 的旧值 (old value),当 key 存在但不是字符串类型时,返回一个错误,当key不存在时,返回nil。

进程P4执行 SETNX lock.foo 以尝试获取锁
由于进程P1已获得了锁,所以P4执行 SETNX lock.foo 返回0,即获取锁失败
P4执行 GET lock.foo 来检测锁是否已超时,如果没超时,则等待一段时间,再次检测
如果P4检测到锁已超时,即当前的时间大于键 lock.foo 的值,P4会执行以下操作 
GETSET lock.foo 
由于 GETSET 操作在设置键的值的同时,还会返回键的旧值,通过比较键 lock.foo 的旧值是否小于当前时间,可以判断进程是否已获得锁
假如另一个进程P5也检测到锁已超时,并在P4之前执行了 GETSET 操作,那么P4的 GETSET 操作返回的是一个大于当前时间的时间戳,这样P4就不会获得锁而继续等待。注意到,即使P4接下来将键 lock.foo 的值设置了比P5设置的更大的值也没影响。

使用REDIS做分布式自增ID

比如说淘宝的订单号,肯定不能相同,而且也应该保持一个大致的顺序。REDIS自增操作是原子性的。这个可以让我们轻松搞定这一问题。但是!redis的同一个key只存在一个server(或者一个主从)里,所以可能会有性能问题。可以使用Redis集群来获取更高的吞吐量。假如一个集群中有5台Redis。可以初始化每台Redis的值分别是1,2,3,4,5,然后步长都是5。各个Redis生成的ID为:
A:1,6,11,16,21
B:2,7,12,17,22
C:3,8,13,18,23
D:4,9,14,19,24
E:5,10,15,20,25
事实上,采用时间+UUID的做法也是可以的。

使用REDIS可能遇到的问题

缓存击穿

黑客故意取请求缓存中不存在的数据,导致所有请求都会被堆积到数据库上,引起数据库瘫痪。可以采取异步起一个线程读数据库,预热和更新缓存。也可以采取布隆滤波器,迅速判断出请求所携带的key是否合法。但是这两种方法都有局限性,所以还是建议采取封IP等前端的反DDOS措施。

缓存雪崩

同一时间缓存大面积失效。假设你是批量把数据库中的内容更新到缓存中,并设置了相同的缓存时间,那么到时后,缓存将集体失效,所有请求瞬间全部打入数据库,这显然不合理。建议设置随机的缓存时间。

缓存一致性

缓存一致性是一个非常复杂的问题。议采取先更新数据库,再删除redis缓存的方法。

具体使用

redis在java中可以使用jedis,也可以使用spring封装的jedis(Spring Data Redis)

Redis与Python

主要有以下两个包

[kettle@vm-kvm11559-app bin]$ ./pip list|grep -i redis
redis (2.10.5)
[kettle@vm-kvm11559-app bin]$ ./pip show redis
---
Metadata-Version: 1.1
Name: redis
Version: 2.10.5
Summary: Python client for Redis key-value store
Home-page: http://github.com/andymccurdy/redis-py
Author: Andy McCurdy
Author-email: [email protected]
License: MIT
Location: /DATA/software/anaconda2/lib/python2.7/site-packages
Requires:
Classifiers:
  Development Status :: 5 - Production/Stable
  Environment :: Console
  Intended Audience :: Developers
  License :: OSI Approved :: MIT License
  Operating System :: OS Independent
  Programming Language :: Python
  Programming Language :: Python :: 2.6
  Programming Language :: Python :: 2.7
  Programming Language :: Python :: 3
  Programming Language :: Python :: 3.2
  Programming Language :: Python :: 3.3

Redis实现细节之ZipList

ziplist是一个经过特殊编码的双向链表,它的设计目标就是为了提高存储效率。ziplist可以用于存储字符串或整数,其中整数是按真正的二进制表示进行编码的,而不是编码成字符串序列。它能以O(1)的时间复杂度在表的两端提供push和pop操作。

实际上,ziplist充分体现了Redis对于存储效率的追求。一个普通的双向链表,链表中每一项都占用独立的一块内存,各项之间用地址指针(或引用)连接起来。这种方式会带来大量的内存碎片,而且地址指针也会占用额外的内存。而ziplist却是将表中每一项存放在前后连续的地址空间内,一个ziplist整体占用一大块内存。它是一个表(list),但其实不是一个链表(linked list)。

另外,ziplist为了在细节上节省内存,对于值的存储采用了变长的编码方式,大概意思是说,对于大的整数,就多用一些字节来存储,而对于小的整数,就少用一些字节来存储。我们接下来很快就会讨论到这些实现细节。

Redis总结_第2张图片

  • zlbytes: ziplist的长度(单位: 字节),是一个32位无符号整数
  • zltail: ziplist最后一个节点的偏移量,反向遍历ziplist或者pop尾部节点的时候有用。
  • zllen: ziplist的节点(entry)个数
  • entry: 节点
  • zlend: 值为0xFF,用于标记ziplist的结尾

entry的结构如下
Redis总结_第3张图片

  • prevlengh: 记录上一个节点的长度,为了方便反向遍历ziplist
    • entry的前8位小于254,则这8位就表示上一个节点的长度
    • entry的前8位等于254,则意味着上一个节点的长度无法用8位表示,后面32位才是真实的prevlength。用254 不用255(11111111)作为分界是因为255是zlend的值,它用于判断ziplist是否到达尾部。
  • encoding: 当前节点的编码规则,下文会详细说
  • data: 当前节点的值,可以是数字或字符串

你可能感兴趣的:(java)