【笔记】Redis

/ 0评 / 0

Redis支持的数据类型:String、List、Hash、Set、Sorted Set(ZSet)共5种基本类型,除此之外还有JSON、Stream、Bitmap、Bitfield、Geospatial、Time series等特殊类型以及HyperLogLog、Bloom Filter、Cuckoo Filter、t-digest、Top-K、Count-min sketch等概率类型。

String:字符串类型(字节序列),可以保存文本、序列号对象或二进制数据,最大不能超过512MB。

基本指令:set、setnx、get、getset、mset、mget,可以作为原子计数器使用,因而支持incr、incrby、decr、decrby

String是动态字符串,可以修改,内部结构类似于Java的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。

List:有序列表,本质上是字符串类型的链表(类似于Java的LinkedList),可以用于实现栈或者队列,最大长度为2^32 - 1。

基本指令:lpush、lpop、rpush、rpop、llen、ltrim、lrange、lmove、lindex、linsert、lset、lrem

阻塞指令:blpop、brpop、bmlpop、blmove

大部分指令的时间复杂度为O(1),lindex方法的时间复杂度为O(n)

ltrim用于保留列表中的[start_index, end_index]区间,其余部分丢弃。

Hash:无序字典,即散列哈希表(类似于Java的HashMap),最多可以保存2^32 - 1个键值对。

基本指令:hset、hget、hgetall、hkeys、hvals、hmset、hmget、hincrby、hdel、hexists、hlen

支持设置键的过期时间:

hexpire(设置以秒为单位的过期时间)、hpexpire(设置以毫秒为单位的过期时间)、

hexpireat(设置以秒为单位的时过期间戳)、hpexpireat(设置以毫秒为单位的过期时间戳)、

httl(读取以秒为单位的过期时间)、hpttl(读取以毫秒为单位的过期时间)、

hexpiretime(读取以秒为单位的过期时间戳)、hpexpiretime(读取以毫秒为单位的过期时间戳)、hpersist(移除过期时间)

Set:无序集合(类似于Java的HashSet),自动去重,没有任何顺序保证,最多可以保存2^32 - 1个元素。

基本指令:sadd、srem、sismember、smismember、smembers、scard、sinter、sdiff、spop

Sorted Set:有序集合(类似于Java的SortedSet和HashMap的结合体),自动去重并且自动排序,顺序通过赋予的score确定,如果A.score > B.score那么A > B。如果A.score = B.score,那么A和B的顺序将通过字典顺序决定。内部采用跳跃列表来实现。

基本指令:zadd、zrem、zrange(按score从低到高返回)、zrevrange(按score从高到低返回)、zrangebyscore、zremrangebyscore

zrank、zrevrank、zincrby

元素的score可以修改,只需再次使用zadd指令即可,时间复杂度为O(logn)

Bitmap:通过一组String类型上的操作来实现位图,本质上是String类型,由于String类型最大不能超过512MB,因此Bitmap的最大容量为2^32位。

基本指令:setbit、getbit、bitcount、bitpos、bitop

bitcount可以统计区间[start, end]中bit=0或者bit=1的个数,但start和end都是字节索引,因此必须是8bit的倍数。

bitpos可以查找区间[start, end]中第一个bit=0或者bit=1的索引,start和end也是字节索引。

Bitfield:通过一组String类型上的操作来实现按位存取,支持任意位长度,本质上是Stirng类型。

基本指令:bitfield、bitfiled_ro

HyperLogLog:概率数据结构,用于模糊集合计数,计数是不精确的(标准差0.81%),但所需的空间和集合元素的数量无关(最多使用12KB),元素添加上和Set相同,自动去重。HyperLogLog本质上是通过String类型保存的,因此可以使用get指令获取其序列化文本,并用set指令反序列化。

基本指令:pfadd、pfcount、pfmerge

pf是HyperLogLog发明人Philippe Flajolet名字的缩写。

最多支持2^64个元素

Geospatial: 描述坐标,并提供相关的操作,例如在给定半径或区域内查找最近的坐标点。

基本指令:geoadd、geodist、geopos、geohash、georadius、georadiusbymember、geosearch

Geospatial的原理是将二维平面上的点映射到一维直线上,二维平面上相近的点映射后在一维直线上也相近,这种算法并非精确的,有一定损失。

