数据结构
redis数据结构包括:简单动态字符串SDS、链表、字典、跳跃表、整数组合、压缩列表。
SDS:在增加/减少字符串时不会频繁进行内存充分配,采用了空间预分配和惰性空间释放两种优化策略。
链表:链表节点使用void*保存节点值,并且可以指定属性函数,可以用来保存不同类型的结构体。将数据与操作函数封装在一起,类似C++中的类定义。
字典:字典由dict结构体表征,管理两张哈希表,哈希表指向一个哈希表数组,哈希表数组管理哈希表节点。
type指向dictType 结构体,封装了很多数据操作的函数指针,类似Java中的Interface。ht指向两组哈希表,ht[1]在进行rehash时使用。
哈希表使用链表解决键冲突,且新入的节点添加在链表表头位置。
tips:STL中有个hashmap链表树化的阈值,根据泊松分布计算阈值为8,加载系数为0.75时树化的概率为0.00000006。
哈希表一般会设置负载因子范围,超过范围会进行扩容或收缩。rehash只会在主线程中操作,所以交换ht指针不需要加锁。
为避免rehash对性能造成影响,采用渐进式rehash,每次只操作一个索引值上的数据。渐进式rehash操作期间的哈希表查找会先查找ht[0],再查找ht[1]。插入操作一律操作ht[1]。
跳跃表:skiplist支持范围查找,平均查找性能为O(logN)。跟红黑树比较,树在rebalance时可能需要修改整棵树,而跳跃表使用增加层数的办法来防止劣化。
整数集合:当集合只包含整数元素且元素不多时可以使用整数集合。整数集合内部顺序保存所有元素,不会像SDS一样预留空间,所以增加/删除元素需要操作整个数组,复杂度为O(n)。
压缩列表ziplist:当列表键只有少量列表项,且列表项为小整数/短字符串时,可以使用压缩列表实现。
压缩列表内部使用连续内存保存,操作性能集中了数组和链表的缺点,插入节点、删除节点、查找节点的复杂度都是O(n)。
对象
对象包括:字符串对象、列表对象、哈希对象、集合对象、有序集合对象。
redis没有直接使用数据结构来实现数据库的键值对,而是通过这些数据结构构建了一个对象系统。redis对象基于引用计数实现了内存回收机制和对象共享机制。redis数据库中的key总是字符串对象(可以为SDS,也可以为整数值),value才有不同的对象类型。
两个命令:TYPE查看对象的类型,即属于什么类型的对象;OBJECT查看对象的编码,即属于什么数据结构。
字符串对象:字符串对象可能为int整数,也可能为SDS。当使用APPEND命令对字符串对象追加操作时,会将整数值先转换为字符串,再进行追加。
列表对象:列表对象可采用压缩列表/链表进行编码。
集合对象:集合对象使用整数集合/哈希表进行编码。使用哈希表实现时,只用到了字典键,字典值为NULL。
有序集合对象:采用压缩列表/跳表进行编码实现。使用跳表实现时,会同时使用字典+跳表。字典可以O1复杂度查找元素,跳表可以以O(logN)复杂度实现范围查找。
对象共享
redis基于引用计数实现了内存回收,以及所有整数值的共享对象。为什么不共享字符串对象?因为验证整数相等的复杂度为O1,而验证字符串相等的复杂度为O(n)。
单机数据库
redis服务器将所有数据库保存在db[]数组中,每个redis客户端都会有自己的目标数据库,默认为0号。为了误操作,一般需要显是使用SELECT命令切换到目标数据库再执行命令。
数据库使用dict字典结构保存所有的键值对。
过期时间
使用EXPIRE命令可以设置键的过期时间。数据库使用dict字典保存键值对,使用expires字典保存键的过期时间。
基于过期字典,有三种过期键删除策略。
定时删除:设置定时器删除。
惰性删除:在取出键时才做过期检查。
定期删除:设定删除操作执行的时长、频率和检查的键的数量,减少删除操作对性能的影响。使用全局变量记录检查进度,默认从顺序检查所有16个数据库。
持久化方式
redis的持久化方式:rdb、aof。
RDB
创建子进程压缩保存所有数据库当前键值对至文件。因为是子进程操作,所以会写时复制,即父进程修改键值对时,会先复制页面再操作,不需要像AOF一样需要缓冲区。此外,子进程先将临时文件写入完毕之后,再原子替换原来的rdb文件,不会出现服务器崩溃导致旧rdb文件不可用的情况。
rdb文件载入:仅在服务器启动时载入一次,主服务器不会载入过期键。
在使用 RDB 进行持久化时,redis 会 fork 子进程来完成,fork操作的用时和 Redis 的数据量是正相关的,而 fork 在执行时会阻塞主线程。数据量越大,fork 操作造成的主线程阻塞的时间越长。
AOF
追加保存写命令的方式进行持久化。
所有中间件的WAL文件磁盘持久化都有三种回写策略:
redis事件循环中,先处理客户端命令,然后处理时间事件和AOF文件同步,如下:
def eventLoop():
While true:
1、处理客户端命令;
2、处理定时器事件;
3、执行AOF文件操作。
所以即使配置AOF_FSYNC_ALWAYS每次刷盘,每次执行客户端命令的时候操作并没有写到aof文件中,只是写到了aof_buf内存当中,只有当下一个事件来临时,才会去fsync到disk。因此Redis即使在配制appendfsync=always的策略下,还是会丢失一个事件循环的数据。另外AOF虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险,因此需要选择合适的持久化策略。
AOF重写
AOF重写机制会对过大的AOF文件进行重写,来压缩AOF文件的大小。实现是:检查当前键值数据库中的键值对,记录键值对的最新状态至文件,过期键不会记录。采用子进程执行重写过程,减少性能损耗。
子进程进行AOF重写期间,父进程继续处理客户端命令请求,此时写命令会存入AOF重写缓冲区,等子进程完成AOF文件重写之后,父进程再将AOF重写缓冲区的命令追加到新文件中。AOF缓冲区和AOF重写缓冲区都有溢出风险,需要根据业务速率合理设置大小。
混合日志
混合使用 AOF 日志和RDB内存快照:内存快照以一定的频率执行,在两次快照之间,使用AOF 日志记录这期间的所有命令操作。
客户端缓冲区
服务器使用dict字典保存全局命令表,每个建联的客户端使用redisClient结构体标识,redisClient存有一个输入缓冲区两个输出缓冲区。
struct redisClient {
sds inbuf; //输入缓冲区大小不超过1G,超过的话会关闭客户端
shar buf[]; //不超过16k的输出消息使用短缓冲区保存
list bigbuf; //超过16k的输出消息使用链表形式保存
}
集群
redis主从服务器同步有三种方法:主从复制、哨兵、集群。
复制
PSYNC命令有完整重同步与部分重同步两种模式,完整重同步用于初次复制(包括主服务器换了的情况),部分重同步用于断线后重新连接上主服务器的情况。
部分重同步使用到以下三部分:
主服务器和从服务器的复制偏移量;
主服务器的复制积压缓冲区;//默认大小为1MB
主服务器的runID。
部分重同步流程:从服务器发送PSYNC命令,携带上一次复制的主服务器runID和从服务器当前的复制偏移量。主服务器会校验这两个值,如果主服务器runID匹配,且从服务器偏移量之后的数据存在复制积压缓冲区,则进行部分重同步,否则从头进行完整同步。redis的主从复制无法解决ABA问题,一般的分布式集群通过永久数epoch来区分每一个工作阶段。
心跳检测
主从服务器启动同步成功之后,从服务器以默认1s一次的频率向主服务器发送心跳命令REPLCONF,主要用于三个作用:
1、检测主从服务器网络连接状态;
2、实现min-slaves配置项。该项用于确保redis的高可用,即从服务器的数量少于x个,或者x个服务器的延迟时间都大于阈值,则主服务器拒绝客户端写命令。类似,kafka里面也是采用消息延迟时间,而不是消息延迟数量来踢出ISR的。
3、检测命令丢失。REPLCONF命令会携带主服务器的当前复制偏移量,从服务器根据该值同步丢失的数据。
哨兵模式
所有的哨兵都会相互创建命令链接,哨兵跟主从服务器会创建命令连接和订阅连接。默认情况下,哨兵1s/次向所有事例(主从服务器、其他哨兵)发送PING命令,并通过回复来判断实例是否在线。不同勺柄设置的主观下线时长可能不同。哨兵将某服务器标示为主观下线后,会向其他哨兵发出询问。当超过quorum数量的其他哨兵回复某服务器为下线状态后,该哨兵将某服务器标示为客观下线状态。同样,不同的哨兵配置的quorum参数可能不同。
主服务器被标示为客观下线后,监视该服务器的所有哨兵会选举出领头哨兵,由领头哨兵完成主服务器的故障转移。领头哨兵会选出一个保存数据量最全最新的从服务器,将其升级为主服务器。
领头哨兵的选举机制:采用epoch标识选举纪元;每轮选举每个哨兵只能投票一次,先到先得;过半选举,即n/2+1.
哨兵实例是不是越多越好?
并不是。哨兵在判定“主观下线”和选举“哨兵领导者”时,都需要和其他节点进行通信,交换信息,哨兵实例越多,通信的次数也就越多,而且哨兵分布在不同机器上,节点越多带来的机器故障风险也会越大,这些问题都会影响到哨兵的通信和选举,出问题时也就意味着选举时间会变长,切换主从的时间变久。这是一个可靠性与可用性的平衡。
集群模式
数据切片
redis分片集群是指启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。
一主多从是数据复制,用来解决数据并发量大所带来的压力,而切片集群是数据切片,是用来解决单个实例存储大数据量的局限性。
计算键属于哪个槽
首先根据键值对的 key,按照CRC16 算法计算一个 16 bit 的值;然后,再用这个 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。
路由请求:Redis Cluster在每个节点记录完整的映射关系(便于纠正客户端的错误路由请求),同时也发给客户端让客户端缓存一份,便于客户端直接找到指定节点,客户端与服务端配合完成数据的路由,这需要客户端升级为集群版的SDK才支持客户端和服务端的协议交互。
故障转移
集群模式下每个节点使用clusterNode结构记录自己的状态,也会为集群其他所有主从节点创建对应的clusterNode结构记录其状态。
每个节点都有一个40字节的name来表识。与runID不同,每次重启runID都会重新生成,而name只在集群初始化时创建一次,然后持久化至配置文件中,后续重启不会改变。
集群每个节点都会定期向其他节点发送PING消息,并对没有回复PONG消息的节点标记为疑似下线。如果集群里面过半主节点都将某个主节点x标记为疑似下线,此时该主节点会被标记为已下线,将主节点x标记为已下线的节点会像集群广播一条消息,所有收到该消息的节点会将主节点x标记为已下线。
三种集群方案对比
主从复制(Replication)主要是备份数据、读写分离、负载均衡,一个主服务器可以有多个从服务器作为备份。主从断开重连后会根据断开之前最新的命令偏移量进行增量复制。主从复制无法容错。
哨兵(Sentinel)是为了高可用,可以管理多个Redis服务器,提供了监控,以及自动执行故障转移。sentinel发现master挂了后,就会从slave(从服务器)中重新选举一个master(主服务器)。哨兵模式无法扩容。
集群(cluster)可以解决单机Redis容量有限/服务能力有限的问题,将数据按CRC分散分配到多台机器,提高并发量,内存/QPS不受限于单机,具备高扩展性。集群模式需要客户端缓存路由信息,旧版本SDK无法识别ASK、MOVED等新命令。
高性能
Redis为了保证高性能复制过程是异步的,写命令处理完后直接返回给客户端,不等待从节点复制完成。因此从节点数据集会有延迟情况。即当使用从节点用于读写分离时会存在数据延迟、过期数据、从节点可用性等问题,需要根据自身业务提前作出规避。
一般公司基本仅将redis作为缓存使用,关闭其持久化功能。数据持久化由专门的数据库来完成。
缓存与数据库同步策略
如何保证从 Redis 中读取的数据与数据库中存储的数据最终是一致的,这就是经典的缓存与数据库同步问题。
考虑业务场景,一般采用先更新数据库,再删除缓存的方案,流程为:更新操作就是先更新数据库,再删除缓存;读取操作先从缓存取数据,没有,则从数据库中取数据,成功后,放到缓存中;
对于多线程可能造成的缓存脏数据以及数据库主从不一致导致的缓存脏数据,一般采用延时双删策略,即主从同步的延时时间加上几百毫秒余量。如果业务数据量不大,数据库不会采用读写分离架构,而是热备策略,因此不需要考虑主从同步时延。
redis实现锁
1、获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁,锁的value值为一个随机生成的UUID,通过此在释放锁的时候进行判断,防止如下情况:
2、获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
3、释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放。
Redlock算法
现在假设有5个Redis主节点(大于3的奇数个),获取锁和释放锁的过程中,客户端会执行以下操作:
获取当前Unix时间,以毫秒为单位;
依次尝试从5个实例,使用相同的key和具有唯一性的value获取锁;
当向Redis服务器请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间,这样可以避免客户端死等;
客户端使用当前时间减去开始获取锁时间就得到获取锁使用的时间。当且仅当从半数以上的Redis节点取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功;
如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间;
如果因为某些原因,获取锁失败(没有在半数以上实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁,无论Redis实例是否加锁成功,因为可能服务端响应消息丢失了但是实际成功了,毕竟多释放一次也不会有问题。
除了RedLock之外目前并没有有效解决Redis主从切换导致锁失效的方法。如果一致性要求非常高,推荐使用Zookeeper。
Redis6.0多线程
纯内存操作 vs 网络读写,网络IO是瓶颈,因此只会使用多线程进行网络数据收发。redis多线程是将读取数据+数据解析,以及数据封装+发送数据这两部分并行化,而具体的命令行执行还是在主线程里单线程执行,这样可以不用考虑锁带来的性能损耗。参考下图:
多线程读写流程:
将当前建联客户端(pending_list)分配至IO线程和主线程,主线程通知所有IO线程启动工作,主线程等待所有IO线程完成读写操作,主线程完成命令行执行。
写操作中,待处理客户端 < 2倍IO线程数 时,将由主 IO 线程完成所有客户端数据刷回。
redis事务
场景 | 是否开启 Redis 事务支持 | 是否开启 Spring 事务注解 | 命令执行结果 |
1 | setEnableTransactionSupport=false(默认) | @Transactional | 正常 |
2 | setEnableTransactionSupport=false(默认) | 非@Transactional | 正常 |
3 | setEnableTransactionSupport=true | @Transactional | null |
4 | setEnableTransactionSupport=true | 非@Transactional | 正常 |
RedisTemplete开启Redis事务支持后,在@Transactional中执行的Redis命令也会被认为是在Redis事务中执行的。此时要执行的递增命令会被放到队列中,不会立即返回执行后的结果,返回的是一个null,需要等待事务提交时,队列中的命令才会顺序执行,最后Redis 数据库的键值才会递增。
解决方法:使用两个 RedisTemplate Bean,一个用来执行Redis事务,一个用来执行普通 Redis命令(不支持事务)。不同的业务需求使用不同的 Bean。
大key问题
大key可能导致服务器性能下降,客户端耗时增加,输入输出缓冲区溢出。一般需要监控服务器内存/流量/时延等指标,配置实时Top Key统计,配置异常告警。出现dakey,需要在业务层将数据进行切分,拆分成多个小key。
热点Key问题
需要同时在客户端/服务端设置识别机制识别热点key。解决方法:
1、增加分片副本,均衡读流量;
2、将热key分散到不同的服务器中;
3、使用二级缓存,即JVM本地缓存,减少Redis服务器的读请求。
4、使用proxy
启用代理查询缓存。代理节点会缓存热点Key对应的请求和查询结果,当在有效时间内收到同样的请求时直接返回结果至客户端,无需和redis服务器交互。
由于代理节点中缓存的热点Key的查询结果在有效时间内不会更新,需要业务上允许数据在缓存有效时间内的最终一致性。