Redis安装和使用

使用Docker安装Redis

docker run --name redis -p 6379:6379 --restart always -d redis

使用redis-cli执行redis命令

docker exec -it redis redis-cli

Redis思维导图




redis list 最大值 redis最大key_数据

Redis的整体结构



单线程

Redis使用一个线程来处理所有的客户端请求,使用多路复用来达到高性能。多个 socket 可能会并发产生不同的操作,每个操作对应不同的文件事件,但是 IO 多路复用程序会监听多个 socket,会将 socket 产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。

几种数据结构

Redis不是一个简单的key-value存储。

键相关知识点

Redis过期键的删除策略是定期删除+惰性删除。

定期删除使用的是贪心策略,它每秒会进行 10 次过期扫描,此配置可在 redis.conf 进行配置,默认值是 hz 10,Redis 会随机抽取 20 个值,删除这 20 个键中过期的键,如果过期 key 的比例超过 25% ,重复执行此流程。需要预防大量的缓存在同一时刻一起过期,简单的解决方案就是在过期时间的基础上添加一个指定范围的随机数。

惰性删除是指查的时候如果键过期,就把键删掉。

lazy free 特性是 Redis 4.0 新增的一个非常使用的功能,它可以理解为延迟删除。意思是在删除的时候提供异步延时释放键值的功能,把键值释放操作放在 BIO(Background I/O) 单独的子线程处理中,以减少删除删除对 Redis 主线程的阻塞,可以有效地避免删除 big key 时带来的性能和可用性问题。

值相关知识点

Redis的value支持不同数据结构类型的值,以下是Redis value支持的数据结构的列表。

字符串

字符串的最大值是512M。

SET命令支持同时设置nx和px属性,因此可以用于单实例的分布式锁。

INCR命令可以将字符串键存储的整数值加上1,可以作为ID生成器

MSET和MGET命令代替多条SET和GET命令只需要一次网络通信,从而有效地减少程序执行多个设置和获取操作时的时间。

APPEND命令在键不存在时执行设置操作,在键存在时执行追加操作,可以用于追加存储一段时间内的日志。

列表

基于链表实现,内部数据结构是quickList

LPUSH命令从列表的左边添加数据,RPOP命令从列表的右边获取并且移除数据。因此可以用作消息队列。

LRANGE命令可以根据索引获取列表的数据,因此可以用作记录用户最新发布的内容ID。

哈希

底层以hashtable和ziplist实现

hash算法是MurmurHash算法,MurmurHash 是一种非加密型哈希函数,适用于一般的哈希检索操作。

扩容和缩容是根据负载因子的值进行判断的,负载因子=哈希表中已有元素和哈希桶数的比值。当负载因子大于1时,可能会扩容,如果大于1小于5并且没有进行bgsave/bdrewrite操作会扩容,大于5就会立刻扩容。当负载因子小于0.1就会缩容。扩容和缩容时桶的数量都是指数变化的,并且会新建一个哈希表用于扩容和缩容。

哈希适合用来存储短网址ID与目标网址之间的映射。

集合

底层是intset或者hashtable(hashtable实现时,hashtable中key为集合的元素,value为null)

集合是无序的,并且没有重复数据。

SADD命令向集合添加新元素。

Redis提供的命令还可以对集合执行一些其他操作,比如测试给定元素是否已经存在,执行多个集合之间的交集、并集或差集等等。

有序集合

底层以ziplist或skiplist+hashtable来实现。

有序集合的每个元素都由一个成员和一个与成员相关联的分值组成,其中成员以字符串方式存储,而分值则以64位双精度浮点数格式存储。同一个有序集合不能存储相同的成员,但不同成员的分值却可以是相同的。

有序集合是Redis提供的所有数据结构中最为灵活的一种,它可以以多种不同的方式获取数据,比如根据成员获取分值、根据分值获取成员、根据成员的排名获取成员、根据指定的分值范围获取多个成员等。

使用zrange和zrevrange命令可以获取升序或者降序的排行榜,使用zrank(正序)和zrevrank(从大到小)可以获取排名,使用zscore命令可以获取成员的分值,使用zincrby命令可以对有序集合中指定成员的分值执行自增操作或自减操作。

位图

位图不是实际的数据类型,而是在字符串类型上定义的一组面向位的操作。由于字符串是二进制安全的blob,其最大长度为512mb,因此适合设置为2的32次方个不同的位。

位图最大的优点之一是,它们在存储信息时通常可以极大地节省空间。例如,在不同用户由递增的用户id表示的系统中,仅使用512mb的内存就可以记住40亿用户的单个比特信息(例如,知道用户是否想要接收时事通讯)。