Stream:描述流,流是一种仅追加的数据结构,流中的每个entry具有一个唯一ID,按ID检索是O(n)的,其中n是ID的长度,Redis中的流是通过基数树实现的。xadd指令向指定的流增加entry,时间复杂度为O(1),所分配的ID是单调递增的。

基本指令:xadd、xread、xdel、xlen、xinfo、xrange、xrevrange、xread

Redis中的流支持多播,也支持常规的阻塞读(xread),多播下流会将每个消息发送到每个消费者,并且不会删除消息,消息将无限期地保存在流中,除非用户显式删除。流还支持按范围检索,不同的消费者可以访问流中的不同部分,只需使用xrange或者xrevrange指令,Redis支持消费者分组,并提供相关的指令:xgroup *、xreadgroup、xack。每个消费组会有个游标last_delivered_id在Stream数组之上往前移动,表示当前消费组已经消费到哪条消息,每个消费组都有一个唯一的名称,创建消费组时可以指定消费的起始消息ID。同一个消费组可以挂载多个消费者,一个组内的消费者是竞争关系,任意一个消费者读取了消息都会移动游标last_delivered_id。

消息ID的格式是TimestampMillis-sequence,即一个毫秒时间戳和一个序列号,序列号表示该消息是该毫秒内生成的第几条消息。

使用xread指令时,可以把流当成普通的消息队列来使用,此时忽略消费组的存在,block参数可以指定是否阻塞,count参数可以指定消息的数量,如xread block 0 count 1 streams mystream,表示无限阻塞读取mystream流中的1条消息,block是以毫秒为单位的,block 1000表示1秒。如果需要通过某个消费组内的消费者阻塞读,可以使用xreadgroup指令。

创建消费组的指令是xgroup create,如xgroup create mystream mygroup 0,表示创建mystream流的一个消费组mygroup,并且从头开始消费消息,而xgroup create mystream mygroup $则表示只消费新消息,忽略已存在的消息。除此之外还有xgroup createconsumer,xgroup delconsumer,xgroup destroy,xgroup setid指令,分别用于创建组内消费者、删除组内消费者、删除消费组、设置消费组消息ID。

流中的消息是持久化的,Redis重启后内容还在,而且消息的消费是有ACK机制的,保证消息至少由一个客户端进行消费。

JSON:提供JSON标准的完全支持,支持JSONPath语法,以二进制形式保存在树结构,支持类型化原子化的JSON操作,需要安装rejson.so模块。

基本指令:json.set、json.get、json.mset、json.mget、json.del

也可以使用String类型来保存JSON,但String类型无法提供JSON中子对象的获取、索引和搜索,而JSON类型支持这些,并提供原子操作。

Time series:描述时间序列,并可以进行任意时段的查询和统计,需要安装redistimeseries.so模块。

基本指令:ts.add、ts.alter、ts.create、ts.del、ts.get、ts.range

Bloom Filter:概率数据结构,布隆过滤器,可以用固定的内存大小来判断元素的存在性,如果布隆过滤器认为元素不存在,那么元素一定不存在,如果认为元素存在,那么元素可能存在也可能(很小概率)不存在。需要安装redisbloom.so模块。

基本指令:bf.add、bf.exists、bf.card、bf.insert、bf.reserve

在创建布隆过滤器的时候,可以根据需要指定假阳性率、预期容量等,插入时间复杂度为O(k),k是哈希函数的数量,查询时间复杂度为O(k*n),其中n是过滤器数量。

创建布隆过滤器的指令是bf.reserve。errorRate越低需要的空间越大,capacity表示预计的元素数量,如果实际数量超过capacity,误判率会显著上升。默认情况下errorRate=0.01,capacity=100

Cuckoo Filter:概率数据结构,布谷鸟(杜鹃鸟)过滤器,布隆过滤器需要对元素数量可预测才能控制假阳性率,而布谷鸟过滤器则没有这个局限,并且支持删除。布谷鸟过滤器的原理源自布谷鸟哈希方案,通过多个桶来保存元素哈希值,通常不保存完整的元素,而是保存其指纹,指纹的大小决定假阳性率。需要安装redisbloom.so模块。

基本指令:cf.add、cf.exists、cf.count、cf.del、cf.insert、cf.reserve

Top-K:概率数据结构,用于估计集合中的TopK元素,原理是HeavyKeepers算法。需要安装redisbloom.so模块。

