1 常见面试题

  • 系统在某个时刻访问量剧增(热点新闻),造成数据库压力剧增甚至崩溃,怎么办?
    限流,二级缓存,热点数据多个集群都存留一份。

  • 什么是缓存雪崩、缓存穿透和缓存击穿,会造成什么问题,如何解决?

  • 什么是大Key和热Key,会造成什么问题,如何解决?

  • 如何保证 Redis 中的数据都是热点数据?
    缓存淘汰策略,allkeys-lru

  • 缓存和数据库数据是不一致时,会造成什么问题,如何解决?
    延时双删。利用binlog插件更新缓存。

  • 什么是数据并发竞争,会造成什么问题,如何解决?
    加时间戳。

  • 单线程的Redis为什么这么快?
    内存存取数据。
    IO多路复用。
    多种数据结构。
    没有复杂的事务。
    类似于hash结构存取数据。
    渐进式rehash。
    单线程没有锁,没有多线程的切换和调度,不会死锁,没有性能消耗 。
    进行持久化的时候会以子进程的方式执行,主进程不阻塞。
    可以搭建redis集群,数据分片存储。

  • Redis哨兵和集群的原理及选择?

  • 在多机Redis使用时,如何保证主从服务器的数据一致性?
    全量同步,增量同步,命令传播, 数据补发。

2 缓存基本思想

2.1 缓存使用场景

DB缓存,减轻服务器压力

一般情况下数据存在数据库中,应用程序直接操作数据库。

当访问量上万,数据库压力增大,可以采取的方案有: 读写分离,分库分表

当访问量达到10万、百万,需要引入缓存。 将已经访问过的内容或数据存储起来,当再次访问时先找缓存,缓存命中返回数据。 不命中再找数据库,并回填缓存。

Redis笔记 - 图1

提高系统响应

数据库的数据是存在文件里,也就是硬盘。与内存做交换(swap) 在大量瞬间访问时(高并发)MySQL单机会因为频繁IO而造成无法响应。MySQL的InnoDB是有行锁。

将数据缓存在Redis中,也就是存在了内存中。

内存天然支持高并发访问。可以瞬间处理大量请求。 qps到达11万读请求/s。8万写请求/s。

做Session分离

传统的session是由tomcat自己进行维护和管理。

集群或分布式环境,不同的tomcat管理各自的session。 只能在各个tomcat之间,通过网络和Io进行session的复制,极大的影响了系统的性能。

  • 各个Tomcat间复制session,性能损耗
  • 不能保证各个tomcat之间的数据同步

将登录成功后的Session信息,存放在Redis中,这样多个服务器(Tomcat)可以共享Session信息。

Redis笔记 - 图2

做分布式锁(Redis)

一般讲锁是多线程的锁,是在一个进程中的。

多个进程(JVM)在并发时也会产生问题,也要控制时序性 可以采用分布式锁。使用Redis实现 setNX

做乐观锁

同步锁和数据库中的行锁、表锁都是悲观锁 悲观锁的性能是比较低的,响应性比较差。

高性能、高响应(秒杀)采用乐观锁 Redis可以实现乐观锁 watch + incr

2.2 什么是缓存

缓存原指CPU上的一种高速存储器,它先于内存与CPU交换数据,速度很快

现在泛指存储在计算机上的原始数据的复制集,便于快速访问。

在互联网技术中,缓存是系统快速响应的关键技术之一。

以空间换时间。

2.3 缓存分类

客户端缓存

传统互联网:页面缓存和浏览器缓存

移动互联网:APP缓存

页面缓存:页面自身对某些元素或全部元素进行存储,并保存成文件。

html5:Cookie、WebStorage(SessionStorage和LocalStorage)、WebSql、indexDB、Application Cache等

开启步骤:

1、设置manifest描述文件

  1. CACHE MANIFEST
  2. #comment
  3. js/index.js
  4. img/bg.png

2、html关联manifest属性

  1. <html lang="en" manifest="demo.appcache">

使用LocalStorage进行本地的数据存储,示例代码:

  1. localStorage.setItem("Name","张飞")
  2. localStorage.getItem("Name")
  3. localStorage.removeItem("Name")
  4. localStorage.clear()

浏览器缓存

当客户端向服务器请求资源时,会先抵达浏览器缓存,如果浏览器有“要请求资源”的副本,就可以直接 从浏览器缓存中提取而不是从原始服务器中提取这个资源。

浏览器缓存可分为强制缓存和协商缓存。

强制缓存:直接使用浏览器的缓存数据 条件:Cache-Control的max-age没有过期或者Expires的缓存时间没有过期

  1. <meta http-equiv="Cache-Control" content="max-age=7200" />
  2. <meta http-equiv="Expires" content="Mon, 20 Aug 2010 23:00:00 GMT" />

协商缓存:服务器资源未修改,使用浏览器的缓存(304);反之,使用服务器资源(200)。

  1. <meta http-equiv="cache-control" content="no-cache">

APP缓存

原生APP中把数据缓存在内存、文件或本地数据库(SQLite)中。比如图片文件。

网络端缓存

通过代理的方式响应客户端请求,对重复的请求返回缓存中的数据资源。

Web代理缓存

可以缓存原生服务器的静态资源,比如样式、图片等。常见的反向代理服务器比如大名鼎鼎的Nginx

Redis笔记 - 图3

边缘缓存

边缘缓存中典型的商业化服务就是CDN了。

CDN的全称是Content Delivery Network,即内容分发网络。

CDN通过部署在各地的边缘服务器,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度 和命中率。

CDN的关键技术主要有内容存储和分发技术。现在一般的公有云服务商都提供CDN服务

服务端缓存

服务器端缓存是整个缓存体系的核心。包括数据库级缓存、平台级缓存和应用级缓存。

数据库缓存

数据库是用来存储和管理数据的。

MySQL在Server层使用查询缓存机制。将查询后的数据缓存起来。

K-V结构,Key:select语句的hash值,Value:查询结果

InnoDB存储引擎中的buffer-pool用于缓存InnoDB索引及数据块。

平台级缓存

平台级缓存指的是带有缓存特性的应用框架。 比如:GuavaCache 、EhCache(二级缓存)、OSCache(页面缓存)等。

部署在应用服务器上,也称为服务器本地缓存。

应用级缓存(重点)

具有缓存功能的中间件:Redis、Memcached、EVCache(AWS)、Tair(阿里,美团)等。 采用K-V形式存储。 利用集群支持高可用、高性能、高并发、高扩展。 分布式缓存。

2.4 缓存的优势与代价

  • 使用缓存的优势
    提升用户体验 用户体验(User Experience):用户在使用产品过程中建立起来的一种纯主观感受。 缓存的使用可以提升系统的响应能力,大大提升了用户体验。
    减轻服务器压力 客户端缓存、网络端缓存减轻应用服务器压力。 服务端缓存减轻数据库服务器的压力。
    提升系统性能性能指标:响应时间、延迟时间、吞吐量、并发用户数和资源利用率等。
    缓存技术可以: 缩短系统的响应时间 减少网络传输时间和应用延迟时间 提高系统的吞吐量 增加系统的并发用户数 提高了数据库资源的利用率

  • 使用缓存的代价
    额外的硬件支出 缓存是一种软件系统中以空间换时间的技术 需要额外的磁盘空间和内存空间来存储数据 搭建缓存服务器集群需要额外的服务器 采用云服务器的缓存服务就不用额外的服务器了 阿里云,百度云,提供缓存服务
    高并发缓存失效 在高并发场景下会出现缓存失效(缓存穿透、缓存雪崩、缓存击穿) 造成瞬间数据库访问量增大,甚至崩溃
    缓存与数据库数据同步 缓存与数据库无法做到数据的时时同步 Redis无法做到主从时时数据同步
    缓存并发竞争 多个redis的客户端同时对一个key进行set值得时候由于执行顺序引起的并发问题

2.5 缓存的读写模式

1 Cache Aside Pattern(旁路缓存),是最经典的缓存+数据库读写模式。

读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。

Redis笔记 - 图4

更新的时候,先更新数据库,然后再删除缓存。

Redis笔记 - 图5

为什么是删除缓存,而不是更新缓存呢?

1、缓存的值是一个结构:hash、list,更新数据需要遍历

2、懒加载,使用的时候才更新缓存 也可以采用异步的方式填充缓存

高并发脏读的三种情况

1、先更新数据库,再更新缓存

update与commit之间更新缓存,更新缓存,commit失效。会导致缓存与DB数据不一致

2,先删除缓存,再更新数据库

update与commit之间,有新的读,缓存空,先读DB数据到缓存,数据还是旧的数据。commit之后才是新数据。

缓存与DB数据不一致。

3,先更新数据再删除缓存。

update与commit之间,有新的读,缓存空,先读DB数据到缓存,数据还是旧的数据。commit之后才是新数据。

缓存与DB数据不一致。、

这个可以从用延迟双删策略。

2 Read/Write Through Pattern

应用程序只操作缓存,缓存操作数据库。

  • Read-Through(穿透读模式/直读模式):
    应用程序读缓存,缓存没有,由缓存回源到数据库,并写入缓存。 (GuavaCache)

  • Write-Through(穿透写模式/直写模式):应用程序写缓存,缓存写数据库。 该种模式需要提供数据库的handler,开发较为复杂。

3 Write Behind Caching Pattern

  1. 应用程序只更新缓存。 缓存通过异步的方式将数据批量或合并后更新到DB 不能时时同步,甚至会丢数据

2.6 缓存的实际思路

  • 多层次
    Redis笔记 - 图6

分布式缓存宕机,本地缓存还可以使用

  • 数据类型
    简单数据类型 Value是字符串或整数 Value的值比较大(大于100K) 只进行setter和getter 可采用Memcached Memcached纯内存缓存,多线程
    复杂数据类型 Value是hash、set、list、zset 需要存储关系,聚合,计算 可采用Redis

  • 要做集群
    分布式缓存集群方案(Redis) 哨兵+主从 ,RedisCluster

  • 缓存的数据结构设计
    1、与数据库表一致 数据库表和缓存是一一对应的 缓存的字段会比数据库表少一些 缓存的数据是经常访问的 用户表,商品表
    2、与数据库表不一致 需要存储关系,聚合,计算等 比如某个用户的帖子、用户的评论。

拉勾缓存设计:

Redis笔记 - 图7

nginx静态缓存配置:

  1. #要缓存文件的后缀,可以在以下设置。
  2. location ~* \.(gif|jpg|png|css|js)$ {
  3. proxy_pass http://ip地址:90;
  4. proxy_redirect off;
  5. proxy_set_header Host $host;
  6. proxy_cache cache_one;
  7. proxy_cache_valid 200 302 24h;
  8. proxy_cache_valid 301 30d;
  9. proxy_cache_valid any 5m;
  10. expires 90d;
  11. add_header wall "hello lagou.";
  12. }

3 缓存原理

掌握Redis五种基本数据类型的用法和常见命令的使用

了解bitmap、geo、stream的使用

理解Redis底层数据结构(Hash、跳跃表、quicklist)

了解RedisDB和RedisObject

理解LRU算法

理解Redis缓存淘汰策略

能够较正确的应用Redis缓存淘汰策略

3.1 数据类型

Redis是一个Key-Value的存储系统,使用ANSI C语言编写。

key的类型是字符串。

value的数据类型有: 常用的:string字符串类型、list列表类型、set集合类型、sortedset(zset)有序集合类型、hash类 型。

不常见的:bitmap位图类型、geo地理位置类型。

Redis5.0新增一种:stream类型

注意:Redis中命令是忽略大小写,(set SET),key是不忽略大小写的 (NAME name)

Key设计

  1. 用:分割
  2. 把表名转换为key前缀, 比如: user:
  3. 第二段放置主键值
  4. 第三段放置列名

比如:用户表user, 转换为redis的key-value存储

userid username pwd email
5 zhangsan 123435 12313@qq.com

key: user:userid:username

  1. user:5:zhangsan
  2. {userid:5,username:张三,pwd:12345,emai:12313@qq.com}

String 字符串类型

Redis的String能表达3种值的类型:字符串、整数、浮点数

命令名称 命令描述
set set key value 赋值
get get key 取值
getset getset key value 取值并赋值
setnx setnx key value 当value不存在时采用赋值 set key value NX PX 3000 原子操作,px 设置毫秒数
append append key value 向尾部追加值
strlen strlen key 获取字符串长度
incr incr key 递增数字
incrby incrby key increment 增加指定的整数
decr decr key 递减数字
decrby decrby key decrement 减少指定的整数

incr用于乐观锁 incr:递增数字,可用于实现乐观锁 watch(事务)

setnx用于分布式锁 当value不存在时采用赋值,可用于实现分布式锁

List列表类型

list列表类型可以存储有序、可重复的元素

获取头部或尾部附近的记录是极快的

list的元素个数最多为2^32-1个(40亿)

命令名称 命令格式 描述
lpush lpush key v1 v2 v3 … 从左侧插入列表
lpop lpop key 从列表左侧取出
rpush rpush key v1 v2 v3 … 从右侧插入列表
rpop rpop key 从列表右侧取出
lpushx lpushx key value 将值插入到列表头部
rpushx rpushx key value 将值插入到列表尾部
blpop blpop key timeout 从列表左侧取出,当列表为空时阻塞,可以设置最大阻塞时间,单位为秒
命令名称 命令格式 描述
brpop blpop key timeout 从列表右侧取出,当列表为空时阻塞,可以设置最大阻塞时间,单位为秒
llen llen key 获得列表中元素个数
lindex lindex key index 获得列表中下标为index的元素 index从0开始
lrange lrange key start end 返回列表中指定区间的元素,区间通过start和end指定
lrem lrem key count value 删除列表中与value相等的元素当count>0时, lrem会从列表左边开始删除;当count<0时, lrem会从列表后边开始删除;当count=0时, lrem删除所有值为value的元素
lset lset key index value 将列表index位置的元素设置成value的值 从0开始
ltrim ltrim key start end 对列表进行修剪,只保留start到end区间
rpoplpush rpoplpush key1 key2 从key1列表右侧弹出并插入到key2列表左侧
brpoplpush brpoplpush key1 key2 从key1列表右侧弹出并插入到key2列表左侧,会阻塞
linsert linsert key BEFORE/AFTER pivot value 将value插入到列表,且位于值pivot之前或之后

1、 作为栈或队列使用

列表有序可以作为栈和队列使用

2、 可用于各种列表,比如用户列表、商品列表、评论列表等。

set集合类型

Set:无序、唯一元素

集合中最大的成员数为 2^32 - 1

命令名称 命令格式 描述
sadd sadd key mem1 mem2 …. 为集合添加新成员
srem srem key mem1 mem2 …. 删除集合中指定成员
smembers smembers key 获得集合中所有元素
spop spop key 返回集合中一个随机元素,并将该元素删除
srandmember srandmember key 返回集合中一个随机元素,不会删除该元素
scard scard key 获得集合中元素的数量
sismember sismember key member 判断元素是否在集合内
sinter sinter key1 key2 key3 求多集合的交集
sdiff sdiff key1 key2 key3 求多集合的差集
sunion sunion key1 key2 key3 求多集合的并集

适用于不能重复的且不需要顺序的数据结构

关注的用户,还可以通过spop进行随机抽奖

sortedset有序集合类型

SortedSet(ZSet) 有序集合: 元素本身是无序不重复的每个元素关联一个分数(score) 可按分数排序

命令名称 命令格式 描述
zadd zadd key score1 member1 score2 member2 … 为有序集合添加新成员
zrem zrem key mem1 mem2 …. 删除有序集合中指定成员
zcard zcard key 获得有序集合中的元素数量
zcount zcount key min max 返回集合中score值在[min,max]区间的元素数量
zincrby zincrby key increment member 在集合的member分值上加increment
zscore zscore key member 获得集合中member的分值
zrank zrank key member 获得集合中member的排名(按分值从小到大)
zrevrank zrevrank key member 获得集合中member的排名(按分值从 大到小)
zrange zrange key start end 获得集合中指定区间成员,按分数递增排序
zrevrange zrevrange key start end 获得集合中指定区间成员,按分数递减排序

start 从0开始 end是以0为基数的,2代表取前三。-1是到最后。

由于可以按照分值排序,所以适用于各种排行榜。比如:点击排行榜、销量排行榜、关注排行榜等。

hash类型(散列表)

Redis hash 是一个 string 类型的 field 和 value 的映射表,它提供了字段和字段值的映射。

每个 hash 可以存储 2^32 - 1 键值对(40多亿)。

可以将表明和id组成key 然后各字段为hashkey 字段值为hash value

Redis笔记 - 图8

命令名称 命令格式 描述
hset hset key field value 赋值,不区别新增或修改
hmset hmset field1 value1 field2 value2 批量赋值
hsetnx hsetnx key field value 赋值,如果filed存在则不操作
hexists hexists key filed 查看某个field是否存在
hget hget key field 获取一个字段值
hmget hmget key field1 field2 … 获取多个字段值
hgetall hgetall key
hdel hdel key field1 field2… 删除指定字段
hincrby hincrby key field increment 指定字段自增increment
hlen hlen key 获得字段数量

对象的存储 ,表数据的映射举

  1. hmset user:001 username zhangfei password 123455 gender man age 25
  2. hmset user:002 username lisi password 237689 gender man age 28
  1. 127.0.0.1:6379> hgetall user:001
  2. 1) "username"
  3. 2) "zhangfei"
  4. 3) "password"
  5. 4) "123455"
  6. 5) "gender"
  7. 6) "boy"
  8. 7) "age"
  9. 8) "25"
  1. 127.0.0.1:6379> hmget user:002 username age
  2. 1) "lisi"
  3. 2) "28"

bitmap位图类型

bitmap是进行位操作的

通过一个bit位来表示某个元素对应的值或者状态,其中的key就是对应元素本身。

bitmap本身会极大的节省储存空间。

命令名称 命令格式 描述
setbit setbit key offset value 设置key在offset处的bit值(只能是0或者 1)。
getbit getbit key offset 获得key在offset处的bit值
bitcount bitcount key 获得key的bit位为1的个数
bitpos bitpos key value 返回第一个被设置为bit值的索引值
bitop bitop and[or/xor/not] destkey key [key …] 对多个key 进行逻辑运算后存入destkey 中

1、 用户每月签到,用户id为key , 日期作为偏移量 1表示签到

2、 统计活跃用户, 日期为key,用户id为偏移量 1表示活跃

查询用户在线状态, 日期为key,用户id为偏移量 1

  1. 127.0.0.1:6379> setbit user:sign:1000 20200101 1 #id为1000的用户20200101签到 (integer) 0 127.0.0.1:6379> setbit user:sign:1000 20200103 1 #id为1000的用户20200103签到 (integer) 0 127.0.0.1:6379> getbit user:sign:1000 20200101 #获得id为1000的用户20200101签到状态 1 表示签到 (integer) 1 127.0.0.1:6379> getbit user:sign:1000 20200102 #获得id为1000的用户20200102签到状态 0表示未签到 (integer) 0
  2. 127.0.0.1:6379> bitcount user:sign:1000 # 获得id为1000的用户签到次数 (integer) 2 127.0.0.1:6379> bitpos user:sign:1000 1 #id为1000的用户第一次签到的日期 (integer) 20200101
  3. 127.0.0.1:6379> setbit 20200201 1000 1 #20200201的1000号用户上线 (integer) 0 127.0.0.1:6379> setbit 20200202 1001 1 #20200202的1000号用户上线 (integer) 0 127.0.0.1:6379> setbit 20200201 1002 1 #20200201的1002号用户上线 (integer) 0 127.0.0.1:6379> bitcount 20200201 #20200201的上线用户有2个 (integer) 2 127.0.0.1:6379> bitop or desk1 20200201 20200202 #合并20200201的用户和20200202上线 了的用户 (integer) 126
  4. 127.0.0.1:6379> bitcount desk1 #统计20200201和20200202都上线的用 户个数 (integer) 3

geo

geo是Redis用来处理位置信息的。在Redis3.2中正式使用。主要是利用了Z阶曲线、Base32编码和 geohash算法

Z阶曲线

在x轴和y轴上将十进制数转化为二进制数,采用x轴和y轴对应的二进制数依次交叉后得到一个六位数编 码。把数字从小到大依次连起来的曲线称为Z阶曲线,Z阶曲线是把多维转换成一维的一种方法

Redis笔记 - 图9

Base32编码

Base32这种数据编码机制,主要用来把二进制数据编码成可见的字符串,其编码规则是:任意给定一 个二进制数据,以5个位(bit)为一组进行切分(base64以6个位(bit)为一组),对切分而成的每个组进行编 码得到1个可见字符。Base32编码表字符集中的字符总数为32个(0-9、b-z去掉a、i、l、o),这也是 Base32名字的由来。

Redis笔记 - 图10

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还原成坐标值就可以得到元素的原始坐 标。

命令名称 命令格式 描述
geoadd geoadd key 经度 纬度 成员名称1 经度1 纬度1 成员名称2 经度2 纬度 2 … 添加地理坐标
geohash geohash key 成员名称1 成员名称2… 返回标准的 geohash串
geopos geopos key 成员名称1 成员名称2… 返回成员经纬度
geodist geodist key 成员1 成员2 单位 计算成员间距离
georadiusbymember georadiusbymember key 成员 值单位 count 数 asc[desc] 根据成员查找附近的成员

1、 记录地理位置

2、 计算距离

3、查找”附近的人”

  1. 127.0.0.1:6379> geoadd user:addr 116.31 40.05 zhangf 116.38 39.88 zhaoyun 116.47 40.00 diaochan #添加用户地址 zhangf、zhaoyun、diaochan的经纬度 (integer) 3
  2. 127.0.0.1:6379> geohash user:addr zhangf diaochan #获得zhangf和diaochan的geohash码 1) "wx4eydyk5m0" 2) "wx4gd3fbgs0"
  3. 127.0.0.1:6379> geopos user:addr zhaoyun #获得zhaoyun的经纬度 1) 1) "116.38000041246414185" 2) "39.88000114172373145"
  4. 127.0.0.1:6379> geodist user:addr zhangf diaochan #计算zhangf到diaochan的距离,单 位是m "14718.6972"
  5. 127.0.0.1:6379> geodist user:addr zhangf diaochan km #计算zhangf到diaochan的距离, 单位是km "14.7187"
  6. 127.0.0.1:6379> geodist user:addr zhangf zhaoyun km "19.8276"
  7. 127.0.0.1:6379> georadiusbymember user:addr zhangf 20 km withcoord withdist count 3 asc # 获得距离zhangf20km以内的按由近到远的顺序排出前三名的成员名称、距离及经纬度 #withcoord : 获得经纬度 withdist:获得距离 withhash:获得geohash码
  1. 127.0.0.1:6379> georadiusbymember user:addr zhangf 20 km withcoord withdist count 3
  2. 1) 1) "zhangf"
  3. 2) "0.0000"
  4. 3) 1) "116.31000012159348"
  5. 2) "40.049999820438281"
  6. 2) 1) "diaochan"
  7. 2) "14.7187"
  8. 3) 1) "116.46999925374985"
  9. 2) "39.999999910849162"
  10. 3) 1) "zhaoyun"
  11. 2) "19.8276"
  12. 3) 1) "116.38000041246414"
  13. 2) "39.880001141723731"

Stream

stream是Redis5.0后新增的数据结构,用于可持久化的消息队列。

几乎满足了消息队列具备的全部内容,包括:

  • 消息ID的序列化生成
  • 消息遍历
  • 消息的阻塞和非阻塞读取
  • 消息的分组消费
  • 未完成消息的处理
  • 消息队列监控
  • 每个Stream都有唯一的名称,它就是Redis的key,首次使用 xadd 指令追加消息时自动创建。 | 命令名称 | 命令格式 | 描述 | | —- | —- | —- | | xadd | xadd key id <> field1 value1…. | 将指定消息数据追加到指定队列(key)中, 表示最新生成的id(当前时间+序列号) | | xread | xread [COUNT count] [BLOCK milliseconds] STREAMS key [key …] ID [ID …] | 从消息队列中读取,COUNT:读取条数, BLOCK:阻塞读(默认不阻塞)key:队列名称 id:消息id | | xrange | xrange key start end [COUNT] | 读取队列中给定ID范围的消息 COUNT:返回消息条数(消息id从小到大) | | xrevrange | xrevrange key start end [COUNT] | 读取队列中给定ID范围的消息 COUNT:返回消息条数(消息id从大到小) | | xdel | xdel key id | 删除队列的消息 | | xgroup | xgroup create key groupname id | 创建一个新的消费组 | | xgroup | xgroup destory key groupname | 删除指定消费组 | | xgroup | xgroup delconsumer key groupname cname | 删除指定消费组中的某个消费者 | | xgroup | xgroup setid key id | 修改指定消息的最大id | | xreadgroup | xreadgroup group groupname consumer COUNT streams key | 从队列中的消费组中创建消费者并消费数据(consumer不存在时则创建) |
  1. 127.0.0.1:6379> xadd topic:001 * name zhangfei age 23
  2. "1591151905088-0"
  3. 127.0.0.1:6379> xadd topic:001 * name zhaoyun age 24 name diaochan age 16
  4. "1591151912113-0"
  5. 127.0.0.1:6379> xrange topic:001 - +
  6. 1) 1) "1591151905088-0"
  7. 2) 1) "name"
  8. 2) "zhangfei"
  9. 3) "age"
  10. 4) "23"
  11. 2) 1) "1591151912113-0"
  12. 2) 1) "name"
  13. 2) "zhaoyun"
  14. 3) "age"
  15. 4) "24"
  16. 5) "name"
  17. 6) "diaochan"
  18. 7) "age"
  19. 8) "16"
  20. 127.0.0.1:6379> xread COUNT 1 streams topic:001 0
  21. 1) 1) "topic:001"
  22. 2) 1) 1) "1591151905088-0"
  23. 2) 1) "name"
  24. 2) "zhangfei"
  25. 3) "age"
  26. 4) "23"
  27. #创建的group1
  28. 127.0.0.1:6379> xgroup create topic:001 group1 0
  29. OK
  30. # 创建cus1加入到group1 消费 没有被消费过的消息 消费第一条
  31. 127.0.0.1:6379> xreadgroup group group1 cus1 count 1 streams topic:001 >
  32. 1) 1) "topic:001"
  33. 2) 1) 1) "1591151905088-0"
  34. 2) 1) "name"
  35. 2) "zhangfei"
  36. 3) "age"
  37. 4) "23"
  38. #继续消费 第二条
  39. 127.0.0.1:6379> xreadgroup group group1 cus1 count 1 streams topic:001 >
  40. 1) 1) "topic:001"
  41. 2) 1) 1) "1591151912113-0"
  42. 2) 1) "name"
  43. 2) "zhaoyun"
  44. 3) "age"
  45. 4) "24"
  46. 5) "name"
  47. 6) "diaochan"
  48. 7) "age"
  49. 8) "16"
  50. #没有可消费
  51. 127.0.0.1:6379> xreadgroup group group1 cus1 count 1 streams topic:001 >
  52. (nil)

3.2 底层数据结构

Redis笔记 - 图11

Redis没有表的概念,Redis实例所对应的db以编号区分,db本身就是key的命名空间。

Redis中存在“数据库”的概念,该结构由redis.h中的redisDb定义。

当redis 服务器初始化时,会预先分配 16 个数据库 所有数据库保存到结构 redisServer 的一个成员 redisServer.db 数组中

redisClient中存在一个名叫db的指针指向当前使用的数据库

RedisDB的结构体源码:

  1. typedef struct redisDb {
  2. int id; //id是数据库序号,为0-15(默认Redis有16个数据库)
  3. long avg_ttl; //存储的数据库对象的平均ttl(time to live),用于统计
  4. dict *dict; //存储数据库所有的key-value
  5. dict *expires; //存储key的过期时间
  6. dict *blocking_keys;//blpop 存储阻塞key和客户端对象
  7. dict *ready_keys;//阻塞后push 响应阻塞客户端 存储阻塞后push的key和客户端对象
  8. dict *watched_keys;//存储watch监控的的key和客户端对象
  9. } redisDb;

RedisObject结构:

Value是一个对象 包含字符串对象(位图用的是string),列表对象,哈希对象,集合对象和有序集合对象(地理坐标类型位zset)

  1. typedef struct redisObject {
  2. unsigned type:4;//类型 五种对象类型
  3. unsigned encoding:4;//编码
  4. void *ptr;//指向底层实现数据结构的指针
  5. //...
  6. int refcount;//引用计数
  7. //...
  8. unsigned lru:LRU_BITS; //LRU_BITS为24bit 记录最后一次被命令程序访问的时间
  9. //...
  10. }robj;

4位type type 字段表示对象的类型,占 4 位; REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有 序集合)。

当我们执行 type 命令时,便是通过读取 RedisObject 的 type 字段获得对象的类型

  1. 127.0.0.1:6379> set k1 "dsf"
  2. OK
  3. 127.0.0.1:6379> object encoding k1
  4. "embstr"
  5. 127.0.0.1:6379> type k1
  6. string
  7. 127.0.0.1:6379>

4位encoding

encoding 表示对象的内部编码,占 4 位 每个对象有不同的实现编码 Redis 可以根据不同的使用场景来为对象设置不同的编码,大大提高了 Redis 的灵活性和效率。

通过 object encoding 命令,可以查看对象采用的编码方式

24位LRU

lru 记录的是对象最后一次被命令程序访问的时间,( 4.0 版本占 24 位,2.6 版本占 22 位)。 高16位存储一个分钟数级别的时间戳,低8位存储访问计数(lfu : 最近访问次数)

lru使用的是高16位:最后访问的时间

lfu使用的是低八位:最近访问次数

24+4+4=32位=4个字节

refcount

refcount 记录的是该对象被引用的次数,类型为整型。 refcount 的作用,主要在于对象的引用计数和内存回收。

当对象的refcount>1时,称为共享对象 Redis 为了节省内存,当有一些对象重复出现时,新的程序不会创建新的对象,而是仍然使用原来的对 象。

ptr

ptr 指针指向具体的数据,比如:set hello world,ptr 指向包含字符串 world 的 SDS。

3.3 7种type

字符串对象

C语言: 字符数组 末尾为”\0”表示结束

Redis 使用了 SDS(Simple Dynamic String)。用于存储字符串和整型数据。有一个sds.h文件

Redis笔记 - 图12

  1. struct sdshdr{
  2. //记录buf数组中已使用字节的数量
  3. int len;
  4. //记录 buf 数组中未使用字节的数量
  5. int free;
  6. //字节数组,用于保存字符串
  7. char buf[];
  8. }

如上 buff={‘R’,’E’,’D’,’I’,’S’,’\0’};

buf[] 长度=len+free+1

SDS的优势:

1、SDS 在 C 字符串的基础上加入了 free 和 len 字段,获取字符串长度:SDS 是 O(1),C 字符串是 O(n)。

buf数组的长度=free+len+1

