Redis基础、应用(秒杀系统)

Redis基础 & 应用(秒杀系统)


(一)安装 & 启动

  1. 阿里云安装
  2. Docker ubuntu镜像中安装Redis
1
2
3
4
5
6
docker ps -a
docker attach 241b
apt-get update
apt-get -y install redis-server
/etc/inti.d/redis-server restart
redis-cli #启动Redis客户端
  1. 如果只想实践一下Redis命令,那么在线Redis是非常不错网站

    2019-11-14发现已关闭,还是蛮可惜的,祈祷能有重开的一天(。_。)

Redis默认端口号是6379,出于好奇搜了一下为什么是6379,然后发现了作者写的一篇文章,大致意思是说6379在手机9键上是MERZ,是意大利的一个女明星,作者认为她是“stupid”的代名词……然后作者还说你一定会好奇MERZ是谁,不过劝你不要上班查,因为not safe for work,然后我处于好奇搜了一下……后台硬的可以看看我搜到了什么…… - -|||


(二)基础命令

所有Redis命令查询

  1. 存储键值对:SET key value

  2. 根据键读取值:GET key

  3. 长度:STRLEN key

  4. 批量存储键值对:MSET key1 value1 key2 value2

  5. 根据键批量读取值:MGET key1 key2

  6. Hash存储:HSET key field value

  7. Hash读取:HGET key field

  8. 批量Hash存储:HMSET key field1 value1 field2 value2

  9. 批量Hash读取:HGETALL key

  10. 设置过期时间:EXPIRE key seconds

  11. 返回所有给定模式pattern的key:KEYS pattern

    比如:
    KEYS *:返回所有key;
    KEYS h?llow:匹配hallow/hbllow/…;
    KEYS h*llow:匹配hallow/hbbllow/hccccllow/…;
    KEYS h[ae]llow:匹配hallow/hellow

  12. 基于游标的迭代器:SCAN cursor [MATCH pattern] [COUNT cnt]

    SCAN 0:新开始一次迭代,返回下一次迭代应该开始的位置

SCAN返回的结果为0时,表示迭代已经结束

keysscan时间复杂度都是O(N),但是不同的是keys会阻塞遍历整个Redis,在海量数据时会很严重的性能问题;而scan采取的是增量迭代模式,不会阻塞整个Redis

  1. 清空当前数据库中的所有key:flushDB

(三)Redis 对象类型 & 数据结构

Redis核心对象redisObject

  1. 数据类型type表示一个value对象具体是哪种数据类型,包括string/hash/list/set/sorted set
  2. 编码方式encodingraw/int/ht/zipmap/linkedlist/ziplist/intset
  3. 数据指针ptr
  4. 虚拟内存vm
  5. 其他信息

1. String:简单动态字符串SDS

String类型是最简单的K/V类型,V不一定要是String,也可以是数字。

常用命令:getsetdecrincrmgetmsetexpiredelsetexsetnx
应用:

  1. 计数:如粉丝数、点赞数……
  2. 缓存:将对象序列化为JSON字符串后存储,要使用时再反序列化

原理:动态数组

1
2
3
int len;      // 数组中元素实际长度
int free; // 数组中空闲长度,用于扩容
char[] buf; // 实际数组,既包括实际元素,也包括用于扩容的空闲部分

特点:len + free + 1 = buf.length(字符串以\0作为结尾标志,占一个字符)

优点:

  1. 获取字符串长度的时间复杂度位O(1),即直接查len
  2. 可以自动扩容防止内存溢出
  3. 空间预分配:对SDS进行修改后,如果1en<1MB,额外分配len大小的free;如果1en>=1MB,额外分配1MB的free
  4. 惰性空间释放:删除后的buf[]不会直接释放内存,而是记录到free

命令

  1. 单个键值对操作:set & getexpiredel
1
2
3
4
5
6
7
8
9
10
11
12
13
14
> set name DragonBaby
OK

> get name
"DragonBaby"

> exists name
(integer) 1

> del name
(integer) 1

> get name
(nil)
  1. 键值对批量操作:mset & mget
1
2
3
4
5
> mset name1 boy name2 girl name3 other
> mget name1 name2 name3
1) "boy"
2) "girl"
3) "other"
  1. 创建含过期时间的K/V:setex = set + expire
1
2
3
4
> set name fxxk
> expire name 5 # 5s后过期

> setex name 5 fxxk # 5s后过期,setnx = set + expire
  1. 不存在则创建:setnx
1
> setnx name fxxk   # 不存在name则创建
  1. 计数:incrincrby

    计数要求value是一个整数,同时不得超过Long.Max

1
2
3
4
5
6
7
8
9
10
11
12
13
14
> set age 20

> incr age # 自增1
(integer) 21

> incrby age 5 # 加5
(integer) 26

> incrby age -5 # 加-5
(integer) 21

> set age 9223372036854775807 # Long.Max
> incr age
(error) ERR increment or decrement would overflow

2. List:链表

常用命令:lpushlpoprpushrpoplrangellenlindexlrangeltrim

原理:双向无环quicklist链表(支持反向查找和遍历,但是带来了额外的内存开销)

list数据较少时,Redis会分配连续的内存空间,称为ziplist
当数据量较大时,才会将多个ziplist用双向指针串起来,形成quicklist

特点:

  1. head指针和tail指针
  2. 记录了链表长度
  3. 插入/删除时间复杂度O(1),查找时间复杂度O(N)
  4. list弹出了最后一个元素就会被删除,内存自动回收