基本指令:topk.add、topk.count、topk.list、topk.query、topk.reserve

Redis支持小对象压缩存储,如果某个数据对象很小,那么Redis会使用ziplist来保存它,ziplist是一个紧凑型的字节数组结构,所有数据都保存在同一块连续的内存当中,Hash、List、Set、Sorted Set都会在数据对象很小时使用ziplist来保存。


容器型数据结构的通用规则:create if not exists和drop if no elements,如果指令指定的容器不存在,那么将自动创建,如果指令执行后容器已经没有元素,那么该容器将被回收。Redis所有的数据结构都可以设置过期时间,过期是以对象为单位的,一个has结构的过期是整个hash对象的过期,而不是某个key的过期。如果一个对象已经设置了过期时间,调用不带过期参数的set、getset、*store指令修改它,将会清除它的过期时间,其它在概念上仅仅只是修改对象值而不是替换对象的指令将不会清除过期时间,如incr、rename等。

分布式锁:Redis实现分布式锁的核心指令是setnx(set if no exists),这只能允许一个客户端成功执行set指令,并通过del指令释放锁。为了避免客户端持有锁后崩溃没有释放锁,一般会为锁设置一个过期时间,但不能使用expire指令,因为客户端可能在setnx和expire指令之间崩溃,为了原子化可以使用Redis的扩展参数,即set ... ex ...。

分布式锁的过期时间需要根据业务场景进行调整,过长的过期时间意味着崩溃时恢复的速度更慢,过短的过期时间意味着客户端可能在业务操作完成之前锁就过期了,此时其它客户端将持有锁,引起并发冲突。解决的方法是锁续命,设置一个适当小的过期时间并由持有锁的客户端按需重置过期时间,通常需要使用Lua脚本来保证原子性。

Redis也可以实现可重入的分布式锁(类似于Java的ReentrantLock),只需要记录每个客户端获取锁的次数即可,使用ThreadLocal变量。

可以使用zset来实现限流,每个用户使用一个zset来记录其行为,通过score来记录时间,这样就可以通过zremrangebyscore指令来保留特定时间窗口内的记录,并且通过zcard指令来获取用户在该时间窗口内行为的数量,这是一种计数器限流方法。

public class CounterRateLimiter {
    private Jedis jedis;

    public CounterRateLimiter(Jedis jedis) {
        this.jedis = jedis;
    }

    public boolean tryAct(String userId, String actId, int period, int maxCount) {
        String key = String.format("rl:%s:%s", userId, actId);
        long nowTs = System.currentTimeMillis();
        Pipeline pipe = jedis.pipelined();
        pipe.zadd(key, nowTs, Long.toString(nowTs));
        pipe.zremrangeByScore(key, 0, nowTs - period * 1000);
        Response<Long> count = pipe.zcard(key);
        pipe.expire(key, period + 1);
        pipe.sync();
        pipe.close();
        System.out.println(count.get());
        return count.get() <= maxCount;
    }
}

漏斗限流:水(即请求)从进水口进入到漏桶里,漏桶以一定的速度出水(请求放行),当水流入速度过大,桶内的总水量大于桶容量会直接溢出,请求被拒绝。

进水口的速率是任意的(取决于用户行为),但出水口的速率是固定的,而且漏桶的容量也是固定的。漏斗限流可以使用Redis来实现,但需要保证操作的原子性,可以使用第三方模块Redis-Cell。

public class FunnelRateLimiter {

    private int capacity;
    private int leftSpace;
    private int outRate;
    public FunnelRateLimiter(int capacity, int outRate) {
        this.capacity = capacity;
        this.leftSpace = capacity;
        this.outRate = outRate;
    }

    private class Funnel {
        private int capacity;
        private int leftSpace;
        private int outRate;
        private long lastOutTs;

        public Funnel(int capacity, int outRate) {
            this.capacity = capacity;
            this.leftSpace = capacity;
            this.outRate = outRate;
        }

        private void out(){
            long ts = System.currentTimeMillis();
            long period = ts - lastOutTs;
            long outSpace = period / 1000 * outRate;
            if(outSpace < 1) {
                return;
            }
            this.leftSpace += outSpace;
            this.lastOutTs = ts;
            if(this.leftSpace > capacity) {
                this.leftSpace = capacity;
            }
        }