2、 SDS 由于记录了长度,在可能造成缓冲区溢出时会自动重新分配内存,杜绝了缓冲区溢出。

3、可以存取二进制数据,以字符串长度len来作为结束标识

  1. C语言 二进制数据包括空字符串 '\0' 所以没办法存取二进制数据
  2. SDS数据结构:如果为非二进制数据,以 \0 结束标识,如果为二级制数据,最后以 字符串长度结尾
  3. 所以SDS可以存取二进制数据

使用场景: SDS的主要应用在:存储字符串和整型数据、存储key、AOF缓冲区和用户输入缓冲。

跳跃表(重点)

跳跃表可以参考这个文章:【https://www.jianshu.com/p/ee7352dac7bb】【https://zhuanlan.zhihu.com/p/68516038】

在learn项目算法项目有SkipList的java实现

跳跃表是有序集合(sorted-set)的底层实现,效率高,实现简单。

跳跃表的基本思想: 将有序链表中的部分节点分层,每一层都是一个有序链表。

redis跳跃表的实现

  1. //跳跃表节点
  2. typedef struct zskiplistNode {
  3. sds ele; /* 存储字符串类型数据 redis3.0版本中使用robj类型表示,
  4. 但是在redis4.0.1中直接使用sds类型表示 */
  5. double score;//存储排序的分值
  6. struct zskiplistNode *backward;//后退指针,指向当前节点最底层的前一个节点
  7. /*
  8. 层,柔性数组,随机生成1-64的值
  9. */
  10. struct zskiplistLevel {
  11. struct zskiplistNode *forward; //指向本层下一个节点
  12. unsigned int span;//本层下个节点到本节点的元素个数
  13. } level[];
  14. } zskiplistNode;
  15. //链表
  16. typedef struct zskiplist{
  17. //表头节点和表尾节点
  18. structz skiplistNode *header, *tail;
  19. //表中节点的数量
  20. unsigned long length;
  21. //表中层数最大的节点的层数
  22. int level;
  23. }zskiplist;

java实现

  1. import java.util.Random;
  2. public class SkipList2 {
  3. private int level;
  4. private int MAX_LEVEL = 1 << 5;
  5. //顶层第一个节点
  6. //每一层 的第一个节点都为 Integer.MIN_VALUE
  7. private SkipNode top;
  8. private int MIN = Integer.MIN_VALUE;
  9. private Random random = new Random();
  10. public SkipList2(int level) {
  11. this.level = level;
  12. SkipNode temp = null, prev = null;
  13. for (int i = 1; i <= this.level; i++) {
  14. temp = new SkipNode(MIN, "\n");
  15. temp.down = prev;
  16. temp.level = i;
  17. prev = temp;
  18. }
  19. top = temp;
  20. }
  21. public SkipList2() {
  22. //默认层数为4 包含原始数据层
  23. this(4);
  24. }
  25. class SkipNode {
  26. String value;
  27. int score;
  28. //下一个节点
  29. SkipNode right;
  30. //下一层value对应的节点
  31. SkipNode down;
  32. //层数
  33. int level;
  34. public SkipNode(int score, String value) {
  35. this.value = value;
  36. this.score = score;
  37. }
  38. }
  39. public static void main(String[] args) {
  40. SkipList2 skipList2 = new SkipList2();
  41. skipList2.put(1, "A");
  42. System.out.println(skipList2);
  43. skipList2.put(2, "B");
  44. System.out.println(skipList2);
  45. skipList2.put(3, "C");
  46. System.out.println(skipList2);
  47. skipList2.put(4, "D");
  48. System.out.println(skipList2);
  49. skipList2.put(5, "E");
  50. System.out.println(skipList2);
  51. skipList2.put(6, "F");
  52. System.out.println(skipList2);
  53. System.out.println(skipList2.find(5));
  54. }
  55. public void put(int score, String value) {
  56. //首先查看是否存在
  57. SkipNode findNode = findNode(score);
  58. //如果存在,则更新
  59. if (findNode != null) {
  60. SkipNode cur = findNode;
  61. while (cur != null) {
  62. cur.value = value;
  63. cur = cur.down;
  64. }
  65. return;
  66. }
  67. //抛硬币方式获取插入层数
  68. int newLevel = getRandomLevel();
  69. SkipNode temp;
  70. //这里相当于扩容 不插入真实数据
  71. for (int i = newLevel; i > level; i--) {
  72. temp = new SkipNode(MIN, null);
  73. temp.level = level + 1;
  74. temp.down = top;
  75. top = temp;
  76. }
  77. //找出开始插入数据的头部
  78. SkipNode cur = top;
  79. if (newLevel > level) {
  80. level = newLevel;
  81. } else {
  82. while (cur.level > newLevel) {
  83. cur = cur.down;
  84. }
  85. }
  86. //因为数据是从小到大的,所以找出尾部,然后插入数据即可
  87. SkipNode preNew = null;
  88. while (cur != null) {
  89. while (cur.right != null) {
  90. cur = cur.right;
  91. }
  92. temp = new SkipNode(score, value);
  93. cur.right = temp;
  94. if (preNew != null) {
  95. preNew.down = temp;
  96. }
  97. preNew = temp;
  98. cur = cur.down;
  99. }
  100. }
  101. private int getRandomLevel() {
  102. //return level + 1;
  103. int level = 1;
  104. while (random.nextInt() % 2 == 0) {
  105. level++;
  106. }
  107. return level > MAX_LEVEL ? MAX_LEVEL : level;
  108. }
  109. public String find(int score) {
  110. SkipNode node = findNode(score);
  111. if (node != null) {
  112. return node.value;
  113. }
  114. return null;
  115. }
  116. private SkipNode findNode(int score) {
  117. SkipNode t = top;
  118. while (t != null) {
  119. if (t.score == score) {
  120. return t;
  121. }
  122. if (t.score > score || t.right == null || t.right.score > score) {
  123. t = t.down;
  124. } else {
  125. t = t.right;
  126. }
  127. }
  128. return null;
  129. }
  130. @Override
  131. public String toString() {
  132. StringBuilder sb = new StringBuilder();
  133. SkipNode t = top, next = null;
  134. while (t != null) {
  135. next = t;
  136. while (next != null) {
  137. sb.append(next.score + " ");
  138. next = next.right;
  139. }
  140. sb.append("\n");
  141. t = t.down;
  142. }
  143. return sb.toString();
  144. }
  145. }

字典(重点+难点)

字典dict又称散列表(hash),是用来存储键值对的一种数据结构。

Redis整个数据库是用字典来存储的。

(K-V结构) 对Redis进行CURD操作其实就是对字典中的数据进行CURD操作。

数组

数组:用来存储数据的容器,采用头指针+偏移量的方式能够以O(1)的时间复杂度定位到数据所在的内 存地址。

Hash函数

Hash(散列),作用是把任意长度的输入通过散列算法转换成固定类型、固定长度的散列值。

hash函数可以把Redis里的key:包括字符串、整数、浮点数统一转换成整数。

key=100.1 String “100.1” 5位长度的字符串

Redis-cli :times 33

Redis-Server : siphash

Hash冲突

不同的key经过计算后出现数组下标一致,称为Hash冲突。 、

采用单链表在相同的下标位置处存储原始key和value 当根据key找Value时,找到数组下标,遍历单链表可以找出key相同的value

其实就相当于HashMap的实现方式 数组+链表(只是HashMap 当链表长度大于8的时候会转成红黑树而已)

Redis字典的实现

Redis字典实现包括:字典(dict)、Hash表(dictht)、Hash表节点(dictEntry)。

Redis笔记 - 图13

Hash表

  1. typedef struct dictht {
  2. dictEntry **table; // 哈希表数组
  3. unsigned long size; // 哈希表数组的大小
  4. unsigned long sizemask; // 用于映射位置的掩码,值永远等于(size-1)
  5. unsigned long used; // 哈希表已有节点的数量,包含next单链表数据
  6. } dictht;

1、hash表的数组初始容量为4,随着k-v存储量的增加需要对hash表数组进行扩容,新扩容量为当前量 的一倍,即4,8,16,32

2、索引值=Hash值&掩码值(Hash值与Hash表容量取余)

Hash表节点

  1. typedef struct dictEntry {
  2. void *key; // 键
  3. union { // 值v的类型可以是以下4种类型
  4. void *val;
  5. uint64_t u64;
  6. int64_t s64;
  7. double d;
  8. } v;
  9. struct dictEntry *next; // 指向下一个哈希表节点,形成单向链表 解决hash冲突
  10. } dictEntry;

key字段存储的是键值对中的键

v字段是个联合体,存储的是键值对中的值。

next指向下一个哈希表节点,用于解决hash冲突

Redis笔记 - 图14

dict字典:

  1. typedef struct dict {
  2. dictType *type; // 该字典对应的特定操作函数
  3. void *privdata; // 上述类型函数对应的可选参数
  4. dictht ht[2]; /* 两张哈希表,存储键值对数据,ht[0]为原生哈希表,
  5. ht[1]为 rehash 哈希表 */
  6. long rehashidx; /*rehash标识 当等于-1时表示没有在rehash,否则表示正在进行rehash操作,存储的值表
  7. 示hash表 ht[0]的rehash进行到哪个索引值(数组下标) 缩容扩容的时候需要rehash。等rehash结束后可以交换位置*/
  8. int iterators; // 当前运行的迭代器数量
  9. } dict;

type字段,指向dictType结构体,里边包括了对该字典操作的函数指针

  1. typedef struct dictType {
  2. // 计算哈希值的函数
  3. unsigned int (*hashFunction)(const void *key);
  4. // 复制键的函数
  5. void *(*keyDup)(void *privdata, const void *key);
  6. // 复制值的函数
  7. void *(*valDup)(void *privdata, const void *obj);
  8. // 比较键的函数
  9. int (*keyCompare)(void *privdata, const void *key1, const void *key2);
  10. // 销毁键的函数
  11. void (*keyDestructor)(void *privdata, void *key);
  12. // 销毁值的函数
  13. void (*valDestructor)(void *privdata, void *obj);
  14. } dictType;

Redis字典除了主数据库的K-V数据存储以外,还可以用于:散列表对象、哨兵模式中的主从节点管理等 在不同的应用中,字典的形态都可能不同,dictType是为了实现各种形态的字典而抽象出来的操作函数 (多态)。

完整的Redis字典数据结构:

Redis笔记 - 图15

字典扩容:

字典达到存储上限,需要rehash(扩容) 博客【https://blog.csdn.net/yuanrxdu/article/details/24779693】

扩容流程 申请新内存=>地址赋给h[1]=>rehashidx=0=>h[1]=h[0](拷贝,这个不是简单的拷贝需要重新计算hash值)

说明:

  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。

渐进式rehash

对比:java中的hashmap,当数据数量达到阈值的时候(0.75),就会发生rehash,hash表长度变为原来的二倍,将原hash表数据全部重新计算hash地址,重新分配位置,达到rehash目的

redis中的hash表采用的是渐进式hash的方式:

1、redis字典(hash表)底层有两个数组,还有一个rehashidx用来控制rehash

Redis笔记 - 图16

2、初始默认hash长度为4,当元素个数与hash表长度一致时,就发生扩容,hash长度变为原来的二倍

Redis笔记 - 图17

3、redis中的hash则是执行的单步rehash的过程:

Redis笔记 - 图18

每次的增删改查,rehashidx+1,然后执行对应原hash表rehashidx索引位置的rehash

总结:

在扩容和收缩的时候,如果哈希字典中有很多元素,一次性将这些键全部rehash到ht[1]的话,可能会导致服务器在一段时间内停止服务。所以,采用渐进式rehash的方式,详细步骤如下:

  1. ht[1]分配空间,让字典同时持有ht[0]ht[1]两个哈希表
  2. rehashindex的值设置为0,表示rehash工作正式开始
  3. 在rehash期间,每次对字典执行增删改查操作是,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashindex索引上的所有键值对rehash到ht[1],当rehash工作完成以后,rehashindex的值+1
  4. 随着字典操作的不断执行,最终会在某一时间段上ht[0]的所有键值对都会被rehash到ht[1],这时将rehashindex的值设置为-1,表示rehash操作结束

渐进式rehash采用的是一种分而治之的方式,将rehash的操作分摊在每一个的访问中,避免集中式rehash而带来的庞大计算量。

因为在进行渐进式 rehash 的过程中, 字典会同时使用 ht[0]ht[1] 两个哈希表, 所以在渐进式 rehash 进行期间, 字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行: 比如说, 要在字典里面查找一个键的话, 程序会先在 ht[0] 里面进行查找, 如果没找到的话, 就会继续到 ht[1] 里面进行查找, 诸如此类。

  1. 其实删除、查找、更新的时候去计算keyhash值,如果hash值小于rehashidx,就应该对ht[1]进行操作。

另外, 在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到 ht[1] 里面, 而 ht[0] 则不再进行任何添加操作: 这一措施保证了 ht[0] 包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表。

应用场景: 1、主数据库的K-V数据存储 2、散列表对象(hash) 3、哨兵模式中的主从节点管理

压缩列表

压缩列表(ziplist)是由一系列特殊编码的连续内存块组成的顺序型数据结构,节省内存

是一个字节数组,可以包含多个节点(entry)。每个节点可以保存一个字节数组或一个整数。

数据结构:

Redis笔记 - 图19

zlbytes:压缩列表的字节长度

zltail:压缩列表尾元素相对于压缩列表起始地址的偏移量

zllen:压缩列表的元素个数

entry1..entryX : 压缩列表的各个节点 entryX元素的编码结构

Redis笔记 - 图20

zlend:压缩列表的结尾,占一个字节,恒为0xFF(255)

previous_entry_length:前一个元素的字节长度

encoding:表示当前元素的编码

content:数据内容

ziplist结构体:

ziplist.c中

  1. typedef struct zlentry {
  2. unsigned int prevrawlensize; //previous_entry_length字段的长度
  3. unsigned int prevrawlen; //previous_entry_length字段存储的内容
  4. unsigned int lensize; //encoding字段的长度
  5. unsigned int len; //数据内容长度
  6. unsigned int headersize; //当前元素的首部长度,即previous_entry_length字段长度与 encoding字段长度之和。
  7. unsigned char encoding; //数据类型
  8. unsigned char *p; //当前元素首地址
  9. } zlentry;

应用场景:

sorted-set和hash元素个数少且是小整数或短字符串(直接使用)

比如:

  1. 127.0.0.1:6379> hmset h1 k1 1 k2 1 k3 3
  2. 127.0.0.1:6379> object encoding h1
  3. "ziplist"

list用快速链表(quicklist)数据结构存储,而快速链表是双向列表与压缩列表的组合。(间接使用)

整数集合

整数集合(intset)是一个有序的(整数升序)、存储整数的连续存储结构,

当Redis集合类型的元素都是整数并且都处在64位有符号整数范围内(2^64),使用该结构体存储。

  1. 127.0.0.1:6379> sadd set:001 1 3 5 6 2
  2. (integer) 5
  3. 127.0.0.1:6379> object encoding set:001
  4. "intset"
  5. 127.0.0.1:6379> sadd set:004 1 100000000000000000000000000 9999999999
  6. (integer) 3
  7. 127.0.0.1:6379> object encoding set:004
  8. "hashtable"

数据结构:

Redis笔记 - 图21

  1. typedef struct intset{
  2. //编码方式
  3. uint32_t encoding;
  4. //集合包含的元素数量
  5. uint32_t length;
  6. //保存元素的数组
  7. int8_t contents[];
  8. }intset;

应用场景: 可以保存类型为int16_t、int32_t 或者int64_t 的整数值,并且保证集合中不会出现重复元素。

快速列表(重要)

快速列表(quicklist)是Redis底层重要的数据结构。是列表(list)的底层实现。

(在Redis3.2之前,Redis采 用双向链表(adlist)和压缩列表(ziplist)实现。)

在Redis3.2以后结合adlist和ziplist的优势Redis设 计出了quicklist。

  1. 127.0.0.1:6379> lpush list1 14 45 13 9 10
  2. (integer) 5
  3. 127.0.0.1:6379> lpop list1
  4. "10"
  5. 127.0.0.1:6379> lrange list1 0 2
  6. 1) "9"
  7. 2) "13"
  8. 3) "45"
  9. 127.0.0.1:6379> object encoding list1
  10. "quicklist"

双向列表(adlist)

Redis笔记 - 图22

双向链表优势:

  1. 双向:链表具有前置节点和后置节点的引用,获取这两个节点时间复杂度都为O(1)。

  2. 普通链表(单链表):节点类保留下一节点的引用。链表类只保留头节点的引用,只能从头节点插 入删除

(redis 的list 可以lpush和rpush)

  1. 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问都是以 NULL 结 束。(环状,头指向尾 头的prev指向尾节点,尾节点next指向头节点)

  2. 带链表长度计数器:通过 len 属性获取链表长度的时间复杂度为 O(1)。

  3. 多态:链表节点使用 void* 指针来保存节点值,可以保存各种不同类型的值。

quicklist是一个双向链表,链表中的每个节点是一个ziplist结构。quicklist中的每个节点ziplist都能够存储多个数据元素

Redis笔记 - 图23

quicklist的结构定义如下:

  1. typedef struct quicklist {
  2. quicklistNode *head; // 指向quicklist的头部
  3. quicklistNode *tail; // 指向quicklist的尾部
  4. unsigned long count; // 列表中所有数据项的个数总和
  5. unsigned int len; // quicklist节点的个数,即ziplist的个数
  6. int fill : 16; // ziplist大小限定,由list-max-ziplist-size给定(Redis设定)
  7. unsigned int compress : 16; // 节点压缩深度设置,由list-compress-depth给定(Redis设定)
  8. } quicklist;
  1. # -5: max size: 64 Kb <-- not recommended for normal workloads
  2. # -4: max size: 32 Kb <-- not recommended
  3. # -3: max size: 16 Kb <-- probably not recommended
  4. # -2: max size: 8 Kb <-- good
  5. # -1: max size: 4 Kb <-- good
  6. list-max-ziplist-size -2
  7. # 0: disable all list compression
  8. # 1: depth 1 means "don't start compressing until after 1 node into the list,
  9. # going from either the head or tail"
  10. # So: [head]->node->node->...->node->[tail]
  11. # [head], [tail] will always be uncompressed; inner nodes will compress.
  12. # 2: [head]->[next]->node->node->...->node->[prev]->[tail]
  13. # 2 here means: don't compress head or head->next or tail->prev or tail,
  14. # but compress all nodes between them.
  15. # 3: [head]->[next]->[next]->node->node->...->node->[prev]->[prev]->[tail]
  16. # etc.
  17. list-compress-depth 0

quicklistNode的结构定义如下:

  1. typedef struct quicklistNode {
  2. struct quicklistNode *prev; // 指向上一个ziplist节点
  3. struct quicklistNode *next; // 指向下一个ziplist节点
  4. unsigned char *zl; // 数据指针,如果没有被压缩,就指向ziplist结构,反之指向 quicklistLZF结构
  5. unsigned int sz; // 表示指向ziplist结构的总长度(内存占用长度)
  6. unsigned int count : 16; // 表示ziplist中的数据项个数
  7. unsigned int encoding : 2; // 编码方式,1ziplist,2quicklistLZF
  8. unsigned int container : 2; // 预留字段,存放数据的方式,1NONE,2ziplist
  9. unsigned int recompress : 1; // 解压标记,当查看一个被压缩的数据时,需要暂时解压,标记此参数为 1,之后再重新进行压缩
  10. unsigned int attempted_compress : 1; // 测试相关
  11. unsigned int extra : 10; // 扩展字段,暂时没用
  12. } quicklistNode;

数据压缩

quicklist每个节点的实际数据存储结构为ziplist,这种结构的优势在于节省存储空间。为了进一步降低 ziplist的存储空间,还可以对ziplist进行压缩。Redis采用的压缩算法是LZF。

其基本思想是:数据与前面重复的记录重复位置及长度,不重复的记录原始数据。

压缩过后的数据可以分成多个片段,每个片段有两个部分:解释字段和数据字段。quicklistLZF的结构 体如下:

  1. typedef struct quicklistLZF {
  2. unsigned int sz; // LZF压缩后占用的字节数
  3. char compressed[]; // 柔性数组,指向数据部分
  4. } quicklistLZF;

应用场景

列表(List)的底层实现、发布与订阅、慢查询、监视器等功能。

流对象

stream主要由:消息、生产者、消费者和消费组构成。

Redis Stream的底层主要使用了listpack(紧凑列表)和Rax树(基数树)。

listpack:

listpack表示一个字符串列表的序列化,listpack可用于存储字符串或整数。用于存储stream的消息内容。

Redis笔记 - 图24

Rax树:

Rax 是一个有序字典树 (基数树 Radix Tree),按照 key 的字典序排列,支持快速地定位、插入和删除操 作。

Redis笔记 - 图25

Rax 被用在 Redis Stream 结构里面用于存储消息队列,在 Stream 里面消息 ID 的前缀是时间戳 + 序 号,这样的消息可以理解为时间序列消息。使用 Rax 结构 进行存储就可以快速地根据消息 ID 定位到具 体的消息,然后继续遍历指定消息之后的所有消息。

Redis笔记 - 图26

应用场景: stream的底层实现

3.4 8种 encoding

encoding 表示对象的内部编码,占 4 位。

Redis通过 encoding 属性为对象设置不同的编码 对于少的和小的数据

Redis采用小的和压缩的存储方式,体现Redis的灵活性 大大提高了 Redis 的存储量和执行效率

set

intset : 元素是64位以内的整数 2^(63) - 2^(63) -1 long的范围

hashtable:元素是64位以外的整数

  1. 127.0.0.1:6379> sadd set:001 1 3 5 6 2
  2. (integer) 5
  3. 127.0.0.1:6379> object encoding set:001
  4. "intset"
  5. 127.0.0.1:6379> sadd set:004 1 100000000000000000000000000 9999999999
  6. (integer) 3
  7. 127.0.0.1:6379> object encoding set:004
  8. "hashtable"

String

int、raw、embstr

int

REDIS_ENCODING_INT(int类型的整数)

  1. 127.0.0.1:6379> set n1 123
  2. OK
  3. 127.0.0.1:6379> object encoding n1
  4. "int"

embstr

REDIS_ENCODING_EMBSTR(编码的简单动态字符串)

  1. 127.0.0.1:6379> set name:001 zhangfei
  2. OK
  3. 127.0.0.1:6379> object encoding name:001
  4. "embstr"

raw

REDIS_ENCODING_RAW (简单动态字符串)

大字符串 长度大于44个字节 也就是45个字符的时候

  1. 127.0.0.1:6379> set address:001 hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh
  2. OK
  3. 127.0.0.1:6379> object encoding address:001
  4. "raw"

list

列表的编码是quicklist。

REDIS_ENCODING_QUICKLIST(快速列表)

  1. 127.0.0.1:6379> lpush list:001 1 2 5 4 3
  2. (integer) 5
  3. 127.0.0.1:6379> object encoding list:001
  4. "quicklist"

hash

散列的编码是字典和压缩列表

dict

REDIS_ENCODING_HT(字典) HashTable

当散列表元素的个数比较多或元素不是小整数或短字符串时。

  1. 127.0.0.1:6379> hmset user:003
  2. username111111111111111111111111111111111111111111111111111111111111111111111111
  3. 11111111111111111111111111111111 zhangfei password 111 num
  4. 2300000000000000000000000000000000000000000000000000
  5. OK
  6. 127.0.0.1:6379> object encoding user:003
  7. "hashtable"

zset

有序集合的编码是压缩列表和跳跃表+字典

ziplist

REDIS_ENCODING_ZIPLIST(压缩列表)

当散列表元素的个数比较少,且元素都是小整数或短字符串时。

  1. 127.0.0.1:6379> hmset user:001 username zhangfei password 111 age 23 sex M
  2. OK
  3. 127.0.0.1:6379> object encoding user:001
  4. "ziplist"
  1. 127.0.0.1:6379> zadd z1 100 hah 101 heihei 104 mm
  2. (integer) 3
  3. 127.0.0.1:6379> object encoding z1
  4. "ziplist"

可以看出 sorted set 不一定用的是跳跃表,当元素的个数比较少,且元素都是小整数或短字符串时。使用的就是ziplist

skiplist + dict

REDIS_ENCODING_SKIPLIST(跳跃表+字典)

当元素的个数比较多或元素不是小整数或短字符串时。

  1. 127.0.0.1:6379> zadd hit:2 100
  2. item1111111111111111111111111111111111111111111111111111111111111111111111111111
  3. 1111111111111111111111111111111111 20 item2 45 item3
  4. (integer) 3
  5. 127.0.0.1:6379> object encoding hit:2
  6. "skiplist"

intset hashtable int embstr raw quiklist ziplist skiplist 综合起来只有8种呀

  1. /* Objects encoding. Some kind of objects like Strings and Hashes can be
  2. * internally represented in multiple ways. The 'encoding' field of the object
  3. * is set to one of this fields for this object. */
  4. \#define OBJ_ENCODING_RAW 0 /* Raw representation */
  5. \#define OBJ_ENCODING_INT 1 /* Encoded as integer */
  6. \#define OBJ_ENCODING_HT 2 /* Encoded as hash table */
  7. \#define OBJ_ENCODING_ZIPMAP 3 /* Encoded as zipmap */ // 已废弃
  8. \#define OBJ_ENCODING_LINKEDLIST 4 /* Encoded as regular linked list */
  9. \#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
  10. \#define OBJ_ENCODING_INTSET 6 /* Encoded as intset */
  11. \#define OBJ_ENCODING_SKIPLIST 7 /* Encoded as skiplist */
  12. \#define OBJ_ENCODING_EMBSTR 8 /* Embedded sds string encoding */
  13. \#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */

这也是redis 性能比较高的原因之一,根据不同的数据调整为不同的数据结构。

3.5 缓存过期淘汰策略

redis的性能非常高,单机读写可以达到 110000次/s 81000次/s

因为redis是直接对内存进行操作,但是作为缓存层使用,key会不断增加,物理内存也会满。然后系统就会产生虚拟内存(swap,硬盘与内存交换),导致redis的性能急剧下降。

所以我们要避免这种情况。淘汰数据。

maxmemory-policy

  1. # volatile-lru -> remove the key with an expire set using an LRU algorithm
  2. # allkeys-lru -> remove any key according to the LRU algorithm
  3. # volatile-lfu -> Evict using approximated LFU, only keys with an expire set.
  4. # allkeys-lfu -> Evict any key using approximated LFU.
  5. # volatile-random -> remove a random key with an expire set
  6. # allkeys-random -> remove a random key, any key
  7. # volatile-ttl -> remove the key with the nearest expire time (minor TTL)
  8. # noeviction -> don't expire at all, just return an error on write operations

redis可以设置

maxmemory 来指定redis可以使用的最大内存,一般为物理内存的3/4。设置maxmemory的时候必须指定maxmemory-policy来指定缓存淘汰策略。作为缓存层使用的时候,最好指定maxmeory。

maxmemory 默认值为0,不限制。maxmemory-policy默认为noeviction,不淘汰数据。

Redis的key是固定的,不会增加;当作为DB使用的时候,保证数据的完整性,不能淘汰。可以不设置。

redis可以为key设置存活时间 ttl(time to live)。到了时间,就会自动删除。

expire原理

  1. typedef struct redisDb {
  2. dict *dict;
  3. dict *expires;
  4. dict *blocking_keys;
  5. dict *ready_keys;
  6. dict *watched_keys;
  7. int id;
  8. } redisDb;

dict 用来维护一个 Redis 数据库中包含的所有 Key-Value 键值对,expires则用于维护一个 Redis 数据 库中设置了失效时间的键(即key与失效时间的映射)。

当我们使用 expire命令设置一个key的失效时间时,Redis 首先到 dict 这个字典表中查找要设置的key是 否存在,如果存在就将这个key和失效时间添加到 expires 这个字典表。

当我们使用 setex命令向系统插入数据时,Redis 首先将 Key 和 Value 添加到 dict 这个字典表中,然后 将 Key 和失效时间添加到 expires 这个字典表中。

简单地总结来说就是,设置了失效时间的key和具体的失效时间全部都维护在 expires 这个字典表中。

删除策略:

Redis的数据删除有定时删除、惰性删除和主动删除三种方式。

Redis目前采用惰性删除+主动删除的方式。

  • 定时删除
    在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除 操作。 需要创建定时器,而且消耗CPU,一般不推荐使用。

  • 惰性删除
    在key被访问时如果发现它已经失效,那么就删除它。
    调用expireIfNeeded函数,该函数的意义是:读取数据之前先检查一下它有没有失效,如果失效了就删 除它

    1. int expireIfNeeded(redisDb *db, robj *key) {
    2. //获取主键的失效时间
    3. long long when = getExpire(db,key);
    4. //假如失效时间为负数,说明该主键未设置失效时间(失效时间默认为-1),直接返回0
    5. if (when < 0) return 0;
    6. //假如Redis服务器正在从RDB文件中加载数据,暂时不进行失效主键的删除,直接返回0
    7. if (server.loading) return 0;
    8. ...
    9. //如果以上条件都不满足,就将主键的失效时间与当前时间进行对比,如果发现指定的主键
    10. //还未失效就直接返回0
    11. if (mstime() <= when) return 0;
    12. //如果发现主键确实已经失效了,那么首先更新关于失效主键的统计个数,然后将该主键失
    13. //效的信息进行广播,最后将该主键从数据库中删除
    14. server.stat_expiredkeys++;
    15. propagateExpire(db,key);
    16. return dbDelete(db,key);
    17. }
  • 主动删除
    在redis.conf文件中可以配置主动删除策略,默认是noenviction(不删除)
    1. maxmemory-policy allkeys-lru

LRU

LRU (Least recently used) 最近最少使用,算法根据数据的历史访问记录来进行淘汰数据,其核心思想 是“如果数据最近被访问过,那么将来被访问的几率也更高”。LRU是淘汰一定时间没有被使用的数据。

最常见的实现是使用一个链表保存缓存数据,详细算法实现如下:

  1. 新数据插入到链表头部;
  2. 每当缓存命中(即缓存数据被访问),则将数据移到链表头部;
  3. 当链表满的时候,将链表尾部的数据丢弃。
  4. 在Java中可以使用LinkedHashMap(哈希链表)去实现LRU

以用户信息的需求为例,来演示一下LRU算法的基本思路:

1.假设我们使用哈希链表来缓存用户信息,目前缓存了4个用户,这4个用户是按照时间顺序依次从链表 右端插入的。

Redis笔记 - 图27

2.此时,业务方访问用户5,由于哈希链表中没有用户5的数据,我们从数据库中读取出来,插入到缓存 当中。这时候,链表中最右端是最新访问到的用户5,最左端是最近最少访问的用户1。

Redis笔记 - 图28

3.接下来,业务方访问用户2,哈希链表中存在用户2的数据,我们怎么做呢?我们把用户2从它的前驱节点和后继节点之间移除,重新插入到链表最右端。这时候,链表中最右端变成了最新访问到的用户 2,最左端仍然是最近最少访问的用户1。