命令

  1. 异步队列MQ):左进右出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
> lpush books suiren fuxi shenong
(integer) 3

> llen books
(integer) 3

> rpop books
"suiren"
> rpop books
"fuxi"
> rpop books
"shennong"
> rpop books
(nil)
  1. 栈:左进左出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
> lpush books suiren fuxi shenong
(integer) 3

> llen books
(integer) 3

> lpop books
"shennong"
> lpop books
"fuxi"
> lpop books
"suiren"
> lpop books
(nil)
  1. 根据下标(从0开始)查找:lindex list

    时间复杂度O(N),慎用

1
2
3
4
5
> lpush books suiren fuxi shenong
(integer) 3

> lindex books 1
"fuxi"
  1. 查找某个范围:lrange list s_index e_index

    时间复杂度O(N),慎用

1
2
3
4
5
6
7
> lpush books suiren fuxi shenong
(integer) 3

> lrange books 0 -1 # -1表示全部元素
1) "suiren"
2) "fuxi"
3) "shennong"
  1. 只保留某个范围的元素,其他全部删除ltrim list s_index e_index

    时间复杂度O(N),慎用

1
2
3
4
5
6
7
8
> lpush books suiren fuxi shenong
(integer) 3

> ltrim books 1 -1 # -1表示全部元素
OK
> lrange books 0 -1
1) "fuxi"
2) "shennong"

3. Hash

Hash是一个String类型的 field-entries(key-value) 映射,特别适合存储对象,比如商品信息:

1
2
3
4
5
6
key = commodity
value = {
"id": 1,
"name": "SNBiss",
"price": 100
}

常用命令:hgethsethgetall
原理:HashMap

hash移除了最后一个元素,该数据结构被删除,内存自动回收

  1. 哈希冲突:加链法
  2. rehash:渐进式哈希 —— 在rehash的同时,保留新旧两个HashMap,查询时会同时查询两个HashMap,然后在后续的定时任务以及hash子指令中,循序渐进地将旧hash的内容迁移到新hash

命令

  1. hset field key value
  2. hget field key
  3. hgetall field
  4. hlen field
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
> hset books java "java 8 in action"  # value含空格要用""
(integer) 1

> hset books python "python cookbook"
(integer) 1

> hgetall books
1) "java"
2) "java 8 in action"
3) "python"
4) "python cookbooko"

> hlen books
(integer) 2

> hget books java
"java 8 in action"

4. Set

常用命令:saddspopsmemberssunion

原理:相当于HashSet,内部K/V无序且唯一

应用:

  1. 去重

    Set的存储代价很大,如果只是想实现不精确的去重计数,那么应该考虑HyperLogLog

  2. 基于Set可以很方便地实现交集(sinter)、并集、差集,比如共同关注功能

1
2
# 将key1 & key2交集存于key
sinterstore key key1 key2

命令

1
2
3
4
5
6
7
8
9
10
11
> sadd books python
(integer) 1
> sadd books java
(integer) 1
> sadd books java # 重复元素不会被添加
(integer) 0

> sadd books2 java
(integer) 0
> sinter books books2 # 求交集
1) "java"

5.Zset

Set相比,sorted set添加了一个权重参数score,使得元素可以根据score进行有序排列。
常用命令:zaddzrangezremzcard


底层数据结构:ziplist / 字典+skiplist

参考https://blog.csdn.net/Windgs_YF/article/details/103031177


1.压缩双向链表:ziplist

zset保存的元素数量小于128个,单个元素的长度均小于64个字节时,底层数据结构采用的是压缩双向列表ziplist

  • zset-max-ziplist-entries = 128:使用ziplist存储时最大存储entry节点数,默认128,如果超过该限制ziplist就会转为skiplist
  • zset-max-ziplist-value = 64:使用ziplist存储时单个entry节点最大字节数,默认64,如果超过该限制ziplist就会转为skiplist

ziplist作为zset的底层数据存储时,每个集合元素使用相邻的两个节点保存,第一个节点保存元素的name,第二个节点保存元素的score元素按照score从小到大排序,小的在表头


2.字典 + 跳跃表(skiplist

一个zset结构体同时包含一个跳跃表(skiplist)和一个字典(即HashMap),字典用于以O(1)的时间复杂度查找单个元素,跳跃表则用于以O(logN)的时间复杂度范围查找、插入、删除,两种数据结构通过指针共享底层数据存储,所以不会产生重复的成员和分值,造成内存浪费

1
2
3
4
5
6
7
typedef struct zset
{
//跳跃表:object属性保存元素成员,score属性保存元素分值
zskiplist *zsl;
//字典:key-元素name、value-元素score
dict *dice;
} zset;

SkipList

时间复杂度:O(1)O(logN)
  1. 查找单个元素O(1)如果只是查询单个元素,通过字典的get()可以做到O(1)的时间复杂度

    但是字典(HashMap)中的元素是无序的,如果需要范围查找则需要先排序然后再查找,代价太大,这时候就需要用到skiplist了。

  2. 范围查找/插入/删除O(logN)skiplist的范围查找、插入、删除时间复杂度都是O(logN)

为什么不用红黑树/HashMap?【范围查找】
  1. skiplist实现较红黑树更简单,所以Redis选择了skiplist作为zset的数据结构在红黑树上进行范围查找需要进行中序遍历,实现起来比较复杂,而在skiplist上进行范围查找只需要单向遍历即可;红黑树插入、删除可能引发子树的调整,逻辑复杂,而skiplist只需要修改相邻节点的指针即可

  2. HashMap元素是无序的,无法进行范围查找 —— Redis的策略是HashMapskiplist同时使用,共享底层数据存储,如果是查找单个元素则利用HashMap达到O(1)的时间复杂度,范围查找则选择skiplist


应用

  1. 直播间礼物排行榜
  2. 对粉丝列表按关注事件排序
  3. 学生成绩排名

命令

  1. 添加元素:zadd key score value
1
2
3
4
5
6
> zadd books 9.0 "think in java"
(integer) 1
> zadd books 8.9 "python cookbook"
(integer) 1
> zadd books 8.6 "java concurrency"
(integer) 1
  1. 输出某范围元素:zrange key s_index e_index

    e_index-1时,表示倒数第一个元素

1
2
3
4
> zrange books 0 -1
1) "java concurrency"
2) "python cookbook"
3) "think in java"
  1. 逆序输出某范围元素:zrerange key s_index e_index

    e_index-1时,表示倒数第一个元素