        private boolean watering(int requireSpace){
            if(leftSpace > requireSpace){
                leftSpace -= requireSpace;
                return true;
            }
            return false;
        }
    }

    private Map<String, Funnel> map = new HashMap<>();

    public boolean tryAct(String userId, String actId) {
        String key = String.format("rl:%s:%s", userId, actId);
        Funnel funnel = map.getOrDefault(key, null);
        if(funnel == null) {
            funnel = new Funnel(this.capacity, this.outRate);
            map.put(key, funnel);
        }
        return funnel.watering(1);
    }
}

除了计数器限流和漏斗限流之外,还有令牌桶限流:用户操作之前必须先获取令牌,令牌桶以一定速率放入新的令牌,令牌桶的容量是固定的,如果用户申请令牌的速率高于令牌桶放入新令牌的速率,那么用户将耗尽令牌并被拒绝。

Redis提供了一组指令来扫描数据,可以使用keys regex来列出所有满足正则表达式regex的键名,这个指令的时间复杂度是O(n),但使用这个指令风险很大,因为Redis是单线程的,如果满足的键非常多,那么Redis在扫描的过程中将无法处理外部请求。为了解决这个问题,可以使用scan指令。

scan指令的时间复杂度也是O(n),但它是分步执行的,每一次执行返回一定数量的结果并返回一个游标,下次执行时传入该游标即可继续执行。每次执行返回的数量通过count参数来指定。第一次执行scan指令时,游标传入0,一旦scan指令返回为0,表示扫描过程已经完成。

scan指令返回的结果可能会重复,因此需要客户端去重,并且如果遍历过程中数据被修改,scan无法保证返回的结果可以反映该修改。

scan指令并不保证每次返回结果的数量一定是count,实际上count参数仅仅是一个数量上的提示,默认count=10,多次执行可以传入不同的count,只要游标对应即可。

可以通过match参数来匹配正则表达式。

JedisPool pool = new JedisPool("localhost", 6379);
try (Jedis jedis = pool.getResource()) {
    ScanResult<String> res = jedis.scan(String.valueOf(0), new ScanParams().count(10));
    System.out.println(res.getResult());
    System.out.println(res.getCursor());
}

除了scan指令之外,还有一些特定于数据结构的指令,如sscan、zscan、hscan,用于扫描这些容器内的元素。

如果Redis存在长度非常大的key,那么集群中复制时将会耗费比较长的时间,这称为Big Key问题。通过scan指令找出Big Key是比较麻烦的,可以使用redis-cli --bigkeys来扫描出各个类型的Big Key或者使用第三方RDB分析工具,找出Big Key之后可以对其拆分、压缩或清理。


Redis是单线程程序(Node.js、Nginx也是单线程),为什么Redis这么快呢?第一个原因是Redis将所有数据都存放在内存中,所有的运算都是内存级别的运算。

第二个原因是Redis采用IO多路复用的方式处理客户端的并发请求。

在没有IO多路复用之前,是通过BIO、NIO来实现的。BIO全称Blocking IO即同步阻塞IO,程序发起读写操作时必须阻塞等待读写完成,如read操作必须等待接收到数据后才能返回,而write操作则将写入到缓存区,如果缓存区没有足够的空间那么也必须等待缓存区有足够的空闲空间后才能返回。在BIO方式下,一个IO请求必须等待上一个IO请求完成后才能开始。

NIO全称Non-Blocking IO即同步非阻塞IO,程序发起读写操作时不必阻塞,而是能读多少读多少、能写多少写多少,如read操作将读取套接字分配的读缓冲区内的可用数据,如果读缓冲区内没有可用数据可以读,read操作将直接返回,同理write操作最多写入套接字分配的写缓存区的数据字节数,read和write操作都通过返回值告诉调用方其实际读取或写入的字节数。

在NIO方式下,一个完整的IO请求需要多个IO调用来完成,这就需要一种事件轮询机制,最经典的就是select函数,select函数可以同时监听多个套接字,当事件(如数据到达)发生时作出响应,数据的传输通过管道(channel)来实现,由于read和write操作都不需要阻塞,因此单个线程就可以同时服务多个套接字,虽然本质上这些操作都是串行的,但非阻塞保证了线程可以快速切换套接字,同时select函数具有一个timeout参数,如果没有任何事件到达那么最多等待timeout时间,在这段时间内线程是阻塞的。