Redis笔记 - 图29

4.接下来,业务方请求修改用户4的信息。同样道理,我们把用户4从原来的位置移动到链表最右侧,并 把用户信息的值更新。这时候,链表中最右端是最新访问到的用户4,最左端仍然是最近最少访问的用 户1。

Redis笔记 - 图30

5.业务访问用户6,用户6在缓存里没有,需要插入到哈希链表。假设这时候缓存容量已经达到上限,必须先删除最近最少访问的数据,那么位于哈希链表最左端的用户1就会被删除掉,然后再把用户6插入到 最右端。

Redis笔记 - 图31

  1. # volatile-lru -> remove the key with an expire set using an LRU algorithm
  2. # allkeys-lru -> remove any key according to the LRU algorithm
  3. # volatile-lfu -> Evict using approximated LFU, only keys with an expire set.
  4. # allkeys-lfu -> Evict any key using approximated LFU.
  5. # volatile-random -> remove a random key with an expire set
  6. # allkeys-random -> remove a random key, any key
  7. # volatile-ttl -> remove the key with the nearest expire time (minor TTL)
  8. # noeviction -> don't expire at all, just return an error on write operations

Redis的LRU 数据淘汰机制:

  • 在服务器配置中保存了 lru 计数器 server.lrulock,会定时(redis 定时程序 serverCorn())更新, server.lrulock 的值是根据 server.unixtime 计算出来的。
  • 另外,从 struct redisObject 中可以发现,每一个 redis 对象都会设置相应的 lru。可以想象的是,每一 次访问数据的时候,会更新 redisObject.lru。 lru一共24位,高16位保存最后访问时间 lru用,低8位保存最近访问次数 lfu用。
  • LRU 数据淘汰机制是这样的:在数据集中随机挑选几个键值对,取出其中 lru 最大的键值对淘汰。 不可能遍历key 用当前时间-最近访问越大 说明 访问间隔时间越长
  • volatile-lru 从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
  • allkeys-lru 从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰

LFU:

LFU (Least frequently used) 最不经常使用,如果一个数据在最近一段时间内使用次数很少,那么在将来一段时间内被使用的可能性也很小。LFU是淘汰一段时间内,使用次数最少的数据。

volatile-lfu

allkeys-lfu

Random:

volatile-random 从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰

allkeys-random 从数据集(server.db[i].dict)中任意选择数据淘汰

ttl:

volatile-ttl 从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰 redis 数据集数据结构中保存了键值对过期时间的表,即 redisDb.expires。 TTL 数据淘汰机制:从过期时间的表中随机挑选几个键值对,取出其中 ttl 最小的键值对淘汰。

noenviction

禁止驱逐数据,不删除 默认

缓存淘汰策略的选择

  • allkeys-lru : 在不确定时一般采用策略。 冷热数据交换。
  • volatile-lru :
  • allkeys-random : 希望请求符合平均分布(每个元素以相同的概率被访问)
  • 自己控制:volatile-ttl 缓存穿透

4 Redis通信协议

Redis是单进程单线程的。 应用系统和Redis通过Redis协议(RESP)进行交互。

4.1 请求响应模式

Redis协议位于TCP层之上,即客户端和Redis实例保持双工的连接。

Redis笔记 - 图32

  • 串行的请求响应模式(ping-pong)
    串行化是最简单模式,客户端与服务器端建立长连接
    连接通过心跳机制检测(ping-pong) ack应答
    客户端发送请求,服务端响应,客户端收到响应后,再发起第二个请求,服务器端再响应。

Redis笔记 - 图33

telnet和redis-cli 发出的命令 都属于该种模式

特点:

有问有答

耗时在网络传输命令

性能较低

  • 双工的请求响应模式(pipeline)
    批量请求,批量响应
    请求响应交叉进行,不会混淆(TCP双工)
    pipeline的作用是将一批命令进行打包,然后发送给服务器,服务器执行完按顺序打包返回。
    通过pipeline,一次pipeline(n条命令)=一次网络时间 + n次命令时间

Redis笔记 - 图34

jedis使用pipeline:

  1. Jedis redis = new Jedis("127.0.0.1", 6379);
  2. redis.auth("12345678");//授权密码 对应redis.conf的requirepass密码
  3. Pipeline pipe = jedis.pipelined();
  4. for (int i = 0; i <50000; i++) {
  5. pipe.set("key_"+String.valueOf(i),String.valueOf(i));
  6. }
  7. //将封装后的PIPE一次性发给redis
  8. pipe.sync();
  • 原子化的批量请求响应模式(事务)

  • 脚本化的批量执行(lua)

  • 发布订阅模式(pub/sub)
    发布订阅模式是:一个客户端触发,多个客户端被动接收,通过服务器中转。后面会详细讲解。

4.2 请求数据格式

Redis客户端与服务器交互采用序列化协议(RESP)。

请求以字符串数组的形式来表示要执行命令的参数 Redis使用命令特有(command-specific)数据类型作为回复。

Redis通信协议的主要特点有: 客户端和服务器通过 TCP 连接来进行数据交互, 服务器默认的端口号为 6379 。

客户端和服务器发送的命令或数据一律以 \r\n (CRLF)结尾。

在这个协议中, 所有发送至 Redis 服务器的参数都是二进制安全(binary safe)的。 简单,高效,易读。

  • 内联格式
    可以使用telnet给Redis发送命令,首字符为Redis命令名的字符,格式为 str1 str2 str3…
    1. [root@localhost bin]# telnet 127.0.0.1 6379
    2. Trying 127.0.0.1...
    3. Connected to 127.0.0.1.
    4. Escape character is '^]'.
    5. ping
    6. +PONG
    7. exists name
    8. :1
  • 规范格式
    1. 1、间隔符号,在Linux下是\r\n,在Windows下是\n
    2. 2、简单字符串 Simple Strings, "+"加号 开头
    3. 3、错误 Errors, "-"减号 开头
    4. 4、整数型 Integer ":" 冒号开头
    5. 5、大字符串类型 Bulk Strings, "$"美元符号开头,长度限制512M
    6. 6、数组类型 Arrays,以 "*"星号开头


用SET命令来举例说明RESP协议的格式。

  1. redis> SET mykey Helloworld
  2. "OK"


实际发送的请求数据:查看appendonly.aof文件

  1. *3\r\n$3\r\nSET\r\n$5\r\nmykey\r\n$5\r\nHelloworld\r\n
  2. *3
  3. $3
  4. set
  5. $5
  6. mykey
  7. $10
  8. helloworld

4.3 命令处理流程

整个流程包括:服务器启动监听、接收命令请求并解析、执行命令请求、返回命令回复等。

Redis笔记 - 图35

  • Server启动时监听socket

    • 启动调用 initServer方法
    • 创建eventLoop(事件机制)
    • 注册时间事件处理器
    • 注册文件事件(socket)处理器
    • 监听 socket 建立连接
  • 建立 Client

    • redis-cli建立socket
    • redis-server为每个连接(socket)
    • 创建一个 Client 对象
    • 创建文件事件监听socket 指定事件处理函数
  • 读取socket数据到输入缓冲区

    • 从client中读取客户端的查询缓冲区内容。
  • 解析获取命令

    • 将输入缓冲区中的数据解析成对应的命令
    • 判断是单条命令还是多条命令并调用相应的解析器解析
  • 执行命令

    • 解析成功后调用processCommand 方法执行命令,如下图:

Redis笔记 - 图36 大致分三个部分:

  • 调用 lookupCommand 方法获得对应的
  • redisCommand 检测当前 Redis 是否可以执行该命令调用
  • call 方法真正执行命令

4.4 协议解析及处理

包括协议解析、调用命令、返回结果。

  • 协议解析 用户在Redis客户端键入命令后,Redis-cli会把命令转化为RESP协议格式,然后发送给服务器。服务器 再对协议进行解析,分为:
    解析命令请求参数数量
    1. set mykey helloworld
  1. *3\r\n$3\r\nSET\r\n$5\r\nmykey\r\n$5\r\nHelloworld\r\n
  2. *3
  3. $3
  4. set
  5. $5
  6. mykey
  7. $10
  8. helloworld


3 为参数数量 参数数量为3
*循环解析请求参数

首字符必须是$,使用/r定位到行尾,之间的数是参数的长度,从/n后到下一个$之间就是参数的值了
循环解析直到没有$

  • 协议执行

    • 协议的执行包括命令的调用和返回结果

    • 判断参数个数和取出的参数是否一致

    • 校验成功后,会调用call函数执行命令,并记录命令执行时间和调用次数

    • 如果执行命令时间过长还要记录慢查询日志

    • 执行命令后返回结果的类型不同则协议格式也不同,分为5类:状态回复、错误回复、整数回复、批量 回复、多条批量回复。

  • 参数校验
    quit校验,如果是“quit”命令,直接返回并关闭客户端
    命令语法校验,执行lookupCommand,查找命令(set),如果不存在则返回:“unknown command”错误。
    参数数目校验,参数数目和解析出来的参数个数要匹配,如果不匹配则返回:“wrong number of arguments”错误。
    此外还有权限校验,最大内存校验,集群校验,持久化校验等等。

4.5 协议响应格式

状态回复

对于状态,回复的第一个字节是“+”

  1. "+OK"

错误回复

对于错误,回复的第一个字节是“ - ”

  1. 1. -ERR unknown command 'foobar'
  2. 2. -WRONGTYPE Operation against a key holding the wrong kind of value

整数回复

对于整数,回复的第一个字节是“:”

  1. ":6"

批量回复

对于批量字符串,回复的第一个字节是“$”

  1. "$6 foobar"

多条批量回复

对于多条批量回复(数组),回复的第一个字节是“*”

  1. "*3"

5 事件处理机制

Redis服务器是典型的事件驱动系统。

Redis将事件分为两大类:文件事件和时间事件。

5.1 文件事件

文件事件即Socket的读写事件,也就是IO事件。

客户端的连接、命令请求、数据回复、连接断开

socket

套接字(socket)是一个抽象层,应用程序可以通过它发送或接收数据。

Reactor

Redis事件处理机制采用单线程的Reactor模式,属于I/O多路复用的一种常见模式。

IO多路复用( I/O multiplexing )指的通过单个线程管理多个Socket。

Reactor pattern(反应器设计模式)是一种为处理并发服务请求,并将请求提交到 一个或者多个服务处理 程序的事件设计模式。

Reactor模式是事件驱动的

有一个或多个并发输入源(文件事件)

有一个Service Handler

有多个Request Handlers

这个Service Handler会同步的将输入的请求(Event)多路复用的分发给相应的Request Handler

Redis笔记 - 图37

Redis笔记 - 图38

Handle:I/O操作的基本文件句柄,在linux下就是fd(文件描述符 file descriptor)

Synchronous Event Demultiplexer :同步事件分离器,阻塞等待Handles中的事件发生。(系统)

Reactor: 事件分派器,负责事件的注册,删除以及对所有注册到事件分派器的事件进行监控, 当事件发生时会调用Event Handler接口来处理事件。

Event Handler: 事件处理器接口,这里需要Concrete Event Handler来实现该接口

Concrete Event Handler:真实的事件处理器,通常都是绑定了一个handle,实现对可读事件 进行读取或对可写事件进行写入的操作。

Redis笔记 - 图39

主程序向事件分派器(Reactor)注册要监听的事件

Reactor调用OS提供的事件处理分离器,监听事件(wait)

当有事件产生时,Reactor将事件派给相应的处理器来处理 handle_event()

5.2 4种IO多路复用模型与实践

select,poll,epoll、kqueue都是IO多路复用的机制。

I/O多路复用就是通过一种机制,一个进程可以监视多个描述符(socket),一旦某个描述符就绪(一 般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

  • select
    1. int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct
    2. timeval *timeout);


select 函数监视的文件描述符分3类,分别是: writefds readfds exceptfds
调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时 (timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过 遍历fd列表,来找到就绪的描述符。
优点: select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。 windows linux …
缺点: 单个进程打开的文件描述是有一定限制的,它由FD_SETSIZE设置,默认值是1024,采用数组存储 另外在检查数组中是否有文件描述需要读写时,采用的是线性扫描的方法,即不管这些socket是不是活 跃的,都轮询一遍,所以效率比较低

  • poll
    1. int poll (struct pollfd *fds, unsigned int nfds, int timeout);
    2. struct pollfd {
    3. int fd; //文件描述符
    4. short events; //要监视的事件
    5. short revents; //实际发生的事件
    6. };


poll使用一个 pollfd的指针实现,pollfd结构包含了要监视的event和发生的event,不再使用select“参 数-值”传递的方式。
优点: 采样链表的形式存储,它监听的描述符数量没有限制,可以超过select默认限制的1024大小
缺点: 另外在检查链表中是否有文件描述需要读写时,采用的是线性扫描的方法,即不管这些socket是不是活跃的,都轮询一遍,所以效率比较低。

  • epoll
    百度百科关于epoll的描述【https://baike.baidu.com/item/epoll/10738144?fr=aladdin】
    知乎 【https://zhuanlan.zhihu.com/p/63179839】
    epoll是在linux2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更 加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件 存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
    1. int epoll_create(int size)


创建一个epoll的句柄。自从linux2.6.8之后,size参数是被忽略的。需要注意的是,当创建好epoll 句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

  1. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)


poll的事件注册函数,它不同于select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先 注册要监听的事件类型。
第一个参数是epoll_create()的返回值。
第二个参数表示动作,用三个宏来表示:

  • EPOLL_CTL_ADD:注册新的fd到epfd中

  • EPOLL_CTL_MOD:修改已经注册的fd的监听事件

  • EPOLL_CTL_DEL:从epfd中删除一个fd

  1. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int
  2. timeout);

epoll 最大的优点就在于它只管你“活跃”的连接 ,而跟连接总数无关,因此在实际的网络环境中, epoll 的效率就会远远高于 select 和 poll

  • kqueue
    kqueue 是 unix 下的一个IO多路复用库。最初是2000年Jonathan Lemon在FreeBSD系统上开发的一个 高性能的事件通知接口。注册一批socket描述符到 kqueue 以后,当其中的描述符状态发生变化时, kqueue 将一次性通知应用程序哪些描述符可读、可写或出错了。
    1. struct kevent {
    2. uintptr_t ident; //是事件唯一的 key,在 socket() 使用中,它是 socket 的 fd句柄
    3. int16_t filter; //是事件的类型(EVFILT_READ socket 可读事件EVFILT_WRITE socket 可 写事件)
    4. uint16_t flags; //操作方式
    5. uint32_t fflags; //
    6. intptr_t data; //数据长度
    7. void *udata; //数据
    8. };


优点: 能处理大量数据,性能较高

5.3 文件事件分派器

在redis中,对于文件事件的处理采用了Reactor模型。采用的是epoll的实现方式。

  1. ![](assets/image-20201102174823209.png#alt=image-20201102174823209)

Redis在主循环中统一处理文件事件和时间事件,信号事件则由专门的handler来处理。

主循环

  1. void aeMain(aeEventLoop *eventLoop) {
  2. eventLoop->stop = 0;
  3. while (!eventLoop->stop) { //循环监听事件
  4. // 阻塞之前的处理
  5. if (eventLoop->beforesleep != NULL)
  6. eventLoop->beforesleep(eventLoop);
  7. // 事件处理,第二个参数决定处理哪类事件
  8. aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
  9. }
  10. }

事件处理器

  • 连接处理函数 acceptTCPHandler
    当客户端向 Redis 建立 socket时,aeEventLoop 会调用 acceptTcpHandler 处理函数,服务器会为每 个链接创建一个 Client 对象,并创建相应文件事件来监听socket的可读事件,并指定事件处理函数。
    1. // 当客户端建立链接时进行的eventloop处理函数 networking.c
    2. void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask) {
    3. ....
    4. // 层层调用,最后在anet.c 中 anetGenericAccept 方法中调用 socket 的 accept 方法
    5. cfd = anetTcpAccept(server.neterr, fd, cip, sizeof(cip), &cport);
    6. if (cfd == ANET_ERR) {
    7. if (errno != EWOULDBLOCK)
    8. serverLog(LL_WARNING,
    9. "Accepting client connection: %s", server.neterr);
    10. return;
    11. }
    12. serverLog(LL_VERBOSE,"Accepted %s:%d", cip, cport);
    13. /**
    14. * 进行socket 建立连接后的处理
    15. */
    16. acceptCommonHandler(cfd,0,cip);
    17. }
  • 请求处理函数 readQueryFromClient
    当客户端通过 socket 发送来数据后,Redis 会调用 readQueryFromClient 方法,readQueryFromClient 方法会调用 read 方法从 socket 中读取数据到输入缓冲区中,然后判断其大小是否大于系统设置的client_max_querybuf_len,如果大于,则向 Redis返回错误信息,并关闭 client。
    1. // 处理从client中读取客户端的输入缓冲区内容。
    2. void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask) {
    3. client *c = (client*) privdata;
    4. ....
    5. if (c->querybuf_peak < qblen) c->querybuf_peak = qblen;
    6. c->querybuf = sdsMakeRoomFor(c->querybuf, readlen);
    7. // 从 fd 对应的socket中读取到 client 中的 querybuf 输入缓冲区
    8. nread = read(fd, c->querybuf+qblen, readlen);
    9. ....
    10. // 如果大于系统配置的最大客户端缓存区大小,也就是配置文件中的client-query-buffer-limit
    11. if (sdslen(c->querybuf) > server.client_max_querybuf_len) {
    12. sds ci = catClientInfoString(sdsempty(),c), bytes = sdsempty();
    13. // 返回错误信息,并且关闭client
    14. bytes = sdscatrepr(bytes,c->querybuf,64);
    15. serverLog(LL_WARNING,"Closing client that reached max query buffer
    16. length: %s (qbuf initial bytes: %s)", ci, bytes);
    17. sdsfree(ci);
    18. sdsfree(bytes);
    19. freeClient(c);
    20. return;
    21. }
    22. if (!(c->flags & CLIENT_MASTER)) {
    23. // processInputBuffer 处理输入缓冲区
    24. processInputBuffer(c);
    25. } else {
    26. // 如果client是master的连接
    27. size_t prev_offset = c->reploff;
    28. processInputBuffer(c);
    29. // 判断是否同步偏移量发生变化,则通知到后续的slave
    30. size_t applied = c->reploff - prev_offset;
    31. if (applied) {
    32. replicationFeedSlavesFromMasterStream(server.slaves,
    33. c->pending_querybuf, applied);
    34. sdsrange(c->pending_querybuf,applied,-1);
    35. }
    36. }
    37. }
  • 命令回复处理器 sendReplyToClient
    sendReplyToClient函数是Redis的命令回复处理器,这个处理器负责将服务器执行命令后得到的命令回复通过套接字返回给客户端。
    1、将outbuf内容写入到套接字描述符并传输到客户端
    2、aeDeleteFileEvent 用于删除 文件写事件

5.4 时间事件

时间事件分为定时事件与周期事件: 一个时间事件主要由以下三个属性组成:

  • id(全局唯一id)
  • when (毫秒时间戳,记录了时间事件的到达时间)
  • timeProc(时间事件处理器,当时间到达时,Redis就会调用相应的处理器来处理事件)
  1. /* Time event structure
  2. *
  3. * 时间事件结构
  4. */
  5. typedef struct aeTimeEvent {
  6. // 时间事件的唯一标识符
  7. long long id; /* time event identifier. */
  8. // 事件的到达时间,存贮的是UNIX的时间戳
  9. long when_sec; /* seconds */
  10. long when_ms; /* milliseconds */
  11. // 事件处理函数,当到达指定时间后调用该函数处理对应的问题
  12. aeTimeProc *timeProc;
  13. // 事件释放函数
  14. aeEventFinalizerProc *finalizerProc;
  15. // 多路复用库的私有数据
  16. void *clientData;
  17. // 指向下个时间事件结构,形成链表
  18. struct aeTimeEvent *next;
  19. } aeTimeEvent;

serverCron

时间事件的最主要的应用是在redis服务器需要对自身的资源与配置进行定期的调整,从而确保服务器的 长久运行,这些操作由redis.c中的serverCron函数实现。该时间事件主要进行以下操作:

1)更新redis服务器各类统计信息,包括时间、内存占用、数据库占用等情况。

2)清理数据库中的过期键值对。

3)关闭和清理连接失败的客户端。

4)尝试进行aof和rdb持久化操作。

5)如果服务器是主服务器,会定期将数据向从服务器做同步操作。

6)如果处于集群模式,对集群定期进行同步与连接测试操作。

redis服务器开启后,就会周期性执行此函数,直到redis服务器关闭为止。默认每秒执行10次,平 均100毫秒执行一次,可以在redis配置文件的hz选项,调整该函数每秒执行的次数。

server.hz serverCron在一秒内执行的次数 , 在redis/conf中可以配置

  1. hz 10

比如:server.hz是100,也就是servreCron的执行间隔是10ms

run_with_period

  1. #define run_with_period(_ms_) \
  2. if ((_ms_ <= 1000/server.hz) || !(server.cronloops%((_ms_)/(1000/server.hz))))

定时任务执行都是在10毫秒的基础上定时处理自己的任务(run_with_period(ms)),即调用 run_with_period(ms)[ms是指多长时间执行一次,单位是毫秒]来确定自己是否需要执行。

返回1表示执行。 假如有一些任务需要每500ms执行一次,就可以在serverCron中用run_with_period(500)把每500ms需 要执行一次的工作控制起来。

定时事件

定时事件:让一段程序在指定的时间之后执行一次 aeTimeProc(时间处理器)的返回值是AE_NOMORE 该事件在达到后删除,之后不会再重复。

周期性事件

周期性事件:让一段程序每隔指定时间就执行一次 aeTimeProc(时间处理器)的返回值不是AE_NOMORE 当一个时间事件到达后,服务器会根据时间处理器的返回值,对时间事件的 when 属性进行更新,让这 个事件在一段时间后再次达到。

serverCron就是一个典型的周期性事件。

5.5 aeEventLoop

aeEventLoop 是整个事件驱动的核心,Redis自己的事件处理机制 它管理着文件事件表和时间事件列表, 不断地循环处理着就绪的文件事件和到期的时间事件。

Redis笔记 - 图40

  1. typedef struct aeEventLoop {
  2. //最大文件描述符的值
  3. int maxfd; /* highest file descriptor currently registered */
  4. //文件描述符的最大监听数
  5. int setsize; /* max number of file descriptors tracked */
  6. //用于生成时间事件的唯一标识id
  7. long long timeEventNextId;
  8. //用于检测系统时间是否变更(判断标准 now<lastTime)
  9. time_t lastTime; /* Used to detect system clock skew */
  10. //注册的文件事件
  11. aeFileEvent *events; /* Registered events */
  12. //已就绪的事件
  13. aeFiredEvent *fired; /* Fired events */
  14. //注册要使用的时间事件
  15. aeTimeEvent *timeEventHead;
  16. //停止标志,1表示停止
  17. int stop;
  18. //这个是处理底层特定API的数据,对于epoll来说,该结构体包含了epoll fd和epoll_event
  19. void *apidata; /* This is used for polling API specific data */
  20. //在调用processEvent前(即如果没有事件则睡眠),调用该处理函数
  21. aeBeforeSleepProc *beforesleep;
  22. //在调用aeApiPoll后,调用该函数
  23. aeBeforeSleepProc *aftersleep;
  24. } aeEventLoop;

初始化

Redis 服务端在其初始化函数 initServer 中,会创建事件管理器 aeEventLoop 对象。

函数 aeCreateEventLoop 将创建一个事件管理器,主要是初始化 aeEventLoop 的各个属性值,比如 events 、 fired 、 timeEventHead 和 apidata :

  • 首先创建 aeEventLoop 对象。
  • 初始化注册的文件事件表、就绪文件事件表。 events 指针指向注册的文件事件表、 fired 指针指 向就绪文件事件表。表的内容在后面添加具体事件时进行初变更。
  • 初始化时间事件列表,设置 timeEventHead 和 timeEventNextId 属性。
  • 调用 aeApiCreate 函数创建 epoll 实例,并初始化 apidata 。
  • 停止标志,1表示停止,初始化为0。

aeFileEvent

结构体为已经注册并需要监听的事件的结构体。

  1. typedef struct aeFileEvent {
  2. // 监听事件类型掩码,
  3. // 值可以是 AE_READABLE 或 AE_WRITABLE ,
  4. // 或者 AE_READABLE | AE_WRITABLE
  5. int mask; /* one of AE_(READABLE|WRITABLE) */
  6. // 读事件处理器
  7. aeFileProc *rfileProc;
  8. // 写事件处理器
  9. aeFileProc *wfileProc;
  10. // 多路复用库的私有数据
  11. void *clientData;
  12. } aeFileEvent;

aeFiredEvent:已就绪的文件事件

  1. typedef struct aeFiredEvent {
  2. // 已就绪文件描述符
  3. int fd;
  4. // 事件类型掩码,
  5. // 值可以是 AE_READABLE 或 AE_WRITABLE
  6. // 或者是两者的或
  7. int mask;
  8. } aeFiredEvent;

apidata:

在ae创建的时候,会被赋值为aeApiState结构体,结构体的定义如下:

这个结构体是为了epoll所准备的数据结构。redis可以选择不同的io多路复用方法。因此 apidata 是个 void类型,根据不同的io多路复用库来选择不同的实现

  1. typedef struct aeApiState {
  2. // epoll_event 实例描述符
  3. int epfd;
  4. // 事件槽
  5. struct epoll_event *events;
  6. } aeApiState;

aeTimeEvent 时间事件

aeTimeEvent结构体为时间事件,Redis 将所有时间事件都放在一个无序链表中,每次 Redis 会遍历整 个链表,查找所有已经到达的时间事件,并且调用相应的事件处理器

  1. typedef struct aeTimeEvent {
  2. /* 全局唯一ID */
  3. long long id; /* time event identifier. */
  4. /* 秒精确的UNIX时间戳,记录时间事件到达的时间*/
  5. long when_sec; /* seconds */
  6. /* 毫秒精确的UNIX时间戳,记录时间事件到达的时间*/
  7. long when_ms; /* milliseconds */
  8. /* 时间处理器 */
  9. aeTimeProc *timeProc;
  10. /* 事件结束回调函数,析构一些资源*/
  11. aeEventFinalizerProc *finalizerProc;
  12. /* 私有数据 */
  13. void *clientData;
  14. /* 前驱节点 */
  15. struct aeTimeEvent *prev;
  16. /* 后继节点 */
  17. struct aeTimeEvent *next;
  18. } aeTimeEvent;

beforesleep 对象是一个回调函数,在 redis-server 初始化时已经设置好了。

功能:

  • 检测集群状态
  • 随机释放已过期的键
  • 在数据同步复制阶段取消客户端的阻塞
  • 处理输入数据,并且同步副本信息
  • 处理非阻塞的客户端请求
  • AOF持久化存储策略,类似于mysql的bin log
  • 使用挂起的输出缓冲区处理写入

aftersleep 对象是一个回调函数,在IO多路复用与IO事件处理之间被调用。

aeMain aeMain 函数其实就是一个封装的 while 循环,循环中的代码会一直运行直到 eventLoop 的 stop 被设 置为1(true)。它会不停尝试调用 aeProcessEvents 对可能存在的多种事件进行处理,而 aeProcessEvents 就是实际用于处理事件的函数。

  1. void aeMain(aeEventLoop *eventLoop) {
  2. eventLoop->stop = 0;
  3. while (!eventLoop->stop) {
  4. if (eventLoop->beforesleep != NULL)
  5. eventLoop->beforesleep(eventLoop);
  6. aeProcessEvents(eventLoop, AE_ALL_EVENTS);
  7. }
  8. }

aemain函数中,首先调用Beforesleep。这个方法在Redis每次进入sleep/wait去等待监听的端口发生 I/O事件之前被调用。当有事件发生时,调用aeProcessEvent进行处理。

5.6 aeProcessEvent

首先计算距离当前时间最近的时间事件,以此计算一个超时时间;

然后调用 aeApiPoll 函数去等待底层的I/O多路复用事件就绪;

aeApiPoll 函数返回之后,会处理所有已经产生文件事件和已经达到的时间事件。

  1. int aeProcessEvents(aeEventLoop *eventLoop, int flags)
  2. {//processed记录这次调度执行了多少事件
  3. int processed = 0, numevents;
  4. if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS)) return 0;
  5. if (eventLoop->maxfd != -1 ||
  6. ((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
  7. int j;
  8. aeTimeEvent *shortest = NULL;
  9. struct timeval tv, *tvp;
  10. if (flags & AE_TIME_EVENTS && !(flags & AE_DONT_WAIT))
  11. //获取最近将要发生的时间事件
  12. shortest = aeSearchNearestTimer(eventLoop);
  13. //计算aeApiPoll的超时时间
  14. if (shortest) {
  15. long now_sec, now_ms;
  16. //获取当前时间
  17. aeGetTime(&now_sec, &now_ms);
  18. tvp = &tv;
  19. //计算距离下一次发生时间时间的时间间隔
  20. long long ms =
  21. (shortest->when_sec - now_sec)*1000 +
  22. shortest->when_ms - now_ms;
  23. if (ms > 0) {
  24. tvp->tv_sec = ms/1000;
  25. tvp->tv_usec = (ms % 1000)*1000;
  26. } else {
  27. tvp->tv_sec = 0;
  28. tvp->tv_usec = 0;
  29. }
  30. } else {//没有时间事件
  31. if (flags & AE_DONT_WAIT) {//马上返回,不阻塞
  32. tv.tv_sec = tv.tv_usec = 0;
  33. tvp = &tv;
  34. } else {
  35. tvp = NULL; //阻塞到文件事件发生
  36. }
  37. }//等待文件事件发生,tvp为超时时间,超时马上返回(tvp为0表示马上,为null表示阻塞到事
  38. 件发生)
  39. numevents = aeApiPoll(eventLoop, tvp);
  40. for (j = 0; j < numevents; j++) {//处理触发的文件事件
  41. aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
  42. int mask = eventLoop->fired[j].mask;
  43. int fd = eventLoop->fired[j].fd;
  44. int rfired = 0;
  45. if (fe->mask & mask & AE_READABLE) {
  46. rfired = 1;//处理读事件
  47. fe->rfileProc(eventLoop,fd,fe->clientData,mask);
  48. }
  49. if (fe->mask & mask & AE_WRITABLE) {
  50. if (!rfired || fe->wfileProc != fe->rfileProc)
  51. //处理写事件
  52. fe->wfileProc(eventLoop,fd,fe->clientData,mask);
  53. }
  54. processed++;
  55. }
  56. }
  57. if (flags & AE_TIME_EVENTS)//时间事件调度和执行
  58. processed += processTimeEvents(eventLoop);
  59. return processed;
  60. }