使用SETBIT和GETBIT可以在常量时间内设置和获取某一个位的值。使用BITCOUNT命令可以统计位图中值为1的二进制位数量。

HyperLogLog

HyperLogLog是一种概率数据结构,对于一个给定的集合,HyperLogLog可以计算出这个集合的近似基数:近似基数并非集合的实际基数,它可能会比实际的基数小一点或者大一点,但是估算基数和实际基数之间的误差会处于一个合理的范围之内,因此那些不需要知道实际基数或者因为条件限制而无法计算出实际基数的程序就可以把这个近似基数当作集合的基数来使用。

使用PFADD命令可以对给定的一个或多个集合元素进行计数, 如果已经计数返回0,没有计数返回1,因此可以用于检测重复信息。使用PFCOUNT命令可以为集合计算出的近似基数。使用PFMERGE命令可以对多个给定的HyperLogLog执行并集计算,然后把计算得出的并集HyperLogLog保存到指定的键中,因此可以对多个HyperLogLog实现的唯一计数器执行并集计算,从而实现每周/月度/年度计数器。

地理坐标

可以将经纬度格式的地理坐标存储到Redis中,并对这些坐标执行距离计算、范围查找等操作。

Streams

Redis流是使用Redis实现消息队列应用的最佳选择。流是一个包含零个或任意多个流元素的有序队列,队列中的每个元素都包含一个ID和任意多个键值对,这些元素会根据ID的大小在流中有序地进行排列。当用户将符号*用作id参数的值时,Redis将自动为新添加的元素生成一个可用的新ID。

流元素的ID由毫秒时间(millisecond)和顺序编号(sequcen number)两部分组成,其中使用UNIX时间戳表示的毫秒时间用于标识与元素相关联的时间,而以0为起始值的顺序编号则用于区分同一时间内产生的多个不同元素。因为毫秒时间和顺序编号都使用64位的非负整数表示,所以整个流ID的总长为128位,而Redis在接受流ID输入以及展示流ID的时候都会使用连字符-分割这两个部分。通过将元素ID与时间进行关联,并强制要求新元素的ID必须大于旧元素的ID, Redis从逻辑上将流变成了一种只执行追加操作(append only)的数据结构,这种特性对于使用流实现消息队列和事件系统的用户来说是非常重要的:用户可以确信,新的消息和事件只会出现在已有消息和事件之后,就像现实世界里新事件总是发生在已有事件之后一样,一切都是有序进行的。

Redis流的消费者组(consumer group)允许用户将一个流从逻辑上划分为多个不同的流,并让消费者组属下的消费者去处理组中的消息。

一条消费者组消息从出现到处理完毕,需要经历以下阶段:不存在;未递送;待处理;已确认。

使用XADD命令可以将一个带有指定ID以及包含指定键值对的元素追加到流的末尾。

使用XDEL命令删除消息,这里的删除仅仅是设置了标志位,不影响消息总长度。

使用XRANGE命令可以获取消息列表,会自动过滤已经删除的消息。

使用XLEN命令消息长度。

Redis通信方式

请求响应

交互方式是将一个命令发送到服务器,等服务器执行完这个命令并将结果返回给客户端之后,再执行下一个命令。

pipeline

允许客户端把任意多条Redis命令请求打包在一起,然后一次性地将它们全部发送给服务器,而服务器则会在流水线包含的所有命令请求都处理完毕之后,一次性地将它们的执行结果全部返回给客户端。可以提高整个交互的性能。

事务

当EXEC中有一条请求执行失败时,后续请求继续执行,只在返回客户端的array型响应中标记这条出错的结果,由客户端的应用程序决定如何恢复,Redis自身不包含回滚机制(执行到一半的批量操作必须继续执行完)。回滚机制的缺失使得Redis的事务实现极大地简化:无须为事务引入数据版本机制,无须为每个操作引入逆向操作。所以严格地讲,Redis的事务并不是一致的。

lua脚本

Lua脚本是以原子的方式执行的。

每一个提交到服务器端的lua脚本都会在服务器端的lua_script map中常驻,除非显式通过FLUSH命令清理;script在实例的主备间可通过script重放和cmd重放两种方式实现复制;之前执行过的script后续可直接通过它的sha指定而不用再向服务器端发送一遍script内容。

发布订阅

发布订阅的交互方式是一个客户端触发,多个客户端被动接收,通过服务器的中转。

使用PUBLISH命令可以将一条消息发送至给定频道。

使用SUBSCRIBE命令可以让客户端订阅给定的一个或多个频道。