上述NIO实现的三大核心就是select、buffer和channel。目前基于select函数的多路复用API已经很少使用,最流行的是epoll(linux)和kqueue(FreeBSD、Mac OSX)。除了BIO和NIO之外,还有一种AIO实现,全称Async IO即异步非阻塞IO,程序发起读写操作后无论是否可以执行都是立即返回,对于read操作即便存在可用数据也不会等待读取完成,对于write操作即便写缓冲区有足够空间也不会等待写入完成,所有IO调用都是零阻塞的,而IO调用的结果是在随后通过另一种机制来告诉调用方的,例如通过回调函数或者事件通知。在AIO方式下,程序可以随时发起IO操作而不必等待上一个IO操作完成。

在Redis中,会为每个客户端套接字关联一个指令队列和一个响应队列,并使用IO多路复用来处理指令,Redis采用的通讯是协议是RESP(Redis Serialization Protocol),RESP是一个纯文本协议而不使用二进制数据,这是因为Redis的作者认为性能的瓶颈不在于网络流量。

既然是单线程,那么Redis如何执行定时任务呢?Redis会将定时任务保存在最小堆中,堆顶是最快需要执行的任务,在每个循环周期内Redis都会对最小堆中已经到时间的任务进行处理,处理完毕后将堆顶任务尚需等待的时间记录这下,这个时间也是Redis能够搁置定时任务的最长时间,该时间即是Redis下次执行select函数的timeout时间。

Redis效率高的第三个原因是其管道机制,客户端和服务端之间的管道用于传输RESP指令,而客户端可以通过对管道中的指令进行重排以大幅节省IO时间,这对于高并发情况下管道中指令特别多的时候效率提升很明显。为什么指令的重排可以提高效率呢?因为读操作和写操作所需的时间是不同的,写操作只需要写入到缓冲区即可返回,服务端会在之后通过IO多路复用处理缓冲区中的数据,但读操作就不同了,如果读缓冲区中没有想要读的数据,那么读操作就必须等待服务端将数据写入到读缓冲区,管道指令的重排可以在不改变语义的情况下尽量减少读操作。


Redis的数据全都存放在内存中,如果发生宕机那么数据就会丢失,因此需要一种持久化机制。Redis支持两种持久化机制,分别是RDB快照和AOF日志,Redis默认开启RDB模式。

RDB即Redis Database,通过定时对当前数据库进行快照并保存在二进制文件(.rdb文件)中,快照可以自动定时触发,也可以手动通过bgsave指令触发,Redis不会主线程上进行快照,而是通过fork一个新的线程进行快照,为了避免快照时数据发生修改,Redis使用写时复制(Copy On Write)机制,当主线程想要修改数据库时它需要对这部分数据进行复制,并在副本上进行修改,这样的话子线程看到的数据就是一个固定的状态。

子线程会将快照保存到某个临时文件,当子线程完成快照之后会通知主线程,主线程会将快照过程中发生的修改写入到这个临时文件中,最后重命名为最终的RDB文件。

RDB可以在redis.conf配置文件中进行配置,save 900 1表示如果900秒内有至少一个key的值发生变化,则触发快照,如果要关闭快照,只需要写成save ""即可。

其它相关配置:

除了bgsave指令之外,还可以使用save指令,该指令触发快照并阻塞主线程,注意执行flushall清空数据库后也会生成一个空的RDB文件。

AOF即Append-Only File,通过日志的方式记录数据库执行过的操作,宕机重启后可以通过重放日志文件来恢复数据库状态,默认情况下AOF是不开启的。

AOF可以在redis.conf配置文件中进行配置,默认是appendonly noappendfilename "appendonly.aof",AOF文件中保存的格式和RESP是一样的。

在Redis中,服务器收到写请求后,都会添加一条记录到预先分配的缓冲区aof_buf中,这个缓冲区将定期通过write系统调用写入到内核缓存中然后调用fsync进行同步,写入的策略由appendfsync配置来决定,默认是appendfsync everysec

Redis支持对AOF文件进行重写,这是通过对现有的AOF文件进行冗余记录的精简来实现的,重写不会损失数据状态,可以通过bgrewriteaof指令来手动触发,也可以通过下列配置主动触发,重写是通过fork子线程完成的。