aeProcessEvents 都会先 计算最近的时间事件发生所需要等待的时间(aeSearchNearestTimer) ,然后调用 aeApiPoll 方法在这 段时间中等待事件的发生,在这段时间中如果发生了文件事件,就会优先处理文件事件,否则就会一直 等待,直到最近的时间事件需要触发。

  • 堵塞等待文件事件产生
    aeApiPoll 用到了epoll,select,kqueue和evport四种实现方式。

  • 处理文件事件
    rfileProc 和 wfileProc 就是在文件事件被创建时传入的函数指针
    处理读事件:rfileProc 处理写事件:wfileProc 处理时间事件

  • processTimeEvents
    取得当前时间,循环时间事件链表,如果当前时间>=预订执行时间,则执行时间处理函数。

6 Redis持久化机制

6.1 为什么要持久化

Redis是内存数据库,宕机后数据会消失。

Redis重启后快速恢复数据,要提供持久化机制。

持久化只是为了快速恢复数据而不是为了存储数据。(避免重启之后,大量请求打到DB上,造成缓存雪崩)。

Redis有两种持久化方式:RDB和AOF

注意:Redis持久化不保证数据的完整性。

当Redis用作DB时,DB数据要完整,所以一定要有一个完整的数据源(文件、mysql) 在系统启动时,从这个完整的数据源中将数据load到Redis中 数据量较小,不易改变,比如:字典库

通过info persistence命令查看持久化信息

  1. 127.0.0.1:6379> info persistence
  2. # Persistence
  3. loading:0
  4. rdb_changes_since_last_save:0
  5. rdb_bgsave_in_progress:0
  6. rdb_last_save_time:1604385203
  7. rdb_last_bgsave_status:ok
  8. rdb_last_bgsave_time_sec:0
  9. rdb_current_bgsave_time_sec:-1
  10. aof_enabled:1
  11. aof_rewrite_in_progress:0
  12. aof_rewrite_scheduled:0
  13. aof_last_rewrite_time_sec:-1
  14. aof_current_rewrite_time_sec:-1
  15. aof_last_bgrewrite_status:ok
  16. aof_last_write_status:ok
  17. aof_current_size:43072
  18. aof_base_size:0
  19. aof_pending_rewrite:0
  20. aof_buffer_length:0
  21. aof_rewrite_buffer_length:0
  22. aof_pending_bio_fsync:0
  23. aof_delayed_fsync:0

6.2 RDB

RDB(Redis DataBase),是redis默认的存储方式,RDB方式是通过快照( snapshotting )完成 的。

保存的是保存快照时这一刻内存中的数据。不关注过程。

触发快照的方式

  1. 符合自定义配置的快照规则

  2. 执行save或者bgsave命令

  3. 执行flushall命令,清空所有数据库所有数据,产生的rdb文件为空,意义不大

  4. 执行主从复制操作 (第一次)

  5. 执行shutdown 命令,保证服务器正常关闭且不丢失任何数据

  6. 定期执行
    redis.conf中配置

    1. save "" # 不使用RDB存储 不能主从
    2. save 900 1 # 表示15分钟(900秒钟)内至少1个键被更改则进行快照。
    3. save 300 10 # 表示5分钟(300秒)内至少10个键被更改则进行快照。
    4. save 60 10000 # 表示1分钟内至少10000个键被更改则进行快照。


不保证数据的完整性。比如我10s前触发了一次快照存储。在这10s内1000次写操作,然后redis宕机了 ,那么这10s的数据就没有存到rdb中,数据就不完整了。

RDB执行流程

Redis笔记 - 图41

  1. Redis父进程首先判断:当前是否在执行save,或bgsave/bgrewriteaof(aof文件重写命令)的子进程,如果在执行则bgsave命令直接返回。
  2. 父进程执行fork(调用OS函数复制主进程)操作创建子进程,这个过程中父进程是阻塞的,Redis 不能执行来自客户端的任何命令。 (只有fork过程时父进程是阻塞的,fork的过程中还涉及到数据的拷贝)【https://www.cnblogs.com/cjjjj/p/12748306.html】
  3. 父进程fork后,bgsave命令返回”Background saving started”信息并不再阻塞父进程,并可以响应其他命令。
  4. 子进程创建RDB文件,根据父进程内存快照生成临时快照文件,完成后对原有文件进行原子替换。 (RDB始终完整)
  5. 子进程发送信号给父进程表示完成,父进程更新统计信息。
  6. 父进程fork子进程后,继续工作。

RDB文件结构

Redis笔记 - 图42

1、头部5字节固定为“REDIS”字符串

2、4字节“RDB”版本号(不是Redis版本号),当前为9,填充后为0009

3、辅助字段,以key-value的形式

Redis笔记 - 图43

4、存储数据库号码

5、字典大小

6、过期key

7、主要数据,以key-value的形式存储

8、结束标志

9、校验和,就是看文件是否损坏,或者是否被修改。

优缺点

优点 RDB是二进制压缩文件,占用空间小,便于传输(传给slaver) 主进程fork子进程,可以最大化Redis性能,主进程不能太大,复制过程中主进程阻塞

缺点 不保证数据完整性,会丢失最后一次快照以后更改的所有数据

6.3 AOF

AOF(append only file)是Redis的另一种持久化方式。

Redis默认情况下是不开启的。开启AOF持久化后 Redis 将所有对数据库进行过写入的命令(及其参数)(RESP)记录到 AOF 文件, 以此达到记录数据 库状态的目的, 这样当Redis重启后只要按顺序回放这些命令就会恢复到原始状态了。

AOF会记录过程,RDB只管结果

在redis.conf里面进行配置:

  1. # 可以通过修改redis.conf配置文件中的appendonly参数开启
  2. appendonly yes
  3. # AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的。
  4. dir ./
  5. # 默认的文件名是appendonly.aof,可以通过appendfilename参数修改
  6. appendfilename appendonly.aof

AOF原理

AOF文件中存储的是redis的命令,同步命令到 AOF 文件的整个过程可以分为三个阶段:

  • 命令传播:Redis 将执行完的命令、命令的参数、命令的参数个数等信息发送到 AOF 程序中。
  • 缓存追加:AOF 程序根据接收到的命令数据,将命令转换为网络通讯协议的格式,然后将协议内容追加 到服务器的 AOF 缓存中(AOF_BUF)。
  • 文件写入和保存:AOF 缓存中的内容被写入到 AOF 文件末尾,如果设定的 AOF 保存条件被满足的话, fsync 函数或者 fdatasync 函数会被调用,将写入的内容真正地保存到磁盘中。

命令传播 当一个 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 文件保存到磁盘中。(落盘)

AOF保存模式

  • AOF_FSYNC_NO :不保存。

  • AOF_FSYNC_EVERYSEC :每一秒钟保存一次。(默认)

  • AOF_FSYNC_ALWAYS :每执行一个命令保存一次。(不推荐)

AOF_FSYNC_NO

在这种模式下, 每次调用 flushAppendOnlyFile 函数, WRITE 都会被执行, 但 SAVE 会被略过。

但是,以下任一种情况都会触发SAVE

  • Redis 被关闭

  • AOF 功能被关闭

  • 系统的写缓存被刷新(可能是缓存已经被写满,或者定期保存操作被执行)
    这三种情况下的 SAVE 操作都会引起 Redis 主进程阻塞。

AOF_FSYNC_EVERYSEC

在这种模式中, SAVE 原则上每隔一秒钟就会执行一次, 因为 SAVE 操作是由后台子线程(fork)调用 的, 所以它不会引起服务器主进程阻塞。

AOF_FSYNC_ALWAYS

在这种模式下,每次执行完一个命令之后, WRITE 和 SAVE 都会被执行。

为 SAVE 是由 Redis 主进程执行的,所以在 SAVE 执行期间,主进程会被阻塞,不能接受命令请求。

AOF 保存模式对性能和安全性的影响

Redis笔记 - 图44

6.4 AOF原理

AOF记录数据的变化过程,越来越大,需要重写“瘦身”。

Redis可以在 AOF体积变得过大时,自动地在后台(Fork子进程)对 AOF进行重写。重写后的新 AOF文 件包含了恢复当前数据集所需的最小命令集合。

所谓的“重写”其实是一个有歧义的词语, 实际上, AOF 重写并不需要对原有的 AOF 文件进行任何写入和读取, 它针对的是数据库中键的当前值。(对现有数据反向生成命令)

比如:

  1. set s1 11
  2. set s1 22 ------- > set s1 33
  3. set s1 33

Redis 不希望 AOF 重写造成服务器无法处理请求, 所以 Redis 决定将 AOF 重写程序放到(后台)子进 程里执行, 这样处理的最大好处是:

1、子进程进行 AOF 重写期间,主进程可以继续处理命令请求。

2、子进程带有主进程的数据副本,使用子进程而不是线程,可以在避免锁的情况下,保证数据的安全性。

不过, 使用子进程也有一个问题需要解决: 因为子进程在进行 AOF 重写期间, 主进程还需要继续处理 命令, 而新的命令可能对现有的数据进行修改, 这会让当前数据库的数据和重写后的 AOF 文件中的数 据不一致。

为了解决这个问题, Redis 增加了一个 AOF 重写缓存, 这个缓存在 fork 出子进程之后开始启用, Redis 主进程在接到新的写命令之后, 除了会将这个写命令的协议内容追加到现有的 AOF 文件之外, 还会追加到这个缓存中。

Redis笔记 - 图45

重写过程分析(整个重写操作是绝对安全的):

Redis 在创建新 AOF 文件的过程中,会继续将命令追加到现有的 AOF 文件里面,即使重写过程中发生停机,现有的 AOF 文件也不会丢失。 而一旦新 AOF 文件创建完毕,Redis 就会从旧 AOF 文件切换到 新 AOF 文件,并开始对新 AOF 文件进行追加操作。

子进程在重写过程中,不仅会拿开始重写时内存中的数据(而不是原来的aof),还会从AOF重写缓存中拿到执行修改命令以保证数据的一致性,然后生成临时AOF文件。

当子进程完成 AOF 重写之后, 它会向父进程发送一个完成信号, 父进程在接到完成信号之后, 会调用 一个信号处理函数,将 AOF 重写缓存中的内容全部写入到新 AOF 文件中。 对新的 AOF 文件进行改名,覆盖原有的 AOF 文件。这个信号处理函数执行完毕之后, 主进程就可以继续像往常一样接受命令请求了。 在整个 AOF 后台重 写过程中, 只有最后的写入缓存和改名操作会造成主进程阻塞, 在其他时候, AOF 后台重写都不会对 主进程造成阻塞, 这将 AOF 重写对性能造成的影响降到了最低。

Redis笔记 - 图46

触发方式

在redis.conf中配置

  1. # 表示当前aof文件大小超过上一次aof文件大小的百分之多少的时候会进行重写。如果之前没有重写过,以
  2. #启动时aof文件大小为准 比如启动时为51m那么超过102m时就会重写
  3. auto-aof-rewrite-percentage 100
  4. # 限制允许重写最小aof文件大小,也就是文件大小小于64mb的时候,不需要进行优化
  5. auto-aof-rewrite-min-size 64mb

可以执行bgrewriteaof命令手动触发

6.5 混合持久化

RDB和AOF各有优缺点,Redis 4.0 开始支持 rdb 和 aof 的混合持久化。如果把混合持久化打开,aof rewrite 的时候就直接把 rdb 的内容写到 aof 文件开头。

RDB的头+AOF的身体——>appendonly.aof

开启混合持久化

  1. aof-use-rdb-preamble yes

在加载时,首先会识别AOF文件是否以 REDIS字符串开头,如果是就按RDB格式加载,加载完RDB后继续按AOF格式加载剩余部分。

6.7 AOF文件的载入与数据还原

因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍AOF 文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态 Redis读取AOF文件并还原数据库状态的详细步骤如下:

  • 1、创建一个不带网络连接的伪客户端(fake client):因为Redis的命令只能在客户端上下文中执行, 而载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服 务器使用了一个没有网络 连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令 的效果和带网络连接的客户端执行命 令的效果完全一样
  • 2、从AOF文件中分析并读取出一条写命令
  • 3、使用伪客户端执行被读出的写命令
  • 4、一直执行步骤2和步骤3,直到AOF文件中的所有写命令都被处理完毕为止 当完成以上步骤之后,AOF文件所保存的数据库状态就会被完整地还原出来,整个过程如下图所示:

Redis笔记 - 图47

6.8 AOF与RDB对比

1、RDB存某个时刻的数据快照,采用二进制压缩存储,AOF存操作命令,采用文本存储(混合)

2、RDB性能高、AOF性能较低

3、RDB在配置触发状态会丢失最后一次快照以后更改的所有数据,AOF设置为每秒保存一次,则最多丢1秒的数据

4、Redis以主服务器模式运行,RDB不会保存过期键值对数据,Redis以从服务器模式运行,RDB会保 存过期键值对,当主服务器向从服务器同步时,再清空过期键值对。

AOF写入文件时,对过期的key会追加一条del命令,当执行AOF重写时,会忽略过期key和del命令。

6.9 不同应用场景aof/rdb配置

1、内存数据库 rdb+aof 数据不容易丢

2、缓存服务器 rdb 性能高 不建议 只使用 aof (性能差)

3、如果启动的时候从数据源恢复数据,两个都不开启

在数据还原时 有rdb+aof 则还原aof,因为RDB会造成文件的丢失,AOF相对数据要完整

只有rdb,则还原rdb

7 Redis扩展功能

7.1 发布订阅

Redis提供了发布订阅功能,可以用于消息的传输

Redis的发布订阅机制包括三个部分,publisher,subscriber和Channel

Redis笔记 - 图48

发布者和订阅者都是Redis客户端,Channel则为Redis服务器端。

订阅

需要先订阅,然后发布

  1. subscribe channel1 channel2 ...可以一次性订阅多个 subscribe ch1 ch2
  2. psubscribe pattern [pattern ...] 也可以模式订阅 psubscribe ch* 这种只有是ch开头的通道都订阅到了

发布消息

  1. publish channel message 每次只可以发布一个通道 publish ch1 haha

退订

  1. unsubscribe [channel [channel ...]]
  2. punsubscribe [pattern [pattern ...]]

7.2 发布订阅机制

客户端订阅之后,服务端肯定要保存通道和客户端信息。

看一下记录对象的结构:

  1. typedef struct redisClient {
  2. ...
  3. dict *pubsub_channels; //该client订阅的channels,以channel为key用dict的方式组织
  4. list *pubsub_patterns; //该client订阅的pattern,以list的方式组织
  5. ...
  6. } redisClient;
  7. struct redisServer {
  8. ...
  9. dict *pubsub_channels; //redis server进程中维护的channel dict,它以channel为key,订阅channel的client list为value
  10. list *pubsub_patterns; //redis server进程中维护的pattern list
  11. int notify_keyspace_events;
  12. ...
  13. };

客户端(client):

  • pubsub_channels,该属性表明了该客户端订阅的所有频道
  • pubsub_patterns,该属性表示该客户端订阅的所有模式

服务器端(RedisServer):

  • pubsub_channels,该服务器端中的所有频道以及订阅了这个频道的客户端
  • pubsub_patterns,该服务器端中的所有模式和订阅了这些模式的客户端

当客户端向某个频道发送消息时,Redis首先在redisServer中的pubsub_channels中找出键为该频道的 结点,遍历该结点的值,即遍历订阅了该频道的所有客户端,将消息发送给这些客户端。

然后,遍历结构体redisServer中的pubsub_patterns,找出包含该频道的模式的结点,将消息发送给订 阅了该模式的客户端。

使用场景:

  • 哨兵模式,哨兵通过发布与订阅的方式与Redis主服务器和Redis从服务器进行通信。

  • Redisson是一个分布式锁框架,在Redisson分布式锁释放的时候,是使用发布与订阅的方式通知的。

8 事务

8.1 ACID和Redis事务对比

Atomicity(原子性):构成事务的的所有操作必须是一个逻辑单元,要么全部执行,要么全部不 执行。

  1. Redis:一个队列中的命令要么执行要么不执行

Consistency(一致性):数据库在事务执行前后状态都必须是稳定的或者是一致的。

  1. Redis: 集群中不能保证实时的一致性,只能最终一致性

Isolation(隔离性):事务之间不会相互影响。

  1. Redis:命令是顺序执行的,在一个事务中,有可能执行其他客户端的命令

Durability(持久性):事务执行成功后必须全部写入磁盘。

  1. Redis:Redis有持久化但是不保证数据的完整性。(RDBAOF)

8.2 使用

Redis的事务是通过multi、exec、discard和watch这四个命令来完成的。

Redis的单个命令都是原子性的,所以这里需要确保事务性的对象是命令集合。

Redis将命令集合序列化并确保处于同一事务的命令集合连续且不被打断的执行

Redis不支持回滚操作

multi:用于标记事务块的开始

Redis会将后续的命令逐个放入队列中,然后使用exec原子化地执行这个 命令队列

exec:执行命令队列

discard:清除命令队列

watch:监视key

unwatch:清除监视key

注意watch不能放在事务里执行,不然会报错

  1. 127.0.0.1:6379> multi
  2. OK
  3. 127.0.0.1:6379> watch k1
  4. (error) ERR WATCH inside MULTI is not allowed

客户端1

  1. 127.0.0.1:6379> set k1 1
  2. OK
  3. 127.0.0.1:6379> get k1
  4. "1"
  5. 127.0.0.1:6379> watch k1
  6. OK
  7. 127.0.0.1:6379> multi
  8. OK
  9. 127.0.0.1:6379> set k2 ha
  10. QUEUED
  11. 127.0.0.1:6379> set k3 hm
  12. QUEUED
  13. 127.0.0.1:6379> exec
  14. (nil) #执行失败
  15. 127.0.0.1:6379>

客户端2

  1. 127.0.0.1:6379> set k1 2
  2. OK
  3. 127.0.0.1:6379>

不watch的情况下,或者另一个客户端不修改watch的key

  1. 127.0.0.1:6379> watch k1
  2. OK
  3. 127.0.0.1:6379> set k2 ha
  4. OK
  5. 127.0.0.1:6379> multi
  6. OK
  7. 127.0.0.1:6379> set k2 hm
  8. QUEUED
  9. 127.0.0.1:6379> set ke hd
  10. QUEUED
  11. 127.0.0.1:6379> exec
  12. 1) OK
  13. 2) OK
  14. 127.0.0.1:6379> get k2
  15. "hm"
  16. 127.0.0.1:6379> get ke
  17. "hd"

8.3 事务机制

事务的执行过程

  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命令将会失败 返回。

  1. typedef struct redisClient{
  2. // flags
  3. int flags //状态
  4. // 事务状态
  5. multiState mstate;
  6. // .....
  7. }redisClient;
  8. // 事务状态
  9. typedef struct multiState{
  10. // 事务队列,FIFO顺序
  11. // 是一个数组,先入队的命令在前,后入队在后
  12. multiCmd *commands;
  13. // 已入队命令数
  14. int count;
  15. }multiState;
  16. // 事务队列
  17. typedef struct multiCmd{
  18. // 参数
  19. robj **argv;
  20. // 参数数量
  21. int argc;
  22. // 命令指针
  23. struct redisCommand *cmd;
  24. }multiCmd;

watch的执行

使用WATCH命令监视数据库键 redisDb有一个watched_keys字典,key是某个被监视的数据的key,值是一个链表.记录了所有监视这个数据的客户端。

当修改数据后,监视这个数据的客户端的flags置为REDIS_DIRTY_CAS

RedisClient向服务器端发送exec命令,服务器判断RedisClient的flags,如果为REDIS_DIRTY_CAS,则 清空事务队列。

  1. typedef struct redisDb{
  2. // .....
  3. // 正在被WATCH命令监视的键
  4. dict *watched_keys;
  5. // .....
  6. }redisDb;

redis弱事务性

如果在事务里面出现语法错误,整个事务的队列就会被清除。并且flag改为multi_dirty

  1. 127.0.0.1:6379> multi
  2. OK
  3. 127.0.0.1:6379> sets m1 44
  4. (error) ERR unknown command `sets`, with args beginning with: `m1`, `44`,
  5. 127.0.0.1:6379> set m2 55
  6. QUEUED
  7. 127.0.0.1:6379> exec
  8. (error) EXECABORT Transaction discarded because of previous errors.
  9. 127.0.0

如果可以正常添加到事务队列里,但是exec的时候异常,事务不会回滚

  1. 127.0.0.1:6379> multi
  2. OK
  3. 127.0.0.1:6379> set m1 55
  4. QUEUED
  5. 127.0.0.1:6379> lpush m1 1 2 3 #不能是语法错误
  6. QUEUED
  7. 127.0.0.1:6379> exec
  8. 1) OK
  9. 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
  10. 127.0.0.1:6379> get m1
  11. "55"

Redis不支持事务回滚(为什么呢)

1、大多数事务失败是因为语法错误或者类型错误,这两种错误,在开发阶段都是可以预见的

2、Redis为了性能方面就忽略了事务回滚。 (回滚记录历史版本)

8.4 lua脚本

lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用 程序中,从而为应用程序提供灵活的扩展和定制功能。

Lua应用场景:游戏开发、独立应用脚本、Web应用脚本、扩展和数据库插件。

OpenRestry:一个可伸缩的基于Nginx的Web平台,是在nginx之上集成了lua模块的第三方服务器

OpenResty是一个通过Lua扩展Nginx实现的可伸缩的Web平台,内部集成了大量精良的Lua库、第三方 模块以及大多数的依赖项。 用于方便地搭建能够处理超高并发(日活千万级别)、扩展性极高的动态Web应用、Web服务和动态网 关。

功能和nginx类似,就是由于支持lua动态脚本,所以更加灵活,可以实现鉴权、限流、分流、日志记 录、灰度发布等功能。 OpenResty通过Lua脚本扩展nginx功能,可提供负载均衡、请求路由、安全认证、服务鉴权、流量控 制与日志监控等服务。

类似的还有Kong(Api Gateway)、tengine(阿里)

在redis中使用lua

脚本的命令是原子的,RedisServer在执行脚本命令中,不允许插入新的命令

脚本的命令可以复制,RedisServer在获得脚本后不执行,生成标识返回,Client根据标识就可以随时执行

eval命令

  1. EVAL script numkeys key [key ...] arg [arg ...]

script参数:是一段Lua脚本程序,它会被运行在Redis服务器上下文中,这段脚本不必(也不应该) 定义为一个Lua函数。

numkeys参数:用于指定键名参数的个数。

key [key …]参数: 从EVAL的第三个参数开始算起,使用了numkeys个键(key),表示在脚本中 所用到的那些Redis键(key),这些键名参数可以在Lua中通过全局变量KEYS数组,用1为基址的形 式访问( KEYS[1] , KEYS[2] ,以此类推)。

arg [arg …]参数:可以在Lua中通过全局变量ARGV数组访问,访问的形式和KEYS变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)。

KEYS和ARGV必须大写

  1. eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 k1 k2 hello world

redis.call()可以执行redis命令

redis.call(): 返回值就是redis命令执行的返回值 如果出错,则返回错误信息,不继续执行

redis.pcall(): 返回值就是redis命令执行的返回值 如果出错,则记录错误信息,继续执行

注意事项 在脚本中,使用return语句将返回值返回给客户端,如果没有return,则返回nil

  1. eval "return redis.call('set',KEYS[1],ARGV[1])" 1 n1 zhaoyun

evalsha

EVAL 命令要求你在每次执行脚本的时候都发送一次脚本主体(script body)。

Redis 有一个内部的缓存机制,因此它不会每次都重新编译脚本,不过在很多场合,付出无谓的带宽来 传送脚本主体并不是最佳选择。 (重启之后依然可以执行)

为了减少带宽的消耗, Redis 实现了 EVALSHA 命令,它的作用和 EVAL 一样,都用于对脚本求值,但 它接受的第一个参数不是脚本,而是脚本的 SHA1 校验和(sum)

  • SCRIPT FLUSH :清除所有脚本缓存

  • SCRIPT EXISTS :根据给定的脚本校验和,检查指定的脚本是否存在于脚本缓存

  • SCRIPT LOAD :将一个脚本装入脚本缓存,返回SHA1摘要,但并不立即运行它

  • SCRIPT KILL :杀死当前正在运行的脚本

  1. 127.0.0.1:6379> eval "return redis.call('set',KEYS[1],ARGV[1])" 1 n1 zhaoyun
  2. OK
  3. 127.0.0.1:6379> script load "return redis.call('set',KEYS[1],ARGV[1])"
  4. "c686f316aaf1eb01d5a4de1b0b63cd233010e63d"
  5. 127.0.0.1:6379> evalsha c686f316aaf1eb01d5a4de1b0b63cd233010e63d 1 k1 hahahah
  6. OK
  7. 127.0.0.1:6379> get k1
  8. "hahahah"
  9. 127.0.0.1:6379>

redlis-cli直接执行lua脚本文件

tet1.lua 脚本内容

  1. return redis.call('set',KEYS[1],ARGV[1])

执行

  1. ./redis-cli -h 127.0.0.1 -p 6379 --eval test1.lua name:1 , 'wanghao' #,两边有空格

结果

  1. 127.0.0.1:6379> get name:1
  2. "'wanghao'"
  3. 127.0.0.1:6379>

test2.lua脚本内容

  1. local key=KEYS[1]
  2. local list=redis.call("lrange",key,0,-1);
  3. return list;

执行

  1. 127.0.0.1:6379> lpush l1 1 4 8 2 9
  2. (integer) 5
  3. 127.0.0.1:6379> lrange l1 0 -1
  4. 1) "9"
  5. 2) "2"
  6. 3) "8"
  7. 4) "4"
  8. 5) "1"
  9. 127.0.0.1:6379>
  1. ./redis-cli -h 127.0.0.1 -p 6379 --eval test2.lua l1

利用Redis整合Lua,主要是为了性能以及事务的原子性。因为redis帮我们提供的事务功能太差。

windows不用./

8.5 脚本复制

Redis 传播 Lua 脚本,在使用主从模式和开启AOF持久化的前提下: 当执行lua脚本时,Redis 服务器有两种模式:脚本传播模式和命令传播模式。

脚本传播

脚本传播模式是 Redis 复制脚本时默认使用的模式

Redis会将被执行的脚本及其参数复制到 AOF 文件以及从服务器里面。

  1. eval "redis.call('set',KEYS[1],ARGV[1]);redis.call('set',KEYS[2],ARGV[2])" 2 n1 n2 zhaoyun gaunyu

aof文件中是这样记录的,但是在Redis5,也是处于同一个事务中(记录的也是命令)。

  1. *7
  2. $4
  3. eval
  4. $67
  5. redis.call('set',KEYS[1],ARGV[1]);redis.call('set',KEYS[2],ARGV[2])
  6. $1
  7. 2
  8. $2
  9. n1
  10. $2
  11. n2
  12. $7
  13. zhaoyun
  14. $6
  15. gaunyu

如果存在从服务器,那么主服务器将向从服务器发送完全相同的 eval 命令。

注意:在这一模式下执行的脚本不能有时间、内部状态、随机函数(spop)等。执行相同的脚本以及参数 必须产生相同的效果。

命令传播模式

处于命令传播模式的主服务器会将执行脚本产生的所有写命令用事务包裹起来,然后将事务复制到 AOF 文件以及从服务器里面。

因为命令传播模式复制的是写命令而不是脚本本身,所以即使脚本本身包含时间、内部状态、随机函数 等,主服务器给所有从服务器复制的写命令仍然是相同的

为了开启命令传播模式,用户在使用脚本执行任何写操作之前,需要先在脚本里面调用以下函数:

  1. redis.replicate_commands()

redis.replicate_commands() 只对调用该函数的脚本有效:在使用命令传播模式执行完当前脚本之后, 服务器将自动切换回默认的脚本传播模式。

执行:

  1. eval "redis.replicate_commands();redis.call('set',KEYS[1],ARGV[1]);redis.call('set',KEYS[2],ARGV[2])" 2 n1 n2 zhaoyun gaunyu

aof文件中是这样记录的

  1. *1
  2. $5
  3. MULTI
  4. *3
  5. $3
  6. set
  7. $2
  8. n1
  9. $7
  10. zhaoyun
  11. *3
  12. $3
  13. set
  14. $2
  15. n2
  16. $6
  17. gaunyu
  18. *1
  19. $4
  20. EXEC

如果存在从服务器,就会将以上命令发送给从服务器。

道(pipeline),事务和脚本(lua)三者的区别

三者都可以批量执行命令

  • 管道无原子性,命令都是独立的,属于无状态的操作
  • 事务和脚本是有原子性的,其区别在于脚本可借助Lua语言可在服务器端存储的便利性定制和简化操作
  • 脚本的原子性要强于事务,脚本执行期间,另外的客户端 其它任何脚本或者命令都无法执行,脚本的执行时间应该尽量短,不能太耗时的脚本

8.6 慢查询日志

redis.conf中配置

  1. #执行时间超过多少微秒的命令请求会被记录到日志上 0 :全记录 <0 不记录
  2. slowlog-log-slower-than 10000
  3. #slowlog-max-len 存储慢查询日志条数
  4. slowlog-max-len 128

Redis使用列表存储慢查询日志,采用队列方式(FIFO),头部插入。

当队列达到slowlog-max-len 128 配置值的时候,会剔除队尾的日志。

可以通过slowlog get 命令查询慢查询日志

慢查询日志保存

在redisServer中保存和慢查询日志相关的信息

  1. struct redisServer {
  2. // ...
  3. // 下一条慢查询日志的 ID
  4. long long slowlog_entry_id;
  5. // 保存了所有慢查询日志的链表 FIFO quicklist
  6. list *slowlog;
  7. // 服务器配置 slowlog-log-slower-than 选项的值
  8. long long slowlog_log_slower_than;
  9. // 服务器配置 slowlog-max-len 选项的值
  10. unsigned long slowlog_max_len;
  11. // ...
  12. };

lowlog 链表保存了服务器中的所有慢查询日志, 链表中的每个节点都保存了一个 slowlogEntry 结 构, 每个 slowlogEntry 结构代表一条慢查询日志。

  1. typedef struct slowlogEntry {
  2. // 唯一标识符
  3. long long id;
  4. // 命令执行时的时间,格式为 UNIX 时间戳
  5. time_t time;
  6. // 执行命令消耗的时间,以微秒为单位
  7. long long duration;
  8. // 命令与命令参数
  9. robj **argv;
  10. // 命令与命令参数的数量
  11. int argc;
  12. } slowlogEntry;

初始化日志列表源码

  1. void slowlogInit(void) {
  2. server.slowlog = listCreate(); /* 创建一个list列表 */
  3. server.slowlog_entry_id = 0; /* 日志ID从0开始 */
  4. listSetFreeMethod(server.slowlog,slowlogFreeEntry); /* 指定慢查询日志list空间
  5. 的释放方法 */
  6. }

获得慢查询日志记录源码

