3种常用的缓存读写策略详解
Cache Aside Pattern(旁路缓存模式)
Cache Aside Pattern是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。Cache Aside Pattern中服务端需要同时维系db和cache,并且是以db的结果为准。下面我们来看一下这个策略模式下的缓存读写步骤。
写:
- 先更新db
- 然后直接删除cache。
简单画了一张图帮助大家理解写的步骤。
读:
- 从cache中读取数据,读取到就直接返回
- cache中读取不到的话,就从db中读取数据返回
- 再把数据放到cache中。
简单画了一张图帮助大家理解读的步骤。
你仅仅了解了上面这些内容的话是远远不够的,我们还要搞懂其中的原理。比如说面试官很可能会追问:在写数据的过程中,可以先删除cache,后更新db么?答案:那肯定是不行的!因为这样可能会造成数据库(db)和缓存(Cache)数据不一致的问题。
举例:请求1先写数据A,请求2随后读数据A的话,就很有可能产生数据不一致性的问题。这个过程可以简单描述为:
请求1先把cache中的A数据删除->请求2从db中读取数据->请求1再把db中的A数据更新
当你这样回答之后,面试官可能会紧接着就追问:在写数据的过程中,先更新db,后删除cache就没有问题了么?答案:理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多。
举例:请求1先读数据A,请求2随后写数据A,并且数据A在请求1请求之前不在缓存中的话,也有可能产生数据不一致性的问题。这个过程可以简单描述为:
请求1从db读数据A->请求2更新db中的数据A(此时缓存中无数据A,故不用执行删除缓存操作)->请求1将数据A写入cache
现在我们再来分析一下Cache Aside Pattern的缺陷。
缺陷1:首次请求数据一定不在cache的问题
解决办法:可以将热点数据可以提前放入cache中。
缺陷2:写操作比较频繁的话导致cache中的数据会被频繁被删除,这样会影响缓存命中率。
解决办法:
- 数据库和缓存数据强一致场景:更新db的时候同样更新cache,不过我们需要加一个锁/分布式锁来保证更新cache的时候不存在线程安全问题。
- 可以短暂地允许数据库和缓存数据不一致的场景:更新db的时候同样更新cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。
Read/Write Through Pattern(读写穿透)
Read/Write Through Pattern中服务端把cache视为主要数据存储,从中读取数据并将数据写入其中。cache服务负责将此数据读取和写入db,从而减轻了应用程序的职责。这种缓存读写策略小伙伴们应该也发现了在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存Redis并没有提供cache将数据写入db的功能。
写(Write Through):
- 先查cache,cache中不存在,直接更新db。
- cache中存在,则先更新cache,然后cache服务自己更新db(同步更新cache和db)。
简单画了一张图帮助大家理解写的步骤。
读(Read Through):
- 从cache中读取数据,读取到就直接返回。
- 读取不到的话,先从db加载,写入到cache后返回响应。
简单画了一张图帮助大家理解读的步骤。
Read-Through Pattern实际只是在Cache-Aside Pattern之上进行了封装。在Cache-Aside Pattern下,发生读请求的时候,如果cache中不存在对应的数据,是由客户端自己负责把数据写入cache,而Read Through Pattern则是cache服务自己来写入缓存的,这对客户端是透明的。和Cache Aside Pattern一样,Read-Through Pattern也有首次请求数据一定不再cache的问题,对于热点数据可以提前放入缓存中。
Write Behind Pattern(异步缓存写入)
Write Behind Pattern和Read/Write Through Pattern很相似,两者都是由cache服务来负责cache和db的读写。但是,两个又有很大的不同:Read/Write Through是同步更新cache和db,而Write Behind则是只更新缓存,不直接更新db,而是改为异步批量的方式来更新db。很明显,这种方式对数据一致性带来了更大的挑战,比如cache数据可能还没异步更新db的话,cache服务可能就就挂掉了。这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL的Innodb Buffer Pool机制都用到了这种策略。Write Behind Pattern下db的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量
Redis5种基本数据结构详解
Redis共有5种基本数据结构:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。这5种数据结构是直接提供给用户使用的,是数据的保存形式,其底层实现主要依赖这8种数据结构:简单动态字符串(SDS)、LinkedList(双向链表)、HashTable(哈希表)、SkipList(跳跃表)、Intset(整数集合)、ZipList(压缩列表)、QuickList(快速列表)。Redis基本数据结构的底层数据结构实现如下:
String | List | Hash | Set | Zset |
---|---|---|---|---|
SDS | LinkedList/ZipList/QuickList | Hash Table、ZipList | ZipList、Intset | ZipList、SkipList |
Redis3.2之前,List底层实现是LinkedList或者ZipList。Redis3.2之后,引入了LinkedList和ZipList的结合QuickList,List的底层实现变为QuickList。你可以在Redis官网上找到Redis数据结构非常详细的介绍:
String(字符串)
介绍
String是Redis中最简单同时也是最常用的一个数据结构。String是一种二进制安全的数据结构,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的base64编码或者解码或者图片的路径)、序列化后的对象。
虽然Redis是用C语言写的,但是Redis并没有使用C的字符串表示,而是自己构建了一种简单动态字符串(Simple Dynamic String,SDS)。相比于C的原生字符串,Redis的SDS不光可以保存文本数据还可以保存二进制数据,并且获取字符串长度复杂度为O(1)(C字符串为O(N)),除此之外,Redis的SDS API是安全的,不会造成缓冲区溢出。
常用命令
命令 | 介绍 |
---|---|
SET key value | 设置指定key的值 |
SETNX key value | 只有在key不存在时设置key的值 |
GET key | 获取指定key的值 |
MSET key1 value1 key2 value2 … | 设置一个或多个指定key的值 |
MGET key1 key2 … | 获取一个或多个指定key的值 |
STRLEN key | 返回key所储存的字符串值的长度 |
INCR key | 将key中储存的数字值增一 |
DECR key | 将key中储存的数字值减一 |
EXISTS key | 判断指定key是否存在 |
DEL key(通用) | 删除指定的key |
EXPIRE key seconds(通用) | 给指定key设置过期时间 |
更多Redis String命令以及详细使用指南,请查看Redis官网对应的介绍。
基本操作:
> SET key value
OK
> GET key
"value"
> EXISTS key
(integer) 1
> STRLEN key
(integer) 5
> DEL key
(integer) 1
> GET key
(nil)
批量设置:
> MSET key1 value1 key2 value2
OK
> MGET key1 key2 # 批量获取多个key对应的value
1) "value1"
2) "value2"
计数器(字符串的内容为整数的时候可以使用):
> SET number 1
OK
> INCR number #将key中储存的数字值增一
(integer) 2
> GET number
"2"
> DECR number # 将key中储存的数字值减一
(integer) 1
> GET number
"1"
设置过期时间(默认为永不过期):
> EXPIRE key 60
(integer) 1
> SETNX key 60 value # 设置值并设置过期时间
OK
> TTL key
(integer) 56
应用场景
需要存储常规数据的场景
- 举例:缓存session、token、图片地址、序列化后的对象(相比较于Hash存储更节省内存)。
- 相关命令:SET、GET。
需要计数的场景
- 举例:用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数。
- 相关命令:SET、GET、INCR、DECR。
分布式锁
利用SETNX key value命令可以实现一个最简易的分布式锁(存在一些缺陷,通常不建议这样实现分布式锁)。
List(列表)
介绍
Redis中的List其实就是链表数据结构的实现。
我在线性数据结构:数组、链表、栈、队列这篇文章中详细介绍了链表这种数据结构,我这里就不多做介绍了。
许多高级编程语言都内置了链表的实现比如Java中的LinkedList,但是C语言并没有实现链表,所以Redis实现了自己的链表数据结构。Redis的List的实现为一个双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。
常用命令
命令 | 介绍 |
---|---|
RPUSH key value1 value2 … | 在指定列表的尾部(右边)添加一个或多个元素 |
LPUSH key value1 value2 … | 在指定列表的头部(左边)添加一个或多个元素 |
LSET key index value | 将指定列表索引index位置的值设置为 value |
LPOP key | 移除并获取指定列表的第一个元素(最左边) |
RPOP key | 移除并获取指定列表的最后一个元素(最右边) |
LLEN key | 获取列表元素数量 |
LRANGE key start end | 获取列表start和end之间的元素 |
更多Redis List命令以及详细使用指南,请查看Redis官网对应的介绍。
通过RPUSH/LPOP或者LPUSH/RPOP实现队列:
> RPUSH myList value1
(integer) 1
> RPUSH myList value2 value3
(integer) 3
> LPOP myList
"value1"
> LRANGE myList 0 1
1) "value2"
2) "value3"
> LRANGE myList 0 -1
1) "value2"
2) "value3"
通过RPUSH/RPOP或者LPUSH/LPOP实现栈:
> RPUSH myList2 value1 value2 value3
(integer) 3
> RPOP myList2 # 将list的头部(最右边)元素取出
"value3"
我专门画了一个图方便大家理解RPUSH,LPOP,lpush,RPOP命令:
通过LRANGE查看对应下标范围的列表元素:
> RPUSH myList value1 value2 value3
(integer) 3
> LRANGE myList 0 1
1) "value1"
2) "value2"
> LRANGE myList 0 -1
1) "value1"
2) "value2"
3) "value3"
通过LRANGE命令,你可以基于List实现分页查询,性能非常高!
通过LLEN查看链表长度:
> LLEN myList
(integer) 3
应用场景
信息流展示
- 举例:最新文章、最新动态。
- 相关命令:LPUSH、LRANGE。
消息队列
Redis List数据结构可以用来做消息队列,只是功能过于简单且存在很多缺陷,不建议这样做。
相对来说,Redis5.0新增加的一个数据结构Stream更适合做消息队列一些,只是功能依然非常简陋。和专业的消息队列相比,还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。
Hash(哈希)
介绍
Redis中的Hash是一个String类型的field-value(键值对)的映射表,特别适合用于存储对象,后续操作的时候,你可以直接修改这个对象中的某些字段的值。Hash类似于JDK1.8前的HashMap,内部实现也差不多(数组+链表)。不过,Redis的Hash做了更多优化。
常用命令
命令 | 介绍 |
---|---|
HSET key field value | 设置指定哈希表中指定字段的值 |
HSETNX key field value | 只有指定字段不存在时设置指定字段的值 |
HMSET key field1 value1 field2 value2 … | 同时将一个或多个field-value(域-值)对设置到指定哈希表中 |
HGET key field | 获取指定哈希表中指定字段的值 |
HMGET key field1 field2 … | 获取指定哈希表中一个或者多个指定字段的值 |
HGETALL key | 获取指定哈希表中所有的键值对 |
HEXISTS key field | 查看指定哈希表中指定的字段是否存在 |
HDEL key field1 field2 … | 删除一个或多个哈希表字段 |
HLEN key | 获取指定哈希表中字段的数量 |
HINCRBY key field increment | 对指定哈希中的指定字段做运算操作(正数为加,负数为减) |
更多Redis Hash命令以及详细使用指南,请查看Redis官网对应的介绍。
模拟对象数据存储:
> HMSET userInfoKey name "guide" description "dev" age 24
OK
> HEXISTS userInfoKey name # 查看key对应的value中指定的字段是否存在。
(integer) 1
> HGET userInfoKey name # 获取存储在哈希表中指定字段的值。
"guide"
> HGET userInfoKey age
"24"
> HGETALL userInfoKey # 获取在哈希表中指定key的所有字段和值
1) "name"
2) "guide"
3) "description"
4) "dev"
5) "age"
6) "24"
> HSET userInfoKey name "GuideGeGe"
> HGET userInfoKey name
"GuideGeGe"
> HINCRBY userInfoKey age 2
(integer) 26
应用场景
对象数据存储场景
- 举例:用户信息、商品信息、文章信息、购物车信息。
- 相关命令:HSET(设置单个字段的值)、HMSET(设置多个字段的值)、HGET(获取单个字段的值)、HMGET(获取多个字段的值)。
Set(集合)
介绍
Redis中的Set类型是一种无序集合,集合中的元素没有先后顺序但都唯一,有点类似于Java中的HashSet。当你需要存储一个列表数据,又不希望出现重复数据时,Set是一个很好的选择,并且Set提供了判断某个元素是否在一个Set集合内的重要接口,这个也是List所不能提供的。你可以基于Set轻易实现交集、并集、差集的操作,比如你可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。这样的话,Set可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程。
常用命令
命令 | 介绍 |
---|---|
SADD key member1 member2 … | 向指定集合添加一个或多个元素 |
SMEMBERS key | 获取指定集合中的所有元素 |
SCARD key | 获取指定集合的元素数量 |
SISMEMBER key member | 判断指定元素是否在指定集合中 |
SINTER key1 key2 … | 获取给定所有集合的交集 |
SINTERSTORE destination key1 key2 … | 将给定所有集合的交集存储在destination中 |
SUNION key1 key2 … | 获取给定所有集合的并集 |
SUNIONSTORE destination key1 key2 … | 将给定所有集合的并集存储在destination中 |
SDIFF key1 key2 … | 获取给定所有集合的差集 |
SDIFFSTORE destination key1 key2 … | 将给定所有集合的差集存储在destination中 |
SPOP key count | 随机移除并获取指定集合中一个或多个元素 |
SRANDMEMBER key count | 随机获取指定集合中指定数量的元素 |
更多Redis Set命令以及详细使用指南,请查看Redis官网对应的介绍。
基本操作:
> SADD mySet value1 value2
(integer) 2
> SADD mySet value1 # 不允许有重复元素,因此添加失败
(integer) 0
> SMEMBERS mySet
1) "value1"
2) "value2"
> SCARD mySet
(integer) 2
> SISMEMBER mySet value1
(integer) 1
> SADD mySet2 value2 value3
(integer) 2
mySet
:value1
、value2
。mySet2
:value2
、value3
。
求交集:
> SINTERSTORE mySet3 mySet mySet2
(integer) 1
> SMEMBERS mySet3
1) "value2"
求并集:
> SUNION mySet mySet2
1) "value3"
2) "value2"
3) "value1"
求差集:
> SDIFF mySet mySet2 # 差集是由所有属于mySet但不属于A的元素组成的集合
1) "value1"
应用场景
需要存放的数据不能重复的场景
- 举例:网站UV统计(数据量巨大的场景还是HyperLogLog更适合一些)、文章点赞、动态点赞等场景。
- 相关命令:SCARD(获取集合数量)。
需要获取多个数据源交集、并集和差集的场景
- 举例:共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集)、订阅号推荐(差集+交集)等场景。
- 相关命令:SINTER(交集)、SINTERSTORE(交集)、SUNION(并集)、SUNIONSTORE(并集)、SDIFF(差集)、SDIFFSTORE(差集)。
需要随机获取数据源中的元素的场景
- 举例:抽奖系统、随机。
- 相关命令:SPOP(随机获取集合中的元素并移除,适合不允许重复中奖的场景)、SRANDMEMBER(随机获取集合中的元素,适合允许重复中奖的场景)。
Sorted Set(有序集合)
介绍
Sorted Set类似于Set,但和Set相比,Sorted Set增加了一个权重参数score,使得集合中的元素能够按score进行有序排列,还可以通过score的范围来获取元素的列表。有点像是Java中HashMap和TreeSet的结合体。
常用命令
命令 | 介绍 |
---|---|
ZADD key score1 member1 score2 member2 … | 向指定有序集合添加一个或多个元素 |
ZCARD KEY | 获取指定有序集合的元素数量 |
ZSCORE key member | 获取指定有序集合中指定元素的score值 |
ZINTERSTORE destination numkeys key1 key2 … | 将给定所有有序集合的交集存储在destination中,对相同元素对应的score值进行SUM聚合操作,numkeys为集合数量 |
ZUNIONSTORE destination numkeys key1 key2 … | 求并集,其它和ZINTERSTORE类似 |
ZDIFFSTORE destination numkeys key1 key2 … | 求差集,其它和ZINTERSTORE类似 |
ZRANGE key start end | 获取指定有序集合start和end之间的元素(score从低到高) |
ZREVRANGE key start end | 获取指定有序集合start和end之间的元素(score从高到底) |
ZREVRANK key member | 获取指定有序集合中指定元素的排名(score从大到小排序) |
更多Redis Sorted Set命令以及详细使用指南,请查看Redis官网对应的介绍。
基本操作:
> ZADD myZset 2.0 value1 1.0 value2
(integer) 2
> ZCARD myZset
2
> ZSCORE myZset value1
2.0
> ZRANGE myZset 0 1
1) "value2"
2) "value1"
> ZREVRANGE myZset 0 1
1) "value1"
2) "value2"
> ZADD myZset2 4.0 value2 3.0 value3
(integer) 2
myZset
:value1
(2.0)、value2
(1.0)。myZset2
:value2
(4.0)、value3
(3.0)。
获取指定元素的排名:
> ZREVRANK myZset value1
0
> ZREVRANK myZset value2
1
求交集:
> ZINTERSTORE myZset3 2 myZset myZset2
1
> ZRANGE myZset3 0 1 WITHSCORES
value2
5
求并集:
> ZUNIONSTORE myZset4 2 myZset myZset2
3
> ZRANGE myZset4 0 2 WITHSCORES
value1
2
value3
3
value2
5
求差集:
> ZDIFF 2 myZset myZset2 WITHSCORES
value1
2
应用场景
需要随机获取数据源中的元素根据某个权重进行排序的场景
- 举例:各种排行榜比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。
- 相关命令:ZRANGE(从小到大排序)、ZREVRANGE(从大到小排序)、ZREVRANK(指定元素排名)。
需要存储的数据有优先级或者重要程度的场景,比如优先级任务队列。
- 举例:优先级任务队列。
- 相关命令:ZRANGE(从小到大排序)、ZREVRANGE(从大到小排序)、ZREVRANK(指定元素排名)。
Redis3种特殊数据结构详解
Bitmap
介绍
Bitmap存储的是连续的二进制数字(0和1),通过Bitmap,只需要一个bit位来表示某个元素对应的值或者状态,key就是对应元素本身。我们知道8个bit可以组成一个byte,所以Bitmap本身会极大的节省储存空间。你可以将Bitmap看作是一个存储二进制数字(0和1)的数组,数组中每个元素的下标叫做offset(偏移量)。
常用命令
命令 | 介绍 |
---|---|
SETBIT key offset value | 设置指定offset位置的值 |
GETBIT key offset | 获取指定offset位置的值 |
BITCOUNT key start end | 获取start和end之前值为1的元素个数 |
BITOP operation destkey key1 key2 … | 对一个或多个Bitmap进行运算,可用运算符有AND,OR,XOR以及NOT |
Bitmap基本操作演示:
# SETBIT会返回之前位的值(默认是0)这里会生成7个位
> SETBIT mykey 7 1
(integer) 0
> SETBIT mykey 7 0
(integer) 1
> GETBIT mykey 7
(integer) 0
> SETBIT mykey 6 1
(integer) 0
> SETBIT mykey 8 1
(integer) 0
# 通过bitcount统计被被设置为1的位的数量。
> BITCOUNT mykey
(integer) 2
应用场景
需要保存状态信息(0/1即可表示)的场景
- 举例:用户签到情况、活跃用户情况、用户行为统计(比如是否点赞过某个视频)。
- 相关命令:SETBIT、GETBIT、BITCOUNT、BITOP。
HyperLogLog
介绍
HyperLogLog是一种有名的基数计数概率算法,基于LogLog Counting(LLC)优化改进得来,并不是Redis特有的,Redis只是实现了这个算法并提供了一些开箱即用的API。Redis提供的HyperLogLog占用空间非常非常小,只需要12k的空间就能存储接近2^64个不同元素。这是真的厉害,这就是数学的魅力么!并且,Redis对HyperLogLog的存储结构做了优化,采用两种方式计数:
- 稀疏矩阵:计数较少的时候,占用空间很小。
- 稠密矩阵:计数达到某个阈值的时候,占用12k的空间。
Redis官方文档中有对应的详细说明:
基数计数概率算法为了节省内存并不会直接存储元数据,而是通过一定的概率统计方法预估基数值(集合中包含元素的个数)。因此,HyperLogLog的计数结果并不是一个精确值,存在一定的误差(标准误差为0.81%)。
HyperLogLog的使用非常简单,但原理非常复杂。HyperLogLog的原理以及在Redis中的实现可以看这篇文章:HyperLogLog算法的原理讲解以及Redis是如何应用它的。
再推荐一个可以帮助理解HyperLogLog原理的工具:Sketch of the Day: HyperLogLog — Cornerstone of a Big Data Infrastructure。
常用命令
HyperLogLog相关的命令非常少,最常用的也就3个。
命令 | 介绍 |
---|---|
PFADD key element1 element2 … | 添加一个或多个元素到HyperLogLog中 |
PFCOUNT key1 key2 | 获取一个或者多个HyperLogLog的唯一计数。 |
PFMERGE destkey sourcekey1 sourcekey2 … | 将多个HyperLogLog合并到destkey中,destkey会结合多个源,算出对应的唯一计数。 |
HyperLogLog基本操作演示:
> PFADD hll foo bar zap
(integer) 1
> PFADD hll zap zap zap
(integer) 0
> PFADD hll foo bar
(integer) 0
> PFCOUNT hll
(integer) 3
> PFADD some-other-hll 1 2 3
(integer) 1
> PFCOUNT hll some-other-hll
(integer) 6
> PFMERGE desthll hll some-other-hll
"OK"
> PFCOUNT desthll
(integer) 6
应用场景
数量量巨大(百万、千万级别以上)的计数场景
- 举例:热门网站每日/每周/每月访问ip数统计、热门帖子uv统计、
- 相关命令:
PFADD
、PFCOUNT
。
Geospatial index
介绍
Geospatial index(地理空间索引,简称GEO)主要用于存储地理位置信息,基于Sorted Set实现。
通过GEO我们可以轻松实现两个位置距离的计算、获取指定位置附近的元素等功能。
常用命令
命令 | 介绍 |
---|---|
GEOADD key longitude1 latitude1 member1 … | 添加一个或多个元素对应的经纬度信息到GEO中 |
GEOPOS key member1 member2 … | 返回给定元素的经纬度信息 |
GEODIST key member1 member2 M/KM/FT/MI | 返回两个给定元素之间的距离 |
GEORADIUS key longitude latitude radius distance | 获取指定位置附近distance范围内的其他元素,支持ASC(由近到远)、DESC(由远到近)、Count(数量)等参数 |
GEORADIUSBYMEMBER key member radius distance | 类似于GEORADIUS命令,只是参照的中心点是GEO中的元素 |
基本操作:
> GEOADD personLocation 116.33 39.89 user1 116.34 39.90 user2 116.35 39.88 user3
3
> GEOPOS personLocation user1
116.3299986720085144
39.89000061669732844
> GEODIST personLocation user1 user2 km
1.4018
通过Redis可视化工具查看personLocation,果不其然,底层就是Sorted Set。
GEO中存储的地理位置信息的经纬度数据通过GeoHash算法转换成了一个整数,这个整数作为Sorted Set的score(权重参数)使用。
获取指定位置范围内的其他元素:
> GEORADIUS personLocation 116.33 39.87 3 km
user3
user1
> GEORADIUS personLocation 116.33 39.87 2 km
> GEORADIUS personLocation 116.33 39.87 5 km
user3
user1
user2
> GEORADIUSBYMEMBER personLocation user1 5 km
user3
user1
user2
> GEORADIUSBYMEMBER personLocation user1 2 km
user1
user2
GEORADIUS命令的底层原理解析可以看看阿里的这篇文章:Redis到底是怎么实现“附近的人”这个功能的呢?。
移除元素:
GEO底层是Sorted Set,你可以对GEO使用Sorted Set相关的命令。
> ZREM personLocation user1
1
> ZRANGE personLocation 0 -1
user3
user2
> ZSCORE personLocation user2
4069879562983946
应用场景
需要管理使用地理空间数据的场景
- 举例:附近的人。
- 相关命令:GEOADD、GEORADIUS、GEORADIUSBYMEMBER
Redis事务
如何使用Redis事务?
Redis可以通过MULTI,EXEC,DISCARD和WATCH等命令来实现事务(transaction)功能。
> MULTI
OK
> SET PROJECT "JavaGuide"
QUEUED
> GET PROJECT
QUEUED
> EXEC
1) OK
2) "JavaGuide"
MULTI命令后可以输入多个命令,Redis不会立即执行这些命令,而是将它们放到队列,当调用了EXEC命令后,再执行所有的命令。这个过程是这样的:
- 开始事务(MULTI);
- 命令入队(批量操作Redis的命令,先进先出(FIFO)的顺序执行);
- 执行事务(EXEC)。
你也可以通过DISCARD命令取消一个事务,它会清空事务队列中保存的所有命令。
> MULTI
OK
> SET PROJECT "JavaGuide"
QUEUED
> GET PROJECT
QUEUED
> DISCARD
OK
你可以通过WATCH命令监听指定的Key,当调用EXEC命令执行事务时,如果一个被WATCH命令监视的Key被其他客户端/Session修改的话,整个事务都不会被执行。
# 客户端1
> SET PROJECT "RustGuide"
OK
> WATCH PROJECT
OK
> MULTI
OK
> SET PROJECT "JavaGuide"
QUEUED
# 客户端2
# 在客户端1执行EXEC命令提交事务之前修改PROJECT的值
> SET PROJECT "GoGuide"
# 客户端1
# 修改失败,因为PROJECT的值被客户端2修改了
> EXEC
(nil)
> GET PROJECT
"GoGuide"
不过,如果WATCH与事务在同一个Session里,并且被WATCH监视的Key被修改的操作发生在事务内部,这个事务是可以被执行成功的
(相关issue:WATCH命令碰到MULTI命令时的不同效果)。
事务内部修改WATCH监视的Key:
> SET PROJECT "JavaGuide"
OK
> WATCH PROJECT
OK
> MULTI
OK
> SET PROJECT "JavaGuide1"
QUEUED
> SET PROJECT "JavaGuide2"
QUEUED
> SET PROJECT "JavaGuide3"
QUEUED
> EXEC
1) OK
2) OK
3) OK
127.0.0.1:6379> GET PROJECT
"JavaGuide3"
事务外部修改WATCH监视的Key:
> SET PROJECT "JavaGuide"
OK
> WATCH PROJECT
OK
> SET PROJECT "JavaGuide2"
OK
> MULTI
OK
> GET USER
QUEUED
> EXEC
(nil)
Redis官网相关介绍https://redis.io/topics/transactions
Redis事务支持原子性吗?
Redis的事务和我们平时理解的关系型数据库的事务不同。我们知道事务具有四大特性:原子性,隔离性,持久性,一致性。
- 原子性(Atomicity):事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
- 隔离性(Isolation):并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
- 持久性(Durability):一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。
- 一致性(Consistency):执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的;
Redis事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis事务是不支持回滚(rollback)操作的。因此,Redis事务其实是不满足原子性的(而且不满足持久性)。
Redis官网也解释了自己为啥不支持回滚。简单来说就是Redis开发者们觉得没必要支持回滚,这样更简单便捷并且性能更好。Redis开发者觉得即使命令执行错误也应该在开发过程中就被发现而不是生产过程中。
你可以将Redis中的事务就理解为:Redis事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。除了不满足原子性之外,事务中的每条命令都会与Redis服务器进行网络交互,这是比较浪费资源的行为。明明一次批量执行多个命令就可以了,这种操作实在是看不懂。因此,Redis事务是不建议在日常开发中使用的。
相关issue:
如何解决Redis事务的缺陷?
Redis从2.6版本开始支持执行Lua脚本,它的功能和事务非常类似。我们可以利用Lua脚本来批量执行多条Redis命令,这些Redis命令会被提交到Redis服务器一次性执行完成,大幅减小了网络开销。一段Lua脚本可以视作一条命令执行,一段Lua脚本执行过程中不会有其他脚本或Redis命令同时执行,保证了操作不会被其他指令插入或打扰。不过,如果Lua脚本运行时出错并中途结束,出错之后的命令是不会被执行的。并且,出错之前执行的命令是无法被撤销的,无法实现类似关系型数据库执行失败可以回滚的那种原子性效果。因此,严格来说的话,通过Lua脚本来批量执行Redis命令实际也是不完全满足原子性的。如果想要让Lua脚本中的命令全部执行,必须保证语句语法和命令都是对的。另外,Redis7.0新增了Redis functions特性,你可以将Redis functions看作是比Lua更强大的脚本。
Redis性能优化
使用批量操作减少网络传输
一个Redis命令的执行可以简化为以下4步:
- 发送命令
- 命令排队
- 命令执行
- 返回结果
其中,第1步和第4步耗费时间之和称为Round Trip Time(RTT,往返时间),也就是数据在网络上传输的时间。使用批量操作可以减少网络传输次数,进而有效减小网络开销,大幅减少RTT。
原生批量操作命令
Redis中有一些原生支持批量操作的命令,比如:
- mget(获取一个或多个指定key的值)、mset(设置一个或多个指定key的值)、
- hmget(获取指定哈希表中一个或者多个指定字段的值)、hmset(同时将一个或多个field-value对设置到指定哈希表中)、
- sadd(向指定集合添加一个或多个元素)
- ……
不过,在Redis官方提供的分片集群解决方案Redis Cluster下,使用这些原生批量操作命令可能会存在一些小问题需要解决。就比如说mget无法保证所有的key都在同一个hash slot(哈希槽)上,mget可能还是需要多次网络传输,原子操作也无法保证了。不过,相较于非批量操作,还是可以节省不少网络传输次数。整个步骤的简化版如下(通常由Redis客户端实现,无需我们自己再手动实现):
- 找到key对应的所有hash slot;
- 分别向对应的Redis节点发起mget请求获取数据;
- 等待所有请求执行结束,重新组装结果数据,保持跟入参key的顺序一致,然后返回结果。
如果想要解决这个多次网络传输的问题,比较常用的办法是自己维护key与slot的关系。不过这样不太灵活,虽然带来了性能提升,但同样让系统复杂性提升。
Redis Cluster并没有使用一致性哈希,采用的是哈希槽分区,每一个键值对都属于一个hash slot(哈希槽)。当客户端发送命令请求的时候,需要先根据key通过上面的计算公示找到的对应的哈希槽,然后再查询哈希槽和节点的映射关系,即可找到目标Redis节点。
pipeline
对于不支持批量操作的命令,我们可以利用pipeline(流水线)将一批Redis命令封装成一组,这些Redis命令会被一次性提交到Redis服务器,只需要一次网络传输。不过,需要注意控制一次批量操作的元素个数(例如500以内,实际也和元素字节数有关),避免网络传输的数据量过大。与mget、mset等原生批量操作命令一样,pipeline同样在Redis Cluster上使用会存在一些小问题。原因类似,无法保证所有的key都在同一个hash slot(哈希槽)上。如果想要使用的话,客户端需要自己维护key与slot的关系。原生批量操作命令和pipeline的是有区别的,使用的时候需要注意:
- 原生批量操作命令是原子操作,pipeline是非原子操作;
- pipeline可以打包不同的命令,原生批量操作命令不可以;
- 原生批量操作命令是Redis服务端支持实现的,而pipeline需要服务端和客户端的共同实现。
另外,pipeline不适用于执行顺序有依赖关系的一批命令。就比如说,你需要将前一个命令的结果给后续的命令使用,pipeline就没办法满足你的需求了。对于这种需求,我们可以使用Lua脚本。
Lua脚本
Lua脚本同样支持批量操作多条命令。一段Lua脚本可以视作一条命令执行,可以看作是原子操作。一段Lua脚本执行过程中不会有其他脚本或Redis命令同时执行,保证了操作不会被其他指令插入或打扰,这是pipeline所不具备的。并且,Lua脚本中支持一些简单的逻辑处理比如使用命令读取值并在Lua脚本中进行处理,这同样是pipeline所不具备的。不过,Redis Cluster下Lua脚本的原子操作也无法保证了,原因同样是无法保证所有的key都在同一个hashslot(哈希槽)上。
大量key集中过期问题
我在前面提到过:对于过期key,Redis采用的是定期删除+惰性/懒汉式删除策略。
定期删除执行过程中,如果突然遇到大量过期key的话,客户端请求必须等待定期清理过期key任务线程执行完成,因为这个这个定期任务线程是在Redis主线程中执行的。这就导致客户端请求没办法被及时处理,响应速度会比较慢。如何解决呢?下面是两种常见的方法:
- 给key设置随机过期时间。
- 开启lazy-free(惰性删除/延迟释放)。lazy-free特性是Redis4.0开始引入的,指的是让Redis采用异步方式延迟释放key使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。
个人建议不管是否开启lazy-free,我们都尽量给key设置随机过期时间。
Redis bigkey
什么是bigkey?
简单来说,如果一个key对应的value所占用的内存比较大,那这个key就可以看作是bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:string类型的value超过10kb,复合类型的value包含的元素超过5000个(对于复合类型的value来说,不一定包含的元素越多,占用的内存就越多)。
bigkey有什么危害?
除了会消耗更多的内存空间,bigkey对性能也会有比较大的影响。因此,我们应该尽量避免写入bigkey!
如何发现bigkey?
1、使用Redis自带的 –bigkeys参数来查找。
# redis-cli -p 6379 --bigkeys
# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type. You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).
[00.00%] Biggest string found so far '"ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20"' with 4437 bytes
[00.00%] Biggest list found so far '"my-list"' with 17 items
-------- summary -------
Sampled 5 keys in the keyspace!
Total key length in bytes is 264 (avg len 52.80)
Biggest list found '"my-list"' has 17 items
Biggest string found '"ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20"' has 4437 bytes
1 lists with 17 items (20.00% of keys, avg size 17.00)
0 hashs with 0 fields (00.00% of keys, avg size 0.00)
4 strings with 4831 bytes (80.00% of keys, avg size 1207.75)
0 streams with 0 entries (00.00% of keys, avg size 0.00)
0 sets with 0 members (00.00% of keys, avg size 0.00)
0 zsets with 0 members (00.00% of keys, avg size 0.00
从这个命令的运行结果,我们可以看出:这个命令会扫描(Scan)Redis中的所有key,会对Redis的性能有一点影响。并且,这种方式只能找出每种数据结构top 1 bigkey(占用内存最大的string数据类型,包含元素最多的复合数据类型)。
2、分析RDB文件
通过分析RDB文件来找出bigkey。这种方案的前提是你的Redis采用的是RDB持久化。网上有现成的代码/工具可以直接拿来使用:
- redis-rdb-tools:Python语言写的用来分析Redis的RDB快照文件用的工具
- rdb_bigkeys:Go语言写的用来分析Redis的RDB快照文件用的工具,性能更好。
Redis内存碎片
相关问题:
- 什么是内存碎片?为什么会有Redis内存碎片?
- 如何清理Redis内存碎片?
参考答案:Redis内存碎片详解。
Redis生产问题
缓存穿透
什么是缓存穿透?
缓存穿透说简单点就是大量请求的key是不合理的,根本不存在于缓存中,也不存在于数据库中。这就导致这些请求直接到了数据库上,根本没有经过缓存这一层,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。
举个例子:某个黑客故意制造一些非法的key发起大量请求,导致大量请求落到数据库,结果数据库上也没有查到对应的数据。也就是说这些请求最终都落到了数据库上,对数据库造成了巨大的压力。
有哪些解决办法?
最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库id不能小于0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。
1)缓存无效key
如果缓存和数据库都查不到某个key的数据就写一个到Redis中去并设置过期时间,具体命令如下:SET key value EX 10086
。这种方式可以解决请求的key变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求key,会导致Redis中缓存大量无效的key。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的key的过期时间设置短一点比如1分钟。
另外,这里多说一嘴,一般情况下我们是这样设计key的:表名:列名:主键名:主键值
。
如果用Java代码展示的话,差不多是下面这样的:
public Object getObjectInclNullById(Integer id) {
// 从缓存中获取数据
Object cacheValue = cache.get(id);
// 缓存为空
if (cacheValue == null) {
// 从数据库中获取
Object storageValue = storage.get(key);
// 缓存空对象
cache.set(key, storageValue);
// 如果存储数据为空,需要设置一个过期时间(300秒)
if (storageValue == null) {
// 必须设置过期时间,否则有被攻击的风险
cache.expire(key, 60 * 5);
}
return storageValue;
}
return cacheValue;
}
2)布隆过滤器
布隆过滤器是一个非常神奇的数据结构,通过它我们可以非常方便地判断一个给定数据是否存在于海量数据中。我们需要的就是判断key是否合法,有没有感觉布隆过滤器就是我们想要找的那个“人”。
具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。加入布隆过滤器之后的缓存处理流程图如下。
但是,需要注意的是布隆过滤器可能会存在误判的情况。总结来说就是:布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。为什么会出现误判的情况呢?我们还要从布隆过滤器的原理来说!我们先来看一下,当一个元素加入布隆过滤器中的时候,会进行哪些操作:
- 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。
- 根据得到的哈希值,在位数组中把对应下标的值置为1。
我们再来看一下,当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行哪些操作:
- 对给定元素再次进行相同的哈希计算;
- 得到值之后判断位数组中的每个元素是否都为1,如果值都为1,那么说明这个值在布隆过滤器中,如果存在一个值不为1,说明该元素不在布隆过滤器中。
然后,一定会出现这样一种情况:不同的字符串可能哈希出来的位置相同。(可以适当增加位数组大小或者调整我们的哈希函数来降低概率)
更多关于布隆过滤器的内容可以看我的这篇原创:《不了解布隆过滤器?一文给你整的明明白白!》,强烈推荐,个人感觉网上应该找不到总结的这么明明白白的文章了。
总结
缓存穿透是指查询一个一定不存在的数据。由于缓存不命中,并且出于容错考虑,如果从数据库查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,失去了缓存的意义。解决方案:
- 当我们从数据库找不到的时候,我们也将这个空对象设置到缓存里边去。下次再请求的时候,就可以从缓存里边获取了。
这种情况我们一般会将空对象设置一个较短的过期时间。 - 布隆过滤器
缓存击穿
什么是缓存击穿?
缓存击穿中,请求的key对应的是热点数据,该数据存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期)。这就可能会导致瞬时大量的请求直接打到了数据库上,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。
举个例子:秒杀进行过程中,缓存中的某个秒杀商品的数据突然过期,这就导致瞬时大量对该商品的请求直接落到数据库上,对数据库造成了巨大的压力。
有哪些解决办法?
- 设置热点数据永不过期或者过期时间比较长。
- 针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
- 请求数据库写数据到缓存之前,先获取互斥锁,保证只有一个请求会落到数据库上,减少数据库的压力。
缓存穿透和缓存击穿有什么区别?
缓存穿透中,请求的key既不存在于缓存中,也不存在于数据库中。
缓存击穿中,请求的key对应的是热点数据,该数据存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期)。
总结
缓存击穿,就是说某个key非常热点,访问非常频繁,处于集中式高并发访问的情况,当这个key在失效的瞬间,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。解决方案:
- 可以将热点数据设置为永远不过期
- 基于redis or zookeeper实现互斥锁,等待第一个请求构建完缓存之后,再释放锁,进而其它请求才能通过该key访问数据
缓存雪崩
什么是缓存雪崩?
实际上,缓存雪崩描述的就是这样一个简单的场景:缓存在同一时间大面积的失效,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力。这就好比雪崩一样,摧枯拉朽之势,数据库的压力可想而知,可能直接就被这么多请求弄宕机了。另外,缓存服务宕机也会导致缓存雪崩现象,导致所有的请求都落到了数据库上。
举个例子:数据库中的大量数据在同一时间过期,这个时候突然有大量的请求需要访问这些过期的数据。这就导致大量的请求直接落到数据库上,对数据库造成了巨大的压力。
有哪些解决办法?
针对Redis服务不可用的情况:
- 采用Redis集群,避免单机出现问题整个缓存服务都没办法使用。
- 限流,避免同时处理大量的请求。
针对热点缓存失效的情况:
- 设置不同的失效时间比如随机设置缓存的失效时间。
- 缓存永不失效(不太推荐,实用性太差)。
- 设置二级缓存。
缓存雪崩和缓存击穿有什么区别?
缓存雪崩和缓存击穿比较像,但缓存雪崩导致的原因是缓存中的大量或者所有数据失效,缓存击穿导致的原因主要是某个热点数据不存在与缓存中(通常是因为缓存中的那份数据已经过期)。
总结
如果我们的缓存挂掉了,这意味着我们的全部请求都跑去数据库了。解决方案:
- 事发前:实现Redis的高可用(主从架构+Sentinel或者Redis Cluster),尽量避免Redis挂掉这种情况发生。
- 事发中:万一Redis真的挂了,我们可以设置本地缓存(ehcache)+限流(hystrix),尽量避免我们的数据库被干掉
- 事发后:redis持久化,重启后自动从磁盘上加载数据,快速恢复缓存数据
Redis缓存击穿(失效)、缓存穿透、缓存雪崩怎么解决? | Redis缓存的常见异常及解决方案 | 常说的「缓存穿透」和「击穿」是什么 |
---|---|---|
漫话:如何给女朋友解释什么是缓存穿透、缓存击穿、缓存雪崩 | 再也不怕,缓存雪崩、击穿、穿透! | 一个Redis的雪崩和穿透问题 |
烂大街的缓存穿透、缓存击穿和缓存雪崩,你真的懂了? |
如何保证缓存和数据库数据的一致性?
下面单独对Cache Aside Pattern(旁路缓存模式) 来聊聊。Cache Aside Pattern中遇到写请求是这样的:更新DB,然后直接删除cache。如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说两个解决方案:
- 缓存失效时间变短(不推荐,治标不治本):我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。
- 增加cache更新重试机制(常用):如果cache服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。如果多次重试还是失败的话,我们可以把当前更新失败的key存入队列中,等缓存服务可用之后,再将缓存中对应的key删除即可。
相关文章推荐:缓存和数据库一致性问题,看这篇就够了-水滴与银弹
总结
读的时候先读缓存,缓存中没有数据的话就去数据库读取,然后再存入缓存中,同时返回响应。更新的时候,先更新数据库,然后再删除缓存。
双写一致方案
先删除缓存,后更新数据库:解决了缓存删除失败导致库与缓存不一致的问题,适用于并发量不高的业务场景。
缓存延时双删策略
在写库前后都进行Redis的删除操作,并且第二次删除通过延迟的方式进行,第一步:先删除缓存,第二步:再写入数据库,第三步:休眠xxx毫秒(根据具体的业务时间来定),第四步:再次删除缓存。这种方案解决了高并发情况下,同时有读请求与写请求时导致的不一致问题。读取速度快,如果二次删除失败了,还是会导致缓存脏数据存在的;二次删除前面涉及到休眠,可能导致系统性能降低,可以采用异步的方式,再起一个线程来进行异步删除。在分布式系统中,缓存和数据库同时存在时,如果有写操作的时候,先操作数据库,再操作缓存。如下:
- 读取缓存中是否有相关数据
- 如果缓存中有相关数据value,则返回
- 如果缓存中没有相关数据,则从数据库读取相关数据放入缓存中key->value,再返回
- 如果有更新数据,则先更新数据,再删除缓存
- 为了保证第四步删除缓存成功,使用binlog异步删除
- 如果是主从数据库,binglog取自于从库
- 如果是一主多从,每个从库都要采集binlog,然后消费端收到最后一台binlog数据才删除缓存
相关文章
Redis的key过期策略
我们在set key的时候,可以给它设置一个过期时间,比如expire key 60。指定这key60s后过期,60s后,redis是如何处理的?我们先来介绍几种过期策略:一般有定时过期、惰性过期、定期过期三种。
定时过期
每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即对key进行清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
惰性过期
只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
定期过期
每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。expires字典会保存所有设置了过期时间的key的过期时间数据,其中,key是指向键空间中的某个键的指针,value是该键的毫秒精度的UNIX时间戳表示的过期时间。键空间是指该Redis集群中保存的所有键。
总结
Redis中同时使用了惰性过期和定期过期两种过期策略。假设Redis当前存放30万个key,并且都设置了过期时间,如果你每隔100ms就去检查这全部的key,CPU负载会特别高,最后可能会挂掉。因此,redis采取的是定期过期,每隔100ms就随机抽取一定数量的key来检查和删除的。但是呢,最后可能会有很多已经过期的key没被删除。这时候,redis采用惰性删除。在你获取某个key的时候,redis会检查一下,这个key如果设置了过期时间并且已经过期了,此时就会删除。但是,如果定期删除漏掉了很多过期的key,然后也没走惰性删除。就会有很多过期key积在内存内存,直接会导致内存爆的。或者有些时候,业务量大起来了,redis的key被大量使用,内存直接不够了,运维也忘记加大内存了。难道redis直接这样挂掉?不会的!Redis用8种内存淘汰策略保护自己~
Redis内存淘汰策略
- volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰(当内存不足以容纳新写入数据时,从设置了过期时间的key中使用LRU(最近最少使用)算法进行淘汰)
- volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰(当内存不足以容纳新写入数据时,在设置了过期时间的key中,根据过期时间进行淘汰,越早过期的优先被淘汰)
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰(当内存不足以容纳新写入数据时,从设置了过期时间的key中,随机淘汰数据)
- allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key(这个是最常用的)(当内存不足以容纳新写入数据时,从所有key中使用LRU(最近最少使用)算法进行淘汰)
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰(当内存不足以容纳新写入数据时,从所有key中随机淘汰数据)
- no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!(默认策略,当内存不足以容纳新写入数据时,新写入操作会报错)
4.0版本后增加以下两种:
- volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰(当内存不足以容纳新写入数据时,在过期的key中,使用LFU(最少访问算法)进行删除key。)
- allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的key(当内存不足以容纳新写入数据时,从所有key中使用LFU算法进行淘汰)
Redis内存碎片怎么解决? | Redis内存满了怎么办? | 内存耗尽后Redis会发生什么? |
---|
Redis持久化
AOF
AOF(append only file) 持久化,采用日志的形式来记录每个写操作,追加到AOF文件的末尾。Redis默认情况是不开启AOF的。重启时再重新执行AOF文件中的命令来恢复数据。它主要解决数据持久化的实时性问题。AOF是执行完命令后才记录日志的。为什么不先记录日志再执行命令呢?这是因为Redis在向AOF记录日志时,不会先对这些命令进行语法检查,如果先记录日志再执行命令,日志中可能记录了错误的命令,Redis使用日志回复数据时,可能会出错。正是因为执行完命令后才记录日志,所以不会阻塞当前的写操作。但是会存在两个风险:更执行完命令还没记录日志时,宕机了会导致数据丢失,AOF不会阻塞当前命令,但是可能会阻塞下一个操作。这两个风险最好的解决方案是折中
**妙用AOF机制的三种写回策略(appendfsync)**:
- always:同步写回,每个子命令执行完,都立即将日志写回磁盘。
- everysec:每个命令执行完,只是先把日志写到AOF内存缓冲区,每隔一秒同步到磁盘。
- no:只是先把日志写到AOF内存缓冲区,有操作系统去决定何时写入磁盘。
always同步写回,可以基本保证数据不丢失,no策略则性能高但是数据可能会丢失,一般可以考虑折中选择everysec。如果接受的命令越来越多,AOF文件也会越来越大,文件过大还是会带来性能问题。日志文件过大怎么办呢?AOF重写机制!就是随着时间推移,AOF文件会有一些冗余的命令如:无效命令、过期数据的命令等等,AOF重写机制就是把它们合并为一个命令(类似批处理命令),从而达到精简压缩空间的目的。
AOF重写会阻塞嘛?
AOF日志是由主线程会写的,而重写则不一样,重写过程是由后台子进程bgrewriteaof完成。
AOF优缺点
- AOF的优点:数据的一致性和完整性更高,秒级数据丢失。
- AOF的缺点:相同的数据集,AOF文件体积大于RDB文件。数据恢复也比较慢。
RDB
因为AOF持久化方式,如果操作日志非常多的话,Redis恢复就很慢。有没有在宕机快速恢复的方法呢,有的,RDB!RDB,就是把内存数据以快照的形式保存到磁盘上。和AOF相比,它记录的是某一时刻的数据,,并不是操作。什么是快照?可以这样理解,给当前时刻的数据,拍一张照片,然后保存下来。RDB持久化,是指在指定的时间间隔内,执行指定次数的写操作,将内存中的数据集快照写入磁盘中,它是Redis默认的持久化方式。执行完操作后,在指定目录下会生成一个dump.rdb文件,Redis重启的时候,通过加载dump.rdb文件来恢复数据。RDB触发机制主要有以下几种:
- 手动触发:save(同步,会阻塞当前redis服务器)bgsave(异步,redis执行fork操作创建子进程)
- 自动触发:(save m n)m秒内数据集存在n次修改时,自动触发bgsave
RDB通过bgsave命令的执行全量快照,可以避免阻塞主线程。basave命令会fork一个子进程,然后该子进程会负责创建RDB文件,而服务器进程会继续处理命令。请求快照时,数据能修改嘛?Redis接入操作系统的写时复制技术(copy-on-write,COW),在执行快照的同时,正常处理写操作。虽然bgsave执行不会阻塞主线程,但是频繁执行全量快照也会带来性能开销。比如bgsave子进程需要通过fork操作从主线程创建出来,创建后不会阻塞主线程,但是创建过程是会阻塞主线程的。可以做增量快照。
RDB优缺点
- RDB的优点:与AOF相比,恢复大数据集的时候会更快,它适合大规模的数据恢复场景,如备份,全量复制等
- RDB的缺点:没办法做到实时持久化/秒级持久化。
Redis4.0开始支持RDB和AOF的混合持久化,就是内存快照以一定频率执行,两次快照之间,再使用AOF记录这期间的所有命令操作。
如何选择RDB和AOF
如果数据不能丢失,RDB和AOF混用。如果只作为缓存使用,可以承受几分钟的数据丢失的话,可以只使用RDB。如果只使用AOF,优先使用everysec的写回策略。
混合持久化
既然RDB与AOF持久化都存在各自的缺点,那么有没有一种更好的持久化方式?接下来要介绍的是混合持久化。其实就是RDB与AOF的混合模式,这是Redis4之后新增的。
- 持久化方式
混合持久化是通过aof-use-rdb-preamble参数来开启的。它的操作方式是这样的,在写入的时候先把数据以RDB的形式写入文件的开头,再将后续的写命令以AOF的格式追加到文件中。这样既能保证数据恢复时的速度,同时又能减少数据丢失的风险。 - 文件恢复
那么混合持久化中是如何来进行数据恢复的呢?在Redis重启时,先加载RDB的内容,然后再重放增量AOF格式命令。这样就避免了AOF持久化时的全量加载,从而使加载速率得到大幅提升。
相关文章
同样是持久化,竟然有这么大的差别! | 如何让Redis更持久 | Redis宕机,数据丢了 |
---|---|---|
小伙用12张图讲明白了Redis持久化! | 彻底理解Redis的持久化和主从复制 | 一文了解Redis的持久化 |
Redis高可用
哨兵
主从模式中,一旦主节点由于故障不能提供服务,需要人工将从节点晋升为主节点,同时还要通知应用方更新主节点地址。显然,多数业务场景都不能接受这种故障处理方式。Redis从2.8开始正式提供了Redis Sentinel(哨兵)架构来解决这个问题。哨兵模式,由一个或多个Sentinel实例组成的Sentinel系统,它可以监视所有的Redis主节点和从节点,并在被监视的主节点进入下线状态时,自动将下线主服务器属下的某个从节点升级为新的主节点。但是呢,一个哨兵进程对Redis节点进行监控,就可能会出现问题(单点问题),因此,可以使用多个哨兵来进行监控Redis节点,并且各个哨兵之间还会进行监控。简单来说,哨兵模式就三个作用:
- 发送命令,等待Redis服务器(包括主服务器和从服务器)返回监控其运行状态;
- 哨兵监测到主节点宕机,会自动将从节点切换成主节点,然后通过发布订阅模式通知其他的从节点,修改配置文件,让它们切换主机;
- 哨兵之间还会相互监控,从而达到高可用。
故障切换的过程是怎样的呢
假设主服务器宕机,哨兵1先检测到这个结果,系统并不会马上进行failover过程,仅仅是哨兵1主观的认为主服务器不可用,这个现象成为主观下线。当后面的哨兵也检测到主服务器不可用,并且数量达到一定值时,那么哨兵之间就会进行一次投票,投票的结果由一个哨兵发起,进行failover操作。切换成功后,就会通过发布订阅模式,让各个哨兵把自己监控的从服务器实现切换主机,这个过程称为客观下线。这样对于客户端而言,一切都是透明的。哨兵的工作模式如下:每个Sentinel以每秒钟一次的频率向它所知的Master,Slave以及其他Sentinel实例发送一个PING命令。如果一个实例(instance)距离最后一次有效回复PING命令的时间超过down-after-milliseconds选项所指定的值,则这个实例会被Sentinel标记为主观下线。如果一个Master被标记为主观下线,则正在监视这个Master的所有Sentinel要以每秒一次的频率确认Master的确进入了主观下线状态。当有足够数量的Sentinel(大于等于配置文件指定的值)在指定的时间范围内确认Master的确进入了主观下线状态,则Master会被标记为客观下线。在一般情况下,每个Sentinel会以每10秒一次的频率向它已知的所有Master,Slave发送INFO命令。当Master被Sentinel标记为客观下线时,Sentinel向下线的Master的所有Slave发送INFO命令的频率会从10秒一次改为每秒一次,若没有足够数量的Sentinel同意Master已经下线,Master的客观下线状态就会被移除;若Master重新向Sentinel的PING命令返回有效回复,Master的主观下线状态就会被移除。
Cluster集群
哨兵解决和主从不能自动故障恢复的问题,但是同时也存在难以扩容以及单机存储、读写能力受限的问题,并且集群之前都是一台redis都是全量的数据,这样所有的redis都冗余一份,就会大大消耗内存空间。集群模式实现了Redis数据的分布式存储,实现数据的分片,每个redis节点存储不同的内容,并且解决了在线的节点收缩(下线)和扩容(上线)问题。集群模式真正意义上实现了系统的高可用和高性能,但是集群同时进一步使系统变得越来越复杂,接下来我们来详细的了解集群的运作原理。
相关文章
阿里官方Redis开发规范
键值设计
- key名设计
- 可读性和可管理性
以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如业务名:表名:id - ugc:video:1 - 简洁性
保证语义的前提下,控制key的长度,当key较多时,内存占用也不容忽视,例如:user:{uid}:friends:messages:{mid}简化为u:{uid}:fr:m:{mid}。
不要包含特殊字符
反例:包含空格、换行、单双引号以及其他转义字符
- value设计
拒绝bigkey
防止网卡流量、慢查询,string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000。
反例:一个包含200万个元素的list。
非字符串的bigkey,不要使用del删除,使用hscan、sscan、zscan方式渐进式删除,同时要注意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发del操作,造成阻塞,而且该操作不会不出现在慢查询中(latency可查)),查找方法和删除方法
选择适合的数据类型
例如:实体类型(要合理控制和使用数据结构内存编码优化配置,例如ziplist,但也要注意节省内存和性能之间的平衡)
反例:
set user:1:name tomset user:1:age 19set user:1:favor football
正例:
hmset user:1 name tom age 19 favor football
控制key的生命周期
redis不是垃圾桶,建议使用expire设置过期时间(条件允许可以打散过期时间,防止集中过期),不过期的数据重点关注idletime。
命令使用
- O(N)命令关注N的数量
例如hgetall、lrange、smembers、zrange、sinter等并非不能使用,但是需要明确N的值。有遍历的需求可以使用hscan、sscan、zscan代替。 - 禁用命令
禁止线上使用keys、flushall、flushdb等,通过redis的rename机制禁掉命令,或者使用scan的方式渐进式处理。 - 合理使用select
redis的多数据库较弱,使用数字进行区分,很多客户端支持较差,同时多业务用多数据库实际还是单线程处理,会有干扰。 - 使用批量操作提高效率
原生命令:例如mget、mset。非原生命令:可以使用pipeline提高效率。但要注意控制一次批量操作的元素个数(例如500以内,实际也和元素字节数有关)。
注意两者不同:原生是原子操作,pipeline是非原子操作。pipeline可以打包不同的命令,原生做不到,pipeline需要客户端和服务端同时支持。 - 不建议过多使用Redis事务功能
Redis的事务功能较弱(不支持回滚),而且集群版本(自研和官方)要求一次事务操作的key必须在一个slot上(可以使用hashtag功能解决) - 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” - monitor命令
必要情况下使用monitor命令时,要注意不要长时间使用。
客户端使用
避免多个应用使用一个Redis实例:不相干的业务拆分,公共数据做服务化。
使用连接池:可以有效控制连接,同时提高效率,标准使用方式:
执行命令如下:Jedis jedis = null; try { jedis = jedisPool.getResource(); //具体的命令 jedis.executeCommand() } catch (Exception e) { logger.error("op key {} error: " + e.getMessage(), key, e); } finally { //注意这里不是关闭连接,在JedisPool模式下,Jedis会被归还给资源池。 if (jedis != null) jedis.close(); }
熔断功能:高并发下建议客户端添加熔断功能(例如netflix hystrix)
合理的加密:设置合理的密码,如有必要可以使用SSL加密访问(阿里云Redis支持)
淘汰策略
根据自身业务类型,选好maxmemory-policy(最大内存淘汰策略),设置好过期时间。
默认策略是volatile-lru,即超过最大内存后,在过期键中使用lru算法进行key的剔除,保证不过期数据不被删除,但是可能会出现OOM问题。
其他策略如下:
allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止。
allkeys-random:随机删除所有键,直到腾出足够空间为止。
volatile-random:随机删除过期键,直到腾出足够空间为止。
volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策略。
noeviction:不会剔除任何数据,拒绝所有写入操作并返回客户端错误信息”(error) OOM command not allowed when used memory”,此时Redis只响应读操作。
相关工具
- 数据同步:redis间数据同步可以使用:redis-port
- big key搜索:redis大key搜索工具
- 热点key寻找
内部实现使用monitor,所以建议短时间使用facebook的redis-faina 阿里云Redis已经在内核层面解决热点key问题
删除bigkey
下面操作可以使用pipeline加速。
redis4.0已经支持key的异步删除,欢迎使用。
- Hash删除: hscan + hdel
public void delBigHash(String host, int port, String password, String bigHashKey) {
Jedis jedis = new Jedis(host, port);
if (password != null && !"".equals(password)) {
jedis.auth(password);
}
ScanParams scanParams = new ScanParams().count(100);
String cursor = "0";
do {
ScanResult<Entry<String, String>> scanResult = jedis.hscan(bigHashKey, cursor, scanParams);
List<Entry<String, String>> entryList = scanResult.getResult();
if (entryList != null && !entryList.isEmpty()) {
for (Entry<String, String> entry : entryList) {
jedis.hdel(bigHashKey, entry.getKey());
}
}
cursor = scanResult.getStringCursor();
} while (!"0".equals(cursor));
//删除bigkey
jedis.del(bigHashKey);
}
- List删除: ltrim
public void delBigList(String host, int port, String password, String bigListKey) {
Jedis jedis = new Jedis(host, port);
if (password != null && !"".equals(password)) {
jedis.auth(password);
}
long llen = jedis.llen(bigListKey);
int counter = 0;
int left = 100;
while (counter < llen) {
//每次从左侧截掉100个
jedis.ltrim(bigListKey, left, llen);
counter += left;
}
//最终删除key
jedis.del(bigListKey);
}
- Set删除: sscan + srem
public void delBigSet(String host, int port, String password, String bigSetKey) {
Jedis jedis = new Jedis(host, port);
if (password != null && !"".equals(password)) {
jedis.auth(password);
}
ScanParams scanParams = new ScanParams().count(100);
String cursor = "0";
do {
ScanResult<String> scanResult = jedis.sscan(bigSetKey, cursor, scanParams);
List<String> memberList = scanResult.getResult();
if (memberList != null && !memberList.isEmpty()) {
for (String member : memberList) {
jedis.srem(bigSetKey, member);
}
}
cursor = scanResult.getStringCursor();
} while (!"0".equals(cursor));
//删除bigkey
jedis.del(bigSetKey);
}
- SortedSet删除:zscan + zrem
public void delBigZset(String host, int port, String password, String bigZsetKey) {
Jedis jedis = new Jedis(host, port);
if (password != null && !"".equals(password)) {
jedis.auth(password);
}
ScanParams scanParams = new ScanParams().count(100);
String cursor = "0";
do {
ScanResult<Tuple> scanResult = jedis.zscan(bigZsetKey, cursor, scanParams);
List<Tuple> tupleList = scanResult.getResult();
if (tupleList != null && !tupleList.isEmpty()) {
for (Tuple tuple : tupleList) {
jedis.zrem(bigZsetKey, tuple.getElement());
}
}
cursor = scanResult.getStringCursor();
} while (!"0".equals(cursor));
//删除bigkey
jedis.del(bigZsetKey);
}
Redis为什么在6.0之后变成了多线程
- 为什么Redis6.0之前,不引入多线程
首先,Redis的设计目标是高性能和高并发。在Redis 6.0之前的版本中,它采用了单线程模型。这种模型可以避免多线程带来的线程切换和锁竞争等开销,从而提高了Redis的性能和并发能力。单线程模型在处理命令时,可以确保每个命令都是顺序执行的,避免了多线程环境下可能出现的复杂性和不稳定性问题,使Redis更加简单和可靠。然而,随着Redis的应用场景越来越广泛,数据量和并发量也越来越大,单线程模型逐渐无法满足日益增长的性能需求。因此,在Redis 6.0中,引入了多线程模型,以提高Redis的性能和并发能力。多线程模型可以充分利用多核CPU的优势,提高Redis的处理能力和吞吐量。在Redis 6.0中,多线程主要用于网络数据的读写这类耗时操作,而执行命令仍然是单线程顺序执行。这样可以避免线程安全问题,同时确保命令执行的顺序性。需要注意的是,虽然Redis 6.0引入了多线程,但多线程默认是禁用的,只使用主线程。如需开启多线程,需要修改Redis配置文件。同时,建议只在具有4核或更多核心的机器上开启多线程,以充分发挥其性能优势。综上所述,Redis 6.0之前不引入多线程是为了保持其高性能和高并发的设计目标,避免多线程带来的开销和复杂性。而随着应用场景和性能需求的变化,Redis 6.0引入了多线程模型以更好地满足这些需求。
既然我们提到了在6.0之前不引入多线程,那么在Redis6.0之前为什么不引入消息队列呢?
- 在Redis6.0之前为什么不引入消息队列呢?
核心功能相悖
首先,Redis的设计初衷是一个高性能的键值对存储数据库,主要用于快速读取和写入数据。它的核心优势在于提供了丰富的数据类型和灵活的操作方式,使得用户可以轻松地进行数据存储、查询和计算等操作。而消息队列的主要功能是实现应用程序之间的异步通信和消息传递,这与Redis的核心功能并不完全吻合。设计目标不同
其次,虽然Redis可以通过List或Pub/Sub等功能实现简单的消息队列功能,但这些功能并不是Redis的主要设计目标,也不是其最擅长的领域。因此,在Redis 6.0之前,它并没有专门引入消息队列的功能,而是专注于提供高效的键值对存储和查询能力。然而,随着Redis应用场景的不断扩大,用户对于消息队列的需求也逐渐增加。为了满足这些需求,Redis社区逐渐发展出了基于Redis的消息队列解决方案,如使用Redis的List结构或Stream功能来实现消息队列的功能。这些解决方案在一定程度上弥补了Redis在消息队列方面的不足,但并非Redis官方正式引入的消息队列功能。直到Redis 6.0版本,Redis官方才开始正式考虑引入更强大的消息队列功能。Redis 6.0提供了对Stream功能的进一步支持和优化,使其更适合作为消息队列使用。Stream功能支持消息的持久化、多播、分组消费以及有序性等特点,使得Redis在消息队列领域有了更广泛的应用场景。综上所述,Redis 6.0之前不引入消息队列主要是因为其设计初衷和功能定位与消息队列不完全吻合。但随着用户需求的变化和Redis社区的发展,基于Redis的消息队列解决方案逐渐出现,并在Redis 6.0版本中得到了官方的进一步支持和优化。
- Redis为什么会在6.0版本引入多线程呢
在Redis中,我们知道,对于存储小数据量来说,Redis的响应十几件非常的短,甚至可以到纳秒级别,而且针对小的数据量来说,他的QPS可以保持在6万到8万之间,而这个QPS对于单线程的Redis来说,可能已经达到了他的极限值。那么引入多线程是什么呢?Redis的瓶颈有时会出现在网络I/O处理上。单线程模型在处理网络请求时,可能会遇到单个主线程处理速度跟不上底层网络硬件速度的问题。引入多线程可以充分利用多核CPU的优势,提高Redis的处理能力和吞吐量,突破这一性能瓶颈。而且随着Redis的应用场景越来越广泛,数据量和并发量也越来越大,单线程模型已经无法满足所有需求。多线程模型可以更好地适应高并发、大数据量的场景,提高Redis的性能和并发能力。其实最终的目的还是为了想让Redis能够抗住更多的并发,这样Redis就目前而言,还不会被淘汰,毕竟开发人员,技术如果跟不上,那就意味着可能遭到淘汰,而技术也是。