Back

Redis

Redis数据结构、架构、常见常见、持久化、主从架构

[TOC]

数据类型及结构

数据类型

具体的分为5种基本类型和3种特殊类型:

String、List(一般当成队列,尽量少使用随机读写)、Hash、Set、ZSet

  • String:简单动态字符串(SDS)
  • List:压缩列表(元素数量小于512,且所有元素的长度都小于64字节) + 双向链表(其他情况使用)
  • Hash:压缩列表(元素数量小于512,且key和value字符串长度都小于64字节) + 哈希表(其他情况使用)
  • Set:数组(带有编码类型字段,所以元素可以使用整型表示,少于512个时使用)+ 哈希表(key为set中的元素,value为null,其他情况使用)
  • ZSet:压缩列表(元素小于128个,且所有元素的长度小于64字节时使用) + 跳表 (其他情况使用)

另外三种扩展类型:

  • Bitmap:位存储,基于String,原理:String类型会保存二进制字节数组,只有0和1两个值,对于这个字节数组的每个bit来表示一个元素的二值状态;
  • HyperLogLog:基数统计,类似set,主要作用是使用少量固定的内存(12KB内存即可统计2^64个不同元素)去存储并识别有大量元素的集合中的唯一元素,能快速算出集合内的元素个数,误差率0.81%;版本2.8.9之后才有
  • Geo:推算地理位置,比如两地之间的距离,方圆几里的人;版本3.2之后才有
  • Stream:5.0之后的版本才有,Stream会在第一次使用 xadd 指令追加消息时自动创建。
    • Consumer Group:消费组,一个消费组有多个消费者,这些消费者是竞争关系;
    • Last_delivered_id:游标,每个消费组的游标,组内任意一个消费者读取了消息都会使游标向前移动;
    • pending_ids:消费者的状态变量,用于维护消费者未确认的id,记录当前已经被客户端读取但还没有被ACK的消息。如果客户端没有ACK,这个变量里面的消息ID会越来越多,一旦某个消息被ACK,它就开始减少,用来确保客户端至少消费了消息一次,而不会在网络传输的中途丢失了没处理。
    • 消息ID:组成方式毫秒级时间戳 - 序号,消息ID也可以自定义,但必须是整数-整数,且能后面加入的消息ID要大于前面的消息ID。Redis本身会记录 lastest_generated_id,防止时间回拨导致ID问题;
    • 消息内容:键值对,类似Hash的结构;

底层数据结构

Redis所有类型有一个顶层的数据结构叫RedisObject,这个RedisObject底层对应着具体对象类型和其编码方式。

之所以有RedisObject对象,是因为每种不同的数据类型有不同的编码方式和结构,Redis必须让每个键都带有类型信息,使得程序可以检查键的类型,还需要根据数据类型的不同编码进行多态处理。

比如:SADD命令只能用于Set,LPUSH命令只能用于List,而DEL、TTL又能用于所有键,要正确实现这些命令,就需要为不同类型的键设置不同的处理方式;另外,同一种数据类型可能由不同的数据结构实现,比如List,底层可能是压缩列表或者双向链表,因此还需要知道数据类型的编码方式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
typedef struct redisObject {
    // 类型,如String、List
    unsigned type:4;
    // 编码方式:SDS、压缩列表
    unsigned encoding:4;
    // LRU - 24位, 记录最后一次访问时间(相对于lru_clock); 或者 LFU(最少使用的数据:8位频率,16位访问时间)
    unsigned lru:LRU_BITS; // LRU_BITS: 24
    // 引用计数,新创建对象时值为1,当对该对象进行共享时值+1,使用完一个对象后值-1,=0时被GC回收
    int refcount;
    // 指向底层数据结构实例,具体的实例,由type和encoding决定
    void *ptr;

} robj;

当执行一个处理数据类型命令的时候,redis执行以下步骤

  1. 根据给定的key,在数据库字典中查找和他相对应的redisObject,如果没找到,就返回NULL;
  2. 检查redisObject的type属性和执行命令所需的类型是否相符,如果不相符,返回类型错误;
  3. 根据redisObject的encoding属性所指定的编码,选择合适的操作函数来处理底层的数据结构;
  4. 返回数据结构的操作结果作为命令的返回值

Redis本身还会预分配一些值对象,比如响应结果OK、ERROR,还有包括0在内,所有小于REDIS_SHARED_INTEGERS(默认值是10k,[0 - 9999])的所有整数,共享对象只能被字典或双向链表这类带有指针的数据结果使用。

简单动态字符串 - SDS

Redis的String类型底层有两种保存形式,当保存的是64位有符号整数时,String类型会保存为一个9字节的Long类型整数;当保存的数据包含字符时,String类型就会用简单动态字符串SDS。

简单动态字符串SDS由三个部分组成:

  • buf:是字节数组,保存实际数据,结束标志位是"/0";
  • len:表示buf已用长度,占4字节;SDS不使用 “/0” 来判断字符串是否结束,而是通过 len 来判断;len 同时也能防止缓冲区溢出;
  • alloc:表示buf的实际分配长度,一般大于len;

lenalloc两个属性,对于修改字符串SDS实现了空间预分配和惰性空间释放两种策略:

  • 空间预分配:当字符串进行扩展时,扩展的内存会比实际需要的多,减少连续字符串增长带来的内存频繁分配。

    当字符串长度小于 1M 时,扩容时双倍扩容,如果超过 1M,扩容一次只会多扩 1M 的空间,默认的最大长度是 512M,可通过配置文件修改。

    执行Append命令的字符串会带有预分配的空间,这些预分配的空间不会被释放,除非对应的key被删除,或者重启后重新载入,才会删除预分配的空间,如果有存在大量预分配的字符串,可以修改配置使其定时释放预分配的空间,更有效的使用内存。

  • 惰性空间释放:当字符串进行缩短时,不会使用内存重新分配的方式来回收多余字节,而只是用alloc 记录这些字节的数量,等待后续使用。

RedisObject包含了8个字节的元数据和一个8字节指针,指针指向具体的数据类型的实际数据所在,

对于String类型的RedisObject:

  • 当保存的是Long类型整数时,RedisObject中的指针直接就是整数数据,不用额外的指针指向整数;
  • 当保存的是字符串时,如果字符串<=44字节,RedisObject中元数据,指针和SDS是一块连续的内存区域,避免内存碎片;
  • 当保存的是字符串时,如果字符串>44字节,RedisObject会给SDS分配独立的空间,并用指针指向SDS;

Redis String RedisObject
Redis String RedisObject

当使用String类型时,且value的类型是String时,如果value的长度太小,可能会出现元数据的大小比数据本身的大小还大,造成额外的内存开销,如果能替换成Long类型,实际存储的大小会大大降低。

哈希表 - Dict

Redis Dict
Redis Dict

无论值是什么类型的,所有的键值对会保存在全局哈希表中,便于快速找到对应的Key,哈希桶只会保存键值对的指针。全局哈希表中的桶每个元素entry由三个8字节指针组成,分别为key、value、next,但实际会占32字节,因为内存分配库jemalloc会分配最接近24的2的幂次数,所以是32,以减少频繁的分配次数。

因此,即使Redis里存在大量数据,也不影响查找的速度,毕竟都是根据Key进行hash就能找到对应的Value,真正有影响的是哈希表的在解决哈希冲突和rehash时带来的阻塞。

Redis的哈希表使用拉链法解决哈希冲突,并通过两个全局哈希表加快rehash的操作。

Redis Dict
Redis Dict

处理全局哈希表有这种操作,Hash的数据结构也是这样的操作,本质是一样的。

rehash触发条件

当Redis生成RDB和AOF重写时,哈希表不会进行rehash。