slowlog get [n]

  1. def SLOWLOG_GET(number=None):
  2. # 用户没有给定 number 参数
  3. # 那么打印服务器包含的全部慢查询日志
  4. if number is None:
  5. number = SLOWLOG_LEN()
  6. # 遍历服务器中的慢查询日志
  7. for log in redisServer.slowlog:
  8. if number <= 0:
  9. # 打印的日志数量已经足够,跳出循环
  10. break
  11. else:
  12. # 继续打印,将计数器的值减一
  13. number -= 1
  14. # 打印日志
  15. printLog(log)

查看慢查询日志数量

slowlog len

  1. def SLOWLOG_LEN():
  2. # slowlog 链表的长度就是慢查询日志的条目数量
  3. return len(redisServer.slowlog)

清除日志 slowlog reset

  1. def SLOWLOG_RESET():
  2. # 遍历服务器中的所有慢查询日志
  3. for log in redisServer.slowlog:
  4. # 删除日志
  5. deleteLog(log)

慢查询日志记录源码

在每次执行命令的之前和之后, 程序都会记录微秒格式的当前 UNIX 时间戳, 这两个时间戳之间的差 就是服务器执行命令所耗费的时长, 服务器会将这个时长作为参数之一传给 slowlogPushEntryIfNeeded 函数, 而 slowlogPushEntryIfNeeded 函数则负责检查是否需要为这 次执行的命令创建慢查询日志

  1. // 记录执行命令前的时间
  2. before = unixtime_now_in_us()
  3. //执行命令
  4. execute_command(argv, argc, client)
  5. //记录执行命令后的时间
  6. after = unixtime_now_in_us()
  7. // 检查是否需要创建新的慢查询日志
  8. slowlogPushEntryIfNeeded(argv, argc, before-after)
  9. void slowlogPushEntryIfNeeded(robj **argv, int argc, long long duration) {
  10. if (server.slowlog_log_slower_than < 0) return; /* Slowlog disabled */ /* 负
  11. 数表示禁用 */
  12. if (duration >= server.slowlog_log_slower_than) /* 如果执行时间 > 指定阈值*/
  13. listAddNodeHead(server.slowlog,slowlogCreateEntry(argv,argc,duration));
  14. /* 创建一个slowlogEntry对象,添加到列表首部*/
  15. while (listLength(server.slowlog) > server.slowlog_max_len) /* 如果列表长度 >
  16. 指定长度 */
  17. listDelNode(server.slowlog,listLast(server.slowlog)); /* 移除列表尾部元素
  18. */
  19. }

slowlogPushEntryIfNeeded 函数的作用有两个:

  • 检查命令的执行时长是否超过 slowlog-log-slower-than 选项所设置的时间, 如果是的话, 就为命令创建一个新的日志, 并将新日志添加到 slowlog 链表的表头。
  • 检查慢查询日志的长度是否超过 slowlog-max-len 选项所设置的长度, 如果是的话, 那么将多 出来的日志从 slowlog 链表中删除掉。

8.7 慢查询定位&处理

  1. 使用slowlog get 可以获得执行较慢的redis命令,针对该命令可以进行优化:
  2. 1、尽量使用短的key,对于value有些也可精简,能使用intint
  3. 2、避免使用keys *、hgetall等全量操作。
  4. 3、减少大key的存取,打散为小key(超过100K)
  5. 4、将rdb改为aof模式
  6. rdb fork 子进程 主进程阻塞 redis大幅下降
  7. 关闭持久化 (适合于数据量较小,而且有固定数据源)
  8. 5、想要一次添加多条数据的时候可以使用管道
  9. 6、尽可能地使用哈希存储
  10. 7、尽量限制下redis使用的内存大小,这样可以避免redis使用swap分区或者出现OOM错误
  11. 内存与硬盘的swap

8.8 监视器

Redis客户端通过执行MONITOR命令可以将自己变为一个监视器,实时地接受并打印出服务器当前处理 的命令请求的相关信息。

此时,当其他客户端向服务器发送一条命令请求时,服务器除了会处理这条命令请求之外,还会将这条 命令请求的信息发送给所有监视器。

Redis笔记 - 图49

源码

redisServer 维护一个 monitors 的链表,记录自己的监视器,每次收到 MONITOR 命令之后,将客户端追加到链表尾。

  1. void monitorCommand(redisClient *c) {
  2. /* ignore MONITOR if already slave or in monitor mode */
  3. if (c->flags & REDIS_SLAVE) return;
  4. c->flags |= (REDIS_SLAVE|REDIS_MONITOR);
  5. listAddNodeTail(server.monitors,c);
  6. addReply(c,shared.ok); //回复OK
  7. }

向监视器发送命令

利用call函数实现向监视器发送命令

  1. // call() 函数是执行命令的核心函数,这里只看监视器部分
  2. /*src/redis.c/call*/
  3. /* Call() is the core of Redis execution of a command */
  4. void call(redisClient *c, int flags) {
  5. long long dirty, start = ustime(), duration;
  6. int client_old_flags = c->flags;
  7. /* Sent the command to clients in MONITOR mode, only if the commands are
  8. * not generated from reading an AOF. */
  9. if (listLength(server.monitors) &&
  10. !server.loading &&
  11. !(c->cmd->flags & REDIS_CMD_SKIP_MONITOR))
  12. {
  13. replicationFeedMonitors(c,server.monitors,c->db->id,c->argv,c->argc);
  14. }
  15. ......
  16. }

call 主要调用了 replicationFeedMonitors ,这个函数的作用就是将命令打包为协议,发送给监视 器。

有grafana、prometheus以及redis_exporter监控平台结合使用。

9 主从复制

我们知道分布式理论有CAP定理。P(Partion)代表的是分区容错性,这个是分布式系统必不可少的。

而Redis的集群满足的是AP。高可用。因为redis的弱事务机制,以及数据持久化机制也不支持数据的一致性。

单机的Redis是无法保证高可用性的,当Redis服务器宕机后,即使在有持久化的机制下也无法保证不丢失数据。 所以我们采用Redis多机和集群的方式来保证Redis的高可用性。

Redis支持主从复制功能,可以通过执行slaveof(Redis5以后改成replicaof)或者在配置文件中设置 slaveof(Redis5以后改成replicaof)来开启复制功能。

redis主从复制有三种模式

  • 一主一从
  • 一主多从
  • 一主多从,从库再挂从机

通常情况下,redis的主从不能自动切换,就是redis主节点挂了,从节点不能切换为主节点,需要用哨兵模式了。

redis的主从复制配置很简单:

  • 主节点不需要做其他和单节点不同的配置(如果是优化就另说了)

  • 从节点

    1. # slaveof <masterip> <masterport> redis5 的配置
    2. # 表示当前【从服务器】对应的【主服务器】的IP是192.168.10.135,端口是6379。
    3. replicaof 127.0.0.1 6379

作用:

  • 读写分离 一主多从,主从同步 主负责写,从负责读 提升Redis的性能和吞吐量
  • 数据容灾 从机是主机的备份 主机宕机,从机可读不可写 默认情况下主机宕机后,从机不可为主机 利用哨兵可以实现主从切换,做到高可用

但是主从复制就会有主从数据一致性问题(主从延迟)

9.1 原理与实现

① 保存主节点信息

当客户端向从服务器发送slaveof(replicaof) 主机地址(127.0.0.1) 端口(6379)时:从服务器将主 机ip(127.0.0.1)和端口(6379)保存到redisServer的masterhost和masterport中。

  1. Struct redisServer{
  2. char *masterhost;//主服务器ip
  3. int masterport;//主服务器端口
  4. } ;

SLAVEOF命令是一个异步命令,在完成masterhost属性和masterport属性的设置工作之后,从服务器将向发送SLAVEOF命令的客户端返回OK,表示复制指令已经收到,实际上,这个时候复制才真正开始。

②建立socket连接

如果从服务器创建的套接字能成功连接(connect)到主服务器,那么从服务器将为这个套接字关联一个专门用于处理复制工作的文件事件处理器,这个处理器将负责执行后续的复制工作。

Redis笔记 - 图50

主服务器accept从服务器Socket连接后,创建相应的客户端状态。相当于从服务器是主服务器的Client 端。

Redis笔记 - 图51

③ 发送ping命令

Slaver向Master发送ping命令

  • 1、检测socket的读写状态

  • 2、检测Master能否正常处理

Master的响应:

  • 1、发送“pong” , 说明正常
  • 2、返回错误,说明Master不正常
  • 3、timeout,说明网络超时

Redis笔记 - 图52

④权限验证

主从正常连接后,进行权限验证

主未设置密码(requirepass=“”) ,从也不用设置密码(masterauth=“”)

主设置密码(requirepass!=””),从需要设置密码(masterauth=主的requirepass的值)

或者从通过auth命令向主发送密码

⑤发送端口信息

在身份验证步骤之后,从服务器将执行命令REPLCONF listening-port ,向主服务器发送从服务器的监 听端口号。

主服务器会将这个端口号记录在从服务器所对应的客户端状态的slave_listening_port属性中:

  1. typedef struct redisClient{
  2. //...
  3. //从服务器的监听端口号
  4. int slave_listening_port;
  5. //...

slave_listening_port属性唯一的作用就是在主服务器执行INFO replication命令时打印出从服务器的端口号

Redis笔记 - 图53

⑥同步数据

参考博客【https://blog.csdn.net/qq_40594696/article/details/105287947】

Redis 2.8之后分为全量同步和增量同步,具体的后面详细讲解。

待数据初次同步完成之后主从服务器会进入命令传播阶段。主服务器只要将自己执行的写命令发送给从服 务器,而从服务器只要一直执行并接收主服务器发来的写命令。

Redis 2.8以前使用SYNC命令同步复制

redis2.8以前:

Redis的同步功能分为同步(sync)和命令传播(command propagate)。

  • 同步

    1. 通过从服务器发送到SYNC命令给主服务器
    2. 主服务器生成RDB文件并发送给从服务器,同时发送保存所有写命令给从服务器
    3. 从服务器清空之前数据并执行解释RDB文件
    4. 保持数据一致(还需要命令传播过程才能保持一致)

Redis笔记 - 图54

  • 命令传播操作:
    同步操作完成后,主服务器执行写命令,该命令发送给从服务器并执行,使主从保存一致。

缺陷:

没有全量同步和部分同步的概念,从服务器在同步时,会清空所有数据。

主从服务器断线后重复制,主服务器会重新生成RDB文件和重新记录缓冲区的所有命令,并全量同步到 从服务器上。

redis2.8以后:

在Redis 2.8之后使用PSYNC命令,具备完整重同步和部分同步模式。

  1. Redis 的主从同步,分为全量同步和部分同步(类似于断点续传)。
  2. 只有从机第一次连接上主机是全量同步。
  3. 断线重连有可能触发全量同步也有可能是部分同步( master 判断 runid 是否一致)。

Redis笔记 - 图55

同步过程:

  • 同步快照阶段: Master 创建并发送快照RDB给 Slave , Slave 载入并解析快照。 Master 同时将 此阶段所产生的新的写命令存储到缓冲区。
  • 同步写缓冲阶段: Master 向 Slave 同步存储在缓冲区的写操作命令。
  • 同步增量阶段(命令传播): Master 向 Slave 同步写操作命令。

Redis笔记 - 图56

⑦心跳检测

在命令传播阶段,从服务器默认会以每秒一次的频率向主服务器发送命令:

  1. replconf ack <replication_offset>
  2. #ack :应答
  3. #replication_offset:从服务器当前的复制偏移量

作用:

  • 检测主从服务器的网络连接状态
    如果主服务器超过1秒没有收到从服务器发来的心跳检测命令,说明从服务器不能正常和主服务器连接,主服务器开始统计从服务器过了多少秒没有发送命令。

  • 辅助实现min-slaves配置选项
    Redis可以通过配置防止主服务器在不安全的情况下执行写命令
    min-slaves-to-write 3
    min-slaves-max-lag 10
    redis5

    1. min-replicas-to-write 3
    2. min-replicas-max-lag 10


上面的配置表示:从服务器的数量少于3个,或者三个从服务器的延迟(lag)值都大于或等于10 秒时,主服务器将拒绝执行写命令。这里的延迟值就是上面INFOreplication命令的lag值。
min-replicas-to-write:redis提供了可以让master停止写入的方式,如果配置了min-replicas-to-write,健康的slave的个数小于N,mater就禁止写入。master最少得有多少个健康的slave存活才能执行写命令。这个配置虽然不能保证N个slave都一定能接收到master的写操作,但是能避免没有足够健康的slave的时候,master不能写入来避免数据丢失。设置为0是关闭该功能。
min-replicas-max-lag:延迟小于min-replicas-max-lag秒的slave才认为是健康的slave

  • 检测命令是否丢失
    如果因为网络故障,主服务器传播给从服务器的写命令在半路丢失,那么当从服务器向主服务器发 送REPLCONF ACK命令时,主服务器将发觉从服务器当前的复制偏移量少于自己的复制偏移量, 然后主服务器就会根据从服务器提交的复制偏移量,在复制积压缓冲区里面找到从服务器缺少的数 据,并将这些数据重新发送给从服务器。(补发) 网络不断

10 哨兵模式

文档 【http://redisdoc.com/topic/sentinel.html】

哨兵(sentinel)是Redis的高可用性(High Availability)的解决方案: 由一个或多个sentinel实例组成sentinel集群可以监视一个或多个主服务器和多个从服务器。

当主服务器进入下线状态时,sentinel可以将该主服务器下的某一从服务器升级为主服务器继续提供服务,从而保证redis的高可用性。

10.1 配置使用

主从:

master配置

  1. port 6390
  2. # 是否开启保护模式,由yes该为no
  3. protected-mode no
  4. # 将`daemonize`由`no`改为`yes` 后台运行
  5. daemonize yes
  6. pidfile /var/run/redis_6390.pid
  7. min-replicas-to-write 1
  8. min-replicas-max-lag 10

slave1配置

  1. port 6391
  2. replicaof 127.0.0.1 6390
  3. # 是否开启保护模式,由yes该为no
  4. protected-mode no
  5. # 将`daemonize`由`no`改为`yes` 后台运行
  6. daemonize yes
  7. pidfile /var/run/redis_6391.pid

slave2配置

  1. port 6391
  2. replicaof 127.0.0.1 6390
  3. # 是否开启保护模式,由yes该为no
  4. protected-mode no
  5. # 将`daemonize`由`no`改为`yes`
  6. daemonize yes
  7. pidfile /var/run/redis_6391.pid

哨兵配置:

sentinel1:

  1. # 哨兵sentinel实例运行的端口 默认26390
  2. port 26390
  3. # 将`daemonize`由`no`改为`yes`
  4. daemonize yes
  5. pidfile /var/run/redis-sentinel_26390.pid
  6. # 哨兵sentinel监控的redis主节点的 ip port
  7. # master-name 可以自己命名的主节点名字 只能由字母A-z、数字0-9 、这三个字符".-_"组成。
  8. # quorum 当这些quorum个数sentinel哨兵认为master主节点失联 那么这时 客观上认为主节点失联了
  9. # sentinel monitor <master-name> <ip> <redis-port> <quorum>
  10. sentinel monitor mymaster 127.0.0.1 6390 2
  11. # 可以监控多个master
  12. #sentinel monitor mymaster2 127.0.0.1 6391 2
  13. # 当在Redis实例中开启了requirepass foobared 授权密码 这样所有连接Redis实例的客户端都要提
  14. #供密码
  15. # 设置哨兵sentinel 连接主从的密码 注意必须为主从设置一样的验证密码
  16. # sentinel auth-pass <master-name> <password>
  17. #sentinel auth-pass mymaster MySUPER--secret-0123passw0rd
  18. # 指定多少毫秒之后 主节点没有应答哨兵sentinel 此时 哨兵主观上认为主节点下线 默认30秒,改成3秒
  19. # sentinel down-after-milliseconds <master-name> <milliseconds>
  20. sentinel down-after-milliseconds mymaster 3000
  21. # 这个配置项指定了在发生failover主备切换时最多可以有多少个slave同时对新的master进行 同步,
  22. #这个数字越小,完成failover所需的时间就越长,
  23. #但是如果这个数字越大,就意味着越多的slave因为replication而不可用。
  24. #可以通过将这个值设为 1 来保证每次只有一个slave 处于不能处理命令请求的状态。
  25. # sentinel parallel-syncs <master-name> <numslaves>
  26. sentinel parallel-syncs mymaster 1
  27. # 故障转移的超时时间 failover-timeout 可以用在以下这些方面:
  28. #1. 同一个sentinel对同一个master两次failover之间的间隔时间。
  29. #2. 当一个slave从一个错误的master那里同步数据开始计算时间。直到slave被纠正为向正确的master
  30. #那里同步数据时。
  31. #3.当想要取消一个正在进行的failover所需要的时间。
  32. #4.当进行failover时,配置所有slaves指向新的master所需的最大时间。不过,即使过了这个超时,
  33. #slaves依然会被正确配置为指向master,但是就不按parallel-syncs所配置的规则来了
  34. # 默认三分钟
  35. # sentinel failover-timeout <master-name> <milliseconds>
  36. sentinel failover-timeout mymaster 180000

sentinel2

  1. # 哨兵sentinel实例运行的端口 默认26390
  2. port 26391
  3. # 将`daemonize`由`no`改为`yes`
  4. daemonize yes
  5. pidfile /var/run/redis-sentinel_26391.pid
  6. # 哨兵sentinel监控的redis主节点的 ip port
  7. # master-name 可以自己命名的主节点名字 只能由字母A-z、数字0-9 、这三个字符".-_"组成。
  8. # quorum 当这些quorum个数sentinel哨兵认为master主节点失联 那么这时 客观上认为主节点失联了
  9. # sentinel monitor <master-name> <ip> <redis-port> <quorum>
  10. sentinel monitor mymaster 127.0.0.1 6390 2

sentinel3

  1. # 哨兵sentinel实例运行的端口 默认26390
  2. port 26392
  3. # 将`daemonize`由`no`改为`yes`
  4. daemonize yes
  5. pidfile /var/run/redis-sentinel_26392.pid
  6. # 哨兵sentinel监控的redis主节点的 ip port
  7. # master-name 可以自己命名的主节点名字 只能由字母A-z、数字0-9 、这三个字符".-_"组成。
  8. # quorum 当这些quorum个数sentinel哨兵认为master主节点失联 那么这时 客观上认为主节点失联了
  9. # sentinel monitor <master-name> <ip> <redis-port> <quorum>
  10. sentinel monitor mymaster 127.0.0.1 6390 2

然后启动

master,slave1,slave2,sentinel1,sentinel2,sentinel3

sentinel需要使用 redis-sentinel命令

redis的从节点是不能写数据的

  1. 127.0.0.1:6391> set k2 ha
  2. (error) READONLY You can't write against a read only replica.

当master挂了之后,哨兵会自动将两个slave其中一个选举为master。并且会修改配置文件,会删除本身的

replicaof属性。修改另一个slave的replicaof的主机信息为新的master地址。sentinel的配置文件也会被修改,

sentinel monitor mymaster 监控的主机新消息改为新的master地址。当旧的master重启之后,是不能做回

master的,他必须作为新的master的slave节点。

new master:

  1. # Replication
  2. role:master
  3. connected_slaves:3
  4. slave0:ip=127.0.0.1,port=6391,state=online,offset=172523,lag=0
  5. slave1:ip=127.0.0.1,port=6391,state=online,offset=172523,lag=1
  6. slave2:ip=127.0.0.1,port=6390,state=online,offset=172523,lag=1
  7. master_replid:aa8acb40703b1db7f7e12b36ff639a285f28f895
  8. master_replid2:3ead422a07c4812d2943c73fe16df91e75d861ae
  9. master_repl_offset:172656
  10. second_repl_offset:31805
  11. repl_backlog_active:1
  12. repl_backlog_size:1048576
  13. repl_backlog_first_byte_offset:1
  14. repl_backlog_histlen:172656

sentinel:

  1. # Sentinel
  2. sentinel_masters:1
  3. sentinel_tilt:0
  4. sentinel_running_scripts:0
  5. sentinel_scripts_queue_length:0
  6. sentinel_simulate_failure_flags:0
  7. master0:name=mymaster,status=ok,address=127.0.0.1:6392,slaves=2,sentinels=3

10.2 执行流程

sentinel是一个特殊的redis服务器,不保存数据。


sentinel启动后,会创建2个连向master的网络连接。

①命令连接:用于向主服务器发送命令,并接收响应

②订阅连接:用于订阅主服务器的__sentinel__:hello频道。

其实不管是命令连接还是订阅连接都是一个连接而已。没什么区别,只是发送的命令不一样而已。都相当于一个客户端连接。

Redis笔记 - 图57


获取主服务器信息

Sentinel默认每10s一次,向被监控的主服务器发送info命令,获取主服务器和其下属从服务器的信息。

通过info可以看到有哪些从服务器,所以不需要再sentinel配置文件中配置从服务器信息。

  1. 127.0.0.1:6392> info Replication
  2. # Replication
  3. role:master
  4. connected_slaves:2
  5. slave0:ip=127.0.0.1,port=6391,state=online,offset=636658,lag=0
  6. slave1:ip=127.0.0.1,port=6390,state=online,offset=636658,lag=0
  7. master_replid:aa8acb40703b1db7f7e12b36ff639a285f28f895
  8. master_replid2:3ead422a07c4812d2943c73fe16df91e75d861ae
  9. master_repl_offset:636658
  10. second_repl_offset:31805
  11. repl_backlog_active:1
  12. repl_backlog_size:1048576
  13. repl_backlog_first_byte_offset:1
  14. repl_backlog_histlen:636658

获取从服务器信息

当Sentinel发现主服务器有新的从服务器出现时,Sentinel还会向从服务器建立命令连接和订阅连接。 在命令连接建立之后,Sentinel还是默认10s一次,向从服务器发送info命令,并记录从服务器的信息。

Redis笔记 - 图58

  1. 127.0.0.1:6391> info Replication
  2. # Replication
  3. role:slave
  4. master_host:127.0.0.1
  5. master_port:6392
  6. master_link_status:up
  7. master_last_io_seconds_ago:0
  8. master_sync_in_progress:0
  9. slave_repl_offset:674430
  10. slave_priority:100
  11. slave_read_only:1
  12. connected_slaves:0
  13. master_replid:aa8acb40703b1db7f7e12b36ff639a285f28f895
  14. master_replid2:3ead422a07c4812d2943c73fe16df91e75d861ae
  15. master_repl_offset:674430
  16. second_repl_offset:1
  17. repl_backlog_active:1
  18. repl_backlog_size:1048576
  19. repl_backlog_first_byte_offset:1
  20. repl_backlog_histlen:674430

向主服务器和从服务器发送消息(以订阅的方式)

默认情况下,Sentinel每2s一次,通过命令连接向所有被监视的主服务器和从服务器所订阅__sentinel__:hello频道 上发送消息,消息中会携带Sentinel自身的信息和主服务器的信息。

  1. PUBLISH __entinel__:hello "< s_ip > < s_port >< s_runid >< s_epoch > < m_name > <
  2. m_ip >< m_port ><m_epoch>"

接收到的通道信息:

  1. 1) "message"
  2. 2) "__sentinel__:hello"
  3. 3) "127.0.0.1,26391,82094f5c63c01d65b37404bca7830ec50d5d908e,1,mymaster,127.0.0.1,6392,1"
  4. 1) "message"
  5. 2) "__sentinel__:hello"
  6. 3) "127.0.0.1,26392,4b5b8106004770b6cbb507338662aa9618a4fc42,1,mymaster,127.0.0.1,6392,1"
  7. 1) "message"
  8. 2) "__sentinel__:hello"
  9. 3) "127.0.0.1,26392,4b5b8106004770b6cbb507338662aa9618a4fc42,1,mymaster,127.0.0.1,6392,1"
  10. 1) "message"
  11. 2) "__sentinel__:hello"
  12. 3) "127.0.0.1,26390,05b48ab59a4ca2fe42c350f2ac2df820e556b8d8,1,mymaster,127.0.0.1,6392,1"

接收来自主服务器和从服务器的频道信息

当Sentinel与一个主服务器或者从服务器建立起订阅连接之后, Sentinel就会通过订阅连接, 向服务器发送以下命令:

  1. SUBSCRIBE __sentinel__:hello

Sentinel对__sentinel__:hello频道的订阅会一直持续到Sentinel与服务器的连接断开为止。

这也就是说, 对于每个与Sentinel连接的服务器, Sentinel既通过命令连接向服务器的__sentinel__:hello频道发送信息, 又通过订阅连接从服务器的 __sentinel__:hello 频道接收信息。

Redis笔记 - 图59

对于监视同一个channel的多个Sentinel 来说, 一个Sentinel发送的信息会被其他 Sentinel接收到, 这些信息会被用于更新其他Sentinel对发送信息Sentinel的认知 ,也会被用于更新其他Sentinel对被监视服务器的认知。

举个例子, 假设现在有sentinel1、sentinel 2、sentinel 3三个Sentinel在监视同一个服务器, 那么当sentinel1向服务器的__sentinel__:hello频道发送一条信息时,所有订阅了__sentinel__:hello频道的Sentinel(包括sentinel1自己在内)都会收到这条信息。

Redis笔记 - 图60

当一个Sentine__sentinel__:hello频道收到一条信息时,Sentinel会对这条信息进行分析,提取出信息中的Sentinel IP地址、端口号、Sentinel运行ID等参数,并作以下检查:

  1. 如果信息中记录的Sentinel运行ID和接收信息中Sentinel的运行ID 相同,说明这条信息是Sentinel自己发送的,Sentinel将丢弃这条信息,不做进一步处理。
  2. 如果信息中记录的Sentinel运行ID和接收信息的Sentinel的运行ID不相同,那么说明这条信息是监视同一个服务器的其他Sentinel发来的, 接收信息的Sentinel 将根据信息中的各个参数, 对相应主服务器的实例结构进行更新。

这里的更新包括:主服务sentinels属性的更新和创建连向其他Sentinel命令连接

(1) Sentinel为主服务器创建的实例结构中的sentinels属性保存了除Sentinel本身之外,所有同样监视这个主服务器的其他Sentinel的资料。当一个Sentinel接收到其他Sentinel发来的信息时(我们称呼发送信息的Sentinel为源Sentinel, 接收信息的Sentinel为目标Sentinel),根据信息中提取出主服务器参数,目标Sentinel会在自己的Sentinel状态的masters字典中查找相应的主服务器实例结构, 然后根据提取出的Sentinel参数,检查主服务器实例结构的sentinels中,源Sentinel的实例结构是否存在:

  1. 如果源Sentinel的实例结构已经存在, 那么对源Sentinel的实例结构进行更新。
  2. 如果源Sentinel的实例结构不存在,那么说明源Sentinel是刚刚开始监视主服务器的新Sentinel, 目标Sentinel会为源Sentinel创建一个新的实例结构,并将这个结构添加到sentinels属性里面。

因为一个Sentinel可以通过分析接收到的频道信息来获取其他Sentinel的存在,并通过发送频道信息让其他Sentinel知道自己的存在,所以用户在使用Sentinel时不需要提供各个Sentinel的地址信息,监视同一个主服务器的多个Sentinel可以自动发现对方。

(2) 创建连向其他Sentinel命令连接

当Sentinel通过频道信息发现一个新的Sentinel时, 它不仅会为新Sentinel在sentinels中创建相应的实例结构, 还会创建一个连向新Sentinel的命令连接, 而新Sentinel也同样会创建连向这个Sentinel的命令连接, 最终监视同一主服务器的多个Sentinel将形成相互连接的网络。

Redis笔记 - 图61


检测主观下线状态

Sentinel每秒一次向所有与它建立了命令连接的实例(主服务器、从服务器和其他Sentinel)发送PING命令

如果

  • 实例在down-after-milliseconds毫秒内返回无效回复(除了+PONG、-LOADING、-MASTERDOWN外)

  • 实例在down-after-milliseconds毫秒内无回复(超时)

Sentinel就会认为该实例主观下线(SDown)


检查客观下线状态

当一个Sentinel将一个主服务器判断为主观下线后

Sentinel会向同时监控这个主服务器的所有其他Sentinel发送查询命令

  1. SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>

其他Sentinel回复

  1. <down_state>< leader_runid >< leader_epoch >

判断它们是否也认为主服务器下线。如果达到Sentinel配置中的quorum数量的Sentinel实例都判断主服 务器为主观下线,则该主服务器就会被判定为客观下线(ODown)。

10.3 sentinel命令

以下列出的是 Sentinel 接受的命令:

  • PING :返回 PONG
  • SENTINEL masters :列出所有被监视的主服务器,以及这些主服务器的当前状态。
  • SENTINEL slaves :列出给定主服务器的所有从服务器,以及这些从服务器的当前状态。
  • SENTINEL get-master-addr-by-name : 返回给定名字的主服务器的 IP 地址和端口号。 如果这个主服务器正在执行故障转移操作, 或者针对这个主服务器的故障转移操作已经完成, 那么这个命令返回新的主服务器的 IP 地址和端口号。
  • SENTINEL reset : 重置所有名字和给定模式 pattern 相匹配的主服务器。 pattern 参数是一个 Glob 风格的模式。 重置操作清除主服务器目前的所有状态, 包括正在执行中的故障转移, 并移除目前已经发现和关联的, 主服务器的所有从服务器和 Sentinel 。
  • SENTINEL failover : 当主服务器失效时, 在不询问其他 Sentinel 意见的情况下, 强制开始一次自动故障迁移 (不过发起故障转移的 Sentinel 会向其他 Sentinel 发送一个新的配置,其他 Sentinel 会根据这个配置进行相应的更新)。

10.4 哨兵leader选举

Sentinel选举使用的Raft协议。

Raft协议是用来解决分布式系统一致性问题的协议。

Raft协议描述的节点共有三种状态:Leader, Follower, Candidate。 term:Raft协议将时间切分为一个个的Term(任期),可以认为是一种“逻辑时间”。

Raft选举流程:

Raft采用心跳机制触发Leader选举 系统启动后,全部节点初始化为Follower,term为0。

节点如果收到了RequestVote或者AppendEntries,就会保持自己的Follower身份

节点如果一段时间内没收到AppendEntries消息,在该节点的超时时间内还没发现Leader,Follower就 会转换成Candidate,自己开始竞选Leader。

一旦转化为Candidate,该节点立即开始下面几件事情:

  • 增加自己的term。
  • 启动一个新的定时器。
  • 给自己投一票。
  • 向所有其他节点发送RequestVote,并等待其他节点的回复。

如果在计时器超时前,节点收到多数节点的同意投票,就转换成Leader。同时向所有其他节点发送 AppendEntries,告知自己成为了Leader。

每个节点在一个term内只能投一票,采取先到先得的策略,Candidate前面说到已经投给了自己, Follower会投给第一个收到RequestVote的节点。

Raft协议的定时器采取随机超时时间,这是选举Leader的关键。 在同一个term内,先转为Candidate的节点会先发起投票,从而获得多数票。

Sentinel Leader选举流程