1
2
3
4
> zrevrange books 0 -1  # 逆序
1) "think in java"
2) "python cookbook"
3) "java concurrency"
  1. 计数:zcard
1
2
> zcard books
3
  1. 获取指定K/V的scorezscore key value
1
2
> zscore books "java concurrency"
8.6
  1. 排名:zrank key value
1
2
> zrank books "java concurrency"
0
  1. 根据score区间遍历zrangebyscore key s_score e_score (with scores)
1
2
3
4
5
6
7
8
9
10
11
12
> zrangebyscore books 0 8.9
1) "java concurrency"
2) "python cookbook"

# inf表示无穷大
# withscores带上score一起显示

> zrangebyscore books -inf 8.9 withscores
1) "java concurrency"
2) 8.6
3) "python cookbook"
4) 8.9

6. 位图(bitmap)

Redis的位图不是特殊的数据结构,它的内容实际上就是普通的字符串(即byte数组),你可以用get/set对整个位图进行操作,也可以用getbit/setbit对位图的单个位进行操作

(1)零存整取/零存零取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
> setbit s 1 1
0
> setbit s 2 1
0
> setbit s 4 1
0
> setbit s 9 1
0
> setbit s 10 1
0
> setbit s 13 1
0
> setbit s 15 1
0

> get s # 整取
"he"
> getbit s 2 # 零取
1

(2)整存零取

1
2
3
4
> set w h
OK
> getbit w 5 # 零取
0

7. HyperLogLog:不精确的去重方案

(1)试想如果你需要统计某个网页的PV(Page View),你会怎么做?
使用Redis的计数器,key后缀加上当天日期,每次有请求就incrby一次。
(2)如果你需要统计某个网页的UV(User View),你会怎么做?
首先想到的肯定是Set,每个请求带上用户id,然后进行去重计数,但是,如果网页访问量非常大,达到亿级,使用Set进行存储,所需要的空间是十分惊人的。这时候就可以使用HyperLogLog,计数误差率0.81%,已经可以满足不精确去重的需求了

命令

  1. pfadd name valueHyperLogLog添加元素,重复元素会被过滤
  2. pfcount name不精确计数
1
2
3
4
5
6
7
8
9
10
> pfadd books java
(integer) 1
> pfadd books python
(integer) 1

> pfcount books # 不精确计数
(integer) 2

> pfadd books python # 去重
(integer) 0
  1. pfmerge name1 name2将两个pfcount的值相加

特点

  1. 固定需要12k的存储空间
  2. 底层存储实际上仍是位图法

缺点

  • 只提供了pfcount如果想要知道某个元素在不在HyperLogLog中是做不到的

    这时候你可以用到Bloom Filter


(四)Redis的特点(为什么Redis这么快?)

  1. Redis是完全基于内存的:绝大多数请求是存粹的内存操作,所以非常快速。

  2. Redis采用了单进程单线程,杜绝了上下文切换和竞争条件,没有锁来降低性能。

    Redis 6.0 将引入多线程I/O,数据处理的读写操作可以多线程执行,但是核心命令操作仍然是单线程。

  3. Redis采用了NIO(I/O多路复用技术),可以通过单线程来高并发

Redis线程模型

Redis内部采用的文件事件处理器file event handler是单线程的,它通过多路I/O复用同时监听多个socket,将socket上的事件放入队列排队,事件分配器每次从队列中拿出一个事件,交给对应的事件处理器处理。


(五)Redis过期键删除

应用:登录token;短信验证码

策略:定期删除 + 惰性删除

  1. 主动(定期)删除:默认每隔100ms随机抽取一些设置了过期时间的key,检查是否过期,如果过期则删除。如果被删除的key达到了一个比例(默认25%),则重新开始下一轮的主动删除,直到被删除的key低于此比例

    为什么要随机抽取设置了过期时间的key
    假设Redis存储了几十万个设置了过期时间的key,每隔100ms就遍历一次,系统负载会非常大,所以需要随机抽样。

随机抽样删除的key超过了25%会开启下一轮随机抽样。
【阿里-政务钉钉-一面】的时候被问到。

  1. 惰性删除:很多过期key靠定期删除没有及时移除,只有等到读取该key时才判断是否过期,如果过期就删除

    假设有很多key没有被定期删除删掉,恰好也没有查询请求,无法触发惰性删除,那么它们就会一直堆积在内存中,导致内存耗尽。
    所以光有过期键删除是不够的,还需要有内存淘汰机制

  2. 当已使用内存超过maxmemory限定时,触发内存淘汰机制(见第六节)