装载因子:哈希表中所有entry的个数 除以 哈希表的哈希桶个数。

  • 当装载因子>= 1,且哈希表被允许rehash,即此时没有进行RDB和AOF重写,即没有执行或正在执行BGSAVE 命令或者 BGREWRITEAOF 命令

  • 当装载因子>= 5,因为此时数据量已远远大于哈希桶的个数了,此时会立马进行rehash

rehash过程

  1. 默认使用哈希表1,此时哈希表2还没有被分配空间

  2. 当数据增多至需要rehash时,为哈希表2分配空间,大小会比哈希表1大,比如大两倍

  3. 把哈希表1中的数据重新映射并拷贝到哈希表2中

    渐进式rehash:解决大量数据在哈希表1和2之间拷贝,会导致Redis线程阻塞的问题(因为单线程)。

    3.1 拷贝数据时,Redis仍然正常处理客户端请求,每处理一个请求时,从哈希表1中的第一个索引位置开始,顺便将该索引位置上的所有entries拷贝到哈希表2中;

    3.2 等待处理下一个请求时,再顺带拷贝哈希表1中该索引下一个索引位置的entries到哈希表2中;

    通过这两种方式,将一次性的大量拷贝分散到每次请求和等待间隙中。

    3.3 此外Redis本身也有一个定时任务在执行rehash,发生在空闲时间

  4. 释放哈希表1的空间,此时哈希表1的空间被回收,原来的哈希表2变成哈希表1,哈希表1变成哈希表2

渐进式rehash过程中,如果有新的键值对存进来,Redis会把该键值对放到哈希表2中,确保哈希表1里的键值对只会减少;如果是有查询进来,则会先查询哈希表1,再查询哈希表2。

底层的两种实现

对于Hash数据类型,底层有压缩列表和哈希表两种实现,当底层使用压缩列表时,存储的时候是以key、value的顺序存储的,新增的键值对保存在表尾;使用压缩列表时,底层是无法利用index查找,只能遍历查找。

当Hash 集合中写入的元素个数超过了 hash-max-ziplist-entries,或者写入的单个元素大小超过了 hash-max-ziplist-value,Redis 就会自动把 Hash 类型的实现结构由压缩列表转为哈希表,且之后不可逆。

压缩列表 - ZipList

Redis Ziplist
Redis Ziplist

本质上是一个数组,数组中每一个元素保存一个数据。压缩列表的结构:

  • 表头有三个字段:zlbytes记录整个压缩列表占用的内存字节数,可算出列表长度;zltail记录列表最后一个entry的偏移量,可算出尾节点到列表起始地址的字节数,快速完成pop操作;zllen记录列表中的entry个数,只占用2bytes,如果压缩列表中entry的数目小于 65535 ,2的16次方,该字段存储的就是实际的entry数,如果超过或等于该值,则实际数量要遍历entry才能得到;
  • 每个节点元素entry有四个字段:previous_entry_length记录前一个节点的长度;encoding记录content的数据类型和长度;content保存元素的值;len表示自身长度。
  • 表尾有一字段:zlend终止字符,用于标记列表末端。

数据类型List、Hash、ZSet都有使用到压缩列表。

压缩列表可以存储字符串或整数,存储整数时采用整数的二进制而不是字符串形式存储,能在O(1)时间复杂度下完成 list 两端的 push 和 pop 操作,由于每次操作都需要重新分配压缩列表的内存,so实际复杂度跟其内存使用量有关。

压缩列表的优势在于存储结构,普通数组要求数组的每个元素的大小相同,但是当我们需要在每个元素中存储大小不同的字符串时,就会浪费存储空间,压缩列表就是会把每个元素多余的空间进行压缩,让每个元素紧密相连,再为每个元素增加一个长度,用于计算下一个元素在内存中的位置。

另外,在内存的地址查找时,在查找第一个和最后一个的时候有优势,可以利用表头的三个字段查到,是O(1),但是因为存储紧凑的缘故,查找其他元素只能遍历,是O(n)。

压缩列表或者数组主要因为其数据结构紧凑,节省空间,避免内存碎片,提升内存利用率,线性顺序存储,对CPU高速缓存支持友好。对于查找的时间复杂度的优势提升不大。但是,由于压缩列表比较紧凑,在新增更新删除操作时可能会引发连锁更新,此时最坏为O(n^2),但触发概率相对较低,利大于弊。

快表 - Quick List

3.2版本后才添加,之前的版本是双向链表,本质上是一个链表,只是每个节点都是一个压缩列表。

跳表 - Skip List

本质是为链表增加索引,建立多层索引,查找时从顶层的索引开始逐步往下层找,最终定位到元素,适用于范围查询的场景。查找的时间复杂度为O(logN)

参考:Redis底层数据结构详解

Skip List和各种平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做单个key的查找,不适宜做范围查找。

在做范围查找的时候,平衡树比Skip List操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。

平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。

从内存占用上来说,Skip List比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而Skip List每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。

查找单个key,Skip List和平衡树的时间复杂度都为O(log n),大体相当;而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近O(1),性能更高一些。所以我们平常使用的各种Map或dictionary结构,大都是基于哈希表实现的。

参考:https://www.jianshu.com/p/8ac45fd01548

时间复杂度

对各种数据类型操作的时间复杂度取决于底层的数据结构,对于Set,虽然名字看起来是集合,但由于底层是哈希表 + 数组,因此在SREM、SADD、SRANDMENBER命令时,时间复杂度都是O(1)

  • 数据类型的范围查询,都需要进行遍历操作,一般都是比较耗时的。比如List的LRANGE、ZSet的ZRANGE、Set的SMEMBERS等

  • 数据类型的统计查询,比如查看某数据类型的元素个数,时间复杂度是O(1),因为数据结构本身就有记录了。

与Memcached的区别

  • Redis支持存储多种数据类型:string、list、hash、set、zset;而Memcached只支持string;

  • 默认情况下,Redis key的最大上限是512M,string类型、list、hash、set、zset每个元素的value上限是512M,可以通过proto_max_bulk_len的值进行修改;memcached key的上限是50个字符,value最大值是1M;

  • Redis支持持久化:RDB快照和AOF日志;Memcached不支持持久化;

  • Redis支持事务,使用MULTI 和 EXEC命令,支持流水线式发送命令 ;Memcahced不支持事务,命令只能一条一条的发,当然这里的事务并不满足传统意义上的ACID;

  • Redis-Cluster 支持分布式存储,可以多台Redis服务器存储同样的数据;Memcached是以一致性哈希算法实现分布式存储,即多台Memcached服务器,Memcached根据查找的key计算出该数据在哪台服务器上;

  • 在 Redis 中,并不是所有数据都一直存储在内存中,可以将一些很久没用的 value 交换到磁盘;

    Memcached 的数据则会一直在内存中,Memcached使用固定空间分配,将内存分为一组大小不同的slab_class,每个slab_class又分为一组大小相同的slab,每个slab又包含一组大小相同的chunk,根据数据大小,放到不同的chunk里,这种管理方式避免内存碎片管理问题,但是会带来内存浪费,即每个chunk内会放小于这个chunk大小的数据,chunk里有些空间没利用到;

一致性哈希算法,主要解决传统哈希下节点扩容或缩容导致所有key要重新计算迁移的问题。

  1. 构造一个长度为2的32次方的整数环,即0 ~ (2^32)-1的数字空间;
  2. 根据节点的唯一名称或者ip作为关键字进行哈希,确定节点在这个Hash环上的位置;
  3. 根据数据的key值计算Hash值,在Hash环上顺时针查找距离这个Key的Hash值最近的节点,完成key到节点的Hash映射查找;
  4. 当一个节点扩容或缩容时,只会影响到其中一个节点上的key,此时需要将该节点上的key重新计算,迁移到离它最近的一个节点,其他节点上的key则不会受影响,避免大量数据迁移,减少服务器压力。
  5. 为了解决负载不均衡问题(比如节点的数量很少,大量的数据就有可能都集中在上面),可以在此基础上增加一个虚拟层,对每个节点的关键字多计算几次哈希,每次计算的结果作为在环中的位置;key算完哈希值后,先在环上找到虚拟节点,再找到物理节点,将数据分散到各个节点,一般一个物理节点对应150个虚拟节点。