注意Redis是先执行指令再添加日志,因此即便是always也并不能保证数据不会丢失。子线程在重写时具有自己的AOF重写缓冲区,即aof_rewrite_buf,如果主线程在重写的过程中收到了新的写入请求,它同时将其日志写入到aof_buf和aof_rewrite_buf中,这样子线程就不会错过这些日志。

Redis 4.0提供了混合持久化方案,可以通过aof-use-rdb-preamble yes开启,默认是开启的。一旦开启之后,Redis重启时会先加载RDB的内容,然后再重放增量的AOF日志,此时重写AOF的子线程会首先将数据库写入到RDB文件中,然后再写入增量的AOF。

如果同时开启了AOF和RDB机制,那么Redis重启时将优先使用AOF。


Redis支持事务,但这个事务只保证隔离性,由于Redis是单线程程序,因此这个隔离性指的就是事务中的指令会一连串执行,而不会有事务外的指令插入其中,Redis的事务是不具有原子性的,事务中某个指令出错后Redis依旧会继续执行事务后续的指令。

Redis事务开启的指令是multi,执行的指令是exec,中止的指令是discard,事务中的操作指令在multi和exec之间。

Redis支持乐观锁,可以通过watch指令来为某个key设置乐观锁,如果事务执行(exec)时,该key的值已经被修改,exec指令会返回NULL回复客户端提示事务执行失败,注意watch指令必须在multi指令之前使用,不能在multi和exec之间使用watch。


Redis支持集群模式,主要有三种模式:主从复制模式、哨兵模式、集群模式。

主从复制模式即Replication但不支持Shard,主节点和从节点同步同一个数据库,Redis保证主从节点的最终一致性,Redis通过传输指令流进行同步,主节点会将影响数据库状态的指令记录在本地Buffer中,然后异步地传输给从节点,从节点同步执行这些指令并向主节点反馈自己的同步偏移量。

由于Buffer是有限的,如果主从延迟过高,那么从节点缺少的指令可能已经被覆盖,此时主节点必须进行快照同步,生成快照(.rdb)并发送给从节点,从节点使用快照全量加载,再同步执行后续的指令流,快照同步的过程中主节点仍在不断执行新的指令,因此要合理设置Buffer的大小,防止循环快照同步。

Redis支持无盘复制,即生成的快照直接通过套接字传输给从节点,而不需要等待其写入磁盘后再传输。

为了开启主从复制模式,需要在从节点配置中使用replicaof 127.0.0.1 6379配置,主节点不需要额外配置。

如果主节点设置了密码,需要在从节点配置中使用masterauth <password>配置。

如果主节点没有设置密码,主节点配置需确保protected-mode no

其它相关配置:

在主从复制模式中,主节点需要承担所有写请求,如果主节点发生故障,需要手动切换到从节点。可以通过info replication指令或者role指令来查看当前的主从关系。

哨兵模式即Redis Sentinel,哨兵是一个特殊的节点,本质上是一个反向代理,客户端将请求发送到哨兵节点,并由哨兵将这些请求导向不同的Redis节点。

哨兵模式和主从复制模式的区别仅仅是多了哨兵节点,因此先搭建一个主从复制模式,然后增加哨兵节点即可。哨兵节点可以由单独的redis-sentinel程序启动,或者通过redis-server --sentinel启动,哨兵的默认端口是26379。

为了保证可用性,至少需要3个哨兵节点。

在哨兵节点的配置文件(如sentinel.conf)中需要增加如下配置:

注意quorum只决定宕机检测,但主节点切换必须得到超过一半的哨兵节点同意:在5个哨兵节点、quorum=2的情况下,如果2个哨兵节点认为主节点崩溃,那么将试图触发主节点切换,但在此之前哨兵必须投票,只有超过一半的哨兵投票后才会开始主节点切换,这个例子中需要3个哨兵投票出其中一个哨兵,该哨兵将负责进行主节点切换。因此如果发生网络分区那么少数部分将永远无法进行主节点切换,并且如果3个以上的哨兵节点都崩溃了,那么整个集群都无法进行主节点切换。

一个哨兵节点可以监控多个主节点,注意哨兵不需要监控子节点。以3个哨兵监控同一个主节点,这个主节点具有一个从节点,且quorum=2为例,如果主节点宕机,只要有2个哨兵认为主节点崩溃,那么就会触发主节点切换,只要2个哨兵投票,从节点就会升级为主节点。