(六)内存淘汰机制:保证Redis存储的都是热点数据

MySQL里有2000w数据,Redis中只存20w的数据,如何保证Redis中的数据都是热点数据?
当请求申请不到足够的内存空间时,就会触发Redis的内存淘汰机制,一共8种,默认no-eviction最常用的是allkeys-lru

  1. volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
  2. volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选剩余时间(TTL, time to live)短的key淘汰
  3. volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
  4. allkeys-lru(这个是最常用的):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key
  5. allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
  6. no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。

4.0版本后增加以下两种:

  1. volatile-lfu:从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰
  2. allkeys-lfu:当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的key

LRU原理图解:

LFU原理图解:


(七)Redis持久化

1. RDB(redis.conf -> dbfilename)

以记录二进制快照(内存数据在某个时间节点上的副本)方式将数据存储到磁盘,存储目录在redis.confdbfilename dump.rdb中设置。
RDB是Redis的默认持久化方式

命令:BGSAVE

1
2
3
4
5
6
7
8
# 900s(15min)后,至少有1个key发生变化,则触发BGSAVE命令存储快照
save 900 1

# 300s(6min)后,至少有10个key发生变化,则触发BGSACE命令存储快照
save 300 10

# 60s(1min)后,至少有10000个key发生变化,则触发BGSACE命令存储快照
save 60 10000

快照(snapshot)是关于指定数据集合的一个完全可用拷贝,该拷贝包括相应数据在某个时间点(拷贝开始的时间点)的映像。快照可以是其所表示的数据的一个副本,也可以是数据的一个复制品。

参考《快照(Snapshot)》

RDB的缺点

一旦非法关闭就会丢失最后一次快照之后的数据

反观采用appendfsync everysecAOF,即使非法关闭也只会丢失1s的数据。


2. AOF(Append Of File)

记录写命令,实时性更好,已成为主流持久化方式


AOF配置

  1. 开启AOF持久化方式:修改redis.conf中的appendonlyyes
  2. 存储目录在redis.confappendfilename "appendonly.aof"中设置。

AOF的3种方式

  1. appendfsync always:每次有数据修改就写入AOF文件,严重影响Redis速度

  2. appendfsync everysec每秒同步一次,显式将多个命令写入磁盘,这样即使系统崩溃,也只会丢失1s的数据

    AOF默认采用appendfsync everysec,每秒刷一次盘

  3. appendfsync no:由OS决定何时同步


AOF的缺点:文件体积膨胀

AOF文件体积膨胀:由于AOF持久化是通过记录写命令来记录数据库状态的,所以AOF文件的大小随着时间增长肯定会越来越大,Redis存储压力增加,同时通过AOF文件还原数据库的时间增加

解决措施:AOF重写

AOF重写:Redis服务器会创建一个新的AOF文件替代现有的AOF文件,新旧两个文件所保存的数据库状态是相同的,但是新的AOF文件不会包含任何浪费空间的命令,通常体积比原AOF文件小很多

  1. aof_rewrite()可以创建新的AOF文件,但是这个函数会进行大量的写入操作,所以调用该函数的线程将会长时间阻塞,如果是Redis服务器进程调用的此函数,那么AOF期间服务器将无法处理客户端发送的请求
  2. Redis提供的解决方法是“子进程AOF重写”:aof_rewrite()放到子进程中后台执行,这样主进程可以继续处理命令请求
子进程AOF重写(后台重写)
  1. 存在的问题:子进程进行AOF重写时,主进程继续处理请求,新的请求对原有数据进行了修改,会导致AOF重写文件和Redis数据库出现数据不一致
  2. 解决方法:AOF重写缓存 —— AOF重写期间主进程的写请求写入重写缓存中,后续同步更新到重写后的AOF文件中
后台重写的触发机制

可以通过BGREWRITEAOF命令手动触发。

  1. 没有 BGSAVE/AOF持久化 正在进行;
  2. 没有 BGREWRITEAOF 正在进行;
  3. 当前AOF文件要大于server.aof_rewrite_min_size(没有配置,默认1M);
  4. 当前 AOF文件大小 / 最后一次AOF重写后的大小 >= auto-aof-rewrite-percentage(没有配置,默认100%

3. 同时使用RDBAOF

Redis 4.0 开始支持RDBAOF混合持久化。通过aof-use-rdb-preamble开启。开启后,AOF重写的时候就直接把RDB写入AOF。
Redis重启的话,数据会从AOF读取。

  • 优点:结合RDBAOF的优点,快速加载,同时避免丢失过多数据。
  • 缺点:AOF中的RDB是压缩格式,而不是AOF格式,可读性较差。

(八)Redis事务

事务:将多个命令打包,然后顺序、一次性执行多个命令,在事务执行期间,Redis不会中断事务而去执行其他客户端的命令请求

命令

  1. MULTI:事务开始
  2. EXEC:事务执行
1
2
3
4
5
6
7
8
9
10
11
> set books 0
OK
> multi # 事务开始
OK
> incr books
QUEUED
> incrby books 2
QUEUED
> exec # 事务执行
1) 1
2) 3
  1. DISCARD:事务丢弃
  2. WATCH乐观锁 —— WATCH会在事务开始前盯住某些变量,事务执行时会检查这些变量是否会被修改,如果会,事务执行返回失败,需要重试

特点

  1. A:原子性 —— Redis的事务严格意义上来说并不能保证原子性 —— 同一个事务内,上一条命令执行失败,并不会导致下一条命令不执行
  2. C:一致性
  3. I:隔离性
  4. D:如果Redis运行在特定的持久化模式,那么也可以有持久性