1、某Sentinel认定master客观下线后,该Sentinel会先看看自己有没有投过票,如果自己已经投过票 给其他Sentinel了,在一定时间内自己就不会成为Leader。

2、如果该Sentinel还没投过票,那么它就成为Candidate。

3、Sentinel需要完成几件事情:

  • 更新故障转移状态为start
  • 当前epoch加1,相当于进入一个新term,在Sentinel中epoch就是Raft协议中的term。
  • 向其他节点发送 is-master-down-by-addr 命令请求投票。命令会带上自己的epoch。
  • 给自己投一票(leader、leader_epoch)

4、当其它哨兵收到此命令时,可以同意或者拒绝它成为领导者;(通过判断epoch)

5、Candidate会不断的统计自己的票数,直到他发现认同他成为Leader的票数超过一半而且超过它配 置的quorum,这时它就成为了Leader。

6、其他Sentinel等待Leader从slave选出master后,检测到新的master正常工作后,就会去掉客观下线的标识。

10.5 故障转移

当选举出Leader Sentinel后,Leader Sentinel会对下线的主服务器执行故障转移操作,主要有三个步 骤:

  1. 它会将失效 Master 的其中一个 Slave 升级为新的 Master , 并让失效 Master 的其他 Slave 改为复 制新的 Master ;
  2. 当客户端试图连接失效的 Master 时,集群也会向客户端返回新 Master 的地址,使得集群可以使用现在的 Master 替换失效 Master 。
  3. Master 和 Slave 服务器切换后, Master 的 redis.conf 、 Slave 的 redis.conf 和 sentinel.conf 的配置文件的内容都会发生相应的改变,sentinel.conf 的监控目标会随之调换。

主服务器的选择

哨兵leader根据以下规则从客观下线的主服务器的从服务器中选择出新的master服务器。

  1. 过滤掉主观下线的节点
  2. 选择replica-priority最低的节点,如果有则返回没有就继续选择 默认为100。如果为0不选则
  3. 选择出复制偏移量最大的系节点,因为复制偏移量越大则数据复制的越完整,如果有就返回了,没有就继续
  4. 选择run_id最小的节点,因为run_id越小说明重启次数越少

11 分区

分区是将数据分布在多个Redis实例(Redis主机)上,以至于每个实例只包含一部分数据。

  • 性能的提升 单机Redis的网络I/O能力和计算资源是有限的,将请求分散到多台机器,充分利用多台机器的计算能力 和网络带宽,有助于提高Redis总体的服务能力。
  • 存储能力的横向扩展 即使Redis的服务能力能够满足应用需求,但是随着存储数据的增加,单台机器受限于机器本身的存储容量,将数据分散到多台机器上存储使得Redis服务可以横向扩展。

范围

根据id数字的范围比如1—10000、100001—20000…..90001-100000,每个范围分到不同的Redis实例中

好处: 实现简单,方便迁移和扩展

缺陷: 热点数据分布不均,性能损失,比如 3000-5000为频繁访问的热点数据,就会频繁访问这个区间的实例。

而且必须是数字型的key。

hash分区

普通hash

Redis实例=hash(key)%N key:要进行分区的键,比如user_id N:Redis实例个数(Redis主机)

好处: 支持任何类型的key 热点分布较均匀,性能较好

缺陷: 迁移复杂,需要重新计算,扩展较差。如果实例宕机或者扩容,整个数据都要进行迁移。

可以采用hash算法,比如CRC32、CRC16

一致性hash

参考 D:\lagou\笔记\第二阶段\模块二\分布式集群架构场景化解决方案笔记.md

client端分区

对于一个给定的key,客户端直接选择正确的节点来进行读写。许多Redis客户端都实现了客户端分区 (JedisPool),也可以自行编程实现。

部署方案

Redis笔记 - 图62

可以选择普通hash和一致性hash算法进行分区。

缺点

客户端需要自己处理数据路由、高可用、故障转移等问题。

使用分区,数据的处理会变得复杂,不得不对付多个redis数据库和AOF文件,不得在多个实例和主机之间持久化你的数据。

不易扩展 一旦节点的增或者删操作,都会导致key无法在redis中命中,必须重新根据节点计算,并手动迁移全部 或部分数据。

所以基本不会使用客户端分区方案。

Redis Cluster

Redis3.0之后,Redis官方提供了完整的集群解决方案。

方案采用去中心化的方式,包括:sharding(分区)、replication(复制)、failover(故障转移)。 称为RedisCluster。

Redis5.0前采用redis-trib进行集群的创建和管理,需要ruby支持

Redis5.0可以直接使用Redis-cli进行集群的创建和管理

部署架构

Redis笔记 - 图63

去中心化

RedisCluster由多个Redis节点组构成,是一个P2P(peer to peer 类似于eureka集群)无中心节点的集群架构,依靠Gossip协议传播的集群。

Gossip协议 Gossip协议是一个通信协议,一种传播消息的方式。 起源于:病毒传播

Gossip协议基本思想就是: 一个节点周期性(每秒)随机选择一些节点,并把信息传递给这些节点。 这些收到信息的节点接下来会做同样的事情,即把这些信息传递给其他一些随机选择的节点。(这样就可以将节点的信息共享)

博客【https://blog.csdn.net/jiangbb8686/article/details/107009218】【http://www.360doc.com/content/19/0324/14/99071_823813672.shtml】

信息会周期性的传递给N个目标节点。这个N被称为fanout(扇出) gossip协议包含多种消息,包括meet、ping、pong、fail、publish等等。

Redis笔记 - 图64

通过gossip协议,cluster可以提供集群间状态同步更新、选举自助failover等重要的集群功能。

Slot (Hash 槽)

redis-cluster把所有的物理节点映射到[0-16383]个slot上,采用平均分配和连续分配的方式

需要注意的是:Redis Cluster的节点之间会共享消息,每个节点都会知道是哪个节点负责哪个范围内的数据槽

Redis笔记 - 图65

当需要在 Redis 集群中放置一个 key-value 时,redis 先对 key 使用 crc16 算法算出一个结果,然后把 结果对 16384 求余数,这样每个 key 都会对应一个编号在 0-16383 之间的哈希槽,redis 会根据节点数 量大致均等的将哈希槽映射到不同的节点。

比如:

set name zhaoyun hash(“name”)采用crc16算法,得到值:1324203551%16384=15903

根据上表15903在13088-16383之间,所以name被存储在Redis5节点。

slot槽必须在节点上连续分配,如果出现不连续的情况,则RedisCluster不能工作,详见容错。

Redis Cluster 优势

高性能 Redis Cluster 的性能与单节点部署是同级别的。 多主节点、负载均衡、读写分离

高可用 Redis Cluster 支持标准的 主从复制配置来保障高可用和高可靠。Redis Cluster 也实现了一个类似 Raft 的共识方式,来保障整个集群的可用性。

易扩展 向 Redis Cluster 中添加新节点,或者移除节点,都是透明的,不需要停机。 水平、垂直方向都非常容易扩展。 数据分区,海量数据,数据存储

原生 部署 Redis Cluster 不需要其他的代理或者工具,而且 Redis Cluster 和单机 Redis 几乎完全兼 容。

Redis Cluster 搭建

redis cluster的搭建很简单。

我们这里单机模拟。

这里搭建三个集群,每个集群一个主节点,一个从节点。(实际生产环境应该有奇数个节点)

cluster 不需要自己指定那个节点是那个节点的从节点。会自动分配。

我这里创建6个配置文件就可以。然后是使用redis-server启动。

端口从6001开始-6006

其他的修改下port和pidfile就可以了

  1. protected-mode no
  2. port 6001
  3. daemonize yes
  4. pidfile /var/run/redis_6001.pid
  5. #开启cluster
  6. cluster-enabled yes

然后写一个执行脚本start.sh

  1. /usr/local/redis-5.0.10/src/redis-server /usr/local/redis-5.0.10/cluster/6001/redis.conf
  2. /usr/local/redis-5.0.10/src/redis-server /usr/local/redis-5.0.10/cluster/6002/redis.conf
  3. /usr/local/redis-5.0.10/src/redis-server /usr/local/redis-5.0.10/cluster/6003/redis.conf
  4. /usr/local/redis-5.0.10/src/redis-server /usr/local/redis-5.0.10/cluster/6004/redis.conf
  5. /usr/local/redis-5.0.10/src/redis-server /usr/local/redis-5.0.10/cluster/6005/redis.conf
  6. /usr/local/redis-5.0.10/src/redis-server /usr/local/redis-5.0.10/cluster/6006/redis.conf

要让start.sh可执行 chmod +x start.sh

执行结果

  1. root@buqingyishi:/usr/local/redis-5.0.10/cluster# ./start.sh
  2. 192:C 18 Nov 2020 17:29:14.466 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
  3. 192:C 18 Nov 2020 17:29:14.467 # Redis version=5.0.10, bits=64, commit=00000000, modified=0, pid=192, just started
  4. 192:C 18 Nov 2020 17:29:14.467 # Configuration loaded
  5. 194:C 18 Nov 2020 17:29:14.480 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
  6. 194:C 18 Nov 2020 17:29:14.481 # Redis version=5.0.10, bits=64, commit=00000000, modified=0, pid=194, just started
  7. 194:C 18 Nov 2020 17:29:14.481 # Configuration loaded
  8. 199:C 18 Nov 2020 17:29:14.502 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
  9. 199:C 18 Nov 2020 17:29:14.502 # Redis version=5.0.10, bits=64, commit=00000000, modified=0, pid=199, just started
  10. 199:C 18 Nov 2020 17:29:14.503 # Configuration loaded
  11. 201:C 18 Nov 2020 17:29:14.516 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
  12. 201:C 18 Nov 2020 17:29:14.516 # Redis version=5.0.10, bits=64, commit=00000000, modified=0, pid=201, just started
  13. 201:C 18 Nov 2020 17:29:14.516 # Configuration loaded
  14. 203:C 18 Nov 2020 17:29:14.531 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
  15. 203:C 18 Nov 2020 17:29:14.532 # Redis version=5.0.10, bits=64, commit=00000000, modified=0, pid=203, just started
  16. 203:C 18 Nov 2020 17:29:14.532 # Configuration loaded
  17. 205:C 18 Nov 2020 17:29:14.547 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
  18. 205:C 18 Nov 2020 17:29:14.547 # Redis version=5.0.10, bits=64, commit=00000000, modified=0, pid=205, just started
  19. 205:C 18 Nov 2020 17:29:14.548 # Configuration loaded
  1. root@buqingyishi:/usr/local/redis-5.0.10/cluster/6006# ps -ef|grep redis
  2. root 1299 1 0 18:18 ? 00:00:00 /usr/local/redis-5.0.10/src/redis-server 127.0.0.1:6002 [cluster]
  3. root 1318 1 0 18:19 ? 00:00:00 /usr/local/redis-5.0.10/src/redis-server 127.0.0.1:6001 [cluster]
  4. root 1358 1 0 20:30 ? 00:00:00 /usr/local/redis-5.0.10/src/redis-server 127.0.0.1:6
  5. root 1390 1 0 20:33 ? 00:00:00 /usr/local/redis-5.0.10/src/redis-server 127.0.0.1:6
  6. root 1397 1 0 20:33 ? 00:00:00 /usr/local/redis-5.0.10/src/redis-server 127.0.0.1:6
  7. root 1405 1 0 20:34 ? 00:00:00 /usr/local/redis-5.0.10/src/redis-server 127.0.0.1:6
  8. root 1420 9 0 20:36 tty1 00:00:00 grep --color=auto redis

然后可以搭建cluster集群了 —cluster-replicas 指定从节点数量

  1. ./redis-cli --cluster create 127.0.0.1:6001 127.0.0.1:6002 127.0.0.1:6003 127.0.0.1:6004 127.0.0.1:6005 127.0.0.1:6006 --cluster-replicas 1

执行信息

  1. root@buqingyishi:/usr/local/redis-5.0.10/src# ./redis-cli --cluster create 127.0.0.1:6001 127.0.0.1:6002 127.0.0.1:6003 127.0.0.1:6004 127.0.0.1:6005 127.0.0.1:6006 --cluster-replicas 1
  2. >>> Performing hash slots allocation on 6 nodes...
  3. Master[0] -> Slots 0 - 5460
  4. Master[1] -> Slots 5461 - 10922
  5. Master[2] -> Slots 10923 - 16383
  6. ## 我们看到后面的节点变成前面的从节点了
  7. Adding replica 127.0.0.1:6005 to 127.0.0.1:6001
  8. Adding replica 127.0.0.1:6006 to 127.0.0.1:6002
  9. Adding replica 127.0.0.1:6004 to 127.0.0.1:6003
  10. >>> Trying to optimize slaves allocation for anti-affinity
  11. [WARNING] Some slaves are in the same host as their master
  12. M: 41835e39f71d3037f1c6301832070a7ea5c40d9c 127.0.0.1:6001
  13. slots:[0-5460] (5461 slots) master
  14. M: 1f0f6c2cb31c8428eb7e15c29d2254f6ba65496d 127.0.0.1:6002
  15. slots:[5461-10922] (5462 slots) master
  16. M: a9a70d85bbb92546ede2fb3c86cf99dd897f79f0 127.0.0.1:6003
  17. slots:[10923-16383] (5461 slots) master
  18. S: 4a08dbfa59d31a89c8fec3b630c8258223985ace 127.0.0.1:6004
  19. replicates 41835e39f71d3037f1c6301832070a7ea5c40d9c
  20. S: aba5a898e08246708b5e1e6e5df1e9e59580c761 127.0.0.1:6005
  21. replicates 1f0f6c2cb31c8428eb7e15c29d2254f6ba65496d
  22. S: a413e911da9be619b6518e5199f71cc85ccee951 127.0.0.1:6006
  23. replicates a9a70d85bbb92546ede2fb3c86cf99dd897f79f0
  24. Can I set the above configuration? (type 'yes' to accept): yes
  25. >>> Nodes configuration updated
  26. >>> Assign a different config epoch to each node
  27. #meet
  28. >>> Sending CLUSTER MEET messages to join the cluster
  29. Waiting for the cluster to join
  30. ...
  31. >>> Performing Cluster Check (using node 127.0.0.1:6001)
  32. M: 41835e39f71d3037f1c6301832070a7ea5c40d9c 127.0.0.1:6001
  33. slots:[0-5460] (5461 slots) master
  34. 1 additional replica(s)
  35. M: 1f0f6c2cb31c8428eb7e15c29d2254f6ba65496d 127.0.0.1:6002
  36. slots:[5461-10922] (5462 slots) master
  37. 1 additional replica(s)
  38. S: a413e911da9be619b6518e5199f71cc85ccee951 127.0.0.1:6006
  39. slots: (0 slots) slave
  40. replicates a9a70d85bbb92546ede2fb3c86cf99dd897f79f0
  41. S: 4a08dbfa59d31a89c8fec3b630c8258223985ace 127.0.0.1:6004
  42. slots: (0 slots) slave
  43. replicates 41835e39f71d3037f1c6301832070a7ea5c40d9c
  44. M: a9a70d85bbb92546ede2fb3c86cf99dd897f79f0 127.0.0.1:6003
  45. slots:[10923-16383] (5461 slots) master
  46. 1 additional replica(s)
  47. S: aba5a898e08246708b5e1e6e5df1e9e59580c761 127.0.0.1:6005
  48. slots: (0 slots) slave
  49. replicates 1f0f6c2cb31c8428eb7e15c29d2254f6ba65496d
  50. [OK] All nodes agree about slots configuration.
  51. >>> Check for open slots...
  52. >>> Check slots coverage...
  53. [OK] All 16384 slots covered.
  54. root@buqingyishi:/usr/local/redis-5.0.10/src#

我们可以通过config命令

  1. 127.0.0.1:6006> config get replicaof
  2. 1) "replicaof"
  3. 2) "127.0.0.1 6003"

以及info命令

  1. 127.0.0.1:6006> info replication
  2. # Replication
  3. role:slave
  4. master_host:127.0.0.1
  5. master_port:6003
  6. master_link_status:up
  7. master_last_io_seconds_ago:2
  8. master_sync_in_progress:0
  9. slave_repl_offset:294
  10. slave_priority:100
  11. slave_read_only:1
  12. connected_slaves:0
  13. master_replid:6f1074037043a536076e3487e7b8a0e2063af73e
  14. master_replid2:0000000000000000000000000000000000000000
  15. master_repl_offset:294
  16. second_repl_offset:-1
  17. repl_backlog_active:1
  18. repl_backlog_size:1048576
  19. repl_backlog_first_byte_offset:1
  20. repl_backlog_histlen:294
  1. 127.0.0.1:6001> info replication
  2. # Replication
  3. role:master
  4. connected_slaves:1
  5. slave0:ip=127.0.0.1,port=6004,state=online,offset=350,lag=0
  6. master_replid:b8016934448e8fb7739170cf17e704db8f70558d
  7. master_replid2:0000000000000000000000000000000000000000
  8. master_repl_offset:350
  9. second_repl_offset:-1
  10. repl_backlog_active:1
  11. repl_backlog_size:1048576
  12. repl_backlog_first_byte_offset:1
  13. repl_backlog_histlen:350

查看集群状态 cluster info

  1. 127.0.0.1:6001> cluster info
  2. cluster_state:ok
  3. cluster_slots_assigned:16384
  4. cluster_slots_ok:16384
  5. cluster_slots_pfail:0
  6. cluster_slots_fail:0
  7. cluster_known_nodes:6
  8. cluster_size:3
  9. cluster_current_epoch:6
  10. cluster_my_epoch:1
  11. cluster_stats_messages_ping_sent:422
  12. cluster_stats_messages_pong_sent:477
  13. cluster_stats_messages_sent:899
  14. cluster_stats_messages_ping_received:472
  15. cluster_stats_messages_pong_received:422
  16. cluster_stats_messages_meet_received:5
  17. cluster_stats_messages_received:899

查看集群中节点 cluster nodes。以及slot分布情况。

前面的字符串是 节点的id

Redis笔记 - 图66

也可以查看nodes.conf文件。

测试一下看到:

  1. 127.0.0.1:6001> set name:001 张飞
  2. OK
  3. 127.0.0.1:6001> set name:002 zhaozilong
  4. (error) MOVED 8545 127.0.0.1:6002
  5. 127.0.0.1:6001> get name:002
  6. (error) MOVED 8545 127.0.0.1:6002
  7. 127.0.0.1:6001>

分片

我们搭建 Cluster集群是为了让数据分片。但是Cluster集群并不具备保存数据的时候路由的能力。所以需要客户端自己来实现。

我们上面看到当计算key的hash值不在连接客户端的slot范围的时候会返回(error) MOVED 8545 127.0.0.1:6002 异常。客户端可以借助返回的信息路由到不同的服务。

moved重定向

1.每个节点通过通信都会共享Redis Cluster中槽和集群中对应节点的关系

2.客户端向Redis Cluster的任意节点发送命令,接收命令的节点会根据CRC16规则进行hash运算与 16384取余,计算自己的槽和对应节点

3.如果保存数据的槽被分配给当前节点,则去槽中执行命令,并把命令执行结果返回给客户端

4.如果保存数据的槽不在当前节点的管理范围内,则向客户端返回moved重定向异常

5.客户端接收到节点返回的结果,如果是moved异常,则从moved异常中获取目标节点的信息

6.客户端向目标节点发送命令,获取命令执行结果

Redis笔记 - 图67

  1. C:\Users\buqingyishi>redis-cli -p 6001 -c
  2. 127.0.0.1:6001> set name:001 zhangfei
  3. OK
  4. 127.0.0.1:6001> set name:002 zhaozilong
  5. -> Redirected to slot [8545] located at 127.0.0.1:6002
  6. OK
  7. 127.0.0.1:6002> get name:002
  8. "zhaozilong"
  9. 127.0.0.1:6002>

ask重定向

在对集群进行扩容和缩容时,需要对槽及槽中数据进行迁移。

当客户端向某个节点发送命令,节点向客户端返回moved异常,告诉客户端数据对应的槽的节点信息。

如果此时正在进行集群扩展或者缩空操作,当客户端向正确的节点发送命令时,槽及槽中数据已经被迁 移到别的节点了,就会返回ask,这就是ask重定向机制

1.客户端向目标节点发送命令,目标节点中的槽已经迁移支别的节点上了,此时目标节点会返回ask转 向给客户端

2.客户端向新的节点发送Asking命令给新的节点,然后再次向新节点发送命令

3.新节点执行命令,把命令执行结果返回给客户端

Redis笔记 - 图68

moved和ask区别:

1、moved:槽已确认转移

2、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!”

  1. JedisPoolConfig config = new JedisPoolConfig();
  2. Set<HostAndPort> jedisClusterNode = new HashSet<HostAndPort>();
  3. jedisClusterNode.add(new HostAndPort("127.0.0.1", 6001));
  4. jedisClusterNode.add(new HostAndPort("127.0.0.1", 6002));
  5. jedisClusterNode.add(new HostAndPort("127.0.0.1", 6003));
  6. jedisClusterNode.add(new HostAndPort("127.0.0.1", 6004));
  7. jedisClusterNode.add(new HostAndPort("127.0.0.1", 6005));
  8. jedisClusterNode.add(new HostAndPort("127.0.0.1", 6006));
  9. JedisCluster jcd = new JedisCluster(jedisClusterNode, config);
  10. jcd.set("name:001","zhangfei");
  11. String value = jcd.get("name:001");

数据迁移

在RedisCluster中每个slot 对应的节点在初始化后就是确定的。在某些情况下,节点和分片需要变更:

  • 新的节点作为master加入;
  • 某个节点分组需要下线;
  • 负载不均衡需要调整slot 分布。

此时需要进行分片的迁移,迁移的触发和过程控制由外部系统完成。包含下面 2 种:

  • 节点迁移状态设置:迁移前标记源/目标节点。

  • key迁移的原子化命令:迁移的具体步骤。

Redis笔记 - 图69

1、向节点B发送状态变更命令,将B的对应slot 状态置为importing。

2、向节点A发送状态变更命令,将A对应的slot 状态置为migrating。

3、向A 发送migrate 命令,告知A 将要迁移的slot对应的key 迁移到B。

4、当所有key 迁移完成后,cluster setslot 重新设置槽位。

扩容

1、创建一个6007节点,新加的节点必须是无数据的 。

2、执行添加命令

第二个参数是指定要加入的集群,集群中的节点随意指定一个就可以,不然怎么知道加到那个集群

  1. ./redis-cli --cluster add-node 127.0.0.1:6007 127.0.0.1:6001
  1. root@buqingyishi:/usr/local/redis-5.0.10/src# ./redis-cli --cluster add-node 127.0.0.1:6007 127.0.0.1:6001
  2. >>> Adding node 127.0.0.1:6007 to cluster 127.0.0.1:6001
  3. >>> Performing Cluster Check (using node 127.0.0.1:6001)
  4. M: 41835e39f71d3037f1c6301832070a7ea5c40d9c 127.0.0.1:6001
  5. slots:[0-5460] (5461 slots) master
  6. 1 additional replica(s)
  7. M: 1f0f6c2cb31c8428eb7e15c29d2254f6ba65496d 127.0.0.1:6002
  8. slots:[5461-10922] (5462 slots) master
  9. 1 additional replica(s)
  10. S: a413e911da9be619b6518e5199f71cc85ccee951 127.0.0.1:6006
  11. slots: (0 slots) slave
  12. replicates a9a70d85bbb92546ede2fb3c86cf99dd897f79f0
  13. S: 4a08dbfa59d31a89c8fec3b630c8258223985ace 127.0.0.1:6004
  14. slots: (0 slots) slave
  15. replicates 41835e39f71d3037f1c6301832070a7ea5c40d9c
  16. M: a9a70d85bbb92546ede2fb3c86cf99dd897f79f0 127.0.0.1:6003
  17. slots:[10923-16383] (5461 slots) master
  18. 1 additional replica(s)
  19. S: aba5a898e08246708b5e1e6e5df1e9e59580c761 127.0.0.1:6005
  20. slots: (0 slots) slave
  21. replicates 1f0f6c2cb31c8428eb7e15c29d2254f6ba65496d
  22. [OK] All nodes agree about slots configuration.
  23. >>> Check for open slots...
  24. >>> Check slots coverage...
  25. [OK] All 16384 slots covered.
  26. >>> Send CLUSTER MEET to node 127.0.0.1:6007 to make it join the cluster.
  27. [OK] New node added correctly.

我们看下集群的节点,可以看到新加入的6007为master,但是还未分配slot

Redis笔记 - 图70

3、分配slot,这时就需要hash槽重新分配(数据迁移)。

分配slot之后,新加入节点才可以存储数据。

执行命令:

  1. ./redis-cli --cluster reshard 127.0.0.1:6007
  1. root@buqingyishi:/usr/local/redis-5.0.10/src# ./redis-cli --cluster reshard 127.0.0.1:6007
  2. >>> Performing Cluster Check (using node 127.0.0.1:6007)
  3. M: 4df0cd7029d179eff11bf5c84c565b2d8420476c 127.0.0.1:6007
  4. slots: (0 slots) master
  5. S: 4a08dbfa59d31a89c8fec3b630c8258223985ace 127.0.0.1:6004
  6. slots: (0 slots) slave
  7. replicates 41835e39f71d3037f1c6301832070a7ea5c40d9c
  8. S: a413e911da9be619b6518e5199f71cc85ccee951 127.0.0.1:6006
  9. slots: (0 slots) slave
  10. replicates a9a70d85bbb92546ede2fb3c86cf99dd897f79f0
  11. M: 41835e39f71d3037f1c6301832070a7ea5c40d9c 127.0.0.1:6001
  12. slots:[0-5460] (5461 slots) master
  13. 1 additional replica(s)
  14. M: a9a70d85bbb92546ede2fb3c86cf99dd897f79f0 127.0.0.1:6003
  15. slots:[10923-16383] (5461 slots) master
  16. 1 additional replica(s)
  17. S: aba5a898e08246708b5e1e6e5df1e9e59580c761 127.0.0.1:6005
  18. slots: (0 slots) slave
  19. replicates 1f0f6c2cb31c8428eb7e15c29d2254f6ba65496d
  20. M: 1f0f6c2cb31c8428eb7e15c29d2254f6ba65496d 127.0.0.1:6002
  21. slots:[5461-10922] (5462 slots) master
  22. 1 additional replica(s)
  23. [OK] All nodes agree about slots configuration.
  24. >>> Check for open slots...
  25. >>> Check slots coverage...
  26. [OK] All 16384 slots covered.
  27. How many slots do you want to move (from 1 to 16384)?

How many slots do you want to move (from 1 to 16384)? 问分配多少个slot。看实际情况,应该平均分配。

  1. How many slots do you want to move (from 1 to 16384)? 3000
  2. What is the receiving node ID?

我输入3000会继续问接收slot的节点id,我们可用通过cluster nodes 找到6007的node id。前面的信息也有。

  1. What is the receiving node ID? 4df0cd7029d179eff11bf5c84c565b2d8420476c

然后会让你选择从哪里转移slot。输入all,是从所有节点转移。也可以选择其他node id然后最后上 done

  1. How many slots do you want to move (from 1 to 16384)? 3000
  2. What is the receiving node ID? 4df0cd7029d179eff11bf5c84c565b2d8420476c
  3. Please enter all the source node IDs.
  4. Type 'all' to use all the nodes as source nodes for the hash slots.
  5. Type 'done' once you entered all the source nodes IDs.
  6. Source node #1:

我这里选择all

然后等它执行

  1. .......
  2. Moving slot 11919 from a9a70d85bbb92546ede2fb3c86cf99dd897f79f0
  3. Moving slot 11920 from a9a70d85bbb92546ede2fb3c86cf99dd897f79f0
  4. Moving slot 11921 from a9a70d85bbb92546ede2fb3c86cf99dd897f79f0
  5. Do you want to proceed with the proposed reshard plan (yes/no)?

yes

然后等执行完毕

可以查看 节点信息 cluster nodes

Redis笔记 - 图71

可以看到 6007节点重新分配了slot。是从其他三个节点转移过来的。

4、分配从节点

上面的6007还没有分配从节点。

我们可以新启动一个6008节点,作为6007的从节点。

命令

  1. ./redis-cli --cluster add-node 新节点的ip和端口 旧节点ip和端口 --cluster-slave --
  2. cluster-master-id 主节点id
  1. ./redis-cli --cluster add-node 127.0.0.1:6008 127.0.0.1:6007 --
  2. cluster-slave --cluster-master-id 4df0cd7029d179eff11bf5c84c565b2d8420476c
  1. root@buqingyishi:/usr/local/redis-5.0.10/cluster/6008# /usr/local/redis-5.0.10/src/redis-cli --cluster add-node 127.0.0.1:6008 127.0.0.1:6007 --cluster-slave --cluster-master-id 4df0cd7029d179eff11bf5c84c565b2d8420476c
  2. >>> Adding node 127.0.0.1:6008 to cluster 127.0.0.1:6007
  3. >>> Performing Cluster Check (using node 127.0.0.1:6007)
  4. M: 4df0cd7029d179eff11bf5c84c565b2d8420476c 127.0.0.1:6007
  5. slots:[0-998],[5461-6461],[10923-11921] (2999 slots) master
  6. M: aba5a898e08246708b5e1e6e5df1e9e59580c761 127.0.0.1:6005
  7. slots:[6462-10922] (4461 slots) master
  8. 1 additional replica(s)
  9. S: 1f0f6c2cb31c8428eb7e15c29d2254f6ba65496d 127.0.0.1:6002
  10. slots: (0 slots) slave
  11. replicates aba5a898e08246708b5e1e6e5df1e9e59580c761
  12. S: 4a08dbfa59d31a89c8fec3b630c8258223985ace 127.0.0.1:6004
  13. slots: (0 slots) slave
  14. replicates 41835e39f71d3037f1c6301832070a7ea5c40d9c
  15. M: 41835e39f71d3037f1c6301832070a7ea5c40d9c 127.0.0.1:6001
  16. slots:[999-5460] (4462 slots) master
  17. 1 additional replica(s)
  18. M: a413e911da9be619b6518e5199f71cc85ccee951 127.0.0.1:6006
  19. slots:[11922-16383] (4462 slots) master
  20. 1 additional replica(s)
  21. S: a9a70d85bbb92546ede2fb3c86cf99dd897f79f0 127.0.0.1:6003
  22. slots: (0 slots) slave
  23. replicates a413e911da9be619b6518e5199f71cc85ccee951
  24. [OK] All nodes agree about slots configuration.
  25. >>> Check for open slots...
  26. >>> Check slots coverage...
  27. [OK] All 16384 slots covered.
  28. >>> Send CLUSTER MEET to node 127.0.0.1:6008 to make it join the cluster.
  29. Waiting for the cluster to join
  30. >>> Configure node as replica of 127.0.0.1:6007.
  31. [OK] New node added correctly.

然后查看下集群信息

Redis笔记 - 图72

缩容