一致性哈希参考

Redis的线程模型

对于一个 DB 来说,CPU 通常不会是瓶颈,因为大多数请求不会是 CPU 密集型的,而是 I/O 密集型。具体到 Redis 的话,如果不考虑 RDB/AOF 等持久化方案,Redis 是完全的纯内存操作,执行速度是非常快的,因此这部分操作通常不会是性能瓶颈,Redis 真正的性能瓶颈在于网络 I/O,也就是客户端和服务端之间的网络传输延迟,因此 Redis 选择了单线程的 I/O 多路复用来实现它的核心网络模型。

单线程模型

Redis的单线程,指的是 网络IO 和 键值对读写 由一个线程来完成,其他的如持久化、异步删除、集群数据同步都有额外的线程完成。Redis在6.0版本后才支持IO多线程的网络模型。

Redis单线程之所以能处理得很快,得益于高效的数据结构,且采用了多路复用机制,在网络IO操作中能并发处理大量客户端请求,同时为了支持多种数据结构,保证并发安全,单线程模型下避免加锁解锁带来额外的开销。

监听 + 事件驱动 + 回调 的方式,处理易阻塞的accept和recv事件,是一个单Reactor模型,利用 epoll / select / kqueue 等多路复用技术,在单线程的事件循环中不断去处理事件(客户端请求)。

一文揭秘单线程的Redis为什么这么快?

Redis 多线程网络模型全面揭秘

总结一下就是:高效的数据结构和数据压缩、纯内存操作、非阻塞IO多路复用,避免频繁的上下文切换、避免同步机制的开销。

多线程模型

Redis 4.0之后引入多线程来做一些异步操作,比如以前DEL命令在删除big key和大量key时是阻塞操作,为了解决这个问题引入多线程使得部分命令支持多线程的异步操作,比如UNLINKFLUSHALL ASYNCFLUSHDB ASYNC,此时的网络IO模型仍然是单线程的(单Reactor模型)

Redis 6.0之后才引入多线程的网络IO模型(多Reactor模型,但不是标准的多Reactor模型),总的流程还是跟单线程模型一样,只是把读取客户端命令和回写响应数据的操作交由I/O线程去执行异步化,I/O 线程仅仅是读取和解析客户端命令而不会真正去执行命令,客户端命令的执行最终还是要在主线程上完成

影响性能的场景

关于big key:对于字符串类型,其value的大小超过10KB;集合类类型,其元素的个数超过10k;

  • 对big key的操作,比如创建和删除,big key在分配内存和释放内存会比较耗时。big key意味着value很大,当进行全量查询返回、聚合操作、全量删除(因为要释放内存)时可能会阻塞主线程。

    一般可以配合scan命令以及对应数据类型的scan命令来增量获取或删除。

  • 一些命令对应的操作时间复杂度高,比如范围查询、keys命令。

    一般使用scan代替keys命令,scan命令时因为使用高位进位法遍历,所以即使在扩容情况下也不会漏key,但在缩容时可能得到重复的key。

  • 大量Key集中过期,Redis过期机制也在主线程中执行,大量Key集中过期会导致耗时过长,所以在设置过期时间时要加入随机数。

  • 淘汰策略也是在主线程执行的,当内存超过Redis内存上限后,每次写入都需要淘汰Key,也会产生耗时。

  • 主从全量同步生成RDB快照,虽然采用fork子进程生成数据快照的方式,但fork瞬间也会阻塞线程,另外,从库在接收RDB快照后,会清空所有键值对,如果量大的话也很耗时,如果RDB文件太大,加载也耗时。

  • AOF日志同步写,因为是需要写磁盘的,比如设置为everysec可能会阻塞主线程。

  • 切片集群,向其他实例传输哈希槽信息,big key数据的迁移。

改进的话,就只有删除操作和AOF日志同步写可以放在异步线程去做,Redis4.0以后才提供删除和AOF同步写的异步命令。

过期时间和数据淘汰策略

key过期删除原理

  • 定期删除策略:Redis起定时器扫描key(默认100ms),判断key是否过期,过期则删除。虽然可以保证过期的key会被删除,但是每次都要扫描会非常消耗CPU资源,且定时器有间距,有可能出现key过期,但是此时定时器还没起,key仍保存在内存中;

  • 惰性删除策略:每次获取key的时候才判断key是否过期,过期则删除,但如果key一直未被使用,则会一直留在内存里,浪费空间;

    注意在Redis3.2以前,主库惰性删除后,从库不会触发数据删除,此时还能读到,3.2以后的版本才改正,返回空值,4.0后从库才会定时校验过期key。另外,expire命令在主库设置key的过期时间,到了从库可能有延迟导致过期时间比实际的长,expire命令表示执行完多久会过期,expireat/pexpireat命令表示在某一时间点会过期。

  • 所以Redis会将这两种策略整合在一起,定期删除策略不在是每次都扫描全部key,而是随机抽取一部分key进行检查,在配合惰性删除策略,正好可以弥补惰性删除策略的缺点。

淘汰策略

当内存使用量超出所设置的maxmemory值时,才会执行淘汰策略。默认的淘汰策略是当内存满了之后,新写入操作会报错。maxmemory值一般设置为总数据量的15%-30%。