(九)Redis主从复制(防止单点故障)

命令:SLAVEOF

方法:

  1. 完整重同步:主服务器阻塞地生成.rdb文件发送给从服务器,并将阻塞期间缓冲区存储的写命令发送过去
  2. 部分重同步:从服务器断线重连后,主服务器将它断线期间的写命令以.aof的形式发送过去

    部分重同步的判断方法:主从记录一个偏移量,如果偏移量不一致说明不同步


主从持久化方式

Master不做任何持久化工作(RDB/AOF),如果数据较重要,Slave开启AOF备份策略,每秒同步一次。


(十)Redis高可用性解决方案

1.Redis-Cluster集群

  1. 集群中的所有Redis节点彼此互联(ping-pong协议),内部使用二进制协议优化传输速度和带宽。
  2. 节点的fail是超过半数的master节点检测失效时才生效。
  3. 客户端与任意Redis节点直连即可使用,不需要中间代理层,也不需要连所有节点。
  4. Redis-Cluster将所有物理节点映射到[0-16383]slot上,集群维护node<->slot<->key

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


2.哨兵Sentinel

多台服务器组成哨兵集群,监控集群中的所有主从服务器(以每秒一次的频率进行心跳检测),在主服务器宕机时进行选举,从从服务器中选出新的主服务器,原主服务器上线后变为从服务器

说明Redis使用的是Paxos协议,如果使用的是Raft协议,原主服务器上线后会比较选票

哨兵集群是如何判断主服务器宕机的?

  1. 当一台Sentinel服务器对主服务器进行心跳检测时,时间超过TTL,这台Sentinel服务器就会主观认为主服务器已经宕机;
  2. 如果主服务器被标记为宕机,那么所有Sentinel都要对这台主服务器进行心跳检测,确认主服务器是否已经宕机;
  3. 超过半数的Sentinel服务器认为主服务器宕机,那么主服务器就是客观宕机了,这时候会进行新的选举

缺点

  1. 写操作无法复杂均衡
  2. 存储能力受到单机限制

3.Redis Sharding(分片)集群

sharding是指将数据分散到多个Redis实例,是一种横向数据扩展方式,出现时间要早于Redis Cluster,也比Redis Cluster要更轻量级

原理

  • 每一个Redis节点切片称为一个Shard一个Shard中含有1台主Redis以及多台备Redis
  • 对于每一个key通过哈希函数决定映射到哪个Shard

优点

  • 轻量级

缺点

  • 不具备主从切换能力,一旦主节点宕机,无法进行选举
  • 从Redis是“冷备”

解决 单点问题 —— master-master多写随机读

  • 采用多个Shard保证了Redis Sharding集群的可扩展性,但是没有解决单点问题,一旦Master节点宕机,那么该Shard的数据就不可用。
  • 解决方法是采用多写随机读——每次写入数据都写入全部Shard的master Redis,读取的时候随机选择一个Shard,这样可以防止单点故障,保证高可用性

(十一)应用

【一】缓存

1.缓存穿透:查询一个Redis中一定不存在的数据

原理:查询一条数据时,会优先查询Redis,如果Redis不存在该条数据的缓存则查询DB,能查到则更新缓存,查不到则不更新。攻击者可以通过不断查询一个一定不存在的key,来达到压垮DB的目的

解决方案:

  1. 对于请求中不存在的key也存入缓存,只是过期时间较短(5min)
  2. Redis中可能存在的数据全部加入一个BloomFilter,请求一定不存在的数据就会被BloomFilter过滤掉
  3. 在接口层增加校验,比如用户鉴权、参数校验,不合法的直接return

2.缓存雪崩:在某段时间缓存集中失效

简介:缓存同一时间大面积失效,导致后续请求都落到数据库上,造成数据库短时间内承受大量请求而崩溃。

比如抢购,某一大批商品信息存在缓存中,但是过期时间过完后这批信息全部失效。

解决方案:

(1)事前
  1. 不同key设置不同的过期时间

    比如抢购期间的商品延长过期时间

  2. 过期时间设置为随机数

  3. 对于Redis缓存锁来说,使用完毕后主动释放

  4. 设置Redis集群,保证高可用性,发现机器宕机快速补上

  5. 选择合适的内存淘汰策略

(2)事中
  1. 本地ehcache缓存

    Ehcache:纯Java开源分布式缓存框架。

  2. Hystrix限流、降级

  3. 接入MQ进行限流

(3)事后

利用Redis持久化保存的数据,尽快恢复缓存


3.缓存击穿:一个key的查询频率特别高,在它失效的一瞬间,大量的并发请求使得DB频率骤升

虽然说MySQL支持最大连接数为4000,但是由于CPU、内存、磁盘、网络等物理指标,最大连接数一般都采用默认的150一般3000的并发请求就足以打死大部分数据库了

比如:爆款商品。