命令 最后一个参数是要删除的节点id,倒数第二个节点为集群中的某一个节点的ip:port可以随意指定。只要输入集群中的节点就可以(从节点也可以)。

  1. ./redis-cli --cluster del-node 127.0.0.1:6001 4df0cd7029d179eff11bf5c84c565b2d8420476c

加入删除的节点有分配slot:会报错的,说明需要重新reshard 转移slot。

  1. root@buqingyishi:/usr/local/redis-5.0.10/src# ./redis-cli --cluster del-node 127.0.0.1:6005 4df0cd7029d179eff11bf5c84c565b2d8420476c
  2. >>> Removing node 4df0cd7029d179eff11bf5c84c565b2d8420476c from cluster 127.0.0.1:6005
  3. [ERR] Node 127.0.0.1:6007 is not empty! Reshard data away and try again.
  1. 127.0.0.1:6001> set name:008 hah
  2. -> Redirected to slot [43] located at 127.0.0.1:6007
  3. OK
  4. 127.0.0.1:6007> get name:008
  5. "hah"
  6. 127.0.0.1:6007> keys *
  7. 1) "name:008"
  8. 127.0.0.1:6007> del name:008
  9. (integer) 1
  10. 127.0.0.1:6007>

容灾 failover

故障检测

集群中的每个节点都会定期地(每秒)向集群中的其他节点发送PING消息

如果在一定时间内(配置文件中 cluster-node-timeout属性 默认为15s),发送ping的节点A没有收到某节点B的pong回应,则A将B 标识为pfail。

A在后续发送ping时,会带上B的pfail信息, 通知给其他节点。

如果B被标记为pfail的个数大于集群主节点个数的一半(N/2 + 1)时,B会被标记为fail,A向整个集群 广播,该节点已经下线。

其他节点收到广播,标记B为fail。

从节点选举

类似于raft协议选举方式。当集群中一个节点的master挂了,这个节点的从节点参与选举成为新的master,集群中其他的master节点作为投票者。

每个从节点,都根据自己对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。

RedisCluster失效的判定:

1、集群中半数以上的主节点都宕机(无法投票)

2、宕机的主节点的从节点也宕机了(slot槽分配不连续)

变更通知 当slave 收到过半的master 同意时,会成为新的master。

此时会以最新的Epoch 通过PONG 消息广播 自己成为master,让Cluster 的其他节点尽快的更新拓扑结构(node.conf)。

主从切换

自动切换 就是上面的主节点挂了,自动发起master选举

手动切换:

参考文档:【http://www.redis.cn/commands/cluster-failover.html】

人工故障切换是预期的操作,而非发生了真正的故障,目的是以一种安全的方式(数据无丢失)将当前 master节点和其中一个slave节点(执行cluster-failover的节点)交换角色 只能在从节点执行该命令。

1、向从节点发送cluster failover 命令(slaveof no one)

2、从节点告知其主节点要进行手动切换(CLUSTERMSG_TYPE_MFSTART)

3、主节点会阻塞所有客户端命令的执行(10s)

4、从节点从主节点的ping包中获得主节点的复制偏移量

5、从节点复制达到偏移量,发起选举、统计选票、赢得选举、升级为主节点并更新配置

6、切换完成后,原主节点向所有客户端发送moved指令重定向到新的主节点

以上是在主节点在线情况下。 如果主节点下线了,则采用cluster failover force或cluster failover takeover 进行强制切换。

cluster相关命令

  1. 127.0.0.1:6001> cluster help
  2. 1) CLUSTER <subcommand> arg arg ... arg. Subcommands are:
  3. 2) ADDSLOTS <slot> [slot ...] -- Assign slots to current node.
  4. 3) BUMPEPOCH -- Advance the cluster config epoch.
  5. 4) COUNT-failure-reports <node-id> -- Return number of failure reports for <node-id>.
  6. 5) COUNTKEYSINSLOT <slot> - Return the number of keys in <slot>.
  7. 6) DELSLOTS <slot> [slot ...] -- Delete slots information from current node.
  8. 7) FAILOVER [force|takeover] -- Promote current replica node to being a master.
  9. 8) FORGET <node-id> -- Remove a node from the cluster.
  10. 9) GETKEYSINSLOT <slot> <count> -- Return key names stored by current node in a slot.
  11. 10) FLUSHSLOTS -- Delete current node own slots information.
  12. 11) INFO - Return onformation about the cluster.
  13. 12) KEYSLOT <key> -- Return the hash slot for <key>.
  14. 13) MEET <ip> <port> [bus-port] -- Connect nodes into a working cluster.
  15. 14) MYID -- Return the node id.
  16. 15) NODES -- Return cluster configuration seen by node. Output format:
  17. 16) <id> <ip:port> <flags> <master> <pings> <pongs> <epoch> <link> <slot> ... <slot>
  18. 17) REPLICATE <node-id> -- Configure current node as replica to <node-id>.
  19. 18) RESET [hard|soft] -- Reset current node (default: soft).
  20. 19) SET-config-epoch <epoch> - Set config epoch of current node.
  21. 20) SETSLOT <slot> (importing|migrating|stable|node <node-id>) -- Set slot state.
  22. 21) REPLICAS <node-id> -- Return <node-id> replicas.
  23. 22) SLOTS -- Return information about slots range mappings. Each range is made of:
  24. 23) start, end, master and replicas IP addresses, ports and ids

副本漂移

我们知道在一主一从的情况下,如果主从同时挂了,那整个集群就挂了。

为了避免这种情况我们可以做一主多从,但这样成本就增加了。

Redis提供了一种方法叫副本漂移,这种方法既能提高集群的可靠性又不用增加太多的从机。

Redis笔记 - 图73

Master1宕机,则Slaver11提升为新的Master1 集群检测到新的Master1是单点的(无从机) 集群从拥有最多的从机的节点组(Master3)中,选择节点名称字母顺序最小的从机(Slaver31)漂移 到单点的主从节点组(Master1)。

具体流程如下(以上图为例):

1、将Slaver31的从机记录从Master3中删除

2、将Slaver31的的主机改为Master1

3、在Master1中添加Slaver31为从节点

4、将Slaver31的复制源改为Master1

5、通过ping包将信息同步到集群的其他节点

12 Codis(代理端分区)

Codis由豌豆荚于2014年11月开源,基于Go和C开发,是近期涌现的、国人开发的优秀开源软件之一。

codis和redis没啥关系。就相当于另一款nosql数据库。

Redis笔记 - 图74

Codis 3.x 由以下组件组成:

Codis Server:基于 redis-3.2.8 分支开发。增加了额外的数据结构,以支持 slot 有关的操作以及数据迁移指令。

Codis Proxy:客户端连接的 Redis 代理服务, 实现了 Redis 协议。 除部分命令不支持以外(https://github.com/CodisLabs/codis/blob/release3.2/doc/unsupported_cmds.md),表现 的和原生的 Redis 没有区别(就像 Twemproxy)。

  • 对于同一个业务集群而言,可以同时部署多个 codis-proxy 实例;
  • 不同 codis-proxy 之间由 codis-dashboard 保证状态同步。

Codis Dashboard:集群管理工具,支持 codis-proxy、codis-server 的添加、删除,以及据迁移 等操作。在集群状态发生改变时,codis-dashboard 维护集群下所有 codis-proxy 的状态的一致性。

  • 对于同一个业务集群而言,同一个时刻 codis-dashboard 只能有 0个或者1个;
  • 所有对集群的修改都必须通过 codis-dashboard 完成。

Codis Admin:集群管理的命令行工具。 可用于控制 codis-proxy、codis-dashboard 状态以及访问外部存储。

Codis FE:集群管理界面。 多个集群实例共享可以共享同一个前端展示页面; 通过配置文件管理后端 codis-dashboard 列表,配置文件可自动更新。

Storage:为集群状态提供外部存储。 提供 Namespace 概念,不同集群的会按照不同 product name 进行组织;目前仅提供了 Zookeeper、Etcd、Fs 三种实现,但是提供了抽象的 interface 可自行扩展。

12.1 按装环境

看了老师的安装教程还是挺麻烦的。我这里下载的是codis编译好的,不是下载的源码自己编译的。

可以参考官方文档【https://github.com/CodisLabs/codis/blob/release3.2/doc/tutorial_zh.md】

下载软件

  1. #下载golang1.8.3 不好下载可以从本地上传 rz
  2. wget https://storage.googleapis.com/golang/go1.8.3.linux-amd64.tar.gz
  3. #解压
  4. tar zxvf go1.8.3.linux-amd64.tar.gz
  5. #下载jdk
  6. #采用rz上传 jdk-linux-x64.tar.gz
  7. tar -zxvf jdk-linux-x64.tar.gz
  8. #下载zookeeper
  9. wget https://mirrors.tuna.tsinghua.edu.cn/apache/zookeeper/zookeeper3.4.14/zookeeper-3.4.14.tar.gz
  10. #解压
  11. tar -zxvf zookeeper-3.4.14.tar.gz

配置启动

  1. #golang
  2. export GOROOT=/usr/local/go
  3. #codis编译路径
  4. export GOPATH=/usr/local/codis
  5. #java
  6. export JAVA_HOME=/usr/local/jdk-11.0.2
  7. export JRE_HOME=${JAVA_HOME}/jre
  8. export CLASSPATH=.:${JAVA_HOME}/lib:${JRE_HOME}/lib:$CLASSPATH
  9. export JAVA_PATH=${JAVA_HOME}/bin:${JRE_HOME}/bin
  10. #zookeeper
  11. export ZOOKEEPER_HOME=/usr/local/zookeeper
  12. #path
  13. export PATH=$PATH:${GOROOT}/bin:${JAVA_PATH}:${ZOOKEEPER_HOME}/bin

source /etc/profile

然后执行以下

java —version

go version看一下是否配置成功

zookeeper配置文件:

  1. # The number of milliseconds of each tick
  2. tickTime=2000
  3. # The number of ticks that the initial
  4. # synchronization phase can take
  5. initLimit=10
  6. # The number of ticks that can pass between
  7. # sending a request and getting an acknowledgement
  8. syncLimit=5
  9. # the directory where the snapshot is stored.
  10. # do not use /tmp for storage, /tmp here is just
  11. # example sakes.
  12. dataDir=../data # the port at which the clients will connect
  13. clientPort=2181 # the maximum number of client connections.
  14. # increase this if you need to handle more clients
  15. #maxClientCnxns=60
  16. #
  17. # Be sure to read the maintenance section of the
  18. # administrator guide before turning on autopurge.
  19. #
  20. # http://zookeeper.apache.org/doc/current/zookeeperAdmin.html#sc_maintenance
  21. #
  22. # The number of snapshots to retain in dataDir
  23. #autopurge.snapRetainCount=3
  24. # Purge task interval in hours
  25. # Set to "0" to disable auto purge feature
  26. #autopurge.purgeInterval=1

启动zookeeper zkServer.sh start

dashboard配置

codis-dashboard默认配置文件为dashboard.toml。

参数说明

  1. $ ./bin/codis-dashboard -h
  2. Usage:
  3. codis-dashboard [--ncpu=N] [--config=CONF] [--log=FILE] [--log-level=LEVEL] [--host-admin=ADDR]
  4. codis-dashboard --default-config
  5. codis-dashboard --version
  6. Options:
  7. --ncpu=N 最大使用 CPU 个数
  8. -c CONF, --config=CONF 指定启动配置文件
  9. -l FILE, --log=FILE 设置 log 输出文件
  10. --log-level=LEVEL 设置 log 输出等级:INFO,WARN,DEBUG,ERROR;默认INFO,推荐WARN

配置文件参数说明:

  1. 参数 说明
  2. coordinator_name 外部存储类型,接受 zookeeper/etcd
  3. coordinator_addr 外部存储地址
  4. product_name 集群名称,满足正则 \w[\w\.\-]*
  5. product_auth 集群密码,默认为空
  6. admin_addr RESTful API 端口

示例

  1. # Set Coordinator, only accept "zookeeper" & "etcd"
  2. #外部存储类型 不同集群的会按照不同 product name 进行组织
  3. coordinator_name="zookeeper"
  4. #外部存储地址
  5. coordinator_addr="127.0.0.1:2181"
  6. # Set Codis Product {Name/Auth}.
  7. #集群名称
  8. product_name="codis-demo"
  9. product_auth=""
  10. # Set bind address for admin(rpc), tcp only.
  11. #访问端口
  12. admin_addr="0.0.0.0:18080"

启动dashboard

  1. nohup ./codis-dashboard -c config/dashboard.toml > dashboard.log &

可以访问 localhot:18080地址

codis-proxy配置

codis-proxy默认配置文件 proxy.toml

codis-proxy 启动后,处于 waiting 状态,监听 proxy_addr 地址,但是不会 accept 连接,添加到集群并完成集群状态的同步,才能改变状态为 online。添加的方法有以下两种:

  • 通过 codis-fe 添加:通过 Add Proxy 按钮,将 admin_addr 加入到集群中;

  • 通过 codis-admin 命令行工具添加,方法如下:

    1. $ ./bin/codis-admin --dashboard=127.0.0.1:18080 --create-proxy -x 127.0.0.1:11080

其中 127.0.0.1:18080 以及 127.0.0.1:11080 分别为 dashboard 和 proxy 的 admin_addr 地址;

添加过程中,dashboard 会完成如下一系列动作:

  • 获取 proxy 信息,对集群 name 以及 auth 进行验证,并将其信息写入到外部存储中;
  • 同步 slots 状态;
  • 标记 proxy 状态为 online,此后 proxy 开始 accept 连接并开始提供服务;

参数说明:

  1. $ ./bin/codis-proxy -h
  2. Usage:
  3. codis-proxy [--ncpu=N] [--config=CONF] [--log=FILE] [--log-level=LEVEL] [--host-admin=ADDR] [--host-proxy=ADDR] [--ulimit=NLIMIT]
  4. codis-proxy --default-config
  5. codis-proxy --version
  6. Options:
  7. --ncpu=N 最大使用 CPU 个数
  8. -c CONF, --config=CONF 指定启动配置文件
  9. -l FILE, --log=FILE 设置 log 输出文件
  10. --log-level=LEVEL 设置 log 输出等级:INFO,WARN,DEBUG,ERROR;默认INFO,推荐WARN
  11. --ulimit=NLIMIT 检查 ulimit -n 的结果,确保运行时最大文件描述不少于 NLIMIT

配置文件参数说明

参数 说明
product_name 集群名称,参考 dashboard 参数说明
product_auth 集群密码,默认为空
admin_addr RESTful API 端口 此处dashboard的admin_addr
proto_type Redis 端口类型,接受 tcp/tcp4/tcp6/unix/unixpacket
proxy_addr Redis 端口地址或者路径
jodis_addr Jodis 注册 zookeeper 地址
jodis_timeout Jodis 注册 session timeout 时间,单位 second
jodis_compatible Jodis 注册 zookeeper 的路径
backend_ping_period 与 codis-server 探活周期,单位 second,0 表示禁止
session_max_timeout 与 client 连接最大读超时,单位 second,0 表示禁止
session_max_bufsize 与 client 连接读写缓冲区大小,单位 byte
session_max_pipeline 与 client 连接最大的 pipeline 大小
session_keepalive_period 与 client 的 tcp keepalive 周期,仅 tcp 有效,0 表示禁止

示例:

  1. # Set Codis Product {Name/Auth}.
  2. #集群名称
  3. product_name = "codis-demo"
  4. #集群密码
  5. product_auth = ""
  6. # Set bind address for admin(rpc), tcp only.
  7. # RESTful API 端口
  8. admin_addr = "0.0.0.0:11080"
  9. # Set bind address for proxy, proto_type can be "tcp", "tcp4", "tcp6", "unix" or "unixpacket".
  10. #Redis 端口类型,接受 tcp/tcp4/tcp6/unix/unixpacket
  11. proto_type = "tcp4"
  12. #Redis 端口地址
  13. proxy_addr = "0.0.0.0:19000"
  14. # Set jodis address & session timeout.
  15. #Jodis 注册 zookeeper 地址
  16. jodis_addr = "127.0.0.1:2181"
  17. jodis_timeout = 10
  18. jodis_compatible = false
  19. # Proxy will ping-pong backend redis periodly to keep-alive
  20. #与 codis-server 探活周期,单位 second,0 表示禁止
  21. backend_ping_period = 5
  22. # If there is no request from client for a long time, the connection will be droped. Set 0 to disable.
  23. #与 client 连接最大读超时,单位 second,0 表示禁止
  24. session_max_timeout = 1800
  25. #与 client 连接读写缓冲区大小,单位 byte 128KB
  26. # Buffer size for each client connection.
  27. session_max_bufsize = 131072
  28. # Number of buffered requests for each client connection.
  29. # Make sure this is higher than the max number of requests for each pipeline request, or your client may be blocked.
  30. #与 client 连接最大的 pipeline 大小
  31. session_max_pipeline = 1024
  32. #与 client 的 tcp keepalive 周期,仅 tcp 有效,0 表示禁止 默认60 秒
  33. # Set period between keep alives. Set 0 to disable.
  34. session_keepalive_period = 60

启动proxy

  1. nohup ./codis-proxy --config=config/proxy.toml > proxy.log &

可以启动多个codis-proxy,加入到同一个product_name 集群。然后再在fe管理界面添加。避免出现单点问题。

codis-server

因为我这里用的codis 3.2.1版本 是基于redis 3.2.8 的。所以从节点 使用的是slaveof masterip:masterport而不是replicaof。

codis启动就用redis.conf文件。

因为没有区分路径所以后缀需要改一下。

master改这几个就可以

  1. bind 127.0.0.1
  2. port 8379
  3. protected-mode no
  4. daemonize yes
  5. pidfile "/var/run/redis_8379.pid"
  6. logfile "redis_8379.log"
  7. dbfilename dump_8379.rdb

slave

  1. bind 127.0.0.1
  2. port 8380
  3. slaveof 127.0.0.1 8379
  4. protected-mode no
  5. daemonize yes
  6. pidfile "/var/run/redis_8380.pid"
  7. logfile "redis_8380.log"
  8. dbfilename dump_8380.rdb

启动

  1. ./codis-server config/redis_8379.conf
  2. ./codis-server config/redis_8380.conf

可以连接之后看下状态

  1. C:\Users\buqingyishi>redis-cli -p 8380
  2. 127.0.0.1:8380> info replication
  3. # Replication
  4. role:slave
  5. master_host:127.0.0.1
  6. master_port:8379
  7. master_link_status:up
  8. master_last_io_seconds_ago:2
  9. master_sync_in_progress:0
  10. slave_repl_offset:925
  11. slave_priority:100
  12. slave_read_only:1
  13. connected_slaves:0
  14. master_repl_offset:0
  15. repl_backlog_active:0
  16. repl_backlog_size:1048576
  17. repl_backlog_first_byte_offset:0
  18. repl_backlog_histlen:0

codis-fe

启动codis-fe管理界面。

启动命令:

  1. $ nohup ./bin/codis-fe --ncpu=4 --log=fe.log --log-level=WARN \
  2. --zookeeper=127.0.0.1:2181 --listen=127.0.0.1:8080 &

参数说明:

  1. $ ./bin/codis-fe -h
  2. Usage:
  3. codis-fe [--ncpu=N] [--log=FILE] [--log-level=LEVEL] [--assets-dir=PATH] (--dashboard-list=FILE|--zookeeper=ADDR|--etcd=ADDR|--filesystem=ROOT) --listen=ADDR
  4. codis-fe --version
  5. Options:
  6. --ncpu=N 最大使用 CPU 个数
  7. -d LIST, --dashboard-list=LIST 配置文件,能够自动刷新
  8. -l FILE, --log=FILE 设置 log 输出文件
  9. --log-level=LEVEL 设置 log 输出等级:INFO,WARN,DEBUG,ERROR;默认INFO,推荐WARN
  10. --listen=ADDR HTTP 服务端口

配置文件 codis.json 可以手动编辑,也可以通过 codis-admin 从外部存储中拉取,例如:

  1. $ ./bin/codis-admin --dashboard-list --zookeeper=127.0.0.1:2181 | tee codis.json
  2. [
  3. {
  4. "name": "codis-demo",
  5. "dashboard": "127.0.0.1:18080"
  6. },
  7. {
  8. "name": "codis-demo2",
  9. "dashboard": "127.0.0.1:28080"
  10. }
  11. ]

我们这里不用配置文件启动

  1. nohup ./codis-fe --zookeeper=127.0.0.1:2181 --listen=127.0.0.1:9090 > fe.log &

fe界面添加 proxy,和group(codis-server集群),sentinel

添加proxy

Redis笔记 - 图75

添加 group,codis-server。

一个codis-server主从集群为一个group。

首先需要先New Group指定一个Group。

然后Add Server分配到指定Group。

master

Redis笔记 - 图76

slave

Redis笔记 - 图77

此时slot还未分配,我们需要重新分配。

当添加新的group的时候需要点击这里重新分配slot。

Redis笔记 - 图78

Redis笔记 - 图79

因为现在只有一个group,把1024个slot全部分给group 1。

**默认的slot为0-1023共1024个slot。

Redis笔记 - 图80

可以看到全部分给了group 1。

我们可以添加哨兵 sentinel。让哨兵对 codis-server集群做failover。我们需要使用codis的redis-sentinel命令来启动。

Redis笔记 - 图81

我们客户端连接代理端口,即codis-proxy 配置的proxy_addr = “0.0.0.0:19000” 19000端口。

  1. sr\local\zookeeper>redis-cli -p 19000
  2. 127.0.0.1:19000> keys *
  3. (error) ERR handle request, command 'KEYS' is not allowed
  4. 127.0.0.1:19000> set name:001 zhangfei
  5. OK
  6. 127.0.0.1:19000> set name:002 zhaoyun
  7. OK
  8. 127.0.0.1:19000> get name:001
  9. "zhangfei"
  10. 127.0.0.1:19000>

Redis笔记 - 图82

接下来新增一个group,然后添加一个master节点。

添加从节点和上面同样的方式就可以 。

Redis笔记 - 图83

这时需要Rebalance All Slots了

  1. sr\local\zookeeper>redis-cli -p 19000
  2. 127.0.0.1:19000> keys *
  3. (error) ERR handle request, command 'KEYS' is not allowed
  4. 127.0.0.1:19000> set name:001 zhangfei
  5. OK
  6. 127.0.0.1:19000> set name:002 zhaoyun
  7. OK
  8. 127.0.0.1:19000> get name:001
  9. "zhangfei"
  10. 127.0.0.1:19000> set name:003 diaochan
  11. OK
  12. 127.0.0.1:19000> set name:004 lvbu
  13. OK
  14. 127.0.0.1:19000> set name:111 bob
  15. OK
  16. 127.0.0.1:19000> set name:112 liubei
  17. OK
  18. 127.0.0.1:19000>

Redis笔记 - 图84

12.2 分片原理

Codis 将所有的 key 默认划分为 1024 个槽位(slot),它首先对客户端传过来的 key 进行 crc32 运算计算 哈希值,再将 hash 后的整数值对 1024 这个整数进行取模得到一个余数,这个余数就是对应 key 的槽 位。

Redis笔记 - 图85

Codis的槽位和分组的映射关系就保存在codis proxy当中。

zookeeper中存储的数据。

Redis笔记 - 图86

实例之间槽位同步

codis proxy存在单点问题,需要做集群。

不同proxy之间需要同步映射关系

在Codis中使用的是Zookeeper(Etcd)来保存映射关系

Redis笔记 - 图87

Codis 将槽位关系存储在 zk 中,并且提供了一个 Dashboard 可以用来观察和修改槽位关系,当槽位关 系变化时,Codis Proxy 会监听到变化并重新同步槽位关系,从而实现多个 Codis Proxy 之间共享相同 的槽位关系配置。

我们这里再添加一个proxy。

Redis笔记 - 图88

然后执行命令

  1. sr\local\zookeeper>redis-cli -p 19001
  2. 127.0.0.1:19001> get name:001
  3. "zhangfei"
  4. 127.0.0.1:19001> get name:002
  5. "zhaoyun"
  6. 127.0.0.1:19001> get name:111
  7. "bob"
  8. 127.0.0.1:19001> set name:113 sunshangxiang
  9. OK
  10. 127.0.0.1:19001> get name:112
  11. "liubei"
  12. 127.0.0.1:19001> get name:113
  13. "sunshangxiang"
  14. 127.0.0.1:19001>

可以看到是没问题的。

再看下zk节点。多了一个proxy节点。

Redis笔记 - 图89

12.3 优点,缺点

优点

  • 对客户端透明,与codis交互方式和redis本身交互一样
  • 支持在线数据迁移,迁移过程对客户端透明有简单的管理和监控界面
  • 支持高可用,无论是redis数据存储还是代理节点
  • 自动进行数据的均衡分配 最大支持1024个redis实例,存储容量海量
  • 高性能

缺点

  • 采用自有的redis分支,不能与原版的redis保持同步

  • 如果codis的proxy只有一个的情况下, redis的性能会下降20%左右 所以需要启动多个proxy实例。

  • 某些命令不支持

13 企业实战

学习目标:

理解缓存设计要素

掌握缓存预热

能够进行缓存问题分析和提供解决方案

能够整合mybatis使用缓存

理解分布式锁原理并掌握使用

理解乐观锁并掌握秒杀的实现

理解Redisson的原理

了解阿里Redis使用手册

13.1 架构设计

缓存的设计要分多个层次,在不同的层次上选择不同的缓存,包括JVM缓存、文件缓存和Redis缓存

JVM缓存

JVM缓存就是本地缓存,设计在应用服务器中(tomcat)。

通常可以采用Ehcache和Guava Cache,在互联网应用中,由于要处理高并发,通常选择Guava Cache。

适用本地(JVM)缓存的场景:

1、对性能有非常高的要求。

2、不经常变化

3、占用内存不大

4、有访问整个集合的需求

5、数据允许不时时一致

文件缓存

这里的文件缓存是基于http协议的文件缓存,一般放在nginx中。 因为静态文件(比如css,js, 图片)中,很多都是不经常更新的。nginx使用proxy_cache将用户的请 求缓存到本地一个目录。下一个相同请求可以直接调取缓存文件,就不用去请求服务器了。

  1. #要缓存文件的后缀,可以在以下设置。
  2. location ~* \.(gif|jpg|png|css|js)$ {
  3. proxy_pass http://ip地址:90;
  4. proxy_redirect off;
  5. proxy_set_header Host $host;
  6. proxy_cache cache_one;
  7. proxy_cache_valid 200 302 24h;
  8. proxy_cache_valid 301 30d;
  9. proxy_cache_valid any 5m;
  10. expires 90d;
  11. add_header wall "hello lagou.";
  12. }

Redis缓存

分布式缓存,采用主从+哨兵或RedisCluster的方式缓存数据库的数据。

在实际开发中 作为数据库使用,数据要完整

作为缓存使用

作为Mybatis的二级缓存使用

官方说Redis单例能处理key:2.5亿个。 一个key或是value大小最大是512M

redis在作为缓存使用的时候需要设置最大内存。

  1. maxmemory=num # 最大缓存量 一般为内存的3/4
  2. maxmemory-policy allkeys-lru #

如果作为DB使用,不需要设置,就不用设置maxmemory。

缓存命中率

通常来讲,缓存的命中率越高则表示使用缓存的收益越高,应用的性能越好(响应时间越短、吞吐量越 高),抗并发的能力越强。

  1. 127.0.0.1:6379> info stats
  2. # Stats
  3. total_connections_received:28
  4. total_commands_processed:2985
  5. instantaneous_ops_per_sec:0
  6. total_net_input_bytes:228571
  7. total_net_output_bytes:15044687
  8. instantaneous_input_kbps:0.00
  9. instantaneous_output_kbps:0.00
  10. rejected_connections:0
  11. sync_full:0
  12. sync_partial_ok:0
  13. sync_partial_err:0
  14. expired_keys:3
  15. evicted_keys:0
  16. #缓存命中
  17. keyspace_hits:1952
  18. #缓存未命中
  19. keyspace_misses:43
  20. pubsub_channels:0
  21. pubsub_patterns:0
  22. latest_fork_usec:3293
  23. migrate_cached_sockets:0

一个缓存失效机制,和过期时间设计良好的系统,命中率可以做到95%以上。

影响缓存命中率的因素:

1、缓存的数量越少命中率越高,比如缓存单个对象的命中率要高于缓存集合

2、过期时间越长命中率越高

3、缓存越大缓存的对象越多,则命中的越多

性能监控指标

info stats memory

  1. connected_clients:68 #连接的客户端数量
  2. used_memory_rss_human:847.62M #系统给redis分配的内存
  3. used_memory_peak_human:794.42M #内存使用的峰值大小
  4. total_connections_received:619104 #服务器已接受的连接请求数量
  5. instantaneous_ops_per_sec:1159 #服务器每秒钟执行的命令数量 qps
  6. instantaneous_input_kbps:55.85 #redis网络入口kps
  7. instantaneous_output_kbps:3553.89 #redis网络出口kps
  8. rejected_connections:0 #因为最大客户端数量限制而被拒绝的连接请求数量
  9. expired_keys:0 #因为过期而被自动删除的数据库键数量
  10. evicted_keys:0 #因为最大内存容量限制而被驱逐(evict)的键数量
  11. keyspace_hits:0 #查找数据库键成功的次数
  12. keyspace_misses:0 #查找数据库键失败的次数

缓存预热

缓存预热就是系统启动前,提前将相关的缓存数据直接加载到缓存系统。避免在用户请求的时候,先查询 数据库,然后再将数据缓存的问题用户直接查询实现被预热的缓存数据。

加载缓存思路:

数据量不大,可以在项目启动的时候自动进行加载

利用定时任务刷新缓存,将数据库的数据刷新到缓存中

13.2 缓存问题

缓存穿透

一般的缓存系统,都是按照key去缓存查询,如果不存在对应的value,就应该去后端系统查找(比如 DB)。

缓存穿透是指在高并发下查询key不存在的数据,会穿过缓存查询数据库。导致数据库压力过大而宕机

解决方案:

  • 对查询结果为空的情况也进行缓存,缓存时间(ttl)设置短一点,或者该key对应的数据insert了 之后清理缓存。 问题:缓存太多空值占用了更多的空间
  • 使用布隆过滤器。在缓存之前在加一层布隆过滤器,在查询的时候先去布隆过滤器查询 key 是否 存在,如果不存在就直接返回,存在再查缓存和DB

布隆过滤器:

Redis笔记 - 图90

布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机 hash映射函数。 【文档:布隆过滤器.note
链接:http://note.youdao.com/noteshare?id=2aa430c98239b8582b449cb8f597cabb&sub=E5FC8DA4E06047189AE2629A2FEAAEC2】

布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般 的算法。

Redis笔记 - 图91

布隆过滤器的原理是,当一个元素被加入集合时,通过K个Hash函数将这个元素映射成一个数组中的K 个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如 果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的 基本思想。

布隆过滤器可以确定一个 key一定不存在,但是不能确定一个key一定存在。

缓存雪崩

当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如 DB)带来很大压力。

