核心数据结构与底层抽象
简单动态字符串(SDS,Simple Dynamic String)
SDS包含的关键部分:len记录当前字节数组中已经使用的字节数量(即字符串的实际长度)alloc记录当前字节数组总共分配的字节数量flags标记当前的SDS类型,用于针对不同长度的字符串进一步节省内存buf[]实际保存数据的字节数组
C语言的字符串上是一个以空字符\0结尾的字符数组,不记录自身长度,二进制存储不安全,内存分配风险。SDS的len可以O(1)记录字符串长度,依靠len而不是\0来查找末尾,SDS不会对数据做限制和假设,是绝对的二进制安全。
SDS存在空间预分配与惰性释放的特性,当对SDS进行修改并扩展空间时,Redis除了分配修改所需的空间时,还会额外分配未使用的空间(通常等于所需扩展的大小),从而减少内存分配次数。而当SDS缩短时,程序并不会立即回收,而是修改len属性,并将多余的空间保留在alloc中,等待使用。
内存重分配的性能损耗原因
如果当前内存块后面的空闲时间足够,系统会直接扩大内存块。如果后面的空间不够,系统会找一块足够大的连续空间,将原本的数据全部复制过去,释放旧的内存。
SDS空间预分配的算法
1.修改后的实际长度new_len小于1MB,额外分配new_len大小,realloc(2*new_len) 2.修改后的实际长度大于等于1MB,固定额外分配1MB大小,realloc(new_len+1MB) SDS惰性释放的内存浪费
物理内存尽管被裁剪,但剩余的内存仍被alloc占据无法被分配给其它key,可能会造成内存泄露。在编写单个String时,应避免过大的Key阻塞线程,还可以SET摧毁重建来替代APPEND。Redis从4.0还引入了主动内存碎片整理(activedefrag)特性,开启后,Redis会在后台自动扫描并整理由于修改而产生的内存碎片。
List
List早期由双向链表LinkedList、压缩列表ZipList实现,现在的快速列表QuickList,后续引入的紧凑列表Listpack。双向链表每个节点的指针占用大量额外内存,节点在内存中不连续,容易产生内存碎片;压缩列表是一块连续的内存空间,每次修改数据都有可能触发内存重分配,一个节点长度变化还会导致级联更新。
现代方案QuickList快速列表,QuickList本质是一个双向链表,但每一个节点存储一个ZipList。当向List添加元素,先向当前节点的ZipList追加 ,Redis严格控制每个ZipList的最大容量(通常为8KB),超过ZipList的最大容量时,Redis向双向链表创建一个新的节点ZipList。既有内存连续性,又有效率。
Hash
Hash类型的底层数据会根据存储数据的数量和大小进行动态转换。通常情况下,它由ZipList作为初始结构,在满足特定阈值时转换为字典Dict,即哈希表。
- 初始:ZipList.
当Hash类型存储的字段数量较少(小于512个)时,且每个字段和对应值的长度较短(小于64字节)时,Redis会使用ZipList存储Hash数据。Key和Value作为两个相邻的节点存入ZipList,键在前,值在后,查找时,顺序遍历。 - 突破阈值Dict
当数据量突破上述阈值时,底层的存储结构会不可逆地转换为Dict。Redis的Dict底层主要由两个哈希表(ht[0]和ht[1])组成。正常情况下,所有数据都储存在ht[0],ht[1]分配为null。哈希冲突采用链地址方法,所有冲突的节点组成一个单向链表,新的冲突节点插入链表头部。
随着数据增加,哈希冲突会增多,链表会变长,查询效率会退化,需要Redis对哈希表进行Rehash扩容。Redis是单线程处理,一次性计算字段的新哈希值并迁移到新表,会导致主线程长时间阻塞,因此Redis采用了渐进式Rehash机制。为ht[1]分配足够大的内存空间(ht[0]元素个数的2倍的最小2次幂),将字典的状态遍历rehashidx设置为0,表示开始rehash。Rehash期间,每次对Hash结构进行增删查改操作时,Redis会将ht[0]中的rehashidx索引对应的哈希桶内的所有链表节点重新计算哈希值,并迁移到ht[1],完成后将rehashidx的值加1。Rehash期间,新增的字段直接写入ht[1],查询操作现在ht[0]查找,找不到再去ht[1]查找。当ht[0]的所有节点全部迁移完毕,释放ht[0]的内存,将ht[1]设置为ht[0],并在新表后创建一个空的ht[1],最后将rehashidx重置为-1。
Set
无序且元素不重复的集合。Redis有两种底层实现:整数集合IntSet和字典HashTable
IntSet整数集合:当集合中的所有元素都是整数,且元素数量不超过配置阈值(默认为512),Redis会使用IntSet作为底层存储。IntSet是一个具有紧凑内存结构的连续数组。它在内存中从小到大的顺序排列所有整数,利用二分查找算法实现O(logN)查询。
IntSet数组的初始类型通常是int16_t。当存入数据超出能表示的范围时,Redis会触发Upgrade操作,将数组重新分配为int32_t或int64_tHashTable字典:存入非整数形式的字符串,或元素数量超过512,底层结构会转换为HashTable。Set的元素作为字典的键存储,字典的值全部设为NULL
ZSet
在Set的基础上,为每个元素增加一个浮点数类型的分数Score,并依赖分数进行排序。底层实现分为ZipList压缩列表,SkipList跳跃表、Dict字典
- 当Zset存储元素数量较少(默认小于128)且每个元素大小较短(默认小于64字节)时,使用ZipList。元素和它的Score作为两个相邻的节点存入紧凑的连续内存中。元素在前,Score在后。整个集合的数据按Score从小到大排列。
- 当不满足ZipList的条件时,ZSet底层会同时使用两种数据结构来维护数据,来兼顾不同API的操作效率。Dict用于维护元素到Score的映射。SkipList,用于维护元素的有序性并支持高效的范围查询。
SkipList核心原理,SkipList通过空间换时间,基于单向链表,在节点内部维护多个指向后续节点的跨度指针,形成多级索引。查询时,如果目标Score大于当前指针指向的节点,那么向右跨越,如果不大于,则向下一层细化搜索,查找时间复杂度O(logN)。插入节点时,SkipList使用概率算法决定该节点有多少层索引
BitMap位图
位图底层实际是SDS,Redis允许直接对String底层的字节数据进行Bit级别的操作。将SDS底层的字节数组看作是一个由0和1组成的巨大连续数组,通过Offset定位具体的位。Redis中String最大限制是512MB,意味着BitMap最多可以包含 。
位图的0,1bit可以用来存储大量有序二元信息。记录用户签到,以用户ID结合年月作为key,以日期作为offset,可以记录一个用户一年的签到数据。记录活跃用户,以日期作为key,用户ID作为Offset,登录置为1.对多个日期的value进行与或运算,可以计算出连续活跃用户。
HyperLogLog基数统计
HyperLogLog也基于SDS实现,最大内存占用在12KB以内,当向HyperLogLog添加元素,通过观察大量哈希值二进制表示中首个1出现的最大位置,利用伯努利过程的概率论模型,估算出集合的基数。适合海量数据的去重统计。
Geo
Geo类型用于存储地理位置的经纬度,并计算距离或执行范围查询。底层基于ZSet和GeoHash算法。GeoHash算法通过二分法将地球二维平面划分为网格,将经度和纬度分别进行编码,然后将这两串二进制位交替组合,生成一个52位的1D整数。这个整数作为ZSet的score,而用户ID会作为ZSet的member存入底层。越高位距离权重越大,相同位置比较,可以计算距离。
核心运行原理与持久化
线程模型
Redis是单线程模型,但是吞吐率超高。Redis的性能瓶颈不在于CPU计算,而在于网络I/O带宽和内存大小。
单线程模型的优势:
- 消除上下文切换损耗。避免多线程在分配CPU时间片时,频繁进行上下文切换所带来的极高昂代价。
- 极致的无锁化设计。避免了多线程并发操作底层数据结构时的加锁、释放锁开销,杜绝了死锁问题。
- 可维护性与确定性。串行执行消除了各种竞态条件,让代码逻辑更加清晰,排查问题具有极高的确定性。
基于Epoll的I/O多路复用
传统的I/O阻塞,在等待某个客户端发送数据时,主线程会直接挂起,导致完全无法服务其它客户端。Redis在底层采用I/O多路复用技术,多路:多个网络连接,复用:复用同一个单线程。将所有的Socket注册到操作系统的epoll实例,当有请求到达时,内核会主动通过事件机制通知Redis。epoll时间复杂度为O(1),select/poll轮询时间复杂度为O(N)。
Redis将I/O多路复用机制进行了封装,实现了基于事件驱动的Reactor模式。核心组件是文件事件处理器(File Event Handler)的单线程主循环。
运转流程:
- 客户端发起连接,epoll监听到可读事件,连接应答处理器介入,创捷与客户端绑定的Socket。
- 客户端发送具体的Redis命令,Socket就绪,命令请求处理器将网络流读取到输入缓冲区,按RESP协议解析命令。
- Redis按照解析后的命令,在内存中单线程串行执行业务逻辑。
- 将执行结果存入输出缓冲区,当Socket触发可写事件时,命令回复处理器将结果通过网络返回给客户端。
Redis在6.0引入了多线程,原因是网络I/O的读写和协议解析成为了Redis唯一的性能短板,主线程花费大量时间在网络数据的读取和写入上,影响了内存命令执行。Redis引入多线程仅仅用于处理网络请求的并发收发。
当在生产环境执行扫描全表这种耗时极长的命令时,Reactor的单线程主循环被卡死,spoll无法处理其它用户的事件,单线程阻塞使该节点被判定为宕机,触发主从切换,导致短暂的写入拒绝。SpringBoot侧线程池耗尽,业务请求等待Redis返回,Tomcat线程被大量挂起,无法释放。连接池爆满,新请求会抛出连接异常,线程与内存资源耗尽,微服务节点会出现502/503错误,整个应用集群崩溃。如果SpringBoot在Redis超时后,采用降级策略去查询数据库,百万级瞬时并发会瞬间将关系型数据库击穿,全站击穿(缓存击穿)。
持久化机制
Redis生成RDB(Redis Database Snapshot)的机制,向磁盘中写入大量数据,既不能阻塞主线程,又要保持数据一致性。Redis先调用操作系统的fork()命令,克隆出子进程,子进程负责写入磁盘的RDB文件,主线程则返回继续处理客户端的新命令。当客户端发来写请求时,操作系统会将这块即将被修改的内存页单独复制一份副本出来,让主线程在这个副本上进行修改(写时复制)。因此fork()进程读取的内存页的内容不会发生变化,写进磁盘的就是瞬间快照。当bgsave写磁盘时遇到大量写入/更新请求时,内存需求可能会翻倍从而导致Redis进程被杀死。在这种情况下,可以在conf配置文件中取消自动快照,通过BGSAVE命令去在流量低谷时备份快照。
采用主从架构转移BGSAVE风险,主节点只利用单线程处理业务,从节点无外部写入的并发压力,可执行BGSAVE。
AOF文件的持久化但当一次快照后未等到下一次快照时Redis断电宕机,这之间的数据就会缺失。因此引入了AOF文件,AOF文件存的是Redis的执行命令,Redis在内存执行完这条命令后会直接写入AOF文件末尾,然而AOF文件并不是每追加一条指令就写入磁盘中,Redis在内存中由aof_buf缓冲区,提供了三种方式,一条指令写一次盘,一秒写一次盘,交由操作系统决定。生产环境默认1秒(eversec)写入一次磁盘,如果确实断电缺失数据则开机重新执行AOF文件命令即可。
AOF重写机制,由于追加日志,时间过长后,可能导致AOF文件过于庞大臃肿,因此早期Redis引入了AOF重写机制BGREWRITEAOF,主线程fork()一个子进程,子进程不去读原有AOF文件,而是遍历现有的内存状态直接生成新的命令,重写AOF文件仍然具有重写缓冲区,新命令写入重写缓冲区,子进程写完后,主线程直接将重写缓冲区的内容写进新文件末尾,并替换掉旧文件。
混合持久化 触发AOF重写时,前半段:子进程直接将当前内存的全量数据,以RDB快照的二进制格式,写入新的AOF文件的开头,后半段,重写期间产生的新写命令,以传统的AOF日志文本格式,追加到文件末尾。
内存管理与淘汰机制
Redis所有的数据都在内存中,因此物理内存有被使用完的可能,Redis为了不阻塞主线程,坚持纯内存操作,而不会采用磁盘换页这种阻塞费时操作。Redis采用内存淘汰策略,由config文件决定:
- noeviction(不淘汰任何数据,默认)直接向SpringBoot抛出OOM(out of memory),拒绝所有写入和修改请求,允许读请求,使缓存系统失去写入可用性。
- 针对于设置了TTL(过期时间)的Key 2.1 volatile-ttl: 优先杀掉马上就过期的数据 2.2 volatile-lru: LRU最近最少使用算法,优先杀掉最长时间没有被访问过的数据。 2.3 volatile-lfu: LFU最不经常使用算法,优先杀掉历史上被访问频次最低的数据。
- Redis被当作缓存使用,所有数据都可以从MySQL恢复,则在所有Key中无差别淘汰。 3.1 allkeys-lru:全局范围内,杀掉最久未被访问的旧数据。 3.2 allkeys-lfu:全局范围内,杀掉访问频率最低的冷门数据。
Redis的LRU/LFU都是近似算法,为了追求极致的内存和时间效率。
分布式与高可用架构
主从复制
当一个新的从节点第一次连接到主节点时,主节点会在后台执行bgsave生成一份当前的RDB全量快照文件,主节点将这个RDB文件通过网络发送给从节点。从节点收到后,会清空当前的内存,然后加载这份RDB文件,从而建立基准。主节点每处理一条新的写命令,就会把这条命令异步地发送给所有的从节点(命令传播),从节点收到命令后原地执行。主从之间的网络抖动断开几秒,从节点不能重做一次耗时的RDB。主节点内存中维护一个环形缓冲区(repl_backlog_buffer),断线期间的新命令会暂存在缓冲区。从节点具有Offset,来记录断开前与主节点同步的位置,连接后主节点会会把断开的增量命令补发给从节点,实现增量同步。
哨兵模式
一主多从架构下,依赖哨兵模式(Sentinel)进行监督。3台以上哨兵节点组成集群,判断节点状态。
- 主观下线:(SDOWN-Subjective Down)某个哨兵每秒向主节点发送PING,如果主节点连续一段时间没有回复,哨兵主观地认为主节点挂了。
- 客观下线:(ODOWN-Objective Down)超过半数地节点确认主节点无响应时,系统才会将其标记为客观下线。
- 故障转移:哨兵集群内部通过Raft算法选出领头哨兵,去执行将从节点改为主节点,修改路由。
集群模式(Redis Cluster)
哈希槽算法:Redis Cluster不使用简单的哈希取余,而是引入16384个哈希槽,集群中的每个Redis主节点会负责一部分槽位,当SpringBoot发来读写请求时,Redis会对这个Key进行CRC16计算mod16384,从而得到Key存在在哪台物理机上。 热Key现象:百万QPS的Key经过哈希计算后落在同一台Redis上。可以给这个被疯狂访问的节点挂载多个从节点,主节点只负责处理极少量的写请求,而读请求则全部负载均衡分摊给它下面的多台从节点去处理。还可以在SpringBoot的JVM内存中引入Caffeine作为一级本地缓存,Redis作为二级分布式缓存,减少Redis的请求数。
如果负责0-5000哈希槽的主节点A物理宕机了,Redis集群会启动一套内置的自动故障转移机制(Failover)来解决。
- 集群中的其它主节点通过内部的通信机制互相发心跳包,当节点A迟迟不回应时,集体判定节点A已经客观宕机。
- 节点下的几个从节点发现主节点宕机后,会选取Offset最大的从节点,意味着主节点A宕机前与主节点同步程度最大的从节点,作为新的主节点,将身份从Slave变为Master。
- 新的主节点接管旧的节点的哈希槽位,SpringBoot再次请求这些槽位的数据时,客户端会收到重定向指令(MOVED错误),客户端底层会自动更新本地的路由表。
主存切换导致的数据丢失
数据一致性问题的可能解决方案:
- 强同步复制,SpringBoot在写命令后增加一个WAIT numreplicas timeout命令,主线程执行完写命令后,阻塞等待这条命令成功同步给指定数量的从节点后,才向SpringBoot返回成功。然而主线程阻塞,阻碍了Redis吞吐量,因此不被采用。
- 共识算法,先异步进行命令传播,然后主节点再执行。Paxos和Raft共识算法。然而Redis这样做就会变成慢速的强一致性数据库,失去高速缓存的意义。
- 不单纯依赖Redis来保证数据的强一致性,如果Redis主从切换丢失了部分数据,可以通过上游的消息队列进行重试。或者通过专门的组件去监听MySQL的底层日志,自动把丢失的数据再次强行刷入新的Redis节点,从而实现数据的最终一致性。
SpringBoot工程实战与高级场景
经典缓存三大问题与防御
缓存穿透
业务逻辑是先查Redis,Redis没有的话,再查MySQL。如果有数十万的QPS,访问就会被打到MySQL上,耗尽连接池或CPU飙升,导致MySQL崩溃。因此需要在Redis就把这些恶意请求尽量挡回去。
解决方法:首先在网关层或SpringBoot的Controller层进行严格的参数合法性校验,拦截掉低级恶意请求。对于给到Redis的请求采用布隆过滤器。布隆过滤器利用了BitMap,将MySQL的一个ID采用多个Hash进行计算,映射到BitMap不同比特位上,将其置为1,SpringBoot请求新数据时,将ID进行同样的多个Hash计算,只有每次计算结果对应的BitMap为1,才不会被拦截。布隆过滤器拦截的一定是不存在的,不拦截的不一定存在。对于那些极少数的MySQL不存在数据,可以短时间内在Redis存入这个TTL空数据。
缓存击穿
如果查的是真实数据,当十万QPS来临时,热Key正好过期了,请求会直接给到MySQL,同样造成崩溃。两种解法,在不同情况下不同需求。
- 强一致性:互斥锁,十万个并发请求在Redis没有查到数据时,会同时向Redis发送 SETNX lock 1,只有一个线程可以放行,去查询MySQL并重建Redis缓存,剩下的线程sleep一定时间,然后再次去Redis里查数据。但是这种解法会导致一定的卡顿。
- 高可用:逻辑过期策略,热Key的TTL=-1,内存永远有这个Key的数据,但是未必是新的,通过给数据设置expire_time这个真实世界的时间,来判断数据有没有过期。当访问来临时,线程会直接把这份旧数据直接返回,然后开启一个子线程去MySQL查询最新数据并更新Redis。
缓存雪崩
十万个普通Key在同一秒钟过期失效了,此时再访问就同样会将请求全部打到MySQL上。
解决方法:TTL Randomization打散过期时间。在业务中,系统上线前,会先进行缓存预热Cache Pre-war ming,将这十万个Key提前加载到Redis中,因此,将过期时间分散开即可避免缓存雪崩,set(key,value,3600+random(0,300))
分布式锁的深度实践
为了防止节点A在给数据加锁后,还没有解除就宕机的情况。需要给这把分布式锁加上一个过期时间,现代Redis提供了一个合并的原子操作命令,set lock 1 NX EX 10,默认十秒过期。 以上方法仍然面临问题,A线程如果执行时间超过十秒,会导致B线程加锁,A结束完还会删掉B的锁。因此需要引入身份验证与看门狗机制,身份验证,set存的value是UUID+线程号,由于身份验证是get+del,不具有原子性,因此需要将这个操作写入Lua脚本,Redis会将Lua脚本视为一个原子命令,杜绝了误删的可能。
解决了A删B的问题,还有A未执行完锁就过期的问题,采用看门狗机制,TTL默认30秒,加锁的线程还在运行时,后台会开启一个定时任务,每隔1/3TTL,就向Redis发送一段Lua脚本进行探活,如果探活,则TTL重置至30s。如果宕机,就过30s过期。
并发控制与事务
Redis在MULTI开启事务后不会立即执行,只有到EXEC时,才会一口气按顺序执行。Redis事务不支持回滚,为了操作安全,必须将命令写进Lua脚本,Redis会视其为原子性命令,从而确保执行安全。切忌Lua脚本执行时间过长,阻塞主线程。
性能调优与排错排雷
Key的治理
大Key:含有大量元素的一个数据结构,对它进行DEL命令操作,可能会耗时使Redis主线程阻塞,耗尽线程池,进程池爆满。现在Redis采用UNLINK命令,先在键空间删去键,然后再开一个子进程慢慢删除。
热Key:短时间内频繁访问的Key,可以将一个Key判定为热Key,然后将其存在SpringBoot侧,来提高效率。热Key的判定:热Key再SpringBoot侧判定,通过Count-Min Sketch(最小计数草图),通过几个Hash函数将Key映射到一个紧凑的二维数组中累加,通过极小内存,解决了统计问题。统计数据由所有的SpringBoot定时提交各自流量,当统计计算集群发现某个Key超过阈值,则对所有SpringBoot节点进行广播,热Key数据被存在SpringBoot侧。
监控与慢查询
Slowlog(慢查询日志):
通过慢查询日志来查找耗时命令。慢查询日志只记录内存操作花费的时间,不包括网络耗时。而且日志是维护在内存中的一个先进先出队列,因此访问速度极快。
INFO:
used_memory:真正存了多少数据,与maxmemory的距离。
keyspace_hits:缓存命中率。
latest_fork_usec:记录fork()操作的耗时时间。
字符串命令
SET key value 设置键
Get key 获取内容
DEL key 删除键
APPEND key value 追加内容
EXISTS key 检查键是否存在
EXPIRE key 10s 设置过期时间
SET key value EX 10 10s后过期
SET key value PX 5000 5000ms后过期
SET key value NX 仅当键不存在时设置成功
SET key value XX 仅当键存在时设置成功
GETSET key value 设置键并返回旧值
MSET key1 value1 key2 value2 批量设置
MGET key1 key2 批量获取
INCR key 自增
DECR key 自减
哈希命令用于存储对象 HSET user:1001 name "Alice" age 25
HGET user:1001 name
列表命令有序列表,可从两端操作
LPUSH list "a" "b" 头插法
RPUSH list "a" "b" 尾插法
LPOP list 左侧弹出
RPOP list 右侧弹出
LRANGE list 0 -1 获得从0开始,-1结束的所有元素,即全部元素
LINDEX list 0 获取指定索引元素
LLEN list 获取列表长度
LINSERT list BEFORE "b" "e" 在第一个b前插入e,如果没有b则不做任何操作。
LSET list index value 设置索引1的值
LTRIM list 0 2 保留0-2的元素
集合命令无序,不可重复
SADD set "a" "b" "c" 添加元素
SREM set "a" 删除元素
SMEMBERS set 获取所有元素
SISMEMBER set "b" 判断是否存在
SCARD set 获取集合大小
SPOP set 随机弹出元素
SRANDMEMBER set 2 随机获取两个元素
有序集合命令按分数排序
ZADD ranking 100 "Alice" 80 "Bob" 添加成员
ZRANGE ranking 0 -1 按分数从小到大
ZREVRANGE ranking 0 -1 按分数从大到小
ZSCORE ranking "Alice" 获得成员分数
ZRANK ranking "Alice" 获得成员排名
ZREVRANK ranking "Alice" 获得成员反向排名
ZREM ranking "Bob" 删除成员
ZINCRBY ranking 10 "Alice" 增加成员分数
ZCARD ranking 获得成员个数
ZCOUNT ranking 80 100 获得分数80-100的成员个数
ZRANGEBYSCORE ranking 80 100 获得分数80-100的成员
事务命令
MULTI 开启事务
SET key value
EXEC 执行事务
DISCARD 放弃事务
WATCH key 监视键(乐观锁)
UNWATCH 取消监视
发布订阅
SUBSCRIBE channel 订阅频道
PSUBSCRIBE new.* 订阅模式
UNSUBSCRIBE channel 取消订阅
PUBLISH channel "Hello" 发布消息
PUBSUB CHANNELS 查看活跃频道
PUBSUB NUMSUB channel 查看频道的订阅数
服务器管理命令
INFO 服务器信息
脚本和调试
EVAL "return 'Hello'" 0 执行Lua脚本
SCRIPT LOAD "return 1" 加载脚本
SCRIPT EXISTS sha1 检查脚本是否存在
SCRIPT FLUSH 清空脚本缓存
SLOWLOG GET 10 查看慢日志
MONITOR 监控所有命令