解决方案:

  1. 首先最简单的做法肯定是:对于高并发的key(比如爆款商品)的过期时间设置为永久或者是设置为足够久,保证过期的时候改key的并发量已经降低);
  2. 业内最常见的做法是:缓存失效时并不是立即查库,而是SETNX一个互斥键mutex key,操作成功后才去查库(这样可以保证缓存击穿时只有一个请求能够查库更新缓存,不会所有请求都查库),否则就重试整个获取缓存的方法,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public String get(K key){
String value = redis.get(key);
//值为空,说明缓存失效,需要查库更新
if (null == value) {
//并不直接查库,而是尝试去插入一个互斥的mutex_key
//注意:【一定要设置一个过期时间,比如3min,防止del(mutex_key)失败】
if (redis.setnx(mutex_key, 1, 3 * 60) == 1) {
//插入成功,才允许查库,这样可以保证只有一个请求可以成功查库
value = db.get(key);
//Cache Aside,查库后同步更新缓存
redis.set(key, value, expire_time);
//删除互斥键mutex_key,【如果此步失败,下次获取mutex_key需要等其过期】
redis.del(mutex_key);
}else{
sleep(50);
get(key);
}
}else{
return value;
}
}

mutex key这种方案同样也可以用于“缓存雪崩”的情况,非常具有借鉴意义!
应该说,但凡是担心缓存失效导致压力打到库上的情况,都要优先考虑mutex key方案(ง •_•)ง


4.热点键(hot-key)问题:缓存混淆

查询频率十分高的key称为hot-key,比如爆款商品,如果hot-key单独由一台服务器保存,可能会导致服务器压力过大而负载。

解决措施:

  1. 缓存混淆 —— 标准的以空间换时间,为认定为hot-key的键人为地加上一个(entropy),使其路由到不同的服务器保存,降低单一服务器的负载。

    要注意,如果熵过小,可能会导致哈希路由时仍路由到同一台服务器,起不到降低负载的作用;
    如果熵太大,计算难度增加,复制数据的消耗要远大于降低负载的消耗,同时可能会导致数据不一致。

  2. Redis Shard多写随机读


5.缓存&DB 双写一致性

注意:使用缓存,就必然可能出现数据不一致的情况(除非采用强一致性的Paxos协议或者2PC协议,或者强制缓存串行化);
如果对数据一致性要求很高,数据库压力又不是很大,那最好还是不使用缓存

双写一致性策略:

(1)Cache Aside:先更新数据库,后删缓存
  • 失效:先读缓存,如果缓存失效,则直接读DB,更新缓存
  • 命中:读缓存未失效,直接返回缓存
  • 更新:先将数据更新到DB,成功后,再删除缓存

①首先让我们考虑一下为什么“先删缓存,后更新数据库”不行?
有一个写请求删除了缓存,还没来得及更新数据库,此时一个读请求进来,没有读到缓存,所以它会访问数据库,并将“脏数据”写入缓存中。写请求更新了数据库,但是不会再去更新缓存,所以缓存中的数据一直是“脏数据”。
②那么你会想,如果我“先删缓存,更新完数据库后,再同步更新缓存”行不行?
答案还是不行,试想一下,一个写请求删除了缓存,还没来得及更新数据库,此时一个读请求进来,没有读到缓存,所以它会访问数据库,并将“脏数据”准备写入缓存中,而此时写请求更新了数据库,并先于它更新了缓存,那么读请求会将“脏数据”更新到缓存,覆盖新数据。
③但是,“先删除缓存,更新数据库后休眠一段时间,再次删除缓存”是可行的,这种策略被称为“延迟双删”。让我们讨论几种情况:A删除了缓存,还没有更新数据库,B发现缓存已经删除,去读DB,读到脏数据更新到缓存,A休眠一段时间后再次删除缓存,之后如果有读请求发现没有缓存会读DB更新缓存,数据是一致的;A删除了缓存,还没有更新数据库,B发现缓存已经删除,去读DB,A休眠一段时间后再次删除缓存,此时B读到脏数据更新到缓存,数据是脏数据 —— 这种情况很苛刻,但是也有可能发生。
所以“先更新数据库,再删除缓存”才是王道

1
2
3
4
5
6
7
//延迟双删 : 先删除缓存,更新数据库后休眠一段时间,再次删除缓存
public void write(String key, Object data){
redis.del(key); //删缓存
db.update(data); //更新数据库
Thread.sleep(1000); //延迟:根据生产上更新数据库的平均耗时决定
redis.del(key); //再次删除缓存
}

但是“先更新数据库,再删除缓存”同样有可能出现脏数据:
1.请求A读key,此时key刚好失效
2.A读DB,读到旧数据,没来得及更新缓存
3.请求B修改DB,删除缓存
4.A更新缓存,将旧数据写入缓存,这时缓存中的就是脏数据
以上情况建立在读操作先于写操作访问数据库、晚于写操作更新缓存的基础上,但是由于读写分离,DB的读操作要比写操作快很多,所以这种情况不太可能出现。

如果你一定要求强一致性,可以采用Paxos协议或者2PC协议。此外,为缓存设置过期时间是很有必要的。又或者说,你可以直接更新Redis,一段时间后再统一将缓存中的数据持久化到数据库

(2)Redis读、写请求串行化

这种方法对Redis性能影响极大,但是可以保证强一致性。

即所谓的所有操作都在Redis中进行,事后再异步更新到数据库,适用于“秒杀系统”。


【二】分布式锁:幂等操作

应用:保证幂等操作,如缓存锁可以保证一段时间内同一个操作不要大量进行,导致服务崩溃。

(1)单实例 分布式锁

SET key value NX PX 30000

  • value:类似于CAS悲观锁,value一定要是全局唯一的。可以是/var/urandom生成的随机数 / Unix时间戳 + Client ID
  • NX:只有key不存在才创建
  • PX:设置过期时间(单位ms)
