简介
Redis是一种基于内存的数据库,对数据的读写操作均在内存中完成,因此具有非常快的读写速度,常用于缓存、分布式锁、消息队列等场景。Redis单线程处理所有命令,因此不存在并发竞争的问题。此外,Redis支持数据持久化防止重启数据丢失。
1、为什么需要Redis?
(1) 原因
随着流量逐日增大,数据一主多从的存放结构难以承受巨大的流量压力和读写压力,因此诞生了分库分表,而Mysql也从单机演进出了集群(如图所示)。然而在快速的数据量增长下,我们需要一种新的解决方案。
考虑实际的业务场景,数据可按照一段时间内的访问频率分为冷数据和热数据,热数据即经常会被访问到的数据。可以粗略地估计,热数据在接下来受到访问的概率也比较高。如果我们能把热数据存到缓存中,使得请求不再需要访问数据库,那么读写压力会减小许多。
(2) Redis为什么快?为什么用Redis做Mysql的缓存?
Redis具备高并发,单台设备上Redis的QPS(每秒处理请求数)可以每秒十万,而Mysql单机的QPS难以到达每秒一万。
Redis基于内存实现,而内存由CPU控制,其读写不会受磁盘缓慢的IO速度限制。
Redis采用I/O多路复用模型,提供了基于事件的回调机制,且Redis线程不会阻塞在某个特定客户端的请求上。
Redis采用单线程,避免了上下文切换引起的CPU开销和防止并发竞争导致的加锁开销。
Redis拥有高效的数据结构,在Redis中,string的底层是SDS,可保存二进制文件且能 地获取长度;List的底层是quicklist;Hash和zset的底层是紧凑列表listpack。这些数据结构保证了能高效地使用内存。
2、Redis数据类型
(1) 字符串
字符串的创建和查询语句分别为set key value和get key。
set name hello
get name
代码中的name相当于字符串的变量名,hello相当于值,因此上述代码的查询结果是hello。如果key不存在时,查询结果为nil。
我们可以用del删除字符串。
127.0.0.1:6379> del name
(integer) 1
127.0.0.1:6379> get name
(nil)
这个时候查询结果就是nil了。
append和strlen则用于拼接字符串和查询字符串长度。
127.0.0.1:6379> set key hello
OK
127.0.0.1:6379> strlen key
(integer) 5
127.0.0.1:6379> append key ,world
(integer) 11
127.0.0.1:6379> get key
"hello,world"
输出结果为5、11、hello,world。
注意在Redis中空格是分隔符,这使得下面这样写会报错:
set key hello world
错误:(error) ERR syntax error。
自增自减
Redis中可以使用incr、decr、incrby、decrby实现对一个数字+1、-1、+特定值、-特定值。
set num 0
incr num
incrby num 2
decr num
decrby num 3
num从0变成1,再变成3,再变成2,最后变成-1。
批量添加/查询
使用mset和mget可以批量添加字符串和批量查询。
mset key1 v1 key2 v2
mget key1 key2
输出1) "v1"、2) "v2"。
设置过期时间
给数据设置过期时间后,当经过了这么多时间后,数据就会从数据库中删除掉。使用setex设置过期时间,并可以通过ttl查看key的过期时间。
127.0.0.1:6379> setex test 15 a
OK
127.0.0.1:6379> ttl test
(integer) 13
127.0.0.1:6379> ttl test
(integer) 10
127.0.0.1:6379> ttl test
(integer) 4
127.0.0.1:6379> ttl test
(integer) -2
最后输出-2表示该key已过期。
判断是否存在
有的时候我们想判断key是否存在但又不想修改值该怎么办呢?可以使用exists语句判断是否存在,存在返回1反之则0。
127.0.0.1:6379> set key val
OK
127.0.0.1:6379> exists key
(integer) 1
127.0.0.1:6379> del key
(integer) 1
127.0.0.1:6379> exists key
(integer) 0
也可以使用setnx,使用setnx设置时若key存在则不会做任何操作,返回0;否则新增数据。
127.0.0.1:6379> set key val
OK
127.0.0.1:6379> setnx key val2
(integer) 0
127.0.0.1:6379> del key
(integer) 1
127.0.0.1:6379> setnx key val2
(integer) 1
(2) 哈希
Redis中Hash是一个键值对集合,一定程度上可以理解为map[string]string,适合存储对象。
添加和查询只需要在对字符串语句前加h,即hset和hget。
127.0.0.1:6379> hset person1 id 1
(integer) 1
127.0.0.1:6379> hset person1 name nihao
(integer) 1
127.0.0.1:6379> hset person1 job worker
(integer) 1
127.0.0.1:6379> hgetall person1
1) "id"
2) "1"
3) "name"
4) "nihao"
5) "job"
6) "worker"
如上述代码所示,成功设置了三个string的键值对。
同理在讲解字符串时使用的mset、mget批量增删改查,strlen获取长度,exists判断存在均可修改后使用。
127.0.0.1:6379> hmset person2 id 2 name hi job cook
OK
127.0.0.1:6379> hmget person2 id job
1) "2"
2) "cook"
使用hmset和hmget可以一次向一个哈希内添加/获取多个键值对。
127.0.0.1:6379> hmset person2 id 2 name hi job cook
OK
127.0.0.1:6379> hlen person2
(integer) 3
127.0.0.1:6379> hexists person2 id
(integer) 1
127.0.0.1:6379> hexists person2 lalala
(integer) 0
hlen获得的是哈希内的键值对数量,而稍有不同的是,hexists是判断该哈希是否存在某个键,如上述代码所示,哈希存在id这个键但不存在lalala这个键。
(3) 列表
插入、弹出元素与查询
列表的结构非常类似于数据结构中的双端队列(deque),当然其底层实现是quicklist。因此可以头插和尾插,也可以头弹出和尾弹出。
具体地,lpush、rpush、lpop、rpop分别表示左/右端插入、左/右端弹出。列表还支持通过下标范围索引元素,即lrange start end,会查询[start,end]中的所有值,注意end为-1时表示最后一个元素,-2是表示倒数第二个,以此类推。
127.0.0.1:6379> lpush list 1
(integer) 1
127.0.0.1:6379> lpush list 2
(integer) 2
127.0.0.1:6379> rpush list 3
(integer) 3
127.0.0.1:6379> rpush list 4
(integer) 4
127.0.0.1:6379> lrange list 0 -1
1) "2"
2) "1"
3) "3"
4) "4"
127.0.0.1:6379> lpop list
"2"
127.0.0.1:6379> lrange list 0 -1
1) "1"
2) "3"
3) "4"
可以看到,由于是双端队列的结构,所以后插入的2在最左边,因此lpop弹出的是2。除了给定下标范围查询外,也可以用lindex指定具体下标查询。
127.0.0.1:6379> lindex list 1
"3"
删除元素
lrem根据给定值删除对应值的相应数量元素,并返回删除的元素数量。即lrem key count value,value表示要删除的值,count表示删的数量,如果集合中没有这么多数量也不会报错,会全部删除。
127.0.0.1:6379> lrange list 0 -1
1) "1"
2) "3"
3) "4"
4) "1"
127.0.0.1:6379> lrem list 2 1
(integer) 2
127.0.0.1:6379> lrem list 2 3
(integer) 1
127.0.0.1:6379> lrange list 0 -1
1) "4"
在移除2个1和1个3后,列表只剩下4。
截取元素(子数组)
ltrim start end会取原列表的[start,end]子数组,并原地更改本列表。
127.0.0.1:6379> lrange list 0 -1
1) "3"
2) "2"
3) "1"
4) "4"
127.0.0.1:6379> ltrim list 1 2
OK
127.0.0.1:6379> lrange list 0 -1
1) "2"
2) "1"
修改列表中元素的值
lset key index value可以指定下标修改成对应的值。
127.0.0.1:6379> lset list 0 114514
OK
127.0.0.1:6379> lindex list 0
"114514"
(4) 集合
集合即C++中的unordered_set,数据是无序且唯一的,不存在重复值。
增删查
sadd用于向集合中加入元素(可一次加入多个),srem用于从集合中移除元素(若存在返回1,反之则0)。sismember可判断元素是否存在集合内,scard用于查询集合的大小。
127.0.0.1:6379> sadd st1 v1 v2 v3 v4
(integer) 4
127.0.0.1:6379> sismember st1 v2
(integer) 1
127.0.0.1:6379> smembers st1
1) "v2"
2) "v1"
3) "v3"
4) "v4"
127.0.0.1:6379> scard st1
(integer) 4
127.0.0.1:6379> srem st1 v5
(integer) 0
127.0.0.1:6379> srem st1 v3
(integer) 1
127.0.0.1:6379> smembers st1
1) "v2"
2) "v1"
3) "v4"
随机查询/删除
srandmember key [count]用于从集合中随机抽取count个元素,spop key [count]则从集合中随机删除count个元素。
127.0.0.1:6379> srandmember st1 2
1) "v1"
2) "v2"
127.0.0.1:6379> srandmember st1 2
1) "v2"
2) "v4"
127.0.0.1:6379> spop st1 1
1) "v2"
127.0.0.1:6379> smembers st1
1) "v1"
2) "v4"
集合的并交差
集合间的运算可以很好地解决类似于共同好友的需求。smove key1 key2 val表示将key1中val的元素移除到key2中,若key1不存在val则返回0,只要存在那么一定会从key1中删除后再插入到key2中(无论是否已存在),返回1。
sdiff、sinter、sunion分别表示差集、交集、补集。sdiff A B即 ,sinter A B 即,sunion A B 即。
127.0.0.1:6379> sadd s1 1 2 3 4 5
(integer) 5
127.0.0.1:6379> sadd s2 3 4 5 6 7
(integer) 5
127.0.0.1:6379> sinter s1 s2
1) "3"
2) "4"
3) "5"
127.0.0.1:6379> sunion s1 s2
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"
6) "6"
7) "7"
127.0.0.1:6379> sdiff s1 s2
1) "1"
2) "2"
127.0.0.1:6379> sdiff s2 s1
1) "6"
2) "7"
(5) 有序集合
有序集合zset其实就是在set的基础上给每个元素增加了一个得分,在有序集合中,元素将按照得分从小到大排序,若得分相同,则比较元素的字典序。
插入元素使用zadd key1 score1 value1 score2 value2……,查询时可以使用下标范围查询zrange start end,查询排序后位于[start,pos]下标的元素,也可以使用值范围查询zrangebyscore min max,查询值位于[min,max]的元素。两种查询均可在后面加上withscores表示是否输出各元素的得分。
127.0.0.1:6379> zadd zset 2 a 3 b 2 c 4 d
(integer) 4
127.0.0.1:6379> zrangebyscore zset -inf inf
1) "a"
2) "c"
3) "b"
4) "d"
127.0.0.1:6379> zrangebyscore zset -inf inf withscores
1) "a"
2) "2"
3) "c"
4) "2"
5) "b"
6) "3"
7) "d"
8) "4"
127.0.0.1:6379> zrange zset 1 2 withscores
1) "c"
2) "2"
3) "b"
4) "3"
如果想倒序查询,可以使用zrevrange和zrevrange by score。食用方式相反即可。
127.0.0.1:6379> zrevrange zset 1 2 withscores
1) "b"
2) "3"
3) "c"
4) "2"
在set中的scard、srem只需要换成zcard、zrem即可查询zset大小和删除。
单点增加和排名
zincrby单点增加某元素的score,而使用zrank函数可查询元素的排名(默认从小到大排名,第一名是0,反之用revrank),元素不存在时返回nil。
127.0.0.1:6379> zincrby zset 5 b
"8"
127.0.0.1:6379> zrank zset b
(integer) 3
127.0.0.1:6379> zrevrank zset b
(integer) 0
3、简单应用场景
(1) 连续签到
签到可以用incr num使得天数加1,每次签到后过期时间设置为当前时间到后天零点的时间差,即每次签到后,后天零点过期。
(2) 消息通知
使用列表进行通知。
(3) 实时变更的排行榜
使用zset即可,可以实现按顺序输出和单点修改分数。
(4) 限流
要求1s内放行的请求为N,超过N则禁止访问。
(5) 分布式锁
并发场景中为了避免并发竞争,可以利用Redis的特性,其为单线程运行因此不会产生并发竞争问题,而setnx可以保证写过的元素不会再被写。
4、Redis持久化
Redis作为内存数据库,如果数据不存入磁盘中,一旦服务器进程退出那么服务器状态也会消失!
(1) RDB持久化
打开redis.conf,我们会看到这样的代码:
save 900 1
save 300 10
save 60 10000
这表示周期性备份,如save 60 10000表示60秒内有10000个写入。如果有多个条件,只要有一个条件满足时,即写入rdb文件进行持久化保存。
(2) AOF持久化
同样在redis.conf中,我们可以找到这样的代码:
appendonly no
AOF持久化会记录所有执行的写入命令。每执行一条命令时就会将写入命令写入临时缓冲区aof_buf,而缓冲区和文件的同步是appendfsync。
在redis.conf关于AOF的一些配置如下:
# appendfsync always #每次修改都同步缓冲区和文件
appendfsync everysec #每秒同步一次
auto-aof-rewrite-percentage 100 #写入百分比
auto-aof-rewrite-min-size 64mb #写入的文件最大大小
当AOF备份文件越来越大时,效率会越来越低,这个时候可以使用AOF重写。
5、缓存雪崩、击穿、穿透
(1) 缓存雪崩
大量缓存同时过期,若此时有大量的用户请求,都无法在redis中处理,只能直接访问数据库,使得数据库面临的压力骤增。
解决方法:
-
给缓存数据均匀设置过期时间,实现时可以给过期时间加上一个随机数。
-
缓存"永久有效",只当系统内存紧张时采用过期删除/内存淘汰策略。
-
服务熔断/请求限流
-
使用缓存集群,避免单机宕机导致的数据雪崩。
(2) 缓存击穿
某个热点数据过期,而此时大量用户请求访问此热点数据都会直接访问数据库,使得数据库压力骤增。
可以通过不设置热点数据的过期时间,或者在热点数据即将过期时,提前通知后台进程更新缓存及重设过期时间。
(3) 缓存穿透
大量既不在缓存也不在数据库的用户请求将会导致缓存穿透。这些请求在缓存中没有找到数据会去数据库查询,在数据库中也没有找到,使得尽管有大量请求,但是没法构建缓存数据来服务后续的大量请求,产生缓存穿透。
缓存穿透可能来源于恶意攻击、数据库误删热点数据、热key过期。
减少方法:
-
缓存空值或者默认值。下次查询时返回空值即可。
-
使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在。
布隆过滤器拥有超高的压缩率,本质上就是多个哈希函数结合,压缩在较小的位图数组中,然而高效的查询和极小的空间很有可能导致哈希冲突,但我们注意到,哈希冲突发生时,只会导致误以为不存在的数据存在,这也就说,如果判定为不存在的数据(各哈希位只要有一位没有标记),那么就一定不存在。