对于哨兵节点(Sentinel)、主节点(Master)、从节点(Slave)的放置会决定集群的容错能力:

+----+         +----+
| M1 |---------| R1 |
| S1 |         | S2 |
+----+         +----+

在上述配置中,有2个哨兵节点S1和S2,S1和M1位于同一数据中心,R1和S2位于同一数据中心,这种配置的容错能力是有缺陷的,任何一个数据中心的失效都会导致某一个哨兵节点宕机,此时只剩下一个哨兵节点是无法触发主节点切换的,因为在这种情况下必须要有2个哨兵节点投票才行。一个合理的配置是:

       +----+
       | M1 |
       | S1 |
       +----+
          |
+----+    |    +----+
| R2 |----+----| R3 |
| S2 |         | S3 |
+----+         +----+

注意哨兵模式并不能保证数据不会丢失,如果发生网络分区,访问少数部分的客户端的写入将会丢失,可以通过如下配置解决:

如果设置了上述配置,少数部分中的主节点无法接收到来自足够的副本的响应,因此将停止处理写请求,此时客户端可以访问多数部分中的主节点。

哨兵模式和主从模式一样,仅支持Replication,不支持Shard。

集群模式即Redis Cluster,是一种去中心化的分布模式,既支持Replication也支持Shard,可以将key分布在不同的节点当中,Redis所使用的协议类似于Raft+Gossip,是二进制协议,每个节点都会开放两个TCP套接字,一个和客户端进行交流(如端口6379),另一个和集群中的其它节点进行交流(如端口16379)。

Redis Cluster将数据分为16384个槽位,每个节点负责其中一部分槽位,槽位的信息保存在每个节点中,默认会对key使用CRC16算法进行hash,然后对hash值取余16384来得到key对应的槽位。不过Redis Cluster也支持为特定的key嵌入tag标记,以强制该key挂载到某个特定的槽位。

Redis Cluster并不保证强一致性,由于Redis的复制是异步的,因此只能保证最终一致性,而且仍会有数据丢失的风险。

集群模式相关的配置如下:

以上述配置为例,这个最差限度是(15000 * 10) + 10000 = 160000毫秒,即160秒,如果主节点宕机,那么最后一次和主节点交流以来过去的时间大于160秒的话从节点就不能升级为主节点,将从节点有效性因子设置为0可以保证该从节点永远可以升级为主节点。

为了运行Redis Cluster,最少需要3个主节点,Redis推荐3+3的配置,即3个主节点+3个从节点,每个主节点拥有一个从节点。可以使用如下指令启动Cluster(在此之前确保所有节点都已经运行,并且按上述配置开启集群模式):

redis-cli --cluster create 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 127.0.0.1:7006 --cluster-replicas 1

其中--cluster-replicas表示每个主节点拥有一个从节点(副本)。


Redis中的info指令可以用于获取当前实例的运行状态,包括9个模块:server、clients、memory、persistence、stats、replication、cpu、cluster、keyspace。

使用info指令可以一次性获取9个模块的信息,或者使用info server这种格式来获取单独某个模块的信息。

info stats可以查看Redis每秒执行多少次指令,变量为instantaneous_ops_per_sec,可以使用monitor指令开启监控模式,以查看哪些数据被高频访问。

info clients可以查看Redis连接了多少客户端,变量为connected_clients,可以使用client list指令列出所有的客户端链接地址。另一个变量rejected_connections表示因超过最大连接数而拒绝的客户端连接次数,如果这个数字很大,说明maxclients参数可能太小了,默认这个参数是10000,但注意Redis需要创建文件来维持客户端连接,如果Redis创建文件的数量被限制,那么最大连接数无法到达maxclients。

info memory可以查看Redis内存占用多大,如下变量:

info replication可以查看Redis当前的主从复制状态和复制积压缓冲区信息,role表示当前Redis节点的角色(master或者salve),connected_slaves表示当前连接的从节点数量。repl_backlog_size为复制积压缓冲区的大小,可以通过repl-backlog-size参数调整,默认是1MB,而repl_backlog_first_byte_offset和repl_backlog_histlen可以查看当前的缓冲占用情况。可以通过info stats中的sync_partial_err变量来决定是否需要扩大缓冲区,这个变量表示主从半同步复制失败的次数。