具体实现
  1. 获取锁:先判断锁lock对应的key是否存在,不存在才通过SET key val NX PX timeout原子性地加锁并设置过期时间,其中val分布式全局唯一IDNX表示只有key不存在才设置key的值,这样可以保证原子性。如果SET key val NX PX timeout失败,应该先查询key是否过期,如果未过期则加锁失败,否则应尝试获取锁,如果成功则加锁,失败则说明有人截胡。

  2. 释放锁:只有key存在(NX),且val是创建锁设置的值,才允许释放锁,或者通过Lua脚本保证删除操作的原子性。这里的val的作用类似于版本号,如果val已经被修改了,说明锁已经被其他人获取了,这时候需要事务回滚

    这样做的目的是为了防止删除别人创建的锁。
    试想这种情景:A获取了key锁,但是持有时间过长,超过了过期时间,此时key过期,B获取到了key锁,如果A直接删除锁,删除的是B创建的锁。

  3. 过期:过期时间到了,会自动释放锁

代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
//原子获取锁:setnx原子获取锁,成功则加锁;失败则查询是否过期 ——> 未过期则返回失败;过期则尝试获取锁 ——> 成功则加锁;失败则返回
public boolean acquireLock(String lock) {
//缓存锁开关:如果为0则直接返回,不允许设置锁
if(0 == SourceChooseUtil.getValueByKey(SourceChooseUtil.SWITCH_CACHE_LOCK) ) {
return true;
}
//通过SETNX原子加锁
boolean success;
long value = System.currentTimeMillis() + getLockExpiredTime(lock) + 1;
long acquired = commonCache.setnx(lock, String.valueOf(value) );
//SETNX成功
if(1 == acquired) {
success = true;
}else{
//SETNX失败,说明锁仍被其他对象持有,应该检查其是否已经超时
String valueStr = commonCache.get(lock);
if(StringUtil.isBlank(valueStr) ) {
return true;
}
long oldValue = Long.parseLong(valueStr);
//锁已经超时,但是由于延迟删除策略的存在,还没有被删除
if(oldValue < System.currentTimeMillis() ) {
String getValue = commonCache.getSet(lock, String.valueOf(value) );
//获取锁成功
if(StringUtil.isBlank(getValue) || Long.parseLong(getValue) == oldValue) {
success = true;
}else {
//被截胡
success = false;
}
}else{
//锁没有超时,还被其他对象持有
success = false;
}
}
}


//释放锁:必须要获取到锁才允许删除,不允许删除他人持有的锁
public void releaseLock(String lock) {
//缓存锁开关:如果为0则直接返回,不允许释放锁
//缓存锁开关关闭前加的锁会滞留在内存中;开关重新打开,锁会批量失效,不影响业务
if(0 == SourceChooseUtil.getValueByKey(SourceChooseUtil.SWITCH_CACHE_LOCK) ) {
return;
}
long current = System.currentTimeMillis();
//必须要获取到锁,才允许删除
String expiredTime = commonCache.get(lock);
if (StringUtil.isBlank(expiredTime) || current < Long.parseLong(expiredTime)) {
commonCache.del(lock);
}
}
存在的问题

单点问题:不具备容错性,一旦Redis实例宕机,锁服务就不可用。


(2)分布式高可用 分布式锁:Redlock

设计要求
  1. 安全性:必须要线程互斥,同一时刻只能有一个线程获取锁
  2. 无死锁:锁要可以自主释放,不能一直持有
  3. 容错性:只要大部分节点工作正常,就可以正常获取锁服务

    通过开启Redis的AOF持久化可以保证容错性。
    考虑下列场景:如果没有AOF持久化,client从5个master的3个上获取了锁,其中1个重启了,这时系统中又存在了3台机器可以获取同一个锁,这明显违反了互斥性。

获取锁

通过SET key value NX PX expire_time在所有节点上创建锁,只有超过半数节点成功创建锁,并且在超过半数节点成功获取锁的总耗时小于锁的过期时间expire_time),锁才创建成功,获取锁成功后,锁的有效时间要减去获取锁的总耗时,否则回滚,向所有Redis节点发起释放锁的请求

失败重试

如果一个client申请锁失败了,那么它需要稍等一会儿才重试,避免多个client同时申请锁。


【三】消息队列

原理:lpush+rpop / rpush+lpop


(十二)常见问题

1、海量数据应该如何获取?【SCAN取代KEYS

  • 比如Redis中有几百万user_token:userid格式的key,应该如何遍历?
  • KEYS user_token:* —— 时间复杂度O(N),但是它采用的是阻塞式遍历,其它指令必须等待keys执行完毕,这样会导致服务卡顿、机器假死 —— 所以很明显海量数据不能用KEYS获取
  • SCAN 0 MATCH user_token:* COUNT 5 —— 时间复杂度O(N),但是它采用的是迭代,每次只返回部分元素,不会阻塞整个Redis

    返回:
    1) "6"
    2) 1) "user_token:1000"
    2) "user_token:1001"
    3) "user_token:1010"
    4) "user_token:1300"
    6) "user_token:2389"
    那么下一次继续遍历要从6开始:
    SCAN 6 MATCH user_token:* COUNT 5


2、Redis相比Memcached有哪些优化?

  1. Memcached数据类型只有StringRedis数据结构更丰富
  2. Memcached不支持持久化,Redis支持
  3. Redis速度比Memcached快很多