发布与订阅虽然拥有将消息传递给多个客户端的能力,并且也拥有相应的阻塞弹出原语,但发布与订阅的“发送即忘(f ire andforget)”策略会导致离线的客户端丢失消息,所以它是无法实现可靠的消息队列的。如果 Redis 停机重启,PubSub 的消息是不会持久化的,毕竟 Redis 宕机就相当于一个消费者都没有,所有的消息都会被直接丢弃。

持久化

AOF和RDB,以及混合持久化方式

RDB是以全量的方式,持久化Redis的状态到rdb文件中,有save和bgsave两种方式,save和普通的redis命令执行方式一样。bgsave另起一个子线程的方式来异步的持久化。RDB 可能会导致一定时间内的数据丢失。

AOF是以增量的方式,持久化写命令到AOF文件里,有三种Always,Every Second和NO三种刷磁盘的策略。有rewrite机制优化AOF文件的大小。AOF 由于文件较大则会影响 Redis 的启动速度。

混合持久化方式,Redis 4.0 之后新增的方式,混合持久化是结合了 RDB 和 AOF 的优点,在写入的时候,先把当前的数据以 RDB 的形式写入文件的开头,再将后续的操作命令以 AOF 的格式存入文件,这样既能保证 Redis 重启时的速度,又能减低数据丢失的风险。

内存策略

当内存超过maxmemory限定时,触发主动清理策略,一共有八种淘汰策略

LRU淘汰

volatile-lru:从设置过期时间的数据集(server.db[i].expires)中挑选出最近最少使用的数据淘汰。没有设置过期时间的key不会被淘汰,这样就可以在增加内存空间的同时保证需要持久化的数据不会丢失。

allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰,该策略要淘汰的key面向的是全体key集合,而非过期的key集合。

TTL淘汰

volatile-ttl:除了淘汰机制采用LRU,策略基本上与volatile-lru相似,从设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰,ttl值越大越优先被淘汰。

随机淘汰

volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰。当内存达到限制无法写入非过期时间的数据集时,可以通过该淘汰策略在主键空间中随机移除某个key。

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

禁止淘汰

no-enviction:禁止驱逐数据,也就是当内存不足以容纳新入数据时,新写入操作就会报错,请求可以继续进行,线上任务也不能持续进行,采用no-enviction策略可以保证数据不被丢失,这也是系统默认的一种淘汰策略。

LFU淘汰

在 Redis 4.0 版本中又新增了 2 种淘汰策略:

  1. volatile-lfu:淘汰所有设置了过期时间的键值中,最少使用的键值;
  2. allkeys-lfu:淘汰整个键值中最少使用的键值。

分布式

主从复制

SYNC命令和PSYNC命令

Slave发送SYNC命令,Master借助BGSAVE命令生成快照信息,发送给Slave。发送期间的写命令存放在backlog, 在快照发送完成之后发送。之后写命令都实时发送给Slave。

Redis 2.8 中引入了 PSYNC 命令来代替 SYNC,它具有两种模式:

  1. 全量复制: 用于初次复制或其他无法进行部分复制的情况,将主节点中的所有数据都发送给从节点,是一个非常重型的操作;
  2. 部分复制: 用于网络中断等情况后的复制,只将 中断期间主节点执行的写命令 发送给从节点,与全量复制相比更加高效。需要注意 的是,如果网络中断时间过长,导致主节点没有能够完整地保存中断期间执行的写命令,则无法进行部分复制,仍使用全量复制;

部分复制的原理主要是靠主从节点分别维护一个 复制偏移量,有了这个偏移量之后断线重连之后一比较,之后就可以仅仅把从服务器断线之后缺失的这部分数据(写命令)给补回来了。

哨兵

多个哨兵节点通过pubsub来进行交互形成集群保证高可用,哨兵节点和Master节点通过定期心跳,当大于等于配置的Quorum的哨兵节点判断Master节点是否挂断,然后通过类似Raft协议选举主哨兵节点,最后根据条件选择一个Slave节点作为主节点。

集群

Redis集群通过分片来进行数据共享,并提供复制和故障转移功能。

集群节点之间通过CLUSTER MEET命令进行握手,通过Gossip协议病毒式传播形成集群。

Redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384个槽(slot),数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok);相反地,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态(fail)。计算公式:slot = CRC16(key) & 16383,每一个节点负责维护一部分槽以及槽所映射的键值数据。

集群节点CLUSTER ADDSLOTS命令指定自己负责的槽信息,节点会通过发送消息告知集群中的其他节点,自己目前正在负责处理哪些槽。

Redis集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。

