数据结构
redisObject
// from Redis 5.0.5
#define LRU_BITS 24
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS;
int refcount;
void *ptr;
} robj;
字段 | 大小 | 功能 |
---|---|---|
type:对象类型 | 4bits | 当前值对象的数据类型(String、List、Set…) |
encoding:底层编码 | 4bits | 当前值对象的底层存储编码(String [int、embstr、raw]) |
lru:空转时长 | 24bits | 记录对象最后一次被命令程序访问的时间 |
refcount:引用计数 | 32bits | 用来描述有多少个指针,指向该对象 |
ptr:内容指针 | 64bits | 指向实际内容 |
总计:128bit=16字节
对象删除策略
惰性删除
- 只会在取出 key 的时候才对数据进行过期检查
- 这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
定期删除
- info 查看频率 1s 10次
- 每次20个,过期key占比25%,再抽出20个检查,如此循环
- 定期删除过程默认不能超过25ms,防止循环过度
定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 定期删除+惰性/懒汉式删除 。
但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就 Out of memory 了。
怎么解决这个问题呢?答案就是:Redis 内存淘汰机制。
String
String 是最基本的 key-value 结构,key 是唯一标识,value 是具体的值,value 最多可以容纳的数据长度是 512M
。
SDS-简单动态字符串
String 底层数据结构为 SDS(Simple Dynamic String,简单动态字符串)。
包含了下面这 4 个属性:
len
:字符串的长度也就是已经使用的字节数alloc
:总共可用的字符空间大小,alloc-len 就是 SDS 剩余的空间大小buf[]
:实际存储字符串的数组flags
:低三位保存类型标志
SDS 相比于 C 语言中的字符串提升
- 可以避免缓冲区溢出 :C 语言中的字符串被修改(比如拼接)时,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。SDS 被修改时,会先根据 len 属性检查空间大小是否满足要求,如果不满足,则先扩展至所需大小再进行修改操作。
- 获取字符串长度的复杂度较低 : C 语言中的字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。SDS 的长度获取直接读取 len 属性即可,时间复杂度为 O(1)。
- 减少内存分配次数 : 为了避免修改(增加/减少)字符串时,每次都需要重新分配内存(C 语言的字符串是这样的),SDS 实现了空间预分配和惰性空间释放两种优化策略。当 SDS 需要增加字符串时,Redis 会为 SDS 分配好内存,并且根据特定的算法分配多余的内存,这样可以减少连续执行字符串增长操作所需的内存重分配次数。当 SDS 需要减少字符串时,这部分内存不会立即被回收,会被记录下来,等待后续使用(支持手动释放,有对应的 API)。
- 二进制安全 :C 语言中的字符串以空字符
\0
作为字符串结束的标识,这存在一些问题,像一些二进制文件(比如图片、视频、音频)就可能包括空字符,C 字符串无法正确保存。SDS 使用 len 属性判断字符串是否结束,不存在这个问题。
场景
字节数据、文本数据、序列化后的对象、常规计数、分布式锁、共享Session
操作
set、mset、setnx、setex
get、mget、strlen、getrange
del
incr、decr、incrby、decrby
底层
三种编码
int:当字符串是整数使用,能存储8个字节的整数
embstr:当字符串小于等于阈值时使用
- EMBSTR下redisObject和SDS是连续的内存
raw:当字符串大于阈值时使用
- RAW编码下redisObject和SDS的内存是分开的
阈值
3.2版本之前是39字节、3.2版本之后是44字节
为什么阈值是44字节?
Redis采用jemalloc作为内存分配器
jemalloc分配内存时,当申请空间大于64字节,使用raw编码;反之使用embstr编码
Redis字符串对象由redisObject+sdshdr组成,所以能用的大小为:64-16(redisObject)-4(sdshdr非内容属性)=44
为什么3.2之前阈值是39字节?
3.2之前,sds只要一种类型,能使用的大小:64-16(redisObject)-9(字符串长度4字节,分配大小4字节,“\0”1字节)
SDS
sdshdr8、16、32、64
- len:字符串长度、1字节
- alloc:分配的空间长度、1字节
- flags:sds类型、1字节
- buf[]:字节数组,存储实际数据,末尾有“\0”占用1字节
预留空间规则
- len小于1M,alloc是len的一倍,即预留大小为len
- len大于1M,alloc是1M+len,即预留大小为1M
压缩列表 - ZipList
zlbytes
存储的是整个ziplist所占用的内存的字节数zltail
指的是ziplist中最后一个entry的偏移量. 用于快速定位最后一个entry, 以快速完成pop等操作zllen
指的是整个ziplit中entry的数量.zlend
是一个终止字节, 其值为全F, 即0xff.
快表 - QuickList
QuickList = 双向链表+压缩列表
哈希表 - Dict
哈希表是由数组 table 组成,table 中每个元素都是指向 dictEntry 结构
哈希表结构定义:
typedef struct dictht{
dictEntry **table; //哈希表数组
unsigned long size; //哈希表大小
unsigned long sizemask; //哈希表大小掩码,用于计算索引值,总是等于 size-1
unsigned long used; //该哈希表已有节点的数量
}dictht
哈希表是由数组 table 组成,table 中每个元素都是指向 dict.h/dictEntry 结构,dictEntry 结构定义如下:
typedef struct dictEntry{
void *key; //键
union{ //值
void *val;
uint64_tu64;
int64_ts64;
}v;
struct dictEntry *next; //指向下一个哈希表节点,形成链表
}dictEntry
整数集合-intset
首先看源码结构
typedef struct intset {
uint32_t encoding; //编码方式
uint32_t length; //其中存储的整数的个数
int8_t contents[]; //实际存储数值的连续内存区域
} intset;
跳表-SkipList
对于于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是 O(n)。比如查找12,需要7次查找
如果我们增加如下两级索引,那么它搜索次数就变成了3次
skiplist与平衡树、哈希表的比较(为什么不用平衡树或者哈希表)
哈希表上只能做单个key的查找,不适宜做范围查找。skiplist和各种平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。
在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。
平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。
从算法实现难度上来比较,skiplist比平衡树要简单得多。
管道
管道技术
传统交互模式下,客户端发送请求,服务端接收请求并处理,最后返回回包。即我们需要一条命令结束了,再执行下一个命令。即单客户端是同步的。大量发包造成性能瓶颈
所以,有没有一种技术,能够让客户端可以将多条命令一起传递给服务端?
管道技术:将请求在客户端打包在一起,一次传递给服务端,服务端处理完之后,将结果存起来,等管道中的所有命令执行完成,再一起回包。节约网络交互的时间
管道技术的代价
1.当使用Pipeline,服务端不得不为回包排队,同一时间会占用更多内存,我们一直有说,Redis内存是寸土寸金,所以除非特定场景,不然也没必要强行使用Pipeline。
2.单个命令的时间会变长,原因是需要等待Pipeline中其它命令的完成。
Pipeline注意事项
1.Pipeline的命令不是原子的,因为实际上Redis还是一条一条去执行;
2.Pipeline包含的命令也不宜过多,Redis内存寸土寸金;
3.Pipeline只会在一个Redis节点上执行,这个很好了解,因为其实客户端是发送了一次请求,并不会分发到多个节点上。
Pipeline适用场景
适用于一个流程中多次操作Redis的场景,比如一个线程需要更新100个学生的分数,这时候可以用Pipeline打包执行。
总结
Redis支持Pipeline模式来批量发送数据给服务端,这样的好处是节省了网络时间,缺点是服务端会耗费更多内存,以及单个命令的时间会被整体拖慢。
生产中,不建议过度使用Pipeline,只有在真正需要的时候,比如在一个线程需要更新多个数据的时候,就比较合适。
Redis运行原理
基础
redisDb代表Redis数据库结构
// from Redis 5.0.5
typedef struct redisDb {
dict *dict; /* The keyspace for this DB */
dict *expires; /* Timeout of keys with a timeout set */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
list *defrag_later; /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;
RedisObject 就是存储在dict数据结构里
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;
- 添加数据:将键值对添加到dict结构字典中去,Key必须为String对象
- 查询数据:在dict中找到对应的key,即完成查询
- 更新数据:在dict中找到对应的key,赋上新值
- 删除数据:把key和value都从dict中删除
过期键
过期键存在expires字典上,过期字典来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。
SET a b,这个数据的存储结构是怎样的?
如果a在字典中,则value进行覆盖。如果a不存在字典中,则将key添加到字典中对应的偏移位置,并将b作为value进行存储。
SET a之后,调用EXPIRE a 60,此时这个过期信息是存储在哪里的?
如果添加了对键a添加了ttl,则会立马将其key添加到过期字典中,并存储毫秒精度的时间戳
过期的数据的删除策略了解么?
- 惰性删除 :只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
- 定期删除 : 每隔一段时间(1s/10次)抽取一批 key (20个)执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。INFO查看频率,如果删除后过期key的占比还是大于25%,则再抽取20个检查,往复循环,为了防止卡死,定期删除循环时间不超过25ms
定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 定期删除+惰性删除
单线程
Redis是单线程还是多线程的?
核心处理逻辑,Redis一直是单线程的;
某些异步流程从4.0开始用多线程,如UNLINK、FLUSHALL ASYNC、FLUSHDB ASYNC等非阻塞的异步删除操作
网络I/O解包从6.0开始用的都是多线程
Redis为什么选择单线程做核心处理?
① 多线程引入的复杂性是很大的
Redis本来是顺序执行的,如果引入多线程,为了支持事务的原子性、隔离性,就需要引入很复杂的实现;
Redis的数据结构在单线程模式下做了很多优化,是很高效的,如果引入多线程,要改造这些数据结构,很复杂以保证线程安全
② 多线程带来额外的成本
上下文切换的成本:多线程调度需要切换上下文以存储当前线程的本地数据、程序指针等;
同步机制的开销:一些同步资源,在单线程模式下直接访问即可,但在多线程模式下要通过加锁的方式同步
线程本身占据内存:对Redis这种内存数据库而言,内存很珍贵,而多线程本身就会带来内存使用成本
Redis单线程性能如何?
Redis单线程的性能是很好的,每秒可以达到数万级别的处理能力
为什么单线程还能那么快?
① Redis大部分操作在内存上完成,内存操作本身就很快
② Redis选择了高效的数据结构,比如ziplist、hashtable、跳表,有时候一种对象底层有多种实现以应对不同的场景
③ Redis采用多路复用的机制,使其在网络IO操作中能并发处理大量的客户端请求,实现高吞吐量
Redis6.0后引入了多线程,你知道为什么吗?
随着发展,很多业务的请求量剧增,IO操作比如读取请求、发送回包成为了性能提升的瓶颈,所以在维持单线程框架执行Redis指令的基础上引入了多线程处理IO操作
Redis6.0的多线程是默认开启的吗?
不是,默认是关闭的,如果想要开启需要用户在 redis.conf 配置文件中修改
io-threads 4 #设置1的话只会开启主线程
# 官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程
Redis6.0的多线程主要负责命令执行的哪一块?
读取并解析指令以及回包;
单线程模式下是一个线程完成读取、解析、执行、将回包放入客户端缓冲区;
但在多线程模式下,会将不同的client放入clients_pending_read任务队列中,后续会通过Round-Robin轮询负载均衡策略把这些client分给其他IO线程和主线程进行读取和解析;
在回包的时候,也是利用Round-Robin轮询负载均衡策略把等待回包队列中的任务连续均匀地分配给IO线程各自的本地FIFO任务队列和主线程自己,主线程轮询等待所有IO线程完成回包任务
IO多路复用
多路指的是多个socket连接,复用指的是复用一个线程。多路复用主要有三种技术:select,poll,epoll。epoll是最新的也是目前最好的多路复用技术。
采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗),且Redis在内存中操作数据的速度非常快,主要以上两点造就了Redis具有很高的吞吐量。
Redis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler)。
- 文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
- 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关 闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。
内存淘汰
Redis有几种内存回收策略
8种
no-eviction 禁止驱逐数据,当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!
volatile-lru 从已设置过期时间的数据集中挑选最近最少使用的数据淘汰。
volatile-lfu 从已设置过期时间的数据集中挑选最不经常使用的数据淘汰。
volatile-random 从已设置过期时间的数据集中任意选择数据淘汰。
volatile-ttl 从已设置过期时间的数据集中挑选将要过期的数据淘汰。
Allkeys- lru 当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key。
Allkeys-lfu 从数据集中任意选择数据淘汰。
Allkeys-random 从数据集中任意选择数据淘汰。
内存回收是什么时候发起
每次运行读写命令的时候,就会尝试去释放一定内存,策略就按我们上述配置的策略。
介绍下Redis LRU回收算法
Redis采用近似LRU算法,redisObject对象种lru字段存储的就是key被访问时Redis的时钟server.lrulock,当key被访问的时候,Redis会更新这个key的redisObject的lru字段。
LRU在现有数据结构的基础上采用随机采样的方式淘汰元素,具体而言就是随机采样n个key,n默认为5,然后根据时间戳淘汰掉最旧的那个key,如果淘汰后内存还是不足就继续随机采样来淘汰
Redis3.0对近似LRU进行了优化
新算法维护一个大小为16的候选池,池中的数据根据访问时间进行排序,第一次随机选取的key都会放入池中,然后淘汰掉最久未访问的,比如第一次选了5个,淘汰掉1个,剩下4个就放入候选池中。
随后每次随机选取的key只有活性比池中活性最小的key还小时才会放入池中,当池子装满了,如果有新的key需要放入,则将池中活性最大的key移除池子(不是淘汰)
通过池子存储,池子里的数据活性会越来越接近真实活性最低,表现会非常接近标准LRU
Redis LRU算法是标准的吗?为什么不用标准的
不是,标准的LRU需要维护一个双向链表,如果数据过多,维护这个链表对redis来说就会是巨大的成本,所以采用了近似LRU算法
什么是LFU算法,为什么Redis要引入LFU算法
LFU淘汰算法,最不频繁淘汰算法,顾名思义,优先淘汰活跃最低,使用频率最低的。
Redis在LFU策略下复用redisObject的lru字段,还是用它来表示LFU的信息,不过将24拆解,高16bit存储上一次访问时间戳(精度是1分钟),低8bit存储访问次数。一个Key是否活跃,就是这两个字段综合决定的。
LRU本身已经能解决大部分问题,但是脱离频率,只谈最近访问,在部分场景是得不到我们希望的结果
例如有的键访问频率很低,但是最近使用过;有的键访问频率很高,但是最近没使用。
持久化
RDB和AOF本质区别是什么?
RDB | AOF | |
---|---|---|
持久化 | 通过保存快照 | 通过追加日志 |
文件类型 | 二进制文件,更小 | 文本文件,更大 |
安全性 | 容易丢失更多数据 | 丢失数据更少(默认everysec,最多一秒的丢失) |
文件恢复 | 直接解析,恢复速度快 | 一条条命令执行,恢复速度慢 |
开销 | 全量保存,开销更大 | 追加操作,开销更小 |
如果RDB和AOF只能选一种,你选哪个?
如果我们能接受分钟级别的数据丢失,可以考虑选择RDB
如果需要尽量保证数据安全,可以考虑混合持久化
如果只用AOF,那么优先选择everysec策略进行刷盘(在可靠和性能之间有一个平衡)
Redis不推荐单独开AOF,如果需要持久化,快照始终是一个好办法,从默认配置也能看出来RDB是默认的,AOF不是。
RDB触发条件是什么?触发时机又是什么?
- 主动触发:save、bgsave;
- 自动触发:配置文件(达到阈值时触发)
- 正常关闭redis服务(执行shutdown)时触发
其他触发时机:
主从复制时,从库全量复制同步主库数据,此时主库会执行bgsave命令进行快照
客户端执行数据库清空命令FLUSHALL时候,会触发快照的生成
AOF触发条件是什么?触发时机又是什么?
AOF是通过配置项来 自动触发的
always:每条命令执行成功后,都会刷进磁盘
everysec:每一秒触发一次(默认的aof策略)
no:由操作系统来决定刷盘时间
RDB对主流程有什么影响?
当执行save命令的时候,由主进程进行 RDB快照保存,会阻塞主进程
当执行bgsave或是配置自动触发时,由fork出的子进程来进行RDB快照保存
如果数据量比较大的时候,会导致fork子进程 这个操作比较耗时,从而阻塞主进程
由于采用了写时复制技术,如果在进行RDB快照保存的时候,有大量的写入操作执行,会导致主进程多拷贝一份数据给子进程,消耗大量额外的内存
fork创建子进程之后,通过写时复制技术,子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个。只有在发生修改内存数据的情况时,物理内存才会被复制一份。
如果此时主进程需要修改某块数据,那么这块数据就会被复制一份到内存,生成该数据的副本,子进程会把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据,子进程所看到的数据在它被创建的一瞬间就已经固定下来了
写时复制技术:如果有多个调用者同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本给该调用者,而其他调用者所见到的最初的资源仍然保持不变。。此作法主要的优点是如果调用者没有修改该资源,就不会有副本被创建,因此多个调用者只是读取操作时可以共享同一份资源。
执行快照时,数据能被修改吗?
结论:执行 bgsave 过程中,Redis 依然可以修改数据,通过写时复制技术实现,执行 bgsave 命令的时候,会通过 fork()
创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个。如果主线程(父进程)要修改共享数据里的某一块数据(比如键值对 A
)时,就会发生写时复制,于是这块数据的物理内存就会被复制一份(键值对 A'
),然后主线程在这个数据副本(键值对 A'
)进行修改操作。与此同时,bgsave 子进程可以继续把原来的数据(键值对 A
)写入到 RDB 文件。但是bgsave 快照过程中,如果主线程修改了共享数据,发生了写时复制后,RDB 快照保存的是原本的内存数据,而主线程刚修改的数据,是没办法在这一时间写入 RDB 文件的,只能交由下一次的 bgsave 快照。
详细分析:
执行 bgsave 过程中,子进程来构建 RDB 文件,主线程还是可以继续工作的,此时主线程可以修改数据吗?
执行 bgsave 过程中,Redis 依然可以继续处理操作命令的,也就是数据是能被修改的。
那具体如何做到到呢?关键的技术就在于写时复制技术(Copy-On-Write, COW)。
执行 bgsave 命令的时候,会通过 fork()
创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个。
这样的目的是为了减少创建子进程时的性能损耗,从而加快创建子进程的速度,毕竟创建子进程的过程中,是会阻塞主线程的。
所以,创建 bgsave 子进程后,由于共享父进程的所有内存数据,于是就可以直接读取主线程(父进程)里的内存数据,并将数据写入到 RDB 文件。
当主线程(父进程)对这些共享的内存数据也都是只读操作,那么,主线程(父进程)和 bgsave 子进程相互不影响。
但是,如果主线程(父进程)要修改共享数据里的某一块数据(比如键值对 A
)时,就会发生写时复制,于是这块数据的物理内存就会被复制一份(键值对 A'
),然后主线程在这个数据副本(键值对 A'
)进行修改操作。与此同时,bgsave 子进程可以继续把原来的数据(键值对 A
)写入到 RDB 文件。
就是这样,Redis 使用 bgsave 对当前内存中的所有数据做快照,这个操作是由 bgsave 子进程在后台完成的,执行时不会阻塞主线程,这就使得主线程同时可以修改数据。
细心的同学,肯定发现了,bgsave 快照过程中,如果主线程修改了共享数据,发生了写时复制后,RDB 快照保存的是原本的内存数据,而主线程刚修改的数据,是没办法在这一时间写入 RDB 文件的,只能交由下一次的 bgsave 快照。
AOF对主流程有什么影响?
当AOF写入日志压力过大会导致主进程无法继续处理其他请求
当AOF重写发生时,如果数据量比较大,会导致fork子进程这个操作比较耗时,从而阻塞主进程
AOF混合持久化方案是什么?
如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。
简单描述AOF重写流程
当 AOF 变得太大时,Redis 能够在后台自动重写 AOF 产生一个新的 AOF 文件,新的 AOF 文件体积更小。
在执行 BGREWRITEAOF
命令时,Redis 服务器会维护一个AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。
AOF重写你觉得有什么不足之处么?
Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次(因为维护了AOF缓冲和AOF重写缓冲,重写时会将AOF 缓冲 和 AOF重写缓冲 分别 写入到旧日志和新日志中,这是额外的磁盘开销)。
改进:
在Redis 7.0版本,对AOF重新作出了优化,提出了多部件AOF方案,原来的AOF重写缓冲被移除,AOF日志分成了 Base AOF日志 ,Incr AOF日志
Base AOF日志 记录重写之前的命令;Incr AOF 日志记录 重写时新的写入命令
当重写发生时,主进程fork出一个子进程,对Base AOF日志进行重写·(将当前内存数据写入到新的Base AOF日志);如果此时有新的写入命令,会由主进程写入到AOF缓冲,再将缓冲数据刷入新的Incr AOF日志。这样新的Incr AOF日志 + 新的Base AOF日志 就构成了完整的新的AOF日志
事务
什么是事务的 ACID
事务(Transaction)是并发控制单位,一个操作序列组合而成,这些操作要么都执行,要么都不执行。
事务在执行时,会提供专门的属性保证:
- 原子性(Atomicity):一个事务的多个操作必须完成,或者都不完成
- 一致性(Consistency):事务执行结束后,数据库的完整性约束没有被破坏,事务执行的前后顺序都是合法数据状态。
- 隔离性(Isolation):事务内部的操作与其他事务是隔离的,并发执行的各个事务之间不能互相干扰。
- 持久性(Durability):事务一旦提交,所有的修改将永久的保存到数据库中,即使系统崩溃重启后数据也不会丢失。
Redis 如何实现事务
MULTI、EXEC、DISCARD 和 WATCH 命令是 Redis 实现事务的的基础。
Redis 事务的执行过程包含三个步骤:
- 开启事务;
- 命令入队;
- 执行事务或丢弃;
Redis 事务满足 ACID?
Redis 的事务机制可以保证一致性和隔离性,但是无法保证持久性和原子性。
- 原子性
Redis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis 事务是不支持回滚(roll back)操作的。因此,Redis 事务其实是不满足原子性的(而且不满足持久性)。
Redis 官网也解释了自己为啥不支持回滚。简单来说就是 Redis 开发者们觉得没必要支持回滚,这样更简单便捷并且性能更好。Redis 开发者觉得即使命令执行错误也应该在开发过程中就被发现而不是生产过程中。
- 持久性
因为 Redis 是内存数据库,所以,数据是否持久化保存完全取决于 Redis 的持久化配置。
如果 Redis 没有使用 RDB 或 AOF,那么事务的持久化属性肯定得不到保证。如果 Redis 使用了 RDB 模式,那么,在一个事务执行后,下一次的 RDB 快照还未执行前,发生了实例宕机,这种情况下,事务修改的数据也是不能保证持久化的。如果 Redis 采用了 AOF 模式,因为 AOF 模式的三种配置选项 no、everysec 和 always 都会存在数据丢失的情况,所以,事务的持久性属性也还是得不到保证。
所以,不管 Redis 采用什么持久化模式,事务的持久性属性是得不到保证的。
- 隔离性
并发操作在 EXEC
命令前执行,隔离性需要通过 WATCH
机制保证;
WATCH 机制的作用是,在事务执行前,监控一个或多个键的值变化情况。当事务调用 EXEC 命令执行时,WATCH 机制会先检查监控的键是否被其它客户端修改了。如果修改了,就放弃事务执行,避免事务的隔离性被破坏。然后,客户端可以再次执行事务,此时,如果没有并发修改事务数据的操作了,事务就能正常执行,隔离性也得到了保证。
并发操作在 EXEC
命令之后,隔离性可以保证。
因为 Redis 是用单线程执行命令,而且EXEC
命令执行后,Redis 会保证先把命令队列中的所有命令执行完。所以,在这种情况下,并发操作不会破坏事务的隔离性。
- 一致性
EXEC 执行前报错,事务本身就会被放弃执行,所以可以保证数据库的一致性。
EXEC 执行后报错,由于报错的命令并没有去执行,只是执行了正确的命令。这种情况下,也是可以保证数据库的一致性的。
EXEC 执行时,Redis 发生故障,由于 Redis 实例故障,所以会有重启,这就和数据恢复的方式有关了。
下面,我们根据 Redis 实例是否开启 RDB 或 AOF 来分情况讨论下。
首先,我们没有开启 RDB 或 AOF,实例重启之后,数据就没有了,此时数据库是一致的。
如果我们用了 RDB快照,由于在事务执行的时候,是不会进行 RDB 快照的,所以,如果Redis实例故障,事务操作的命令是不会记录到 RDB 快照的,所以和上面一样,实例重启之后,数据库是一致的。
如果我们使用了 AOF 日志,当事务操作还没有被记录到 AOF 日志时,实例就发生了故障,那么,使用 AOF 日志恢复的数据库数据是一致的。如果只有部分操作被记录到了 AOF 日志,我们可以使用 redis-check-aof 清除事务中已经完成的操作,数据库恢复后也是一致的。
所以,综上,Redis 事务机制对一致性属性是有保证的。
缓存一致性
解决方案
先更新数据库,再删除缓存,并配合消息队列来解决缓存一直性的问题
大的方向有四:
- 更新MySQL即可,不管Redis,以过期时间兜底
使用redis的过期时间,mysql更新时,redis不做处理,等待缓存过期失效,再从mysql拉取缓存。
这种方式实现简单,但不一致的时间会比较明显,具体由你的业务来配置。如果读请求非常频繁,且过期时间设置较长,则会产生很多脏数据。
优点:
- redis原生接口,开发成本低,易于实现;
- 管理成本低,出问题的概率会比较小。
不足:
- 完全依赖过期时间,时间太短容易造成缓存频繁失效,太长容易有较长时间不一致,对编程者的业务能力,有一定要求。
- 更新MySQL之后,操作Redis
在方案一的基础上扩展,不光通过key的过期时间兜底,还需要在更新mysql时,同时尝试删除redis,如果删除成功,下次访问该数据,则会直接查询mysql的数据,此时再写入redis,就完成了数据同步。这里为什么说是尝试删除呢?因为有了key本身的过期时间作为保障,最终一致性是一定达成的,主动删除redis数据只是为了减少不一致的时间,但不能让其成为一个关键路径,影响核心流程。
优点:
- 相对方案一,达成最终一致性的延迟更小;
- 实现成本较低,只是在方案一的基础上,增加了删除逻辑。
不足:
- 如果更新mysql成功,删除redis却失败,就退化到了方案一;
- 在高并发场景,业务server需要和mysql、redis同时进行连接,这样是损耗双倍的连接资源,容易造成连接数过多的问题。
- 异步将MySQL的更新同步到Redis
从被动防守,到主动进攻,在更新mysql之后,redis也要更新,用消息队列!具体来说,是将更新操作交给消息队列,由消息队列保证可靠性,此外再搭建一个消费服务订阅消息队列,来异步更新redis数据。
优点:
- 使用消息队列,就相当于将请求投递至信箱,只要投递成功即完成任务,不用关心结果,实现了进一步解耦;
- 消息队列本身具有可靠性,在投递成功的前提下,通过手动提交等手段去消费,可以保证更新操作至少在redis中执行一次。
不足:
- 有时序性问题。举个栗子🌰,两台业务服务器在同一时间发出a = 1和a = 5两条请求,若mysql中先执行a=1再执行a=5,则mysql中a的值最终为5;但由于网络传输本身有延迟,所以无法保证两条请求谁先进入消息队列,最终redis的结果可能是1也可能是5,如果是1,mysql和redis中的数据就会产生不一致;
- 引入了消息队列,同时要增加消费服务,成本较高;
- 依旧要消耗更多客户端连接数的问题。
- 订阅日志,完全解耦
把我们搭建的消费服务作为mysql的一个slave,订阅mysql的binlog日志,解析日志内容,再更新到redis。此方案和业务完全解耦,redis的更新对业务方透明,可以减少心智成本。
优点:
- 在同步服务压力不大情况下,延迟较低;
- 和业务完全解耦,在更新mysql时,不需要做额外操作;
- 解决了时序性问题,可靠性强。
缺点:
- 要单独搭建一个同步服务,并且引入binlog同步机制,成本较大;
- 同步服务如果压力比较大,或者崩溃了,那么在较长时间内,redis中都是老旧数据。
方案选型
- 首先确认产品上对延迟性的要求,如果要求极高,且数据有可能变化,别用缓存。
- 通常来说,方案1就够了。牛牛咨询过4、5个团队,基本都是用方案1,因为使用缓存方案,通常是读多写少场景,同时业务上对延迟具有一定的包容性。方案1虽然有一定延时,但比较实用。
- 如果想增加更新时的即时性,就选择方案2,不过一定要注意,针对redis老数据的删除操作不要作为关键路径,影响核心流程。
- 方案3、方案4均适用于对延时要求比较高的业务,其区别为前者是推模式,后者是拉模式,而后者具有更强的可靠性,且无时序性问题。既然都愿意花功夫做处理消息的逻辑,不如一步到位,用方案4!
Cache-Aside Pattern:旁路缓存模式
应用服务把缓存当作数据库的旁路,直接和缓存进行交互,适合读请求比较多的场景。
读:从 cache 中读取数据,读取到就直接返回,cache 中读取不到的话,就从 db 中读取数据返回,再把数据放到 cache 中。
写 :先更新 db,然后直接删除 cache 。
在写数据的过程中,可以先删除 cache ,后更新 db 么?
可能会造成 数据库(db)和缓存(Cache)数据不一致的问题
在写数据的过程中,先更新 db,后删除 cache 就没有问题了么?
理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多。
Cache Aside Pattern 的缺陷。
- 缺陷 1:首次请求数据一定不在 cache 的问题
解决办法:可以将热点数据可以提前放入 cache 中。
- 缺陷 2:写操作比较频繁的话导致 cache 中的数据会被频繁被删除,这样会影响缓存命中率 。
解决办法:
- 数据库和缓存数据强一致场景 :更新 db 的时候同样更新 cache,不过我们需要加一个锁/分布式锁来保证更新 cache 的时候不存在线程安全问题。
- 可以短暂地允许数据库和缓存数据不一致的场景 :更新 db 的时候同样更新 cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小
Read/Write Through Cache Pattern:读写穿透模式
Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 db,从而减轻了应用程序的职责。
读(Read Through):
- 从 cache 中读取数据,读取到就直接返回
- 读取不到的话,cache 从 db 加载,写入到 cache 后返回响应。
写(Write Through):
- 先查 cache,cache 中不存在,直接更新 db;
- cache 中存在,则先更新 cache,然后 cache 服务自己更新 db。(同步更新 cache 和 db)
Read Through的优势是缓存对业务透明,业务代码更简洁。缺点是缓存命中时性能不如Cache Aside,相比直接访问缓存,还会多一次服务间调用。
在使用Write-Through时要特别注意的是缓存的有效性管理,否则会导致大量的缓存占用内存资源。甚至有效的缓存数据被无效的缓存数据给清除掉。
Through Cache 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 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。
缓存问题
缓存穿透
缓存和数据库中都没有的数据
解决方案
1.接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
2.缓存空值,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击
3.布隆过滤器。用于快速判某个元素是否存在于集合中,其典型的应用场景就是快速判断一个key是否存在于某容器,不存在就直接返回。布隆过滤器的关键就在于hash算法和容器大小,
缓存击穿
缓存中热点数据过期
解决方案
1.设置热点数据永远不过期。
2.接口限流与熔断,降级。重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些 服务 不可用时候,进行熔断,失败快速返回机制。
3.加互斥锁
缓存雪崩
缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机
解决方案
1.缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
2.如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中。
3.限流,避免同时处理大量请求
大key是什么?热key是什么?
大key是什么?在Redis中,大key指的是key对应的value值所占的内存空间比较大。
热key是什么?在Redis中,我们把访问频率高的Key,称为热Key。
如何识别出大key,如何识别出热key?
- redis-cli –bigkeys、redis-cli –hotkeys
- MEMORY USAGE key
- redis-rdb-tools工具
大key的标准一般是什么?热key一般是什么?
大key定义参考值如下:
1.string类型的key超过10KB
2.hash/set/zset/list等数据结构中元素个数大于5k/整体占用内存大于10MB
热key定义参考值如下:
以被请求频率来定义是否是热key,没有固定经验值。某个key被高频访问导致系统稳定性变差,都可以定义为热key。
QPS集中在特定的Key
带宽使用率集中在特定的Key
CPU使用时间占比集中在特定的Key
大key会带来什么问题?热key会带来什么问题?为什么会带来这些问题
内存使用不均匀。
- 例如在redis集群模式中,某个数据分片的内存使用率远超其他数据分片,无法使数据分片的内存资源达到均衡。另外也可能造成redis内存达到maxmemory参数定义的上限导致重要的Key被逐出,甚至引发内存溢出。
响应时间上升、超时阻塞。
- 由于redis是单线程架构,操作大key耗时较长,有可能造成redis阻塞。
过期时可能阻塞。
- 大key设定了过期时间,当过期时这个key会被删除。假如redis版本低于4.0没有非同步删除机制,就会存在阻塞redis的可能性,并且慢查询查不到;同样,内存不足时的key驱逐或者是rename一个大key也会阻塞redis服务。长时间阻塞主库,可能会引发同步中断或主从切换。
占用大量的CPU资源,影响其他请求并导致整体性能降低。
集群架构下,产生访问倾斜,即某个数据分片被大量访问,而其他数据分片处于空闲状态,可能引起该数据分片的连接数被耗尽,新的连接建立请求被拒绝等问题。
在抢购或秒杀场景下,可能因商品对应库存Key的请求量过大,超出Redis处理能力造成超卖。
热Key的请求压力数量超出Redis的承受能力易造成缓存击穿,即大量请求将被直接指向后端的存储层,导致存储访问量激增甚至宕机,从而影响其他业务。
大Key和热Key产生的原因
未正确使用Redis、业务规划不足、无效数据的堆积、访问量突增等都会产生大Key与热Key,如:
大key
在不适用的场景下使用Redis,易造成Key的value过大,如使用String类型的Key存放大体积二进制文件型数据;
业务上线前规划设计不足,没有对Key中的成员进行合理的拆分,造成个别Key中的成员数量过多;
未定期清理无效数据,造成如HASH类型Key中的成员持续不断地增加;
使用LIST类型Key的业务消费侧发生代码故障,造成对应Key的成员只增不减。
热key
- 预期外的访问量陡增,如突然出现的爆款商品、访问量暴涨的热点新闻、直播间某主播搞活动带来的大量刷屏点赞、游戏中某区域发生多个工会之间的战斗涉及大量玩家等。
有什么技术方案解决大key问题?有什么技术方案解决热key问题?
对大Key进行拆分
对大Key进行清理
监控Redis的内存水位,设置报警提醒
使用读写分离架构:如果热Key的产生来自于读请求,那么读写分离是一个很好的解决方案。在使用读写分离架构时可以通过不断的增加从节点来降低每个Redis实例中的读请求压力。
Redis集群扩容:增加分片副本,分摊客户端发过来的读请求。
热Key备份: 比如key,备份为key1,key2……keyN,同样的数据N个备份,N个备份分布到不同分片,访问时可随机访问N个备份中的一个,进一步分担读流量。
场景题
使用 Redis 统计网站 UV 怎么做?
通过 HyperLogLog 来实现
1、将访问指定页面的每个用户 ID 添加到 HyperLogLog
中。
127.0.0.1:6379> PFADD PAGE_1:UV USER1 USER2 USER3 # 添加元素
(integer) 1 # 添加成功返回1,添加失败返回0
127.0.0.1:6379> PFADD PAGE_2:UV USER4 USER5 # 添加元素
(integer) 1 # 添加成功返回1,添加失败返回0
2、统计指定页面的 UV。
127.0.0.1:6379> PFCOUNT PAGE_1:UV # 统计数量
(integer) 3 # 返回数量
3、将多个页面的UV合并
127.0.0.1:6379> PFMERGE PAGE_1:UV PAGE_2:UV # 将后面PAGE_2:UV中的值合并到前面的PAGE_1:UV 中
OK
127.0.0.1:6379> pfcount PAGE_1:UV # 查看merge后的PAGE_1:UV
(integer) 5
使用 Redis 实现抽奖系统?
通过 Set 来实现
SPOP key count
: 随机移除并获取指定集合中一个或多个元素,适合不允许重复中奖的场景。SRANDMEMBER key count
: 随机获取指定集合中指定数量的元素,适合允许重复中奖的场景。
使用 Redis 共同关注?
举例:共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集)、订阅号推荐(差集+交集) 等场景。
相关命令:SINTER
(交集)、SINTERSTORE
(交集)、SUNION
(并集)、SUNIONSTORE
(并集)、SDIFF
(差集)、SDIFFSTORE
(差集)。
使用 Redis 实现排行榜怎么做?
通过 Zset 来实现
Zset经常被用在各种排行榜的场景,比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。
相关的一些 Redis 命令: ZRANGE
(从小到大排序) 、 ZREVRANGE
(从大到小排序)、ZREVRANK
(指定元素排名)。
使用 Redis 实现签到怎么做?
通过 BitMap 来实现
针对签到功能完全可以通过MySQL来完成,用户一次签到,就是一条记录,假如有1000万用户,平均每人每年签到次数为10次,则这张表一年的数据量为1亿条,假设每次签到需要使用20字节的内存,一个月则需要600多字节。
我们如何能够简化一点呢?采用Redis的BitMap
我们按月来统计用户签到信息,把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,签到记录为1,未签到则记录为0。这样我们就用极小的空间,来实现了大量数据的表示。
Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是 2^32个bit位。
问题1:什么叫做连续签到天数?
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。
Java逻辑代码:获得当前这个月的最后一次签到数据,定义一个计数器,然后不停的向前统计,直到获得第一个非1的数字即可,每得到一个非0的数字计数器+1,直到遍历完所有的数据,就可以获得连续签到天数了
问题2:如何得到本月到今天为止的所有签到数据?
BITFIELD key GET u[dayOfMonth] 0
# u[dayOfMonth] 获取dayOfMonth个bit位
# 返回结果为10进制
假设今天是10号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是10号,那么就是10位,去拿这段时间的数据,就能拿到所有的数据了,那么这10天里边签到了多少次呢?统计有多少个1即可。
问题3:如何从后向前遍历每个bit位?
注意:BITFIELD返回的数据是10进制,假如说返回一个数字8,那么我哪儿知道到底哪些是0,哪些是1呢?
我们只需要让得到的10进制数字和1做与运算就可以了,因为1只有遇见1 才是1,其他数字都是0 ,我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,依次内推,我们就能完成逐个遍历的效果了。
BitMap的操作命令有:
- SETBIT:向指定位置(offset)存入一个0或1
- GETBIT :获取指定位置(offset)的bit值
- BITCOUNT :统计BitMap中值为1的bit位的数量
- BITFIELD :操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
- u 代表无符号位,i 代表有符号位
- BITFIELD_RO :获取BitMap中bit数组,并以十进制形式返回
- BITOP :将多个BitMap的结果做位运算(与 、或、异或)
- BITPOS :查找bit数组中指定范围内第一个0或1出现的位置
使用日期(精确到天)作为 key,然后本月的第几天为 offset,如果当日活跃过就设置为 1。初始化数据:
> SETBIT 20210308 1 1
(integer) 0
> SETBIT 20210308 2 1
(integer) 0
> SETBIT 20210309 1 1
(integer) 0
统计 20210308~20210309 总活跃用户数:
> BITOP and desk1 20210308 20210309
(integer) 1
> BITCOUNT desk1
(integer) 1
统计 20210308~20210309 在线活跃用户数:
> BITOP or desk2 20210308 20210309
(integer) 1
> BITCOUNT desk2
(integer) 2
实现分布式锁
setnx+过期时间+owner检测(分布式锁需要满足谁申请谁释放原则,不能释放别人的锁,也就是说,分布式锁,是要有归属的)+Lua(解决owner检测原子性)+主从容灾和多级部署
支付宝一面:如何基于Redis实现分布式锁? (qq.com)
秒杀
“秒杀系统需要保证:
- 高可用:服务器不因为大流量而崩溃,同时秒杀业务不影响其他业务。
- 高扩展,架构适合水平扩展,在特殊活动前能够迅速扩容。
- 一致性:商品不出现超卖和少卖的问题。”
“要保证上述三个性质,主要方案有三个:
- 合理使用消息队列,既可以解决并发安全问题,也可以进行业务解耦,方便水平扩展。
- 前后端的流量限制,将大部分的无效流量拦在服务器之前。
- 热门资源隔离,针对热门商品进行独立处理以及资源分配。
- 第一点,海量请求,服务要能扛住。
秒杀活动的主要思路是削峰、限流、异步、补偿。
异步这一步可以通过消息队列来实现,将抢和购解耦,还可以很方便地限频,不至于让MySQL过度承压。
我们可以先将库存名额预加载到Redis,然后在Redis中进行扣减,扣减成功的再通过消息队列,传递到MySQL做真正的订单生成。Redis可是单机支撑每秒几万的写入,并且可以做成集群,提高扩展能力的。
如果请求过多就要考虑使用多个Redis来分流,通过Nginx来进行负载均衡。
- 第二点,不能超卖。
Redis + Lua 解决售卖过程中的原子性问题,避免超卖
- 第三点,避免少卖。
库存减少了,但用户订单没生成。
第一种,也最简单的方式,在投递Kafka失败的情况下,增加渐进式重试;
第二种,更安全一点,就是在第一种的基础上,将这条消息记录在磁盘上,慢慢重试;
- 第四点,保证触达到用户而不是黄牛。
为了性能,我们还是将限制逻辑加入到Redis中,所以我们的Lua脚本中,第一步查询库存,第二步扣减库存。
优化:第一步查询库存,第二步查询用户已购买个数,第三步扣减库存,第四步记录用户购买数。
黄牛大多是通过代码来抢购,点击速度比人点击快得多,这样就导致了竞争不公平。怎么解决呢?
某个用户请求接口次数过于频繁,一般说明是用脚本在跑,可以只针对该用户做限制。
针对IP做限制也是常见做的做法,但这样容易误杀,主要考虑到使用同一个网络的用户,可能都是一个出口IP。限制IP,会导致正常用户也受到影响。
更好用的方案是加上一个验证码验证。验证码符合91原则,90%的时间,都用在验证码输入上,所以使用脚本点击的影响会降到很低。
海外兔是如何设计秒杀系统的 | 系统设计课程 (osjobs.net)
消息队列解决
发现普通架构 有 3 个潜在问题:
- 当一款商品库存只有 10 件却有 1 万名用户下单的时候,只有前 10 名客户可以下单成功,其他用户都浪费时间在队列等待以及无意义地查询库存,既牺牲了用户体验也增加了消息队列以及数据库的压力。
- 由于库存过少,有大量的请求(例如非法用户的请求,超过秒杀活动开始一定时间的请求)其实是没有机会抢到商品的,所以没有必要到达服务器,更不用说数据库了。
- 大量的客户端在下单前同时请求同一个商品的秒杀页面,导致服务器压力骤升。
针对这三个问题我们可以考虑两个方案:流量控制和资源隔离。
流量限制
第三个问题相对简单,可以将秒杀页面使用 CDN 缓存起来,客户端就可以直接从 CDN 获取到秒杀页面,不需要重复请求服务器。
另外两个问题可以通过流量限制来解决,可以通过限流器,负载均衡以及安全验证组件实现:
- 限流器分为前端限流与后端限流:
- 前端限流包括验证答题,防止重复点击按钮等常见机制。
- 后端限流使用限流算法进行流量限制,简单情况下可以使用固定限流算法,
- 例如秒杀商品的库存是 10 件,只要限流器接收到 10 * k(k 可以根据业务进行调整)个请求之后,就停止接受该商品的所有请求。
- 这样无论有多少个下单请求,最终到达服务器的单个商品请求数量都不超过 10 * k。
- 实际工程中,因为有客户可能会出现支付超时导致释放库存的情况,系统需要通知限流器接受新的请求。
- 负载均衡负责将下单请求通过负载均衡算法转发到最合适的服务器。
- 安全验证组件分为前端安全验证以及后端安全验证:
- 后端安全验证包括黑名单校验,IP 地址校验等机制。
- 前端安全验证包括:客户端账户验证(确保客户有资格参考秒杀活动),客户端版本安全验证(防止反编译以及修改客户端代码),秒杀接口动态生成(防止使用刷单脚本)等机制。
这时候系统的整体架构如下:
热门资源隔离
既然大部分流量集中在少量商品中,我们能不能针对这些商品进行特殊处理呢?这样既可以防止秒杀活动影响其他业务功能,也可以针对热门商品进行资源分配,答案是可以的,首先我们需要识别出热门商品,这里有两种常见的方法:
- 静态识别:包括京东在内的一些电商平台,客户在参加秒杀活动之前需要先进行预约,只有预约过的客户才能参考秒杀活动。这样系统可以提早识别热门商品以及进行流量预估。
- 动态识别:通过实时数据分析系统在秒杀活动前统计出现在较多客户浏览的热门商品,针对预估结果进行资源分配。
识别出热门商品之后,我们可以将热门商品的资源进行隔离,并且设置独立的策略,例如
- 使用特殊的限流器,由于秒杀系统的库存很少,在下单请求开始阶段就可以随机丢弃大部分请求。
- 使用单独的数据库,在架构 2 中,下单请求的处理速度受限于消费者的处理速度,也就是数据库的处理速度。我们可以对热门商品进行分库分表,这样可以将请求处理的压力分摊到多个数据库中。下图中,我们将秒杀系统的一些组件独立开来:
根据以上两个方案,我们可以设计出最后的架构 :
- 客户端从 CDN 获取到秒杀页面
- 客户端发送下单请求给网关
- 在网关或者服务器前进行流量控制以及负载均衡等策略
- 服务端将请求发送到消息队列
- 数据库每次从消息队列取出请求
- 若该商品库存大于零,将库存减一
- 若该商品库存等于零的话,不做操作
- 服务端根据消息队列里的消息状态返回下单结果
秒杀问题合集
1.“应该在什么时候扣除库存,是下单后扣除库存还是支付后扣除库存呢?为什么?”
应该在下单的时候扣除库存,如果在支付成功再扣除库存的话会出现下单请求成功数量大于库存的情况。
2.“对秒杀商品进行分库分表之后可能导致某个分表库存为零,但其他分表还有库存,如何解决这个问题?”
“有三种解决方案:
- 如果当前分表没有库存的话,到其他分表进行重试,缺点是会放大流量。
- 通过路由组件记录每个分表的库存情况,将下单请求转发到有库存的分表中。
- 使用分布式缓存记录每个分表的库存情况,并且每次下单请求只更新缓存,缓存后续再更新到数据库中,缺点是可能出现缓存和数据库不一致的问题。”
3.“客户下单后可能支付超时并释放库存,这时候有哪些要注意的?”
“服务器能够通知限流器以及前端库存发生变化,限流器能够重新接收请求,前端页面显示可下单的页面,确保后续的用户能继续购买商品。”
“消息队列方案有什么潜在问题吗?”
“秒杀系统下,可能 80% 的流量都指向同一个热门商品,那么消息队列中的分区会特别大,影响了两个方面 1)消息队列本身的稳定性,吞吐量会受单个分区限制,也可能影响其他业务。2)下单请求受到消费者消费能力的限制,即使消息队列每秒可以处理大量消息,但是数据库每秒处理的数量有限。可以使用以下几种方案:
- 压力测试:在前期压力测试的时候,模拟流量极端分布的情况,确保现有架构能够支持服务。
- 资源隔离:对秒杀商品使用独立的消息队列,使用特殊的流量限流策略,配置更好的资源。
- 合并下单请求:将多个下单请求合并成一个请求,再交给数据库处理。不过在实际工程中,下单业务可能比较复杂,不只包含扣减库存。所以合并逻辑会影响后续业务的可扩展性。
- 合并事务:将多个事务合并成一个事务执行,这样能有效减少数据库压力,缺点是逻辑会比较复杂,而且一个事务执行失败会影响多个订单。
“消息队列怎么保证消息有且仅生效一次(Exactly Once)?”
- 为了保证最少一次生效, 消费者需要下单成功后才能返回确认 ACK,否则有可能会丢失消息。
- 为了防止消息重复消费的问题,需要使下单逻辑变为幂等操作,常见的解决方案是保证下单请求有全局唯一的 ID,并在消息队列中对 ID 进行持久化,在发送给消费者之前先检查 ID 是否已经消费过。要注意中间层的重试机制不要修改这个全局唯一的 ID,不然会导致消息队列误以为该消息没有消费过。
“消息队列如何保证消息有序/分布式事务一致性/高可用?”
请参考国内外云平台文档的使用场景以及最佳实践:https://cloud.tencent.com/product/tdmq
“如何正确地实现分布式锁?”
了解 SETNX 的局限性以及 RedLock 的基本原理,具体请参考 https://redis.io/topics/distlock
“分布式锁和数据库悲观锁相比有什么优势?有什么共同的缺点?”
- 优点:加锁的操作不依赖数据库,降低数据库资源冲突的概率和压力。
- 共同缺点:可扩展性差,对于单个商品都是串行操作,假如每个订单执行要 100ms,每秒只能执行 10 个对应的订单,可能会出现大量请求阻塞的情况。
“如何保证缓存和数据库的一致性?”
请参考:https://www.pixelstech.net/article/1562504974-Consistency-between-Redis-Cache-and-SQL-Database
“如果电商系统流量过大,如何进行降级服务?”
- 暂停非核心业务:例如淘宝在双十一会暂时关闭退款功能。
- 拒绝服务:当系统压力到达一个阈值的的时候,随机丢弃部分秒杀请求。
- 减少重试:将重试次数降低甚至设置为0,否则容易造成雪崩效应,系统陷入负反馈循环,无法正常恢复。
“怎么测试你的方案,使用最小的资源实现一个稳定的秒杀系统?”
需要分析系统可能出现的瓶颈,并提出优化手段。
“上面的方案有哪些是需要人工运营的,有没有办法将它自动化?”
可以从自己熟悉的领域回答,例如分库分表,自动扩容,自动化测试等方向。
“你的方案还有哪些可以优化的地方?”
首先需要了解不同方案的优缺点,例如乐观锁与悲观锁的优缺点,锁机制与消息队列的优缺点。然后根据不同的基础架构,流量分布以及业务读写比例调整方案。
集群
主从复制
单机 Redis 存在单点风险问题,也就是说,如果唯一的一个 Redis 节点宕机的话,就会导致大量的请求直接被打到数据库,严重的情况下,数据库很可能会直接被干宕机了。
Redis集群的主从复制模型是怎样的?
主从复制 就是将一台 Redis 主节点的数据复制到其他的 Redis 从节点中,尽最大可能保证 Redis 主节点和从节点的数据是一致的。主节点负责写,从节点负责读,如果主节点宕机,就从节点中选出一个作为主节点
slaveof ip port # ip port 是 master 的
# 从 Redis 5 起使用 REPLICAOF 替代 SLAVEOF 。当然,为了向后兼容 SLAVEOF 命令仍然可用。
# 在指定 redis 节点上执行 replicaof 命令就可以让其成为 master 的 slave
# 查看信息
info replication
# 不同步
SLAVEOF no one
- 全量复制的三个阶段?
- 为什么会设计增量复制?
- 增量复制的流程? 如果在网络断开期间,repl_backlog_size环形缓冲区写满之后,从库是会丢失掉那部分被覆盖掉的数据,还是直接进行全量复制呢?
- 为什么不持久化的主服务器自动重启非常危险呢?
- 为什么主从全量复制使用RDB而不使用AOF?
- 为什么还有无磁盘复制模式?
- 为什么还会有从库的从库的设计?
哨兵机制-Sentinel
普通的主从复制方案下,一旦 master 宕机,我们需要从 slave 中手动选择一个新的 master,同时需要修改应用方的主节点地址,还需要命令所有从节点去复制新的主节点,整个过程需要人工干预。人工干预大大增加了问题的处理时间以及出错的可能性。
什么是 Sentinel? 有什么用?
Redis Sentinel 实现 Redis 集群高可用,只是在主从复制基础下,多了一个 Sentinel 角色来监控 Redis 节点的运行状态并自动实现故障转移。当 master 节点出现故障的时候, Sentinel 会帮助我们实现故障转移,自动根据一定的规则选出一个 slave 升级为 master,确保整个 Redis 系统的可用性。整个过程完全自动,不需要人工介入。
- 监控:监控所有 redis 节点(包括 sentinel 节点自身)的状态是否正常。
- 故障转移:如果一个 master 出现故障,Sentinel 会帮助我们实现故障转移,自动将某一台 slave 升级为 master,确保整个 Redis 系统的可用性。
- 通知 :通知 slave 新的 master 连接信息,让它们执行 replicaof 成为新的 master 的 slave。
- 配置提供 :客户端连接 sentinel 请求 master 的地址,如果发生故障转移,sentinel 会通知新的 master 链接信息给客户端。
为什么建议部署多个 sentinel 节点(哨兵集群)?
- 多个 sentinel 节点通过投票的方式来确定节点是否真的不可用,避免误判(比如网络问题可能会导致误判)。
- Sentinel 自身就是高可用。
Sentinel 如何检测节点是否下线?主观下线与客观下线的区别?
主观下线(SDOWN) :sentinel 节点认为某个 Redis 节点已经下线了(主观下线),但还不是很确定,需要其他 sentinel 节点的投票。
客观下线(ODOWN) :法定数量(通常为过半)的 sentinel 节点认定某个 Redis 节点已经下线(客观下线),那它就算是真的下线了。
也就是说,主观下线 当前的 sentinel 自己认为节点宕机,客观下线是 sentinel 整体达成一致认为节点宕机。
Sentinel 是如何实现故障转移的?
每个 sentinel 节点以每秒钟一次的频率向整个集群中的 master、slave 以及其他 sentinel 节点发送一个 PING 命令。如果对应的节点超过规定的时间没有进行有效回复的话,就会被其认定为是 主观下线 。
如果被认定为主观下线的是 slave 的话, sentinel 不会做什么事情,因为 slave 下线对 Redis 集群的影响不大,Redis 集群对外正常提供服务。但如果是 master 被认定为主观下线就不一样了,sentinel 整体还要对其进行进一步核实,确保 master 是真的下线了。
所有 sentinel 节点要以每秒一次的频率确认 master 的确下线了,当法定数量(通常为过半)的 sentinel 节点认定 master 已经下线, master 才被判定为 客观下线(ODOWN) 。这样做的目的是为了防止误判,毕竟故障转移的开销还是比较大的,这也是为什么 Redis 官方推荐部署多个 sentinel 节点(哨兵集群)。
随后, sentinel 中会有一个 Leader 的角色来负责故障转移,也就是自动地从 slave 中选出一个新的 master 并执行完相关的一些工作(比如通知 slave 新的 master 连接信息,让它们执行 replicaof 成为新的 master 的 slave)。
如果没有足够数量的 sentinel 节点认定 master 已经下线的话,当 master 能对 sentinel 的 PING 命令进行有效回复之后,master 也就不再被认定为主观下线,回归正常。
Sentinel 如何选择出新的 master(选举机制)?
slave 必须是在线状态才能参加新的 master 的选举,筛选出所有在线的 slave 之后,通过下面 3 个维度进行最后的筛选(优先级依次降低):
- slave 优先级 :可以通过修改
slave-priority
(redis.conf中配置,Redis5.0 后该配置属性名称被修改为 replica-priority) 配置的值来手动设置 slave 的优先级。slave-priority 默认值为 100,其值越小得分越高,越有机会成为 master。- 比如说假设有三个优先级分别为 10,100,25 的 slave ,哨兵将选择优先级为 10 的。不过,0 是一个特殊的优先级值 ,如果一个 slave 的 slave-priority值为 0,代表其没有参加 master 选举的资格。如果没有优先级最高的,再判断复制进度。
- 复制进度 :Sentinel 总是希望选择出数据最完整(与旧 master 数据最接近)也就是复制进度最快的 slave 被提升为新的 master,复制进度越快得分也就越高。
- runid(运行 id) :通常经过前面两轮筛选已经成功选出来了新的 master,万一真有多个 slave 的优先级和复制进度一样的话,那就 runid 小的成为新的 master,每个 redis 节点启动时都有一个 40 字节随机字符串作为运行 id。
如何从 Sentinel 集群中选择出 Leader ?
当 sentinel 集群确认有 master 客观下线了,就会开始故障转移流程,故障转移流程的第一步就是在 sentinel 集群选择一个 leader,让 leader 来负责完成故障转移。 如何选择出 Leader 角色呢?
这就需要用到分布式领域的 共识算法 了。简单来说,共识算法就是让分布式系统中的节点就一个问题达成共识。在 sentinel 选举 leader 这个场景下,这些 sentinel 要达成的共识就是谁才是 leader 。
大部分共识算法都是基于 Paxos 算法改进而来,在 sentinel 选举 leader 这个场景下使用的是 Raft 算法。这是一个比 Paxos 算法更易理解和实现的共识算法。更具体点来说,Raft 是 Multi-Paxos 的一个变种,其简化了 Multi-Paxos 的思想,变得更容易被理解以及工程实现。
Sentinel 可以防止脑裂吗?
如果 主节点1 和 从节点2、从节点3 之间的网络被隔离,也就是发生了脑裂,主节点1 和 从节点2、从节点3 隔离在了两个不同的网络分区中。这意味着, 从节点2 或者 从节点3 其中一个会被选为 master,这里假设为 从节点2。
但是!这样会出现问题了!!
如果客户端 C1 是和 主节点1 在一个网络分区的话,从网络被隔离到网络分区恢复这段时间,C1 写入 主节点1 的数据都会丢失,并且,C1 读取的可能也是过时的数据。这是因为当网络分区恢复之后,主节点1 将会成为 slave 节点。
想要解决这个问题需要对 Redis 主从复制进行配置。
min-replicas-to-write 1
min-replicas-max-lag 10
下面对这两个配置进行解释:
- min-replicas-to-write 1:用于配置写 master 至少写入的 slave 数量,设置为 0 表示关闭该功能。3 个节点的情况下,可以配置为 1 ,表示 master 必须写入至少 1 个 slave ,否则就停止接受新的写入命令请求。
- min-replicas-max-lag 10 :用于配置 master 多长时间(秒)无法得到从节点的响应,就认为这个节点失联。我们这里配置的是 10 秒,也就是说 master 10 秒都得不到一个从节点的响应,就会认为这个从节点失联,停止接受新的写入命令请求。
不过,这样配置会降低 Redis 服务的整体可用性,如果 2 个 slave 都挂掉,master 将会停止接受新的写入命令请求。
集群-Cluster
为什么需要 Redis Cluster?解决了什么问题?有什么优势?
高并发场景下,使用 Redis 主要会遇到的两个问题:
- 缓存的数据量太大 :实际缓存的数据量可以达到几十 G,甚至是成百上千 G;
- 并发量要求太大 :虽然 Redis 号称单机可以支持 10w 并发,但实际项目中,不可靠因素太多,就比如一些复杂的写/读操作就可能会让这个并发量大打折扣。而且,就算真的可以实际支持 10w 并发,达到瓶颈了,可能也没办法满足系统的实际需求。
主从复制和 Redis Sentinel 这两种方案本质都是通过增加主库(master)的副本(slave)数量的方式来提高 Redis 服务的整体可用性和读吞吐量,都不支持横向扩展来缓解写压力以及解决缓存数据量过大的问题。
对于这两种方案来说,如果写压力太大或者缓存数据量太大的话,我们可以考虑提高服务器硬件的配置。不过,提高硬件配置成本太高,能力有限,无法动态扩容缩容,局限性太大。从本质上来说,靠堆硬件配置的方式并没有实质性地解决问题,依然无法满足高并发场景下分布式缓存的要求。
通常情况下,更建议使用 Redis 切片集群 这种方案,更能满足高并发场景下分布式缓存的要求。
简单来说,Redis 切片集群 就是部署多台 Redis 主节点(master),这些节点之间平等,并没有主从之说,同时对外提供读/写服务。缓存的数据库相对均匀地分布在这些 Redis 实例上,客户端的请求通过路由规则转发到目标 master 上。
为了保障集群整体的高可用,我们需要保证集群中每一个 master 的高可用,可以通过主从复制给每个 master 配置一个或者多个从节点(slave)。
Redis 切片集群对于横向扩展非常友好,只需要增加 Redis 节点到集群中即可。
Redis Cluster 这种方案可以很方便地进行 横向拓展(Scale Out),内置了开箱即用的解决方案。当 Redis Cluster 的处理能力达到瓶颈无法满足系统要求的时候,直接动态添加 Redis 节点到集群中即可。
总结一下 Redis Cluster 的主要优势:
- 可以横向扩展缓解写压力和存储压力,支持动态扩容和缩容;
- 具备主从复制、故障转移(内置了 Sentinel 机制,无需单独部署 Sentinel 集群)等开箱即用的功能。
Redis Cluster 是如何分片的?
Redis Cluster 中的数据是如何分布的?
如何确定给定 key 的应该分布到哪个哈希槽中?
Redis Cluster 并没有使用一致性哈希,采用的是哈希槽分区 ,每一个键值对都属于一个 hash slot(哈希槽) 。
Redis Cluster 通常有 16384 个哈希槽 ,要计算给定 key 应该分布到哪个哈希槽中,我们只需要先对每个 key 计算 CRC-16(XMODEM) 校验码,然后再对这个校验码对 16384(哈希槽的总数) 取模,得到的值即是 key 对应的哈希槽。
哈希槽的计算公式如下:
HASH_SLOT = CRC16(key) mod NUMER_OF_SLOTS
创建并初始化 Redis Cluster 的时候,Redis 会自动平均分配这 16384 个哈希槽到各个节点,不需要我们手动分配。如果你想自己手动调整的话,Redis Cluster 也内置了相关的命令比如 ADDSLOTS、ADDSLOTSRANGE
假设集群有 3 个 Redis 节点组成,每个节点负责整个集群的一部分数据,哈希槽可能是这样分配的(这里只是演示,实际效果可能会有差异):
- Node 1 : 0 - 5500 的 hash slots
- Node 2 : 5501 - 11000 的 hash slots
- Node 3 : 11001 - 16383 的 hash slots
在任意一个 master 节点上执行 CLUSTER SLOTS
命令即可返回哈希槽和节点的映射关系
客户端连接 Redis Cluster 中任意一个 master 节点即可访问 Redis Cluster 的数据,当客户端发送命令请求的时候,需要先根据 key 通过上面的计算公示找到的对应的哈希槽,然后再查询哈希槽和节点的映射关系,即可找到目标节点。
如果哈希槽确实是当前节点负责,那就直接响应客户端的请求返回结果,如果不由当前节点负责,就会返回 \-MOVED
重定向错误,告知客户端当前哈希槽是由哪个节点负责,客户端向目标节点发送请求并更新缓存的哈希槽分配信息。
Redis Cluster 哈希槽分区机制的优点:解耦了数据和节点之间的关系,提升了集群的横向扩展性和容错性。
一致性哈希
简述一致性哈希算法的实现方式及原理
对于分布式存储,不同机器上存储不同对象的数据,我们使用哈希函数建立从数据到服务器之间的映射关系
一致性哈希算法是分布式系统中常用的算法,比如有N台缓存服务器,你需要将数据缓存到这N台服务器上。一致性哈希算法可以将数据尽可能平均的存储到N台缓存服务器上,提高系统的负载均衡,并且当有缓存服务器加入或退出集群时,尽可能少的影响现有缓存服务器的命中率,减少数据对后台服务的大量冲击。
目前主要应用于分布式缓存当中,一致性哈希可以有效地解决分布式存储结构下动态增加和删除节点
聊聊一致性哈希算法,什么是一致性哈希算法,图解面试热点问题,还看不懂你来打我 - 知乎 (zhihu.com)
为什么 Redis Cluster 的哈希槽是 16384 个?
CRC16 算法产生的校验码有 16 位,理论上可以产生 65536(2^16,0 ~ 65535)个值。为什么 Redis Cluster 的哈希槽偏偏选择的是 16384(2^14)个呢?
- 哈希槽太大会导致心跳包太大,消耗太多带宽;
- 哈希槽总数越少,对存储哈希槽信息的 bitmap 压缩效果越好;
- Redis Cluster 的主节点通常不会扩展太多,16384 个哈希槽已经足够用了。
Redis Cluster 如何手动重新分配哈希槽?
。。。
Redis Cluster 扩容缩容期间可以提供服务吗?
如果客户端访问的 key 所属的槽正在迁移怎么办?
Redis Cluster 扩容和缩容本质是进行重新分片,动态迁移哈希槽。
为了保证 Redis Cluster 在扩容和缩容期间依然能够对外正常提供服务,Redis Cluster 提供了重定向机制
- 1、客户端发送请求命令,如果请求的 key 对应的哈希槽还在当前节点的话,就直接响应客户端的请求。
- 2、如果客户端请求的 key 对应的哈希槽当前正在迁移至新的节点,就会返回 ASK 重定向错误,告知客户端要将请求发送到哈希槽被迁移到的目标节点。
Redis Cluster 中的节点是怎么进行通信的?
Redis Cluster 中的各个节点基于 Gossip 协议 来进行通信共享信息,每个 Redis 节点都维护了一份集群的状态信息。
❤️Sponsor
您的支持是我不断前进的动力,如果您感觉本文对您有所帮助的话,可以考虑打赏一下本文,用以维持本博客的运营费用,拒绝白嫖,从你我做起!🥰🥰🥰
支付宝 | 微信 |