突然间大量的key失效了或redis重启,大量访问数据库,数据库崩溃

解决方案:

1、 key的失效期分散开 不同的key设置不同的有效期

2、设置二级缓存(数据不一定一致)

3、高可用(脏读)

缓存击穿

对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对 某一key缓存,前者则是很多key。

缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓 存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。

解决方案:

1、用分布式锁控制访问的线程 使用redis的setnx互斥锁先进行判断,这样其他线程就处于等待状态,保证不会有大并发操作去操作数据库。

2、不设超时时间(并采用volatile-lru 过期策略),但会造成写一致问题 当数据库数据发生更新时,缓存中的数据不会及时更新,这样会造成数据库中的数据与缓存中的数据的 不一致,应用会从缓存中读取到脏数据。可采用延时双删策略处理,这个我们后面会详细讲到。

数据不一致

缓存中数据和DB中数据不一致。

比如一个请求更新数据,先删除缓存再更新,在事务没提交之前,另一个请求到来,缓存没有数据,读取数据库中数据,并放到缓存里面。事务提交之后,缓存中就是脏数据了。

或者先更新缓存,再更新数据,提交失败了,会导致缓存脏数据。

保证数据的最终一致性(延时双删)

1、先更新数据库同时删除缓存项(key),等读的时候再填充缓存

2、2秒后再删除一次缓存项(key)

3、设置缓存过期时间 Expired Time 比如 10秒 或1小时

4、将缓存删除失败记录到日志中,利用脚本提取失败记录再次删除(缓存失效期过长 7*24)

升级方案

通过数据库的binlog来异步淘汰key,利用工具(canal)将binlog日志采集发送到MQ中,然后通过ACK机 制确认处理删除缓存。

数据并发竞争

这里的并发指的是多个redis的client同时set 同一个key引起的并发问题。

多客户端(Jedis)同时并发写一个key,一个key的值是1,本来按顺序修改为2,3,4,最后是4,但是顺 序变成了4,3,2,最后变成了2。

解决方案:

  • 分布式锁+时间戳
    这种情况,主要是准备一个分布式锁,大家去抢锁,抢到锁就做set操作。 加锁的目的实际上就是把并行读写改成串行读写的方式,从而来避免资源竞争。
    Redis笔记 - 图92

用SETNX实现分布式锁。

然后在保存值的时候同时也记录时间戳,在其他系统获取锁,并要修改资源的时候判断是否大于上一次更新的时间戳,如果大于则执行,如果小于则不执行。

比如:

系统A key 1 {ValueA 7:00}

系统B key 1 { ValueB 7:05}

假设系统B先抢到锁,将key1设置为{ValueB 7:05}。接下来系统A抢到锁,发现自己的key1的时间戳早于缓存中的时间戳(7:00<7:05),那就不做set操作了。

  • 第二种方案:利用消息队列
    在并发量过大的情况下,可以通过消息中间件进行处理,把并行读写进行串行化。
    把Redis的set操作放在队列中使其串行化,必须的一个一个执行。但是使用消息队列的时候要保证消息的先后顺序。

Hot Key

Redis笔记 - 图93

当有大量的请求(几十万)访问某个Redis某个key时,由于流量集中达到网络上限,从而导致这个redis的 服务器宕机。造成缓存击穿,接下来对这个key的访问将直接访问数据库造成数据库崩溃,或者访问数 据库回填Redis再访问Redis,继续崩溃。

如何发现热key

1、预估热key,比如秒杀的商品、火爆的新闻等

2、在客户端进行统计,实现简单,加一行代码即可

3、如果是Proxy,比如Codis,可以在Proxy端收集

4、利用Redis自带的命令,monitor、hotkeys。但是执行缓慢(不要用)

5、利用基于大数据领域的流式计算技术来进行实时数据访问次数的统计,比如 Storm、Spark Streaming、Flink,这些技术都是可以的。发现热点数据后可以写到zookeeper中。

Redis笔记 - 图94

如何处理热Key

1、变分布式缓存为本地缓存 发现热key后,把缓存数据取出后,直接加载到本地缓存中。可以采用Ehcache、Guava Cache都可 以,这样系统在访问热key数据时就可以直接访问自己的缓存了。(数据不要求时时一致)

2、在每个Redis主节点上备份热key数据,这样在读取时可以采用随机读取的方式,将访问压力负载到 每个Redis上。

3、利用对热点数据访问的限流熔断保护措施 每个系统实例每秒最多请求缓存集群读操作不超过 400 次,一超过就可以熔断掉,不让请求缓存集群, 直接返回一个空白信息,然后用户稍后会自行再次重新刷新页面之类的。(首页不行,系统友好性差) 通过系统层自己直接加限流熔断保护措施,可以很好的保护后面的缓存集群。

Big Key

Big key指的是存储的值(Value)非常大,常见场景:

  • 热门话题下的讨论
  • 大V的粉丝列表
  • 序列化后的图片
  • 没有及时处理的垃圾数据。
  • 。。。

大key的影响

  • 大key会大量占用内存,在集群中无法均衡
  • Redis的性能下降,主从复制异常
  • 在主动删除或过期删除时会操作时间过长而引起服务阻塞

如何发现大key:

大key的处理:

优化big key的原则就是string减少字符串长度,list、hash、set、zset等减少成员数

1、string类型的big key,尽量不要存入Redis中,可以使用文档型数据库MongoDB或缓存到CDN上。 如果必须用Redis存储,最好单独存储,不要和其他的key一起存储。采用一主一从或多从。

2、单个简单的key存储的value很大,可以尝试将对象分拆成几个key-value, 使用mget获取值,这样 分拆的意义在于分拆单次操作的压力,将操作压力平摊到多次操作中,降低对redis的IO影响。

2、hash, set,zset,list 中存储过多的元素,可以将这些元素分拆。(常见)

String > 10k

hash、set、sorted set、list大于5000个

  1. hash类型举例来说,对于field过多的场景,可以根据field进行hash取模,生成一个新的key,例如原
  2. 来的
  3. hash_key:{filed1:value, filed2:value, filed3:value ...},可以hash取模后形成如下
  4. key:value形式
  5. hash_key:1:{filed1:value}
  6. hash_key:2:{filed2:value}
  7. hash_key:3:{filed3:value}
  8. ...
  9. 取模后,将原先单个key分成多个key,每个key filed个数为原先的1/N

3、删除大key时不要使用del,因为del是阻塞命令,删除时会影响性能。

4、使用 lazy delete (unlink命令) 删除指定的key(s),若key不存在则该key被跳过。但是,相比DEL会产生阻塞,该命令会在另一个线程中 回收内存,因此它是非阻塞的。 这也是该命令名字的由来:仅将keys从key空间中删除,真正的数据删除会在后续异步操作。

  1. redis> SET key1 "Hello"
  2. "OK"
  3. redis> SET key2 "World"
  4. "OK"
  5. redis> UNLINK key1 key2 key3
  6. (integer) 2

缓存更新策略

策略 一致性 维护成本
利用Redis的缓存淘汰策略被动更新 LRU 、LFU 最差 最低
利用TTL被动更新 较差 较低
在更新数据库时主动更新 (先更数据库再删缓存——延时双删) 较强 最高
异步更新 定时任务 数据不保证时时一致 不穿DB 较差 很高

14 锁

14.1 乐观锁

乐观锁基于CAS(Compare And Swap)思想(比较并替换),是不具有互斥性,不会产生锁等待而消 耗资源,但是需要反复的重试,但也是因为重试的机制,能比较快的响应。因此我们可以利用redis来实 现乐观锁。

具体思路如下:

1、利用redis的watch功能,监控这个redisKey的状态值

2、获取redisKey的值 3、创建redis事务

4、给这个key的值+1

5、然后去执行这个事务,如果key的值被修改过则回滚,key不加1

  1. import redis.clients.jedis.Jedis;
  2. import redis.clients.jedis.JedisPool;
  3. import redis.clients.jedis.Transaction;
  4. import java.util.List;
  5. import java.util.UUID;
  6. import java.util.concurrent.ExecutorService;
  7. import java.util.concurrent.Executors;
  8. public class OptimisticLock {
  9. public static void main(String[] args) {
  10. String lock = "lockKey";
  11. ExecutorService executorService = Executors.newFixedThreadPool(20);
  12. JedisPool jedisPool = new JedisPool("127.0.0.1",6379);
  13. Jedis resource = jedisPool.getResource();
  14. resource.set(lock,"0");
  15. resource.close();
  16. int loopCount = 200;
  17. for (int i = 0; i < loopCount; i++) {
  18. executorService.execute(()->{
  19. try(Jedis jedis = jedisPool.getResource()) {
  20. Integer lockVal = Integer.valueOf(jedis.get(lock));
  21. String userId = UUID.randomUUID().toString();
  22. if (lockVal > 20) {
  23. System.out.println("秒杀已经结束了。。。");
  24. } else {
  25. jedis.watch(lock);
  26. //开启事务
  27. Transaction multi = jedis.multi();
  28. //自增1 如果自增失败说明没有抢到锁
  29. multi.incr(lock);
  30. //执行事务
  31. List<Object> exec = multi.exec();
  32. //如果执行队列为空,说明抢锁失败了
  33. if (exec == null || exec.isEmpty()) {
  34. System.out.println(userId + "秒杀失败!");
  35. } else {
  36. System.out.println(userId + "秒杀成功");
  37. }
  38. }
  39. }catch (Exception e){
  40. e.printStackTrace();
  41. }
  42. });
  43. }
  44. executorService.shutdown();
  45. }
  46. }

14.2 分布式锁

方式1

  1. /**
  2. * 使用redis的set命令实现获取分布式锁
  3. * @param lockKey 可以就是锁
  4. * @param requestId 请求ID,保证同一性 uuid+threadID
  5. * @param expireTime 过期时间,避免死锁
  6. * @return
  7. */
  8. public boolean getLock(String lockKey,String requestId,int expireTime) {
  9. //NX:保证互斥性
  10. // hset 原子性操作 只要lockKey有效 则说明有进程在使用分布式锁
  11. String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime);
  12. if("OK".equals(result)) {
  13. return true;
  14. }
  15. return false;
  16. }

方式2 并发会产生问题

  1. public boolean getLock(String lockKey,String requestId,int expireTime) {
  2. Long result = jedis.setnx(lockKey, requestId);
  3. if(result == 1) {
  4. //成功设置 进程down 永久有效 别的进程就无法获得锁
  5. jedis.expire(lockKey, expireTime);
  6. return true;
  7. }
  8. return false;
  9. }

释放锁:

方式1:

  1. /**
  2. * 释放分布式锁
  3. * @param lockKey
  4. * @param requestId
  5. */
  6. public static void releaseLock(String lockKey,String requestId) {
  7. //因为可能响应或者del通信过程中,锁就过期了。
  8. if (requestId.equals(jedis.get(lockKey))) {
  9. jedis.del(lockKey);
  10. }
  11. }

问题在于如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。 那么是否真的有这种场景?答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行 jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户 端B的锁给解除了。

方式2(redis+lua脚本实现)—推荐

  1. public static boolean releaseLock(String lockKey, String requestId) {
  2. String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
  3. Object result = jedis.eval(script, Collections.singletonList(lockKey),
  4. Collections.singletonList(requestId));
  5. if (result.equals(1L)) {
  6. return true;
  7. }
  8. return false;
  9. }

存在问题:

1、单机 无法保证高可用

2、主—从 无法保证数据的强一致性,在主机宕机时会造成锁的重复获得

比如数据还没同步到从机,master挂了,从机升为master,那么其他进程就也可以获取锁了。

Redis笔记 - 图95

3、无法续租 超过expireTime后,不能继续使用

比如A拿到锁了,业务还未执行完,锁到期了,那么其他又可以拿到锁了。

本质分析

CAP模型分析

在分布式环境下不可能满足三者共存,只能满足其中的两者共存,在分布式下P不能舍弃(舍弃P就是单 机了)。

所以只能是CP(强一致性模型)和AP(高可用模型)。

分布式锁是CP模型,Redis集群是AP模型。 (base) Redis集群不能保证数据的随时一致性,只能保证数据的最终一致性。

为什么还可以用Redis实现分布式锁?

与业务有关

  • 当业务不需要数据强一致性时,比如:社交场景,就可以使用Redis实现分布式锁

  • 当业务必须要数据的强一致性,即不允许重复获得锁比如金融场景(重复下单,重复转账)就不要使 用 可以使用CP模型实现,比如:zookeeper和etcd

14.3 Redisson分布式锁

Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。

Redisson在基于NIO的Netty框架上,生产环境使用分布式锁。

redisson坐标

  1. <dependency>
  2. <groupId>org.redisson</groupId>
  3. <artifactId>redisson</artifactId>
  4. <version>3.14.0</version>
  5. </dependency>

配置Redisson对象 单实例

  1. import org.redisson.Redisson;
  2. import org.redisson.api.RedissonClient;
  3. import org.redisson.config.Config;
  4. public class RedissonManager {
  5. private static Config config;
  6. private static RedissonClient redisson;
  7. static {
  8. config = new Config();
  9. config.useSingleServer()
  10. .setAddress("redis://127.0.0.1:6379");
  11. redisson = Redisson.create(config);
  12. }
  13. public static RedissonClient getRedisson() {
  14. return redisson;
  15. }
  16. }

分片集群:

  1. public class RedissonManager {
  2. private static Config config = new Config();
  3. //声明redisso对象
  4. private static Redisson redisson = null;
  5. //实例化redisson
  6. static {
  7. config.useClusterServers()
  8. // 集群状态扫描间隔时间,单位是毫秒
  9. .setScanInterval(2000)
  10. //cluster方式至少6个节点(3主3从,3主做sharding,3从用来保证主宕机后可以高可用)
  11. .addNodeAddress("redis://127.0.0.1:6379")
  12. .addNodeAddress("redis://127.0.0.1:6380")
  13. .addNodeAddress("redis://127.0.0.1:6381")
  14. .addNodeAddress("redis://127.0.0.1:6382")
  15. .addNodeAddress("redis://127.0.0.1:6383")
  16. .addNodeAddress("redis://127.0.0.1:6384");
  17. //得到redisson对象
  18. redisson = (Redisson) Redisson.create(config);
  19. }//获取redisson对象的方法
  20. public static Redisson getRedisson() {
  21. return redisson;
  22. }
  23. }

加锁/释放锁:

  1. import jodd.time.TimeUtil;
  2. import org.redisson.api.RLock;
  3. import org.redisson.api.RedissonClient;
  4. import java.util.concurrent.TimeUnit;
  5. public class DistributedRedisLock {
  6. //从配置类中获取redisson对象
  7. private static RedissonClient redisson = RedissonManager.getRedisson();
  8. private static final String LOCK_TITLE = "redisLock_";
  9. //加锁 这里如果没有拿到锁会一直重试 看源码就知道了
  10. public static boolean lock(String lockName){
  11. //声明key对象
  12. String key = LOCK_TITLE + lockName;
  13. //获取锁对象
  14. RLock mylock = redisson.getLock(key);
  15. //加锁,并且设置锁过期时间3秒,防止死锁的产生 uuid+threadId。而且这里是一个
  16. //可重入锁
  17. mylock.lock(3, TimeUnit.SECONDS);
  18. //加锁成功
  19. return true;
  20. }
  21. //获取锁一次,如果没有获取成功返回true,获取失败返回false
  22. public static boolean tryLock(String lockName) throws InterruptedException {
  23. //声明key对象
  24. String key = LOCK_TITLE + lockName;
  25. //获取锁对象
  26. RLock mylock = redisson.getLock(key);
  27. //加锁,并且设置锁过期时间3秒,防止死锁的产生 uuid+threadId
  28. return mylock.tryLock(3,TimeUnit.SECONDS);
  29. }
  30. //在超时时间内,如果获取成功返回true,获取失败返回false
  31. public static boolean tryLock(String lockName,long timeoutSeconds) throws InterruptedException {
  32. //声明key对象
  33. String key = LOCK_TITLE + lockName;
  34. //获取锁对象
  35. RLock mylock = redisson.getLock(key);
  36. //加锁,并且设置锁过期时间3秒,防止死锁的产生 uuid+threadId
  37. return mylock.tryLock(timeoutSeconds,3,TimeUnit.SECONDS);
  38. }
  39. //锁的释放
  40. public static void release(String lockName){
  41. //必须是和加锁时的同一个key
  42. String key = LOCK_TITLE + lockName;
  43. //获取所对象
  44. RLock mylock = redisson.getLock(key);
  45. //释放锁(解锁)
  46. mylock.unlock();
  47. }
  48. }

测试类:

  1. import java.util.concurrent.ExecutorService;
  2. import java.util.concurrent.Executors;
  3. public class DistributedLockTest {
  4. public static void main(String[] args) {
  5. ExecutorService executorService = Executors.newFixedThreadPool(20);
  6. int taskCount = 50;
  7. String lockName = "lock001";
  8. for (int i = 0; i < taskCount; i++) {
  9. executorService.execute(()->{
  10. try {
  11. boolean b = DistributedRedisLock.tryLock(lockName);
  12. if(b){
  13. System.out.print("获取锁成功");
  14. System.out.println("do something");
  15. Thread.sleep(1000);
  16. DistributedRedisLock.release(lockName);
  17. }else{
  18. System.out.println("获取锁失败");
  19. }
  20. } catch (InterruptedException e) {
  21. e.printStackTrace();
  22. }
  23. });
  24. }
  25. executorService.shutdown();
  26. }
  27. }

原理:

Redis笔记 - 图96

下面是尝试加锁的lua脚本:

  1. /**
  2. *waitTime 等待时间,这个参数lua脚本没用
  3. *leaseTime key过期时间
  4. *threadId 线程id
  5. *
  6. *
  7. */
  8. <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
  9. internalLockLeaseTime = unit.toMillis(leaseTime);
  10. return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
  11. "if (redis.call('exists', KEYS[1]) == 0) then " +
  12. "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
  13. "redis.call('pexpire', KEYS[1], ARGV[1]); " +
  14. "return nil; " +
  15. "end; " +
  16. "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
  17. "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
  18. "redis.call('pexpire', KEYS[1], ARGV[1]); " +
  19. "return nil; " +
  20. "end; " +
  21. "return redis.call('pttl', KEYS[1]);",
  22. Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
  23. }
  1. "if (redis.call('exists', KEYS[1]) == 0) then " + //看有没有锁
  2. "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + //没有锁 加锁 hash 的field为uuid:线程id,对应hash value设为1
  3. "redis.call('pexpire', KEYS[1], ARGV[1]); " + //过期时间
  4. "return nil; " +
  5. "end; " +
  6. "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + //当前线程是否存在锁 实现可重入锁
  7. "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + //hash value自增1
  8. "redis.call('pexpire', KEYS[1], ARGV[1]); " + //过期时间
  9. "return nil; " +
  10. "end; " +
  11. "return redis.call('pttl', KEYS[1]);", //如果其他地方加了锁,返回ttl

KEYS[1]) : 加锁的key

ARGV[1] : key的生存时间,默认为30秒

ARGV[2] : 加锁的客户端ID (UUID.randomUUID()) + “:” + threadId)

锁释放lua代码:

  1. protected RFuture<Boolean> unlockInnerAsync(long threadId) {
  2. return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
  3. "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
  4. "return nil;" +
  5. "end; " +
  6. "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
  7. "if (counter > 0) then " +
  8. "redis.call('pexpire', KEYS[1], ARGV[2]); " +
  9. "return 0; " +
  10. "else " +
  11. "redis.call('del', KEYS[1]); " +
  12. //发布通知
  13. "redis.call('publish', KEYS[2], ARGV[1]); " +
  14. "return 1; " +
  15. "end; " +
  16. "return nil;",
  17. Arrays.asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
  18. }
  1. "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " + //#如果key已经不存在,说明当前线程已不存在锁,直接返回nil
  2. "return nil;" +
  3. "end; " +
  4. "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " + //hash value -1
  5. //如果 -1之后,值还大于0,说明还持有锁(锁重入),延长过期时间 返回0
  6. "if (counter > 0) then " +
  7. "redis.call('pexpire', KEYS[1], ARGV[2]); " +
  8. "return 0; " +
  9. "else " + //如果值=0,说明本次释放锁之后,当前线程已经完全释放锁,删除锁,并发布信息。返回1
  10. "redis.call('del', KEYS[1]); " +
  11. "redis.call('publish', KEYS[2], ARGV[1]); " +
  12. "return 1; " +
  13. "end; " +
  14. "return nil;",

参数解释:

– KEYS[1] :需要加锁的key,这里需要是字符串类型。

– KEYS[2] :redis消息的ChannelName,一个分布式锁对应唯一的一个channelName: “redisson_lockchannel{” + getName() + “}”

– ARGV[1] :reids消息体,这里只需要一个字节的标记就可以,主要标记redis的key已经解锁,再结合 redis的Subscribe,能唤醒其他订阅解锁消息的客户端线程申请锁。

– ARGV[2] :锁的超时时间,防止死锁

– ARGV[3] :锁的唯一标识,也就是刚才介绍的 id(UUID.randomUUID()) + “:” + threadId

特性:

  • 互斥性 任意时刻,只能有一个客户端获取锁,不能同时有两个客户端获取到锁。
  • 同一性 锁只能被持有该锁的客户端删除,不能由其它客户端删除。
  • 可重入性 持有某个锁的客户端可继续对该锁加锁,实现锁的续租
  • 容错性 锁失效后(超过生命周期)自动释放锁(key失效),其他客户端可以继续获得该锁,防止死锁

14.4 分布式锁的实际应用

  • 数据并发竞争
    利用分布式锁可以将处理串行化(分布式锁+时间戳)

  • 防止库存超卖 Redis笔记 - 图97
    订单1下单前会先查看库存,库存为10,所以下单5本可以成功;
    订单2下单前会先查看库存,库存为10,所以下单8本可以成功;
    订单1和订单2 同时操作,共下单13本,但库存只有10本,显然库存不够了,这种情况称为库存超卖。
    可以采用分布式锁解决这个问题。
    Redis笔记 - 图98
    订单1和订单2都从Redis中获得分布式锁(setnx),谁能获得锁谁进行下单操作,这样就把订单系统下单 的顺序串行化了,就不会出现超卖的情况了。伪码如下:

    1. //加锁并设置有效期
    2. if(redis.lock("RDL",200)){
    3. //判断库存
    4. if (orderNum<getCount()){
    5. //加锁成功 ,可以下单
    6. order(5);
    7. //释放锁
    8. redis,unlock("RDL");
    9. }
    10. }


注意此种方法会降低处理效率,这样不适合秒杀的场景,秒杀可以使用CAS和Redis队列的方式(见前面的乐观锁)。

14.5 Zookeeper分布式锁


zookeeper是创建临时节点的方式。
Redis笔记 - 图99

14.5 阿里Redis使用手册

key设计

可读性和可管理性 以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如业务名:表名:id

  1. ugc:video:1

简洁性

保证语义的前提下,控制key的长度,当key较多时,内存占用也不容忽视,例如:

Redis笔记 - 图100

不要包含特殊字符

反例:包含空格、换行、单双引号以及其他转义字符

value设计

拒绝bigkey 防止网卡流量、慢查询,string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000。

反例:一个包含200万个元素的list。

拆解 非字符串的bigkey,不要使用del删除,使用hscan、sscan、zscan方式渐进式删除,同时要注意防止 bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发del操作,造成阻塞,而且 该操作不会不出现在慢查询中(latency可查)),查找方法和删除方法

选择适合的数据类型

例如:实体类型(要合理控制和使用数据结构内存编码优化配置,例如ziplist,但也要注意节省内存和性能 之间的平衡)

反例:

Redis笔记 - 图101

正例:

Redis笔记 - 图102

控制key的生命周期 redis不是垃圾桶,建议使用expire设置过期时间(条件允许可以打散过期时间,防止集中过期),不过期 的数据重点关注idletime。

命令使用

1、O(N)命令关注N的数量

例如hgetall、lrange、smembers、zrange、sinter等并非不能使用,但是需要明确N的值。有遍历的 需求可以使用hscan、sscan、zscan代替。

2、禁用命令

禁止线上使用keys、flushall、flushdb等,通过redis的rename机制禁掉命令,或者使用scan的方式渐 进式处理。

3、合理使用select

redis的多数据库较弱,使用数字进行区分,很多客户端支持较差,同时多业务用多数据库实际还是单线 程处理,会有干扰。不同的业务可以使用不同redis的集群。

4、使用批量操作提高效率

  • 原生命令:例如mget、mset。

  • 非原生命令:可以使用pipeline提高效率。
    但要注意控制一次批量操作的元素个数(例如500以内,实际也和元素字节数有关)。
    注意两者不同:
    1.原生是原子操作,pipeline是非原子操作。
    2.pipeline可以打包不同的命令,原生做不到
    3.pipeline需要客户端和服务端同时支持。

5、不建议过多使用Redis事务功能

Redis的事务功能较弱(不支持回滚),而且集群版本(自研和官方)要求一次事务操作的key必须在一个slot 上(可以使用hashtag功能解决)

6、Redis集群版本在使用Lua上有特殊要求

  • 所有key都应该由 KEYS 数组来传递,redis.call/pcall 里面调用的redis命令,key的位置,必须是 KEYS array, 否则直接返回error,”-ERR bad lua script for redis cluster, all the keys that the script uses should be passed using the KEYS arrayrn”
  • 所有key,必须在1个slot上,否则直接返回error, “-ERR eval/evalsha command keys must in same slotrn”

7、monitor命令

必要情况下使用monitor命令时,要注意不要长时间使用。

客户端使用

1、避免多个应用使用一个Redis实例 不相干的业务拆分,公共数据做服务化。

2、使用连接池 可以有效控制连接,同时提高效率,标准使用方式:

Redis笔记 - 图103

3、熔断功能 高并发下建议客户端添加熔断功能(例如netflix hystrix)

4、合理的加密 设置合理的密码,如有必要可以使用SSL加密访问(阿里云Redis支持)

5、淘汰策略 根据自身业务类型,选好maxmemory-policy(最大内存淘汰策略),设置好过期时间。

默认策略是volatile-lru,即超过最大内存后,在过期键中使用lru算法进行key的剔除,保证不过期数据 不被删除,但是可能会出现OOM问题。

其他策略如下:

  • allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止。
  • allkeys-random:随机删除所有键,直到腾出足够空间为止。
  • volatile-random:随机删除过期键,直到腾出足够空间为止。
  • volatile-ttl:只限于设置了 expire 的部分; 优先删除剩余时间(time to live,TTL) 短的key。
  • noeviction:不会剔除任何数据,拒绝所有写入操作并返回客户端错误信息”(error) OOM command not allowed when used memory”,此时Redis只响应读操作。

相关工具

1、数据同步 redis间数据同步可以使用:redis-port

2、big key搜索 redis大key搜索工具 redis-cli —bigkeys。或者rdbtools分析。

3、热点key寻找 内部实现使用monitor,所以建议短时间使用facebook的redis-faina

阿里云Redis已经在内核层面解决热点key问题

删除big key

1.下面操作可以使用pipeline加速。

2.redis 4.0已经支持key的异步删除,欢迎使用。

1、Hash删除: hscan + hdel

Redis笔记 - 图104

2、List删除: ltrim

Redis笔记 - 图105

3、Set删除: sscan + srem

Redis笔记 - 图106

4、SortedSet删除: zscan + zrem

Redis笔记 - 图107

15 Redis 面试汇总

15.1 缓存穿透,雪崩,击穿

参考 13节

15.2 HotKey

参考 13节

15.3 BigKey

参考13节

String > 10k

hash、set、sorted set、list大于5000个

15.4 数据一致性问题

参考 13 节

Catch Aside Pattern

数据源不一致

场景的适用性(互联网)

保证最终一致,一致的时间处理

15.5 缓存失效

缓存失效带来的问题:缓存穿透、缓存雪崩、缓存击穿(高并发)

会让数据库压力过大而宕机

参考第三节 缓存过期策略

15.6 数据并发竞争

答题思路:

数据并发竞争的概念、场景

数据并发竞争的影响

解决方案:

将并发串行化:分布式锁+时间戳、利用队列

使用CAS:秒杀

这里参考 13节

15.7 冷热数据

答题思路:

热数据:hot key 位于Redis中 命中率尽量高

冷数据:不经常访问的数据 位于DB中

冷热的交换:maxmemory+allkeys LRU

交换比例:热20万、冷200万

Redis作为DB时,冷数据不能驱逐,保证数据的完整性

15.8 单线程redis 为何这么快

redis在内存中操作,持久化只是数据的备份,正常情况下内存和硬盘不会频繁swap

多机主从,集群数据扩展

maxmemory的设置+淘汰策略

数据结构简单,有压缩处理,是专门设计的 (多种数据结构存储)

单线程没有锁,没有多线程的切换和调度,不会死锁,没有性能消耗

使用I/O多路复用模型,非阻塞IO;

构建了多种通信模式,进一步提升性能

进行持久化的时候会以子进程的方式执行,主进程不阻塞

rehash的时候是渐进式的

15.9 redis的过期策略以及内存淘汰机制

答题思路:

为什么要过期

什么情况下不能过期

如何设置过期

expires 原理

如何选择缓存淘汰策略

参考 第三节 缓存淘汰策略

15.10 Redis 为什么是单线程的,优点

答题思路:

Redis采用单线程多进程集群方案

Redis是基于内存的操作,CPU不是Redis的瓶颈 瓶颈最有可能是机器内存的大小或者网络带宽 单

线程的设计是最简单的 但是对多核CPU利用率不够,所以Redis6采用多线程。

单线程优点:

代码更清晰,处理逻辑更简单 不用去考虑各种锁的问题

不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗

不存在多进程或者多线程导致的切换而消耗CPU

15.11 Redis分布式锁问题

参考 第 14节

15.12 有没有尝试进行多机redis 的部署?如何保证数据一致的?

答题思路:

  • redis多机部署方案:Redis主从+哨兵、codis集群、RedisCluster

  • 多机: 高可用、高扩展、高性能

  • 三者的区别 适用场景

  • 数据一致性指的是主从的数据一致性 Redis是AP模型,主从同步有时延。所以不能保证主从数据的时时一致性,只能保证数据最终一致性

    • 保证数据一致性方案:
      1、忽略 如果业务能够允许短时间不同步就忽略,比如:搜索,消息,帖子,职位
      2、强制读主库,从库只做备份使用 使用一个高可用主库提供数据库服务 读和写都落到主库上采用缓存来提升系统读性能
      3、选择性读主 写主库时将哪个库,哪个表,哪个主键三个信息拼装一个key设置到cache里 读时先在cache中查找: cache里有这个key,说明1s内刚发生过写请求,数据库主从同步可能还没有完成,此时就应该 去主库查询 cache里没有这个key,说明最近没有发生过写请求,此时就可以去从库查询。(可以利用布隆过滤器)

参考 9,10,11节