Redis总结
背景
为什么我会学习Redis?
原因很简单,因为工作上需要使用。因此在不懂其原理以及其特性的情况下使用,会在一些情况下使用错误,以及造成一些错误的影响。理解工具的原理才能更好的使用工具。
本文主要为读《Redis开发与运营》的总结,以及一些扩展。本文总结较长,建议使用电脑,按照目录择章阅读。
基础
Redis中主要提供5种数据结构:string(字符串)、hash(哈希)、list(列表)、set(集合)、zset(有序集合)。并且基于字符串的基础上演变出了Bitmaps(位图)、HyperLogLog。随着LBS(基于位置服务)发展,在Redis3.2中加入了GEO(地理信息定位)。
同时,Redis支持键过期、发布订阅、Lua脚本、简单事物、流水线(Pipeline)功能。
Redis为什么速度快?1、Redis中所有的数据都是存储在内存中;2、Redis使用C语言实现;3、Redis使用单线程架构,预防多线程可能产生的竞争问题。
Redis中每个类型有多种的内部编码,通过object ebcoding
命令查询内部编码。Redis中对每个数据结构,在内部根据不同的情况,使用不同的内部编码实现,可以在不同情况下发挥自己各自的优势。
string(字符串)
常用命令
设置值:set key value [ex seconds] [px millesenconds] [nx|xx]
(nx:键必须不存在才能设置成功,xx反之)。setex = set ex
,setnx = set nx
。批量设置值:mset key value [key value ...]
.
获取值: get key
。批量获取值:mget key [key ...]
。
注意:每次批量操作所发送的键数不是无节制的,如果数量过多可能造成Redis阻塞或者网络拥塞。
自增: incr key
。
内部编码
- int 8个字符的长整型
- embstr 小于等于39个字节的字符串
- raw 大于39个字节的字符串
hash(哈希)
常用命令
设置值:hset key field value
。批量设置值:hmset key field value [field value...]
。
获取值:hget key field
。批量获取值:hmget key field [field ...]
。
内部编码
- ziplist(压缩列表)
- hashtable(哈希表)
当field个数较少且没有大value时,内部编码为ziplist;当value大于64字节,转变为hashtable;当field个数超过512,内部编码变为hashtable。
扩容
扩容是一个很有趣的地方。相对于ConcurrentHashMap的多线程协同式rehash,Redis采用单线程渐进式rehash。多线程协同rehash可以参照《ConcurrentHashMap 源码分析(Java version 1.8)》的扩容部分。
而单线程渐进式rehash,主要是将拷贝节点数据的过程平摊到后续的操作中,而不是一次性拷贝。其过程是,在写的时候,当发现正在扩容,则负责将目前元素执行的老的哈希桶的元素,迁移到新的hash中,如果发现已经迁移完成则不操作。
而在读的时候,则先查询老的hash中是否有数据,没有则查找新的hash中。
单线程渐进式rehash和多线程协同式rehash对比:
- ConcurrentHashmap的整个扩容操作,消耗时间短,因此对内存的占用也更短;
- 写操作中,Redis会更快返回,因此多线程协同会去协助扩充操作;
- 读操作中两者基本相似。
在Redis的本身是单线程执行中,采用了这种设计方式,刚好使用空间兑换了时间的做法,完美避免了阻塞。而ConcurrentHashmap中,实现了多线程下的安全性,并通过多线程协作的方式,减短了在写操作时为了保证线程安全性的阻塞时长。
list(列表)
- 列表中的元素是有序的;
- 列表中的元素可以是重复的。
常用命令
设置值:rpush key value [value ...]
; lpush key value [value ...]
。
插入元素:linsert key before|after pivot value
。顺序找到第一个等于该值的元素,在前面或者后面插入元素。
获取值:lrange key start end
: 获取key对应value内指定范围的元素列表(左右都取闭区间)。
修改值:lset key index rewValue
,修改指定索引下标的元素。
删除元素:lrem key count value
,从列表中找到等于value的元素进行删除。
- count > 0,从左到右,删除最多count个元素;
- count < 0,从右到左,删除最多count绝对值个元素;
- count = 0,删除所有。
lpop key
; rpop key
.
blpop key [key ...] timeout
; brpop key [key ...] timeout
。如果存在则直接返回,如果不存在则等待timout时间,timeout为0则代表一直阻塞。
内部编码
- ziplist(列表元素个数小于512,同时值都小于64字节)
- linkedlist
- quicklist(Redis 3.2版本提供。简单说是一个以ziplist为节点的linkedlist,结合了两者的优势)
set(集合)
常用命令
增加值:sadd key element [element ...]
删除值:srem key element [element ...]
随机弹出元素:spop key
,需要注意这个并不删除元素。
集合操作:
- 求交集:
sinter key [key ...]
- 求并集:
sunion key [key ...]
- 求差集:
sdiff key [key ...]
(相当于并集减交集)
使用sinterstore destination key [key ...]
、sunionstore ...
、sdiffstore ...
运算交集、并集、差集的结果并保存。(集合间操作在元素比较多的情况下会比较耗时,因此Redis提供计算并存储命令,计算结果保存到destination key中)。
内部编码
- intset(整数集合),当集合元素是整数且元素个数小于512时
- hashtable
zset(有序集合)
常用命令
增加值:zadd key score member [score member ...]
,集合通过score排序。
注意:Redis 3.2中添加了nx(不存在才能设置成功)、xx(与nx相反)、ch(返回此次操作后集合中的元素和分数发生变化的个数)、incr(对score增加,相当于zincry)选项。
返回指定排名范围的成员:zrange key start end [withscores]
,按照分数从低到高返回; zrevrange key start end [withscores]
,按照分数从高到低返回。start和end都是闭区间。
返回指定分数范围的成员:zrangebyscore key min max [withscores] [limit offset count]
,按照分数从低到高返回;zrevrangebyscore
反之。[limit offset count]
可以限制输出的起始位置和个数。
min和max支持开区间(小括号)和闭区间(中括号),-inf
和+inf
分别代表无限小和无限大。
集合操作:
- 交集:
zinstore destination numkeys key [key ...] [wights weight [weight ...]] [aggregate sum|min|max]
。numkeys
:需要做交集计算键的个数;key [key ...]
:需要做交集计算的键;wights weight [weight ...]
:每个键的权重,在做交集计算时,每个键中的每个member会将自己的分数乘以这个权重,每个键的权重默认时1。aggregate sum|min|max
:计算成员交集后,分值按照sum、min、max做汇总,默认值是sum。
- 并集:
zunionstore destination numkeys keu [key ...] [weights weight [weight ...]] [aggregate sum|min|max]
。
内部编码
- ziplist,有序集合元素个数小于128,且每个元素的值都小于64
- skiplist
其他
键重命名
rename key newkey
、renamenx ...
由于重命名健期间会del命令删除旧的键,如果键对应的值比较大,会存在阻塞Redis的可能。
键过期
expire key seconds
: 键在seconds秒后过期;expireat key timestamp
: 键在秒级时间戳timestamp后过期。
对于字符串类型键,执行set命令会去掉过期时间;set时需要重新设置过期时间。
setex是set + expire的组合,原子操作,并且减少了一次网络通讯时间。
注意:Redis不支持二级数据结构(例如哈希、列表)内部元素的过期功能。
迁移键
- move(基本废弃)
- dump + restore
- 整个迁移过程中不是原子性的,而是通过客户端分步完成的;
- 迁移过程中的是开启了两个客户端的连接,所以dump的结果不是在源Redis和目标Redis之间进行传输(通过客户端)。
- migrate
- 实际上migrate就是将dump、restore、del三个命令进行组合,简化了操作流程。
- migrate具有原子性
- migrate命令的数据传输直接在源Redis和目标Redis上完成。目标Redis完成Restore后发送OK给源Redis,源Redis会根据migrate对应的选项来决定是否在源Redis上删除对应的键。
遍历键
keys支持pattern匹配,进行全量遍历。会阻塞Redis。
scan采用渐进式遍历来解决keys命令可能带来的阻塞问题,每次执行scan命令的时间复杂度是O(1),真正要实现keys的功能需要多次scan。
对于可能产生阻塞问题的hgetall、smembers、zrange等,都用对应的hscan、sscan、zscan命令。
注意:scan并不能保证遍历出所有的键。(遍历过程中某些键发生变化时,可能会有没遍历出来某个键或者遍历出重复键情况)
扩展功能
慢查询分析
slowlog-log-slower-than
配置(单位微秒):通过这个配置,如果命令执行时间大于这个预设值,则记录到慢查询日志中。注意,slowlog-log-slower-than=0
会对记录所有的命令,而小于0则代表对任何命令都不进行记录。
slowlog-max-len
:代表慢查询日志最多存储多少条,这是一个先进先出队列。
配置建议:
- 可以增大
slowlog-max-len
的配置,并不会占用大量内存。 slowlog-log-slower-than
的默认配置是10ms。对于高QPS场景的Redis建议设置成1ms。(实际客户端执行的时间等于命令执行时间以及网络传输等时间,因此大于实际执行时间)
Redis Shell
- redis-cli(及相关参数)
- redis-server
- redis-benchmark(为Redis做基准性能测试)
Pipeline
Pipeline(流水线)操作可以减少网络传输,实现通过一次网络传输执行多个Redis命令。目前如mget、mset等批量操作也可以有效减少网络延时(RTT)。
不得不说其区别,mget、mset是只能在一个批量操作中执行get、set的一类操作,并且其执行是原子性的,而Pipeline没有对执行命令的限制,而执行也不是原子性的。后续会讲到在Redis Cluster中建议都使用Pipeline的操作。
Pipeline执行时,需要注意其中命令的数量,命令数量过多,会加大客户端等待时间,也会造成一定的网络阻塞。建议将一个大的Pipeline操作拆分成几个小的Pipeline完成。
事务与Lua
事务:multi
、exec
、discard
。Redis提供watch
命令来确保事务中的key没有被其他客户端修改过。
Lua脚本优点:
- 原子性,Lua脚本在Redis中的执行是原子性的
- 开发人员可以自定义命令
- 可以将多条命令一次性打包,有效减少网络开销
注意:在执行中,如果Lua脚本已经执行过写操作,那么script kill将不会生效。(这种情况下只能等待执行结束,或者停掉Redis服务)
Bitmaps和HyperLogLog
Bitmaps本质是字符串,但是可以对字符串的位进行操作。
HyperLogLog是一种基数算法,可以利用极小的内存空间完成独立总数的统计。Redis官方给出0.81%的失误率。
发布订阅
subscribe
、psubscribe
、unsubscribe
、punsubscribe
。Redis不会对发布的消息进行持久化,无法实现消极堆积、回溯等。不够专业但足够简单。
GEO
Redis 3.2中提供了GEO功能,用来实现基于地理位置信息的应用,底层实现是zset。
客户端
- 客户端与服务端之间的通信协议是基于TCP协议构建的。
- 通过RESP(Redis系列化协议)实现客户端与服务端的正常交互。
通过直连的方式无法限制Redis客户端对象的个数,在极端情况下可能造成连接泄露,而连接池的形式可以有效的保护和控制资源的使用。(直连的方式,适用与少量长期连接的场景;连接池的方式,降低了开销,并且对资源的使用进行了保护和控制)。Redis提供maxclients参数限制客户端连接数,默认值是1000,如果连接数超过,则新的连接将被拒绝。
Jedis连接池使用时,将连接池大小设置为比默认最大连接数(8个)多一些即可。
Redis为每个客户端分配了输入缓存区,作用是将客户端发送的命令临时保存,通过Redis会从输入缓存区拉取命令并执行,输入缓存区为客户端发送命令到Redis执行命令提供缓冲功能。要求每个客户端缓冲区的大小不能超过1G,超过后客户端将被关闭。
Redis为每个客户端分配了输出缓冲区,作用是保存命令执行的结果返回客户端,为Redis和客户端交互返回结果提供缓冲。输出缓冲区分为3种:普通客户端、发布订阅客户端、slave客户端。
输出缓冲区由两部分组成:固定缓冲区(16KB)、动态缓冲区。固定缓冲区使用的是字节数组,动态缓冲区使用的是列表。
持久化
Redis支持RDB和AOF两种持久化机制。
RDB
RDB持久化是把当前进程数据生成快照保存到硬盘的过程,触发RDB持久化过程分为手动触发和自动触发。
手动触发对应save
和bgsave
命令。save
会阻塞当前Redis服务,直到RDB过程完成为止,已经废弃。`bgsave是在Redis进程执行fork操作创建子进程,然后由子进程负责RDB过程,并自动结束,阻塞只发生在fork阶段。
Redis默认采用LZF算法对RDB文件进行压缩处理。
RDB的优缺点
优点:
- RDB是一个紧凑的压缩的二进制文件,代表Redis在某个时间点上的数据快照。非常适用于备份,全量复制等。
- Redis加载RDB恢复数据远远快于AOF的方式
缺点:
- RDB方式数据没办法做到实时持久化/秒级持久化。
- RDB文件使用特定的二进制格式保存。因版本演进过程中有多个格式的RDB版本,存在老版本Redis服务无法兼容新格式的问题。
AOF
AOF:以独立日志的方式记录每次写命令,重启时再重新执行AOF文件中的命令达到恢复数据的目的。主要解决了数据持久化的实时性。
- 所有的写入命令都会追加aof_buf(缓冲区)中。
- AOF缓冲区根据对应的策略向硬盘中做同步操作。
- 随着AOF文件越来越大,需要定期对AOF文件进行重写,达到压缩的目的。
- 当Redis服务器重启时,可以加载AOF文件进行数据恢复。
AOF采用文本协议格式,并将命令直接追加在aof_buf中。先写入缓冲区aof_buf中,Redis可以提供多种缓冲区同步到硬盘的策略,在性能和安全性方面做出平衡。
- aways:命令写入aof_buf中后调用系统fsync操作同步到AOF文件,fsync完成后线程返回。
- erverysec:fsync同步文件操作由专门线程每秒执行一次
- no:同步操作由操作系统负责,通常同步周期最长30秒
AOF重写机制用来压缩文件体积。重写过程可以手动触发和自动触发。
在AOF重写中,会从父进程fork出子进程,子进程执行AOF重写。这段时间主进程继续响应命令,Redis使用“AOF重写缓冲区”保存这部分新数据,防止新AOF文件生成期间丢失这部分数据。
Redis使用另一条线程每秒执行fsync同步硬盘,当系统硬盘资源繁忙时,会造成Redis主线程阻塞。
由上图刷盘策略发现:
- erverysec配置最多可能丢失2秒数据,不是1秒。
- 如果系统fsync缓慢,将会导致Redis主线程阻塞影响效率。
注:单机下部署多个实例时,为了防止出现多个子进程执行重写操作,建议做隔离控制,避免CPU和IO资源竞争。
复制
复制功能是高可用Redis的基础。
salveof: 从从节点发起,当前服务丢弃旧有数据集,同步新数据集。
slave no one:断开复制。
如果要求低延迟时,建议同机架或同机房部署并关闭repl-disable-tcp-nodelay
;如果考虑高容灾性,可以同城跨机房部署并开启repl-disable-tcp-nodelay
(在主节点设置)。
拓扑
拓扑可以分为三种:一主一从、一主多从、树状主从结构。
一主一从:主节点出现宕机时从节点提供故障转移支持。当应用写命令并发量较高且需要持久化时,可以只在从节点上开启AOF。(需要避免主节点脱机重启,重启前需要断开主从关系,避免从节点的数据被清空)。
一主多从:主要用于读多写少的读写分离场景。
树状结构:引入复制中间层,可以有效降低主节点负载和需要传送给从节点的数据量。
原理
首先,执行slaveof的复制过程:
- 保存主节点信息
- 主从建立socket连接
- 发送ping命令(检测主从之间网络嵌套字是否可用;检测主节点当前是否可接受处理命令。)
- 权限验证
- 同步数据集(首次建立复制的场景,主节点会把持有的数据全部发送给从节点,这部分操作时耗时最长)
- 命令持续复制
从节点(slave)内部通过每秒运行的定时任务维护复制相关逻辑,当定时任务发现存在新的主节点后,会尝试与该节点建立网络连接。
数据同步分为全量复制和部分复制。全量复制一般用于初次复制场景,当数据量较大时,会对主从节点和网络造成很大的开销。部分复制,用于处理在主从复制中因网络闪断等原因造成的数据丢失场景。
部分复制命令psync
需要哪些组件支持呢?
- 主从节点各自的复制偏移量
- 主节点(master)在处理完写命令后,会把命令的字节长度做累加记录,统计信息在
info replication
中的master_repl_offset
指标中。 - 从节点(slave)每秒上报自身的复制偏移量给主节点。(主节点也会保存从节点的复制偏移量)
- 通过比较主从节点的复制偏移量,可以判断主从节点数据是否一致。同时判断当前复制的健康度。
- 主节点(master)在处理完写命令后,会把命令的字节长度做累加记录,统计信息在
- 主节点复制积压缓冲区
- 复制积压缓冲区是保存在主节点上的一个固定长度的队列,默认大小为1MB。(先进先出的固定长度队列)
- 主节点运行id
- 每个Redis节点启动后都会动态分配一个40位的十六进制字符串作为运行ID。主要用来唯一识别Redis节点。
- 主节点重启变更了整体数据集(如替换RDB/AOF文件)从节点再基于偏移量复制数据将是不完全的,因此当运行ID变化后从节点将做全量复制。(需要注意Redis关闭再启动后,运行ID会变,因此会全量复制。)
psync {runId} {offset}
:runId指从节点所复制主节点运行id,默认值为?
;offset指从节点已复制的数据偏移量,默认值为-1。主节点根据psync参数和自身数据情况决定响应结果。
+FULLRESYNC {runId} {offset}
:从节点进行全量复制+CONTINUE
:部分复制+ERR
:主节点版本低于Redis 2.8。无法识别psync命令,从节点将发送旧版sync命令触发全量复制流程。
全量复制
在传输RDB文件这步上非常耗时,可以通过日志算出RDB文件从创建到传输完成消耗的总时间。如果总时间超过repl-timeout
所配制的时间(默认60秒),从节点将放弃接受RDB文件并清理已经下载的临时文件,导致全量复制失败。
无盘复制:为了降低主节点磁盘开销,Redis支持无盘复制,生成的RDB文件不保存到磁盘而直接通过网络发送给从节点。适用于主节点所在机器磁盘性能较差但网络带宽较充裕的场景。
在RDB文件创建到传输完成这段时间内的写命令,主节点将这些命令数据存在复制缓冲区。如果这段时间过长,对于高写入的场景容易造成主节点复制客户端缓冲区溢出。(默认为60秒缓冲区消耗持续大于64MB或者直接超过256MB)。运维人员需要根据主节点数据量和写命令并发量调整client-output-buffer-limit slave
配置,避免全量复制期间客户端缓冲区溢出。
对于读写分离场景,Redis复制提供slave-server-stale-data
参数(默认开启),复制期间,冲节点依然可以响应所有命令。
总结,全量复制整体时间分为几个部分。
- 主节点bgsave时间
- RDB文件网络传输时间
- 从节点清空数据时间
- 从节点加载RDB时间
- 可能的AOF重写时间
部分复制
当主从节点间网络出现中断,如果超过repl-timeout
时间。主节点会认为从节点故障并中断复制连接。
部分复制中,主节点内部存在复制积压缓冲区,可以保存最近一段时间的写命令数据,默认最大缓存1MB。
心跳
主从节点都有心跳检测机制,各自模拟成对方的客户端进行通信。
主节点默认每隔10秒对从节点发送ping命令,判断从节点的存活性和连接状态。
从节点在主线程中每隔1秒发送replconf ack {offset}}
命令,上报自身的复制偏移量。其主要作用有:1)实时监测主从节点网络状态;2)上报自身复制偏移量,检查复制数据是否丢失,如果从节点数据丢失,再从主节点的复制缓冲区中拉取丢失数据;3)实现保证从节点的数量和延长性功能。
为了降低主从延迟,一般把Redis主从节点部署在相同的机房/同城机房,避免网络延迟和网络分区造成的心跳中断等情况。
Redis主节点不但负责数据读写,还负责把写命令同步给从节点。写命令的发送过程是异步完成的。
其他
读写分离:将读流量分摊到从节点上,只对主节点执行写操作。其中可能会遇到一些问题:1)复制数据延迟;2)读到过期数据;3)从节点故障。
注意:避免复制风暴。复制风暴指大量从节点对同一主节点或者对同一台机器的多个主节点短时间内发起全量复制。
内存
mem_fragmentation_ratio
表示内存碎片,当大于1时,代表有内存碎片。如果这个值特别大,说明碎片率相当严重。当小于1,一般出现在操作系统把Redis内存交换(Swap)到硬盘导致,这种情况需要格外注意,由于硬盘速度远远慢于内存,Redis性能会变得很差,甚至僵死。
消耗
Redis进程内消耗主要包括:自身内存 + 对象内存 + 缓冲内存 + 内存碎片。一个空的Redis进程消耗内存可以忽略不计。
对象内存,存储用户所有的数据。在使用Redis时很容易忽略键对内存消耗的影响,应当避免使用过长的键。
缓冲内存主要包括:客户端缓冲、复制积压缓冲区、AOF缓冲区。
客户端缓冲区指所有接入到Redis服务器TCP连接的输入输出缓冲。其中分为多种客户端。1)普通客户端:指除了复制和订阅的客户端之外的所有连接,Redis默认没有对普通客户端的输出缓冲区做限制,一般普通客户端的内存消耗可以忽略不计;2)从客户端:主节点为每个从节点单独建立一条连接用于复制命令,建议主节点挂载从节点不要多于2个,主节点不要部署在较差的网络环境下;3)订阅客户端。
复制积压缓冲区在整个主节点只有一个,所有从节点共享此缓冲区。可以设置较大的缓冲区空间,这个部分投入可以有效避免全量复制。
内存碎片:在 1)频繁做更新操作和2)大量过期键删除,键对象过期删除后,释放的空间无法得到充分的利用的情况下,容易出现高内存碎片问题。其中解决方式有数据对齐和安全重启。
子进程内存消耗:子进程内存消耗主要指执行AOF/RDB重写时Redis创建的子进程内存消耗。
- Redis产生的子进程并不需要消耗1倍的父进程内存,实际消耗根据期间写入命令量决定,但是依然要预留一些内存防止溢出;
- 需要设置
sysctl vm.overcommit_memory=1
允许内核可以分配所有的物理内存,防止Redis进程执行fork时因系统剩余内存不足而失败; - 排查系统是否支持并是否开启THP。
管理
maxmemory
最大可用内存。主要用于缓存场景(超过后使用LRU释放空间),防止内存超过服务器物理内存。Redis支持动态修改maxmemory值,可以动态伸缩Redis内存。(Redis默认无限使用服务器内存,为了防止极端情况,建议所有的Redis进程都配置该值)
Redis内存回收机制分为两种:删除过期对象,达到maxmemory溢出策略。
Redis中过期数据删除策略分为两种:惰性删除,从节点不会主动删除超时数据,主节点每次处理读取命令时,都会检查键是否超时;定时删除:Redis主节点进行定时任务采样一定数据量的键,当发现采样的键过期,则删除(默认每秒10次)。在Redis 3.2中增加了,从节点读取数据之前检查键的过期时间来决定是否返回数据。
为什么在慢模式下执行超时后,需要改为快模式执行呢?在快模式下,超时时间为1毫秒,且2秒内只能运行一次。其中减少了超时时间,并且降低了执行频率。目的是为了保证对主线程不造成性能的影响。
内存溢出控制策略:
noeviction
:默认策略,不会删除任何数据,达到上限时返回OOMvolatitl-lru
:达到上限时根据LRU删除设置了超时属性的键allkeys-lru
:根据LRU算法删除键(不考虑是否设置了超时属性)allkeys-random
:随机删除所有键,直到腾出足够空间为止valatile-random
:随机删除有超时属性的键valatile-ttl
:根据键值对象ttl属性,删除最近将要过期数据。
可以通过设置成allkeys-lru
策略把Redis变为纯缓存服务器使用。
每次Redis执行命令时,如果设置了maxmemory
参数,都会尝试进行内存回收操作(不一定执行),如果使用内存大于上限值,则会根据一定当前策略进行回收。
优化
redisObject对象结构如下:
缩减key和value的长度,value可以通过压缩降低内存占用。(当频繁压缩解压时,需要考虑压缩和解压的开销成本)
共享对象池是指Redis内部维护[0-9999]
的整数对象池。当设置了maxmemory
并启用了LRU相关淘汰策略,Redis禁止使用共享对象池。
对于ziplist编码的值对象,即使内部数据为整数也无法使用对象池,因此ziplist使用压缩且内存连续的结构。对象共享判断成本过高。
Redis自己实现了字符串结构,内部简单动态字符串(SDS)。
尽量减少字符串频繁修改操作,如append、setrange,改为直接使用set修改字符串,降低预分配带来的内存浪费和内存碎片化。
使用二级存储也能帮我们节省内存。
编码
编码就是具体使用那种底层数据结构来实现。通过不同的编码实现效率和空间的平衡。
编码类型转换在Redis写入数据时自动完成,这个转换过程时不可逆的,转换规则只能从小内存编码向大内存编码转换。
可以使用config set
命令设置编码相关参数来满足使用压缩编码的条件。对于已经采用非压缩编码类型的数据,如hashtable``linkedlist
等,设置参数后即使数据慢煮编码条件,Redis也不会做转换,需要重启Redis重新加载数据才能完成转换。
ziplist主要目的是为了节约内存,采用线性连续的内存结构。
ziplist其数据结构特点如下:
- 一块连续内存数组
- 可以模拟双向列表结构,以O(1)实际复杂度入队出队
- 新增、删除操作涉及内存重新分配或释放,加大操作的复杂性
- 读写超过涉及复杂的指针移动,最坏时间复杂度为O(n^2)
- 适合存储小对象和长度有限的数据
针对性能要求较高的场景使用ziplist,建议长度不要超过1000,每个元素大小控制在512字节以内。
intset:存储有序、不重复的整数集。intset编码结构包括encoding``length``contents
。其中类型是根据长度划分,当保存的整数类型超过当前类型,会自动触发升级并且升级后不再回退。
使用intset编码的集合时,尽量保持整数范围一致,防止个别大整数导致集合元素类型升级,产生内存浪费。
其他
Redis内存不足时,首先考虑的问题不是加机器做水平扩展,应该先尝试做内存优化,当遇到瓶颈时在去考虑水平扩展。
Redis Sentinel
Redis Sentinel(哨兵)是一种Redis高可用实现方案。其中包括若干个Sentinel节点和Redis数据节点。每个Sentinel节点会对数据节点和其余Sentinel节点进行监控。
当发现节点不可达时,进行标记。如果该节点是主节点,该Sentinel节点将和其他Sentinel节点进行协商,如果大多数节点认为主节点不可达,会选举出一个Sentinel节点来完成自动故障转移。
Sentinel节点本质是一个特殊的Redis节点。Redis Sentinel中的数据节点和普通的数据节点在配置上没有区别,只增加了一些Sentinel节点对它们进行监控。
生产环境中建议Redis Sentinel的所有节点应该分布在不同物理机上。
Sentinel节点数和数据节点的数量没有关系。只至少需要3个Sentinel节点来保障系统的健壮性。
配置中<quorum>
用于故障发现和判定,至少有quorum
个Sentinel节点认为主节点不可达,则认为该节点客观不可达。一般建议设置为Sentinel节点的一半加1。
down-after-milliseconds
配置代表用于判断超过时间并有有效回复则判定节点不可达。其对于Sentinel节点、主节点、从节点的失败判定同时有效。
parallel-syncs
用来限制在一次故障转移后,每次向新的主节点发起复制操作的从节点个数。
failover-timeout
的作用较多。1)当对一个主节点故障转移时,下次转移起始时间是failover-timeout
的两倍;2)在晋升选出的从节点为主节点时,如果在选出的从节点上执行slaveof no one
一直失败,如果超过这个时间,则故障转移失败;3)Sentinel节点执行info命令来确实选出的从节点晋升主节点,这个阶段超时则故障转移失败;4)在命令其余从节点复制新的主节点时,如果超过这个时间(不包含复制时间)则故障转移失败。注意:即使从节点复制主节点阶段超过这个时间,Sentinel节点也会最终配置从节点去同步最新的主节点。
一套Sentinel可以监控多个主节点,一般根据是否是同一个业务的多个主节点集合来判断是否监控多个主节点。
原理
- 每隔10秒,每隔Sentinel节点向主节点和从节点发送info命令获取最新的拓扑结构
- 每隔2秒,每隔Sentinel节点向Redis数据节点
__sentinel__:hello
频道上发送该Sentinel节点对主节点的判断以及当前Sentinel节点的信息,同时每个Sentinel节点也会订阅该频道,用于了解其他Sentinel节点以及它们对主节点的判断。(发现新的Sentinel节点,交换主节点的状态) - 每隔1秒,每个Sentinel节点会向主节点、从节点、其余Sentinel节点发送ping命令做一次心跳检测,确认这些节点当前是否可达。
下线
主观下线:在每个Sentinel节点每隔1秒对其他所有节点发送ping命令做心跳检测,如果这些节点在down-after-milliseconds
没有进行有效回复,则该Sentinel节点对该节点做失败判定。
客观下线:当判断主观下线时,该Sentinel节点通过sentinel is-master-down-by-addr
命令向其他Sentinel节点询问对主节点的判断,当超过<quorum>
时,则认为主节点确实有问题,这时做出客观下线的决定。
选举
Redis在进行客观下线后,Sentinel节点通过选举选出领导者,领导者进行故障转移工作。其中使用Raft算法实现领导者选举。
故障转移
- 从列表中选出一个节点作为新的主节点
- Sentinel领导者节点对选出的节点执行
slaveof no one
命令 - Sentinel领导者节点向其他从节点发送命令,让它们成为新主节点的从节点
- 原来的主节点更新为从节点,当恢复后命令它复制主节点
其他
部署各个节点的机器时间尽量要同步,否则日志的时序性会混乱。(NTP服务)
Sentinel节点依然会对这些下线节点进行定期监控。
Redis Sentinel实现读写分离高可用可以依赖Sentinel节点的消息通知,获取Redis数据节点的状态变化。
集群
Redis分布式方案一般有两种:客户端分区方案,代理方案。官方提供Redis Cluster。
数据
分布式数据库需要解决将数据集划分到多个节点上,每个节点负责整体数据的一个子集。常见分区规则有哈希分区、顺序分区。
- 节点取余
- 当节点扩容或收缩节点,数据节点映射关系需要重新计算,导致数据的重新迁移。
- 一致性哈希:为系统中的每个节点分配一个token(0~2^32),这些token组成哈希环。数据读写操作时,根据key计算hash,然后顺时针找到第一个大于等于该哈希值的token节点。
- 加减节点会造成哈希环中部分数据无法命中,需要手动处理或忽略,常用于缓存场景
- 少量节点时,节点的变化将大范围影响哈希环中数据映射,不适合少量数据节点方案
- 需要增加一倍或者减半才能保证数据和负载的均衡
- 虚拟槽:使用分散度良好的哈希函数把所有数据映射到固定范围的整数集合中,整数定义为槽,这个范围远大于节点数,Redis Cluster中的范围是0~16383。每个节点负责一定数量的槽。
Redis Cluster采用虚拟槽分区。
- 解耦数据和节点之间的关系,简化了节点扩容和收缩难度;
- 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据;
- 支持节点、槽、键之间的映射查询,用于数据路由、在线伸缩等场景。
限制:
- key批量操作支持有限
- key事务操作支持有限
- key作为数据分区的最小粒度,因此不能将一个大的键值对象映射到不同的节点
- 不支持多数据空间
- 复制结构只能支持一层
通信
Redis集群采用Gossip协议进行通信,原理是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整信息。
常见的Gossip消息分为下面几种:
- meet:通知新节点加入
- ping:集群内每个节点每秒向多个其他节点发送ping消息,监测节点是否在线并交换彼此状态信息
- pong:接受meet和ping消息时,回复给发送方确认消息正常通信,其中封装了自身的状态数据(节点也可以向集群内广播自身pong消息来通知整个集群对自身状态更新)
- fail:当节点判定集群内另一个节点下线时,向集群内广播一个fail消息,其他节点接受不了fail消息后,将对应的节点更新为下线状态
Redis集群内节点通信采用固定频率(定时任务每秒执行10次)。每个节点维护定时任务默认每秒执行10次,每秒会随机选取5个节点找出最久没有通信的节点发送ping消息。(每100毫秒都会扫描本地节点列表,如果发现节点最久一次接受pong消息的时间大于cluster_node_timeout/2
,则立即发送ping消息。cluster_node_timeout
参数默认15秒。
集群伸缩
集群伸缩,即扩容和缩容,原理是槽和数据在节点之间的移动。
在数据迁移过程中,集群可以正常提供读写服务。
在集群下添加从节点,使用cluster replicate {masterNodeId}
,slaveof命令在集群模式不在支持。执行添加从节点命令后,从节点会对主节点发起全量复制,并且更新本地节点的集群相关状态。
注意:对于主从节点都下线的情况,建议先下线从节点再下线主节点,避免不必要的全量复制。
请求
Redis Cluster没有采用代理方式,而是采用客户端直连的方式。
Redis 接收任何键相关命令时首先计算键对应的槽,再根据槽找出所对应的节点,如果节点是自身,则处理命令;否则回复MOVED重定向错误,通知客户端请求正确的节点。
使用redis-cli命令时,加入-c参数支持自动重定向。其本质是client收到了MOVED信息后再次发起请求。
Smart客户端通过在内部维护slot->node
的映射关系,本地就可实现键到节点的查找,从而保证IO效率最大化,而MOVED重定向复制协助Smart客户端更新slot->node
映射。
ASK重定向说明集群正在进行slot数据迁移,客户端无法知道什么时候迁移完成,因此只能临时性的重定向,客户端不会更新slot缓存。
注意:当在集群环境下使用mget、mset等批量操作时,slot迁移数据期间由于键列表无法保证在同一节点,会导致大量错误。
集群环境下对于使用批量操作的场景,建议优先使用Pipeline方式,在客户端实现对ASK重定向的正确处理,既可以通过批量操作进行IO优化,又可以兼顾slot迁移场景。
故障转移
主观下线:
与Redis Sentinel的主观下线不同的时,Redis Cluster中主观下线是直接通过节点通信来判断的,而Sentinel中是通过Sentinel监控节点通信判断的。
客观下线:集群中的故障节点的下线报告通过Gossip消息在节点中传播。当半数以上持有槽的主节点都标记某个节点是主观下线时,触发客观下线流程。(必须半数以上是为了应对网络分区的原因造成的集群分割情况,被分割的小集群无法完成主观下线到客观下线这个关键过程,从而防止小集群完成故障转移继续对外提供服务。)
注:如果在cluster-node-time * 2
的时间内该下线报告没有得到更新则过期删除。(不建议将cluster-node-time
设置得太小。
部署时需要注意网络分区的情况导致分割。主从结构需要根据自身机房/机架拓扑结构,降低主从被分区的可能性。
故障恢复
- 资格检查。判断从节点是否有资格替换故障的主节点。
- 准备选举时间。采用延迟触发。通过对多个从节点使用不同的延迟选举时间来支持不同节点的优先级。
- 发起选举。每个主节点自身维护了一个配置纪元(
clusterNode.configEpoch
)标示当前主节点的版本,所有主节点的配置纪元都不相等,从节点会复制主节点的配置纪元。整个集群维护一个全局的纪元,记录集群中的纪元的最大版。更新纪元后,然后在集群中广播选举消息。- 纪元标示每个主节点的不同版本和当前集群最大版本
- 集群出现新的主节点,通过纪元来记录
- 更大配置纪元代表了更新了集群状态。如果出现slots等关键信息不一致时,以纪元最大的为准。
- 选举投票。只有持有槽的主节点才会处理故障选举消息。当某个从节点获得
N/2+1
个节点的选票,则选举成功。当在cluster-node-timeout * 2
内没有选举成功,则进行下一轮选举。 - 替换主节点。
附
注意开发和运维中场景问题:超大规模集群带宽消耗、pub/sub广播问题、集群节点倾斜问题、手动故障转移、在线迁移数据等。
缓存
更新策略
- LRU/LFU/FIFO算法剔除:当缓存使用量超过预设的最大值,则进行剔除。一致性差,维护成本低。
- 超时剔除:一致性较差,维护成本较低。
- 主动更新:一致性强,维护成本高。
建议在低一致性的业务配置最大内存和淘汰策略的方式使用。高一致性的业务中使用超时剔除和主动更新。
穿透优化
- 缓存空对象。优点:保护了后端数据;缺点:占Redsi内存,且更容易出现不一致情况。
- 布隆过滤器拦截。利用位数组很简洁地表示一个集合,并判断一个元素是否属于这个集合。使用布隆过滤器,存在第一类出错(Falsepositive),但是不会存在第二类错误(Falsenegative),因此,拥有100%的召回率。
无底洞优化
- 客户端一次批量操作会涉及多次网络操作,意味着批量操作随着节点增多,耗时会不断增大。
- 网络连接数变多,对节点的性能也有一定影响。
主要对批量操作的优化方式是:并行IO,使用hash_tag。
雪崩优化
- 保证缓存层服务高可用性
- 依赖隔离组件为后端限流并降级
- 提前演练
热点key重建优化
当某个热点key,在缓存失效瞬间,有大量线程来重建缓存,会造成后端负载过大,甚至崩溃。
解决办法:
- 互斥锁。思路简单,保证一致性。代码复杂度高,存在死锁风险,存在线程池阻塞风险。
- 永不过期。优点是基本杜绝了热点key问题。不保证一致性,逻辑过期时间增加代码维护成本和内存成本。
代理
Twemproxy
Twemproxy是Twitter开源的代理分片机制,Twemproxy作为代理,可接受来自多个程序的访问,按照路由规则,转发给后台的各个Redis服务器,再原路返回,并且它还可以减少与后端缓存的连接数。使用Keepalived来实现高可用。