Redis
分布式锁
保证加锁时设置过期时间的原子性
redis.setnx(key, value)无法设置过期时间
redis.setnx(key, value);
redis.expire(key, 25);
上述操作无法保证原子性,可用下面的方案代替
redis.set(key, value, 25, "NX");
防止超时后释放锁时误删其他线程的锁
- 假如线程
A成功 得到了锁,并且设置的超时时间是25秒。由于某些原因导致线程A执行的很慢很慢,过了25秒都没执行完,这时候锁过期自动释放,线程B得到了锁。随后,线程A执行完了任务,执行del指令来释放锁。但这时候线程B还没执行完,线程A实际上删除的是线程B加的锁。 - 可以在加锁的时候把当前的线程
id当做value,并在删除之前验证key对应的value是不是自己线程的id。
保证删除锁时的原子性
- 防止超时后释放锁时误删其他线程的锁时,加入了判断逻辑,使得删除锁的操作变成了非原子操作。
- 使用
Lua脚本来保证删除锁操作的原子性
String luaScript = 'if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end';
redis.eval(luaScript , Collections.singletonList(key), Collections.singletonList(threadId));
跳表
- 链表加多级索引的结构,就是跳表,是为了解决平衡二叉树复杂的问题,是平衡树的一种替代品,以一种较为简单的方式实现了平衡二叉树的功能。
skiplist 与平衡树、哈希表的比较
skiplist和各种平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的,在哈希表上只能做单个key的查找,不适宜做范围查找。- 在做范围查找的时候,平衡树比
skiplist操作要复杂。在平衡树上,找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第一层链表进行若干步的遍历就可以实现。 - 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而
skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。 - 从内存占用上来说,
skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p = 1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。 - 查找单个
key,skiplist和平衡树的时间复杂度都为O(logn),大体相当,而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近O(1),性能更高一些。 - 从算法实现难度上来比较,
skiplist比平衡树要简单得多。
BigKey
- 通常将含有较大数据或含有大量成员、列表数的
Key称之为大Key
常见衡量指标
- 一个
STRING类型的Key,它的值为5MB(数据过大) - 一个
LIST类型的Key,它的列表数量为20000个(列表数量过多) - 一个
ZSET类型的Key,它的成员数量为10000个(成员数量过多) - 一个
HASH格式的Key,它的成员数量虽然只有1000个但这些成员的value总大小为100MB(成员体积过大)
带来的问题
Redis实例变慢Redis实例内存不断变大引发OOM,或达到maxmemory设置值引发写阻塞或重要Key被逐出Redis Cluster中的某个node内存远超其余node,但因Redis Cluster的数据迁移最小粒度为Key而无法将node上的内存均衡化- 大
Key上的读请求使Redis实例占用服务器全部带宽,自身变慢的同时影响到该服务器上的其它服务 - 删除一个大
Key造成主库较长时间的阻塞并引发同步中断或主从切换
HotKey
- 某个
Key接收到的访问次数显著高于其它Key时,可以将其称之为热Key
常见衡量指标
Redis实例的每秒总访问量为10000,而其中一个Key的每秒访问量达到了7000(访问次数显著高于其它Key)- 对一个拥有上千个成员且总大小为
1MB的HASH Key每秒发送大量的HGETALL(带宽占用显著高于其它Key) - 对一个拥有数万个成员的
ZSET Key每秒发送大量的ZRANGE(CPU时间占用显著高于其它Key)
带来的问题
- 热
Key占用大量的Redis CPU时间使其性能变差并影响其它请求 Redis Cluster中各node流量不均衡造成Redis Cluster的分布式优势无法被利用,一个分片负载很高而其它分片十分空闲从而产生读/写热点问题- 在抢购、秒杀活动中,由于商品对应库存
Key的请求量过大超出Redis处理能力造成超卖 - 热
Key的请求压力数量超出Redis的承受能力造成缓存击穿,此时大量请求将直接指向后端存储将其打挂并影响到其它业务
Pipeline
Redis客户端执行一条命令分为四个过程:发送命令 -> 命令排队 -> 命令执行 -> 返回结果,这整个过程称为Round trip time(简称RTT, 往返时间),批量命令(mget、mset)有效节约了RTT,但大部分命令(如hgetall)不支持批量操作,需要消耗N次RTT,这个时候需要Pipeline来解决这个问题。- 使用
Pipeline组装的命令个数不宜太多,不然数据量过大,增加客户端的等待时间,还可能造成网络阻塞,可以将大量命令的拆分多个小的Pipeline命令完成。
原生批量命令(mset, mget)与 Pipeline 对比
- 原生批量命令是原子的,
Pipeline是非原子的 - 原生批量命令是一个命令对应多个
key,Pipeline支持多个命令 - 原生批量命令是
Redis服务端支持实现的,而Pipeline需要服务端和客户端的共同配合
hash 扩容
- 采用的是渐进式
rehash的方式,两个dictht表,rehashidx。
渐进式 rehash
- 为
ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表 - 将
rehashindex的值设置为0,表示rehash工作正式开始 - 在
rehash期间,在对hash执行增删改查操作时,同时还会将ht[0]哈希表在rehashindex索引上的所有键值对rehash到ht[1],当rehash工作完成以后,rehashindex的值+1 - 随着字典操作的不断执行,最终会在某一时间段上
ht[0]的所有键值对都会被rehash到ht[1],这时将rehashindex的值设置为-1, 表示rehash操作结束
集群
Redis支持三种集群方案- 主从复制模式
Sentinel(哨兵)模式Cluster模式
主从复制模式
- 主从复制模式中包含一个主实例(
master)与一个或多个从实例(slave),客户端可对主数据库进行读写操作,对从数据库进行读操作,主数据库写入的数据会实时自动同步给从数据库。
工作机制
slave启动后,向master发送SYNC命令,master接收到SYNC命令后通过bgsave保存快照(即上文所介绍的RDB持久化),并使用缓冲区记录保存快照这段时间内执行的写命令master将保存的快照文件发送给slave,并继续记录执行的写命令slave接收到快照文件后,加载快照文件,载入数据master快照发送完后开始向slave发送缓冲区的写命令,slave接收命令并执行,完成复制初始化- 此后
master每次执行一个写命令都会同步发送给slave,保持master与slave之间数 据的一致性
优点
master能自动将数据同步到slave,可以进行读写分离,分担master的读压力master、slave之间的同步是以非阻塞的方式进行的,同步期间,客户端仍然可以提交查询或更新请求
缺点
- 不具备自动容错与恢复功能,
master或slave的宕机都可能导致客户端请求失败,需要等待机器重启或手动切换客户端IP才能恢复 master宕机,如果宕机前数据没有同步完,则切换IP后会存在数据不一致的问题- 难以支持在线扩容,
Redis的容量受限于单机配置
Sentinel(哨兵)模式
- 哨兵模式基于主从复制模式,只是引入了哨兵来监控与自动处理故障。
优点
- 哨兵模式基于主从复制模式,所以主从复制模式有的优点,哨兵模式也有
- 哨兵模式下,
master挂掉可以自动进行切换,系统可用性更高
缺点
- 同样也继承了主从模式难以在线扩容的缺点,
Redis的容量受限于单机配置 - 需要额外的资源来启动
sentinel进程,实现相对复杂一点,同时slave节点作为备份节点不提供服务
Cluster 模式
Cluster模式实现了Redis的分布式存储,即每台节点存储不同的内容,来解决在线扩容的问题。Cluster采用无中心结构,它的特点如下:- 所有的
redis节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽 - 节点的
fail是通过集群中超过半数的节点检测失效时才生效 - 客户端与
redis节点直连,不需要中间代理层,客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可
- 所有的
Cluster模式集群节点最小配置6个节点(3主3从,因为需要半数以上),其中主节点提供读写操作,从节点作为备用节点,不提供请求,只作为故障转移使用。
工作机制
- 在
Redis的每个节点上,都有一个插槽(slot),取值范围为0-16383 - 当存取
key的时候,Redis会根据CRC16的算法得出一个结果,然后把结果对16384求余数,这样每个key都会对应一个编号在0-16383之间的哈希槽,通过这个值,去找到对应的插槽所对应的节点,然后直接自动跳转到这个对应的节点上进行存取操作 - 为了保证高可用,
Cluster模式也引入主从复制模式,一个主节点对应一个或者多个从节点,当主节点宕机的时候,就会启用从节点 - 当其它主节点
ping一个主节点A时,如果半数以上的主节点与A通信超时,那么认为主节点A宕机了。如果主节点A和它的从节点都宕机了,那么该集群就无法再提供服务了
Redis 事件模型
Redis服务器是一个事件驱动程序,主要处理文件事件(file event)和时间事件(time event)
文件事件
Redis服务器通过套接字与客户端进行连接,而文件事件就是对套接字操作的抽象,可以将其理解为IO事件Redis将IO事件放入一个就绪队列中(redisServer.aeEventLoop.fired数组),然后在aeProcessEvents会依次分派给文件事件处理器处理Redis中文件事件包括:客户端的连接、命令请求、数据回复、连接断开等,当上述事件发生时,会使得相应的描述符可读可写, 再调用相应类型的文件事件处理器。Redis文件事件处理器有:连接应答处理器(networking.c/acceptTcpHandler)、命令请求处理器(networking.c/readQueryFromClinet)、命令回复处理器(networking.c/sendReplyToClient)
时间事件
- 时间事件包含
定时事件和周期性事件,Redis将其放入一个单向无序链表中,每当时间事件执行器运行时,就遍历链表,查找已经到达的时间事件,调用相应的处理器。- 定时事件:让一段程序在指定的时间之后执行一次,比如让程序X在当前时间
30ms之后执行一次 - 周期事件:让一段程序每隔指定时间就执行一次,比如让程序
Y每隔30ms就执行一次
- 定时事件:让一段程序在指定的时间之后执行一次,比如让程序X在当前时间
文件事件的处理
- 事件分派器(
Initiation Dispatcher) - IO多路复用(
Synchronous Event Demultiplexer) - 事件循环调度
Redis key 删除策略
Redis是一种内存型数据库,数据都是存放在内存中的,内存中的数据可以通过TTL指令获取其状态- XX:具 有时效性的数据
- -1:永久有效的数据
- -2:已经过期的数据 / 被删除的数据 / 未定义的数据
删除 key 的方式
- 被动删除:当读/写一个已经过期的
key时,会触发惰性删除策略,直接删除掉这个过期key - 主动删除:由于惰性删除策略无法保证冷数据被及时删掉,所以
Redis会定期主动淘汰一批已过期的key - 当前已用内存超过
maxmemory限定时,触发主动清理策略
定时删除
- 创建一个定时器,当
key设置有过期时间,且过期时间到达时,由定时器任务立即执行对键的删除操作 - 优点:节约内存,到时就删除,快速释放掉不必要的内存占用
- 缺点:
CPU压力很大,无论CPU此时负载量多高,均占用CPU,会影响redis服务器响应时间和指令吞吐量 - 总结:用处理器性能换取存储空间(拿时间换空间)
惰性删除
- 数据到达过期时间,不做处理,等下次访问该数据时,如果未过期,返回数据,反之则删除并返回不存在
- 优点:节约
CPU性能,发现必须删除的时候才删除 - 缺点:内存压力很大,出现长期占用内存的数据
- 总结:用存储空间换取处理器性能(拿空间换时间)
定期删除
- 周期性轮询
redis库中的时效性数据,采用随机抽取的策略,利用过期数据占比的方式控制删除频度 - 特点1:
CPU性能占用设置有峰值,检测频度可自定义设置 - 特点2:内存压力不是很大,长期占用内存的冷数据会被持续清理
- 总结:周期性抽查存储空间(随机抽查,重点抽查)
逐出算法
Redis清理数据的策略称为逐出算法
内存淘汰机制
- 如果
Redis服务器打开了maxmemory选项,并且服务器占用的内存数超过了maxmemory选项所设置的上限值时,会进行内存淘汰,常见的淘汰策略如下:volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰volatile-random:从已设置过期时间的数据集中任意选择数据淘汰volatile-lfu:从已设置过期时间的数据集挑选使用频率最低的数据淘汰allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰allkeys-lfu:从数据集(server.db[i].dict)中挑选使用频率最低的数据淘汰allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰no-enviction:禁止驱逐数据,这也是默认策略。当内存不足以容纳新入数据时,新写入操作就会报错,请求可以继续进行。采用no-enviction策略可以保证数据不被丢失。
AOF vs. RDB
RDB
RDB(Redis DataBase)是Redis默认的持久化方案。在指定的时间间隔内,执行指定次数的写操作,则会将内存中的数据写入到磁盘中,即在指定目录下生成一个dump.rdb文件。Redis重启后会通过加载dump.rdb文件恢复数据。save(阻塞) / bgsave(异步) / flushall / flushall命令也会触发生成快照。- 优点
- 适合大规模的数据恢复
- 如果业务对数据完整性和一致性要求不高,
RDB是很好的选择
- 缺点
- 数据的完整性和一致性不高,因为可能在备份时宕机
- 备份时占用内存,因为在备份时会独立创建一个子进程,将数据写入到一个临时文件,最后再将临时文件替换之前的备份文件
AOF
- 默认不开启。是为了弥补RDB的不足(数据的不一致性),采用日志的形式来记录每个写操作,并追加到文件中。
Redis重启后会根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。根据配置文件触发,可以是每次执行触发,可以是每秒触发,可以是不同步。 - 重写机制
- 当
AOF文件的大小超过所设定的阈值时,Redis就会对AOF文件的内容压缩。 Redis会fork出一个新进程,读取内存中的数据,并重新写到一个临时文件中,最后替换旧的AOF文件。- 当
AOF文件大小是上次rewrite后大小的一倍且文件大于64M时触发。这里的一倍和64M可以通过配置文件修改。
- 当
- 优点
- 数据的完整性和一致性更高
- 缺点
- 因为
AOF记录的内容多,文件会越来越大,数据恢复也会越来越慢
- 因为
缓冲区
- 缓冲区的功能是用一块内存来暂存命令数据,避免出现因为数据和命令的处理速度慢于发送速度而导致的数据丢失和性能问题。但缓冲区的内存空间有限,如果发生溢出,就会丢失数据。
客户端输入缓冲区(请求)
- 会先把客户端发送过来的命令暂存起来,
Redis主线程再从输入缓冲区中读取命令,进行处理 - 溢出场景(会导致关闭客户端连接)
- 请求写入大量数据(
BigKey) - 生产速度大于消费速度,慢慢被填满
- 请求写入大量数据(
- 优化(缓冲区大小无法调整,服务端默认为每个客户端输入缓冲区分配的大小是
1GB)- 拆分请求数据,避免写入
BigKey - 定位处理服务端阻塞问题
- 拆分请求数据,避免写入
客户端输出缓冲区(响应)
- 会先把客户端发送过来的命令的响应暂存起来
- 溢出场景(会导致关闭客户端连接)
- 服务器端返回了大量数据
- 返回数据的速度太快,比如执行
MONITOR命令,它会持续输出监测到的各个命令操作 - 缓冲区大小设置得不合理
- 优化
- 避免
BigKey操作返回大量数据,避免在线上持续执行MONITOR命令 - 使用
client-output-buffer-limit设置合理的缓冲区大小上限,或是缓冲区连续写入时间和写入量上限
- 避免
复制缓冲区
- 暂存全量同步数据
- 溢出场景(会导致关闭同步连接)
- 从节点接收和加载
RDB较慢,同时主节点接收到了大量的写命令,写命令在复制缓冲区中就会越积越多
- 从节点接收和加载
- 优化
- 控制主节点保存的数据量大小,通常控制在
2 ~ 4GB - 通过
client-output-buffer-limit配置项来设置合理的复制缓冲区大小 - 复制缓冲区是在主节点上的,如果集群中的从节点非常多,主节点的内存开销就会非常大,必须得控制和主节点连接的从节点个数
- 控制主节点保存的数据量大小,通常控制在
复制积压缓冲区
- 暂存增量同步数据
- 溢出场景(会导致增量同步失败转为全量同步)
- 是一个大小有限的环形缓冲区,当写入速度比读取速度要快,会导入新写入的命令数据覆盖缓冲区中的旧命令数据
- 优化
- 通过
repl_backlog_size配置项来调整复制积压缓冲区的大小
- 通过