Redis
2024-11-22 17:10:46 0 举报
AI智能生成
Redis是一个开源、高性能的键值存储数据库,采用内存存储数据,速度非常快。它可以存储字符串、哈希、列表、集合、有序集合等多种类型的数据。由于Redis支持数据持久化,可以将数据保存到硬盘中,从而保证了数据的可靠性。Redis的应用广泛,可以用于缓存、消息队列、分布式锁等多个场景。
作者其他创作
大纲/内容
缓存
缓存的基本思想
使用场景
DB缓存,减轻服务器压力
提高系统响应
Session分离
分布式锁 (setNx)
大型网站缓存应用
常见缓存分类
客户端缓存
页面缓存
浏览器缓存
App缓存
服务端缓存(核心)
数据库级别缓存
Mysql Buffer Pool
平台级缓存
带有缓存特性的应用框架。 比如:GuavaCache 、EhCache、OSCache等
应用级缓存(重点)
具有缓存功能的中间件:Redis、Memcached、EVCache、Tair等
网络端缓存
Web代理缓存
可以缓存原生服务器的静态资源,比如样式、图片等。
常见的反向代理服务器比如大名鼎鼎的Nginx。
常见的反向代理服务器比如大名鼎鼎的Nginx。
边缘缓存
CDN(内容分发网络)
缓存的读写模式
Cache Aside Pattern(旁路缓存)
读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
更新的时候,先更新数据库,然后再删除缓存。
更新的时候,先更新数据库,然后再删除缓存。
为什么是删除缓存,而不是更新缓存呢?
高并发脏读的三种情况
先更新数据库,再更新缓存
先删缓存,在更新数据库
先更新数据库,在删除缓存(推荐)
Read/Write Through Pattern
应用程序只操作缓存,缓存操作数据库。
Read-Through(穿透读模式/直读模式):应用程序读缓存,缓存没有,由缓存回源到数据库,并写入缓存。(guava cache)
Write-Through(穿透写模式/直写模式):应用程序写缓存,缓存写数据库。 该种模式需要提供数据库的handler,开发较为复杂。
Write-Through(穿透写模式/直写模式):应用程序写缓存,缓存写数据库。 该种模式需要提供数据库的handler,开发较为复杂。
Write Behind Caching Pattern
应用程序只更新缓存。 缓存通过异步的方式将数据批量或合并后更新到DB中 不能时时同步,甚至会丢数据
NoSQL
关系型数据库的缺陷
- 性能瓶颈: 磁盘IO 性能低下。关系型数据库在查询数据和修改属性都是需要通过IO流去操作磁盘中的数据,这个过程本身就是重量级的。
- 扩展瓶颈:数据关系复杂,扩展性差,不便于大规模集群
解决方案
降低磁盘 IO 次数,越低越好
尽可能的去除数据间的关系,越简单越好
特点
分类
键值(Key-Value)
Redis
内存中
SSDB
硬盘中
列存储数据库
HBase、Cassandra
文档型数据库
MongoDB
图形(Graph)数据库
Neo4J
简介
概述
Redis (REmote DIctionary Server)(远程字典服务) 是用 C 语言开发的一个开源的高性能键值对(key-value)数据库。
特点
Redis是一个高性能 key/value内存型数据库
Redis支持丰富的数据类型 (String/set/zset/list/hash)
Redis支持持久化 内存数据 持久化到硬盘中
Redis单线程,单进程 效率高 线程安全 => Redis 实现分布式锁(集群)
Redis的优势
性能极高:Redis的读取速度为110000次/s,写入速度为:81000次/s
丰富的数据类型
原子操作:Redis的所有操作都具有原子性,要么成功执行,要么失败完全不执行。多个操作也支持事务,通过multi和exec指令包起来
丰富的特性:Redis还支持publicsh/subscribe(发布-订阅模式),通知key过期等特性
使用场景
(1)为热点数据加速查询(主要场景)。如热点商品、热点新闻、热点资讯、推广类等高访问量信息等。
(2)即时信息查询。如各位排行榜、各类网站访问统计、公交到站信息、在线人数信息(聊天室、网站)、设备信号等。
(3)时效性信息控制。如验证码控制、投票控制等。
(4)分布式数据共享。如分布式集群架构中的 session 分离
(5) 消息队列
(6)分布式锁
(7)延时操作
redis在2.8.0版本之后支持了Keyspace Notification功能,允许客户定于Pub/Sub频道,以便以某种方式接收影响Redis数据集的事件
例如:在订单产生之后,我们占用了库存,10分钟后去检验用户是否真正购买,如果没有购买,则将该单据设置为无效订单,同时还原库存
方案:我们在订单产生时,设置一个key,同时设置10分钟的过期时间,我们在后台实现一个监听器,监听key的时效,监听到redis的key失效后,检测用户是否真正购买,没有购买则设置该单据失效,还原库存
方案:我们在订单产生时,设置一个key,同时设置10分钟的过期时间,我们在后台实现一个监听器,监听key的时效,监听到redis的key失效后,检测用户是否真正购买,没有购买则设置该单据失效,还原库存
(8)排行榜相关问题
关系型数据库在排行榜问题上的查询速度普通偏慢,可以借助Redis的SortedSet进行热点数据的排序
例如:点赞排行榜,利用redis做一个SortedSet,然后以用户的openId作为userName,以用户的点赞数作为上面的score,然后针对每个用户做一个hash,通过zrangebyscore就可以按照点赞数获取排行榜,然后根据userName获取用户的hash信息。
例如:点赞排行榜,利用redis做一个SortedSet,然后以用户的openId作为userName,以用户的点赞数作为上面的score,然后针对每个用户做一个hash,通过zrangebyscore就可以按照点赞数获取排行榜,然后根据userName获取用户的hash信息。
(9)点赞、好友等互相关系的存储
利用Redis的集合,比如:并集、交集、差集等,将每个用户关注的人存储在一个集合里,就很容易实现双方的共同好友的功能
通讯协议及事件处理机制
Redis是单线程吗?
Redis 的单线程主要是指 Redis 的 网络 IO 和键值对读写【socket 获取、命令获取解析、命令的执行、数据响应】是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如 持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。
Redis 单线程为什么还能这么快?
因为它所有的数据都在内存中,所有的运算都是内存级别的运算,而且单线程避免了多线程的切换性能损耗问题。正因为 Redis 是单线程,所以要小心使用 Redis 指令,对于那些耗时的指令(比如keys),一定要谨慎使用,一不小心就可能会导致 Redis 卡顿。
Redis 单线程如何处理那么多的并发客户端连接?
Redis的IO多路复用:redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,依次放到文件事件分派器,事件分派器将事件分发给事件处理器【asEventLoop】
通讯协议
Redis 协议【RESP(REdisSerializationProtocol)】
Redis协议位于TCP层之上,即客户端和Redis实例保持双工的连接。
请求响应模式
串行的请求响应模式(ping-pong)
双工的请求响应模式(pipeline)
发布订阅模式(pub / sub)
原子化的批量请求响应模式(事务)
脚本化批量执行(lua)
请求数据格式
Redis客户端与服务器交互采用序列化协议(RESP)。 请求以字符串数组的形式来表示要执行命令的参数 Redis使用命令特有(command-specific)数据类型作为回复。
Redis通信协议的主要特点有: 客户端和服务器通过 TCP 连接来进行数据交互, 服务器默认的端口号为 6379 。 客户端和服务器发送的命令或数据一律以 `\r\n` (CRLF)结尾。
Redis通信协议的主要特点有: 客户端和服务器通过 TCP 连接来进行数据交互, 服务器默认的端口号为 6379 。 客户端和服务器发送的命令或数据一律以 `\r\n` (CRLF)结尾。
内联格式
规范格式(redis-cli)
命令处理流程
执行命令
协议响应格式
协议的解析及处理
协议解析
在Redis客户端键入命令后,Redis-cli会把命令转化为RESP协议格式,然后发送给服务器。服务器再对协议进行解析,
解析命令请求参数数量
格式: *n \r\n
循环解析请求参数
首字符必须是"$" ,使用"/r"定位到行尾,之间的数是参数的长度,从/n后到下一个"$"之间就是参数的值,循环解析直到没有"$"。
协议执行
- quit校验,如果是“quit”命令,直接返回并关闭客户端
- 命令语法校验,执行lookupCommand,查找命令(set),如果不存在则返回:“unknown command”错误。
- 参数数目校验,参数数目和解析出来的参数个数要匹配,如果不匹配则返回:“wrong number of arguments”错误。
- 此外还有权限校验,最大内存校验,集群校验,持久化校验等等。
校验成功后,会调用call函数执行命令,并记录命令执行时间和调用次数 如果执行命令时间过长还要记录慢查询日志
返回结果
执行命令后返回结果的类型不同则协议格式也不同,分为5类:状态回复、错误回复、整数回复、批量 回复、多条批量回复。
事件处理机制【epoll + aeEventLoop 】
Redis服务器是典型的事件驱动系统。
Redis 基于 Reactor 模式开发了自己的网络事件处理器 - 文件事件处理器(file event handler,简称为 FEH),而该处理器又是单线程的,所以redis设计为单线程模型。
文本事件(IO事件)
文件事件即Socket的读写事件,也就是IO事件。
客户端的连接、命令请求、数据回复、连接断开
客户端的连接、命令请求、数据回复、连接断开
单线程的Reactor模式(I/O多路复用的一种模式)
I/O多路复用就是一个线程管理多个Socket 。 Reactor pattern(反应器设计模式) 是一种为处理并发服务请求,并将请求提交到一个或者多个服务处理 程序的事件设计模式
Reactor模式是事件驱动的
- 有一个或多个并发输入源(文件事件)
- 有一个Service Handler
- 有多个Request Handlers
Reactor模式结构
- Handle:I/O操作的基本文件句柄,在linux下就是`fd(文件描述符)`
- Synchronous Event Demultiplexer :同步事件分离器,阻塞等待Handles中的事件发生。(操作系统)
- Reactor: 事件分派器,负责事件的注册,删除以及对所有注册到事件分派器的事件进行监控, 当事件发生时会调用Event Handler接口来处理事件。
- Event Handler: 事件处理器接口,这里需要Concrete Event Handler来实现该接口
- Concrete Event Handler:真实的事件处理器,通常都是绑定了一个handle,实现对可读事件 进行读 取或对可写事件进行写入的操作。
业务流程及时序图
- - 主程序向事件分派器(Reactor)注册要监听的事件
- - Reactor调用OS提供的事件处理分离器,监听事件(wait)
- - 当有事件产生时,Reactor将事件派给相应的处理器来处理 handle_event()
文件事件处理器(分派器)
I/O 多路复用程序会负责监听多个socket。
尽管文件事件可能并发出现, 但 I/O 多路复用程序会将所有产生事件的socket放入队列, 通过该队列以有序、同步且每次一个socket的方式向文件事件分派器传送socket。
尽管文件事件可能并发出现, 但 I/O 多路复用程序会将所有产生事件的socket放入队列, 通过该队列以有序、同步且每次一个socket的方式向文件事件分派器传送socket。
IO多路复用模型与选择
I/O多路复用就是通过一种机制,一个进程可以监视多个描述符(socket),一旦某个描述符就绪(一 般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
select
select 函数监视的文件描述符分3类,分别是:
- - writefds
- - readfds
- - exceptfds
调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时 (timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过 遍历fd列表,来找到就绪的描述符
优点
select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。 windows linux ...
缺点
单个进程打开的文件描述是有一定限制的,它由`FD_SETSIZE`设置,默认值是`1024`,采用数组存储 另外在检查数组中是否有文件描述需要读写时,采用的是线性扫描的方法,即不管这些socket是不是活跃的,都轮询一遍,所以效率比较低
poll
poll使用一个 pollfd的指针实现,pollfd结构包含了要监视的event和发生的event,不再使用select“参 数-值”传递的方式
优点
采样链表的形式存储,它监听的描述符数量没有限制,可以超过select默认限制的1024大小
缺点
另外在检查链表中是否有文件描述需要读写时,采用的是线性扫描的方法,即不管这些socket是不是活 跃的,都轮询一遍,所以效率比较低
epoll
优点
epoll 没有最大并发连接的限制,上限是最大可以打开文件的数目,举个例子,在1GB内存的机器上大约 是10万左 右 效率提升, epoll 最大的优点就在于它只管你“活跃”的连接 ,而跟连接总数无关,因此在实际的网络环境中, epoll 的效率就会远远高于 select 和 poll 。 epoll使用了共享内存,不用做内存拷贝
kqueue
优点:能处理大量数据,性能较高
事件处理器
连接处理函数 acceptTCPHandler
客户端向 Redis 建立 socket时,aeEventLoop 会调用 acceptTcpHandler 处理函数,服务器会为每 个链接创建一个 Client 对象,并创建相应文件事件来监听socket的可读事件,并指定事件处理函数
请求处理函数 readQueryFromClient
当客户端通过 socket 发送来数据后,Redis 会调用 readQueryFromClient 方法,readQueryFromClient 方法会调用 read 方法从 socket 中读取数据到输入缓冲区中,然后判断其大小是否大于系统设置的 `client_max_querybuf_len`,如果大于,则向 Redis返回错误信息,并关闭 client
命令回复处理器 sendReplyToClient
sendReplyToClient函数是Redis的命令回复处理器,这个处理器负责将服务器执行命令后得到的命令 回复通过套接字返回给客户端
1、将outbuf内容写入到套接字描述符并传输到客户端
2、aeDeleteFileEvent 用于删除文件写事件
1、将outbuf内容写入到套接字描述符并传输到客户端
2、aeDeleteFileEvent 用于删除文件写事件
时间事件
时间事件分为定时事件与周期事件
一个时间事件主要是属性
id(全局唯一id)
when (毫秒时间戳,记录了时间事件的到达时间)
timeProc(时间事件处理器,当时间到达时,Redis就会调用相应的处理器来处理事件)
serverCron
时间事件的最主要的应用是在redis服务器需要对自身的资源与配置进行定期的调整,从而确保服务器的 长久运行,这些操作由redis.c中的serverCron函数实现。该时间事件主要进行以下操作
1)更新redis服务器各类统计信息,包括时间、内存占用、数据库占用等情况。
2)清理数据库中的过期键值对。
3)关闭和清理连接失败的客户端。
4)尝试进行aof和rdb持久化操作。
5)如果服务器是主服务器,会定期将数据向从服务器做同步操作。
6)如果处于集群模式,对集群定期进行同步与连接测试操作。
1)更新redis服务器各类统计信息,包括时间、内存占用、数据库占用等情况。
2)清理数据库中的过期键值对。
3)关闭和清理连接失败的客户端。
4)尝试进行aof和rdb持久化操作。
5)如果服务器是主服务器,会定期将数据向从服务器做同步操作。
6)如果处于集群模式,对集群定期进行同步与连接测试操作。
server.hz
run_with_period
定时事件
- 让一段程序在指定的时间之后执行一次
- aeTimeProc(时间处理器)的返回值是AE_NOMORE
- 该事件在达到后删除,之后不会再重复。
定期时间
- 周期性事件:让一段程序每隔指定时间就执行一次
- aeTimeProc(时间处理器)的返回值不是AE_NOMORE
- 当一个时间事件到达后,服务器会根据时间处理器的返回值,对时间事件的 when 属性进行更新,让这 个事件在一段时间后再次达到。
- serverCron就是一个典型的周期性事件
aeEventLoop
aeEventLoop 是整个事件驱动的核心,Redis自己的事件处理机制
它管理着文件事件表和时间事件列表, 不断地循环处理着就绪的文件事件和到期的时间事件
它管理着文件事件表和时间事件列表, 不断地循环处理着就绪的文件事件和到期的时间事件
初始化
Redis 服务端在其初始化函数 initServer 中,会创建事件管理器 aeEventLoop 对象。
函数 aeCreateEventLoop 将创建一个事件管理器,主要是初始化 aeEventLoop 的各个属性值,比如 events 、 fired 、 timeEventHead 和 apidata :
函数 aeCreateEventLoop 将创建一个事件管理器,主要是初始化 aeEventLoop 的各个属性值,比如 events 、 fired 、 timeEventHead 和 apidata :
更詳細内容查看筆記...
Redis底层数据结构
Redis作为Key-Value存储系统,数据结构
Redis没有表的概念,Redis实例所对应的db以编号区分,db本身就是key的命名空间。
比如:user:1000作为key值,表示在user这个命名空间下id为1000的元素,类似于user表的id=1000的 行。
比如:user:1000作为key值,表示在user这个命名空间下id为1000的元素,类似于user表的id=1000的 行。
RedisDB结构
当redis 服务器初始化时,会预先分配16个数据库,所有数据库保存到结构 redisServer 的一个成员 redisServer.db 数组中, redisClient中存在一个名叫db的指针指向当前使用的数据库
RedisDB结构体源码
RedisObject结构
RedisObject代表了Redis中的数据对象,是redis的核心结构体
Redis必须让每个键都带有类型信息,使得程序可以检查键的类型,从而选择合适的处理方式
Redis的类型系统主要包括
- RedisObject对象
- 基于RedisObject对象的显式多态函数
- 基于RedisObject对象的类型检查
- 对RedisObject进行分配、共享和销毁的机制
结构信息概述
type 【4 bit】
type 字段表示对象的类型,占 4 位; REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有 序集合)。 当我们执行 type 命令时,便是通过读取 RedisObject 的 type 字段获得对象的类型
encoding 【4 bit】
encoding 表示对象的内部编码,占 4 位
每个对象有不同的实现编码
Redis 可以根据不同的使用场景来为对象设置不同的编码,大大提高了 Redis 的灵活性和效率。 通过 object encoding 命令,可以查看对象采用的编码方式
每个对象有不同的实现编码
Redis 可以根据不同的使用场景来为对象设置不同的编码,大大提高了 Redis 的灵活性和效率。 通过 object encoding 命令,可以查看对象采用的编码方式
LRU 【3 byte】
lru 记录的是对象最后一次被命令程序访问的时间,( 4.0 版本占 24 位,2.6 版本占 22 位)。
高16位存储一个分钟数级别的时间戳,低8位存储访问计数(lfu : 最近访问次数)
高16位存储一个分钟数级别的时间戳,低8位存储访问计数(lfu : 最近访问次数)
- lru【高16位】: 最后被访问的时间
- lfu【低8位】:最近访问次数
这里涉及到一个知识点:空转时长
空转时长:当前时间减去键的值对象的lru时间,就是该键的空转时长。object idletime命令可以打印出给定键的空转时长
如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存数超过了maxmemory选项所设定的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存
如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存数超过了maxmemory选项所设定的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存
refcount 【4 byte】
refcount 记录的是该对象被引用的次数,类型为整型。
refcount 的作用,主要在于对象的引用计数和内存回收。 当对象的refcount>1时,称为共享对象 Redis 为了节省内存,当有一些对象重复出现时,新的程序不会创建新的对象,而是仍然使用原来的对象。
ptr【8 byte】
ptr 指针指向具体的数据,比如:set hello world,ptr 指向包含字符串 world 的 SDS。
命令类型的检查和多态
当执行一个处理数据类型的命令时,Redis会执行以下步骤:
- 据给定的key,在数据库字典中查找和他相对应的redisObject,如果没找到则返回null
- 检查redisObject的type属性和执行命令所需的类型是否相等,如果不相等,则返回类型错误
- 根据redisObject的encoding属性所指定的编码,选择合适的操作函数来处理底层的数据结构
共享对象
Redis一般会把常见的值存放在一个共享对象中,这样可使程序避免了重复分配的麻烦,也节约了CPU的时间
redis预分配的值对象如
各种命令的返回值,比如成功时返回的OK,错误时返回的ERROR,命令入队事务时返回的QUEUE,等等
包括0在内,小于REDIS_SHARED_INTEGERS的所有整数(REDIS_SHARED_INTEGERS的默认值是10000)
共享对象只能被字典和双向链表这类能带有指针的数据结构使用。像整数集合和压缩列表这些只能保存字符串、整数等自勉之的内存数据结构
为什么redis不共享list对象、hash对象、set对象、zset对象,只共享字符串对象?
因为这些对象本身可以包含字符串对象,复杂度较高。
如果共享对象是保存字符串对象,那么验证操作的复杂度为O(1)
如果共享对象是保存字符串值的字符串对象,那么验证操作的复杂度为O(N)
如果共享对象是包含多个值的对象,其中值本身又是字符串对象,即其它对象中嵌套了字符串对象,比如list对象、hash对象、set对象、zset对象,那么验证操作的复杂度将会
如果对复杂度较高的对象创建共享对象,需要消耗很大的CPU,用这种消耗去换取内存空间,是不合适的
7种类型
字符串对象(SDS)
SDS(simple dynamic string 简单动态字符串)是一种用于存储二进制数据的结构,具有动态扩容的特点,其实现位域redis源码中的src/sds.h和src/sds.c中,是Redis的默认字符串表示,Redis当中的字符串并不是C语言中的字符串(以空字符"\0"结尾作为字符串结束的判断依据)
- sdshdr表示头部;buf表示数据+"\0"
- 这个结构除了能存储二进制数据,还能作为字符串使用,所以在buf后面始终跟着一个"\0"
SDS(Simple Dynamic String)结构
C语言: 字符数组 "\0"
图中有5中不同的头部结构,其中sdshdr5实际并未使用到,所以实际是有4种不同的头部
- len:保存了SDS所保存的字符串的长度
- buf[]:数组用于保存字符串的每个字符元素
- alloc:分别以uint8、uint16、uint32、uint64表示整个SDS,除了头部和尾部的"\0",剩余的字节数
- flags:始终为一字节,以低三位表示头部类型,高5位未使用
数据大小计算: buf[] 的长度 = len+free+1 (1 表示 \0)
SDS的优势
① SDS 在 C 字符串的基础上加入了 free 和 len 字段,获取字符串长度:SDS 是 O(1),C 字符串是 O(n) buf数组的长度=free+len+1
② SDS 由于记录了长度,在可能造成缓冲区溢出时会自动重新分配内存,杜绝了缓冲区溢出。
③ 可以存取二进制数据,以字符串长度len来作为结束标识
使用场景
SDS的主要应用在:存储字符串和整型数据、存储key、AOF缓冲区和用户输入缓冲
为什么要自定义SDS
自定义SDS的好处主要从性能和安全方面体现
① 常数复杂度获取字符串长度
由于SDS中len的存在,获取SDS字符串的长度的时间复杂度为O(1),对于C语言则需要遍历字符串来计数,时间复杂度为O(n),Redis中可以通过strlen key命令获取字符串长度
② 杜绝缓冲区溢出
在C语言中通常使用strcat函数来进行两个字符串的拼接,一旦没有分配足够的内存空间,就会造成缓冲区溢出,而对于SDS数据类型,在进行字符串修改时,首先会根据记录的len属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后再进行修改操作,不会产生缓冲区溢出的情况
③ 减少修改字符串的内存重新分配次数
C语言中对于修改的字符串,由于没有记录字符串的长度,修改后必须重新分配内存(先释放再申请内存),因为不重新分配内存就会导致如下问题:
1. 字符串长度增大,内存溢出
2. 字符串长度减小,内存泄漏
1. 字符串长度增大,内存溢出
2. 字符串长度减小,内存泄漏
对于SDS而言,由于存在len和alloc属性,对于修改字符串,SDS实现了空间预分配和惰性空间释放两种策略
- 空间预分配:对字符串进行空间扩展的时候,扩展的内存比实际需要的多,这样就可以减少连续执行字符串增长操作所需要的内存重新分配的次数
- 惰性空间释放:对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后的多余空字节,而是使用alloc属性,将这些字节的数量记录下来,等待后续使用。必要情况下可以通过调用Redis的API手动释放alloc记录的未使用空间
④ 二进制安全
因为C语言中字符串以空字符串作为字符串结束的标识,而对于一些二进制文件,例如图片等,内容中可能包括空字符串,因此C语言中的字符串是无法正常存取的;
所有的SDS的API都是以处理二进制的方式来处理buf中的元素,并且SDS不是以空字符串来判断是否结束,而是以len属性表示的长度来判断字符串是否结束的
所有的SDS的API都是以处理二进制的方式来处理buf中的元素,并且SDS不是以空字符串来判断是否结束,而是以len属性表示的长度来判断字符串是否结束的
⑤ 兼容部分C字符串函数
虽然SDS是二进制安全的,但是一样遵从每个字符串都是以空字符串结尾的惯例,这样可以重用c语言库<string.h>中的一部分函数
很显然对于上述的描述中,最大的问题就是空间预分配的问题,是否会造成空间浪费? 答案是会
举个例子:
当执行追加操作时,比如在key="Hello world"的字符串后追加" again!",则这时len从11变成了18,根据空间预分配策略,free(空闲空间)从0变成了18,buf='Hello World again!\0..................'(.表示空闲空间),即buf的内存空间=18+18+1=37字节(1为"\0"所占用的空间),因此Redis给字符串多分配了18个字节的空间,下次append追加时,如果追加的字符串长度不超过当前free的长度,则无需重新分配内存空间
需要注意的是:在6.0版本中当新字符串长度小于1M时,Redis会分配它们所需大小1倍的空间,当大于1M时,就在当前基础上多分配1M的空间
这样确实会浪费内存,因为这些内存不会被释放,除非该字符串所对应的键被删除,或者重启Redis时,重新载入的字符串对象将不会有预分配空间。
举个例子:
当执行追加操作时,比如在key="Hello world"的字符串后追加" again!",则这时len从11变成了18,根据空间预分配策略,free(空闲空间)从0变成了18,buf='Hello World again!\0..................'(.表示空闲空间),即buf的内存空间=18+18+1=37字节(1为"\0"所占用的空间),因此Redis给字符串多分配了18个字节的空间,下次append追加时,如果追加的字符串长度不超过当前free的长度,则无需重新分配内存空间
需要注意的是:在6.0版本中当新字符串长度小于1M时,Redis会分配它们所需大小1倍的空间,当大于1M时,就在当前基础上多分配1M的空间
这样确实会浪费内存,因为这些内存不会被释放,除非该字符串所对应的键被删除,或者重启Redis时,重新载入的字符串对象将不会有预分配空间。
- ① 如果执行append操作的键很少,占用内存的体积通常不会太大,可以不用考虑内存问题
- ② 如果执行append操作的键很多,而且字符串的体积又很大,可能需要修改Redis的服务器,让它定时释放一些字符串键的预分配空间,从而更有效的使用内存
压缩列表(ziplist)
压缩列表(ziplist)是由一系列特殊编码的连续内存块组成的顺序型数据结构,节省内存
是一个字节数组,可以包含多个节点(entry)。每个节点可以保存一个字节数组或一个整数。 压缩列表的数据结构如下:
是一个字节数组,可以包含多个节点(entry)。每个节点可以保存一个字节数组或一个整数。 压缩列表的数据结构如下:
entry 结构(参考)
entry 结构
一般结构:<prevlen> <encoding> <entry-data>
prevlen:前一个entry的大小,编码方式如下
encoding:encoding的长度和值,根据保存的是int还是string,还有数据的长度而定
entry-data:用于存储entry表示的数据
在entry中存储的是int类型时,encoding和entry-data会合并在encoding中表示,此时没有entry-data字段
int类型结构:<prevlen> <encoding>
redis中,在存储数据时,会先尝试将string转换成int存储,节省空间
int类型结构:<prevlen> <encoding>
redis中,在存储数据时,会先尝试将string转换成int存储,节省空间
zipList的优缺点
优点:节省内存空间
ziplist节省内存是相对于普通的list来说的,如果是普通的数组,那么它每个元素占用的内存是一样的,且取决于最大的那个元素(很明显它是需要预留空间的),,所以ziplist在设计时就很容易想到要尽量,让每个元素按照实际的内容大小存储,所以增加encoding字段,针对不同的encoding来细化存储大小
遍历元素时如何定位下一个元素?在普通数组中每个元素定长,所以不需要考虑这个问题,但是ziplist中每个data占据的内存不一样
所以为了解决遍历问题,需要增加记录上一个元素的length,所以增加了prelen字段
所以为了解决遍历问题,需要增加记录上一个元素的length,所以增加了prelen字段
zipList的缺点
ziplist也不预留内存空间,并且在移除节点后,也是立即缩容,这代表每次写操作都会进行内存分配操作
节点如果扩容,会导致节点占用的内存增长,并且超过254字节的话,会导致扩容的链式反应:
- 其中一个节点的entry.prevlen需要从1字节扩容到5字节。
- 最坏情况下,第一个节点的扩容,会导致整个ziplist表中的后续所有节点的entry_prelen字段扩容。虽然这个内存重分配的操作依然只会发生一次,但是代码中的时间复杂度时O(n)级别,因为链式扩容只能一步一步的计算。但是这种情况的概率十分的小,一般情况下链式扩容能连锁反应五六次就很不幸了
优化
可以通过设置每个ziplist的最大容量,quicklist的数据压缩范围,提升数据存取效率:
list-max-ziplist-size -2
单个ziplist节点最大能存储 8kb ,超过则进行分裂,将数据存储在新的ziplist节点中
# -5: max size: 64 Kb <-- not recommended for normal workloads
# -4: max size: 32 Kb <-- not recommended
# -3: max size: 16 Kb <-- probably not recommended
# -2: max size: 8 Kb <-- good
# -1: max size: 4 Kb <-- good
# -5: max size: 64 Kb <-- not recommended for normal workloads
# -4: max size: 32 Kb <-- not recommended
# -3: max size: 16 Kb <-- probably not recommended
# -2: max size: 8 Kb <-- good
# -1: max size: 4 Kb <-- good
list-compress-depth 1
0 代表所有节点,都不进行压缩,1, 代表从头节点往后走一个,尾节点往前走一个不用压缩,其他的全部压缩,2,3,4 ... 以此类推
应用场景
sorted-set和hash元素个数少且是小整数或短字符串(直接使用) list用快速链表(quicklist)数据结构存储,而快速链表是双向列表与压缩列表的组合(间接使用)
快速列表(quicklist)
是列表(list)底层实现
(在Redis3.2之前,Redis采用双向链表(adlist)和压缩列表(ziplist)实现。)在Redis3.2以后结合adlist和ziplist的优势Redis设计出了quicklist。
(在Redis3.2之前,Redis采用双向链表(adlist)和压缩列表(ziplist)实现。)在Redis3.2以后结合adlist和ziplist的优势Redis设计出了quicklist。
双向列表(addlist)
双向列表的优势
1. 双向:链表具有前置节点和后置节点的引用,获取这两个节点时间复杂度都为O(1)。
2. 普通链表(单链表):节点类保留下一节点的引用。链表类只保留头节点的引用,只能从头节点插 入删除
3. 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问都是以 NULL 结束。
环状:头的前一个节点指向尾节点
4. 带链表长度计数器:通过 len 属性获取链表长度的时间复杂度为 O(1)。
5. 多态:链表节点使用 void* 指针来保存节点值,可以保存各种不同类型的值。
快速列表
quicklist是一个双向链表,链表中的每个节点时一个ziplist结构。quicklist中的每个节点ziplist都能够存储多个数据元素。
结构体
数据压缩
quicklist每个节点的实际数据存储结构为ziplist,这种结构的优势在于节省存储空间。为了进一步降低 ziplist的存储空间,还可以对ziplist进行压缩。Redis采用的压缩算法是LZF。其基本思想是:数据与前面重复的记录重复位置及长度,不重复的记录原始数据。
压缩过后的数据可以分成多个片段,每个片段有两个部分:解释字段和数据字段。quicklistLZF的结构 体如下:
quicklist的注意事项
quicklist.fill:的值影响着每个链表节点中ziplist的长度
quicklist.compress:的值影响着quicklistNode.zl字段指向的原生的ziplist,还是指向经过压缩包装后的quicklistLZF
quicklistNode.encoding:字段,表示本链表节点所持有的ziplist是否经过了压缩
quicklistNode.container:字段,表示每个链表节点的数据类型是什么,默认的实现是ziplist。对应的该字段的值为2,目前redis没有提供其他quicklist底层数据类型的实现,所以该字段目前恒为2
quicklistNode.recompress:字段,表示当前节点所持有的ziplist是否经过了解压操作
1:表示之前被解压过,并且需要在下一次操作时重新压缩
由于每个节点所持有的ziplist是有上限长度的,所以在"与"操作时要考虑的分支情况比较多
从内存结构上看,quicklist类似于线性数据结构,list作为最传统的双链表,节点通过指针持有数据,指针字段会耗费大量内存,ziplist解决了这个问题,但是引出了新的问题:每次写操作整个ziplist的内存都需要重新分配,因此quicklist在两者之间做了一个平衡,并且提供给使用者可以通过自定义 quicklist.fill来调参
从内存结构上看,quicklist类似于线性数据结构,list作为最传统的双链表,节点通过指针持有数据,指针字段会耗费大量内存,ziplist解决了这个问题,但是引出了新的问题:每次写操作整个ziplist的内存都需要重新分配,因此quicklist在两者之间做了一个平衡,并且提供给使用者可以通过自定义 quicklist.fill来调参
应用场景:列表(List)的底层实现、发布与订阅、慢查询、监视器等功能。
字典(Hash)
字典dict又称散列表(hash),是用来存储键值对的一种数据结构。 Redis整个数据库是用字典来存储的 (数组+链表)
Redis 字典实现
字典(dict)
Hash表 (dictht)
hash表的数组初始容量为4,随着k-v存储量的增加需要对hash表数组进行扩容,新扩容量为当前量的一倍
Hash表节点(dictEntry)
val属性用来保存值,值可以是一个指针,也可以是uint64_t整数,也可以是int64_t整数。
注意这里还有一个指向下一个哈希表节点的指针,我们知道哈希表最大的问题是存在哈希冲突,如何解决哈希冲突,有开放地址法和链地址法。这里采用的便是链地址法,通过next这个指针可以将多个哈希值相同的键值对连接在一起,用来解决哈希冲突。
扩容和收缩
当哈希表保存的键值对太多或者太少时,就要通过rerehash(重新散列)来对哈希表进行相应的扩展或者收缩。具体步骤:
- ① 如果执行扩展操作,会基于原哈希表创建一个大小等于 ht[0].used*2n 的哈希表(也就是每次扩展都是根据原哈希表已使用的空间扩大一倍创建另一个哈希表)。相反 如果执行的是收缩操作,每次收缩是根据已使用空间缩小一倍创建一个新的哈希表。
- ② 重新利用上面的哈希算法,计算索引值,然后将键值对放到新的哈希表位置上。
- ③ 所有键值对都迁徙完毕后,释放原哈希表的内存空间。
触发扩容的条件
- 服务器目前没有执行BGSAVE命令或者BGREWRITEAOF命令,并且负载因子大于等于1。
- 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且负载因子大于等于5。
ps:负载因子 = 哈希表已保存节点数量 / 哈希表大小
渐进式 rehash
什么叫渐进式 rehash?
也就是说扩容和收缩操作不是一次性、集中式完成的,而是分多次、渐进式完成的。如果保存在Redis中的键值对只有几个几十个,那么 rehash 操作可以瞬间完成,但是如果键值对有几百万,几千万甚至几亿,那么要一次性的进行rehash,势必会造成Redis一段时间内不能进行别的操作。所以Redis采用渐进式rehash,这样在进行渐进式rehash期间,字典的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行增加操作,一定是在新的哈希表上进行的。
字典达到存储上限(阈值 0.75),需要rehash(扩容)
- 1) 初次申请 默认容量为4个dictEntry,非初次申请为当前hash表容量的一倍。
- 2) rehashidx=0 表示要进行rehash操作。
- 3) 新增加的数据在新的hash表h[1]
- 4) 修改、删除、查询在老hash表h[0]、新hash表h[1]中(rehash中)
- 5) 将老的hash表h[0]的数据重新计算索引值后全部迁移到新的hash表h[1]中,这个过程称为 rehash。
跳表(skiplist)
ZSet底层实现
- 查询: 二分查找
- 删除: 找到指定元素并删除每层的该元素即可
跳跃表特点
- 每层都是一个有序链表
- 查找次数近似于层数(1/2)
- 底层包含所有元素
- 空间复杂度 O(n) 扩充了一倍
跳表(skiplist)结构
完整的跳跃表结构体
优势
快速查找数据 O(logn)
可以在O(1)的时间复杂度下,快速获得跳跃表的头节点、尾结点、长度和高度。
整数集合(intset)
整数集合(intset) 是一个有序的(整数升序)、存储整数的连续存储结构
当Redis集合类型的元素都是整数并且都处在64位有符号整数范围内(2^64),使用该结构体存储
结构
- encoding: 可以存储类型为int16_t、int32_t 或者int64_t 的整数值,并且保证集合中不会出现重复元素。
- length:存储的整数的个数
- contents:指向实际存储数值的连续内存区域,就是一个数组;整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序排序,且数组中不包含任何重复项。
内存布局图
content数组里面每个元素的数据类型是由encoding来决定的,那么如果原来的数据类型是int16, 当我们再插入一个int32类型的数据时怎么办呢?
这就涉及到Redis对inset的二次改造:intset的升级
这就涉及到Redis对inset的二次改造:intset的升级
整数集合(intset)的升级
当在一个int16类型的整数集合中插入一个int32类型的值,整个集合的所有元素都会转换成int32类型。 整个过程有三步:
- ① 根据新元素的类型(比如int32),扩展整数集合底层数组的空间大小,并为新元素分配空间。
- ② 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
- ③ 最后改变encoding的值,length+1。
那么如果我们删除掉刚加入的int32类型时,会不会做一个降级操作呢?
不会。主要还是减少开销的权衡
不会。主要还是减少开销的权衡
流对象
流对象 stream主要由:消息、生产者、消费者和消费组构成
Redis Stream的底层主要使用了listpack(紧凑列表)和Rax树(基数树)
Rax 被用在 Redis Stream 结构里面用于存储消息队列,在 Stream 里面消息 ID 的前缀是时间戳 + 序号,这样的消息可以理解为时间序列消息。使用 Rax 结构 进行存储就可以快速地根据消息 ID 定位到具体的消息,然后继续遍历指定消息之后的所有消息
Rax 被用在 Redis Stream 结构里面用于存储消息队列,在 Stream 里面消息 ID 的前缀是时间戳 + 序号,这样的消息可以理解为时间序列消息。使用 Rax 结构 进行存储就可以快速地根据消息 ID 定位到具体的消息,然后继续遍历指定消息之后的所有消息
listpack(紧凑列表)
listpack表示一个字符串列表的序列化,listpack可用于存储字符串或整数。用于存储stream的消息内容。
Rax树(基数树)
Rax 是一个有序字典树 (基数树 Radix Tree),按照 key 的字典序排列,支持快速地定位、插入和删除操作。
Rax 被用在 Redis Stream 结构里面用于存储消息队列,在 Stream 里面 消息 ID 的前缀是时间戳+序号,这样的消息可以理解为时间序列消息。使用 Rax 结构 进行存储就可以快速地根据消息 ID 定位到具 体的消息,然后继续遍历指定消息 之后的所有消息。
应用场景: Stream 的底层实现
10种encoding
encoding 表示对象的内部编码,占 4 位。Redis通过 encoding 属性为对象设置不同的编码,对于少的和小的数据,Redis采用小的和压缩的存储方式,体现Redis的灵活性。大大提高了 Redis 的存储量和执行效率
object encoding [key] 查看类型
object encoding [key] 查看类型
String
int【REDIS_ENCODING_INT(int类型的整数))】
embstr 【REDIS_ENCODING_EMBSTR(编码的简单动态字符串)】
小字符串 长度小于44个字节
raw (REDIS_ENCODING_RAW (简单动态字符串))
大字符串 长度大于44个字节
类型
int: 能转化为数字的(不超过long类型)
直接用原本指向具体对象的指针存储数值
- - 节省了内存IO
- - 节省了内存空间
embstr:小于等于44字节
操作系统 CacheLine 一次读取 64byte
RedisObject: 16byte , 所以还剩余48个 byte,而且我们还需要用ptr的地址再去读取数据,那么剩余的48个byte能不能利用起来呢?
48type使用的应该是 sdshdr8 数据类型
`len: 1byte` 、 `alloc 1byte`、 `flags 1byte` ,Redis的C语言函数库会在buf的最后加上 `\0` ,所以我们可用的字节就是 44。
RedisObject: 16byte , 所以还剩余48个 byte,而且我们还需要用ptr的地址再去读取数据,那么剩余的48个byte能不能利用起来呢?
48type使用的应该是 sdshdr8 数据类型
`len: 1byte` 、 `alloc 1byte`、 `flags 1byte` ,Redis的C语言函数库会在buf的最后加上 `\0` ,所以我们可用的字节就是 44。
raw:大于44字节
raw和embstr的区别
embstr是专门用于保存短字符串的优化编码,浮点数类型也是作为字符串保存的,在需要时再将其转换为浮点数类型
ebmstr与raw都使用redisObject和sds保存数据,区别如下
ebmstr的使用只分配一次内存空间,因此redisObject和sds是连续的内存空间
好处:创建是少分配一次空间,删除时少删除一次空间,内存空间连续,方便查找
坏处:字符串长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配内存空间,因此redis中的embstr实现方式为只读
raw的使用需要分配两次内存空间,分别为redisObject和sds分配空间
list
快速列表(quicklist【REDIS_ENCODING_QUICKLIST】 )【双向链表+压缩列表】
内存布局
hash
散列的编码是字典和压缩列表
Hash 数据结构底层实现为一个字典( dict )。也是RedisBb用来存储K-V的数据结构,当数据量比较小,或者单个元素比较小时,底层用ziplist存储,数据大小和元素数量阈值可以通过如下参数设置(内存占用比较小)
- hash-max-ziplist-entries 512 // ziplist 元素个数超过 512 ,将改为hashtable编码
- hash-max-ziplist-value 64 // 单个元素大小超过 64 byte时,将改为hashtable编码
压缩列表(ziplist)【REDIS_ENCODING_ZIPLIST】
当散列表元素的个数比较少,且元素都是小整数或短字符串时
字典(dict)【REDIS_ENCODING_HT】
当散列表元素的个数比较多或元素不是小整数或短字符串时。
内存对象
上图中不严谨的地方有:
1. ziplist中每个entry` 除了键与值本身的二进制数据, 还包括其它字段, 图中没有画出来
2. dict底层可能持有两个dictht实例
3. 没有画出dict的哈希冲突
需要注意的是: 当采用HT编码, 即使用dict作为哈希对象的底层数据结构时, 键与值均是以`sds`的形式存储的。
1. ziplist中每个entry` 除了键与值本身的二进制数据, 还包括其它字段, 图中没有画出来
2. dict底层可能持有两个dictht实例
3. 没有画出dict的哈希冲突
需要注意的是: 当采用HT编码, 即使用dict作为哈希对象的底层数据结构时, 键与值均是以`sds`的形式存储的。
set
Set 数据结构底层实现为一个value 为 null 的 字典( dict ),当数据可以用整形表示时,Set集合将被编码为intset数据结构。两个条件任意满足时
Set将用hashtable存储数据。
set-max-intset-entries 512 // intset 能存储的最大元素个数,超过则用hashtable编码
Set将用hashtable存储数据。
- 元素个数大于 set-max-intset-entries
- 元素无法用整形表示
set-max-intset-entries 512 // intset 能存储的最大元素个数,超过则用hashtable编码
整形集合(intset)【REDIS_ENCODING_INTSET】
当Redis集合类型的元素都是整数并且都处在64位有符号整数范围内(<18446744073709551616)
字典(dict)【REDIS_ENCODING_HT】
当Redis集合类型的元素都是整数并且都处在64位有符号整数范围外(>18446744073709551616)
内存布局
集合对象的编码可以是intset或者hashtable; 底层实现有两种, 分别是intset和dict。 显然当使用intset作为底层实现的数据结构时, 集合中存储的只能是数值数据, 且必须是整数; 而当使用dict作为集合对象的底层实现时, 是将数据全部存储于dict的键中, 值字段闲置不用。
zset
有序集合的编码是压缩列表和(跳跃表+字典)
- zset-max-ziplist-entries 128 // 元素个数超过128 ,将用skiplist编码
- zset-max-ziplist-value 64 // 单个元素大小超过 64 byte, 将用 skiplist编码
压缩列表(dict)【REDIS_ENCODING_ZIPLIST】
当元素的个数比较少,且元素都是小整数或短字符串时
跳表+字典(skiplist + dict)【REDIS_ENCODING_SKIPLIST】
当元素的个数比较多或元素不是小整数或短字符串时
内存布局
首先是编码为ZIPLIST时, 有序集合的内存布局如下
然后是编码为SKIPLIST时, 有序集合的内存布局如下
那么为什么还要辅助一个dict实例呢?
说明:其实有序集合单独使用字典或跳跃表其中一种数据结构都可以实现,但是这里使用两种数据结构组合起来,原因是:
① 假如我们单独使用字典,虽然能以O(1)的时间复杂度查找成员的分值,但是因为字典是以无序的方式来保存集合元素,所以每次进行范围操作的时候都要进行排序;
② 假如我们单独使用跳跃表来实现,虽然能执行范围操作,但是查找操作由O(1)的复杂度变为了O(logN)。
因此Redis使用了两种数据结构来共同实现有序集合。
① 假如我们单独使用字典,虽然能以O(1)的时间复杂度查找成员的分值,但是因为字典是以无序的方式来保存集合元素,所以每次进行范围操作的时候都要进行排序;
② 假如我们单独使用跳跃表来实现,虽然能执行范围操作,但是查找操作由O(1)的复杂度变为了O(logN)。
因此Redis使用了两种数据结构来共同实现有序集合。
数据类型及指令
String
常用指令
注意事项
应用场景
单值缓存
对象缓存
分布式锁
Session共享
分布式系统全局序列号
INCRBY orderId 1000
缺点
如果在保存的键值对本身占用的内存空间不大时(例如图片 ID 和图片存储对象 ID 【16字节】),String 类型的元数据开销就占据主导了,这里面包括了 RedisObject 结构、SDS 结构、dictEntry 结构的内存开销。
RedisObject结构(16字节)
因为 Redis 的数据类型有很多,而且,不同数据类型都有些相同的元数据要记录(比如最后一次访问的时间、被引用的次数等),所以,Redis 会用一个 RedisObject 结构体来统一记录这些元数据,同时指向实际数据。
一个 RedisObject 包含了 8 字节的元数据和一个 8 字节指针,这个指针再进一步指向具体数据类型的实际数据所在
一个 RedisObject 包含了 8 字节的元数据和一个 8 字节指针,这个指针再进一步指向具体数据类型的实际数据所在
SDS结构
dictEntry结构(32字节)
Redis 会使用一个全局哈希表保存所有键值对,哈希表的每一项是一个 dictEntry 的结构体,用来指向一个键值对。dictEntry 结构中有三个 8 字节的指针,分别指向 key、value 以及下一个 dictEntry,三个指针共 24 字节
但是,这三个指针只有 24 字节,为什么会占用了 32 字节呢?这就要提到 Redis 使用的内存分配库 jemalloc 了。
jemalloc 在分配内存时,会根据我们申请的字节数 N,找一个比 N 大,但是最接近 N 的 2 的幂次数作为分配的空间,这样可以减少频繁分配的次数。// 如 2、4、8、16、32 这样数,大于 24 就只能选择 32 作为分配的空间了
jemalloc 在分配内存时,会根据我们申请的字节数 N,找一个比 N 大,但是最接近 N 的 2 的幂次数作为分配的空间,这样可以减少频繁分配的次数。// 如 2、4、8、16、32 这样数,大于 24 就只能选择 32 作为分配的空间了
List
元素有序 、可以重复
内存模型
常用指令
注意事项
应用场景
常用的数据结构
Stack(栈) = LPUSH + LPOP
Queue(队列)= LPUSH + RPOP
阻塞Queue(队列)= LPUSH + BRPOP
微博和微信公号消息流
微博消息和微信公号消息
Set
元素无序、不可重复
内存模型
常用指令
应用场景
微信抽奖小程序
微信微博点赞,收藏,标签
集合操作
交集、差集、并集
集合操作实现微博微信关注模型
Zset
可排序的 Set
内存模型
常用指令
应用场景
排行榜
Hash
内存模型
常用指令
注意事项
优缺点
优点
同类数据归类整合储存,方便数据管理
相比string操作消耗内存与cpu更小
相比string储存更节省空间
缺点
过期功能不能使用在field上,只能用在key(外层Key)上
Redis集群架构下不适合大规模使用
应用场景
对象存储
电商购物车
Bitmap
bitmap 底层实现还是使用的String类型
String 存储的是字符串, bitmap存储的字节数组
bitmap本身会极大的节省存储空间
优势:如果存储一年的打卡状态,则365天=365bit,1字节等于8bit,约46字节
常用指令
应用场景
用户每月签到,用户id为key , 日期作为偏移量 1表示签到
统计活跃用户, 日期为key,用户id为偏移量 1表示活跃
查询用户在线状态, 日期为key,用户id为偏移量 1表示在线
举例
geo
geo是Redis用来处理位置信息的。
在Redis3.2中正式使用。主要是利用了Z阶曲线、Base32编码和 geohash算法
在Redis3.2中正式使用。主要是利用了Z阶曲线、Base32编码和 geohash算法
Z阶曲线
在x轴和y轴上将十进制数转化为二进制数,采用x轴和y轴对应的二进制数依次交叉后得到一个六位数编 码。把数字从小到大依次连起来的曲线称为Z阶曲线,Z阶曲线是把多维转换成一维的一种方法。
Base32编码
Base32这种数据编码机制,主要用来把二进制数据编码成可见的字符串,其编码规则是:任意给定一 个二进制数据,以5个位(bit)为一组进行切分(base64以6个位(bit)为一组),对切分而成的每个组进行编 码得到1个可见字符。Base32编码表字符集中的字符总数为32个(0-9、b-z去掉a、i、l、o),这也是 Base32名字的由来。
geohash算法
Gustavo在2008年2月上线了geohash.org网站。Geohash是一种地理位置信息编码方法。 经过 geohash映射后,地球上任意位置的经纬度坐标可以表示成一个较短的字符串。可以方便的存储在数据 库中,附在邮件上,以及方便的使用在其他服务中。以北京的坐标举例,[39.928167,116.389550]可以 转换成 wx4g0s8q3jf9
Redis中经纬度使用52位的整数进行编码,放进zset中,zset的value元素是key,score是GeoHash的 52位整数值。在使用Redis进行Geo查询时,其内部对应的操作其实只是zset(skiplist)的操作。通过zset 的score进行排序就可以得到坐标附近的其它元素,通过将score还原成坐标值就可以得到元素的原始坐标。
常用指令
举例
Stream
stream是Redis5.0后新增的数据结构,用于可持久化的消息队列(借鉴了 Kafka的设计思路)
Redis Stream 的结构如上图所示,每一个Stream都有一个消息链表,将所有加入的消息都串起来,每个消息都有一个唯一的 ID 和对应的内容。消息是持久化的,Redis 重启后,内容还在。
每个 Stream 都可以挂多个消费组,每个消费组会有个游标last_delivered_id在 Stream 数组之上往前移动,表示当前消费组已经消费到哪条消息了。每个消费组都有一个 Stream 内唯一的名称,消费组不会自动创建,它需要单独的指令xgroup create进行创建,需要指定从 Stream 的某个消息 ID 开始消费,这个 ID 用来初始化last_delivered_id变量。
每个消费组 (Consumer Group) 的状态都是独立的,相互不受影响。也就是说同一份 Stream 内部的消息会被每个消费组都消费到。
同一个消费组 (Consumer Group) 可以挂接多个消费者 (Consumer),这些消费者之间是竞争关系,任意一个消费者读取了消息都会使游标last_delivered_id往前移动。每个消费者有一个组内唯一名称。
同一个消费组 (Consumer Group) 可以挂接多个消费者 (Consumer),这些消费者之间是竞争关系,任意一个消费者读取了消息都会使游标last_delivered_id往前移动。每个消费者有一个组内唯一名称。
消费者 (Consumer) 内部会有个状态变量pending_ids,它记录了当前已经被客户端读取,但是还没有 ack的消息。如果客户端没有 ack,这个变量里面的消息 ID 会越来越多,一旦某个消息被 ack,它就开始减少。这个 pending_ids 变量在 Redis 官方被称之为PEL(Pending Entries List),这是一个很核心的数据结构,它用来确保客户端至少消费了消息一次,而不会在网络传输的中途丢失了没处理。
消息 ID 的形式是timestampInMillis-sequence,例如1527846880572-5,它表示当前的消息在毫米时间戳1527846880572时产生,并且是该毫秒内产生的第 5 条消息。消息 ID 可以由服务器自动生成,也可以由客户端自己指定,但是形式必须是整数-整数,而且必须是后面加入的消息的 ID 要大于前面的消息 ID。
功能
消息ID的序列化生成
消息遍历
消息的阻塞和非阻塞读取
消息的分组消费
未完成消息的处理
消息队列监控
常用指令
HyperLogLog
HyperLogLog 并不是一种新的数据结构(实际类型为字符串类型),而是一种基数算法,通过HyperLogLog可以利用极小的内存空间完成独立总数的统计
HyperLogLog 的原因
如果你负责开发维护一个大型的网站,有一天产品经理要网站每个网页每天的 UV 数据(用户访问量),然后让你来开发这个统计模块,你会如何实现?
如果统计 PV(页面访问量) 那非常好办,给每个网页一个独立的 Redis 计数器就可以了,这个计数器的 key 后缀加上当天的日期。这样来一个请求,incrby 一次,最终就可以统计出所有的 PV 数据。
但是 UV 不一样,它要去重,同一个用户一天之内的多次访问请求只能计数一次。这就要求每一个网页请求都需要带上用户的 ID,无论是登陆用户还是未登陆用户都需要一个唯一 ID 来标识。
一个简单的方案,那就是为每一个页面一个独立的 set 集合来存储所有当天访问过此页面的用户 ID。当一个请求过来时,我们使用 sadd 将用户 ID 塞进去就可以了。通过 scard 可以取出这个集合的大小,这个数字就是这个页面的 UV 数据。
但是,如果你的页面访问量非常大,比如一个爆款页面几千万的 UV,你需要一个很大的 set 集合来统计,这就非常浪费空间。如果这样的页面很多,那所需要的存储空间是惊人的。为这样一个去重功能就耗费这样多的存储空间,值得么?其实需要的数据又不需要太精确,1050w 和 1060w 这两个数字对于老板们来说并没有多大区别,So,有没有更好的解决方案呢?
如果统计 PV(页面访问量) 那非常好办,给每个网页一个独立的 Redis 计数器就可以了,这个计数器的 key 后缀加上当天的日期。这样来一个请求,incrby 一次,最终就可以统计出所有的 PV 数据。
但是 UV 不一样,它要去重,同一个用户一天之内的多次访问请求只能计数一次。这就要求每一个网页请求都需要带上用户的 ID,无论是登陆用户还是未登陆用户都需要一个唯一 ID 来标识。
一个简单的方案,那就是为每一个页面一个独立的 set 集合来存储所有当天访问过此页面的用户 ID。当一个请求过来时,我们使用 sadd 将用户 ID 塞进去就可以了。通过 scard 可以取出这个集合的大小,这个数字就是这个页面的 UV 数据。
但是,如果你的页面访问量非常大,比如一个爆款页面几千万的 UV,你需要一个很大的 set 集合来统计,这就非常浪费空间。如果这样的页面很多,那所需要的存储空间是惊人的。为这样一个去重功能就耗费这样多的存储空间,值得么?其实需要的数据又不需要太精确,1050w 和 1060w 这两个数字对于老板们来说并没有多大区别,So,有没有更好的解决方案呢?
这就是HyperLogLog 的用武之地,Redis 提供了 HyperLogLog 数据结构就是用来解决这种统计问题的。HyperLogLog 提供不精确的去重计数方案,虽然不精确但是也不是非常不精确,Redis官方给出标准误差是 0.81%,这样的精确度已经可以满足上面的 UV 统计需求了。
- PV 数据(页面访问量): 以页面为基准,访问一次页面添加1(一个用户访问10次 10次PV)
- UV 数据(用户访问量): 需要去重(一个用户访问10次 1次UV)
操作指令
pfadd key element [element …]
pfadd用于向HyperLogLog 添加元素,如果添加成功返回1:
pfcount key [key …]
pfcount用于计算一个或多个HyperLogLog的独立总数,例如08-15:u:id的独立总数为4:
pfmerge destkey sourcekey [sourcekey ... ]
pfmerge可以求出多个HyperLogLog的并集并赋值给destkey
原理概述
基本原理
HyperLogLog基于概率论中伯努利试验并结合了极大似然估算方法,并做了分桶优化(增大样本数尽可能的使结果降低误差)
伯努利试验
k是每回合抛到1(硬币的正面)所用的次数,我们已知的是最大的k值,也就是Mark老师告诉Fox老师的数,可以用k_max表示。由于每次抛硬币的结果只有0和1两种情况,因此,能够推测出k_max在任意回合出现的概率 ,并由kmax结合极大似然估算的方法推测出n的次数n = 2^(k_max) 。概率学把这种问题叫做伯努利实验
现在Mark老师已经完成了n个回合,并且告诉Fox老师最长的一次抛了4次,Fox老师此时也胸有成竹,马上说出他的答案16,最后的结果是:Mark老师只抛了3回合,
这三个回合中k_max=4,放到公式中,Fox老师算出n=2^4,于是推测Mark老师抛了16个回合,但是Fox老师输了,要负责买奶茶一个星期。
所以这种预估方法存在较大误差,为了改善误差情况,HLL中引入分桶平均的概念。
这三个回合中k_max=4,放到公式中,Fox老师算出n=2^4,于是推测Mark老师抛了16个回合,但是Fox老师输了,要负责买奶茶一个星期。
所以这种预估方法存在较大误差,为了改善误差情况,HLL中引入分桶平均的概念。
分桶平均
将统计数据划分为m个桶,每个桶分别统计各自的k_max, 并能得到各自的基数预估值,最终对这些基数预估值求平均得到整体的基数估计值。LLC中使用几何平均数预估整体的基数值,但是当统计数据量较小时误差较大;HLL在LLC基础上做了改进,采用调和平均数过滤掉不健康的统计值。
什么叫调和平均数呢?
求平均工资:A的是1000/月,B的30000/月。采用平均数的方式就是: (1000 + 30000) / 2 = 15500
采用调和平均数的方式就是: 2/(1/1000 + 1/30000) ≈ 1935.484
可见调和平均数比平均数的好处就是不容易受到大的数值的影响,比平均数的效果是要更好的。
采用调和平均数的方式就是: 2/(1/1000 + 1/30000) ≈ 1935.484
可见调和平均数比平均数的好处就是不容易受到大的数值的影响,比平均数的效果是要更好的。
Redis的实现
HyperLogLog 占据12KB(占用内存为=16834 * 6 / 8 / 1024 = 12K)的大小,共设有 16384 个桶,即:2^14 = 16384,每个桶有 6 位(Byte字节),每个桶可以表达的最大数字是:25+24+...+1 = 63 (bit),二进制为: 111 111
对于命令:pfadd key value
在存入时,value 会被 hash 成 64 位,即 64 bit 的比特字符串,前 14 位用来分桶,剩下50位用来记录第一个1出现的位置。
index 的转化规则:
首先因为完整的 value 比特字符串是 64 位形式,减去 14 后,剩下 50 位,假设极端情况,出现 1 的位置,是在第 50 位,即位置是 50。此时 index = 50。此时先将 index 转为 2 进制,它是:110010 。
对于命令:pfadd key value
在存入时,value 会被 hash 成 64 位,即 64 bit 的比特字符串,前 14 位用来分桶,剩下50位用来记录第一个1出现的位置。
index 的转化规则:
首先因为完整的 value 比特字符串是 64 位形式,减去 14 后,剩下 50 位,假设极端情况,出现 1 的位置,是在第 50 位,即位置是 50。此时 index = 50。此时先将 index 转为 2 进制,它是:110010 。
value 被转为 64 位的比特串,最终被按照上面的做法记录到每个桶中去。64 位转为十进制就是:2^64,HyperLogLog 仅用了:16384 * 6 / 8 / 1024 =12K 存储空间就能统计多达 2^64 个数。
同时,在具体的算法实现上,HLL还有一个 分阶段偏差修正算法
各个数据类型的最大存储量
数据库操作指令
操作key的指令
keys:全量遍历键
用来列出所有满足特定正则字符串规则的key,当redis数据量比较大时,性能比较差,要避免使用
scan:渐进式遍历键
SCAN cursor [MATCH pattern] [COUNT count]
scan 参数提供了三个参数,第一个是 cursor 整数值(hash桶的索引值),第二个是 key 的正则模式,第三个是一次遍历的key的数量(参考值,底层遍历的数量不一定),并不是符合条件的结果数量。第一次遍历时,cursor 值为 0,然后将返回结果中第一个整数值作为下一次遍历的 cursor。一直遍历到返回的 cursor 值为 0 时结束。
注意:但是scan并非完美无瑕, 如果在scan的过程中如果有键的变化(增加、 删除、 修改) ,那么遍历效果可能会碰到如下问题: 新增的键可能没有遍历到, 遍历出了重复的键等情况, 也就是说scan并不能保证完整的遍历出来所有的键, 这些是我们在开发时需要考虑的。
持久化机制
快照(snapshot) / RDB
某一时刻的所有数据都写入硬盘中,当然这也是redis的默认开启持久化方式,保存的文件是以.rdb形式结尾的文件
生产快照方式
客户端
save
SAVE命令并不常用,使用SAVE命令在快照创建完毕之前,redis处于阻塞状态,无法对外服务
bgsave
Redis 借助操作系统提供的写时复制技术(Copy-On-Write, COW),在生成快照的同时,依然可以正常处理写命令
- - Redis父进程首先判断:当前是否在执行save,或bgsave/bgrewriteaof(aof文件重写命令)的子 进程,如果在执行则bgsave命令直接返回。
- - 父进程执行fork(调用OS函数复制主进程)操作创建子进程,这个复制过程中父进程是阻塞的,Redis 不能执行来自客户端的任何命令。
- - 父进程fork后,bgsave命令返回”Background saving started”信息并不再阻塞父进程,并可以响 应其他命令。
- - 子进程创建RDB文件,根据父进程内存快照生成临时快照文件,完成后对原有文件进行原子替换。 (RDB始终完整)
- - 子进程发送信号给父进程表示完成,父进程更新统计信息。
- - 父进程fork子进程后,继续工作。
save & bgsave 对比
服务端
配置(bgsave)
save m n
服务器接收客户端shutdown指令
RDB文件结构
1、头部5字节固定为“REDIS”字符串
2、4字节“RDB”版本号(不是Redis版本号),当前为9,填充后为0009
3、辅助字段,以key-value的形式
4、存储数据库号码
5、字典大小
6、过期key
7、主要数据,以key-value的形式存储
8、结束标志
9、校验和,就是看文件是否损坏,或者是否被修改。
可以用winhex打开dump.rdb文件查看。
优缺点
AOF
1.开启AOF持久化
- a.修改 appendonly yes 开启持久化
- b.修改 appendfilename "appendonly.aof" 指定生成文件名称
- a.修改 appendonly yes 开启持久化
- b.修改 appendfilename "appendonly.aof" 指定生成文件名称
原理
命令传播
当一个 Redis 客户端需要执行命令时, 它通过网络连接, 将协议文本发送给 Redis 服务器。服务器在 接到客户端的请求之后, 它会根据协议文本的内容, 选择适当的命令函数, 并将各个参数从字符串文 本转换为 Redis 字符串对象( StringObject )。每当命令函数成功执行之后, 命令参数都会被传播到 AOF 程序。
缓存追加
当命令被传播到 AOF 程序之后, 程序会根据命令以及命令的参数, 将命令从字符串对象转换回原来的 协议文本。协议文本生成之后, 它会被追加到 redis.h/redisServer 结构的 `aof_buf` 末尾。 `redisServer` 结构维持着 Redis 服务器的状态, `aof_buf` 域则保存着所有等待写入到 AOF 文件的协 议文本(RESP)。
文件写入和保存
每当服务器常规任务函数被执行、 或者事件处理器被执行时, aof.c/flushAppendOnlyFile 函数都会被调用, 这个函数执行以下两个工作:
- WRITE:根据条件,将 aof_buf 中的缓存写入到 AOF 文件。
- SAVE:根据条件,调用 fsync 或 fdatasync 函数,将 AOF 文件保存到磁盘中。
- WRITE:根据条件,将 aof_buf 中的缓存写入到 AOF 文件。
- SAVE:根据条件,调用 fsync 或 fdatasync 函数,将 AOF 文件保存到磁盘中。
持久化策略
appendfsync everysec|always|no
appendfsync everysec|always|no
always
每个redis写命令都要同步写入硬盘,严重降低redis速度
everysec
每秒执行一次同步显式的将多个写命令同步到磁盘
no
由操作系统决定何时同步
重写(ReWrite)机制
重写aof文件的操作,并没有读取旧的aof文件,而是将整个内存中的数据库内容用命令的方式重写了一个新的aof文件,替换原有的文件这点和快照有 类似。 这个过程是非常耗时的
1. redis调用fork ,现在有父子两个进程 子进程根据内存中的数据库生成快照,然后往临时文件中写入重建数据库状态的命令
2. 父进程继续处理client请求,除了把写命令写入到原来的aof文件中。同时把收到的写命令缓存起来。这样就能保证如果子进程重写失败的话并不会出问题。
3. 当子进程把快照内容写入已命令方式写到临时文件中后,子进程发信号通知父进程。然后父进程把缓存的写命令也写入到临时文件。
4. 现在父进程可以使用临时文件替换老的aof文件,并重命名,后面收到的写命令也开始往新的aof文件中追加。
重写触发方式
客户端命令触发
`BGREWRITEAOF`命令 不会阻塞redis的服务
服务器配置自动触发
- auto-aof-rewrite-min-size 64mb //aof文件至少要达到64M才会自动重写,文件太小恢复速度本来就很快,重写的意义不大
- auto-aof-rewrite-percentage 100 //aof文件自上一次重写后文件大小增长了100%则再次触发重写
混合持久化(redis 4.0 +)
aof-use-rdb-preamble yes // 开启混合持久化(必须先开启 AOF)
如果开启了混合持久化,AOF在重写时,不再是单纯将内存数据转换为RESP命令写入AOF文件,而是将重写这一刻之前的内存做RDB快照处理,并且将RDB快照内容和增量的AOF修改内存数据的命令存在一起,都写入新的AOF文件,新的文件一开始不叫appendonly.aof,等到重写完新的AOF文件才会进行改名,覆盖原有的AOF文件,完成新旧两个AOF文件的替换。
使用混合持久化可以关掉 RDB
Redis数据备份策略
写crontab定时调度脚本,每小时都copy一份rdb或aof的备份到一个目录中去,仅仅保留最近48小时的备份
每天都保留一份当日的数据备份到一个目录中去,可以保留最近1个月的备份
每次copy备份的时候,都把太旧的备份给删了
每天晚上将当前机器上的备份复制一份到其他机器上,以防机器损坏
生产环境使用
过期数据删除与淘汰策略
过期数据
已经过期了但是还没有被删除的数据
TTL返回的值有三种情况:正数,-1,-2
- 正数:代表该数据在内存中还能存活的时间
- -1:永久有效的数据
- -2 :已经过期的数据 或被删除的数据 或 未定义的数据
时效性数据存储格式
过期数据是一块独立的存储空间,Hash结构,field(key)是内存地址,value是过期时间,保存了所有key的过期描述,在最终进行过期处理的时候,对该空间的数据进行检测, 当时间到期之后通过field找到内存该地址处的数据,然后进行相关操作。
删除策略
定时删除
创建一个定时器,当key设置有过期时间,且过期时间到达时,由定时器任务立即执行对键的删除操作
优点:节约内存,到时就删除,快速释放掉不必要的内存占用
缺点:CPU压力很大,无论CPU此时负载量多高,均占用CPU,会影响redis服务器响应时间和指令吞吐量
总结:用处理器性能换取存储空间(拿时间[CPU的处理时间]换空间)
惰性删除
定期删除
淘汰策略
热点数据选择的淘汰策略?
当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重。这时使用LFU可能更好点
主从模式下淘汰?
只有主结点才会执行过期删除策略,然后把删除操作”del key”同步到从结点删除数据
当新数据进入redis时,如果内存不足,在执行每一个命令前,会调用freeMemoryIfNeeded()检测内存是否充足`。如果内存不满足新 加入数据的最低存储要求,redis要临时删除一些数据为当前指令清理存储空间
策略配置
最大可使用内存(默认是0,表示不限制)
maxmemory ?mb
每次选取待删除数据的个数
maxmemory-samples count
对数据进行删除的选择策略
maxmemory-policy policy
LFU算法介绍
在redis中每个对象都有24 bits空间来记录LRU/LFU信息
当这24 bits用作LFU时,其被分为两部分:
① 高16位用来记录访问时间(单位为分钟)
② 低8位用来记录访问频率,简称counter
① 高16位用来记录访问时间(单位为分钟)
② 低8位用来记录访问频率,简称counter
counter:基于概率的对数计数器
这里读者可能会有疑问,8 bits最大值也就是255,只用8位来记录访问频率够用吗?对于counter,redis用了一个trick的手段,counter并不是一个简单的线性计数器,而是用基于概率的对数计数器来实现,算法如下
对应的概率分布计算公式为: 1/((counter-LFU_INIT_VAL)*server.lfu_log_factor+1)
其中LFU_INIT_VAL为5,我们看下概率分布图会有一个更直观的认识,以默认server.lfu_log_factor=10为例
从上图可以看到,counter越大,其增加的概率越小,8 bits也足够记录很高的访问频率,下表是不同概率因子server.lfu_log_factor与访问频率counter的对应关系
也就是说,默认server.lfu_log_factor 为10的情况下,8 bits的counter可以表示1百万的访问频率
也就是说,默认server.lfu_log_factor 为10的情况下,8 bits的counter可以表示1百万的访问频率
counter的衰减因子
counter增长函数LFULogIncr中我们可以看到,随着key的访问量增长,counter最终都会收敛为255,这就带来一个问题,如果counter只增长不衰减就无法区分热点key。
为了解决这个问题,redis提供了衰减因子server.lfu_decay_time,其单位为分钟,计算方法也很简单,如果一个key长时间没有访问那么它的计数器counter就要减少,减少的值由衰减因子来控制:
为了解决这个问题,redis提供了衰减因子server.lfu_decay_time,其单位为分钟,计算方法也很简单,如果一个key长时间没有访问那么它的计数器counter就要减少,减少的值由衰减因子来控制:
默认为1的情况下也就是N分钟内没有访问,counter就要减N。
概率因子和衰减因子均可配置,推荐使用redis的默认值即可:
概率因子和衰减因子均可配置,推荐使用redis的默认值即可:
常见的淘汰策略
FIFO (先进先出算法)
如果一个数据最先进入缓存中,则应该最早淘汰掉。换句话说:最先进来的数据,被认为在未来被访问的概率也是最低的,
优点
最简单、最公平的一种数据淘汰算法,逻辑简单清晰,易于实现
缺点
这种算法逻辑设计所实现的缓存的命中率是比较低的,因为没有任何额外逻辑能够尽可能的保证常用数据不被淘汰掉
LRU(适用于 局部突发流量场景)
如果一个数据最近很少被访问到,那么被认为在未来被访问的概率也是最低的,当规定空间用尽且需要放入新数据的时候,会优先淘汰最久未被访问的数据
优点
LRU 实现简单,在一般情况下能够表现出很好的命中率,是一个“性价比”很高的算法。
LRU可以有效的对访问比较频繁的数据进行保护,也就是针对热点数据的命中率提高有明显的效果。
LRU局部突发流量场景,对突发性的稀疏流量(sparse bursts)表现很好。
缺点
在存在 周期性的局部热点 数据场景,有大概率可能造成缓存污染。
最近访问的数据,并不一定是周期性数据,比如把全量的数据做一次迭代,那么LRU 会产生较大的缓存污染,因为周期性的局部热点数据,可能会被淘汰。
演进一:LRU-K
LRU中的K是指数据被访问K次,传统LRU与此对比则可以认为传统LRU是LRU-1
可以看到LRU-K有两个队列,新来的元素先进入到历史访问队列中,该队列用于记录元素的访问次数,采用的淘汰策略是LRU或者FIFO,当历史队列中的元素访问次数达到K的时候,才会进入缓存队列。
演进二:Two Queues
Two Queues与LRU-K相比,他也同样是两个队列,不同之处在于,他的队列一个是缓存队列,一个是FIFO队列,
当新元素进来的时候,首先进入FIFO队列,当该队列中的元素被访问的时候,会进入LRU队列
当新元素进来的时候,首先进入FIFO队列,当该队列中的元素被访问的时候,会进入LRU队列
LFU (适用于 局部周期性流量场景)
如果一个数据在最近一段时间内 使用次数很少,使用频率最低,那么在将来一段时间内被使用的可能性也很小
与LRU的区别在于LRU是以时间先后来衡量,LFU是以时间段内的使用次数衡量
与LRU的区别在于LRU是以时间先后来衡量,LFU是以时间段内的使用次数衡量
优点
LFU适用于 局部周期性流量场景,在这个场景下,比LRU有更好的缓存命中率。
在 局部周期性流量场景下, LFU是以次数为基准,所以更加准确,自然能有效的保证和提高命中率
缺点
LFU需要记录数据的访问频率,因此需要额外的空间;
需要给每个记录项维护频率信息,每次访问都需要更新,这是个巨大的开销;
在存在局部突发流量场景下,有大概率可能造成缓存污染, 算法命中率会急剧下降,这也是他最大弊端。 所以,LFU 对突发性的稀疏流量(sparse bursts)是无效的。
LFU 对突发性的稀疏流量无效呢?
总体来说,LFU 按照访问次数或者访问频率取胜,这个次数有一个累计的长周期, 导致前期经常访问的数据,访问次数很大,或者说权重很高,新来的缓存数据, 哪怕他是突发热点,但是,新数据的访问次数累计的时间太短, 在老人面前,还是个矮个子,LFU 就想一个企业,有点论资排辈,排斥性新人,新人进来,都需要吃苦头,哪怕他是明日之星。
所以,LFU 算法中,老的记录已经占用了缓存,过去的一些大量被访问的记录,在将来不一定会继续是热点数据,但是就一直把“坑”占着了,而那些偶然的突破热点数据,不太可能会被保留下来,而是被淘汰。
所以,在存在突发性的稀疏流量下,LFU中的偶然的、稀疏的突发流量在访问频率上,不占优势,很容易被淘汰,造成缓存污染和未来缓存命中率下降。
所以,LFU 算法中,老的记录已经占用了缓存,过去的一些大量被访问的记录,在将来不一定会继续是热点数据,但是就一直把“坑”占着了,而那些偶然的突破热点数据,不太可能会被保留下来,而是被淘汰。
所以,在存在突发性的稀疏流量下,LFU中的偶然的、稀疏的突发流量在访问频率上,不占优势,很容易被淘汰,造成缓存污染和未来缓存命中率下降。
既然 LRU 和 LFU 各自的优点却又是彼此的缺点,那么是否有兼容的方案能同时处理 局部突发流量场景 和 局部周期性流量场景 呢?
早 Caffeine 本地缓存框架里面实现了 W-TinyLFU 的缓存架构 (W-TinyLFU = LRU + LFU)
W-TinyLFU(Window Tiny Least Frequently Used)是对TinyLFU的的优化和加强,加入 LRU 以应对局部突发流量, 从而实现缓存命中率的最优
W-TinyLFU的数据架构
W-TinyLFU 的设计如下所示
W-TinyLFU 是怎么引入 LRU 的呢?他增加了一个 W-LRU窗口队列 的组件。
当一个数据进来的时候,会进行筛选比较,进入W-LRU窗口队列,经过淘汰后进入Count-Min Sketch算法过滤器,通过访问访问频率判决, 是否进入缓存
当一个数据进来的时候,会进行筛选比较,进入W-LRU窗口队列,经过淘汰后进入Count-Min Sketch算法过滤器,通过访问访问频率判决, 是否进入缓存
- W-LRU窗口队列用于应对 局部突发流量
- TinyLFU 用于 应对 局部周期流量
进一步的分治和解耦
W-TinyLFU将缓存存储空间分为两个大的区域:Window Cache 和 Main Cache
- Window Cache是一个标准的LRU Cache,Main Cache则是一个SLRU(Segmemted LRU)cache,
- Main Cache进一步划分为Protected Cache(保护区)和Probation Cache(考察区)两个区域,这两个区域都是基于LFU的Cache。
Protected 是一个受保护的区域,该区域中的缓存项不会被淘汰。
而且经过实验发现当 window 区配置为总容量的 1%,剩余的 99%当中的 80%分给 protected 区,20%分给 probation 区时,这时整体性能和命中率表现得最好,所以 Caffeine 默认的比例设置就是这个。
不过这个比例 Caffeine 会在运行时根据统计数据(statistics)去动态调整,如果你的应用程序的缓存随着时间变化比较快的话,或者说具备的突发特点数据多,那么增加 window 区的比例可以提高命中率,如果周期性热地数据多,缓存都是比较固定不变的话,增加 Main Cache 区(protected 区 +probation 区)的比例会有较好的效果。
而且经过实验发现当 window 区配置为总容量的 1%,剩余的 99%当中的 80%分给 protected 区,20%分给 probation 区时,这时整体性能和命中率表现得最好,所以 Caffeine 默认的比例设置就是这个。
不过这个比例 Caffeine 会在运行时根据统计数据(statistics)去动态调整,如果你的应用程序的缓存随着时间变化比较快的话,或者说具备的突发特点数据多,那么增加 window 区的比例可以提高命中率,如果周期性热地数据多,缓存都是比较固定不变的话,增加 Main Cache 区(protected 区 +probation 区)的比例会有较好的效果。
W-TinyLFU的算法流程
caffeine综合了LFU和LRU的优势,将不同特性的缓存项存入不同的缓存区域,最近刚产生的缓存项进入Window区,不会被淘汰;访问频率高的缓存项进入Protected区,也不会淘汰;介于这两者之间的缓存项存在Probation区,当缓存空间满了时,Probation区的缓存项会根据访问频率判断是保留还是淘汰;通过这种机制,很好的平衡了访问频率和访问时间新鲜程度两个维度因素,尽量将新鲜的访问频率高的缓存项保留在缓存中。同时在维护缓存项访问频率时,引入计数器饱和和衰减机制,即节省了存储资源,也能较好的处理稀疏流量、短时超热点流量等传统LRU和LFU无法很好处理的场景。
TinyLFU写入机制
当有新的缓存项写入缓存时,会先写入Window Cache区域,当Window Cache空间满时,最旧的缓存项会被移出Window Cache。
如果Probation Cache未满,从Window Cache移出的缓存项会直接写入Probation Cache;
如果Probation Cache已满,则会根据TinyLFU算法确定从Window Cache移出的缓存项是丢弃(淘汰)还是写入Probation Cache。
Probation Cache中的缓存项如果访问频率达到一定次数,会提升到Protected Cache;
如果Protected Cache也满了,最旧的缓存项也会移出Protected Cache,然后根据TinyLFU算法确定是丢弃(淘汰)还是写入Probation Cache。
如果Probation Cache未满,从Window Cache移出的缓存项会直接写入Probation Cache;
如果Probation Cache已满,则会根据TinyLFU算法确定从Window Cache移出的缓存项是丢弃(淘汰)还是写入Probation Cache。
Probation Cache中的缓存项如果访问频率达到一定次数,会提升到Protected Cache;
如果Protected Cache也满了,最旧的缓存项也会移出Protected Cache,然后根据TinyLFU算法确定是丢弃(淘汰)还是写入Probation Cache。
TinyLFU淘汰机制
从Window Cache或Protected Cache移出的缓存项称为Candidate,Probation Cache中最旧的缓存项称为Victim。
如果Candidate缓存项的访问频率大于Victim缓存项的访问频率,则淘汰掉Victim。
如果Candidate小于或等于Victim的频率,那么如果Candidate的频率小于5,则淘汰掉Candidate;否则,则在Candidate和Victim两者之中随机地淘汰一个。
如果Candidate缓存项的访问频率大于Victim缓存项的访问频率,则淘汰掉Victim。
如果Candidate小于或等于Victim的频率,那么如果Candidate的频率小于5,则淘汰掉Candidate;否则,则在Candidate和Victim两者之中随机地淘汰一个。
具体查看 Caffeine 笔记
集群架构
主从复制
作用
读写分离:master写、slave读,提高服务器的读写负载能力
负载均衡:基于主从结构,配合读写分离,由slave分担master负载,并根据需求的变化,改变slave的数量,通过多个从节点分担数据读取负载,大大提高Redis服务器并发量与数据吞吐量
故障恢复:当master出现问题时,由slave提供服务,实现快速的故障恢复
数据冗余备份:实现数据热备份,是持久化之外的一种数据冗余方式
高可用基石:基于主从复制,构建哨兵模式与集群,实现Redis的高可用方案
流程
建立连接阶段(即准备阶段)
- slave:保存master的地址与端口
- master:保存slave的地址和端口
- 总体:之间创建了连接的socket
数据同步阶段(全量数据)
数据同步阶段 master 注意
① master数据量巨大,数据同步阶段应避开流量高峰期,避免造成master阻塞,影响业务正常执行
② 复制缓冲区大小设定不合理,会导致数据溢出。如进行全量复制周期太长,进行部分复制时发现数据已经存在丢失的情况,必须进行`第二次全量复制`,致使slave陷入死循环状态。
repl-backlog-size ?mb // 配置复制缓存区的大小
repl-backlog-size ?mb // 配置复制缓存区的大小
③ master单机内存占用主机内存的比例不应过大,建议使用50%-70%的内存,留下30%-50%的内存用于执行bgsave命令和创建复制缓冲区`
数据同步阶段 slave 注意
① 为避免slave进行全量复制、部分复制时服务器响应阻塞或数据不同步,建议 关闭此期间的对外服务
slave-serve-stale-data yes|no
slave-serve-stale-data yes|no
② 多个slave同时对master请求数据同步,master发送的RDB文件增多,会对带宽造成巨大冲击,如果master带宽不足,因此 数据同步需要根据业务需求,适量错峰
③ slave过多时,建议调整拓扑结构(缓解主从复制风暴),由一主多从结构变为树状结构,中间的节点既是master,也是 slave。注意使用树状结构时,由于层级深度,导致深度越高的slave与最顶层master间数据同步延迟 较大,数据一致性变差,应谨慎选择
命令传播阶段(反复同步)
master将接收到的数据变更命令发送给slave,slave接收命令后执行命令
命令传播阶段出现了断网现象:
- 网络闪断闪连:忽略
- 短时间网络中断:部分复制
- 长时间网络中断:全量复制
部分复制的三个核心要素
服务器的运行 id(run id)
主服务器的复制缓冲区(复制积压缓冲区)
主从服务器的复制偏移量
master复制偏移量:记录发送给所有slave的指令字节对应的位置(多个)
slave复制偏移量:记录slave接收master发送过来的指令字节对应的位置(一个)
作用:`同步信息,比对master与slave的差异,当slave断线后,恢复数据使用`
心跳机制
进入命令传播阶段候,master与slave间需要进行信息交换,使用心跳机制进行维护,实现双方连接保持在线
master心跳
内部指令:PING
周期:由repl-ping-slave-period决定,默认10秒
作用:判断slave是否在线
查询:INFO replication 获取slave最后一次连接时间间隔,lag项维持在0或1视为正常
slave心跳任务
内部指令:REPLCONF ACK {offset}
周期:1秒
- 作用1:汇报slave自己的复制偏移量,获取最新的数据变更指令
- 作用2:判断master是否在线
心跳阶段注意事项
当slave多数掉线,或延迟过高时,master为保障数据稳定性,将拒绝所有信息同步 (slave数量少于2个,或者所有slave的延迟都大于等于8秒时,强制关闭master写功能,停止数据同步)
slave数量由slave发送REPLCONF ACK命令做确认
slave延迟由slave发送REPLCONF ACK命令做确认
完整的主从复制流程
主从复制常见问题
① 频繁的全量复制
① 伴随着系统的运行,master的数据量会越来越大,一旦master重启,runid将发生变化,会导致全部slave的全量复制操作
② 网络环境不佳,出现网络中断,slave不提供服务
问题原因:复制缓冲区过小,断网后slave的offset越界,触发全量复制
最终结果:slave反复进行全量复制
解决方案:修改复制缓冲区大小 repl-backlog-size ?mb
② 频繁的网络中断
① master的CPU占用过高 或 slave频繁断开连接
② slave与master连接断开a
问题原因
master发送ping指令频度较低
master设定超时时间较短
ping指令在网络中存在丢包
解决方案:提高ping指令发送的频度
repl-ping-slave-period seconds
repl-ping-slave-period seconds
超时时间repl-time的时间至少是ping指令频度的5到10倍,否则slave很容易判定超时
③ 数据不一致
问题现象:多个slave获取相同数据不同步
问题原因:网络信息不同步,数据发送有延迟
解决方案
优化主从间的网络环境,通常放置在同一个机房部署,如使用阿里云等云服务器时要注意此现象
监控主从节点延迟(通过offset)判断,如果slave延迟过大,暂时屏蔽程序对该slave的数据访问
主从复制风暴
多个从节点同时复制主节点导致主节点压力过大
解决: 让部分从节点与从节点(与主节点同步)同步数据
哨兵模式
哨兵(sentinel) 是一个分布式系统,用于对主从结构中的每台服务器进行监控,当出现故障时通过投票机制选择新的master并将所有slave连接到新的master。
工作原理
- 监控:监控master和slave,不断的检查master和slave是否正常运行,master存活检测、master与slave运行情况检测
- 通知(提醒):当被监控的服务器出现问题时,向其他(哨兵间,客户端)发送通知
- 自动故障转移:断开master与slave连接,选取一个slave作为master,将其他slave连接新的master,并告知客户端新的服务器地址
哨兵也是一台redis服务器,只是不提供数据相关服务,通常哨兵的数量配置为单数
通过 Raft 分布式协议 选举
挑选备选master原则
不在线的 OUT
响应慢的 OUT
与原master断开时间久的 OUT
优先原则
优先级(offset越大复制的数据越多,runid越小说明重启的次数越小) offset runid
选出新的master之后,发送指令( sentinel )给其他的slave
向新的master发送 slaveof no one(与原有的master 断开联系)
向其他slave发送 slaveof 新masterIP端口
集群Cluster
架构设计
优势
高性能
Redis Cluster 的性能与单节点部署是同级别的。
多主节点、负载均衡、读写分离
高可用
Redis Cluster 支持标准的主从复制配置来保障高可用和高可靠。
failover (失效转移)
Redis Cluster 也实现了一个类似 Raft 的共识方式,来保障整个集群的可用性。
高扩展
向 Redis Cluster 中添加新节点,或者移除节点,都是透明的,不需要停机。
水平、垂直方向都非常容易扩展。
数据分区,海量数据,数据存储
Redis原生
数据存储设计
一致性Hash 16384哈希槽 CRC16算法
查找数据流程
Redis集群节点间的通信机制(Goossip)
去中心化(Gossip协议传播)
优缺点
- gossip协议的优点在于元数据的更新比较分散,不是集中在一个地方,更新请求会陆陆续续,打到所有节点上去更新,有一定的延时,降低了压力;
- 缺点在于元数据更新有延时可能导致集群的一些操作会有一些滞后。
gossip通信的10000端口
每个节点都有一个专门用于节点间gossip通信的端口,就是自己提供服务的端口号+10000,比如7001,那么用于节点间通信的就是17001端口。 每个节点每隔一段时间都会往另外几个节点发送ping消息,同时其他几点接收到ping消息之后返回pong消息。
Cluster分片
客户端路由
Moved重定向
1. 每个节点通过通信都会共享Redis Cluster中槽和集群中对应节点的关系
2. 客户端向Redis Cluster的任意节点发送命令,接收命令的节点会根据CRC16规则进行hash运算与 16384取余,计算自己的槽和对应节点
3. 如果保存数据的槽被分配给当前节点,则去槽中执行命令,并把命令执行结果返回给客户端
4. 如果保存数据的槽不在当前节点的管理范围内,则向客户端返回moved重定向异常
5. 客户端接收到节点返回的结果,如果是moved异常,则从moved异常中获取目标节点的信息
6. 客户端向目标节点发送命令,获取命令执行结果
ask重定向
在对集群进行扩容和缩容时,需要对槽及槽中数据进行迁移 当客户端向某个节点发送命令,节点向客户端返回moved异常,告诉客户端数据对应的槽的节点信息
如果此时正在进行集群扩展或者缩空操作,当客户端向正确的节点发送命令时,槽及槽中数据已经被迁 移到别的节点了,就会返回ask,这就是ask重定向机制
如果此时正在进行集群扩展或者缩空操作,当客户端向正确的节点发送命令时,槽及槽中数据已经被迁 移到别的节点了,就会返回ask,这就是ask重定向机制
1. 客户端向目标节点发送命令,目标节点中的槽已经迁移支别的节点上了,此时目标节点会返回ask转 向给客户端
2. 客户端向新的节点发送Asking命令给新的节点,然后再次向新节点发送命令
3. 新节点执行命令,把命令执行结果返回给客户端
ask 和 Moved 区别
moved:槽已确认转移
ask:槽还在转移过程中
ask:槽还在转移过程中
Smart智能客户端(JedisCluster)
JedisCluster是Jedis根据RedisCluster的特性提供的集群智能客户端,JedisCluster为每个节点创建连接池,并跟节点建立映射关系缓存(Cluster slots) JedisCluster将每个主节点负责的槽位一一与主节点连接池建立映射缓存,JedisCluster启动时,已经知道key、slot和node之间的关系,可以找到目标节点 JedisCluster对目标节点发送命令,目标节点直接响应给JedisCluster。如果JedisCluster与目标节点连接出错,则JedisCluster会知道连接的节点是一个错误的节点 此时节点返回moved异常给JedisCluster,JedisCluster会重新初始化slot与node节点的缓存关系,然后向新的目标节点发送命令,目标命令执行 命令并向JedisCluster响应 如果命令发送次数超过5次,则抛出异常"Too many cluster redirection!"
迁移
在RedisCluster中每个slot 对应的节点在初始化后就是确定的。在某些情况下,节点和分片需要变更:
新的节点作为master加入
某个节点分组需要下线
负载不均衡需要调整slot 分布
此时需要进行分片的迁移,迁移的触发和过程控制由外部系统完成
节点迁移状态设置:迁移前标记源/目标节点
key迁移的原子化命令
1、向节点B发送状态变更命令,将B的对应slot 状态置为`importing`。
2、向节点A发送状态变更命令, 将A对应的slot 状态置为`migrating`。
3、向A 发送`migrate` 命令,告知A 将要迁移的slot对应的key 迁移 到B。
4、当所有key 迁移完成后,cluster setslot 重新设置槽位。
扩容
缩容
容灾
故障检测
集群中的每个节点都会定期地(每秒)向集群中的其他节点发送`PING`消息 如果在一定时间内(cluster-node-timeout),发送ping的节点A没有收到某节点B的`pong`回应,则A将B 标识为`pfail`。 A在后续发送ping时,会带上B的pfail信息, 通知给其他节点。
如果B被标记为pfail的个数大于集群主节点个数的一半(N/2 + 1)时,B会被标记为`fail`,A向整个集群广播,该节点已经下线。 其他节点收到广播,标记B为`fail`。
如果B被标记为pfail的个数大于集群主节点个数的一半(N/2 + 1)时,B会被标记为`fail`,A向整个集群广播,该节点已经下线。 其他节点收到广播,标记B为`fail`。
从节点选举(Raft协议)
每个从节点,都根据自己对master复制数据的offset,来设置一个选举时间,offset越大(复制数 据越多)的从节点,选举时间越靠前,优先进行选举。 slave 通过向其他master发送FAILVOER_AUTH_REQUEST 消息发起竞选, master 收到后回复FAILOVER_AUTH_ACK 消息告知是否同意。
slave 发送FAILOVER_AUTH_REQUEST 前会将currentEpoch 自增,并将最新的Epoch 带入到 FAILOVER_AUTH_REQUEST 消息中,如果自己未投过票,则回复同意,否则回复拒绝。
所有的Master开始slave选举投票,给要进行选举的slave进行投票,如果大部分master node(N/2 + 1)都投票给了某个从节点,那么选举通过,那个从节点可以切换成master。
slave 发送FAILOVER_AUTH_REQUEST 前会将currentEpoch 自增,并将最新的Epoch 带入到 FAILOVER_AUTH_REQUEST 消息中,如果自己未投过票,则回复同意,否则回复拒绝。
所有的Master开始slave选举投票,给要进行选举的slave进行投票,如果大部分master node(N/2 + 1)都投票给了某个从节点,那么选举通过,那个从节点可以切换成master。
RedisCluster失效的判定
1. 集群中半数以上的主节点都宕机(无法投票)
2. 宕机的主节点的从节点也宕机了(slot槽分配不连续)
从节点并不是在主节点一进入 FAIL 状态就马上尝试发起选举,而是有一定延迟,一定的延迟确保我们等待FAIL状态在集群中传播,slave如果立即尝试选举,其它masters或许尚未意识到FAIL状态,可能会拒绝投票
DELAY = 500ms + random(0 ~ 500ms) + SLAVE_RANK * 1000ms
SLAVE_RANK 表示此slave已经从master复制数据的总量的rank。Rank越小代表已复制的数据越新。这种方式下,持有最新数据的slave将会首先发起选举(理论上)
变更通知
当slave 收到过半的master 同意时,会成为新的master。此时会以最新的Epoch 通过PONG 消息广播 自己成为master,让Cluster 的其他节点尽快的更新拓扑结构(`node.conf`)
主从切换
自动切换(上面的故障转移)
手动切换
人工故障切换是预期的操作,而非发生了真正的故障,目的是以一种安全的方式(数据无丢失)将当前 master节点和其中一个slave节点(执行cluster-failover的节点)交换角色
1、向从节点发送cluster failover 命令(slaveof no one)
2、从节点告知其主节点要进行手动切换(CLUSTERMSG_TYPE_MFSTART)
3、主节点会阻塞所有客户端命令的执行(10s)
4、从节点从主节点的ping包中获得主节点的复制偏移量
5、从节点复制达到偏移量,发起选举、统计选票、赢得选举、升级为主节点并更新配置
6、切换完成后,原主节点向所有客户端发送moved指令重定向到新的主节点
2、从节点告知其主节点要进行手动切换(CLUSTERMSG_TYPE_MFSTART)
3、主节点会阻塞所有客户端命令的执行(10s)
4、从节点从主节点的ping包中获得主节点的复制偏移量
5、从节点复制达到偏移量,发起选举、统计选票、赢得选举、升级为主节点并更新配置
6、切换完成后,原主节点向所有客户端发送moved指令重定向到新的主节点
以上是在主节点在线情况下。
如果主节点下线了,则采用 cluster failover force 或 cluster failover takeover 进行强制切换。
如果主节点下线了,则采用 cluster failover force 或 cluster failover takeover 进行强制切换。
副本漂移(黑奴贸易)
我们知道在一主一从的情况下,如果主从同时挂了,那整个集群就挂了。 为了避免这种情况我们可以做一主多从,但这样成本就增加了。
Redis提供了一种方法叫副本漂移,这种方法既能提高集群的可靠性又不用增加太多的从机
Redis提供了一种方法叫副本漂移,这种方法既能提高集群的可靠性又不用增加太多的从机
集群脑裂数据丢失问题
redis集群没有过半机制会有脑裂问题,网络分区导致脑裂后多个主节点对外提供写服务,一旦网络分区恢复,会将其中一个主节点变为从节点,这时会有大量数据丢失。
规避方法可以在redis配置里加上参数(这种方法不可能百分百避免数据丢失,参考集群leader选举机制):
规避方法可以在redis配置里加上参数(这种方法不可能百分百避免数据丢失,参考集群leader选举机制):
min-replicas-to-write 1 写数据成功最少同步的slave数量,这个数量可以模仿大于半数机制配置,比如集群总共三个节点可以配置1,加上leader就是2,超过了半数
这个配置在一定程度上会影响集群的可用性,比如slave要是少于1个,这个集群就算leader正常也不能提供服务了,需要具体场景权衡选择
集群是否完整才能对外提供服务?
当redis.conf的配置cluster-require-full-coverage=no时,表示当负责一个插槽的主库下线且没有相应的从库进行故障恢复时,集群仍然可用,如果为yes则集群不可用。
Redis集群为什么至少需要三个master节点,并且推荐节点数为奇数?
奇数个master节点可以在满足选举该条件的基础上节省一个节点,比如三个master节点和四个master节点的集群相比,大家如果都挂了一个master节点都能选举新master节点,如果都挂了两个master节点都没法选举新master节点了,所以奇数的master节点更多的是从节省机器资源角度出发说的
Redis集群对批量操作命令的支持?
代码操作Redis
Jedis
redisTemplate
企业级解决方案
架构设计
多级缓存
缓存的设计要分多个层次,在不同的层次上选择不同的缓存,包括JVM缓存、文件缓存和Redis缓存
JVM缓存(本地缓存)
Ehcache
Guava Cache
文件缓存(nginx)
Redis缓存
缓存大小
读写峰值
Redis采用的是基于内存的采用的是单进程单线程模型的 KV 数据库,由C语言编写,官方提供的数据是 可以达到110000+的QPS(每秒内查询次数)。80000的写
命中率
缓存的命中率越高则表示使用缓存的收益越高,应用的性能越好(响应时间越短、吞吐量越 高),抗并发的能力越强
通过 info命令监控服务器状态
性能监控指标
info指令
Redis监控平台
grafana、prometheus以及redis_exporter。
缓存问题
缓存穿透(key没有)
Redis 中有一个key不存在,会直接从数据库中查询,如果数据库中也不存在,那么有些人有意为之,一直大量请求这些不存在的key,大量的请求会跑到数据库,数据库扛不住压力直接宕机
问题排查
① Redis中大面积出现未命中
② 出现非正常URL访问
② 出现非正常URL访问
问题分析
① 获取的数据在数据库中也不存在,数据库查询未得到对应数据
② Redis获取到null数据未进行持久化,直接返回
③ 下次此类数据到达重复上述过程
④ 出现黑客攻击服务器
② Redis获取到null数据未进行持久化,直接返回
③ 下次此类数据到达重复上述过程
④ 出现黑客攻击服务器
解决方案
缓存null
对查询结果为null的数据进行缓存(长期使用,定期清理),设定短时限,例如30-60秒,最高5分钟
白名单策略
提前预热各种分类数据id对应的bitmaps,id作为bitmaps的offset,相当于设置了数据白名单。当加载正常数据时放行,加载异常数据时直接拦截(效率偏低)
使用布隆过滤器(有关布隆过滤器的命中问题对当前状况可以忽略)
实施监控
实时监控redis命中率(业务正常范围时,通常会有一个波动值)与null数据的占比
非活动时段波动:通常检测3-5倍,超过5倍纳入重点排查对象
活动时段波动:通常检测10-50倍,超过50倍纳入重点排查对象
根据倍数不同,启动不同的排查流程。然后使用黑名单进行防控(运营)
key加密
问题出现后,临时启动防灾业务key,对key进行业务层传输加密服务,设定校验程序,过来的key校验
例如每天随机分配60个加密串,挑选2到3个,混淆到页面数据id中,发现访问key不满足规则,驳回数据访问
BloomFilter
布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是由一个很长的 bit数组 和一系列哈希函数组成的。布隆过滤器可以用于检索一个元素是否在一个集合中
- 数组的每个元素都只占1bit空间,并且每个元素只能为0或1。
- 布隆过滤器还拥有 k 个哈希函数,当一个元素加入布隆过滤器时,会使用k个哈希函数对其进行 k 次计算,得到k个哈希值,并且根据得到的哈希值,在维数组中把对应下标的值置为1。
判断某个数是否在布隆过滤器中,就对该元素进行 k 次哈希计算,得到的值在位数组中判断每个元素是否都为1,如果每个元素都为1,就说明这个值在布隆过滤器中。
优点
① 节省内存空间
② 插入和查询时间复杂度都为O(1)
缺点
① 布隆过滤器不支持删除,如果要删除得重新初始化数据。(定时重建)
② 由于哈希冲突的原因,可能会出现假阳性
缓存预热
问题排查
- 请求数量较高,大量的请求过来之后都需要去从缓存中获取数据,但是缓存中又没有,此时从数据库中查找数据然后将数据再存入缓存,造成了短期内对redis的高强度操作从而导致问题
- 主从之间数据吞吐量较大,数据同步操作频度较高
解决方案
前置准备工作:
1. 日常例行统计数据访问记录,统计访问频度较高的热点数据
2. 利用LRU数据删除策略,构建数据留存队列 例如:storm与kafka配合
准备工作:
1. 将统计结果中的数据分类,根据级别,redis优先加载级别较高的热点数据
2. 利用分布式多服务器同时进行数据读取,提速数据加载过程
3. 热点数据主从同时预热
实施:
1. 使用脚本程序固定触发数据预热过程
2. 如果条件允许,使用了CDN(内容分发网络),效果会更好
缓存预热就是系统启动前,提前将相关的缓存数据直接加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!
缓存雪崩
① 短时间内,大量key集中过期
② 缓存雪崩指的是缓存层支撑不住或宕掉后(缓存处理的能力也是有限的)
由于缓存层承载着大量请求, 有效地保护了存储层, 但是如果缓存层由于某些原因不能提供服务(比如超大并发过来,缓存层支撑不住,或者由于缓存设计不好,类似大量请求访问bigkey,导致缓存能支撑的并发急剧下降), 于是大量请求都会打到存储层, 存储层的调用量会暴增, 造成存储层也会级联宕机的情况。
预防和解决缓存雪崩问题
保证缓存层服务高可用性,比如使用Redis Sentinel或Redis Cluster
依赖隔离组件为后端限流熔断并降级。比如使用Sentinel或Hystrix限流降级组件。
问题排查
1. 在一个较短的时间内,缓存中较多的key集中过期
2. 此周期内请求访问过期的数据,redis未命中,redis向数据库获取数据
3. 数据库同时接收到大量的请求无法及时处理
4. Redis大量请求被积压,开始出现超时现象
5. 数据库流量激增,数据库崩溃
6. 重启后仍然面对缓存中无数据可用
7. Redis服务器资源被严重占用,Redis服务器崩溃
8. Redis集群呈现崩塌,集群瓦解
9. 应用服务器无法及时得到数据响应请求,来自客户端的请求数量越来越多,应用服务器崩溃
10. 应用服务器,redis,数据库全部重启,效果不理想
解决方案: 错峰
思路:(道)
① 更多的页面静态化处理【freemaker | thymeleaf ......】=> 减少了对后台数据库的请求
② 构建多级缓存架构( Nginx缓存+redis缓存+本地缓存【ehcache 】 )
③ 检测Mysql严重耗时业务进行优化
对数据库的瓶颈排查:例如超时查询、耗时较高事务等
④ 灾难预警机制
监控redis服务器性能指标
CPU占用、CPU使用率
内存容量
查询平均响应时间(RT)
线程数
⑤ 限流、降级
短时间范围内牺牲一些客户体验,限制一部分请求访问,降低应用服务器压力,待业务低速运转后再逐步放开访问
落地实践:(术)
① LRU与LFU切换
② 数据有效期策略调整【避免大量的key在同一时间内同时过期】
根据业务数据有效期进行分类错峰,A类90分钟,B类80分钟,C类70分钟
过期时间使用固定时间+随机值的形式,稀释集中到期的key的数量
③ 超热数据使用永久key
④ 定期维护(自动+人工)
对即将过期数据做访问量分析,确认是否延时,配合访问量统计,做热点数据的延时
⑤ 加锁:慎用!
缓存击穿(高热数据key失效)
Redis中的某些key,这时有大量的请求访问这些key,很不幸,这些key失效了,这个时候就会导致大量的请求直接跑到数据库,数据库扛不住直接宕机
问题排查:
① Redis中某些key过期,该key访问量巨大
② 多个数据请求从服务器直接压到Redis后,均未命中
③ Redis在短时间内发起了大量对数据库中同一数据的访问
总而言之就两点:单个key高热数据,key过期
② 多个数据请求从服务器直接压到Redis后,均未命中
③ Redis在短时间内发起了大量对数据库中同一数据的访问
总而言之就两点:单个key高热数据,key过期
解决方案:
① 预先设定
以电商为例,每个商家根据店铺等级,指定若干款主打商品,在购物节期间,加大此类信息key的过期时长 注意:购物节不仅仅指当天,以及后续若干天,访问峰值呈现逐渐降低的趋势
② 现场调整
监控访问量,对自然流量激增的数据延长过期时间或设置为永久性key
③ 后台刷新数据
启动定时任务,高峰期来临之前,刷新数据有效期,确保不丢失
④ 二级缓存
设置不同的失效时间,保障不会被同时淘汰就行
⑤ 加锁
分布式锁,防止被击穿,但是要注意也是性能瓶颈,慎重!
热点缓存key重建优化
当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大
重建缓存不能在短时间完成, 可能是一个复杂计算, 例如复杂的SQL、 多次IO、 多个依赖等。
重建缓存不能在短时间完成, 可能是一个复杂计算, 例如复杂的SQL、 多次IO、 多个依赖等。
解决方案
① 设计热点Key不过期,或者由人为,相对应的算法来控制key的生命周期
② 借助Redis的过期策略: 避免大量线程同时重建缓存
我们可以利用互斥锁来解决,此方法只允许一个线程重建缓存, 其他线程等待重建缓存的线程执行完, 重新从缓存获取数据即可。
缓存与数据库双写不一致
双写不一致情况
读写并发不一致
解决方案
1、对于并发几率很小的数据(如个人维度的订单数据、用户数据等),这种几乎不用考虑这个问题,很少会发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。(简单有效,能解决大部分的问题)就算并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求。
2、如果不能容忍缓存数据不一致,可以通过加分布式读写锁保证并发读写或写写的时候按顺序排好队,读读的时候相当于无锁。
3、延迟双删(不推荐,代价太大,本身就是处理小概率的事件)
4、也可以用阿里开源的canal通过监听数据库的binlog日志及时的去修改缓存,但是引入了新的中间件,增加了系统的复杂度。
总结:
以上我们针对的都是读多写少的情况加入缓存提高性能,如果写多读多的情况又不能容忍缓存数据不一致,那就没必要加缓存了,可以直接操作数据库。当然,如果数据库抗不住压力,还可以把缓存作为数据读写的主存储,异步将数据同步到数据库,数据库只是作为数据的备份。
放入缓存的数据应该是对实时性、一致性要求不是很高的数据。
切记 不要为了用缓存,同时又要保证绝对的一致性做大量的过度设计和控制,增加系统复杂性!
以上我们针对的都是读多写少的情况加入缓存提高性能,如果写多读多的情况又不能容忍缓存数据不一致,那就没必要加缓存了,可以直接操作数据库。当然,如果数据库抗不住压力,还可以把缓存作为数据读写的主存储,异步将数据同步到数据库,数据库只是作为数据的备份。
放入缓存的数据应该是对实时性、一致性要求不是很高的数据。
切记 不要为了用缓存,同时又要保证绝对的一致性做大量的过度设计和控制,增加系统复杂性!
性能指标监控(info)
性能指标:Performance
latency
响应请求的平均时间:
instantaneous_ops_per_sec
平均每秒处理请求总数
hit_rate(calculated)
缓存查询命中率(通过查询总次数与查询得到非nil数据总次数计算而来)
内存指标:Memory
used_memory
当前内存使用量
mem_fragmentation_ratio
内存碎片率(关系到是否进行碎片整理)
evicted_keys
为避免内存溢出删除的key的总数量
blocked_clients
基于阻塞操作(BLPOP等)影响的客户端数量
基本活动指标:Basic_activity
connected_clients
当前客户端连接总数
connected_slaves
当前连接slave总数
master_last_io_seconds_ago
最后一次主从信息交换距现在的秒
keyspace
key的总数
持久性指标:Persistence
rdb_last_save_time
当前服务器最后一次RDB持久化的时间
rdb_changes_since_last_save
当前服务器最后一次RDB持久化后数据变化总量
错误指标:Error
rejected_connections
被拒绝连接的客户端总数(基于达到最大连接值的因素)
keyspace_misses
key未命中的总次数
master_link_down_since_seconds
主从断开的秒数
开发规范与性能优化
键值设计
key名设计
可读性和可管理性
简洁性
不要包含特殊字符(包含空格、换行、单双引号以及其他转义字符)
value设计
拒绝bigkey(防止网卡流量、慢查询)
什么是 bigkey
字符串类型:它的big体现在单个value值很大,一般认为超过 10KB 就是bigkey
- 非字符串类型:哈希、列表、集合、有序集合,它们的big体现在元素个数太多。
- 一般来说,string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000
bigkey的危害
导致redis阻塞
网络拥塞
bigkey也就意味着每次获取要产生的网络流量较大,假设一个bigkey为1MB,客户端每秒访问量为1000,那么每秒产生1000MB的流量,对于普通的千兆网卡(按照字节算是128MB/s)的服务器来说简直是灭顶之灾,而且一般服务器会采用单机多实例的方式来部署,也就是说一个bigkey可能会对其他实例也造成影响,其后果不堪设想
过期删除
Redis 4.0的过期异步删除(lazyfree-lazy-expire yes)
bigkey的产生
一般来说,bigkey的产生都是由于程序设计不当,或者对于数据规模预料不清楚造成的
优化 bigkey
拆
big list: list1、list2、...listN
如果bigkey不可避免,也要思考一下要不要每次把所有元素都取出来(例如有时候仅仅需要hmget,而不是hgetall),删除也是一样,尽量使用优雅的方式来处理
业务场景中经常会有各种大key多key的情况
① 单个简单的key存储的value很大
该对象需要每次都整存整取
可以尝试将对象分拆成几个key-value, 使用multiGet获取值,这样分拆的意义在于分拆单次操作的压力,将操作压力平摊到多个redis实例中,降低对单个redis的IO影响
该对象每次只需要存取部分数据
可以像第一种做法一样,分拆成几个key-value, 也可以将这个存储在一个hash中,每个field代表一个具体的属性
使用 hget,hmget 来获取部分的value,使用 hset,hmset 来更新部分属性
使用 hget,hmget 来获取部分的value,使用 hset,hmset 来更新部分属性
② value中存储过多的元素
类似于场景一种的第一个做法,可以将这些元素分拆。
以hash为例,原先的正常存取流程是 hget(hashKey, field) ; hset(hashKey, field, value) 现在,固定一个桶的数量,比如 10000, 每次存取的时候,先在本地计算field的hash值,模除 10000, 确定了该field落在哪个key上。
newHashKey = hashKey + ( set, zset, list 也可以类似上述做法但有些不适合的场景,比如,要保证 lpop 的数据的确是最早push到list中去的,这个就需要一些附加的属性,或者是在 key的拼接上做一些工作(比如list按照时间来分拆)。
以hash为例,原先的正常存取流程是 hget(hashKey, field) ; hset(hashKey, field, value) 现在,固定一个桶的数量,比如 10000, 每次存取的时候,先在本地计算field的hash值,模除 10000, 确定了该field落在哪个key上。
newHashKey = hashKey + ( set, zset, list 也可以类似上述做法但有些不适合的场景,比如,要保证 lpop 的数据的确是最早push到list中去的,这个就需要一些附加的属性,或者是在 key的拼接上做一些工作(比如list按照时间来分拆)。
③ 一个集群存储了上亿的key
key的个数过多会带来更多的内存空间占用
这两个方面在key个数上亿的时候消耗内存十分明显(Redis 3.2及以下版本均存在这个问题,4.0有优化);
所以减少key的个数可以减少内存消耗,可以参考的方案是转Hash结构存储,即原先是直接使用Redis String 的结构存储,现在将多个key存储在一个Hash结构中,具体场景参考如下:
- key本身的占用(每个key 都会有一个Category前缀)
- 集群模式中,服务端需要建立一些slot2key的映射关系,这其中的指针占用在key多的情况下也是浪费巨大空间
这两个方面在key个数上亿的时候消耗内存十分明显(Redis 3.2及以下版本均存在这个问题,4.0有优化);
所以减少key的个数可以减少内存消耗,可以参考的方案是转Hash结构存储,即原先是直接使用Redis String 的结构存储,现在将多个key存储在一个Hash结构中,具体场景参考如下:
① key 本身就有很强的相关性,比如多个key 代表一个对象,每个key是对象的一个属性,这种可直接按照特定对象的特征来设置一个新Key——Hash结构, 原先的key则作为这个新Hash 的field。
② key 本身没有相关性,预估一下总量,采取和上述第二种场景类似的方案,预分一个固定的桶数量
比如现在预估key 的总数为 2亿,按照一个hash存储 100个field来算,需要 2亿 / 100 = 200W 个桶 (200W 个key占用的空间很少,2亿可能有将近 20G )
② key 本身没有相关性,预估一下总量,采取和上述第二种场景类似的方案,预分一个固定的桶数量
比如现在预估key 的总数为 2亿,按照一个hash存储 100个field来算,需要 2亿 / 100 = 200W 个桶 (200W 个key占用的空间很少,2亿可能有将近 20G )
注意两个地方:1,hash 取模对负数的处理;2,预分桶的时候, 一个hash 中存储的值最好不要超过 512 ,100 左右较为合适
④ 大Bitmap或布隆过滤器(Bloom)拆分
使用bitmap或布隆过滤器的场景,往往是数据量极大的情况,在这种情况下,Bitmap和布隆过滤器使用空间也比较大,比如用于公司userid匹配的布隆过滤器,就需要512MB的大小,这对redis来说是绝对的大value了。这种场景下,我们就需要对其进行拆分,拆分为足够小的Bitmap,比如将512MB的大Bitmap拆分为1024个512KB的Bitmap。不过拆分的时候需要注意,要将每个key落在一个Bitmap上。有些业务只是把Bitmap 拆开, 但还是当做一个整体的bitmap看, 所以一个 key 还是落在多个 Bitmap 上,这样就有可能导致一个key请求需要查询多个节点、多个Bitmap。
如下图,被请求的值被hash到多个Bitmap上,也就是redis的多个key上,这些key还有可能在不同节点上,这样拆分显然大大降低了查询的效率。
如下图,被请求的值被hash到多个Bitmap上,也就是redis的多个key上,这些key还有可能在不同节点上,这样拆分显然大大降低了查询的效率。
因此我们所要做的是把所有拆分后的Bitmap当作独立的bitmap,然后通过hash将不同的key分配给不同的bitmap上,而不是把所有的小Bitmap当作一个整体。这样做后每次请求都只要取redis中一个key即可。
有同学可能会问,通过这样拆分后,相当于Bitmap变小了,会不会增加布隆过滤器的误判率?实际上是不会的,布隆过滤器的误判率是哈希函数个数k,集合元素个数n,以及Bitmap大小m所决定的,其约等于
因此如果我们在第一步,也就是在分配key给不同Bitmap时,能够尽可能均匀的拆分,那么n/m的值几乎是一样的,误判率也就不会改变。具体的误判率推导可以参考wiki:Bloom_filter同时,客户端也提供便利的api (>=2.3.4版本), setBits/ getBits 用于一次操作同一个key的多个bit值 。建议 :k 取 13 个, 单个bloomfilter控制在 512KB 以下
因此如果我们在第一步,也就是在分配key给不同Bitmap时,能够尽可能均匀的拆分,那么n/m的值几乎是一样的,误判率也就不会改变。具体的误判率推导可以参考wiki:Bloom_filter同时,客户端也提供便利的api (>=2.3.4版本), setBits/ getBits 用于一次操作同一个key的多个bit值 。建议 :k 取 13 个, 单个bloomfilter控制在 512KB 以下
选择适合的数据类型
控制key的生命周期,redis不是垃圾桶(条件允许可以打散过期时间,防止集中过期)
命令使用
O(N)命令关注N的数量
hgetall、lrange、smembers、zrange、sinter 等并非不能使用,但是需要明确N的值。有遍历的需求可以使用hscan、sscan、zscan代替
禁用命令
禁止线上使用keys、flushall、flushdb等,通过redis的rename机制禁掉命令,或者使用scan的方式渐进式处理。
合理使用select
redis的多数据库较弱,使用数字进行区分,很多客户端支持较差,同时多业务用多数据库实际还是单线程处理,会有干扰。
(尽可能的别根据业务把Redis存储在一个Redis的不同的库,而是放置到不同的实例)
(尽可能的别根据业务把Redis存储在一个Redis的不同的库,而是放置到不同的实例)
使用批量操作提高效率
原生命令:例如mget、mset
非原生命令:可以使用pipeline提高效率
Redis事务功能较弱,不建议过多使用,可以用lua替代
客户端使用
避免多个应用使用一个Redis实例
使用带有连接池的数据库,可以有效控制连接,同时提高效率
使用方式
连接池的参数
优化建议
maxTotal
业务希望Redis并发量
客户端执行命令时间
Redis资源:例如 nodes(例如应用个数) * maxTotal 是不能超过redis的最大连接数maxclients(默认10000)。
资源开销:例如虽然希望控制空闲连接(连接池此刻可马上使用的连接),但是不希望因为连接池的频繁释放创建连接造成不必靠开销。
maxIdle和minIdle
连接池的最佳性能是maxTotal = maxIdle
maxIdle实际上才是业务需要的最大连接数,maxTotal是为了给出余量,所以maxIdle不要设置过小,否则会有new Jedis(新连接)开销。
这样就避免连接池伸缩带来的性能干扰。但是如果并发量不大或者maxTotal设置过高,会导致不必要的连接资源浪费。一般推荐maxIdle可以设置为按上面的业务期望QPS计算出来的理论连接数,maxTotal可以再放大一倍
这样就避免连接池伸缩带来的性能干扰。但是如果并发量不大或者maxTotal设置过高,会导致不必要的连接资源浪费。一般推荐maxIdle可以设置为按上面的业务期望QPS计算出来的理论连接数,maxTotal可以再放大一倍
Redis 连接池预热
Redis的连接池资源是懒加载,不是在初始化的时候就加载最少空间的连接数的资源
高并发下建议客户端添加熔断功能(例如sentinel、hystrix)
设置合理的密码,如有必要可以使用SSL加密访问
系统内核参数
vm.swapiness
swap对于操作系统来说比较重要,当物理内存不足时,可以将一部分内存页进行swap到硬盘上,以解燃眉之急。但世界上没有免费午餐,swap空间由硬盘提供,对于需要高并发、高吞吐的应用来说,磁盘IO通常会成为系统瓶颈。在Linux中,并不是要等到所有物理内存都使用完才会使用到swap,系统参数swppiness会决定操作系统使用swap的倾向程度。swappiness的取值范围是0~100,swappiness的值越大,说明操作系统可能使用swap的概率越高,swappiness值越低,表示操作系统更加倾向于使用物理内存。swappiness的取值越大,说明操作系统可能使用swap的概率越高,越低则越倾向于使用物理内存。
如果linux内核版本<3.5,那么swapiness设置为0,这样系统宁愿swap也不会oom killer(杀掉进程)
如果linux内核版本>=3.5,那么swapiness设置为1,这样系统宁愿swap也不会oom killer
cat /proc/version #查看linux内核版本
echo 1 > /proc/sys/vm/swappiness
echo vm.swapiness=1 >> /etc/sysctl.conf
如果linux内核版本<3.5,那么swapiness设置为0,这样系统宁愿swap也不会oom killer(杀掉进程)
如果linux内核版本>=3.5,那么swapiness设置为1,这样系统宁愿swap也不会oom killer
cat /proc/version #查看linux内核版本
echo 1 > /proc/sys/vm/swappiness
echo vm.swapiness=1 >> /etc/sysctl.conf
PS:OOM killer 机制是指Linux操作系统发现可用内存不足时,强制杀死一些用户进程(非内核进程),来保证系统有足够的可用内存进行分配。
vm.overcommit_memory(默认0)
0:表示内核将检查是否有足够的可用物理内存(实际不一定用满)供应用进程使用;如果有足够的可用物理内存,内存申请允许;否则,内存申请失败,并把错误返回给应用进程
1:表示内核允许分配所有的物理内存,而不管当前的内存状态如何
合理设置文件句柄数
操作系统进程试图打开一个文件(或者叫句柄),但是现在进程打开的句柄数已经达到了上限,继续打开会报错:“Too many open files”
ulimit -a #查看系统文件句柄数,看open files那项
ulimit -n 65535 #设置系统文件句柄数
ulimit -a #查看系统文件句柄数,看open files那项
ulimit -n 65535 #设置系统文件句柄数
扩展功能
发布订阅
Redis的发布订阅机制包括三个部分,publisher,subscriber和Channel
``
``
基于频道(Channel)的发布订阅
发布/订阅包含两种角色,分别是发布者和订阅者;发布者可以向指定频道发送消息,订阅者可以订阅一个或多个频道,所有订阅此频道的订阅者都会收到此消息
subscribe::订阅 subscribe channel1 channel2 ..
publish:发布消息 publish channel message
发出去的消息不会被持久化,即客户端订阅channel:1后,只能接收到订阅后发布的该频道的消息,之前的是无法接收到的
unsubscribe:退订 channel
基于模式(Pattern)的发布订阅
对通道进行了 * 号匹配
注意点
使用psubscribe命令可以重复订阅同一个频道,如客户端执行了psubscribe c? c?*。 这时向c1发布消息客户端会接受到两条消息,而同时publish命令的返回值是2而不是1。同样的,如果有另一个客户端执行了subscribe c1 和 psubscribe c?* 的话,向c1发送一条消息该客户端也会收到两条消息(但是是两种类型:message和pmessage),同时publish命令也返回
punsubscribe命令可以退订指定的规则,用法是: punsubscribe [pattern [pattern ...]],如果没有参数则会退订所有规则。
使用punsubscribe只能退订通过psubscribe命令订阅的规则,不会影响直接通过subscribe命令订阅的频道;同样unsubscribe命令也不会影响通过psubscribe命令订阅的规则。
深入理解发布订阅
基于频道(Channel)的发布/订阅实现
底层是通过字典(图中的pubsub_channels)实现的,这个字典就用于保存订阅频道的信息
- 字典的键为正在被订阅的频道
- 字典的值则是一个链表, 链表中保存了所有订阅这个频道的客户端。
数据结构
在下图展示的这个 pubsub_channels 示例中,client2、client5和client1就订阅了channel1, 而其他频道也分别被别的客户端所订阅
订阅
当客户端调用SUBSCRIBE命令时, 程序就将客户端和要订阅的频道在pubsub_channels字典中关联起来。
如下图所示:如果客户端client10086执行命令 SUBSCRIBE channel1 channel2 channel3 ,那么前面展示的 pubsub_channels 将变成下面这个样子:
如下图所示:如果客户端client10086执行命令 SUBSCRIBE channel1 channel2 channel3 ,那么前面展示的 pubsub_channels 将变成下面这个样子:
发布
当调用 PUBLISH channel message 命令, 程序首先根据channel定位到字典的键, 然后将信息发送给字典值链表中的所有客户端。
比如说,对于以上这个pubsub_channels实例, 如果某个客户端执行命令PUBLISH channel1 "hello moto",那么client2、client5和client1三个客户端都将接收到"hello moto"信息:
比如说,对于以上这个pubsub_channels实例, 如果某个客户端执行命令PUBLISH channel1 "hello moto",那么client2、client5和client1三个客户端都将接收到"hello moto"信息:
退订
使用UNSUBSCRIBE命令可以退订指定的频道, 这个命令执行的是订阅的反操作: 它从pubsub_channels字典的给定频道(键)中, 删除关于当前客户端的信息, 这样被退订频道的信息就不会再发送给这个客户端。
基于模式(Pattern)的发布/订阅实现
底层是 pubsubPattern 节点的链表
数据结构
redisServer.pubsub_patterns 属性是一个链表,链表中保存着所有和模式相关的信息
client属性保存着订阅模式的客户端,而pattern属性则保存着被订阅的模式
上图展示了一个包含两个模式的pubsub_patterns链表, 其中client123和client256都正在订阅tweet.shop.*模式
每当调用PSUBSCRIBE命令订阅一个模式时, 程序就创建一个包含客户端信息和被订阅模式的pubsubPattern结构, 并将该结构添加到redisServer.pubsub_patterns链表中。
每当调用PSUBSCRIBE命令订阅一个模式时, 程序就创建一个包含客户端信息和被订阅模式的pubsubPattern结构, 并将该结构添加到redisServer.pubsub_patterns链表中。
订阅
如果这时客户端client10086执行PSUBSCRIBE broadcast.list.*, 那么pubsub_patterns链表将被更新成这样:
通过遍历整个pubsub_patterns链表,程序可以检查所有正在被订阅的模式,以及订阅这些模式的客户端。
发布
发送信息到模式的工作也是由 PUBLISH 命令进行的, 显然就是匹配模式获得Channels,然后再把消息发给客户端。
退订
使用PUNSUBSCRIBE命令可以退订指定的模式, 这个命令执行的是订阅模式的反操作:
程序会删除redisServer.pubsub_patterns链表中, 所有和被退订模式相关联的 pubsubPattern 结构, 这样客户端就不会再收到和模式相匹配的频道发来的信息。
程序会删除redisServer.pubsub_patterns链表中, 所有和被退订模式相关联的 pubsubPattern 结构, 这样客户端就不会再收到和模式相匹配的频道发来的信息。
redisson 使用
事务
事务的命令
- multi:用于标记事务块的开始,Redis会将后续的命令逐个放入队列中,然后使用exec原子化地执行这个 命令队列
- exec:执行命令队列
- discard:清除命令队列
- watch:监视
- key unwatch:清除监视key
事务机制
事务执行
执行流程
1. 事务开始
在RedisClient中,有属性flags,用来表示是否在事务中 `flags=REDIS_MULTI`
2. 命令入队
RedisClient将命令存放在事务队列中 (EXEC,DISCARD,WATCH,MULTI除外)
3. 事务队列 `multiCmd *commands` 用于存放命令
4. 执行事务 RedisClient向服务器端发送exec命令,RedisServer会遍历事务队列,执行队列中的命令,最后将执行的结果一次性返回给客户端。
如果某条命令在入队过程中发生错误,redisClient将flags置为`REDIS_DIRTY_EXEC`,EXEC命令将会失败 返回。
底层结构
watch执行
执行流程
① 使用WATCH命令监视数据库键 redisDb有一个`watched_keys`字典,key是某个被监视的数据的key,值是一个链表.记录了所有监视这个数 据的客户端。
② 监视机制的触发 ,当修改数据后,监视这个数据的客户端的flags置为REDIS_DIRTY_CAS
③ 事务执行,redisClient向服务器端发送exec命令,服务器判断RedisClient的flags,如果为REDIS_DIRTY_CAS,则 清空事务队列。
② 监视机制的触发 ,当修改数据后,监视这个数据的客户端的flags置为REDIS_DIRTY_CAS
③ 事务执行,redisClient向服务器端发送exec命令,服务器判断RedisClient的flags,如果为REDIS_DIRTY_CAS,则 清空事务队列。
底层结构
Redis的弱事务性
语法错误
整个事务的命令在队列里都清除
运行错误
在队列里正确的命令可以执行 (弱事务性)
弱事务性 :
1. 在队列里正确的命令可以执行 (非原子操作)
2. 不支持回滚
1. 在队列里正确的命令可以执行 (非原子操作)
2. 不支持回滚
lua脚本
lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放
从Redis2.6.0版本开始,通过内置的lua编译/解释器,可以使用EVAL命令对lua脚本进行求值
利用Redis整合Lua,主要是为了性能(减少网络开销)以及事务的原子性。因为redis帮我们提供的事务功能太差
利用Redis整合Lua,主要是为了性能(减少网络开销)以及事务的原子性。因为redis帮我们提供的事务功能太差
命令
eval
evalsha
EVAL 命令要求你在每次执行脚本的时候都发送一次脚本主体(script body)。
Redis 有一个内部的缓存机制,因此它不会每次都重新编译脚本,不过在很多场合,付出无谓的带宽来 传送脚本主体并不是最佳选择。
为了减少带宽的消耗, Redis 实现了 EVALSHA 命令,它的作用和 EVAL 一样,都用于对脚本求值,但它接受的第一个参数不是脚本,而是脚本的 SHA1 校验和(sum)
Redis 有一个内部的缓存机制,因此它不会每次都重新编译脚本,不过在很多场合,付出无谓的带宽来 传送脚本主体并不是最佳选择。
为了减少带宽的消耗, Redis 实现了 EVALSHA 命令,它的作用和 EVAL 一样,都用于对脚本求值,但它接受的第一个参数不是脚本,而是脚本的 SHA1 校验和(sum)
script 命令
脚本复制( 前提 主从模式和开启 AOF)
脚本传播模式(默认)
Redis会将被执行的脚本及其参数复制到 AOF 文件以及从服务器里面。
在这一模式下执行的脚本不能有时间、内部状态、随机函数(spop)
命令传播模式
处于命令传播模式的主服务器会将执行脚本产生的所有写命令用事务包裹起来,然后将事务复制到 AOF 文件以及从服务器里面
管道(pipeline),事务和脚本(lua)三者的区别? (三者都可以批量执行命令)
管道无原子性,命令都是独立的,属于无状态的操作
事务和脚本是有原子性的,其区别在于脚本可借助Lua语言可在服务器端存储的便利性定制和简化操作
脚本的原子性要强于事务,脚本执行期间,另外的客户端 其它任何脚本或者命令都无法执行,脚本的执行时间应该尽量短,不能太耗时的脚本
慢查询记录
慢查询设置
redis.conf
#执行时间超过多少微秒的命令请求会被记录到日志上 0 :全记录 <0 不记录
slowlog-log-slower-than 10000
#slowlog-max-len 存储慢查询日志条数
slowlog-max-len 128
slowlog-log-slower-than 10000
#slowlog-max-len 存储慢查询日志条数
slowlog-max-len 128
临时
config set的方式可以临时设置,redis重启后就无效
`config set slowlog-log-slower-than` 微秒
`config set slowlog-max-len` 条数
`config set slowlog-log-slower-than` 微秒
`config set slowlog-max-len` 条数
Redis使用列表存储慢查询日志,采用队列方式(FIFO)
查看日志:`slowlog get [n]`
慢查询底层存储结构
慢查询日志查询删除
慢查询添加日志实现
慢查询处理
1、尽量使用短的key,对于value有些也可精简,能使用int就int。
2、避免使用keys *、hgetall等全量操作。
3、减少大key的存取,打散为小key
5、想要一次添加多条数据的时候可以使用管道
6、尽可能地使用哈希存储
7、尽量限制下redis使用的内存大小(maxmemory),这样可以避免redis使用swap分区或者出现OOM错误 内存与硬盘的swap
监视器
Redis客户端通过执行 MONITOR 命令可以将自己变为一个监视器,实时地接受并打印出服务器当前处理的命令请求的相关信息
使用
Redis客户端1
Redis客户端2
实现监视器
向监视器发送命令信息
管道(pipeline)
管道是用于优化多条命令导致的客户端与服务端的频繁连接造成的性能问题的优化,用管道的目的就是用于做批处理
一个请求会执行以下步骤:
1. 客户端向服务器发送命令分以下四步,并且会监听Socket的返回,通常是以阻塞的形式等待服务端的响应
①发送命令 ②命令排队 ③ 命令执行 ④ 返回结果
2. 服务端处理命令,并将结果返回给客户端
这样的操作又称为RTT(Round Trip Time,数据包往返两端的时间)
1. 客户端向服务器发送命令分以下四步,并且会监听Socket的返回,通常是以阻塞的形式等待服务端的响应
①发送命令 ②命令排队 ③ 命令执行 ④ 返回结果
2. 服务端处理命令,并将结果返回给客户端
这样的操作又称为RTT(Round Trip Time,数据包往返两端的时间)
如果同时需要执行大量的命令,那么就要等待上一条命令应答后再执行,这中间不仅多了RTT,而且还会频繁调用系统IO,发送网络请求。同时需要Redis调用多次read()和write()系统方法,系统方法会将数据从用户态转移为内核态,这样就会对进程上下文有较大的影响,性能急剧降低。
管道可以一次性发送多条命令给服务端,服务端依次处理完毕后,通过一条响应一次性将结果返回,减少客户端和Redis服务的通信次数,降低RTT,pipeline实现的原理就是队列,先进先出来保证数据的顺序性
注意事项
①. pipeline缓冲的指令只是会依次执行,不保证原子性,如果执行中指令发生异常,会继续执行后续指令
② 使用pipeline组装的命令个数不能太多,不然数据量过大,客户端阻塞的时间可能过久,同时服务端此时也被迫回复一个队列答复,占用很多内存
管道与原生批处理命令对比
pipeline属于是linux层面的组件命令,mset、mget是redis的原生命令
原生的批处理命令是具有原子性的,pipeline是非原子性的
原生的批处理命令一次只能执行一种命令,pipeline支持批量执行不同的命令
原生批处理命令是服务端实现的,而pipeline需要服务端和客户端共同完成
Pipeline与事务的对比
事务具有原子性,管道不具有原子性
管道一次性将多条命令发送到服务器,由服务器一条一条的执行;事务是一条一条的发的到服务端,只有在接收到exec命令后在服务端统一执行
执行事务时会阻塞其他命令的执行,而执行管道中的命令不会阻塞,因为管道不在
Roaring Bitmap 咆哮位图
BitMap 原理
BitMap的基本思想就是用 bit位 来标记某个元素对应的value,而key就是这个元素。
例如,在下图中,是一个字节代表的8位,下标为1,2,4,6的bit位的值为1,则该字节表示{1,2,4,6}这几个数
例如,在下图中,是一个字节代表的8位,下标为1,2,4,6的bit位的值为1,则该字节表示{1,2,4,6}这几个数
在Java中,1个int占用4个字节,如果用int来存储这四个数字的话,那么将需要 4 * 4 = 16字节。
BitMap可以用于快速排序,查找,及去重等操作。优点是占用内存少(相较于数组)和运算效率高,但是缺点也非常明显,无法存储重复的数据,并且在存储较为稀疏的数据时,浪费存储空间较多。
BitMap可以用于快速排序,查找,及去重等操作。优点是占用内存少(相较于数组)和运算效率高,但是缺点也非常明显,无法存储重复的数据,并且在存储较为稀疏的数据时,浪费存储空间较多。
基本位图实现存在问题
即使只存一个数(最大的),存储空间也是512MB
对于原始的Bitmap来说,这就需要 长度的bit数组,通过计算可以发现 ( ), 一个普通的Bitmap需要耗费512MB的存储空间
不管业务值的基数有多大,这个存储空间的消耗都是恒定不变的,这显然是不能接受的。
redis 的基本的位图实现就存在这个问题
对于原始的Bitmap来说,这就需要 长度的bit数组,通过计算可以发现 ( ), 一个普通的Bitmap需要耗费512MB的存储空间
不管业务值的基数有多大,这个存储空间的消耗都是恒定不变的,这显然是不能接受的。
redis 的基本的位图实现就存在这个问题
REDIS BITMAP 测试
咆哮位图,是一种压缩位图,是对bitmap的改进,除了使用bitmap存储数据,还使用了array等数据结构,以达到压缩的目的。
基于位图的数据结构,可以高效地存储大量的非负整数,并支持多种集合运算,如并集、交集、差集等。它可以高效地判断一个元素是否在集合中,并且可以使用很少的空间来存储集合。
基于位图的数据结构,可以高效地存储大量的非负整数,并支持多种集合运算,如并集、交集、差集等。它可以高效地判断一个元素是否在集合中,并且可以使用很少的空间来存储集合。
咆哮位图和bitmap的区别
① 比bitmap更节省内存空间
1. 把32位分为 个容器,只为用到的容器分配空间,解决了稀疏数据浪费空间的问题。
2. 每个容器根据数据的稠密情况使用array或bitmap数据结构,节省了每个容器占用的内存空间。
② 比bitmap性能更高
1. 因为不会开辟大量不用的内存,参与计算的内存块比较少,提升计算速度。
2. 使用有序数组保存容器,在进行逻辑运算时(与或非)基本只需要计算相同索引的容器。
作用
① 解决bitmap统计大数据尤其是稀疏数据浪费内存空间的问题;
② 解决bitmap内存空间无法收缩的问题:存储容器的array和ArrayContainer都是数组,支持清空和移除元素,但其空间释按照语言自身的GC机制处理
Roaring Bitmap实现
使用拆分模式,把 位拆分为 个容器,每个容器最多保存 个元素。 65536
把要统计的数字拆分位高16位和低16位,高16位用作容器的索引、用于定位数字在哪个容器;低16位用作容器内元素的索引、用作定位数字在容器内的位置。
容器保存在一个有序数组中,在需要时被创建,不需要时不会创建。该数组名为highLowContainer。
容器有3种类型:ArrayContainer、BitmapContainer和RunContainer,根据要统计的数字的数量和数字的连续性自动选择合适的容器。
把要统计的数字拆分位高16位和低16位,高16位用作容器的索引、用于定位数字在哪个容器;低16位用作容器内元素的索引、用作定位数字在容器内的位置。
容器保存在一个有序数组中,在需要时被创建,不需要时不会创建。该数组名为highLowContainer。
容器有3种类型:ArrayContainer、BitmapContainer和RunContainer,根据要统计的数字的数量和数字的连续性自动选择合适的容器。
- RunContainer:使用Run-Length Encoding方式压缩存储的元素,对连续数据的压缩效果特别好,但如果数据比较散列,反而会更占用空间,长度没有限制;
- ArrayContainer:元素为short类型的有序数组,存储散列数据时,效果比较好;
- BitmapContainer:使用bitmap存储数据,存储大量数据时,效果比较好;
Container 是 RoaringBitmap的核心,我们结合上面的图会发现每个 32 位整形(int)的高 16 位已经作为key 存储在 RoaringArray 中了,那么 Container 只需要处理低 16 位的数据即可。
ArrayContainer
可以看出 16 位数据 value 直接存储在 short[] content 中,因为是数组,始终保持顺序存储且不会重复,有利于二分查找。Container 存储数据没有任何压缩,只适合存储少量数据。
ArrayContainer 占用的空间大小与存储的数据量为线性关系,每个 short 大小为 2 kb,所以存储了 N 个数据的ArrayContainer 占用空间大致为 2N kb。存储一个数据需要占用 2kb,存储 4096 需要占用 8kb。
上面 DEFAULT_MAX_SIZE 值为 4096,可以知道,当容量超过这个值的时候会将当前 Container 替换为BitmapContainer
ArrayContainer 占用的空间大小与存储的数据量为线性关系,每个 short 大小为 2 kb,所以存储了 N 个数据的ArrayContainer 占用空间大致为 2N kb。存储一个数据需要占用 2kb,存储 4096 需要占用 8kb。
上面 DEFAULT_MAX_SIZE 值为 4096,可以知道,当容量超过这个值的时候会将当前 Container 替换为BitmapContainer
为什么是4096的时侯升级容器?
bitmap vs array
1. bitmap存储空间恒定为8K,最大的基数可达到 8 * 1024 * 8 = 65536个
2. array的基数与存储空间成正比,即基数越大,占用空占越多。通过以上两点我们取两者交相交的点为平衡点,即小于该点array更省空间,大于该点bitmap更省空间。
2. array的基数与存储空间成正比,即基数越大,占用空占越多。通过以上两点我们取两者交相交的点为平衡点,即小于该点array更省空间,大于该点bitmap更省空间。
BitmapContainer
BitmapContainer 底层用了 long[] 存储位图数据。RMB 每个 Container处理 16 位整形(int)数据,0~65535,需要 65536 个 bit 来存储数据,每个 bit 位用 1 来表示有,0 来表示无。每个 long 有 64 位,所以需要 1024 个 long 来提供 65536 个 bit。
BitmapContainer 中无论存储了 1 个还是存储了 65536 个数据,其占用的空间都是同样的 8 kb (4096)。
BitmapContainer 中无论存储了 1 个还是存储了 65536 个数据,其占用的空间都是同样的 8 kb (4096)。
RunContainer
RunContainer 又称行程长度压缩算法(Run Length Encoding),在连续数据上压缩效果显著。
RunContainer 原理在连续出现的数字,只会记录其初始数字和后续数量,举个例子:
其中,short[] valueslength 中存储的就是压缩后的数据。
可以看出,这种压缩算法在性能和数据的连续性(紧凑性)关系极为密切
如果要分析RunContainer的容量,我们可以做下面两种极端的假设:
小结一下:
RunContainer 原理在连续出现的数字,只会记录其初始数字和后续数量,举个例子:
- 数列 22,它会压缩为 22,0;
- 数列 22,23,24 它会压缩为 22,3;
- 数列 22,23,24,32,33,它会压缩为 22,3,32,1;
其中,short[] valueslength 中存储的就是压缩后的数据。
可以看出,这种压缩算法在性能和数据的连续性(紧凑性)关系极为密切
- 在连续的 100 个 short,可以将 200 字节压缩成 4 个 kb。
- 对于不连续的 100 个 short,编码完之后会从 200 字节变为 400 kb。
如果要分析RunContainer的容量,我们可以做下面两种极端的假设:
- 最优情况,只存在一个数据或者一串连续数字,存储 2 个 short 会占用 4 kb。
- 最差情况,0~65535 的范围内填充所有的不连续数字,(全部奇数位或全部偶数位),需要存储 65536 个short 占用 128 kb。
小结一下:
Redis 新版特性
Redis 4.0
模块系统 Modules
Redis Module是Redis的一种扩展模块,从 4.0版本开始,允许用户自定义扩展模块,在Redis内部实现新的数据类型和功能,使用统一的调用方式和传输协议格式扩展Redis的能力。它本身的设计目的就是在不同版本的Redis中运行,因此无需重新编译模块即可与特定版本(Redis > 4.0)一起运行
常见的Redis Module
RediSearch
一个功能齐全,可实现 快速检索、二次索引和全文搜索的搜索引擎模块
RedisJSON
RedisJSON是一个用于处理 JSON 数据的模块,它实现了JSON数据交换标准,允许从Redis 文档中存储、更新和获取JSON值
RedisTimeSeries
RedisTimeSeries是Redis的一个时间序列数据库(TSDB)管理模块。RedisTimeSeries可以保存多个时间序列,每个时间序列都可以通过一个Redis键访问(类似于任何其他Redis数据结构)。
RedisGraph
用于实现图形数据库的模块
RedisBloom
实现分布式限流能力的模块,使用了相对精妙的算法 generic cell rate algorithm (GCRA) 。
RedisAI
RedisAI是一个Redis模块,用于执行深度学习/机器学习模型并管理其数据。它的目的是成为模型服务的“主力军”,为流行的DL/ML框架提供开箱即用的支持和无与伦比的性能。RedisAI坚持数据本地化原则,最大限度地提高了计算吞吐量,减少了延迟
如果你想要了解很多的内容,可以参考官方文档:https://redis.io/modules
改进主从复制策略 psync2
Redis4.0新特性psync2(partial resynchronization version2)部分重新同步(partial resync)增加版本;
主要解决Redis运维管理过程中,从实例重启和主实例故障切换等场景带来的全量重新同步(full resync)问题。
主要解决Redis运维管理过程中,从实例重启和主实例故障切换等场景带来的全量重新同步(full resync)问题。
新版本的PSYNC命令解决了旧版本的 Redis 在复制时的一些不够优化的地方
在旧版本 Redis 中, 如果一个从服务器在 FAILOVER 之后成为了新的主节点, 那么其他从节点在复制这个新主的时候就必须进行全量复制。 在 Redis 4.0 中, 新主和从服务器在处理这种情况时, 将在条件允许的情况下使用部分复制。
在旧版本 Redis 中, 一个从服务器如果重启了, 那么它就必须与主服务器重新进行全量复制, 在 Redis 4.0 中, 只要条件允许, 主从在处理这种情况时将使用部分复制。
在旧版本中,当复制为链式复制的时候,如 A—>B—>C ,主节点为A。当A出现问题,C节点不能正常复制B节点的数据。当提升B为主节点,C需要全量同步B的数据。在PSYNC2:PSYNC2解决了链式复制之间的关联性。A出现问题不影响C节点,B提升为主C不需要全量同步。
在使用星形复制时,如一主两从。A—>B , A—>C ,主节点为A。当A出现问题,B提升为主节点,C 重新指向主节点B。使用同步机制PSYNC2,C节点只做增量同步即可。在使用sentinel故障转移可以较少数据重新同步的延迟时间,避免大redis同步出现的网络带宽占满。
什么是Redis部分重新同步psync
在psync1功能出现前,redis复制秒级中断,就会触发从实例进行fullsync。每一次的fullsync,集群的性能和资源使用都可能带来抖动;如果redis所处的网络环境不稳定,那么fullsync的出步频率可能较高
为解决此问题,redis2.8引入psync1, 有效地解决这种复制闪断,带来的影响
为解决此问题,redis2.8引入psync1, 有效地解决这种复制闪断,带来的影响
redis的fullsync对业务而言,算是比较“重”的影响;
对性能和可用性都有一定危险。这里列举几个fullsync常见的影响
对性能和可用性都有一定危险。这里列举几个fullsync常见的影响
1. master需运行bgsave,出现Fork,可能造成master达到毫秒或秒级的卡顿(latest_fork_usec);
2. redis进程Fork导致Copy-On-Write内存使用消耗(后文简称COW),最大能导致master进程内存使用量的消耗。 (eg RDB: 5213 MB of memory used by copy-on-write)
3. Redis Slave load RDB过程,会导致复制线程的client output buffer增长很大;增大Master进程内存消耗;
4. Redis保存RDB(不考虑disless replication),导致服务器磁盘IO和CPU(压缩)资源消耗
5. 发送数GB大小的RDB文件,会导致服务器网络出口爆增,如果千兆网卡服务器,期间会影响业务正常请求响应时间(以及其他连锁影响)
psync1的基本实现
redis2.8为支持psync1,引入了 replication backlog buffer(复制积压缓冲区)
复制积压缓冲区是redis维护的固定长度缓冲队列(由参数repl-backlog-size设置,默认1MB),master的写入命令在同步给slaves的同时,会在缓冲区中写入一份(master只有1个积压缓冲区,所有slaves共享)。
当redis复制中断后,slave会尝试采用psync, 上报原master runid + 当前已同步master的offset(复制偏移量,类似mysql的binlog file和position);
如果runid与master的一致,且复制偏移量在master的复制积压缓冲区中还有(即offset >= min(backlog值),master就认为部分重同步成功,不再进行全量同步。
如果runid与master的一致,且复制偏移量在master的复制积压缓冲区中还有(即offset >= min(backlog值),master就认为部分重同步成功,不再进行全量同步。
psync1的不足
psync1需2个条件同时满足,才能成功psync: ① master runid不变 和 ②复制偏移量在master复制积缓冲区中
那么在redis slave重启,因 master runid和复制偏移量都会丢失,需进行全量重同步;
redis master发生故障切换,因master runid发生了变化;故障切换后,新的slave需进行全量重同步。
而slave维护性重启、master故障切换都是redis运维常见场景,为redis的psync1是不能解决这两类场景的成功部分重同步问题。
因此redis4.0的加强版部分重同步功能-psync2,主要解决这两类场景的部分重新同步。
redis master发生故障切换,因master runid发生了变化;故障切换后,新的slave需进行全量重同步。
而slave维护性重启、master故障切换都是redis运维常见场景,为redis的psync1是不能解决这两类场景的成功部分重同步问题。
因此redis4.0的加强版部分重同步功能-psync2,主要解决这两类场景的部分重新同步。
psync2的实现
实例的replid信息,可通过info replication进行查看; 示例如下:
在redis cluster的实际生产运营中,实例的维护性重启、主实例的故障切换(如cluster failover)操作都是比较常见的(如实例升级、rename command和释放实例内存碎片等)。而在redis4.0版本前,这类维护性的处理,redis都会发生全量重新同步,导到性能敏感的服务有少量受损
psync2主要让redis在从实例重启和主实例故障切换场景下,也能使用部分重新同步
- master_replid : 复制ID1(后文简称:replid1),一个长度为41个字节(40个随机串+'\0')的字符串。redis实例都有,和runid没有直接关联,但和runid生成规则相同,都是由getRandomHexChars函数生成。当实例变为从实例后,自己的replid1会被主实例的replid1覆盖。
- master_replid2:复制ID2(后文简称:replid2),默认初始化为全0,用于存储上次主实例的replid1:
实例的replid信息,可通过info replication进行查看; 示例如下:
Redis从实例重启的部分重新同步
在之前的版本,redis重启后,复制信息是完全丢失;所以从实例重启后,只能进行全量重新同步。
redis4.0 为实现重启后,仍可进行部分重新同步,主要做以下3点:
1. redis关闭时,把复制信息作为辅助字段(AUX Fields)存储在RDB文件中;以实现同步信息持久化。
2. redis启动加载RDB文件时,会把复制信息赋给相关字段;为部分同步
3. redis重新同步时,会上报repl-id和repl-offset同步信息,如果和主实例匹配,且offset还在主实例的复制积压缓冲区内,则只进行部分重新同步。
redis4.0 为实现重启后,仍可进行部分重新同步,主要做以下3点:
1. redis关闭时,把复制信息作为辅助字段(AUX Fields)存储在RDB文件中;以实现同步信息持久化。
2. redis启动加载RDB文件时,会把复制信息赋给相关字段;为部分同步
3. redis重新同步时,会上报repl-id和repl-offset同步信息,如果和主实例匹配,且offset还在主实例的复制积压缓冲区内,则只进行部分重新同步。
① redis关闭时,持久化复制信息到RDB
redis在关闭时,通过shutdown save,都会调用 rdbSaveInfoAuxFields 函数,把当前实例的repl-id和repl-offset保存到RDB文件中。
说明:当前的RDB存储的数据内容和复制信息是一致性的。熟悉MySQL的同学,可以认为MySQL中全量备份数和binlog信息是一致的
说明:当前的RDB存储的数据内容和复制信息是一致性的。熟悉MySQL的同学,可以认为MySQL中全量备份数和binlog信息是一致的
生成的RDB文件,可以通过redis自带的redis-check-rdb工具查看辅助字段信息。
其中repl两字段信息和info中的相同;
其中repl两字段信息和info中的相同;
② redis启动读取RDB中复制信息
redis实例启动读取RDB文件,通过rdb.c文件中 rdbLoadRio() 函数实现。
redis加载RDB文件,会专门处理文件中辅助字段(AUX fields)信息,把其中repl_id和repl_offset加载到实例中,分别赋给master_replid和master_repl_offset两个变量值。
redis加载RDB文件,会专门处理文件中辅助字段(AUX fields)信息,把其中repl_id和repl_offset加载到实例中,分别赋给master_replid和master_repl_offset两个变量值。
以下代码,是从RDB文件中读取两个辅助字段值
③ redis从实例尝试部分重新同步
redis实例重启后,从RDB文件中加载(注:此处不讨论AOF和RDB加载优先权)master_replid和master_repl_offset;相当于实例的server.cached_master。当我们把它作为某个实例的从库时(包含如被动的cluster slave或主动执行slaveof指令),实例向主实例上报master_replid和master_repl_offset+1;从实例同时满足以下两条件,就可以部分重新同步
1、从实例上报master_replid串,与主实例的master_replid1或replid2有一个相等
2、从实例上报的master_repl_offset+1字节,还存在于主实例的复制积压缓冲区中
1、从实例上报master_replid串,与主实例的master_replid1或replid2有一个相等
2、从实例上报的master_repl_offset+1字节,还存在于主实例的复制积压缓冲区中
④ redis重启时,临时调整主实例的复制积压缓冲区大小
redis的复制积压缓冲区是通过参数repl-backlog-size设置,默认1MB;为确保从实例重启后,还能部分重新同步,需设置合理的repl-backlog-size值
1. 计算合理的repl-backlog-size值大小
通过主库每秒增量的master复制偏移量master_repl_offset(info replication指令获取)大小,如每秒offset增加是5MB,那么主实例复制积压缓冲区要保留最近60秒写入内容,backlog_size设置就得大于300MB(60*5)。而从实例重启加载RDB文件是较耗时的过程,如重启某个重实例需120秒(RDB大小和CPU配置相关),那么主实例backlog_size就得设置至少600MB.
2. 重启从实例前,调整主实例的动态调整repl-backlog-size的值
通过config set动态调整redis的repl-backlog-size时,redis会释放当前的积压缓冲区,重新分配一个指定大小的缓冲区。 所以我们必须在从实例重启前,调整主实例的repl-backlog-size。
psync2实现Redis Cluster Failover部分全新同步
pipe优化aofrewrite
redis是通过fork子进程来做aofrewrite。同时为了保证aof的连续性,父进程把aofrewrite期间的写命令缓存起来,等子进程结束之后再追加到新的aof文件。如果期间写入量较大的话,就要有大量的写硬盘操作。这个写硬盘的操作是阻塞的
通过管道方式,将追加写盘的操作也就转交给了子进程,避免阻塞主进程
支持发现热点key
由于热点key的发现是基于LFU算法实现的,注意需要内存逐出策略设置为allkeys-lfu或者volatile-lfu才能使用该功能
命令: ./redis-cli --hotkeys
命令: ./redis-cli --hotkeys
lazyfree 异步方式优化阻塞
Redis 对于 ①大key删除、②FLUSHDB和FLUSHALL清空包含大量键的数据库、③大量key集中过期、④主从全量复制时,从库清空旧数据。都会造成redis阻塞。
为了解决以上问题, redis 4.0 引入了lazyfree的机制,它可以将删除键或数据库的操作放在后台线程里执行, 从而尽可能地避免服务器阻塞
为了解决以上问题, redis 4.0 引入了lazyfree的机制,它可以将删除键或数据库的操作放在后台线程里执行, 从而尽可能地避免服务器阻塞
4.0增加unlink、flushdb async、flushall async命令, 还增加了4个后台删除配置项,分别为:
- slave-lazy-flush:slave接收完rdb文件后清空数据选项
- lazyfree-lazy-eviction:内存满逐出选项
- lazyfree-lazy-expire:过期key删除选项
- lazyfree-lazy-server-del:内部删除选项,比如rename srckey destkey时,如果destkey存在需要先删除destkey
unlink
会判断下对象的大小(太小就没必要后台删除),如果足够大就丢给后台线程,最后清理下数据库字典的条目信息
FLUSHALL、FLUSHDB
lazyfree线程
虽然redis把处理网络收发和执行命令这些操作都放在了主工作线程,但是除此之外还有许多bio后台线程也在兢兢业业的工作着,比如用来处理关闭文件和刷盘这些比较重的IO操作,这次bio家族又加入了新的小伙伴——lazyfree线程
1、后台删除对象,调用 decrRefCount 来减少对象的引用计数,引用计数为0时会真正的释放资源
2、后台清空数据库字典,调用 dictRelease 循环遍历数据库字典删除所有对象
3、后台删除key-slots映射表,原生redis如果运行在集群模式下会用,云redis使用的自研集群模式这一函数目前并不会调用
过期与逐出
所以redis 4.0这次除了显示增加 unlink、flushdb async、flushall async命令之外,还增加了4个后台删除配置项,分别为
- slave-lazy-flush:slave接收完RDB文件后清空数据选项
- lazyfree-lazy-eviction:内存满逐出选项
- lazyfree-lazy-expire:过期key删除选项
- lazyfree-lazy-server-del:内部删除选项,比如rename srckey destkey时,如果destkey存在需要先删除destkey
expire优化
redis在空闲时会进入activeExpireCycle循环删除过期key,每次循环都会率先计算一个执行时间,在循环中并不会遍历整个数据库,而是随机挑选一部分key查看是否到期,所以有时时间不会被耗尽(采取异步删除时更会加快清理过期key),剩余的时间就可以交给freeMemoryIfNeeded来执行
evict优化
如果evict未能根据逐出策略释放足够多的内存空间,就可以查看BIO_LAZY_FREE后台线程的任务队列,尝试等待后台线程来释放空间。如果后台线程释放了足够的内存就返回C_OK,如果超时或是后台线程执行完毕仍不能释放足够多的内存空间,那就只能返回C_ERR了
MEMORY命令更好的了解redis内存
在过去,查看redis的内存使用状态只有 info memory 命令,而且也只有一些基础信息,想要获取全局信息就有些困难。4.0开始redis提供了MEMORY命令,一切都变得简单起来
MEMORY命令
MEMORY命令一共有5个子命令,可以通过 MEMORY HELP 来查看
MEMORY STATS
redis的内存使用不仅包含所有的key-value数据,还有描述这些key-value的元信息,以及许多管理功能的消耗,比如持久化、主从复制,通过MEMORY STATS可以更好的了解到redis的内存使用状况
这里我们启动了一个打开持久化功能并且带slave的redis,向其中随机写入了一些数据(某些数据还带有过期时间),以便读者可以更好的了解redis的内存使用,接下来执行MEMORY STATS命令:
peak.allocated
redis启动到现在,最多使用过多少内存
total.allocated
当前使用的内存总量
startup.allocated
- redis启动初始化时使用的内存,有很多读者会比较奇怪,为什么我的redis启动以后什么都没做就已经占用了几十MB的内存?
- 这是因为redis本身不仅存储key-value,还有其他的内存消耗,比如共享变量、主从复制、持久化和db元信息,下面各项会有详细介绍
replication.backlog
主从复制backlog使用的内存,默认10MB,backlog只在主从断线重连时发挥作用,主从复制本身并不依赖此项
clients.slaves
主从复制中所有slave的读写缓冲区,包括output-buffer(也即输出缓冲区)使用的内存和querybuf(也即输入缓冲区)
aof.buffer
此项为aof持久化使用的缓存和aofrewrite时产生的缓存之和,当然如果关闭了appendonly那这项就一直为0
db.0
redis每个db的元信息使用的内存,这里只使用了db0,所以只打印了db0的内存使用状态,当使用其他db时也会有相应的信息
MEMORY USAGE
使用方法:MEMORY usage [samples]
命令参数不多,通过字面意思也可以看出来是评估指定key的内存使用情况。samples是可选参数默认为5,以hash为例看下其如果工作:
① 首先类似于上一节中的 overhead.hashtable.main,要计算hash的元信息内存,包括hash表的大小以及所有dictEntry的内存占用信息。
② 与overhead.hashtable.main不同的是,每个dictEntry中key-value都是字符串,所以没redisObject的额外消耗。在评估真正的数据内存大小时redis并没有去遍历所有key,而是采用的抽样估算:随机抽取samples个key-value对计算其平均内存占用,再乘以key-value对的个数即得到结果。试想一下如果要精确计算内存占用,那么就需要遍历所有的元素,当元素很多时就是使redis阻塞,所以请合理设置samples的大小
① 首先类似于上一节中的 overhead.hashtable.main,要计算hash的元信息内存,包括hash表的大小以及所有dictEntry的内存占用信息。
② 与overhead.hashtable.main不同的是,每个dictEntry中key-value都是字符串,所以没redisObject的额外消耗。在评估真正的数据内存大小时redis并没有去遍历所有key,而是采用的抽样估算:随机抽取samples个key-value对计算其平均内存占用,再乘以key-value对的个数即得到结果。试想一下如果要精确计算内存占用,那么就需要遍历所有的元素,当元素很多时就是使redis阻塞,所以请合理设置samples的大小
MEMORY DOCTOR
此项子命令是作者给出的关于redis内存使用方面的建议,在不同的允许状态下会有不同的分析结果
MEMORY MALLOC-STATS
打印内存分配器状态,只在使用jemalloc时有用
MEMORY PURGE
请求分配器释放内存,同样只对jemalloc生效
redis 碎片整理
redis碎片率过高时,物理内存利用率低。以前只能通过重启的方式来解决,现在提供动态清理的方式。
- 支持在运行期进行自动内存碎片清理 (config set activedefrag yes)
- 支持通过命令 memory purge 进行清理(与自动清理区域不同)
需要使用jemalloc作为内存分配器(默认的).
redis为了保证碎片清理时的性能,只会对属于jemalloc small bin (not large or huge)的kv进行清理
redis为了保证碎片清理时的性能,只会对属于jemalloc small bin (not large or huge)的kv进行清理
Redis的数据被删除,内存占用还这么大?
假设 Redis 实例保存了 5GB 的数据,现在删除了 2GB 数据,Redis 进程占用的内存一定会降低么?(也叫做 RSS,进程消耗内存页数)?
Redis的内存消耗组成
- Redis 自身启动所占用的内存;
- 存储对象数据内存;
- 缓冲区内存:主要由 client-output-buffer-limit 客户端输出缓冲区、复制积压缓冲区、AOF 缓冲区。
- 内存碎片。
- 子进程消耗
使用 info memory 命令获取 Redis 内存相关指标,我列举了几个重要的数据
Redis 自身空进程占用的内存很小可以忽略不计,对象内存是占比对打的一块,里面存储着所有的数据。
缓冲区内存在大流量场景容易失控,造成 Redis 内存不稳定,需要重点关注。
内存碎片过大会导致明明有空间可用,但是却无法存储数据。
碎片 = used_memory_rss 实际使用的物理内存(RSS 值)除以 used_memory 实际存储数据内存。
缓冲区内存在大流量场景容易失控,造成 Redis 内存不稳定,需要重点关注。
内存碎片过大会导致明明有空间可用,但是却无法存储数据。
碎片 = used_memory_rss 实际使用的物理内存(RSS 值)除以 used_memory 实际存储数据内存。
什么是内存碎片?
内存碎片会造成明明有内存空间空闲,可是却无法存储数据
内存碎片形成原因
① 内存分配器的分配策略
Redis 默认的内存分配器采用 jemalloc,可选的分配器还有:glibc、tcmalloc。
内存分配器并不能做到按需分配,而是采用固定范围的内存块进行分配
内存分配器并不能做到按需分配,而是采用固定范围的内存块进行分配
例如 8 字节、16 字节…..,2 KB,4KB,当申请内存最近接某个固定值的时候,jemalloc 会给它分配最接近固定值大小的空间。
这样就会出现内存碎片,比如程序只需要 1.5 KB,内存分配器会分配 2KB 空间,那么这 0.5KB 就是碎片。
这么做的目的是减少内存分配次数,比如申请 22 字节的空间保存数据,jemalloc 就会分配 32 字节,如果后边还要写入 10 字节,就不需要再向操作系统申请空间了,可以使用之前申请的 32 字节。
删除 key 的时候,Redis 并不会立马把内存归还给操作系统,出现这个情况是因为底层内存分配器管理导致,比如大多数已经删除的 key 依然与其他有效的 key分配在同一个内存页中。
另外,分配器为了复用空闲的内存块,原有 5GB 的数据中删除了 2 GB 后,当再次添加数据到实例中,Redis 的 RSS 会保持稳定,不会增长太多。
因为内存分配器基本上复用了之前删除释放出来的 2GB 内存。
这样就会出现内存碎片,比如程序只需要 1.5 KB,内存分配器会分配 2KB 空间,那么这 0.5KB 就是碎片。
这么做的目的是减少内存分配次数,比如申请 22 字节的空间保存数据,jemalloc 就会分配 32 字节,如果后边还要写入 10 字节,就不需要再向操作系统申请空间了,可以使用之前申请的 32 字节。
删除 key 的时候,Redis 并不会立马把内存归还给操作系统,出现这个情况是因为底层内存分配器管理导致,比如大多数已经删除的 key 依然与其他有效的 key分配在同一个内存页中。
另外,分配器为了复用空闲的内存块,原有 5GB 的数据中删除了 2 GB 后,当再次添加数据到实例中,Redis 的 RSS 会保持稳定,不会增长太多。
因为内存分配器基本上复用了之前删除释放出来的 2GB 内存。
② 键值对大小不一样和删改操作
由于内存分配器是按照固定大小分配内存,所以通常分配的内存空间比实际数据占用的大小多一些,会造成碎片,降低内存的存储效率。
另外,键值对的频繁修改和删除,导致内存空间的扩容和释放,比如原本占用 32 字节的字符串,现在修改为占用 20 字节的字符串,那么释放出的 12 字节就是空闲空间。
如果下一个数据存储请求需要申请 13 字节的字符串,那么刚刚释放的 12 字节空间无法使用,导致碎片。
另外,键值对的频繁修改和删除,导致内存空间的扩容和释放,比如原本占用 32 字节的字符串,现在修改为占用 20 字节的字符串,那么释放出的 12 字节就是空闲空间。
如果下一个数据存储请求需要申请 13 字节的字符串,那么刚刚释放的 12 字节空间无法使用,导致碎片。
碎片最大的问题:空间总量足够大,但是这些内存不是连续的,可能大致无法存储数据。
内存释放措施
那该如何解决呢?
首先要确定是否发生了内存碎片,重点关注前面 INFO memory 命令提示的 mem_fragmentation_ratio 指标,表示内存碎片率
mem_fragmentation_ratio = used_memory_rss/ used_memory
mem_fragmentation_ratio = used_memory_rss/ used_memory
如果 1 < 碎片率 < 1.5,可以认为是合理的,而大于 1.5 说明碎片已经超过 50%,我们需要采取一些手段解决碎片率过大的问题
① 重启大法
最简单粗暴的方式就是重启,如果没有开启持久化,数据会丢失。
开启持久化的话,需要使用 RDB 或者 AOF 恢复数据,如果只有一个实例,数据大的话会导致恢复阶段长时间无法提供服务,高可用大打折扣。
开启持久化的话,需要使用 RDB 或者 AOF 恢复数据,如果只有一个实例,数据大的话会导致恢复阶段长时间无法提供服务,高可用大打折扣。
② 手动清理
memory purge
③ 自动清理
对于 Redis 来说,当一块连续的内存空间被划分为好几块不连续的空间的时候,操作系统先把数据以依次挪动拼接在一块,并释放原来数据占据的空间,形成一块连续空闲内存空间。。
开启自动内存碎片清理
CONFIG SET activedefrag yes
清理的条件
active-defrag-ignore-bytes 200mb:内存碎片占用的内存达到 200MB,开始清理;
active-defrag-threshold-lower 20:内存碎片的空间占惭怍系统分配给 Redis 空间的 20% ,开始清理
自动清理内存碎片的代价
自动清理虽好,可不要肆意妄为,操作系统把数据移动到新位置,再把原有空间释放是需要消耗资源的。
Redis 操作数据的指令是单线程,所以在数据复制移动的时候,只能等待清理碎片完成才能处理请求,造成性能损耗
Redis 操作数据的指令是单线程,所以在数据复制移动的时候,只能等待清理碎片完成才能处理请求,造成性能损耗
如何避免清理碎片对性能的影响又能实现自动清理呢?
清理时间有了,还需要控制清理对性能的影响。由一项两个设置先分配清理碎片占用的 CPU 资源,保证既能正常清理碎片,又能避免对 Redis 处理请求的性能影响。
- active-defrag-cycle-min 20:自动清理过程中,占用 CPU 时间的比例不低于 20%,从而保证能正常展开清理任务。
- active-defrag-cycle-max 50:自动清理过程占用的 CPU 时间比例不能高于 75%,超过的话就立刻停止清理,避免对 Redis 的阻塞,造成高延迟
总结
① 如果你发现明明 Redis 存储数据的内存占用远小于操作系统分配给 Redis 的内存,而又无法保存数据,那可能出现大量内存碎片了。
② 通过 info memory 命令,看下内存碎片mem_fragmentation_ratio 指标是否正常。
③ 那么我们就开启自动清理并合理设置清理时机和 CPU 资源占用,该机制涉及到内存拷贝,会对 Redis 性能造成潜在风险。
④ 如果遇到 Redis 性能变慢,排查下是否由于清理碎片导致,如果是,那就调小 active-defrag-cycle-max 的值。
② 通过 info memory 命令,看下内存碎片mem_fragmentation_ratio 指标是否正常。
③ 那么我们就开启自动清理并合理设置清理时机和 CPU 资源占用,该机制涉及到内存拷贝,会对 Redis 性能造成潜在风险。
④ 如果遇到 Redis 性能变慢,排查下是否由于清理碎片导致,如果是,那就调小 active-defrag-cycle-max 的值。
RDB-AOF混合持久化
混合存储先以RDB格式写入全量数据再追加增量日志呢这样既可以提高aofrewrite和恢复速度也可以减少文件大小还可以保证数据的完毕性整合RDB和AOF的优点
redis有两种持久化的方式——RDB和AOF,RDB是一份内存快照,AOF则为可回放的命令日志,他们两个各有特点也相互独立。4.0开始允许使用RDB-AOF混合持久化的方式,结合了两者的优点,通过 aof-use-rdb-preamble 配置项可以打开混合开关
缓存驱逐策略优化
新添加了Last Frequently Used(LFU)缓存驱逐策略;
交换数据库
Redis 4.0 对数据库命令的另外一个修改是新增了 SWAPDB 命令, 这个命令可以对指定的两个数据库进行互换:
比如说, 通过执行命令 SWAPDB 0 1 , 我们可以将原来的数据库 0 变成数据库 1 , 而原来的数据库 1 则变成数据库 0
比如说, 通过执行命令 SWAPDB 0 1 , 我们可以将原来的数据库 0 变成数据库 1 , 而原来的数据库 1 则变成数据库 0
其他
- Redis Cluster的故障检测方式改变,node之间的通讯减少;
- 慢日志记录客户端来源IP地址,这个小功能对于故障排查很有用处;
- 新增zlexcount命令,用于sorted set中,和zrangebylex类似,不同的是zrangebylex返回member,而zlexcount是返回符合条件的member个数;
Redis 5.0
Stream
新的Redis模块API
定时器(Timers)、集群(Cluster)和字典API(Dictionary APIs)
Lua改进
- 将 Lua 脚本更好地传播到 replicas / AOF
- Lua 脚本现在可以超时并在副本中进入 -BUSY 状态
RDB 现在可存储 LFU 和 LRU 信息
Redis 5.0 引入了新的 RDB 文件格式,该格式支持存储 LFU(最少频率使用)和 LRU(最近最少使用)信息。这些信息用于在内存中保留热门和冷门数据,从而改善缓存效率。
在 Redis 5.0 之前,RDB 文件格式没有原生支持存储 LFU/LRU 信息。因此,Redis 5.0 之前的版本不支持直接从 RDB 文件恢复 LFU/LRU 信息
在 Redis 5.0 之前,RDB 文件格式没有原生支持存储 LFU/LRU 信息。因此,Redis 5.0 之前的版本不支持直接从 RDB 文件恢复 LFU/LRU 信息
如果你需要在 Redis 5.0 及以上版本中使用 LFU 或 LRU 数据驱动的内存回收策略,你需要在 redis.conf 配置文件中设置相应的淘汰策略,并且在使用 SAVE 或 BGSAVE 命令创建 RDB 文件时,新的 RDB 文件格式将包含有关键的 LFU/LRU 信息
动态HZ
以前redis版本的配置项hz都是固定的,redis5.0将hz动态化是为了平衡空闲CPU的使用率和响应能力。
当然这个是可配置的,只不过在5.0中默认是动态的,其对应的配置为:dynamic-hz yes
当然这个是可配置的,只不过在5.0中默认是动态的,其对应的配置为:dynamic-hz yes
ZPOPMIN&ZPOPMAX命令
ZPOPMIN key [count]
在有序集合ZSET所有key中,删除并返回指定count个数得分最低的成员,如果返回多个成员,也会按照得分高低(value值比较),从低到高排列。
ZPOPMAX key [count]
在有序集合ZSET所有key中,删除并返回指定count个数得分最高的成员,如果返回多个成员,也会按照得分高低(value值比较),从高到低排列。
BZPOPMIN key [key …] timeout
ZPOPMIN的阻塞版本。
BZPOPMAX key [key …] timeout
ZPOPMAX的阻塞版本。
CLIENT新增命令
CLIENT UNBLOCK
格式:CLIENT UNBLOCK client-id [TIMEOUT|ERROR]
用法:当客户端因为执行具有阻塞功能的命令如BRPOP、XREAD被阻塞时,该命令可以通过其他连接解除客户端的阻塞
集群管理器更改
redis3.x和redis4.x的集群管理主要依赖基于Ruby的redis-trib.rb脚本,redis5.0彻底抛弃了它,将集群管理功能全部集成到完全用C写的redis-cli中。可以通过命令redis-cli --cluster help查看帮助信息
其他
主动碎片整理V2:增强版主动碎片整理,配合Jemalloc版本更新,更快更智能,延时更低;
HyperLogLog改进:在Redis5.0中,HyperLogLog算法得到改进,优化了计数统计时的内存使用效率;
更好的内存统计报告;
客户经常连接和断开连接时性能更好;
错误修复和改进;
Jemalloc内存分配器升级到5.1版本;
许多拥有子命令的命令,新增了HELP子命令,如:XINFO help、PUBSUB help、XGROUP help…
LOLWUT命令:没什么实际用处,根据不同的版本,显示不同的图案,类似安卓;
如果不为了API向后兼容,我们将不再使用“slave”一词:查看原因 (opens new window)
Redis核心在许多方面进行了重构和改进。
Redis 6.0
多线程处理网络IO
【将主线程IO读写交由一个线程组】
【将主线程IO读写交由一个线程组】
6.0之前的单线程模式
Redis所谓的单线程并不是所有工作都是只有一个线程在执行,而是指Redis的网络IO和键值对读写是由一个线程来完成的,Redis在处理客户端的请求时包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理。
这就是所谓的“单线程”。这也是Redis对外提供键值存储服务的主要流程
Redis在处理命令的时候是单线程作业的,所以会有一个Socket队列,每一个到达的服务端命令来了之后都不会马上被执行,而是进入队列,然后被线程的事件分发器逐个执行。
这就是所谓的“单线程”。这也是Redis对外提供键值存储服务的主要流程
Redis在处理命令的时候是单线程作业的,所以会有一个Socket队列,每一个到达的服务端命令来了之后都不会马上被执行,而是进入队列,然后被线程的事件分发器逐个执行。
Redis的其他功能, 比如持久化、异步删除、集群数据同步等等,其实是由额外的线程执行的
6.0之前为什么要使用单线程?
Redis 主要受限是在内存和网络上,CPU 几乎没有性能瓶颈的问题
以Linux 系统为例子,在Linux系统上Redis 通过 pipelining 可以处理 100w 个请求每秒,而应用程序的计算复杂度主要是 O(N) 或 O(log(N)) ,不会消耗太多 CPU
使用了单线程后,提高了可维护性。多线程模型在某些方面表现优异,却增加了程序执行顺序的不确定性,并且带来了并发读写的一系列问题,增加了系统复杂度。同时因为线程切换、加解锁,甚至死锁,造成一定的性能损耗
Redis 通过 AE 事件模型 以及 IO 多路复用 等技术,拥有超高的处理性能,因此没有使用多线程的必要
6.0之后的多线程主要解决什么问题?
近年来底层网络硬件性能越来越好,Redis 的性能瓶颈逐渐体现在网络 I/O 的读写上,单个线程处理网络 I/O 读写的速度跟不上底层网络硬件执行的速度。
从下图我们可以看到,Redis 在处理网络数据时,调用 epoll 的过程是阻塞的,这个过程会阻塞线程。如果并发量很高,达到万级别的 QPS,
就会形成瓶颈,影响整体吞吐能力。
从下图我们可以看到,Redis 在处理网络数据时,调用 epoll 的过程是阻塞的,这个过程会阻塞线程。如果并发量很高,达到万级别的 QPS,
就会形成瓶颈,影响整体吞吐能力。
6.0版本优化之后,主线程和多线程网络IO的执行流程如下
本质上是将主线程 IO 读写的这个操作 独立出来,单独交给一个I/O线程组处理。
这样多个 socket 读写可以并行执行,整体效率也就提高了。同时注意 Redis 命令还是主线程串行执行。
这样多个 socket 读写可以并行执行,整体效率也就提高了。同时注意 Redis 命令还是主线程串行执行。
① 主线程建立连接,并接受数据,并将获取的 socket 数据放入等待队列;
② 通过轮询的方式将 socket读取出来并分配给 IO 线程;
③ 之后主线程保持阻塞,一直等到 IO 线程完成 socket 读取和解析;
④ I/O 线程读取和解析完成之后,返回给主线程 ,主线程开始执行 Redis 命令;
⑤ 执行完Redis命令后,主线程阻塞,直到 IO线程 完成结果回写到socket 的工作;
⑥ 主线程清空已完成的队列,等待客户端新的请求。
开启多线程的方式
Redis6.0的多线程默认是禁用的,只使用主线程
# io-threads-do-reads no
io-threads-do-reads yes
# io-threads-do-reads no
io-threads-do-reads yes
读写io线程数
io-threads 5
io-threads 5
关于线程数的设置,官方有一个建议:4 核的机器建议设置为 2 或 3 个线程,8核的建议设置为 6 个线程,线程数一定要小于机器核数。
线程数并不是越大越好,官方认为超过了 8 个就很难继续提效了,没什么意义
线程数并不是越大越好,官方认为超过了 8 个就很难继续提效了,没什么意义
细粒度的权限控制ACL
Redis 6开始支持ACL,该功能通过限制对命令和key的访问来提高安全性。ACL的工作方式是在连接之后,要求客户端进行身份验证(用户名和有效密码);如果身份验证阶段成功,则连接与指定用户关联,并且该用户具有限制
RESP3
RESP(Redis Serialization Protocol)是 Redis 服务端与客户端之间通信的协议。在Reds6之前的版本,使用的是RESP2协议,数据都是以字符串数组的形式返回给客户端,不管是 list 还是 sorted set。因此客户端需要自行去根据类型进行解析,这样会增加了客户端实现的复杂性
为了照顾老用户,Redis6在兼容 RESP2 的基础上,开始支持 RESP3,但未来会全面切换到RESP3之上。今天的客户端缓存在基于RESP3才能有更好的实现,可以在同一个连接中运行数据的查询和接收失效消息。而目前在RESP2上实现的客户端缓存,需要两个客户端连接以转发重定向的形式实现
在Redis6中我们可以使用HELLO命令在RESP2和RESP3协议之间进行切换:
RESP3 是 RESP version 2 的更新版本
客户端缓存
什么是客户端缓存
客户端缓存是一种用于创建高性能服务的技术,在此技术下,应用程序端将数据库中的数据缓存在应用端的内存中,当应用程序访问数据时直接从本机内存中读取,而无需连接数据库端,减少了网络IO,提升了应用程序的响应速度,同时也减少了数据库端的压力
没有客户端缓存: 应用端先查询Redis端,如果没有Redis缓存则到源数据库端查询,如果有则直接从Redis端查询数据,更新数据时直接更新MySQL端并同步至Redis内;
有客户端缓存:应用端先查询本地缓存如Guava、Caffeine,若没有本地缓存则访问Redis缓存,如果Redis缓存中也没有则查询源数据库;
客户端缓存的优点
降低了客户端的数据延迟,提升客户端的响应速度;
数据库端接收的查询减少,降低了数据库端的压力,因此在相同的数据集下可以使用更少的节点提供服务
客户端缓存存在的问题
为了实现客户端缓存,我们面临这样的问题,当进程中缓存了数据,而数据库端数据发生变更,该如何通知到进程,避免客户端显示失效的数据呢?
在Redis中可以使用发布订阅机制,向客户端发布数据失效的通知,但该模式下即使某些客户端中没有包含过期数据也会向所有客户端发送无效的消息,非常影响数据库的性能。
在之前的版本中,客户端缓存采用缓存槽(caching slot)的方式记录每个客户端内的key是否发生变化以及时同步,最新版中已弃用该方式,而是采用记录key的名称或前缀。
在Redis中可以使用发布订阅机制,向客户端发布数据失效的通知,但该模式下即使某些客户端中没有包含过期数据也会向所有客户端发送无效的消息,非常影响数据库的性能。
在之前的版本中,客户端缓存采用缓存槽(caching slot)的方式记录每个客户端内的key是否发生变化以及时同步,最新版中已弃用该方式,而是采用记录key的名称或前缀。
客户端缓存的实现方式
默认模式
服务器记录客户端访问了哪些key,当其中的key发生变更时给客户端发送失效信息,消耗服务器端内存
服务器端会记录访问key的客户端列表并维护一个表,这个表被称为失效表(Invalidation Table),如果插入一个新的key,服务器端会给客户端发送失效信息并从客户端踢除该key,避免提供过时数据。
在失效表中不会记录key和客户端内对应指针的映射关系,只会记录key的指针和各客户端ID(每个Redis客户端都有一个唯一ID)的映射关系,当发送完失效信息后,客户端剔除key,服务端从失效表中删除key的指针和客户端ID的映射关系。
在失效表中key的命名空间只有一个,即是说,在db0~db15中相同的key名,在失效表中会记录在同一个命名空间内,即使客户端缓存的是db0内的key,如果db1内的同名key被更新,也会通知客户端剔除db0内的同名key。
在失效表中不会记录key和客户端内对应指针的映射关系,只会记录key的指针和各客户端ID(每个Redis客户端都有一个唯一ID)的映射关系,当发送完失效信息后,客户端剔除key,服务端从失效表中删除key的指针和客户端ID的映射关系。
在失效表中key的命名空间只有一个,即是说,在db0~db15中相同的key名,在失效表中会记录在同一个命名空间内,即使客户端缓存的是db0内的key,如果db1内的同名key被更新,也会通知客户端剔除db0内的同名key。
客户端缓存的操作就是对key的内存地址进行操作
- ① 当开启客户端缓存的客户端从Redis获取数据时,Redis服务端会调用 enableTracking 方法在上面的失效表中记录key和客户端ID的映射关系;
- ② 若key被修改,则Redis服务端会调用 trackingInvalidateKey 函数根据该key被缓存的客户端列表ID调用 sendTrackingMessage 函数向它们发送失效消息。(发送失效消息前会检查客户端的Client_Tracking和NOLOOP状态)
- ③ 服务端发送完失效消息后会从失效表中将该key与客户端ID的映射关系删除;
- ④ 由于客户端可能会在开启之后关闭了缓存功能,在失效表中删除key和该客户端ID之间的映射关系比较消耗性能,因此服务端采用懒删除的方式,只是将该客户端的Client_Tracking相关标志位删除;
当客户端缓存的key因过期策略或内存淘汰策略被驱逐时,服务端也会发送失效消息给开启了tracking的客户端
广播模式
广播不会消耗服务端的内存,而是向各客户端发送更多的失效消息。广播模式与默认模式类似,不同的是广播模式下维护的是前缀表,在前缀表中存储客户端订阅的key前缀与客户端ID之间的映射关系
- ① 客户端使用 BCAST 选项开启客户端缓存的广播模式,并使用 PREFIX 指定一个或多个前缀。如果不指定前缀则默认客户端接收所有的key的失效消息,如果指定则只会接收匹配该前缀的key的失效消息;
- ② 在广播模式下,服务端维护的不是失效表,而是前缀表(Prefix Table),每个前缀映射一些客户端ID;
- ③ 每次修改跟任意前缀匹配的键时,所有订阅该前缀的客户端都将收到失效消息;
- ④ 服务端的CPU消耗与订阅的key前缀数量成正比,订阅的key前缀数量越多服务器端压力越大;
- ⑤ 服务器可以为订阅特定前缀的客户端创建单个回复,并向所有的客户端发送相同的回复来进行优化,有助于降低CPU使用率
广播模式下,只要符合客户端设置的key前缀的key发生新增、修改、删除、过期、淘汰等动作,即使该key没有被该客户端缓存,也会收到key的失效消息;
重定向模式
为了兼容RESP2协议,在Redis6 中客户端缓存可以以重定向(Redirect)的方式实现,不再使用 RESP3 原生支持的PUSH消息,而是将消息通过 Pub/Sub 通知给另外一个客户端连接
OPTIN 和 OPTOUT
在默认模式或重定向模式下,我们可以有选择的对需要的key进行缓存,而由于广播模式是匹配key前缀,因此不能使用此命令
NOLOOP选项
我们的客户端修改自己已缓存的key的时候也会收到这个key的过期信息,事实上这个客户端是不需要收到该消息的,这造成了浪费,因此我们可以使用NOLOOP选项将该客户端设置为:本客户端修改的key不会收到相关的失效信息
#开启客户端缓存的NOLOOP选项
client tracking on noloop
#开启客户端缓存的NOLOOP选项
client tracking on noloop
失效表key上限
可以使用 tracking_table_max_keys 参数修改服务端失效表内记录的缓存的key的数量,当失效表内记录的缓存key达到配置的数量时会随机从失效表内移除缓存
Redis7.0
共享辅助缓冲区【主从辅助优化】
全量同步
主库通过 fork 子进程产生内存快照,然后将数据序列化为 RDB 格式同步到从库,使从库的数据与主库某一时刻的数据一致。
命令传播
当从库与主库完成全量同步后,进入命令传播阶段,主库将变更数据的命令发送到从库,从库将执行相应命令,使从库与主库数据持续保持一致。
Redis 复制缓存区相关问题分析
多从库时主库内存占用过多
对于 Redis 主库,当用户的写请求到达时,主库会将变更命令分别写入所有从库复制缓冲区(OutputBuffer),以及复制积压区 (ReplicationBacklog)。全量同步时依然会执行该逻辑,所以在全量同步阶段经常会触发 client-output-buffer-limit,主库断开与从库的连接,导致主从同步失败,甚至出现循环持续失败的情况。
该实现一个明显的问题是内存占用过多,所有从库的连接在主库上是独立的,也就是说每个从库 OutputBuffer 占用的内存空间也是独立的
该实现一个明显的问题是内存占用过多,所有从库的连接在主库上是独立的,也就是说每个从库 OutputBuffer 占用的内存空间也是独立的
OutputBuffer(从库复制缓冲区) 拷贝和释放的堵塞问题
Redis 为了提升多从库全量复制的效率和减少 fork 产生 RDB 的次数,会尽可能的让多个从库共用一个 RDB,从代码(replication.c)上看:
当已经有一个从库触发 RDB BGSAVE 时,后续需要全量同步的从库会共享这次 BGSAVE 的 RDB,为了从库复制数据的完整性,会将之前从库的 OutputBuffer 拷贝到请求全量同步从库的 OutputBuffer 中。
其中的copyClientOutputBuffer 可能存在堵塞问题,因为 OutputBuffer 链表上的数据可达数百 MB 甚至数 GB 之多,对其拷贝可能使用百毫秒甚至秒级的时间,而且该堵塞问题没法通过日志或者 latency 观察到,但对Redis性能影响却很大。
同样地,当 OutputBuffer 大小触发 limit 限制时,Redis 就是关闭该从库链接,而在释放 OutputBuffer 时,也需要释放数百 MB 甚至数 GB 的数据,其耗时对 Redis 而言也很长。
其中的copyClientOutputBuffer 可能存在堵塞问题,因为 OutputBuffer 链表上的数据可达数百 MB 甚至数 GB 之多,对其拷贝可能使用百毫秒甚至秒级的时间,而且该堵塞问题没法通过日志或者 latency 观察到,但对Redis性能影响却很大。
同样地,当 OutputBuffer 大小触发 limit 限制时,Redis 就是关闭该从库链接,而在释放 OutputBuffer 时,也需要释放数百 MB 甚至数 GB 的数据,其耗时对 Redis 而言也很长。
ReplicationBacklog(复制积压缓冲区) 的限制
复制积压缓冲区 ReplicationBacklog 是 Redis 实现部分重同步的基础,如果从库可以进行增量同步,则主库会从 ReplicationBacklog 中拷贝从库缺失的数据到其 OutputBuffer
如果重新设置 ReplicationBacklog 大小时,会导致 ReplicationBacklog 中的内容全部清空
Redis7.0共享复制缓存区的设计与实现
每个从库在主库上单独拥有自己的 OutputBuffer,但其存储的内容却是一样的,一个最直观的想法就是主库在命令传播时,将这些命令放在一个全局的复制数据缓冲区中,多个从库共享这份数据,不同的从库对引用复制数据缓冲区中不同的内容,这就是『共享复制缓存区』方案的核心思想。实际上,复制积压缓冲区(ReplicationBacklog)中的内容与从库 OutputBuffer 中的数据也是一样的,所以该方案中,ReplicationBacklog 和从库一样共享一份复制缓冲区的数据,也避免了 ReplicationBacklog 的内存开销。
『共享复制缓存区』方案中复制缓冲区 (ReplicationBuffer) 的表示采用链表的表示方法,将 ReplicationBuffer 数据切割为多个 16KB 的数据块 (replBufBlock),然后使用链表来维护起来。为了维护不同从库的对 ReplicationBuffer 的使用信息,在 replBufBlock 中存在字段:
- refcount:block 的引用计数
- id:block 的唯一标识,单调递增的数值
- repl_offset:block 开始的复制偏移
ReplicationBuffer 由多个 replBufBlock 组成链表,当 复制积压区 或从库对某个 block 使用时,便对正在使用的 replBufBlock 增加引用计数,上图中可以看到,复制积压区正在使用的 replBufBlock refcount 是 1,从库 A 和 B 正在使用的 replBufBlock refcount 是 2。当从库使用完当前的 replBufBlock(已经将数据发送给从库)时,就会对其 refcount 减 1 而且移动到下一个 replBufBlock,并对其 refcount 加 1。
- refcount:block 的引用计数
- id:block 的唯一标识,单调递增的数值
- repl_offset:block 开始的复制偏移
ReplicationBuffer 由多个 replBufBlock 组成链表,当 复制积压区 或从库对某个 block 使用时,便对正在使用的 replBufBlock 增加引用计数,上图中可以看到,复制积压区正在使用的 replBufBlock refcount 是 1,从库 A 和 B 正在使用的 replBufBlock refcount 是 2。当从库使用完当前的 replBufBlock(已经将数据发送给从库)时,就会对其 refcount 减 1 而且移动到下一个 replBufBlock,并对其 refcount 加 1。
数据结构
当从库尝试与主库进行增量重同步时,会发送自己的 repl_offset,主库在每个 replBufBlock 中记录了该其第一个字节对应的 repl_offset,但如何高效地从数万个 replBufBlock 的链表中找到特定的那个?
从链表的性质我们知道,链表只能直接从头到位遍历链表查找对应的 replBufBlock ,这个操作必然会耗费较多时间而堵塞服务。有什么改进的思路?
可以额外使用一个链表用于索引固定区间间隔的 replBufBlock,每 1000 个 replBufBlock 记录一个索引信息,当查找 repl_offset 时,会先从索引链表中查起,然后再查找 replBufBlock 链表,这个就类似于跳表的查找实现。Redis 的 zset就是跳表的实现:
从链表的性质我们知道,链表只能直接从头到位遍历链表查找对应的 replBufBlock ,这个操作必然会耗费较多时间而堵塞服务。有什么改进的思路?
可以额外使用一个链表用于索引固定区间间隔的 replBufBlock,每 1000 个 replBufBlock 记录一个索引信息,当查找 repl_offset 时,会先从索引链表中查起,然后再查找 replBufBlock 链表,这个就类似于跳表的查找实现。Redis 的 zset就是跳表的实现:
zset 跳表
跳表[加了索引] - 为了快速定位到某个特定节点开始)
跳表的查询速度接近于红黑树的,但是跳表的实现逻辑是远远低于红黑树的
跳表的查询速度接近于红黑树的,但是跳表的实现逻辑是远远低于红黑树的
Rax树
RAX叫做基数树(前缀压缩树),就是有相同前缀的字符串,其前缀可以作为一个公共的父节点,什么又叫前缀树?
Trie树
字典树,也有的称为前缀树,是一种树形结构。广泛应用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是最大限度地减少无谓的字符串比较,查询效率比较高。
Trie的核心思想是空间换时间,利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。
Trie的核心思想是空间换时间,利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。
Radix 树: 压缩后的Trie树
Redis 7.0 核心特性
Redis 分布式锁
Redisson 加锁原理分析
Redis实现分布式锁
redisson WatchDog
Redisson中的原子操作都是基于lua脚本的
主从架构锁失效问题
问题描述: 主从架构,key存储在主节点成功之后,在往从节点同步之前挂掉了,那么锁失效了
解决方案: RedLock(红锁)【不推荐】
Redlock类似于Zk实现分布式锁的流程,这样虽然提高一致性,降低了可用性,而且RedLock也不是万无一失的(不如直接用Zk)
- 如果 A、B、C 三个节点都有从节点,如果一个key写到 A、B的主节点,但是B节点在同步从节点之前挂掉了,B、C 节点就可以加锁成功,这就会造成锁失效
- 如果有一半节点挂掉了,那么整体集群的分布式锁均不可用
- 如果 A、B、C 三个节点都有从节点,如果一个key写到 A、B的主节点,但是B节点在同步从节点之前挂掉了,B、C 节点就可以加锁成功,这就会造成锁失效
- 如果有一半节点挂掉了,那么整体集群的分布式锁均不可用
大促场景分布式性能提升
0 条评论
下一页