Redis 全家桶
1、数据结构
String List Hash Set ZSet
Geospatial
指定地址位置信息,包括经度纬度坐标信息。
//将经度为116.404、纬度为39.915的地理位置添加到名为"cities"的Geospatial集合中
GEOADD cities 116.404 39.915 "Beijing"
//查询地理位置
GEOPOS cities "Beijing"
//查询地址位置之间的距离
GEODIST cities "Beijing" "Shanghai" km
//查询地址位置周围的其他地理位置
GEORADIUS cities 116.404 39.915 100km
HyperLogLog
一种近似计数的数据结构,可以在极小的内存开销下,对一个数据集的基数进行估算。
//添加元素
PFADD mylog hello
//获取近似基数
PFCOUNT mylog
//合并多个
PFMERGE mylog3 mylog1 mylog2
Bitmaps
一种位图数据结构,存储大量的二进制位,用于对位图进行操作。 适合统计和分析大规模数据
//设置位
SETBIT online_users 100 1
//查询位
GETBIT online_users 100
//统计位
BITCOUNT online_users
操作API
API | |||||||||
---|---|---|---|---|---|---|---|---|---|
类型 | 设置,不存在添加,存在则修改 | 不存在添加,如存在返回0 | set的基础上添加TTL的 | 获取key | 获取原来key的旧值,并设置新值 | 增量存入值 | 自减运算 | map集合批量插入数据 | 批量获取数据 |
String | set | setNX | setEX | get | getAndSet | incr | decr | mset | mget |
Hash | hset | hsetNX | hDEL | hGET | hlen | hmset | hmget | hkeys | hgetall |
List | lpush | lpop | rpush | rpop | lindex | lren | lrange | llen | rpoplpush |
Zset | zadd | zrem | zscore | zcard | zrange | zrank | zrevran | zrevrange | |
set | sadd | srem | sismember | scard | sinter | sunion | sdiff | spop | smembers |
事务 | multi | exec | discard | watch | unwarch |
2、使用场景
- 购物车
- 抽奖
- 点赞/签到/打卡
- 用户关注
- 商品标签
- 热点话题
- 附近的人
- 相关模型推荐
- 分布式session
- 数据缓存
- 全局ID
- 分布式锁
- 分布式队列/栈
- 服务注册/发现
- 分布式消息队列
3、淘汰策略
- volatile前缀的策略代表从redisDB的expire字典中选择键进行清除;
- allkeys前缀的策略代表从dict字典选择键进行清除;
Redis一共提供了8种淘汰策略,默认是noeviction
- volatile-lru:针对设置了过期时间的key,使用lru算法进行淘汰
- allkeys-lru:针对所有key,使用lru算法进行淘汰
- volatile-lfu:针对设置了过期时间的key,使用lfu算法进行淘汰
- allkeys-lfu:针对所有key,使用lfu算法进行淘汰
- volatile-random:针对设置了过期时间的key,进行随机淘汰
- allkeys-random:针对所有key,使用随机淘汰
- volatile-ttl:删除生存时间最近的一个key
- noeviction:不删除键,返回错误
缓存失效策略
- 定时清除:针对每个设置国旗时间的key都创建指定定时器
- 惰性清除:访问时判断,对内存不友好
- 定时扫码清除:定时100ms随机20个检查过期字典,若存在25%以上则继续循环删除
LRU算法
Least Recently Used:最久没使用过的
- 数据结构:HashTable + LinkedList 相结合,HashTable用来查询链表中数据位置,链表负责数据的插入,当新数据插入到链表头部时有两种情况
- 链表满了,把链表尾部的数据丢弃掉,新加入的缓冲直接加入到链表头中
- 当链表中的某个缓冲被命中时,直接把数据移到链表头部,原本的头节点缓存就向链表尾部移动 这样,经过多次cache后,最近被命中的缓存都会存在链表头部,没有命中的,都会在链表尾部方向,当需要替换内容时,由于链表尾部是最少被命中的,我们只需要淘汰链表尾部的数据即可。
LFU算法
Least Frequently Used:最近最少使用,它与key的使用次数有关,思想是根据key最近被访问的频率进行,比较少访问的key优先淘汰,反之则保留。
- 原理是:使用计数器来对key进行排序,每次key被访问时,计数器会增大,当计数器越大,意味着越频繁,也就是热点数据。很好的解决了LRU的缺陷:很久没被访问的key,偶尔被访问一次就被误认为热点数据。
- 数据结构:HashTable + 两个LinkedList,横向组成的链表用来存储访问频率,每个访问频率的节点下存储另一个具有相同访问频率的缓存数据。
- 当添加元素时,找到相同访问频次的节点,然后添加到该节点的数据链表头部。如果该数据链表满了,则移除链表尾部节点
- 当获取元素或删除元素时,都会增加key的访问频次,并把当前节点移到下一个频次节点位置
4、事务操作
MULTI:开启一个事务,发返回OK。执行后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是放到一个队列中,当EXEC命令被调用时,所有队列中的命令才会被执行。
EXEC:执行所有事务块内的命令。返回事务块内所有命令的返回值,按命令执行的先后顺序排列。当操作被打断时,返回nil。
WATCH:一个乐观锁,可以为Redis事务提供CAS行为。监控一个或多个键,一旦其中一个键被修改,之后的事务就不会执行,监控一直持续到EXEC命令结束。(秒杀场景)
DISCARD:调用该命令,客户端可以清空事务队列,并放弃执行事务,且客户端会从事务状态中退出。
5、分布式锁
redisson实现原理
lock
- tryLock()方法通过
- tryAcquire()尝试获取锁
- tryAcquireAsync()异步获取锁
- tryLockInnerAsync方法获得一个异步返回Futrue
- 执行一串LUA脚本返回判断lock是否存在
- 不存在直接返回nil获得锁
- 存在冲入次数+1并设置过期时间,返回nil获得锁
- 被其他线程锁定,返回锁有效期时间
unlock
- unlockInnerAsync方法获得一个异步返回Futrue
- 执行一串LUA脚本返回判断解锁流程
- lock键不存在,通过publish指令发送一个消息表示锁已经可用
- 如果锁不是被当前线程锁定,则返回nil
- 由于支持可重入,在解锁时将重入次数 - 1
- 如果计算后冲入次数>0,则重新设置过期时间
- 如果计算后重入次数<=0,则消息锁已经可用
竞争情况
- 优化了锁机制,可有一个等待获得锁的过程
- 发现锁被其他线程使用了,进入循环尝试获得锁
- 以线程ID向redis订阅消息,并阻塞等待订阅结果
- 如果超过等待时间,那么直接返回获取不到并取消订阅
- while true循环,通过信号量阻塞等待解锁信息
锁过期后的续约
redisson引入一个 Watch Dog机制,针对分布式锁来实现锁的自动续约,就是说:如果在指定时间内当前线程没有执行完,由于锁超时导致锁被释放,那么其他线程就会拿到这锁从而导致一些故障。
tryAcquireAsync
- 首先判断expirationRenewalMap 是否存在entryName,这个map结构,主要还是判断在这个服务实例中加锁客户端的锁key是否存在
- 如果已经存在,直接返回,主要考虑RedissonLock是可重入锁
- renewExpirationAsync 时间轮任务,在该任务中进行续约
时间轮
时间轮是一种高效利用线程资源进行批量化调度的一种调度模型。把大批量的调度任务全部绑定到同一个调度器上,使用这个调度器来进行所有任务的管理,触发,以及运行。所以时间轮的模型能够高效管理各种延时任务,周期任务,通知任务。
时间轮就和手表的表圈一样,所以称为时间轮。因为它是以时间作为刻度组成的一个环形队列,这个环形队列采用数组来实现,数组的每个元素称为槽,每个槽可以放一个定时任务列表,叫HashedWheelBucket,它是一个双向链表,链表的每一项标识一个定时任务项(HashedWheelTimeout),其中封装了真正的任务TimerTask。
时间轮是由多个时间格组成,下图中有8个时间格,每个时间格代表当前时间轮的基本时间跨度(tickDuration),其中时间轮的时间格的个数是固定的。
在下图中,有8个时间格(槽),假设每个时间格的单位为1s,那么整个时间轮走完一圈需要8s钟。每秒钟指针会沿着顺时针方向移动一个,这个单位可以设置,比如以秒为单位,可以以一小时为单位,这个单位可以代表时间精度。通过指针移动,来获得每个时间格中的任务列表,然后遍历这一个时间格中的双向链表来执行任务,以此循环。
6、高级实战
数据一致性
不同的场景下采取不同的策略:
- 先删除缓存后再更新数据库(实现简单,可以允许少量不一致的情况,时效性强)
- 并发场景下,删除redis后立刻被回填,这时会造成数据不一致
- 解决方案:延迟双删,睡眠1s后再删除redis缓存数据
- 先更新数据库后再删除缓存(实现复杂,最终一致性,时效性弱)
- 如果删除缓存失败,那么会造成数据不一致
- 解决1:使用消息队列删除缓存,利用失败重试机制
- 解决2:利用cannel监听binlog日志再使用消息队列进行更新redis缓存
- 数据一致性问题:因为某些操作会导致数据的不一致
- 消息队列删除缓存失败,采用死信队列进行删除缓存
- 缓存时间尽可能短,让缓存数据尽早过期
缓存实战
- 缓存雪崩
- 场景:大量热点数据同时过期或者失效了,导致所有请求落到数据库上
- 解决方案:
- 不同的key设置不同的过期时间(随机过期时间)
- 热点数据不设置过期时间
- 多级缓存
- 缓存击穿
- 场景:热点数据缓存失效,导致大量并发查询同一条数据
- 解决方案:
- 加锁,只允许一条线程查询数据库再更新缓存数据
- 热点数据永不过期
- 缓存预热
- 缓存穿透
- 场景:访问的数据不存在,导致查询都落到数据库中(恶意攻击)
- 解决方案:
- 接口校验
- 缓存空值
- 使用布隆过滤器
- 异步缓存写入
- 场景:针对大量的数据更新,增加吞吐量
- 只写入缓存,缓存批量异步更新DB
- 场景:针对大量的数据更新,增加吞吐量
- 大 key 问题
- 底层数据结构里,根据value不同选择对应的数据结构
- 拓展新数据结构,根据protobuf等进行序列化,通过resotre一次写入
- 将大key拆分为多个key,设置较长的过期时间。
7、通信原理
Redis 为什么那么快
- 底层C语言针对性数据结构实现
- 存内存IO存储
- IO多路复用模型
- 单线程模型:瓶颈不在计算上,所以单线程能够满足高并发的需求,也避免多线程切换以及同步机制带来的其他性能开销
网络IO的通讯
TCP通信:TCP Soket的内核中都有一个发送缓冲区和接收缓冲区
- 接收缓冲区把数据缓存到内核,若应用进程一直没有调用Socket的read方法进行读取,那么数据一直在缓冲区中
- read:把内存接受缓冲区的数据复制到应用层用户的缓冲区中
- send:将数据从应用层用户的缓冲区复制到socket内核发送缓冲区
- 网卡中的缓冲区不属于内核空间也不属于用户空间。属于硬件缓冲,允许网卡和操作系统之间的缓冲
- 内核缓冲区在内核空间,内存中用于内核程序,作为读和写往硬件的数据缓冲区
- 用户缓冲区在用户空间,内存中用于用户程序,作为读和写往硬件的数据缓冲区
多路复用模型
- BIO模型:最基础的一个通信模型,采用Socket套接字来完成
- 基于BIO实现socket通信:如果有两个客户端进行连接访问,那么需要一个个来
- 基于多线程优化BIO模型:多线程数取决于硬件,数量有限
- NIO模型:客户端或服务端需要通过一个线程不断轮询才能获得结果(每个连接需要一个线程轮询)
- NIO多路复用:NIO模型基础上取消线程轮询(采用一个线程轮询所有的连接)
- 本质上是通过一种机制让线程监视多个socketfb,一旦有就绪,能够通知程序进行读写操作
- select:通过一个或多个fd传递给select调用,进程会阻塞在select操作上,这样可以帮我们检测多个fd是否就绪状态。这种模型有两个缺点
- 由于能够同时监听多个fd,如果fd过多那么一个线程轮询所有fd(开销大)
- select单线程能打开fd是有限的(1024)
- epoll:基于事件驱动方式代理线程轮询,因此性能更高
- 主要原理:监听fd就绪时,告知当前线程哪个fd就绪,当前线程从指定fd读取数据即可
- epoll能支持的fd是操作系统最大文件句柄数,远远大于1024
- 核心组件:
- channel:数据传输通道
- selector:选择器(监控chennel是否就绪)
- buffer:缓冲区
Reacor 多路复用模型
- 单线程Reacor模型:基于NIO多路复用提炼出一个高性能IO设计模型
- 把响应IO事件和业务处理进行分离,通过一个或多个线程处理IO事件,然后将就绪得到事件分发给Handlers线程进行异步非阻塞业务处理
- 核心组件:
- Reactor:监控IO事件,分发给Handler活Acceptor
- Acceptor:处理客户端连接请求
- Handlers:绑定事件,执行非阻塞读写业务处理
- 多线程单Reactor模型:处理Handler时使用线程池进行处理
- 多线程多Reactor模型:上述的基础上,采用多个Reactor处理
8、持久化
Redis支持两种方式的持久化,一种是RDB,另一种是AOF方式,两种持久化方式可以单独使用其中之一,也可以都使用。
- RDB:(快照)Redis DataBase 根据指定规则“定时”将内存中的数据存储在硬盘上 dump.rdb save参数定义快照周期
- AOF:(指令)每次执行命令后将命令本身记录下来
RDB 模式
RDB文件通过压缩的二进制文件,占用空间会小于内存中的数据,更利于传输,默认的持久化方式 如何触发生成RDB文件:
- 根据配置规则进行自动快照
- 用户执行save或gbsave
- save 同步快照操作(不推荐)
- gbsave 后台异步快照操作(推荐)
- fork一个子进程进行处理快照操作,写入临时文件中,主进程继续处理其他命令
- 当子进程完成后会将临时文件替换旧的RDB文件
- 执行flushall命令:定义快照规则,才会生成RDB快照文件
- 执行复制时:没有定义快照规则,也会生成RDB快照文件 RDB 优势:
- RDB是一个紧凑的文件,保持了redis某个时间点上的数据集,适合备份和灾难恢复
- 生成时,fork子进程处理,主进程不需要任何io操作
- RDB在恢复大数据时速度比AOF快 RDB 缺点:
- RDB方式数据没办法做到实时持久化。因为数据量如果过多,每次全量执行成本高
- redis意外宕机了,就会丢失最后一次快照的数据
AOF 模式
Append Only File:默认不开启。每次更新采用以日志的形式追加到AOF文件中。
异步刷盘策略:
- 1 no 表示不执行fsync,由操作系统同步到磁盘,速度最快,不太安全
- 2 always 表示每次写入都只想fsync,效率很低
- 3 everysec 表示每秒只想一次fsync,可能丢失1s数据。(两者兼顾)
AOF重写流程:
- 主进程会fork一个子进程出来进行AOF重写,这个重写过程并不是基于原有的aof文件来做的,而是有点类似于快照的方式,全量遍历内存中的数据,然后逐个序列到aof文件中
- 在fork子进程这个过程中,服务端仍然可以对外提供服务,那这个时候重写的aof文件的数据和redis内存数据不一致怎么办?不用担心,这个过程,主程序的数据更新操作,会缓存到aof_rewrite_buf中,也就是单独开辟一个缓存来存储重写期间收到的命令,当子进程重写完以后再把缓冲中的数据追加到aof中
- 当所有的数据全部追加到新的aof文件后,把新的aof文件重命名正式的文件名字,此后所有的操作都会被写入新的aof文件。
- 如果在rewrite过程中出现故障,不会影响原来aof文件的正常工作,只有当rewrite完成后才会切换文件。所以这个rewrite过程是比较可靠的。
9、高可用
Redis 高可用方案
- 负载(性能):Redis本身QPS已经很高,但是如果一些并发量非常高的情况下,性能还是会受到影响,这时需要集群
- 扩容(水平):存储的考虑,因为Redis所有数据都存放在内存中,如果数据量大,很容易受到硬件的限制。升级硬件收效和成本比较低,所以横向扩展
- 主从复制(实现读写分离)
- 哨兵机制(实现master选举)
- 集群机制(实现数据分片)
主从复制
优点:
- 数据冗余:主从复制实现数据的热备
- 读写分离:使得数据库支持更大的并发
- 负载均衡:主节点提供写服务,从节点提供读服务,提高并发
- 保证高可用:作为后备数据库,如主节点故障后,切换到从节点,保证高可用
全量复制
- slave 向 master 发送 sync 命令
- master接收到sync后,执行 bgsave生成RDB文件并使用缓冲区记录此后所有写命令
- master执行bgsave命令后,向所有slave发送RDB文件
- slave丢弃旧数据,重新载入RDB快照文件
- master发送完毕后开始向服务器发送缓冲区的写命令
- slave完成RDB快照的载入,并开始接收命令请求,并执行master缓冲区的写命令
注:乐观复制,容忍一定事件内数据不一致,但会最终一致。
数据一致性方案:master执行请求后立刻返回客户端,然后异步把命令同步slave中(这样如果网络或者服务问题导致同步失败)redis提供一个配置项进行限制,就是min-replicas-to-write 3 表示至少同步给3个slave节点时再返回给客户端。
增量复制
- master和slave分别维护一个偏移量,代表master向slave传递的字节数
- master每次向slave传播N个字节数时,master的offset会增加N
- slave每次收到master传来的N个字节数时,slave的offset会增加N
无磁盘复制
repl-diskless-sync yes
全量复制可能存在的问题:
- master禁用RDB,复制初始化还会生成RDB,slave下次启动时会执行该RDB进行恢复。
- 当硬盘性能慢时,初始化复制过程会对性能产生影响
哨兵模式
哨兵的主要功能
- 监控:sentinel不断监控 master 和 slave 是否正常运行
- 通知:如果某一个实例出现问题,Sentinel可以发出通知及时采取措施
- 故障转移:master发送故障,sentinel可以重新选举并发出通知
- 配置管理:客户端连接到sentinel,获取当前master服务器地址
实现原理:
主观下线(SDOWN)当前sentinel实例对某个服务做出的判断
客观下线(ODOWN)多个sentinel实例对msater做出SDOWN判断,并通过sentinel交流后做出下线判断,然后开启failover重新选举
- 每个 Sentinel 每秒钟的频率发送ping命令监控所有的master/slave/sentinel节点
- 如果某个实例最后一次有效回复的时间超过down-after-millisenconds,则这个实例会被sentinel标记为主观下线
- 如果master被标记为主观下线,那么所有sentinel每秒一次频率确认master的确进入主观下线状态
- 当有足够数量的sentinel指定时间范围内确认master的确主观下线状态,则master会被标记为客观下线
- 一般情况下,每个sentinel会以每10秒一次频率向其他master/slave发送info命令
- 当master被sentinel标记为客观下线时,sentinel向下线的master集群中的所有slave发送info命令,频率为1秒1次,若没有足够数量的sentinel同意master已经下线,master的客观下线状态会被移除
- 若master重新向sentinel的ping命令返回有效恢复,master的主观下线状态也会被移除
选举Leaser(Raft)
- Leader的选择
- 断开时长:与哨兵连接断开比较久,直接失去选举权
- 优先级:配置文件replica-priority 100 数值越小优先级越高
- 复制数量:偏移量越大代表数据越全
- 进程ID:最后进程ID最小
- 选举流程
- Raft 心跳机制触发选举后全部节点初始化为follower,term=0
- follower收到requestVote或AppendEntries就保持自己follower身份
- 一段时间内没收到AppendEntries,超时时间内没发现leader,那么就转成Candidate,自己开始竞选Leader。一旦转为Candidate,那么立刻会做几件事:
- 增加term,启动新的定时器
- 给自己投一片,向所有节点发送requestVote,并等待其他节点回复
- 如果计时器超时前,收到多数同意投票,就会转成Leader。同时通过AppendEntries,向其他节点发送通知。
- 每个节点在一个term内只能投票一次,采取先到先得策略,Candidate投自己,Floolwer会投给第一个收到RequestVote的节点。
- Raft协议的定时器采取随机超时时间,先转为Candidate节点后才会发起投票,从而获得多数票
- 故障转移
- 选出leader后,由leader向某个节点发送slaveof no one 命令,让它成为独立节点master
- 然后向其他节点发送 relicaof xx.xx.xx.xx,让他们成为这个节点的子节点并复制新master节点数据
- master和slave服务切换后,master的redis.conf,slave的redis.conf和sentinel.conf内容都会发生相应的改变
- 心跳检测(主从同步)
- 命令传播节点,slave默认会每秒一次频率向master发送ack命令
- lag值应用在0,1之间跳动,如果超过1则说明主从之间连接有故障
- min-slaves配置防止master不安全情况下执行写命令
- 检测命令丢失,增加重传机制
- 网络故障,master传播给slave写命令的半路丢失,那么slave向master发送replconf ack命令时,master将发觉slave当前复制offset少于自己的offset,然后master就会根据slave提交的offset,复制缓冲区里面找到slave缺少的数据,并将这些数据重新发给slave
集群模式
集群模式的特点:
- 一个Redis Cluster 由多个节点组成,不同节点服务的数据没有交集
- 一个节点组分为一个master和多个slave节点,两者数据准实时一致,通过异步化主备复制
- 一般一组集群至少6个节点才能保证完整高可用
- 3个master会分配不同的slot,当master出现故障时,slave会自动选举成为master
gossip 协议
集群架构中,如果出现了新节点加入/宕机删除,slot迁移,选举master等等,希望能让整个集群中的每个节点都能发现,传播到整个集群并集群中的节点达成一致,那么各个节点之间就需要互相连通并携带相关状态数据进行传播。
正常逻辑是广播方式,向所有节点发送消息,但对cpu和带宽的消耗很大,所以引入gossip协议,流行病协议,疫情传播算法。其特点是在节点数量有限的网络中,每个节点都会随机与部分节点通信,经过一番杂乱无章的通信后,每个节点的状态在一定时间内都会达成一致。
设置的规则:
- Gossip周期性的散播消息,把周期限定为1秒
- 被感染的节点随机选择k个邻接节点散播消息,这里把fanout设置为3,每次往3个节点散播
- 每次散播消息都选择尚未发送过的节点进行散播
- 收到消息的节点不再往发送节点散播,比如A->B,那么 B不再发给A
协议消息:
- ping:每个节点都会频繁给其他节点发送ping,其中包括自己的状态还有自己维护的集群元数据互相通过ping交换元数据;
- pong:返回ping和meet,包含自己的状态和其他信息,也可以用于信息广播和更新
- fail:某个节点判断另一个节点fail之后,就发送fail给其他节点,通知其他节点,有节点宕机了
- meet:某个节点发送meet给新加入的节点,让新节点加入集群中,然后新节点就会开始与其他节点进行通信,不需要发送形成网络所需的cluster meet命令。发送cluster meet消息以便每个节点能够达到其他每个节点只需通过一条已知节点链就够了。由于心跳爆中会交换gossip信息,将会创建节点间却是的链接。
gossip的优缺点:
- 元数据的更新比较分散,更新请求会陆陆续续,达到所有节点上更新有一定的延迟,降低了压力;去中心化,可扩展,容错,一致性收敛,简单。由于不能保证某个时刻所有节点都收到消息,但是理论上最重所有节点都会收到消息,因此它是最终一致性协议。
- 元数据更新有延迟,可能导致集群的一些操作有一定的滞后性。消息的延迟,消息的冗余。
数据的分布
采用 slot(槽)的概念,一共分为16384个槽。对于每个进入Redis键值对,根据key进行散列,分配到16384个slot中的某一个,使用hash算法也比较简单,CRC16后16384取模【crc16(key)%15384】
客户端重定向:
- 用户需要访问的key在节点3,用户如果在节点1或节点2调用获取key,那么会如何处理?
- 服务端返回moved,也就是根据key计算出slot不再当前节点管理,服务端返回moved告诉客户端去节点3处理。
- 如何让相关的数据落到同一节点上?
- key里面加入{hash tag}即可。Redis在计算槽编号的时候会获取{}之间的字符串进行槽编号计算,这样由于上面两个不同键,{}里面的字符串是相同的,因此可以计算出相同的槽
主从切换原理
- slave发现自己的master变为fail
- 将自己记录的集群currentEpoch + 1,并广播failover_auth_request信息
- 其他节点收到该信息,只有master响应,判断请求者的合法性,并发送failover_auth_ack,对每个epoch值发送一次ack
- 尝试failover的slave收集master返回的failover_auth_ack
- slave收到超过半数master的ack后变成master
转载自:https://juejin.cn/post/7378046072952504354