Redis支持过期时间,会将设置了过期时间的key放入一个独立的字典中,并定时遍历这个字典来删除过期的key,Redis使用惰性删除策略,只有在客户端访问key的时候才检查是否过期,如果过期就删除该key,除此之外Redis每秒进行10次过期扫描,并强制每次扫描不会超过25ms。

Redis的过期时间误差在0-1ms之内。

从节点不会进行过期扫描,而是通过主节点在key过期时发送del指令给从节点,由于Redis的复制是异步的,因此主节点中的某些key过期了,仍可能在从节点中访问得到。

Redis设置过期时间的指令是expire(秒)或pexpire(毫秒),可以通过ttl(秒)或pttl(毫秒)指令获取某个key的过期时间。

注意NX、XX、GT、LT仅在Redis 7.0.0以上版本支持。

如果要移除某个键上的过期时间,可以使用persist指令。

为了保证原子性,可以使用Redis的扩展参数来实现set指令和expire指令作为一个整体执行:

除了set nx(即setnx)之外还有set xx(即setex)、set get(即getset),除了ex、px之外还支持exat、pxat指定过期时间戳。

默认情况下,set指令会移除键上的过期时间,如果要保留过期时间,可以使用set keepttl。

getex指令也可以使用ex、px、exat、pxat、persist参数,即在get的同时设置键的过期时间。

getdel指令可以在get之后删除该键。

del指令可以用于删除某个键,如果被删除的key是个非常大的对象那么会导致线程阻塞,可以使用unlink指令惰性删除key,回收过程将有后台线程处理。

flushdb可以用于清空当前数据库,flushall指令可以用于清空所有数据库,在其后增加async参数可以由后台线程执行,注意执行这两个指令前注意备份RDB和AOF文件。


Redis使用maxmemory参数来限制内存最大占用,如果实际内存超过这个限制后,Redis将执行特定的淘汰策略,如果不设置maxmemory参数(默认),Redis将在到达堆限制时异常退出。淘汰策略(共6种)由参数maxmemory-policy配置,如maxmemory-policy noeviction


如何保证Redis缓存和数据库的一致性呢?由于网络的原因,无论是先更新数据库再更新缓存,还是先更新缓存再更新数据库都可能会导致缓存和数据库的数据不一致,这是因为没有附加机制的情况下无法确保数据库更新和缓存更新的顺序是一致的:

更常见的方法是:

写请求使用删除缓存而不是更新缓存的主要原因在于删除是幂等的,而更新不是幂等的,详见Memcached(Facebook)。在集群的环境下,仅仅由读请求来触发缓存失效是不够的,还需要一个后台机制来失效缓存,例如通过在事务中嵌入相关的缓存信息,并由数据库层在更新数据库后失效相关的缓存。

为什么写请求先更新数据库再删除缓存,而不是先删除缓存再更新数据库呢?实际上仅靠这两种该方法都无法保证缓存和数据库一定一致:

考虑到更新缓存的速度远远高于更新数据库,因此先更新数据库再删除缓存的方法更好,因为很难出现更新缓存慢于某个数据库更新的情况。

如果对缓存一致性要求比较高,可以使用锁机制或者Lease机制来避免Stale Set问题,缓存服务器通过锁或者Lease来识别出哪个缓存更新操作是最新的。


zset的底层是通过跳跃列表实现的,由于Redis对小对象进行压缩,因此如果列表成员数小于128且每个成员的字符串长度小于64字节,Redis会使用压缩列表来存储zset,否则使用跳跃列表。

跳跃列表是一个多层链表,每一层由一个链表实现,最底层的链表存储所有元素,上层存储的元素是下层的子集,越往上层元素数量越少。每一层的链表中的结点保存两个指针,一个指向同一层的下一个结点,另一个指向下一层中存储同个元素的结点。

zset背后的跳跃列表在每个结点保存元素的score值及其value,并根据score值对结点进行分层和排序,插入、查找、删除的时间复杂度都是O(logn)。查找过程就是从顶层开始,根据score值向右或向下层查找,本质上跳跃列表中的结点形成的是一个扁平的二叉树结构。

 

 

Leave a Reply

Your email address will not be published. Required fields are marked *