其他淘汰策略,分为两种,一种是在所有数据范围内,一种是在设置了过期时间的范围内。

  • allkeys-random:在所有key中,随机移除某个key
  • allkeys-Lru:在所有key中,移除最近最少使用的key(优先使用
  • allkeys-Lfu:在所有key中,移除访问次数最少的某个key
  • volatile-random:在所有有设置过期时间的key中,随机移除某个key
  • volatile-Lru:在所有有设置过期时间的key中,移除最近最少使用的key
  • volatile-ttl:在所有有设置过期时间的key中,有更早过期时间的key优先移除
  • volatile-Lfu:在所有有设置过期时间的key中,移除访问次数最少的某个key

淘汰策略中的LRU算法

对于LRU算法,如果一些元素被频繁使用,会导致频繁的移动,带来了额外的开销。

Redis的LRU算法做了简化,其只会在RedisObject中记录每个数据最近以此访问的时间戳,当出现数据淘汰时,第一次会随机选出N个数据,作为一个候选集合,比较N个数据的lru字段,把lru字段最小的元素淘汰。

再次淘汰时,Redis会挑选那些LRU的值小于候选集合中最小LRU值的数据,进入第一次淘汰时创建的候选集合,可能会把里面最大的换出去或者淘汰里面最小的。准备候选集和淘汰数据是两个解耦操作。

N的值由maxmemory-samples决定。

淘汰策略中的LFU算法

Redis的LRU算法有个问题是对于非热点数据,其访问次数很少,直到触发淘汰策略才会被删除,在被删除之前都会一直留存,造成缓存污染。LFU算法就是为了解决这个问题。

LFU缓存策略是在LRU策略基础上,为每个数据增加一个计数器,统计数据访问次数。当使用LFU淘汰策略筛选淘汰数据时,首先会根据访问次数进行筛选,把访问次数最低的数据淘汰出缓存,当淘汰次数相同时,才会比较时间。

LFU在LRU的基础上,将RedisObject中的访问时间戳拆成两半,16bit存时间,8bit存计数,但计数不是线性递增,而是采用一种计算规则,让其增长不那么快到达2^8=255次,同时也有衰减机制,减少次数对时间的影响。

存储与持久化

RDB快照(Redis DataBase)

RDB快照由于保存的是数据,恢复起来会比AOF快(AOF保存的是命令),而且AOF是文本文件,RDB是二进制文件,所以RDB快照在网络传输、IO效率都比AOF好。

相关命令和配置

bgsave:会调子进程创建快照写入磁盘,主线程继续处理其他命令请求;

save:主线程创建快照写入磁盘,会阻塞其他命令请求,生产环境中比较少用;

redis.conf 文件里的配置:

savesave [时间] [次数] 表示在[时间]内有[次数]写入/更新,就会触发bgsave命令

另外,在进行主从复制,主redis发送sync命令给从redis时,如果刚刚没有执行完bgsave,也会进行一次bgsave操作。

rdbcompression:是否启用LZF算法进行压缩,压缩可以使得RDB快照落盘的文件变小,但在压缩时会消耗CPU资源。

原理

将Redis中的数据全量保存在文件中,一般会异步使用子进程进行刷盘操作,不阻塞主线程,此时主线程仍然能处理命令。(先全量

此外,会使用Copy on Write机制(写时复制),解决创建快照的过程中,原有数据被修改对RDB快照的影响。当主线程对原有数据进行修改前,这块数据会被复制一份(复制引用,由bgsave子进程操作),形成副本(此时会消耗两倍内存),由子进程将该副本写入RDB文件中,由于写的是引用,主线程修改后会同步到RDB中。(后增量

优点

  • RDB文件是某个时间节点的快照,默认使用LZF算法进行压缩,压缩后的文件体积远远小于内存大小,适用于备份、全量复制等场景;
  • Redis加载RDB文件恢复数据要远远快于AOF方式;

潜在风险

  • 风险在于快照的创建频率,快照是每隔一段时间进行创建和写入的,如果频繁创建快照,多快照写盘会影响磁盘IO,因此每次都进行全量快照并不可取。
  • 如果频率过低,则会导致宕机时丢失的数据过多。解决方式是RDB + AOF一起使用,在两次快照期间用AOF代替。
  • 当使用copy on write机制时,主线程会为其申请额外的空间,当进行频繁的写操作时,会导致内存很快被耗光。当实例系统开启了Swap机制时,超过内存使用量部分会转移到磁盘,访问磁盘的那部分就会很慢,如果没有开启Swap机制,则会触发OOM,Redis进程可能被kill或宕机;
  • 另外,当出现频繁的写操作时,由于生成RDB的子进程需要CPU核运行,主线程、多个线程或后台进程会竞争使用CPU,导致性能降低;
  • 实时性不够,无法做到秒级别的持久化;
  • RDB文件是二进制,没有可读性,而AOF文件可以在了解其结构后手动修改或补全;
  • 版本兼容RDB文件问题

AOF(Append Only File)

相关命令和配置

bgrewriteaof:执行后,Redis主进程fork子进程执行AOF重写,该子进程会创建新的AOF文件来存储重写结果,防止影响旧文件;

redis.conf文件里的配置:

appendonly yes:aof默认是关闭的,如果要打开,设置成yes;

appendfsync

  • no:每次命令只写内存(日志缓冲区),刷盘记日志的操作由操作系统决定,不阻塞主线程,调用fsync,通常最多30s;
  • everysec(默认):写命令记录到文件中,默认是每秒同步一次,所以如果发生故障,最多会丢失一秒的数据,但使用AOF保存的数据文件比RDB快照要大;Redis会使用另外的线程进行刷盘;
  • always:此外AOF还能选择每接收一个写命令就追加写入到AOF文件中,虽然能避免不丢数据,但每个写命令都是在主线程上完成,且后面都跟着一个刷盘操作,对机器的负担较大,影响服务性能;

auto-aof-rewrite-percentage 100:aof文件的容量超过原来aof文件容量一倍的时候, 进行aof文件的重写,默认100;

auto-aof-rewrite-min-size 64mb:执行aof重写时, aof文件的最小容量,默认64MB;

原理

AOF时,Redis会先把命令追加近AOF内存缓冲区,然后根据appendfsync配置的策略将内存中的数据写入磁盘。

不同与MySQL的WAL机制,AOF是先执行命令将数据写入内存,再写入日志。因为AOF会记录Redis收到的每一条命令,并以文本的形式保存,如果先写日志,并不知道命令是否是正确的,因此先写内存,让系统执行成功后,才会记录到日志中,避免错误命令。

潜在风险

  • 执行完命令,还没来得及记录日志就宕机,此时会丢失该命令的记录和相应数据。如果使用Redis当缓存,且要保证Redis宕机时不直接读库,利用Redis的AOF机制时就要注意了。
  • AOF是在命令执行后才记录日志,所以不会阻塞当前的写操作,但由于日志的写操作也是在主线程中的,虽然避免阻塞当前操作,但可能会阻塞下一个命令操作,比如刷盘时磁盘IO过慢。

重写机制

一般用于避免AOF日志文件过大,毕竟如果文件太大会影响磁盘IO、重放会太耗时。

原理

当AOF文件大小超过配置时,执行AOF重写机制。Redis会创建一个新的AOF文件,读取Redis中所有键值对后进行写入,但在重写时不是原样copy,而是会对命令进行合并,以此减小文件大小

重写时,主线程会fork后台的bgrewriteiaof子进程,把主线程的内存拷贝一份给bgrewriteiaof子进程,其中也包括了Redis的最新数据,bgrewriteiaof子进程逐一写入重写日志,避免阻塞主线程。

因为重写是在子进程中处理的,此时主线程仍然能处理客户端的命令,当接收到客户端的写命令时,会记录到AOF日志中,同时也会写进AOF重写日志的缓冲区,等子进程将拷贝数据写完后,再把缓冲区的数据刷入,完成后即可删除旧的AOF文件,留下新的AOF重写文件。

潜在风险

  • 主线程fork创建bgrewriteaof子进程时,内核会把主线程的PCB内容拷贝给子进程,此时会阻塞主线程,当要拷贝的内容特别大时,fork执行的时间就会变长,阻塞主线程的时间也会变长。
  • bgrewriteaof子进程会和主线程共享内存,当主线程收到新增或修改操作时,主线程会申请新的内存空间用来保存,但是如果是bigkey,主线程就会面临申请空间过大导致耗时。

高可用集群

RDB和AOF保证了数据少丢失,集群部署保证服务少中断,实现高可用。

主从复制 - 保证数据一致性

一般采用主从读写分离,主库进行写操作,然后再同步给从服务;而读操作发生在主从库均可。最好还是读写都在主库,从库只保证高可用,原因是主从数据同步是异步的,总有可能出现网络波动等问题导致主从库数据不一致,或者主库设置过期时间,但是到从库上有延迟导致读到过期数据,主库的过期删除无法同步到从库(4.0才解决)。

如果写操作可以发生在主从库上,会导致主从库上的数据不一致,取数据时可能会取到旧值,而如果要保持一致,则需要加锁,加锁的话效率旧太差了。

一般用在从节点初始化加入时使用,先进行全量同步(通过快照),再进行增量同步(通过命令缓存同步)

相关命令

在从库上使用命令:Redis 5.0以前使用slaveof、之后使用replicaof [主库IP] [主库端口]

过程

  1. 主库创建RDB快照文件,发送给从库,并在发送期间使用缓冲区(replication buffer)记录之后收到的写命令。

    快照文件发送完毕之后,开始向从库发送存储在缓冲区中的写命令;整个过程中主库不会被阻塞;

  2. 从库丢弃所有旧数据,载入主库发来的快照文件,之后从库开始接受主库发来的写命令;

  3. 主库每执行一次写命令,就向从库发送相同的写命令;

一般只设置一个主节点,当负载上升时,主库可能无法很快地更新所有从库,或者重新连接和重新同步从库将导致系统超载。因为在一次主从数据同步过程中,主库有两个耗时的操作:生成RDB文件和传输RDB文件,如果从库数量过多,会导致主库忙于fork子进程生成RDB文件进行全量复制,导致阻塞主线程的正常请求,同时传输的带宽也带来了压力。

解决办法:通过主从级联模式解决,可以设置主节点为根节点,向下延申,从节点再设置从节点的方式,形成树状的主从链,让从节点帮忙同步给其子节点的方式,降低主节点的压力。

同样使用命令slaveof或replicaof,只是ip换成从库的ip,这样就形成了主-从-从的模式了。

主从库命令传播时网络中断

Redis2.8之前,如果主从库发生网络中断,重新连接时会进行全量复制,开销巨大。

2.8之后,会使用增量复制。断连期间,主库会把收到的写命令写入缓冲区(replication bufferrepl_backlog_buffer)。

断连时,会记录从库的偏移量,待重新连接后即可根据偏移量进行同步。由于repl_backlog_buffer是环形缓冲区,如果从库同步太慢,因此可能会出现新命令覆盖到未读取的命令,只能通过调整其大小解决,配置repl_backlog_size,否则,从库将进行全量复制。

主从模式下,从库宕机影响不大,但主库宕机就会影响从库的同步了,此时需要哨兵机制重新选举主库,保证高可用。

replication buffer:主从库在进行全量复制时,主库上用于和从库连接的客户端的 buffer,非共享,每个从库对应一个。

repl_backlog_buffer:主库上用于持续保存写操作的一块专用 buffer,是一个环形缓冲区, 从库共享。为了支持从库增量复制,主库会记录自己写到的偏移量(master_repl_offset),而每个从库都会有自己的复制进度标志(slave_repl_offset)记录在上面,记录自己的写偏移量。

哨兵机制

哨兵机制会解决三个问题:1.判断主库是否宕机;2.选择哪个从库为主库;3.如何把新主库的信息通知给从库和客户端。

使用哨兵模式(sentinel)来监听和管理Redis集群,存储集群配置,作用类似ZooKeeper,哨兵节点是一台特殊的Redis,功能有限,主要用来支持哨兵机制,哨兵节点有三个任务:监控、选主、通知

原理

哨兵节点一般设置3个及以上的奇数个,哨兵节点间是平级的,会互相监控

  • 所有哨兵节点都会周期性的给所有主从库发送Ping命令检测Redis的主从节点是否正常运行,当有Redis节点出现问题时,进行通知。
  • 如果发生问题的节点是主节点,会从从节点中选出主节点,代替失效的主节点。
  • 之后会把新主库的连接信息发给其他从库,让他们执行replicaof命令,和主库建立连接,并进行数据复制。同时把新主库的连接信息通知给客户端,让客户端把请求发送到新主库上。

如何监控,如何判断主库不可用

主观下线:每个哨兵节点每隔1s对主从节点发生心跳,当有节点在超过x秒后没有进行回复,此时该节点为主观下线,还需要进一步判断。

客观下线:当主观下线的节点是主节点时,该哨兵节点会通过sentinel is-master-down-by-addr命令,向其它n - 1个哨兵节点询问对主节点的判断,当超过 n / 2 + 1 个哨兵节点认为主节点有问题时,该节点为客观下线。多这一步是为了防止误判。

客观下线后,会从从节点中选举出主节点,前主节点重新上线后会被设置为从节点。

Redis没有使用什么一致性算法,仅依据Gossip协议在有效时间范围内收到其它Sentinel节点的确认。

另外,如果不使用哨兵模式,只使用Redis集群,也可以实现高可用,只不过是把监控和选择转移到各个节点中。

如何选举

哨兵节点会周期性的发送心跳给主从库,在此过程中会对各个节点进行打分。之后按照一定的筛选条件和规则,选出得分最高的从库为新主库。

  • 筛选条件:从库的在线状态,之前的网络连接状态。通过设置主从库断连的最大连接超时时间(down-after-milliseconds)、断连次数(n),当超过阈值时则说明从库的网络状况不好。
  • 打分规则:从库的优先级(比如不同从库的配置不一样,优先级也就不一样)、从库的复制进度(根据从库在repl_backlog_buffer中的偏移量,从库间比较)、从库的ID号(ID号小的分高)

如何通知

客户端在访问主从库时,不能写死主库地址,而是从哨兵节点中获取主库地址;当哨兵选出新的主库时,会更新自己存的新主库地址。哨兵节点通过 发布/订阅 机制,让客户端进行订阅和修改。从而也能让客户端了解整个主从切换过程。

主从切换时,可能产生脑裂

如果主库因为某些原因进入假死状态(但还能处理命令),被哨兵判定为客观下线,哨兵执行主从切换,但此时主库恢复正常,但此期间写操作的数据还未同步到从库,待哨兵完成主从切换后,Redis集群会短暂出现两个主库,导致客户端的写操作会发往不同的主库,或者原主库降级成从库,会清空本地数据重新载入新主库的RDB快照,导致数据不一致或丢失。

判断主从库数据是否不一致,可以通过对比主从库上的复制进度差值来进行判断,计算matser_repl_offsetslave_repl_offset的差值,如果从库上的slave_repl_offset小于原主库的master_repl_offset,就说明数据丢失是因为数据未同步完成导致的。

根本原因:Redis主从集群没有使用共识算法,每个写操作没有在大多数节点写成功后才认为成功导致的。不像ZooKeeper,客户端的操作都会经过ZooKeeper的主节点,当发生脑裂时,ZooKeeper主节点无法写入大多数节点,写请求直接失败,保证数据一致性。

解决方法:在主库上配置min-slaves-to-write表示主库能进行数据同步的最少从库数量;min-slaves-max-lag表示主从数据复制时,从库给主库发送ACK消息的最大延迟(秒)。这样当主库假死时,无法响应哨兵心跳,也不能和从库同步,确认从库的ACK命令,原主库就无法再接收客户端的写操作。

哨兵集群的高可用

由于哨兵需要进行客观下线的判断,因此需要多个哨兵组成集群,集群就会涉及到高可用。

哨兵节点间、与主从库间的相互发现机制

  • 哨兵节点间的相互发现:正常情况下,每个哨兵都是平级的。每个哨兵节点在设置时的命令是sentinel monitor <master-name> <ip> <redis-port> <quorum> ,并不感知其他哨兵的存在。哨兵节点间的相互发现,依赖Redis的 发布/订阅 机制。哨兵节点一旦和主库建立连接,就会把自己的连接信息(如IP、端口)发布到主库上,同时它也会订阅,从而发现其他哨兵节点。

  • 哨兵节点发现从库:哨兵节点连接主库后,发送INFO命令,主库就会把从库连接信息列表发给哨兵节点,从而实现哨兵节点对从库的监控。

哨兵节点Leader选举原理

正常情况下哨兵集群内的每个哨兵节点是平级的,但是当触发客观下线时,需要选出一个哨兵节点Leader来执行主从库切换。重新选举主库只能由一个哨兵节点来做,如果不是,可能会出现主从库集群脑裂。另外,哨兵节点越多,选举速度越慢,风险也会增加。

哨兵节点Leader选举分为两个阶段:

  1. 各个哨兵节点判断主/客观下线阶段:各个哨兵在判断主库是主观下线后,首先会给自己投Yes票,之后会发送is-master-down-by-addr命令给其他哨兵节点,其他哨兵节点会根据自己的判断情况,回复Yes / No回去。该哨兵节点收集得到的Yes票,当超过设置的quorum值时,标记主库为客观下线。

  2. 每个哨兵在一次选举节点只有一次投票机会,当有哨兵节点得出客观下线结论后,该哨兵再发起投票,进行Leader选举,当收集到的票数超过一半,则该哨兵节点成为Leader节点,如果没有选举成功,则等待一般是故障转移超时时间failover_timeout的2倍时间后会从新举行选举。

Redis哨兵下线主库和Leader选举
Redis哨兵下线主库和Leader选举

几个注意点

  • 主从复制设置时,最好在主节点上开启持久化,否则可能会导致从节点的数据被清空;

    当主节点关闭持久化后,从节点开始从主节点复制数据,如果此时主节点挂了,因为关闭了持久化,且重启速度比较快,哨兵节点没有发现,重启后主节点数据为空,当从节点在进行数据复制时发现主节点是空的,从节点上的数据也会被删除。

  • Redis支持无磁盘复制,当使用低转速的磁盘时,可能会影响复制性能,当启动无磁盘复制时,主节点子进程直接将RDB通过网络发送给从节点,不使用磁盘。

高可用扩展集群

一般一个Redis保存几个G内比较合适,当单个Redis实例要保存的键值对太多时,会影响Redis的主从复制、RDB快照和AOF日志的大小、影响从库重放速度、fork子进程的速度(太慢会导致阻塞主线程),因此需要进行对单Redis实例扩展,常见的方式是对单个Redis实例进行扩展,一般分为纵向扩展和横向扩展。

redis中默认有编号0-15总共16个db,默认使用db0,每个数据库都有属于自己的空间。

在redis集群时,不可以使用select命令选择db,因为redis集群仅支持db0。

Redis集群最大节点个数是 16384 个,因为Redis集群没有使用一致性hash,而是哈希槽,Redis集群就有16384个哈希槽,原因是 Redis节点发送心跳包时需要把所有槽放到这个心跳包中,让节点知道当前集群信息,16384 = 16k 在发送心跳包时使用char进行bitmap压缩后是2k(2 * 8 (8 bit) * 1024(1k) = 16K),因此使用 2k 空间就能创建 16k 的槽数了,尽管CRC16 算法最多课分配 2 ^ 16 - 1 = 65535 个槽位,压缩后也才8k,但一般来说,一个Redis集群不会超过1000个master节点,所以16k的槽位就比较合适。

扩展

  • 纵向扩展:升级单Redis实例的配置,如内存容量、磁盘容量、CPU等,但会影响RDB快照和AOF日志大小,网络传输等,一般用在不使用Redis持久化功能的场景。
  • 横向扩展:根据key,对Redis实例进行分片,增加Redis实例个数。

分片

Redis分片的实现使用哈希槽。

原理

  • 将数据分散到集群的多个机器上,Redis里使用的概念是槽Slot,每个集群的槽数固定为16 * 1024 = 16384个,使用哈希分片算法对Key进行取模,计算方法:HASH_SLOT = CRC16(Key) mod 16384,余数为Key所在的槽。

    之所以会使用槽,是因为要把数据和节点解耦,如果不使用槽,而是使用key与节点的映射表,当key的数量非常庞大时,映射表也会非常大,映射表的修改和迁移的性能不高。

    集群只需要记录槽的ID与槽节点的地址映射即可,默认情况下,当集群 16384 个槽任何一个没有指派到节点时会导致整个集群不可用,so,当有节点故障下线时,从故障发现到自动完成转移期间整个集群不可用,如果要关闭这个配置,需要在redis.conf中配置:cluster-require-full-coverage no,这样就只会影响故障节点了。

  • 集群内每台机器会存放一些槽,在集群初始化的时候会对集群内的机器平均分配这16384个槽,使用查表法进行分配。因此,当需要扩容时,会重新计算槽的位置和迁移key,可以使用官方提供的redis-trib.rb脚本实现。

  • 客户端连接分片集群后,即可获得槽与各个Redis分片节点的映射。访问时可以访问集群内的任意节点,先根据key算出在哪个槽,在查询槽和节点间的关系,找到对应的节点进行查询。

    另外,要注意分片后,对于Keys、scan这样的扫描命令的性能就会更加差了。

  • 当分片集群有增删时,槽与节点的映射也会随之修改,为了负载均衡,Redis需要把槽重新分布到各个分片上。但是客户端却不感知,当客户端发送命令时,如果节点上该槽已迁往别处,客户端会收到MOVED 新槽编号 新槽所在的host的错误信息,客户端将重新请求,同时修改槽与节点的映射关系。

  • 如果客户端请求发生在Redis迁移槽的过程中,则会先收到ASK 新槽编号 新槽所在的host的错误消息,让客户端进行重试,直到Redis完成槽的迁移,重试成功。

  • HashTag,如果key的格式是 user:order:{3214},由{}括起来的部分为HashTag,CRC算法在计算key在哪个槽时只会计算{}里的值,HashTag主要是让多个类型的key可以映射到同一个槽,方便范围查询。

    但使用HashTag可能会导致数据倾斜,使得请求无法平均分到各个分片。

一般的分配规模是几十个以内,不适合构建超大规模的集群,原因是去中心化设计,选举算法使用Gossip,规模越大时,传播速度越慢。如果要构建超大规模的集群,则需要增加一层代理,进行集群间的转发,例如twemproxy或者Codis这类基于代理的集群架构。

事务

使用 MULTI 和 EXEC 命令将多个写操作包围起来进行发送,Redis收到后会先将命令暂存到一个队列里,当收到EXEC命令时才会一起顺序执行。

如果客户端发送的命令为 EXEC 、 DISCARD 、 WATCH 、 MULTI 四个命令的其中一个, 那么服务器立即执行这个命令。

与此相反, 如果客户端发送的命令是 EXEC 、 DISCARD 、 WATCH 、 MULTI 四个命令以外的其他命令, 那么服务器并不立即执行这个命令, 而是将这个命令放入一个事务队列里面, 然后向客户端返回 QUEUED 回复。

WATCH: 监视一个或多个key,如果事务在执行前,这个key(或多个key)被其他命令修改,则事务被中断,不会执行事务中的任何命令,此时EXEC执行结果为null。

pipline主要用于批量发送命令,一次性发送多个请求,一次性读取所有返回结果,目的是为了减少 连接-》发送命令 -》返回结果 这个过程产生的耗时,减少IO次数,减少 read() 和 write() 的系统调用次数。

MULTI和EXEC事务机制与Redis pipline的区别:

  • 事务中的每个命令会先发送到服务端,只会再执行;而pipline是会把命令请求缓存在客户端本地内存中,再发送请求一次性执行
  • 事务中的每个命令都会先发送到服务端,请求次数多,而pipline只发送一次请求
  • 一般事务和pipline会配合使用,以减少请求次数,减少网络IO,提升性能,保证多个命令发送不会被别的请求打断,保证多个事务的隔离性,

Redis中的原子性

  • 更多的是为了减少客户端与服务端的通信次数。Redis本身不提供回滚机制,如果一次性传输多个命令时,当有一个命令执行失败了,剩下的命令还是会继续往下执行,无法实现事务的原子性。
  • 如果命令本身有错,只有到了EXEC命令时才会报错,整个MULTI期间的命令都不会执行,此时才保证了原子性。
  • RDB不会在事务执行的时候执行,所以可以保证原子性。
  • 事务执行过程中的命令,AOF日志会记录,所以如果事务执行过程中宕机了,重启前需要使用redis-check-aof工具检查AOF日志,去除执行到一半的事务命令,才能保证原子性。

Redis中的原子操作

  • Redis的 incr / decr 命令,本质上是一个读取 -》修改 -》写入 的过程;
  • 为Redis定义新的数据结构实现原子操作,毕竟Redis本身是单线程的,本身不会有其他线程的影响;
  • Lua脚本,才能实现复杂逻辑的原子操作,使用时会先通过script load命令把脚本加载到Redis中,得到唯一摘要,再通过命令evalsha + 摘要的方式执行脚本,避免每次执行脚本都要先传输,减少网络IO;另外Lua脚本的时间复杂度不易过长,否则会阻塞主线。

Demo:限制客户端每分钟访问次数不能超过20次 的Lua脚本,该脚本包含了计数、访问次数判断和过期时间设置。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 客户端先获取ip对应的访问次数
current = redis.call("get", KEYS[1])
//如果超过访问次数超过20次,则报错,这两步不涉及临界值的修改,因此可以不放在脚本中原子执行。
IF current != NULL AND current > 20 THEN
    ERROR "exceed 20 accesses per second"

Lua脚本名称visit_restrict.script
脚本内容
    //如果访问次数不足20次,增加一次访问计数
    value = redis.call("incr", KEYS[1])
    //如果是第一次访问,将键值对的过期时间设置为60s后
    IF value == 1 THEN
        redis.call("expire", KEYS[1], 60)
    END
    //执行其他操作
    DO THINGS
END
执行命令:
redis-cli --eval visit_restrict.script keys , args

Redis中的隔离性

  • 需要依赖WATCH机制,因为Redis流水线命令是在EXEC命令执行后开始的,在EXEC命令还未执行前,如果要保证隔离性,需要使用WATCH监控某个key,当EXEC命令执行时,WATCH机制就会触发,如果监控的数据被修改了,就放弃此次事务的执行。
  • 如果是在EXEC命令执行后的,由于Redis是单线程的,所以并发情况下不会破坏事务隔离性。

因此调用方可以利用这个特性,不断的重试这个操作,知道成功为止。

使用Watch的实现监控:在使用MULTI命令之前使用WATCH监控某些键值对,然后使用MULTI命令开启事务,执行各种数据结构的操作命令,此时这些命令入队列;当使用EXEC执行事务时,首先会对比WATCH所监控的键值对,如果没有发生变化,它会执行事务队列中的命令,提交事务,如果发生变化,则不执行事务中的任何命令,同时事务回滚,然后取消执行事务前的WATCH命令。

Redis中的一致性

在命令执行错误或 Redis 发生故障的情况下,Redis 事务机制对一致性属性是有保证的。

Redis中的持久性

尽管有RDB和AOF日志的支持,但是仍然有丢数据的可能。比如使用RDB模式,在一个事务执行后,而下一次的 RDB 快照还未执行前,如果发生了实例宕机,此时,事务修改的数据也是不能保证持久化的。

应用场景

聚合统计 - 统计每日新增用户

  • 使用Set类型
  • 一个Set记录所有登录过的用户ID:[key:user:id,value:Set<userId>],另一个Set记录每日登录过的用户ID[key:user:id :日期,value:Set<userId>]
  • 统计每日新增用户,计算每日用户 Set 和累计用户 Set 的差集,使用命令SDIFFSTORE [结果key] [user:id :日期] [user:id]
  • 弊端:Set的差集、并集、交集计算复杂度较高,当数据量较大时计算太慢会阻塞主线程,一般这种计算会在从库上做。

排序统计 - 统计最新评论、排行榜、时间段内在线数等

  • 使用List,当有新元素LPush或RPush插入时,原先所有元素都会后移一位,使用Lrange读取元素时可能会读到旧数据。
  • 使用ZSet,为每个元素设置权重,按权重排序,使用Zrangebyscore则不会读到旧数据。

二值状态统计 - 统计是否签到、存不存在、有没有问题

  • 针对值只有0 或1的场景,使用bitmap,是Redis的扩展数据类型,本身是String类型的一种特殊应用。
  • bitmap利用的是存储的String value的位。并且bitmap支持Bitop命令对多个bitmap进行按位与、或、异或操作,bitcount统计位中1的个数。

基数统计 - 统计一个集合中不重复的元素

  • 使用Set或Hash,但是可能会比较耗内存
  • 使用HyperLogLog,专门用于基数统计,优势在于所需空间总是固定,使用PFAdd key v1 v2 v3命令做添加,PFCount key统计key的value数量,但是存在误差,误差率约为0.81%
  • 一般用于统计注册的IP数、每日访问IP数、页面实时UV数、在线用户数、搜索的关键词数

GEO - LBS应用

  • 底层实现是ZSet,权重值是经纬度,但是由于ZSet的权重值只能是浮点类型,因此一般会对经纬度做GeoHash编码。

  • GeoHash编码的基本原理是:二分区间,区间编码。

    • 二分区间:比如把经度范围[-180,180]会被分成两个子区间:[-180,0) 和[0,180],如果要编码的经度值落在左区间,则用0表示,右区间用1表示,通过不断的对区间进行分区,经过N次之后,得到一个N位数的01组合。纬度同理。

      比如有一个点(39.923201,116.390705),以纬度为例,(-90,90)中间值为0,39.923201在(0,90)得到1,(0,90)的中间值是45,在(0,45)得到0,继续对分区进行二分和比较,最终得到一个二进制数,经度也是以同样的方式进行计算

    • 区间编码:把计算过后的经纬度编码进行组合,规则:偶数位是经度编码值,奇数位是纬度编码值。

    • base32进行编码

  • 通过GeoHash编码,地图上的每个方格都能用数字进行表示,分区越多越精准。通过范围查询 + 方格周围方格的编码,即可实现搜索附近的人功能

  • GeoHash表示一个矩形的区域,精度取决于二分的次数,次数越多越精确,经过base32编码后得到一个字符串,编码越长,范围越小,位置更精确;两个相同前缀的字符串,长度越短表示范围更大,可以通过此特性来表示两个点之间的大概距离。

  • Redis也提供了Geo的数据类型

保存时间序列数据

  • 时间序列数据的特点是插入速度要快,数据一旦插入就不变、查询模式较多。
  • 可以基于Hash和ZSet实现。Hash实现插入快速和点查询,ZSet实现范围查询,但由于插入一条数据的时候需要同时操作Hash和ZSet,因此要求这个操作是原子的。
  • ZSet只能提供范围查询,聚合查询只能在让客户端查回去之后自己做,但是大量数据查询传输比较依赖网络资源,可能会导致其他操作响应速度变慢,如果想要Redis实现的话,就得依靠RedisTimeSeries。

消息队列

  • 消息队列一般要解决三个问题:顺序消费、幂等消费、消息可靠性保证

  • 使用List

    • List的每个元素除了消息的内容外还要保存消息的唯一id,用于解决重复性消费;
    • List类型有提供BRPOP供消费者阻塞读取,避免循环读取带来的消耗;
    • List类型有提供BRPOPLPUSH命令,让消费者读取时会把数据存入另一备份List中,用于避免消费者从List中移除消息读取时宕机,重启后可重新读取消息。
    • List不支持消费组实现,无法避免生产速度大于消费速度的场景。
    • 通过 BLPOP 命令,在没有消息的时候,它会一直阻塞,直至有消息到来。
  • 实现延迟队列:

    • 使用ZSet,拿时间戳当score,调用ZADD命令生产消息,ZRANGEBYSOCRE命令获取N秒前的数据进行轮询处理。
  • 使用Stream类型,解决多端消费问题。

    • Stream的XADD命令,可以为其生成全局唯一ID。
    • 读取时使用命令XRead,可支持指定ID读取,也支持超时阻塞读取。
    • 命令XGroup创建消费组,XREADGROUP指定组内哪个消费者进行消费。
    • Streams 会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用 XACK 命令进行回复。如果消费不成功,它就不会给 Streams 发送 XACK 命令,消息仍然会留存。当消费者重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息。
    • Stream是Redis5.0以后才有的专门用来处理消息队列的。

缓存可能引发的问题以及应对方法

使用Redis来构建缓存有两种模式

  • 只读缓存:加速读请求。应用读取数据,先向Redis查询,查不到再查库,然后保存到缓存中。应用写数据,先改库,删掉旧缓存数据。这种方式可以很好的保证了一致性。
  • 读写缓存:同时加速读写请求。读写都会在缓存里发生,最新的数据是在Redis中,但需要严重依赖Redis的持久化。这种方式对一致性的保证就差了点,特别是在高并发下。
    • 同步直写:优先保证数据可靠。写请求会同时修改缓存和库,等到都完成了才返回,需要保证原子性。
    • 异步写回:优先保证快速响应。写请求会先在缓存中处理,等到这些增改数据要从缓存中淘汰,才会写回数据库。

缓存雪崩

现象:缓存大面积失效导致请求到达数据库

应对方案:

  • 缓存过期时间设置均匀,不能让一大片缓存在某一时间全部失效,比如设置过期时间时加入随机数,让过期时间在一个范围内波动。
  • 请求时加锁(比如利用redission的rlock),后续请求只能等到前面的查完数据库,进行缓存后,才能继续,但会造成吞吐量降低,响应时间变长,或者可以使用semaphore设置一定的信号量,不至于只有一个请求去回源数据库。
  • 不设置key的过期时间,另开一个定时任务定期全量更新缓存;或者定时任务定期扫描,将快要过期的key延迟过期时间;设置多级缓存。
  • 灰度发布,对缓存进行预热。
  • 服务降级,当访问的数据是非核心数据,直接返回预定义数据或空值;当访问的数据是核心数据,仍允许查缓存,查库。
  • 缓存标记,比如,给数据设置缓存过期时间为60分钟,标记缓存过期时间为30分钟,当时间超过缓存标记时间后,触发缓存更新,但此时实际缓存还能返回旧数据,直到缓存更新完成,才会返回新缓存。
  • 如果是Redis实例发生宕机,只能在应用层中实现服务熔断或请求限流。

缓存击穿

现象:大量请求访问热点数据时,但热点数据刚好过期,此时请求击穿缓存层,直接到达数据库

应对方案:

  • 不给热点数据的缓存设置过期时间,一直保留。
  • 使用互斥锁或者信号量,当有大量请求访问一个过期的热点数据时,只有一定数量的请求会到达数据库查询数据并缓存,其他请求等待缓存加载即可。

缓存穿透

现象:查询一个一定不存在的数据,导致请求一直到达数据库,数据也不在数据库中。

应对方案:

  • 使用布隆过滤器,将可能出现查询的值哈希到一个bitMap中,进行拦截,虽然布隆过滤有一定的误报几率,但也能一定程度的减少穿透的影响,常见的方案是配合2一起降低穿透带来的影响。
  • 如果查询结果为空,也加入缓存中(可以直接设置为空,或者使用特殊标识来表示),并设置过期时间。
  • 通过异步更新服务 + 消息队列的方式进行全量缓存的更新。缓存的设置还是照旧,只是当有数据更新时,触发消息交给消息队列,再由异步更新服务消费消息,实现缓存更新。
  • 利用数据库的Bin Log,当数据库执行更新操作时,从数据库接收到Bin Log之后根据Bin Log更新Redis缓存,道理跟消息队列类似,只是不用担心消息发生失败问题。
  • 前端预防,对请求进行检测。

缓存无底洞

现象:增加缓存节点,性能不升反降,原因是客户端要维护大量的连接,如果key分布在不同机器,需要查多次

应对方案:

  • 减少网络请求,能批量查尽量批量查
  • 将key进行分类,存到指定节点,查询同类的key时只需要特定的节点去查
  • 并发查询

缓存污染

现象:对于那些访问次数很少的数据,一直留存在缓存中,占用缓存空间。

应对方案:Redis的淘汰策略,一般会使用LRU、LFU、TTL的淘汰策略。

主动更新缓存要注意的点

  1. 不推荐先更新缓存再更新数据库(也称 Write Behind Caching 模式,更新数据时,只更新缓存,异步 / 同步 更新数据库)原因是数据库操作可能失败,导致缓存与数据库不一致;

    这个模式也有点像Linux的PageCache算法,好处是因为直接操作内存,异步,write back可以合并同一个数据的多次操作,IO性能极高,但因为要保证数据一致性,write back的实现就会很复杂,它需要知道哪些数据被更新了,然后还要持久化到磁盘上,比如操作系统的write back仅会在当这个缓存需要失效、内存不够、进程退出时,才会真正持久化。

  2. 不推荐先更新数据库再更新缓存(也称 Write Through 模式),原因是当AB两个操作同时写,两者的操作顺序无法保证,导致数据不一致,即并发写导致脏数据;另外,更新到缓存的数据也不一定被访问;

  3. 不推荐先删缓存再更新数据库,访问时再进行加载,原因是并发情况下,删除缓存后来不及更新数据库,但旧值已经被其他线程读到了,更新到缓存了;或者数据库操作失败了,但缓存已经没了,导致其他请求还要再读一次数据库,应对的方案是延迟双删:先删缓存 -》更新数据库 -》sleep -》再删除缓存

  4. 推荐先更新数据库再删除缓存(也称为Cache Aside 模式),访问时再进行加载,虽然也可能出现3中的情况,比如读缓存时缓存失效,紧接着一个并发写操作,就有可能出现读操作覆盖了写操作后的数据更新,导致数据不一致,但实际上发生的概率不大,带来的影响会相对小一些,因为写操作通常会比读操作慢,再加上要锁表,而读操作必须在写操作前进入数据库操作,而又要晚于写操作更新缓存,条件就很苛刻了。

    如果删除缓存失败了,可以延迟任务进行删除重试,因为删除操作一般是幂等的,所以即使重复删除也没关系,另外,相比Read/Write Through模式(更新数据库后更新缓存操作),不会因为并发读写产生脏数据。还有由于会删除缓存,所以要注意缓存穿透问题。

    另外,即使使用了先更新数据库再删除缓存的模式,在主从同步延迟的场景也会导致不一致(对于其他模式也是),解决方案仍然是延迟双删,只是sleep的时间最好等于延迟时间;

    如果还要强一致,就只能使用2PC、3PC、Raft之类的一致性协议或者分布式锁等方案来解决了,但是性能就保证不了了;

  5. 另外的方案是利用数据库的能力 + 消息队列的方式,如根据MySQL的Bin log来更新缓存;

Redis缓存方案操作 - 极客时间Redis核心技术与实战
Redis缓存方案操作 - 极客时间Redis核心技术与实战

使用规范

  • String类型的数据不要超过10KB,避免BigKey,value可以使用压缩算法进行压缩来减少大小;
  • Key的长度尽量短,尽量控制在39字节以内,减少创建RedisObject内存分配次数,提升性能;
  • key的过期时间加上随机值,避免key的集中过期;
  • 集合类型的元素个数不超过一万个;
  • Redis实例容量控制在10GB内;
  • 禁用Keys、FlushAll、FlushDB命令,慎用全量操作命令、Monitor命令、复杂度过高的命令(如Sort、Sinter、SinterStore、ZUnionStore、ZInterStore)

参考

极客时间 - Redis核心技术与实战:讲得很不错,推荐。

还有其他看过的别人博客,但是当时忘记把链接贴下来。

Built with Hugo
Theme Stack designed by Jimmy