3、如果事务执行一半Redis锁过期了怎么办?

  1. 首先想到的当然是增加Redis锁过期时间,但是不能太长,否则一旦客户端忘记解锁,其他线程需要等待锁自动过期的时间就会过长
  2. 此外,Redis锁一般会设置一个版本号,如果一个事务执行完成去解锁的时候,发现版本号非预期值,那么说明锁自动过期的时间内有其他事务获取了锁,该事务需要回滚

4、秒杀系统的设计(双11期间,如何尽快将1000商品卖出去,同时又不出错)

参考《秒杀系统中下单、减库存、支付之间的关系》

  1. 【负载均衡】:【服务器端肯定要引入负载均衡,减少单一服务器的压力】;
  2. 【动静分离】:【将秒杀页面设计为静态页面,使用Nginx做动静分离,或者是存储到CDN】;
  3. 【内存替代硬盘】:【由于秒杀系统对响应时间要求很高,所以传统DB是不行的,需要使用Redis的单线程、高并发实现持久化】;
  4. Redis分布式锁】:【应该使用Redis的分布式乐观锁,防止出现超买超卖的问题,保证数据准确】;
  5. MQ流量削峰】:方法有MQ、答题验证等
  6. 【写回】:【在秒杀结束后,Redis中缓存的数据,应该要通过MQ以异步的方式写入DB中】;
  7. 【缓存】:【如果觉得Redis压力太大,可以在服务器和Redis间加入一级缓存】
  8. 【缓存过期时间】:爆款商品、秒杀商品的过期时间可以设置为永久
  9. 【机器扩容】;
  10. 【服务降级】
  11. 【使用 漏桶算法/令牌环算法 进行限流】

5.Cache Aside策略中,如果更新DB后,删除缓存失败怎么办?

  1. 首先缓存中的数据必须设置合理的过期时间,这样即使更新失败,数据也只有一段时间会是脏数据;
  2. 如果你一定要求强一致性的数据,那么可以对缓存进行读写串行化;
  3. 为了尽量减少对业务数据的影响,一旦发现删除缓存失败,可以将删除缓存的命令写入MQ/日志/事件中,后续异步地进行更新;同时本次操作可以返回一个失败的标志,要求事务回滚,或者返回一个特殊标志,让业务方不读缓存,强制读库

6.Redis数据一致性要求高、不容丢失的场景,如何设置主从持久化策略?

数据一致性要求高、不容丢失的场景:购物车去DB,全部数据存储于Redis

  1. Redis关闭持久化

    Redis通过AOF持久化记录所有主Redis的写命令,至多丢失1s的数据,数据存储有保障,主Redis不需要额外持久化,浪费宝贵的内存和CPU资源

  2. Redis开启AOF持久化,同时关闭AOF自动重写,通过定时任务闲时(比如每晚12点)进行AOF重写

    在闲时才进行AOF重写是为了保障AOF文件和数据库之间的数据强一致性,防止主RedisAOF重写时宕机恢复,通过AOF文件进行数据同步时出现数据丢失的情况


7.MySQL主从同步有延迟,Redis缓存了脏数据怎么办?

字节跳动社招有3-4次都被问到,参考https://blog.csdn.net/john1337/article/details/98850192

  1. 根据CAP定理BASE定理,如果想要满足高可用,那么就只能保证最终一致性。用到Redis的业务都不可能强一致性,只要保证最终一致性即可

  2. 如果要求强一致性,可以Redis强制读主库,这种方法主库压力会比较大。

    苏宁购物车的异地多活就是这么做的:Redis强制读MySQL主库,MySQL从库只作为冷备

  3. 所有请求强制读主库过于粗暴,可以在只有主库有更新时强制读主库,其他时候读从库:写主库时(insert/update/delete),将“库-表-主键”拼装成一个keydb-table-pk)存储到Redis缓存中,过期时间设置为MySQL主从同步的延迟,Redis读库的时候判断key是否存在:key存在强制读主库,否则允许读从库


(十三)高级模块

  1. Redis-Cell:限流模块
  2. Redis-ML:机器学习模块
  3. Redis-Search:高性能全文搜索引擎

(十四)Redis 6正式支持多线程I/O

Redis 6引入的多线程I/O特性对性能提升至少一倍以上,预计发布时间2019年年底。

目前单线程Redis的性能瓶颈主要在与网络I/O,主要优化方向有:

  1. 提高网络I/O性能,比如使用DPDK替代内核网络栈
  2. 使用多个单线程Redis充分利用多CPU

多线程I/O,执行命令仍是单线程

不同于MemcachedRedis 6.0只有处理网络数据的读写和协议解析部分是多线程的,执行命令仍然是单线程。这样设计是不想key/事务/LPUSH/LPOP/lua等因为多线程并发的原因变得复杂
Redis多线程I/O
这些I/O线程同一时刻只能全读/写,不存在部分读部分写的情况

加入多线程I/O后,整体的读流程

  1. 主线程负责接收建连请求,读事件到来则放到一个全局等待读处理队列

  2. 主线程处理完读事件后,通过轮询将这些连接分配给这些I/O线程,然后主线程忙等待状态

    BIO

  3. I/O线程将请求数据读取并解析完成

  4. 主线程串行执行所有命令并清空整个请求等待读处理队列

-------------本文结束感谢您的阅读-------------

本文标题:Redis基础、应用(秒杀系统)

文章作者:DragonBaby308

发布时间:2019年07月01日 - 13:21

最后更新:2020年02月27日 - 21:00

原始链接:http://www.dragonbaby308.com/redis/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

急事可以使用右下角的DaoVoice,我绑定了微信会立即回复,否则还是推荐Valine留言喔( ఠൠఠ )ノ
0%