主要内容出自:
Java知识体系最强总结(2020版)
CS-Notes
一、Redis与Memcached对比
对比项 | Redis | Memcached |
---|---|---|
数据类型 | 5种 | 只支持最简单的 k/v 数据类型 |
持久化 | RDB 快照和 AOF 日志 | 不支持 |
集群模式 | 原生支持 cluster 模式 | 没有原生的集群模式,需要依靠客户端来实现往集群中分片读写数据(hash取模) |
网络IO模型 | 单线程IO多路复用模型 | 多线程非阻塞IO模式 |
附加功能 | 1. 发布/订阅模式 2.主从分区 3. 序列化支持 4. LUA脚本支持 | 多线程服务支持 |
内存管理机制 | 在 Redis 中,并不是所有数据都一直存储在内存中,可以将一些很久没用的数据交换到磁盘 | Memcached 的数据则会一直在内存中,Memcached 将内存分割成特定长度的块来存储数据,以完全解决内存碎片的问题。但是这种方式会使得内存的利用率不高,例如块的大小为 128 bytes,只存储 100 bytes 的数据,那么剩下的 28 bytes 就浪费掉了。 |
过期数据删除 | 惰性删除+定期删除 | 惰性删除 |
二、Redis数据类型及应用场景
数据类型 | 可以存储的值 | 操作 | 底层数据结构 |
---|---|---|---|
string | 字符串、整数或者浮点数 | 对整个字符串或者字符串的其中一部分执行操作 对整数和浮点数执行自增或者自减操作 | 整数值、SDS |
list | 列表 | 从两端压入或者弹出元素 对单个或者多个元素进行修剪,只保留一个范围内的元素 | 双向链表、压缩列表 |
set | 无序集合 | 添加、获取、移除单个元素 检查一个元素是否存在于集合中 计算交集、并集、差集 从集合里面随机获取元素 | 字典、整数集合 |
hash | 包含键值对的无序散列表 | 添加、获取、移除单个键值对 获取所有键值对 检查某个键是否存在 | 字典、压缩列表 |
zset | 有序集合 | 添加、获取、删除元素 根据分值范围或者成员来获取元素 计算一个键的排名 | 跳跃表、字典、压缩列表 |
计数器
可以对 String 进行自增自减运算,从而实现计数器功能。
Redis 这种内存型数据库的读写性能非常高,很适合存储频繁读写的计数量。
缓存
将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率。
会话缓存
可以使用 Redis 来统一存储多台应用服务器的会话信息。
当应用服务器不再存储用户的会话信息,也就不再具有状态,一个用户可以请求任意一个应用服务器,从而更容易实现高可用性以及可伸缩性。
消息队列(发布/订阅功能)
List 是一个双向链表,可以通过 lpush 和 rpop 写入和读取消息
不过最好使用 Kafka、RabbitMQ 等消息中间件。
分布式锁实现
在分布式场景下,无法使用单机环境下的锁来对多个节点上的进程进行同步。
可以使用 Redis 自带的 SETNX
命令实现分布式锁,除此之外,还可以使用官方提供的 RedLock
分布式锁实现。
其它
Set 可以实现交集、并集等操作,从而实现共同好友等功能。
ZSet 可以实现有序性操作,从而实现排行榜等功能。
底层数据结构
Redis 原理及应用(1)–数据类型及底层实现方式
1、简单动态字符串(SDS)
/*
* 保存字符串对象的结构
*/
struct sdshdr {
// buf 中已占用空间的长度
int len;
// buf 中剩余可用空间的长度
int free;
// 数据空间
char buf[];
};
SDS的优点:
- 获取字符串长度(SDS O(1))
- 防止缓冲区溢出
- 预分配策略减少扩展或收缩字符串带来的内存重分配次数
- 二进制安全:根据len判断长度,可以存空字符
2、跳跃表
Redis 只在两个地方用到了跳跃表,一个是实现有序集合键(sorted Sets),另外一个是在集群节点中用作内部数据结构。
其实跳表主要是来替代平衡二叉树的,比起平衡树来说,跳表的实现要简单直观的多。
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速查找访问节点的目的。
跳跃表是查找、删除、添加等操作都可以在O(logn)期望时间下完成。
typedef struct zskiplist {
//表头节点和表尾节点
structz skiplistNode *header,*tail;
//表中节点数量
unsigned long length;
//表中层数最大的节点的层数
int level;
}zskiplist
typedef struct zskiplistNode{
//层
struct zskiplistLevel{
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned int span;
} level[];
//后退指针
struct zskiplistNode *backward;
//分值
double score;
//成员对象
robj *obj;
}
1、层: level 数组可以包含多个元素,每个元素都包含一个指向其他节点的指针。level数组的每个元素都包含:前进指针:用于指向表尾方向的前进指针,跨度:用于记录两个节点之间的距离
2、后退指针:用于从表尾向表头方向访问节点
3、分值和成员:跳跃表中的所有节点都按分值从小到大排序(按照这个进行排序的,也就是平衡二叉树(搜索树的)的节点大小)。成员对象指向一个字符串,这个字符串对象保存着一个SDS值(实际存储的值)
跳跃表是基于多指针有序链表实现的,可以看成多个有序链表。
在查找时,从上层指针开始查找,找到对应的区间之后再到下一层去查找。下图演示了查找 22 的过程。
与红黑树等平衡树相比,跳跃表具有以下优点:
- 插入速度非常快速,因为不需要进行旋转等操作来维护平衡性;
- 更容易实现;
- 支持无锁操作。
3、整数集合
《Redis 设计与实现》 中这样定义整数集合:“整数集合是集合建(sets)的底层实现之一,当一个集合中只包含整数,且这个集合中的元素数量不多时,redis就会使用整数集合intset作为集合的底层实现。”
可以这样理解整数集合,他其实就是一个特殊的集合,里面存储的数据只能够是整数,并且数据量不能过大。
typedef struct intset{
//编码方式
uint32_t enconding;
// 集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}
整数集合的底层实现为数组,这个数组以有序,无重复的范式保存集合元素,在有需要时,程序会根据新添加的元素类型改变这个数组的类型.
4、压缩列表
压缩列表用于元素个数少、元素长度小的场景。其优势在于集中存储,节省空间。
- zlbytes:用于记录整个压缩列表占用的内存字节数
- zltail:记录要列表尾节点距离压缩列表的起始地址有多少字节
- zllen:记录了压缩列表包含的节点数量。
- entryX:要说列表包含的各个节点
- zlend:用于标记压缩列表的末端
压缩列表是一种为了节约内存而开发的顺序型数据结构
压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值
添加新节点到压缩列表,可能会引发连锁更新操作。
因为普通链表节点的内存是随机分配的, 占用的内存是零星的,如果是大量数据的话使用这个好, 但是如果是少量数据的话这样比较浪费空间, 而压缩列表使用的内存是连续的, 在少量数据的时候使用压缩列表节约了一定的内存
三、过期键的删除策略
对于散列表这种容器,只能为整个散列表设置过期时间,而不能为散列表里面的单个元素设置过期时间。
1、立即删除
在设置键的过期时间时,创建一个回调事件,当过期时间达到时,由时间处理器自动执行键的删除操作。
立即删除能保证内存中数据的最大新鲜度,因为它保证过期键值会在过期后马上被删除,其所占用的内存也会随之释放。
但是立即删除对cpu是最不友好的。因为删除操作会占用cpu的时间,如果刚好碰上了cpu很忙的时候,比如正在做交集或排序等计算的时候,就会给cpu造成额外的压力。
2、惰性删除
键过期之后不管它。每次从dict字典中按key取值时,先检查此key是否已经过期,如果过期了就删除它,并返回nil,如果没过期,就返回键值。
惰性删除的缺点:浪费内存。dict字典和expires字典都要保存这个键值的信息。且对于一些后续不经常访问到的数据不能够及时删除。
3、定期删除
每隔一段时间,对expires字典进行检查,删除里面的过期键。
- 通过限制删除操作执行的时长和频率,来减少删除操作对cpu的影响。
- 定时删除也有效的减少了因惰性删除带来的内存浪费。
redis使用的过期键值删除策略是:惰性删除加上定期删除,两者配合使用。
4、对应的数据结构
Redis 通过一个过期字典(expires
,可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。
过期字典是存储在 redisDb 这个结构里的:
typedef struct redisDb {
...
dict *dict; //数据库键空间,保存着数据库中所有键值对
dict *expires // 过期字典,保存着键的过期时间
...
} redisDb;
Redis 的字典 dict 中包含两个哈希表 dictht,这是为了方便进行 rehash 操作。在扩容时,将其中一个 dictht 上的键值对 rehash 到另一个 dictht 上面,完成之后释放空间并交换两个 dictht 的角色。
dictht 是一个散列表结构,使用拉链法解决哈希冲突。
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;
rehash 操作不是一次性完成,而是采用渐进方式,这是为了避免一次性执行过多的 rehash 操作给服务器带来过大的负担。
渐进式 rehash 通过记录 dict 的 rehashidx 完成,它从 0 开始,然后每执行一次 rehash 都会递增。例如在一次 rehash 中,要把 dict[0] rehash 到 dict[1],这一次会把 dict[0] 上 table[rehashidx] 的键值对 rehash 到 dict[1] 上,dict[0] 的 table[rehashidx] 指向 null,并令 rehashidx++。
在 rehash 期间,每次对字典执行添加、删除、查找或者更新操作时,都会执行一次渐进式 rehash。
采用渐进式 rehash 会导致字典中的数据分散在两个 dictht 上,因此对字典的查找操作也需要到对应的 dictht 去执行。
四、数据淘汰策略
可以设置内存最大使用量,当内存使用量超出时,会施行数据淘汰策略。
作为内存数据库,出于对性能和内存消耗的考虑,Redis 的淘汰算法实际实现上并非针对所有 key,而是抽样一小部分并且从中选出被淘汰的 key。
策略 | 描述 | 应用场景 |
---|---|---|
volatile-lru | 从已设置过期时间的数据集中挑选最近最少使用的数据淘汰 | 如果设置了过期时间,且分热数据与冷数据,推荐使用 volatile-lru 策略。 |
volatile-ttl(time to live) | 从已设置过期时间的数据集中挑选将要过期的数据淘汰 | 如果让 Redis 根据过期时间来筛选需要删除的key,请使用 volatile-ttl 策略。 |
volatile-random | 从已设置过期时间的数据集中任意选择数据淘汰 | 很少使用 |
allkeys-lru | 从所有数据集中挑选最近最少使用的数据淘汰 | 使用 Redis 缓存数据时,为了提高缓存命中率,需要保证缓存数据都是热点数据。可以将内存最大使用量设置为热点数据占用的内存量,然后启用 allkeys-lru 淘汰策略,将最近最少使用的数据淘汰。最常用 值得一提的是,设置 expire 会消耗额外的内存,所以使用 allkeys-lru 策略,可以更高效地利用内存,因为这样就可以不再设置过期时间了。 |
allkeys-random | 从所有数据集中任意选择数据进行淘汰 | 如果需要循环读写所有的key,或者各个key的访问频率差不多,可以使用 allkeys-random 策略 |
no-eviction | 不删除策略,达到最大内存限制时,如果需要更多内存,直接返回错误信息。大多数写命令都会导致占用更多的内存 | 很少使用 |
volatile-lfu | LFU 策略通过统计访问频率,将访问频率最少的键值对淘汰 | Redis 4.0 引入 |
allkeys-lfu | LFU 策略通过统计访问频率,将访问频率最少的键值对淘汰 | Redis 4.0 引入 |
五、Redis持久化
Redis 提供了RDB和AOF两种持久化方式。默认是只开启RDB,当Redis重启时,它会优先使用AOF文件来还原数据集。
1、RDB持久化(快照持久化)
RDB 持久化:将某个时间点的所有数据都存放到硬盘上。
快照持久化是Redis默认采用的持久化方式
- 可以将快照复制到其它服务器从而创建具有相同数据的服务器副本。如果数据量很大,保存快照的时间会很长。
- 如果系统发生故障,将会丢失最后一次创建快照之后的数据。
- 快照持久化只适用于即使丢失一部分数据也不会造成一些大问题的应用程序。不能接受这个缺点的话,可以考虑AOF持久化。
创建快照的方式:
-
BGSAVE命令 :客户端向Redis发送 BGSAVE命令 来创建一个快照。对于支持BGSAVE命令的平台来说(基本上所有平台支持,除了Windows平台),Redis会调用fork来创建一个子进程,然后子进程负责将快照写入硬盘,而父进程则继续处理命令请求。
-
SAVE命令 :客户端还可以向Redis发送 SAVE命令 来创建一个快照,接到SAVE命令的Redis服务器在快照创建完毕之前不会再响应任何其他命令。SAVE命令不常用,我们通常只会在没有足够内存去执行BGSAVE命令的情况下,又或者即使等待持久化操作执行完毕也无所谓的情况下,才会使用这个命令。
-
save选项 :如果用户设置了save选项(一般会默认设置),比如 save 60 10000,那么从Redis最近一次创建快照之后开始算起,当“60秒之内有10000次写入”这个条件被满足时,Redis就会自动触发BGSAVE命令。
-
SHUTDOWN命令 :当Redis通过SHUTDOWN命令接收到关闭服务器的请求时,会执行一个SAVE命令,阻塞所有客户端,不再执行客户端发送的任何命令,并在SAVE命令执行完毕之后关闭服务器。
-
一个Redis服务器连接到另一个Redis服务器:当一个Redis服务器连接到另一个Redis服务器,并向对方发送PSYNC命令来开始一次复制操作的时候,如果主服务器目前没有执行BGSAVE操作,或者主服务器并非刚刚执行完BGSAVE操作,那么主服务器就会执行BGSAVE命令
2、AOF持久化(Append Only File)
AOF持久化:将写命令添加到 AOF 文件(Append Only File)的末尾
与快照持久化相比,AOF持久化的实时性更好,因此已成为主流的持久化方案。默认情况下Redis没有开启AOF(append only file)方式的持久化,可以通过appendonly参数开启。
(1)同步方式
使用 AOF 持久化需要设置同步选项,从而确定写命令同步到磁盘文件上的时机。在Redis的配置文件中存在三种同步方式:
选项 | 同步频率 | 影响 |
---|---|---|
always | 每个写命令都同步 | 数据丢失减到最少,但会严重降低Redis的速度 |
everysec | 每秒同步一次 | Redis性能几乎没受到任何影响。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。 当硬盘忙于执行写入操作的时候,Redis还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。 |
no | 让操作系统来决定何时同步 | 这种方案会使Redis丢失不定量的数据 如果用户的硬盘处理写入操作的速度不够的话,那么当缓冲区被等待写入的数据填满时,Redis的写入操作将被阻塞,这会导致Redis的请求速度变慢。 |
(2)重写/压缩AOF
AOF虽然可以将数据丢失降低到最小而且对性能影响也很小,但是极端的情况下,体积不断增大的AOF文件很可能会用完硬盘空间。另外,如果AOF体积过大,那么还原操作执行时间就可能会非常长。
为了解决AOF体积过大的问题,用户可以向Redis发送 BGREWRITEAOF(bgrewriteAOF)
命令 ,这个命令会通过移除AOF文件中的冗余命令来重写(rewrite)AOF文件来减小AOF文件的体积。
在执行 BGREWRITEAOF
命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新旧两个 AOF 文件所保存的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。
随着负载量的上升,或者数据的完整性变得越来越重要时,用户可能需要使用到复制特性。
3、Redis 4.0 对持久化机制的优化
Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。
如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF里面的 RDB 部分就是压缩格式不再是 AOF格式,可读性较差。
4、如何选择合适的持久化方式
-
一般来说, 如果想达到足以媲美PostgreSQL的数据安全性,应该同时使用两种持久化功能。在这种情况下,当 Redis 重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整。
-
如果可以承受数分钟以内的数据丢失,那么可以只使用RDB持久化。
-
有很多用户都只使用AOF持久化,但并不推荐这种方式,因为定时生成RDB快照(snapshot)非常便于进行数据库备份, 并且 RDB 恢复数据集的速度也要比AOF恢复的速度要快,除此之外,使用RDB还可以避免AOF程序的bug。
-
如果只希望数据在服务器运行的时候存在,你也可以不使用任何持久化方式。
六、事件与Redis单线程模型
Redis 服务器是一个事件驱动程序,服务器需要处理两类事件: 1. 文件事件; 2. 时间事件。
1、文件事件
服务器通过套接字与客户端或者其它服务器进行通信,文件事件就是对套接字操作的抽象。
Redis基于Reactor模式开发了网络事件处理器,这个处理器被称为文件事件处理器(file event handler)。因为文件事件分派器队列的消费是单线程的,所以Redis才叫单线程模型。
- 文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字, 并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
- 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时, 与操作相对应的文件事件就会产生, 这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
虽然文件事件处理器以单线程方式运行, 但通过使用 I/O 多路复用程序来监听多个套接字, 文件事件处理器既实现了高性能的网络通信模型, 又可以很好地与 redis 服务器中其他同样以单线程方式运行的模块进行对接, 这保持了 Redis 内部单线程设计的简单性。
文件事件处理器(file event handler)主要是包含 4 个部分:
- 多个 socket(客户端连接)
- IO 多路复用程序(支持多个客户端连接的关键)
- 文件事件分派器(将 socket 关联到相应的事件处理器)
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
Redis为什么不使用多线程?
Redis 是单线程模型。但是,实际上Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令。
- 单线程编程容易并且更容易维护;
- Redis 的性能瓶颈不在CPU ,主要在内存和网络
- 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。
Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了, 执行命令仍然是单线程顺序执行。
2、时间事件
服务器有一些操作需要在给定的时间点执行,时间事件是对这类定时操作的抽象。
时间事件又分为:
- 定时事件:是让一段程序在指定的时间之内执行一次;
- 周期性事件:是让一段程序每隔指定时间就执行一次。
Redis 将所有时间事件都放在一个无序链表中,通过遍历整个链表查找出已到达的时间事件,并调用相应的事件处理器。
事件的调度与执行
服务器需要不断监听文件事件的套接字才能得到待处理的文件事件,但是不能一直监听,否则时间事件无法在规定的时间内执行,因此监听时间由根据距离现在最近的时间事件来决定。
事件调度与执行由 aeProcessEvents 函数负责,伪代码如下:
def aeProcessEvents():
# 获取到达时间离当前时间最接近的时间事件
time_event = aeSearchNearestTimer()
# 计算最接近的时间事件距离到达还有多少毫秒
remaind_ms = time_event.when - unix_ts_now()
# 如果事件已到达,那么 remaind_ms 的值可能为负数,将它设为 0
if remaind_ms < 0:
remaind_ms = 0
# 根据 remaind_ms 的值,创建 timeval
timeval = create_timeval_with_ms(remaind_ms)
# 阻塞并等待文件事件产生,最大阻塞时间由传入的 timeval 决定
aeApiPoll(timeval)
# 处理所有已产生的文件事件
procesFileEvents()
# 处理所有已到达的时间事件
processTimeEvents()
将 aeProcessEvents 函数置于一个循环里面,加上初始化和清理函数,就构成了 Redis 服务器的主函数,伪代码如下:
def main():
# 初始化服务器
init_server()
# 一直处理事件,直到服务器关闭为止
while server_is_not_shutdown():
aeProcessEvents()
# 服务器关闭,执行清理操作
clean_server()
从事件处理的角度来看,服务器运行流程如下:
七、事务
1、相关概念
Redis事务功能是通过MULTI、EXEC、DISCARD和WATCH 四个原语实现的
Redis会将一个事务中的所有命令序列化,然后按顺序执行。服务器在执行事务期间,不会改去执行其它客户端的命令请求。
事务中的多个命令被一次性发送给服务器,而不是一条一条发送,这种方式被称为流水线,它可以减少客户端与服务器之间的网络通信次数从而提升性能。
- redis 不支持回滚,“Redis 在事务失败时不进行回滚,而是继续执行余下的命令”, 所以 Redis 的内部可以保持简单且快速。
- 如果在一个事务中的命令出现语法错误,那么所有的命令都不会执行;
- 如果在一个事务中出现运行错误(例如对一个“非数字”类型的key执行INCR操作),redis不会回滚此前已经执行成功的操作,而且也不会中断ERROR之后的其他操作的执行。
对于开发者而言,你务必关注事务执行后返回的结果(结果将是一个集合,按照操作提交的顺序排列,对于执行失败的操作,结果将是一个ERROR)
2、指令介绍
- WATCH 命令是一个乐观锁,可以为 Redis 事务提供 check-and-set (CAS)行为。 可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行,监控一直持续到调用EXEC、DISCARD、UNWATCH命令。当客户端断开连接时, 该客户端对键的监视也会被取消。如果使用WATCH命令监控一个有过期时间的键,在监控这个键之后,Redis使这个键过期了,那么EXEC命令仍然可以正常工作
- MULTI 命令用于开启一个事务,它总是返回OK。 MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当EXEC命令被调用时,所有队列中的命令才会被执行。
- EXEC:执行所有事务块内的命令。返回事务块内所有命令的返回值,按命令执行的先后顺序排列。 当操作被打断时,返回空值 nil 。
- 通过调用 DISCARD,客户端可以清空事务队列,并放弃执行事务, 并且客户端会从事务状态中退出。此指令不是严格意义上的“事务回滚”,只是表达了“事务操作被取消”的语义。
- UNWATCH命令可以取消watch对所有key的监控。
3、ACID支持度
Redis的事务总是具有ACID中的一致性和隔离性,但事务不保证原子性,且没有回滚。当服务器运行在AOF持久化模式下,并且appendfsync选项的值为always时,事务也具有持久性。
4、在事务和非事务状态下执行命令
无论在事务状态下, 还是在非事务状态下, Redis 命令都由同一个函数执行, 所以它们共享很多服务器的一般设置, 比如 AOF 的配置、RDB 的配置,以及内存限制,等等。
不过事务中的命令和普通命令在执行上还是有一点区别的,其中最重要的两点是:
- 非事务状态下的命令以单个命令为单位执行,前一个命令和后一个命令的客户端不一定是同一个;
而事务状态则是以一个事务为单位,执行事务队列中的所有命令:除非当前事务执行完毕,否则服务器不会中断事务,也不会执行其他客户端的其他命令。 - 在非事务状态下,执行命令所得的结果会立即返回给客户端;
而事务则是将所有命令的结果集合到回复队列,再作为 EXEC 命令的结果返回给客户端。
5、通过事务+watch实现 CAS
将通过一个示例,说明如何使用WATCH命令创建一个新的原子化操作(Redis并不原生支持这个原子化操作),此处会以实现ZPOP操作为例。这个命令会以一种原子化的方式,从一个有序集合中弹出分数最低的元素。以下源码是最简单的实现方式:
WATCH zset
element = ZRANGE zset 0 0
MULTI
ZREM zset element
EXEC
6、Redis事务其他实现
- 基于Lua脚本,Redis可以保证脚本内的命令一次性、按顺序地执行,
其同时也不提供事务运行错误的回滚,执行过程中如果部分命令运行错误,剩下的命令还是会继续运行完 - 基于中间标记变量,通过另外的标记变量来标识事务是否执行完成,读取数据时先读取该标记变量判断是否事务执行完成。但这样会需要额外写代码实现,比较繁琐
- Lua脚本可以保证操作的原子性
八、主从复制
如何保证 redis 的高并发和高可用?redis 的主从复制原理能介绍一下么?redis 的哨兵原理能介绍一下么?
1、主从架构实现高并发
单机的 redis,读的速度是110000次/s,写的速度是81000次/s。对于缓存来说,一般都是用来支撑读高并发的。因此架构做成主从(master-slave)架构,一主多从,主节点负责写,并且将数据复制到其它的 slave 节点,从节点负责读。所有的读请求全部走从节点。这样也可以很轻松实现水平扩容,支撑读高并发。
redis replication -> 主从架构 -> 读写分离 -> 水平扩容支撑读高并发
主从链
随着负载不断上升,主服务器可能无法很快地更新所有从服务器,或者重新连接和重新同步从服务器将导致系统超载。为了解决这个问题,可以创建一个中间层来分担主服务器的复制工作。中间层的服务器是最上层服务器的从服务器,又是最下层服务器的主服务器。
(1)redis replication 的核心机制
- redis 采用异步方式复制数据到 slave 节点,不过 redis2.8 开始,slave node 会周期性地确认自己每次复制的数据量;
- 一个 master node 是可以配置多个 slave node 的;
- slave node 也可以连接其他的 slave node;
- slave node 做复制的时候,不会 block master node 的正常工作;
- slave node 在做复制的时候,也不会 block 对自己的查询操作,它会用旧的数据集来提供服务;但是复制完成的时候,需要删除旧数据集,加载新数据集,这个时候就会暂停对外服务了;
- slave node 主要用来进行横向扩容,做读写分离,扩容的 slave node 可以提高读的吞吐量。
注意,如果采用了主从架构,那么建议必须开启 master node 的持久化,不建议用 slave node 作为 master node 的数据热备,因为那样的话,如果你关掉 master 的持久化,可能在 master 宕机重启的时候数据是空的,然后可能一经过复制, slave node 的数据也丢了。
另外,master 的各种备份方案,也需要做。万一本地的所有文件丢失了,从备份中挑选一份 rdb 去恢复 master,这样才能确保启动的时候,是有数据的,即使采用了后续讲解的高可用机制,slave node 可以自动接管 master node,但也可能 sentinel 还没检测到 master failure,master node 就自动重启了,还是可能导致上面所有的 slave node 数据被清空。
(2)redis 主从复制的核心原理
- 当启动一个 slave node 的时候,它会发送一个同步
(PSYNC)
命令给 master node。 - 如果这是 slave node 初次连接到 master node,那么会触发一次全量同步
(full resynchronization)
。
(1)此时 master 会启动一个后台线程,开始生成一份RDB
快照文件,同时还会将从客户端 client 新收到的所有写命令缓存在内存中。(如果在复制期间,内存缓冲区持续消耗超过 64MB,或者一次性超过 256MB,那么停止复制,复制失败。)
(2)RDB 文件生成完毕后, master 会将这个 RDB 发送给 slave(如果 rdb 复制时间超过 60秒(repl-timeout),那么 slave node 就会认为复制失败),slave 会先写入本地磁盘,然后再从本地磁盘加载到内存中,
(3)接着 master 会将内存中缓存的写命令发送到 slave,slave 也会同步这些数据。
(4)slave node 在做复制的时候,也不会 block 对自己的查询操作,它会用旧的数据集来提供服务;但是复制完成的时候,需要删除旧数据集,加载新数据集,这个时候就会暂停对外服务了; - slave node 如果跟 master node 有网络故障,断开了连接,会自动重连,连接之后 slave node 可以根据master的情况来自动选择执行全量同步还是增量同步
(partial resynchronization)
。 - 主从节点数据同步第一次同步的是RDB,后面的是AOF。注意:一次启动从节点个数不能过多,这样太多从节点从主节点读取数据,会造成主节点压力过大。
主从复制的断点续传
从 redis2.8 开始,就支持主从复制的断点续传,如果主从复制过程中,网络连接断掉了,那么可以接着上次复制的地方,继续复制下去,而不是从头开始复制一份。
master node 会在内存中维护一个 backlog
(默认1MB),master 和 slave 都会保存一个 replica offset
还有一个 master run id
,offset 就是保存在 backlog
中的。如果 master 和 slave 网络连接断掉了,slave可以执行增量同步(partial resynchronization)
。前提条件是master run id
和之前是相同的,并且slave标记的复制偏移量(replica offset)
仍然在master 复制流的内存缓冲区里面,否则的话会执行全量同步。
如果根据 host+ip 定位 master node,是不靠谱的,如果 master node 重启或者数据出现了变化,那么 slave node 应该根据不同的 run id 区分。
无磁盘化复制
master 在内存中直接创建 RDB,然后发送给 slave,不会在自己本地落地磁盘了。只需要在配置文件中开启 repl-diskless-sync yes
即可。
repl-diskless-sync yes
# 等待 5s 后再开始复制,因为要等更多 slave 重新连接过来
repl-diskless-sync-delay 5
过期 key 处理
slave 不会过期 key,只会等待 master 过期 key。如果 master 过期了一个 key,或者通过 LRU 淘汰了一个 key,那么会模拟一条 del 命令发送给 slave。
(3)复制的完整流程
heartbeat
主从节点互相都会发送 heartbeat 信息。
master 默认每隔 10秒 发送一次 heartbeat,slave node 每隔 1秒 发送一个 heartbeat。
(4)主从复制保证最终一致性
Redis 的主从数据是异步同步的,所以分布式的 Redis 系统并不满足「一致性」要求。当客户端在 Redis 的主节点修改了数据后,立即返回,即使在主从网络断开的情况下,主节点依旧可以正常对外提供修改服务,所以 Redis 满足「可用性」。
Redis 保证「最终一致性」,从节点会努力追赶主节点,最终从节点的状态会和主节点的状态将保持一致。如果网络断开了,主从节点的数据将会出现大量不一致,一旦网络恢复,从节点会采用多种策略努力追赶上落后的数据,继续尽力保持和主节点一致。
(5)主从复制中存在的问题
1、延迟与不一致问题
由于主从复制的命令传播是异步的,延迟与数据的不一致不可避免。
如果应用对数据不一致的接受程度程度较低,可能的优化措施包括:
- 优化主从节点之间的网络环境(如在同机房部署);
- 监控主从节点延迟(通过offset)判断,如果从节点延迟过大,通知应用不再通过该从节点读取数据;使用集群同时扩展写负载和读负载等。
- 修改从节点参数配置。当连接发生在数据同步阶段,或从节点失去与主节点的连接时。可修改从节点的
slave-serve-stale-data
参数。
如果slave-serve-stale-data设置为yes(默认设置),从库会继续响应客户端的请求。
如果slave-serve-stale-data设置为no,除去INFO和SLAVOF命令之外的任何请求都会返回一个错误”SYNC with master in progress”。
2、数据过期问题
在单机版Redis中,存在两种删除策略:惰性删除、 定期删除
在主从复制场景下,为了主从节点的数据一致性,从节点不会主动删除数据,而是由主节点控制从节点中过期数据的删除。由于主节点的惰性删除和定期删除策略,都不能保证主节点及时对过期数据执行删除操作,因此,当客户端通过Redis从节点读取数据时,很容易读取到已经过期的数据。
Redis 3.2中,从节点在读取数据时,增加了对数据是否过期的判断:如果该数据已过期,则不返回给客户端;将Redis升级到3.2可以解决数据过期问题。
3.、故障切换问题
在没有使用哨兵的读写分离场景下,应用针对读和写分别连接不同的Redis节点;当主节点或从节点出现问题而发生更改时,需要及时修改应用程序读写Redis数据的连接;连接的切换可以手动进行,或者自己写监控程序进行切换,但前者响应慢、容易出错,后者实现复杂,成本都不算低。
如果使用读写分离,可以使用哨兵,使主从节点的故障切换尽可能自动化,并减少对应用程序的侵入。
4、复制阶段连接超时问题
(1)数据同步阶段:在主从节点进行全量复制bgsave时,主节点需要首先fork子进程将当前数据保存到RDB文件中,然后再将RDB文件通过网络传输到从节点。如果RDB文件过大,主节点在fork子进程+保存RDB文件时耗时过多,可能会导致从节点长时间收不到数据而触发超时;此时从节点会重连主节点,然后再次全量复制,再次超时,再次重连……这是个悲伤的循环。为了避免这种情况的发生,除了注意Redis单机数据量不要过大,另一方面就是适当增大repl-timeout值,具体的大小可以根据bgsave耗时来调整。
(2)命令传播阶段:在该阶段主节点会向从节点发送PING命令,频率由repl-ping-slave-period控制;该参数应明显小于repl-timeout值(后者至少是前者的几倍)。否则,如果两个参数相等或接近,网络抖动导致个别PING命令丢失,此时恰巧主节点也没有向从节点发送数据,则从节点很容易判断超时。
(3)慢查询导致的阻塞:如果主节点或从节点执行了一些慢查询(如keys *或者对大数据的hgetall等),导致服务器阻塞;阻塞期间无法响应复制连接中对方节点的请求,可能导致复制超时。
2、基于哨兵实现高可用
(1)哨兵介绍
sentinel,中文名是哨兵。哨兵是 redis 集群机构中非常重要的一个组件,主要有以下功能:
- 集群监控:负责监控 redis master 和 slave 进程是否正常工作。
- 消息通知:如果某个 redis 实例有故障,那么哨兵负责发送消息作为报警通知给管理员。
- 故障转移:如果 master node 挂掉了,会自动转移到 slave node 上。
- 配置中心:如果故障转移发生了,通知 client 客户端新的 master 地址。
哨兵用于实现 redis 集群的高可用,本身也是分布式的,作为一个哨兵集群去运行,互相协同工作。
(2)哨兵的核心知识
- 故障转移时,判断一个master node是宕机了,需要大部分的哨兵都同意才行,涉及到了分布式选举的问题
- 哨兵至少需要3个实例,来保证自己的健壮性
- 哨兵 + redis主从的部署架构,是不会保证数据零丢失的,只能保证redis集群的高可用性
sdown和odown
- sdown和odown两种失败状态
- sdown是主观宕机,就一个哨兵如果自己觉得一个master宕机了,那么就是主观宕机
- odown是客观宕机,如果quorum数量的哨兵都觉得一个master宕机了,那么就是客观宕机
- sdown达成的条件:如果一个哨兵ping一个master,超过了is-master-down-after-milliseconds指定的毫秒数之后,就主观认为master宕机
- odown达成的条件:如果一个哨兵在指定时间内,收到了quorum指定数量的其他哨兵也认为那个master是sdown了,那么就认为是odown了,客观认为master宕机
quorum和majority
- quorum:确认odown的最少的哨兵数量
- majority:授权进行主备切换的最少的哨兵数量
- 每次一个哨兵要做主备切换,首先需要quorum数量的哨兵认为odown,然后选举出一个哨兵来做切换,这个哨兵还得得到majority哨兵的授权,才能正式执行切换
- 如果quorum < majority,比如5个哨兵,majority就是3,quorum设置为2,那么就3个哨兵授权就可以执行切换,但是如果quorum >= majority,那么必须quorum数量的哨兵都授权,比如5个哨兵,quorum是5,那么必须5个哨兵都同意授权,才能执行切换
为什么哨兵至少3个节点
哨兵集群必须部署2个以上节点。如果哨兵集群仅仅部署了个2个哨兵实例,那么它的majority就是2(2的majority=2,3的majority=2,4的majority=2,5的majority=3),如果其中一个哨兵宕机了,就无法满足majority>=2这个条件,那么在master发生故障的时候也就无法进行主备切换
(3)redis 哨兵主从切换的数据丢失问题
两种情况导致数据丢失
1、异步复制导致的数据丢失
因为 master->slave 的复制是异步的,所以可能有部分数据还没复制到 slave,master 就宕机了,此时这部分数据就丢失了。
2、脑裂导致的数据丢失
脑裂,也就是说,某个 master 所在机器突然脱离了正常的网络,跟其他 slave 机器不能连接,但是实际上 master 还运行着。此时哨兵可能就会认为 master 宕机了,然后开启选举,将其他 slave 切换成了 master。这个时候,集群里就会有两个 master ,也就是所谓的脑裂。
此时虽然某个 slave 被切换成了 master,但是可能 client 还没来得及切换到新的 master,还继续向旧 master 写数据。因此旧 master 再次恢复的时候,会被作为一个 slave 挂到新的 master 上去,自己的数据会清空,重新从新的 master 复制数据。而新的 master 并没有后来 client 写入的数据,因此,这部分数据也就丢失了。
数据丢失问题的解决方案
min-slaves-to-write 1
min-slaves-max-lag 10
表示,要求至少有 1 个 slave,数据复制和同步的延迟不能超过 10 秒。
如果一旦所有的 slave,数据复制和同步的延迟都超过了 10 秒钟,那么这个时候,master 就不会再接收任何请求了。
- 减少异步复制数据的丢失
有了min-slaves-max-lag
这个配置,就可以确保说,一旦 slave 复制数据和 ack 延时太长,就认为可能 master 宕机后损失的数据太多了,那么就拒绝写请求,这样可以把 master 宕机时由于部分数据未同步到 slave 导致的数据丢失降低的可控范围内。 - 减少脑裂的数据丢失
如果一个 master 出现了脑裂,跟其他 slave 丢了连接,那么上面两个配置可以确保说,如果不能继续给指定数量的 slave 发送数据,而且 slave 超过 10 秒没有给自己 ack 消息,那么就直接拒绝客户端的写请求。因此在脑裂场景下,最多就丢失 10 秒的数据。
(4)哨兵集群的自动发现机制
哨兵互相之间的发现,是通过 redis 的 pub/sub
系统实现的,每个哨兵都会往 __sentinel__:hello
这个 channel 里发送一个消息,这时候所有其他哨兵都可以消费到这个消息,并感知到其他的哨兵的存在。
-
每隔两秒钟,每个哨兵都会往自己监控的某个 master+slaves 对应的
__sentinel__:hello
channel 里发送一个消息,内容是自己的 host、ip 和 runid 还有对这个 master 的监控配置。 -
每个哨兵也会去监听自己监控的每个 master+slaves 对应的
__sentinel__:hello
channel,然后去感知到同样在监听这个 master+slaves 的其他哨兵的存在。 -
每个哨兵还会跟其他哨兵交换对 master 的监控配置,互相进行监控配置的同步。
(5)slave 配置的自动纠正
哨兵会负责自动纠正 slave 的一些配置,比如 slave 如果要成为潜在的 master 候选人,哨兵会确保 slave 复制现有 master 的数据;如果 slave 连接到了一个错误的 master 上,比如故障转移之后,那么哨兵会确保它们连接到正确的 master 上。
(6)slave->master 选举算法
如果一个 master 被认为 odown 了,而且 majority 数量的哨兵都允许主备切换,那么某个哨兵就会执行主备切换操作,此时首先要选举一个 slave 来,会考虑 slave 的一些信息:
- 跟 master 断开连接的时长
- slave 优先级
- 复制 offset
- run id
如果一个 slave 跟 master 断开连接的时间已经超过了 down-after-milliseconds
的 10 倍,外加 master 宕机的时长,那么 slave 就被认为不适合选举为 master。
(down-after-milliseconds * 10) + milliseconds_since_master_is_in_SDOWN_state
接下来会对 slave 进行排序:
- 按照 slave 优先级进行排序,slave priority 越低,优先级就越高。
- 如果 slave priority 相同,那么看 replica offset,哪个 slave 复制了越多的数据,offset 越靠后,优先级就越高。
- 如果上面两个条件都相同,那么选择一个 run id 比较小的那个 slave。
(7)configuration epoch
哨兵会对一套 redis master+slaves 进行监控,有相应的监控的配置。
执行切换的那个哨兵,会从新的 master(salve->master)那里得到一个 configuration epoch,这就是一个 version
号,每次切换的 version 号都必须是唯一的。
如果第一个选举出的哨兵切换失败了,那么其他哨兵,会等待 failover-timeout
时间,然后接替继续执行切换,此时会重新获取一个新的 configuration epoch,作为新的 version 号。
(8)configuration 传播
哨兵完成切换之后,会在自己本地更新生成最新的 master 配置,然后同步给其他的哨兵,就是通过之前说的 pub/sub
消息机制。
这里之前的 version 号就很重要了,因为各种消息都是通过一个 channel 去发布和监听的,所以一个哨兵完成一次新的切换之后,新的 master 配置是跟着新的 version 号的。其他的哨兵都是根据版本号的大小来更新自己的 master 配置的。
九、缓存雪崩、穿透、击穿
1、缓存雪崩
缓存雪崩是指缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。
解决方案:
事前:redis 高可用,主从+哨兵,redis cluster,避免全盘崩溃。
事中:本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死。
事后:redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。
一个简单方案就是将缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
用户发送一个请求,系统 A 收到请求后,先查本地 ehcache 缓存,如果没查到再查 redis。如果 ehcache 和 redis 都没有,再查数据库,将数据库中的结果,写入 ehcache 和 redis 中。
限流组件,可以设置每秒的请求,有多少能通过组件,剩余的未通过的请求,怎么办?走降级!可以返回一些默认的值,或者友情提示,或者空白的值。
好处:
- 数据库绝对不会死,限流组件确保了每秒只有多少个请求能通过。
- 只要数据库不死,就是说,对用户来说,2/5 的请求都是可以被处理的。
- 只要有 2/5 的请求可以被处理,就意味着你的系统没死,对用户来说,可能就是点击几次刷不出来页面,但是多点几次,就可以刷出来一次。
2、缓存穿透
缓存穿透是指缓存和数据库中都没有的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。
解决方案:
- 接口层增加校验,如用户权限校验,id做基础校验,id<=0的直接拦截;
- 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击
- 采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力
3、缓存击穿
缓存击穿,就是说某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,当这个 key 在失效的瞬间,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。
和缓存雪崩不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方式:
- 将热点数据设置为永远不过期;
- 基于 redis or zookeeper 实现互斥锁。
十、保证缓存与数据库的一致性
更新缓存 VS 淘汰缓存
- 主要取决于“更新缓存的复杂度”
- 更新缓存的代价很小,此时我们应该更倾向于更新缓存,以保证更高的缓存命中率
- 更新缓存的代价很大,此时我们应该更倾向于淘汰缓存。
- 淘汰缓存操作简单,并且带来的副作用只是增加了一次cache miss,建议作为通用的处理方式。
1、Cache Aside Pattern(旁路缓存模式)(最常用)
适合读请求比较多的场景
写:
- 先更新 DB
- 然后直接删除 cache
读:
- 从 cache 中读取数据,读取到就直接返回
- cache中读取不到的话,就从 DB 中读取数据返回
- 再把数据放到 cache 中。
写的部分有一些争议,网上流传很多种做法,主要有以下几种:
1.先更新数据库,再更新缓存
同时有请求A和请求B对同一数据进行更新操作,那么会出现
(1)线程A更新了数据库
(2)线程B更新了数据库
(3)线程B更新了缓存
(4)线程A更新了缓存
这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。
2.先删除缓存,再更新数据库
对同一数据,同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:
(1)请求A进行写操作,删除缓存
(2)请求B查询发现缓存不存在
(3)请求B去数据库查询得到旧值
(4)请求B将旧值写入缓存
(5)请求A将新值写入数据库
这种线程安全问题需要通过延时双删等方案解决
大概的策略是:
(1)先淘汰缓存
(2)再写数据库(这两步和原来一样)
(3)休眠x秒,再次淘汰缓存
这么做,可以将x秒内所造成的缓存脏数据,再次删除。这个时间设定可根据业务场景进行一个调节。
这种策略主要考虑到以下情形:假如先更新数据库,再淘汰缓存,假如缓存淘汰失败,那么后面的请求都会得到脏数据,直至缓存过期(或者下文所述的case)。假如先淘汰缓存再更新数据库,如果数据库更新失败,只会产生一次缓存miss,相比较而言,后者对业务影响更小一点。
3.先更新数据库,再删除缓存
对同一数据,同时有一个请求A进行更新操作,另一个请求B进行查询操作。
(1)请求B进行查询操作,未命中缓存
(2)请求B去数据库查询
(3)请求A进行写操作,执行完毕,删除缓存
(4)请求B将旧值写入缓存
这个case理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作删除缓存,所有的这些条件都具备的概率基本并不大。
缺点:
1:首次请求数据一定不在 cache 的问题
解决办法:可以将热点数据可以提前放入cache 中。
2:写操作比较频繁的话导致cache中的数据会被频繁被删除,这样会影响缓存命中率 。
解决办法:
- 数据库和缓存数据强一致场景 :更新DB的时候同样更新cache,不过我们需要加一个锁/分布式锁来保证更新cache的时候不存在线程安全问题。
- 可以短暂地允许数据库和缓存数据不一致的场景 :更新DB的时候同样更新cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。
2、Read/Write Through Pattern(读写穿透)
Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 DB,从而减轻了应用程序的职责。
这种缓存读写策略在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 并没有提供 cache 将数据写入DB的功能。
写:
- 先查 cache,cache 中不存在,直接更新 DB。
- cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB。
读:
- 从 cache 中读取数据,读取到就直接返回 。
- 读取不到的话,cache先从 DB 加载,写入到 cache 后返回响应。
Read-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的。
3、Write Behind Caching Pattern(异步缓存写入)
异步缓存写入和读写穿透很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写。
但是,两个又有很大的不同:读写穿透是同步更新 cache 和 DB,而异步缓存写入则是只更新缓存,不直接更新 DB,而是采用异步批量的方式来更新 DB。
很明显,这种方式对数据一致性带来了更大的挑战,比如cache数据可能还没异步更新DB的话,cache服务可能就就挂掉了。
这种策略在平时开发过程中也非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 InnoDB Buffer Pool 机制都用到了这种策略。
异步缓存写入下 DB 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。
十一、一致性hash
1、基本原理
将哈希空间 [0, 2^32-1] 看成一个哈希环,每个服务器节点都通过哈希函数配置到哈希环上。每个数据对象通过哈希取模得到哈希值之后,存放到哈希环中顺时针方向第一个大于等于该哈希值的节点上。
2、一致性Hash算法的容错性和可扩展性
增加或者删除节点时受影响的数据仅仅是此服务器到其环空间前一台服务器之间的数据
3、数据倾斜与虚拟节点
在一致性Hash算法服务节点太少的情况下,容易因为节点分布不均匀造成数据倾斜
问题(被缓存的对象大部分缓存在某一台服务器上)
解决方式是通过增加虚拟节点
,即对每一个服务器节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。
然后将虚拟节点映射到真实节点上。虚拟节点的数量比真实节点来得多,那么虚拟节点在哈希环上分布的均匀性就会比原来的真实节点好,从而使得数据分布也更加均匀。
十二、分布式锁
需要保证一个方法在同一时间内只能被同一个线程执行
在Java JDK已经为我们提供了这样的锁,利用ReentrantLcok或者synchronized,即可达到资源互斥访问的目的。但是在分布式系统中,由于分布式系统的分布性,即多线程和多进程并且分布在不同机器中,这两种锁将失去原有锁的效果,需要我们自己实现分布式锁
1、常用的实现方式
1、 基于 MySQL 中的锁
MySQL 本身有自带的悲观锁 for update 关键字,也可以自己实现悲观/乐观锁来达到目的;
该实现方式完全依靠数据库唯一索引来实现,当想要获得锁时,即向数据库中插入一条记录,释放锁时就删除这条记录。这种方式存在以下几个问题:
(1) 锁没有失效时间,解锁失败会导致死锁,其他线程无法再获取到锁,因为唯一索引insert都会返回失败。
(2) 只能是非阻塞锁,insert失败直接就报错了,无法进入队列进行重试
(3) 不可重入,同一线程在没有释放锁之前无法再获取到锁
采用乐观锁增加版本号:根据版本号来判断更新之前有没有其他线程更新过,如果被更新过,则获取锁失败。
2、基于 Zookeeper 临时有序节点:Zookeeper 允许临时创建有序的子节点,这样客户端获取节点列表时,就能够当前子节点列表中的序号判断是否能够获得锁;
3、 基于 Redis :setnx(key,当前时间+过期时间)和Redlock机制
2、最低保证分布式锁的有效性及安全性的要求
- 互斥;任何时刻只能有一个client获取锁
- 释放死锁;即使锁定资源的服务崩溃或者分区,仍然能释放锁
- 容错性;只要多数redis节点(一半以上)在使用,client就可以获取和释放锁
其他特性:
- 解铃还须系铃人:加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
- 具备可重入特性
- 具备非阻塞锁特性:即没有获取到锁将直接返回获取锁失败。
- 高性能 & 高可用
- 锁的公平性
3、单实例实现分布式锁:setnx
其实目前通常所说的setnx
命令,并非单指redis的setnx key value
这条命令。
一般代指redis中对set命令加上nx参数进行使用, set这个命令,目前已经支持这么多参数可选:
SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]
# NX 表示if not exist 就设置并返回True,否则不设置并返回False
# PX 表示过期时间用毫秒级, 30000 表示这些毫秒时间后此key过期
SET key_name my_random_value NX PX 30000
set命令,包含了setnx,expire
的功能,起到了原子操作的效果。只有在key不存在时才设置成功返回True,并且设置key的过期时间(最好用毫秒)
设置过期时间主要为了防止获取锁的线程释放锁前崩掉,导致系统中其他线程再无法获取到锁
setnx锁的释放
在获取锁,并完成相关业务后,需要删除自己设置的锁(必须是只能删除自己设置的锁,不能删除他人设置的锁);
删除原因:保证服务器资源的高利用效率,不用等到锁自动过期才删除;
删除方法:最好使用Lua脚本删除(redis保证执行此脚本时不执行其他操作,保证操作的原子性),代码如下;逻辑是先获取key,如果存在并且值是自己设置的就删除此key;否则就跳过;
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
- set 命令要用 set key value px milliseconds nx,替代 setnx + expire 需要分两次执行命令的方式,保证了原子性,
- value 要具有唯一性,可以使用UUID.randomUUID().toString()方法生成,用来标识这把锁是属于哪个请求加的,在解锁的时候就可以有依据;
- 释放锁时要验证 value 值,防止误解锁;
- 通过 Lua 脚本来避免 Check And Set 模型的并发问题,因为在释放锁的时候因为涉及到多个Redis操作 (利用了eval命令执行Lua脚本的原子性);
4、多节点实现分布式锁算法:RedLock
(1)Redisson
Redisson是java的redis客户端之一,提供了一些api方便操作redis。
redisson对于一些JUC下面的类搞了分布式的版本,比如AtomicLong,直接用RedissonAtomicLong就行了。
对主从,哨兵,单点,集群等模式都支持
Redisson普通的锁实现源码主要是RedissonLock
这个类,源码中加锁/释放锁操作都是用lua脚本完成的,封装的非常完善,开箱即用。支持锁的重入性
RedLock
并非是一个工具,而是redis官方提出的一种分布式锁的算法。
redisson中,就实现了redLock版本的锁:getRedLock
方法。
Redisson自动续期原理
只要客户端一旦加锁成功,就会启动一个watch dog看门狗后台线程,会每隔10秒检查一下(1/3超时时间),如果客户端还持有锁key,那么就会不断的延长锁key的生存时间。
默认情况下,加锁的时间是30秒,.如果加锁的业务没有执行完,就会进行一次续期,把锁重置成30秒。
此时若执行业务的机器宕机了,看门狗定时任务跑不了,续不了期,超时时间之后会释放锁。
Redisson可重入锁原理
通过lua脚本进行实现
KEYS[1]代表加锁的那个key
ARGV[1]代表的就是锁key的默认生存时间,默认30秒
ARGV[2]代表的是加锁的客户端的ID
拜托,面试请不要再问我Redis分布式锁的实现原理【石杉的架构笔记】
(2)RedLock算法
有效防止单点故障
RedLock算法虽然需要多个实例,但是这些实例都是独自部署的,没有主从关系。
RedLock作者指出,之所以要用独立的,为了避免redis异步复制造成的锁丢失,
算法步骤:
- 获取当前时间戳
- 客户端按顺序使用相同的key,value获取所有redis服务的锁。在这一过程中,获取锁的时间要远比锁过期时间短,为了防止长时间等待获取锁失败的redis服务。若规定的时间内无法获得锁,则尝试获取下一个redis实例的锁。
- 若客户端获取所有锁的时间小于过期时间,并且至少有
n/2+1
个redis实例成功获取锁,才算真正的获取锁成功 - 如果成功获取锁,则锁的真正有效时间是
过期时间-获取所有锁的时间
; - 如果客户端由于某些原因获取锁失败,依次将之前建立的锁删除。
获取锁冲突时
redis作者借鉴了raft算法的精髓,发生冲突后在随机时间开始,可以大大降低冲突时间。
(3)RedLock性能及崩溃恢复的相关解决方法
1、如果redis没有持久化功能,在clientA获取锁成功后,所有redis重启,clientB能够再次获取到锁,这样违法了锁的排他互斥性;
2、如果启动AOF永久化存储,事情会好些。
举例:当我们重启redis后,由于redis过期机制是按照unix时间戳走的,所以在重启后,然后会按照规定的时间过期,不影响业务;
但是由于AOF同步到磁盘的方式默认是每秒一次,如果在一秒内断电,会导致数据丢失,立即重启会造成锁互斥性失效;
但如果同步磁盘方式使用Always(每一个写命令都同步到硬盘)造成性能急剧下降;所以在锁完全有效性和性能方面要有所取舍;
3、解决方法:redis同步到磁盘方式保持默认的每秒,在redis无论因为什么原因停掉后要等待TTL时间后再重启(延迟重启) ;缺点是 在TTL时间内服务相当于暂停状态;
5、Redis分布式锁存在的问题
(1)锁超时
问题描述:如果在加锁和释放锁之间的逻辑执行得太长,以至于超出了锁的超时限制,也会出现问题。因为这时候第一个线程持有锁过期了,而临界区的逻辑还没有执行完,与此同时第二个线程就提前拥有了这把锁,导致临界区的代码不能得到严格的串行执行。
为了避免这个问题,Redis分布式锁不要用于较长时间的任务。如果真的偶尔出现了问题,造成的数据小错乱可能就需要人工的干预。
(2)STW(Stop-The-World)引发的安全问题
解决方法:使用Fencing(栅栏)使得锁变安全
在每次写操作时加入一个 fencing token
。这个场景下,fencing token
可以是一个递增的数字(lock service
可以做到),每次有 client 申请锁就递增一次:
但是对于 Redlock ,没什么生成 fencing token 的方式,并且怎么修改 Redlock 算法使其能产生 fencing token 呢?好像并不那么显而易见。因为产生 token 需要单调递增,除非在单节点 Redis 上完成但是这又没有高可靠性,你好像需要引进一致性协议来让 Redlock 产生可靠的 fencing token。
(3)RedLock过于依赖时间假设
Redlock 依赖于许多时间假设,它假设所有 Redis 节点都能对同一个 Key 在其过期前持有差不多的时间、跟过期时间相比网络延迟很小、跟过期时间相比进程 pause 很短。
仅有在你假设了一个同步性系统模型的基础上,Redlock 才能正常工作,也就是系统能满足以下属性:
- 网络延时边界,即假设数据包一定能在某个最大延时之内到达
- 进程停顿边界,即进程停顿一定在某个最大时间之内
- 时钟错误边界,即不会从一个坏的 NTP 服务器处取得时间
Java中高级核心知识全面解析——Redis(分布式锁【简介、实现】、Redlock分布式锁、HyperLoglog【简介、原理、实现、使用】)中
6、Zookeeper分布式锁
(1) Zookeeper 抽象模型
Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节点为 /app1。
(2)节点类型
永久节点:不会因为会话结束或者超时而消失;
临时节点:如果会话结束或者超时就会消失;
顺序节点:会在节点名的后面加一个数字后缀,并且是有序的,例如生成的有序节点为 /lock/node-0000000000,它的下一个有序节点则为 /lock/node-0000000001,以此类推。
(3)监视器(watcher):
当创建一个节点时,可以注册一个该节点的监视器,当节点状态发生改变时,watch被触发时,ZooKeeper将会向客户端发送且仅发送一条通知,因为watch只能被触发一次。
(4)分布式锁实现
-
创建一个锁目录lock
-
希望获得锁的线程A就在lock目录下,创建临时顺序节点
-
获取锁目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁
-
线程B获取所有节点,判断自己不是最小节点,设置监听(watcher)比自己次小的节点(只关注比自己次小的节点是为了防止发生“羊群效应”:一只羊动起来,其它羊也会一哄而上)
-
线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是最小的节点,获得锁。
(5)会话超时
如果一个已经获得锁的会话超时了,因为创建的是临时节点,所以该会话对应的临时节点会被删除,其它会话就可以获得锁了。可以看到,这种实现方式不会出现数据库的唯一索引实现方式释放锁失败的问题。
7、Redis与Zookeeper分布式锁比较
- Redis集群支持的则是AP
- Zk集群支持的是CP
数据库锁:
优点:直接使用数据库,使用简单。
缺点:分布式系统大多数瓶颈都在数据库,使用数据库锁会增加数据库负担。
缓存锁:
优点:性能高,实现起来较为方便,在允许偶发的锁失效情况,不影响系统正常使用,建议采用缓存锁。
缺点:通过锁超时机制不是十分可靠,当线程获得锁后,处理时间过长导致锁超时,就失效了锁的作用。
zookeeper锁:
优点:不依靠超时时间释放锁;可靠性高;系统要求高可靠性时,建议采用zookeeper锁。
缺点:性能比不上缓存锁,因为要频繁的创建节点删除节点。
十三、Redis优化
1、Redis Big Key 问题
在Redis中,一个字符串最大512MB,一个二级数据结构(例如hash、list、set、zset)可以存储大约40亿个(2^32-1)个元素,但实际上中如果下面两种情况,就会认为它是bigkey。
- 字符串类型:它的big体现在单个value值很大,一般认为超过10KB就是bigkey。
- 非字符串类型:哈希、列表、集合、有序集合,它们的big体现在元素个数太多。
(1)Big Key 危害
- 内存空间分配不平衡:数据量大的 key ,由于其数据大小远大于其他key,导致经过分片之后,某个具体存储这个 big key 的实例内存使用量远大于其他实例,造成内存不足,拖累整个集群的使用。
- 超时阻塞:由于Redis单线程的特性,操作bigkey的通常比较耗时,也就意味着阻塞Redis可能性越大,这样会造成客户端阻塞或者引起故障切换,它们通常出现在慢查询中。
- 网络拥塞:bigkey也就意味着每次获取要产生的网络流量较大,假设一个bigkey为1MB,客户端每秒访问量为1000,那么每秒产生1000MB的流量,对于普通的千兆网卡(按照字节算是128MB/s)的服务器来说简直是灭顶之灾,而且一般服务器会采用单机多实例的方式来部署,也就是说一个bigkey可能会对其他实例造成影响,其后果不堪设想。
- 过期删除:bigkey过期后,会被删除,如果没有使用Redis 4.0的过期异步删除(lazyfree-lazy-expire yes),就会存在阻塞Redis的可能性,而且这个过期删除不会从主节点的慢查询发现(因为这个删除不是客户端产生的,是内部循环事件,可以从latency命令中获取或者从slave节点慢查询发现)。
- 迁移困难:当需要对bigkey进行迁移(例如Redis cluster的迁移slot),实际上是通过migrate命令来完成的,migrate实际上是通过dump + restore + del三个命令组合成原子命令完成,如果是bigkey,可能会使迁移失败,而且较慢的migrate会阻塞Redis。
(2)产生原因
一般来说,bigkey的产生都是由于程序设计不当,或者对于数据规模预料不清楚造成的,来看几个:
-
社交类:粉丝列表,如果某些明星或者大v不精心设计下,必是bigkey。
-
统计类:例如按天存储某项功能或者网站的用户集合,除非没几个人用,否则必是bigkey。
-
缓存类:将数据从数据库load出来序列化放到Redis里,这个方式非常常用,但有两个地方需要注意:
第一,是不是有必要把所有字段都缓存
第二,有没有相关关联的数据
(3)如何发现
redis-cli --bigkeys
redis-cli提供了–bigkeys来查找bigkey,例如下面就是一次执行结果:
-------- summary -------
Biggest string found 'user:1' has 5 bytes
Biggest list found 'taskflow:175448' has 97478 items
Biggest set found 'redisServerSelect:set:11597' has 49 members
Biggest hash found 'loginUser:t:20180905' has 863 fields
Biggest zset found 'hotkey:scan:instance:zset' has 3431 members
40 strings with 200 bytes (00.00% of keys, avg size 5.00)
2747619 lists with 14680289 items (99.86% of keys, avg size 5.34)
2855 sets with 10305 members (00.10% of keys, avg size 3.61)
13 hashs with 2433 fields (00.00% of keys, avg size 187.15)
830 zsets with 14098 members (00.03% of keys, avg size 16.99)
–bigkeys给出了每种数据结构的top 1 bigkey,同时给出了每种数据类型的键值个数以及平均大小。
bigkeys对问题的排查非常方便,但是在使用它时候也有几点需要注意:
- 建议在从节点执行,因为–bigkeys也是通过scan完成的。
- 建议在节点本机执行,这样可以减少网络开销。
- 如果没有从节点,可以使用–i参数,例如(–i 0.1 代表100毫秒执行一次)
- –bigkeys只能计算每种数据结构的top1,如果有些数据结构非常多的bigkey,也搞不定
- debug object ${key}命令获取键值的相关信息
127.0.0.1:6379> hlen big:hash
(integer) 5000000
127.0.0.1:6379> debug object big:hash
Value at:0x7fda95b0cb20 refcount:1 encoding:hashtable serializedlength:87777785 lru:9625559 lru_seconds_idle:2
(1.08s)
其中serializedlength表示key对应的value序列化之后的字节数。如果是字符串类型,可以执行strlen
127.0.0.1:6379> strlen key
(integer) 947394
可以用scan + debug object的方式遍历Redis所有的键值,找到需要阈值的数据。
但是在使用debug object时候要注意以下几点:
- debug object bigkey本身可能就会比较慢,它本身就会存在阻塞Redis的可能
- 建议在从节点执行
- 建议在节点本地执行
- 如果不关系具体字节数,完全可以使用scan + strlen|hlen|llen|scard|zcard替代,他们都是o(1)
memory usage
Redis 4.0开始提供 memory usage 命令可以计算每个键值的字节数(自身、以及相关指针开销),比debug object 更安全、更准确(序列化后的长度)。
127.0.0.1:6379> memory usage big:hash
(integer) 318663444
如果使用Redis 4.0+,就可以用scan + memory usage(pipeline)了,而且很好的一点是,memory usage不会执行很慢,依然建议从节点 + 本地运行。
客户端、监控报警
上面三种方式都有一个问题,就是马后炮,如果想很实时的找到bigkey,一方面你可以试试修改Redis源码,还有一种方式就是可以修改客户端,以jedis为例,可以在关键的出入口加上对应的检测机制,例如以Jedis的获取结果为例子:
protected Object readProtocolWithCheckingBroken() {
Object o = null;
try {
o = Protocol.read(inputStream);
return o;
}catch(JedisConnectionException exc) {
UsefulDataCollector.collectException(exc, getHostPort(), System.currentTimeMillis());
broken = true;
throw exc;
}finally {
if(o != null) {
if(o instanceof byte[]) {
byte[] bytes = (byte[]) o;
if (bytes.length > threshold) {
// 做很多事情,例如用ELK完成收集和展示
//可以统计、进行监控报警
}
}
}
}
}
(4)如何删除
对于string类型,删除速度还是可以接受的。但对于二级数据结构,随着元素个数的增长以及每个元素字节数的增大,删除速度会越来越慢,存在阻塞Redis的隐患。所以在删除它们时候建议采用渐进式的方式来完成:hscan、ltrim、sscan、zscan。
如果使用Redis 4.0+,一条异步删除unlink就解决。
(5)如何优化
优化big key的原则就是减少string字符串长度,list、hash、set、zset等减少成员数。
string类型的 big key
string类型的big key,建议不要存入redis,用非关系型数据库MongoDB代替或者直接缓存到CDN上等方式优化。
有些 key 不只是访问量大,数据量也很大,这个时候就要考虑这个 key 使用的场景,存储在redis集群中是否是合理的,是否使用其他组件来存储更合适;如果坚持要用 redis 来存储,可能考虑迁移出集群,采用主从架构来存储。
大对象
该对象需要每次都整存整取: 可以尝试将对象分拆成几个key-value, 使用multiGet获取值,这样分拆的意义在于分拆单次操作的压力,将操作压力平摊到多个redis实例中,降低对单个redis的IO影响;
该对象每次只需要存取部分数据: 可以分拆成几个key-value,也可以将这个存储在一个hash中,每个field代表一个具体的属性,使用hget,hmget来获取部分的value,使用hset,hmset来更新部分属性。
集合中元素数量过多
可以将这些元素分拆。以hash为例,原先的正常存取流程是 hget(hashKey, field) ; hset(hashKey, field, value)
现在,固定一个桶的数量,比如 10000, 每次存取的时候,先在本地计算field的hash值,模除 10000,确定了该field落在哪个key上。
newHashKey = hashKey + (hash(field) % 10000);
hset(newHashKey, field, value) ;
hget(newHashKey, field)