当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移。首先集群中的所有主节点会选举一个从节点,选择算法都是基于Raft算法,随机时间,配置纪元,过半选举成功。然后从节点成为主节点,并且指派已下线主节点的槽信息给自己。接着像集群广播PONG消息。然后开始处理请求,完成故障转移。

集群里的每个节点默认每隔一秒钟就会从已知节点列表中随机选出五个节点,然后对这五个节点中最长时间没有发送过PING消息的节点发送PING消息,以此来检测被选中的节点是否在线。除此之外,如果节点A最后一次收到节点B发送的PONG消息的时间,距离当前时间已经超过了节点A的cluster-node-timeout选项设置时长的一半,那么节点A也会向节点B发送PING消息,这可以防止节点A因为长时间没有随机选中节点B作为PING消息的发送对象而导致对节点B的信息更新滞后。

当节点接收到一个PUBLISH命令时,节点会执行这个命令,并向集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会执行相同的PUBLISH命令。

集群环境下,Lua脚本的所有 key,必须在 1 个 slot 上,否则直接返回 error。所有 key 都应该由 KEYS 数组来传递,redis.call/pcall 里面调用的 redis 命令,key 的位置,必须是 KEYS array, 否则直接返回 error。

分布式锁

  1. set命令同时设置NX属性和过期时间原子性获取锁,通过锁key和锁的随机值作为参数,使用lua脚本原子性判断值是否相等来删除锁,防止由于某个线程超时导致别的线程的锁被误删除,从而导致锁失效。
  2. redission单实例锁会自动续期,可以更好的避免锁失效的问题, redission使用Lua脚本实现加锁和解锁,锁是可重入的,set命令的锁是不可重入的。
  3. RedLock实现:顺序向五个节点请求加锁,根据一定的超时时间来推断是不是跳过该节点,三个节点加锁成功并且花费时间小于锁的有效期,认定加锁成功。

缓存实战问题

缓存雪崩

缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。

解决的方案:

  • 事前:尽量保证整个 redis 集群的高可用性,发现机器宕机尽快补上。选择合适的内存淘汰策略。
  • 事中:本地ehcache缓存 + hystrix限流&降级,避免MySQL崩掉
  • 事后:利用 redis 持久化机制保存的数据尽快恢复缓存

缓存穿透

大量请求的 key 不存在,缓存中没有,导致请求直接到了数据库上,根本没有经过缓存这一层。

解决的方案:

  1. 缓存无效 key : 如果缓存和数据库都查不到某个 key 的数据就写一个到 redis 中去并设置过期时间,一般设置5分钟。
  2. 布隆过滤器:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,会先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。

Cache-Aside pattern

  • 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中;
  • 命中:应用程序从cache中取数据,取到后返回;
  • 更新:先把数据存到数据库中,成功后,再让缓存失效;

给缓存设有效时间可以保证最终一致性。

问题1:先更新数据库,再删除缓存。如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致。

解决思路:先删除缓存,再更新数据库。如果数据库更新失败了,那么数据库中是旧数据,缓存中是空的,那么数据不会不一致。因为读的时候缓存没有,所以去读了数据库中的旧数据,然后更新到缓存中。。

问题2:上亿流量高并发场景下数据发生了变更,先删除了缓存,然后要去修改数据库,此时还没修改。一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中。随后数据变更的程序完成了数据库的修改。数据库和缓存中的数据不一样了。

解决方案1:更新数据的时候,根据数据的唯一标识,将操作路由之后,发送到一个 jvm 内部队列中。读取数据的时候,如果发现数据不在缓存中,那么将重新执行“读取数据+更新缓存”的操作,根据唯一标识路由之后,也发送到同一个 jvm 内部队列中。一个队列对应一个工作线程,每个工作线程串行拿到对应的操作,然后一条一条的执行。这样的话,一个数据变更的操作,先删除缓存,然后再去更新数据库,但是还没完成更新。此时如果一个读请求过来,没有读到缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,然后同步等待缓存更新完成。

解决方案2:采用双延时删除策略。在主从同步的延时时间基础上,加几百ms。

缓存删除失败的场景,可以通过消息队列进行重试。

参考衔接


  1. 深入分布式缓存:从原理到实践第8章
  2. Redis使用手册
  3. Redis设计与实现
  4. https://mp.weixin.qq.com/s/FxOwmWCGagL5pIdLBkr82A
  5. https://mp.weixin.qq.com/s/-fk-cEIo3iDCUSwT_l8d2w
  6. https://github.com/doocs/advanced-java/blob/master/docs/high-concurrency/redis-consistence.md