简单来说 Redis 就是一个使用C 语言开发的数据库,不过与传统数据库不同的是 Redis 的数据是存在内存中的,也就是它是内存数据库,所以读写速度非常快,因此 Redis 被广泛应用于缓存方向。Redis 除了做缓存之外,也经常用来做分布式锁,甚至是消息队列。Redis 提供了多种数据类型来支持不同的业务场景。Redis 还支持事务 、持久化、Lua 脚本、多种集群方案。
Redis快的原因主要有:
string 数据结构是简单的 key-value 类型。虽然 Redis 是用 C 语言写的,但是 Redis 并没有使用 C 的字符串表示,而是自己构建了一种 简单动态字符串(simple dynamic string,SDS)。相比于 C 的原生字符串,Redis 的 SDS 不光可以保存文本数据还可以保存二进制数据,并且获取字符串长度复杂度为 O(1)(C 字符串为 O(N),除此之外,Redis 的 SDS API 是安全的,不会造成缓冲区溢出,该字符串还是可以进行修改的。)
常用命令: set,get,strlen,exists,decr,incr,setex
使用场景:
(1)需要存储常规数据的场景;缓存 session、token、图片地址、序列化后的对象(相比较于 Hash 存储更节省内存)。
(2)需要计数的场景;用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数。
(3)分布式锁的实现;
基本操作:
127.0.0.1:6379> set key value #设置 key-value 类型的值
OK
127.0.0.1:6379> get key # 根据 key 获得对应的 value
"value"
127.0.0.1:6379> exists key # 判断某个 key 是否存在
(integer) 1
127.0.0.1:6379> strlen key # 返回 key 所储存的字符串值的长度。
(integer) 5
127.0.0.1:6379> del key # 删除某个 key 对应的值
(integer) 1
127.0.0.1:6379> get key
(nil)
批量设置:
127.0.0.1:6379> mset key1 value1 key2 value2 # 批量设置 key-value 类型的值
OK
127.0.0.1:6379> mget key1 key2 # 批量获取多个 key 对应的 value
1) "value1"
2) "value2
计数器:
127.0.0.1:6379> set number 1
OK
127.0.0.1:6379> incr number # 将 key 中储存的数字值增一
(integer) 2
127.0.0.1:6379> get number
"2"
127.0.0.1:6379> decr number # 将 key 中储存的数字值减一
(integer) 1
127.0.0.1:6379> get number
"1"
过期:
127.0.0.1:6379> expire key 60 # 数据在 60s 后过期
(integer) 1
127.0.0.1:6379> setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire)
OK
127.0.0.1:6379> ttl key # 查看数据还有多久过期
(integer) 56
list 即是 链表。链表是一种非常常见的数据结构,特点是易于数据元素的插入和删除并且可以灵活调整链表长度,但是链表的随机访问困难。许多高级编程语言都内置了链表的实现比如 Java 中的 LinkedList,但是 C 语言并没有实现链表,所以 Redis 实现了自己的链表数据结构。Redis 的 list 的实现为一个 双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。
常用命令: rpush,lpop,lpush,rpop,lrange,llen
使用场景:
(1)信息流展示;比如最新文章、最新动态。
(2)消息队列;
通过rpush/lpop
实现队列:
127.0.0.1:6379> rpush myList value1 # 向 list 的头部(右边)添加元素
(integer) 1
127.0.0.1:6379> rpush myList value2 value3 # 向list的头部(最右边)添加多个元素
(integer) 3
127.0.0.1:6379> lpop myList # 将 list的尾部(最左边)元素取出
"value1"
127.0.0.1:6379> lrange myList 0 1 # 查看对应下标的list列表, 0 为 start,1为 end
1) "value2"
2) "value3"
127.0.0.1:6379> lrange myList 0 -1 # 查看列表中的所有元素,-1表示倒数第一
1) "value2"
2) "value3"
hash 类似于 JDK1.8 前的 HashMap,内部实现也差不多(数组 + 链表)。不过,Redis 的 hash 做了更多优化。另外,hash 是一个 string 类型的 field 和 value 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接仅仅修改这个对象中的某个字段的值。 比如我们可以 hash 数据结构来存储用户信息,商品信息等等。
常用命令: hset,hmset,hexists,hget,hgetall,hkeys,hvals
使用场景:
(1)对象数据存储场景:用户信息、商品信息、文章信息、购物车信息。
127.0.0.1:6379> hmset userInfoKey name "guide" description "dev" age "24"
OK
127.0.0.1:6379> hexists userInfoKey name # 查看 key 对应的 value中指定的字段是否存在。
(integer) 1
127.0.0.1:6379> hget userInfoKey name # 获取存储在哈希表中指定字段的值。
"guide"
127.0.0.1:6379> hget userInfoKey age
"24"
127.0.0.1:6379> hgetall userInfoKey # 获取在哈希表中指定 key 的所有字段和值
1) "name"
2) "guide"
3) "description"
4) "dev"
5) "age"
6) "24"
127.0.0.1:6379> hkeys userInfoKey # 获取 key 列表
1) "name"
2) "description"
3) "age"
127.0.0.1:6379> hvals userInfoKey # 获取 value 列表
1) "guide"
2) "dev"
3) "24"
127.0.0.1:6379> hset userInfoKey name "GuideGeGe" # 修改某个字段对应的值
127.0.0.1:6379> hget userInfoKey name
"GuideGeG
set 类似于 Java 中的 HashSet 。Redis 中的 set 类型是一种无序集合,集合中的元素没有先后顺序。当你需要存储一个列表数据,又不希望出现重复数据时,set 是一个很好的选择,并且 set 提供了判断某个成员是否在一个 set 集合内的重要接口,这个也是 list 所不能提供的。可以基于 set 轻易实现交集、并集、差集的操作。比如:你可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis 可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程。
常用命令: sadd,spop,smembers,sismember,scard,sinterstore,sunion
使用场景:
(1)需要存放的数据不能重复的场景;文章点赞、动态点赞等
127.0.0.1:6379> sadd mySet value1 value2 # 添加元素进去
(integer) 2
127.0.0.1:6379> sadd mySet value1 # 不允许有重复元素
(integer) 0
127.0.0.1:6379> smembers mySet # 查看 set 中所有的元素
1) "value1"
2) "value2"
127.0.0.1:6379> scard mySet # 查看 set 的长度
(integer) 2
127.0.0.1:6379> sismember mySet value1 # 检查某个元素是否存在set 中,只能接收单个元素
(integer) 1
127.0.0.1:6379> sadd mySet2 value2 value3
(integer) 2
127.0.0.1:6379> sinterstore mySet3 mySet mySet2 # 获取 mySet 和 mySet2 的交集并存放在 mySet3 中
(integer) 1
127.0.0.1:6379> smembers mySet3
1) "value2"
和 set 相比,sorted set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。有点像是 Java 中 HashMap 和 TreeSet 的结合体。
常用命令: zadd,zcard,zscore,zrange,zrevrange,zrem
使用场景:
(1)需要随机获取数据源中的元素根据某个权重进行排序的场景;各种排行榜比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、
127.0.0.1:6379> zadd myZset 3.0 value1 # 添加元素到 sorted set 中 3.0 为权重
(integer) 1
127.0.0.1:6379> zadd myZset 2.0 value2 1.0 value3 # 一次添加多个元素
(integer) 2
127.0.0.1:6379> zcard myZset # 查看 sorted set 中的元素数量
(integer) 3
127.0.0.1:6379> zscore myZset value1 # 查看某个 value 的权重
"3"
127.0.0.1:6379> zrange myZset 0 -1 # 顺序输出某个范围区间的元素,0 -1 表示输出所有元素
1) "value3"
2) "value2"
3) "value1"
127.0.0.1:6379> zrange myZset 0 1 # 顺序输出某个范围区间的元素,0 为 start 1 为 stop
1) "value3"
2) "value2"
127.0.0.1:6379> zrevrange myZset 0 1 # 逆序输出某个范围区间的元素,0 为 start 1 为 stop
1) "value1"
2) "value2"
bitmap 存储的是连续的二进制数字(0 和 1),通过 bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 bitmap 本身会极大的节省储存空间。
常用命令: setbit 、getbit 、bitcount、bitop
# SETBIT 会返回之前位的值(默认是 0)这里会生成 7 个位
127.0.0.1:6379> setbit mykey 7 1
(integer) 0
127.0.0.1:6379> setbit mykey 7 0
(integer) 1
127.0.0.1:6379> getbit mykey 7
(integer) 0
127.0.0.1:6379> setbit mykey 6 1
(integer) 0
127.0.0.1:6379> setbit mykey 8 1
(integer) 0
# 通过 bitcount 统计被被设置为 1 的位的数量。
127.0.0.1:6379> bitcount mykey
(integer) 2
GeoHash将二维的经纬度转换成字符串,每一个字符串代表了某一矩形区域。也就是说,这个矩形区域内所有的点(经纬度坐标)都共享相同的GeoHash字符串,这样既可以保护隐私(只表示大概区域位置而不是具体的点),又比较容易做缓存,比如左上角这个区域内的用户不断发送位置信息请求餐馆数据,由于这些用户的GeoHash字符串都是WX4ER,所以可以把WX4ER当作key,把该区域的餐馆信息当作value来进行缓存,而如果不使用GeoHash的话,由于区域内的用户传来的经纬度是各不相同的,很难做缓存。
Redis HyperLogLog 是用来做基数统计的算法,所谓基数估算就是在一批数据中不重复的元素个数有多少个。其优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。
基数计数(cardinality counting),则是指计算一个集合的基数,意即count-discint。 基数计算的场景很广泛,例如计算网站的访问uv,计算网络流量网络包请求header中的源地址的distinct数来作为网络攻击的重要指标。想要实现基数计数最直接想到的方式就是通过字典/HashSet,每条数据流入后直接保存相应的key,最后统一次集合的size就得到集合的基数。但是,这种方法的空间复杂度很高,在面对大数据的场景下做这样的统计代价很高。
我们常说的String,list,hash,set,sorted set只是对外的编码,实际上每种数据结构都有自己底层的内部编码实现,而且是多种实现,这样redis可以在合适的场景选择更合适的内部编码。
如图所示:可以看到每种数据结构都有2种以上的内部编码实现,例如String数据结构就博阿寒了raw 、int、emstr三种内部编码。同时,有些内部编码可以作为多种外部数据结构的内部实现,例如ziplist就是hash、list、zset共有的内部编码,而set的内部编码可能是hashtable或者是intset;
redis这样的设计有两个好处:
1.可以偷偷的改进内部编码,而对外的数据机构和命令没有影响,这样一旦开发出更优秀的内部编码,无需改动对外数据结构和命令。
2.多种内部编码的实现可以在不同的场景下发挥各自的又是。例如ziplist比较节省内存,但是列表元素比较多的情况下,性能会有所下降。这个是狗redis会根据配置选项将列表类型的内部事项转换为linkedlist;
注意:当hash的内部实现有ziplist转变成hashtable后,就算后续缓存的内容全部替换了,redis也会一直使用hashtable作为内部实现。
Redis 通过IO 多路复用程序来监听来自客户端的大量连接(或者说是监听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。
这样的好处非常明显:I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗(和 NIO 中的 Selector 组件很像)。
Redis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler)。文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
Redis6.0 引入多线程主要是为了提高网络 IO 读写性能。
Redis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要修改 redis 配置文件redis.conf
:
io-threads-do-reads yes
开启多线程后,还需要设置线程数,否则是不生效的。同样需要修改 redis 配置文件redis.conf
:
io-threads 4 #官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程
Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。
常用的过期数据的删除策略就两个:
定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 定期删除+惰性/懒汉式删除 。
通过Redis内存淘汰机制来对大量过期的key进行处理。
很多时候我们需要持久化数据也就是将内存中的数据写入到硬盘里面,大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了防止系统故障而将数据备份到一个远程位置。
Redis 支持持久化,而且支持两种不同的持久化操作。Redis 的一种持久化方式叫快照(snapshotting,RDB),另一种方式是只追加文件(append-only file, AOF)。
RDB是在指定的时间间隔内将内存中的所有数据集快照写入磁盘。 Redis 会单独创建(fork)一个子进程来进行持久化,会先将数据写入到一个临时文件中,待持久化过程都结束了,再用这个临时文件替换上次持久化好的文件。整个过程中,主进程是不进行任何 IO 操作的,这就确保了极高的性能,如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感,那 RDB 方式是比较高效的。
Redis 可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。 Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。
快照持久化是 Redis 默认采用的持久化方式,在 Redis.conf 配置文件中默认有此下配置:
save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
AOF是以日志的形式来记录每个写操作,将 Redis 执行过的所有写指令记录下来(读操作不记录),只许追加文件但不可以改写文件,Redis 启动之初会读取该文件重新构建数据,也就是「重放」。换言之,Redis 重启的话就根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。
与快照持久化相比,AOF 持久化的实时性更好,因此已成为主流的持久化方案。默认情况下 Redis 没有开启 AOF(append only file)方式的持久化,可以通过 appendonly 参数开启:
appendonly yes
开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到内存缓存 server.aof_buf 中,然后再根据 appendfsync 配置来决定何时将其同步到硬盘中的 AOF 文件。
AOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir 参数设置的,默认的文件名是 appendonly.aof
在 Redis 的配置文件中存在三种不同的 AOF 持久化方式,它们分别是:
appendfsync always #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
appendfsync everysec #每秒钟同步一次,显式地将多个写命令同步到硬盘
appendfsync no #让操作系统决定何时进行同步
为了兼顾数据和写入性能,用户可以考虑 appendfsync everysec
选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能几乎没受到任何影响。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。
因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。
如图,AOF原本有三个命令,但是set num 123 和 set num 666
都是对num的操作,第二次会覆盖第一次的值,因此第一个命令记录下来没有意义。
所以重写命令后,AOF文件内容就是:mset name jack num 666
Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。
如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。
我们用 Redis 事务的时候,可能会遇上以下两种错误:
Redis 针对如上两种错误采用了不同的处理策略。
Redis 可以通过MULTI,EXEC,DISCARD 和 WATCH
等命令来实现事务(transaction)功能。
Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。
使用MULTI
命令后可以输入多个命令。Redis 不会立即执行这些命令,而是将它们放到队列,当调用了 EXEC 命令将执行所有命令。
过程如下:
> MULTI
OK
> SET USER "Guide哥"
QUEUED
> GET USER
QUEUED
> EXEC
1) OK
2) "Guide哥"
你也可以通过 DISCARD
命令取消一个事务,它会清空事务队列中保存的所有命令。
> MULTI
OK
> SET USER "Guide哥"
QUEUED
> GET USER
QUEUED
> DISCARD
OK
WATCH 命令用于监听指定的键,当调用 EXEC 命令执行事务时,如果一个被 WATCH 命令监视的键被修改的话,整个事务都不会执行,直接返回失败。
> WATCH USER
OK
> MULTI
> SET USER "Guide哥"
OK
> GET USER
Guide哥
> EXEC
ERR EXEC without MULTI
事务具有四大特性: 1. 原子性,2. 隔离性,3. 持久性,4. 一致性。
Redis 是不支持 roll back 的,因而不满足原子性的(而且不满足持久性)。
Redis 5.0 新增加的一个数据结构 Stream
可以用来做消息队列,Stream 支持:
缓存穿透说简单点就是大量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这一层。
解决方法:
最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。
(1)缓存无效 key
如果缓存和数据库都查不到某个 key 的数据就写一个到 Redis 中去并设置过期。 这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。
(2)布隆过滤器
把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。
但是,需要注意的是布隆过滤器可能会存在误判的情况。总结来说就是: 布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。
缓存雪崩:缓存在同一时间大面积的失效,后面的请求都直接落到了数据库上,造成数据库短时间内承受大量请求。
还有一种缓存雪崩的场景是:有一些被大量访问数据(热点缓存)在某一时刻大面积失效,导致对应的请求直接落到了数据库上。
解决方法:
1、针对Redis服务不可用的情况:
2、针对热点缓存失效的情况:
这3 种缓存读写策略各有优劣,不存在最佳,需要我们根据具体的业务场景选择更适合的。
Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。
Cache Aside Pattern 中服务端需要同时维系 DB 和 cache,并且是以 DB 的结果为准。
写步骤:
在写数据的过程中,可以先删除 cache ,后更新 DB 么
当然是不行的。因为这样可能会造成数据库(DB)和缓存(Cache)数据不一致的问题。为什么呢?比如说请求1 先写数据A,请求2随后读数据A的话就很有可能产生数据不一致性的问题。这个过程可以简单描述为:
请求1先把cache中的A数据删除 -> 请求2从DB中读取数据->请求1再把DB中的A数据更新。
Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 DB,从而减轻了应用程序的职责。
写步骤:
和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不再 cache 的问题,对于热点数据可以提前放入缓存中。
Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写。
但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。
很明显,这种方式对数据一致性带来了更大的挑战,比如cache数据可能还没异步更新DB的话,cache服务可能就就挂掉了。
分布式锁是控制分布式系统或不同系统之间共同访问共享资源的一种锁实现,如果不同的系统或同一个系统的不同主机之间共享了某个资源时,往往需要互斥来防止彼此干扰来保证一致性。
从锁的场景来看有下边3种锁:
要实现一个分布式锁,我们一般选择集群机器都可以操作的外部系统,然后各个机器都去这个外部系统申请锁。
这个外部系统一般需要满足如下要求才能胜任:
分布式锁一般有三种实现方式:
1. 数据库乐观锁;2. 基于 Redis 的分布式锁;3. 基于 ZooKeeper 的分布式锁。
但为了追求更好的性能,我们通常会选择使用 Redis 或 Zookeeper 来做。
直接用set命令加锁:
set key value[expiration EX seconds|PX milliseconds] [NX|XX]
eg:
SET resource_name my_random_value NX PX 30000
SET 命令的行为可以通过一系列参数来修改
- EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。
- PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。
- NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。
- XX :只在键已经存在时,才对键进行设置操作。
这条指令的意思:当 key——resource_name 不存在时创建这样的 key,设值为 my_random_value,并设置过期时间 30000 毫秒。
Redis 实现分布式锁的主要步骤:
设置一个唯一标识的意思是在解锁时候判断 key 的值和我们存储的随机数是不是一样,一样的话,才是自己的锁,直接 del 解锁就行。
当然这个两个操作要保证原子性,所以 Redis 给出了一段 lua 脚本(Redis 服务器会单线程原子性执行 lua 脚本,保证 lua 脚本在处理的过程中不会被任意其它请求打断。):
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
Redisson 是 Redis 官方的分布式锁组件。Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的 Java 常用对象,还实现了可重入锁(Reentrant Lock)、公平锁(Fair Lock、联锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)等,还提供了许多分布式服务。Redisson 提供了使用 Redis 的最简单和最便捷的方法。
Redisson 支持单点模式、主从模式、哨兵模式、集群模式,只是配置的不同,我们以单点模式来看下怎么使用。
RLock lock = redisson.getLock("lock");
public interface RLock extends Lock, RLockAsync {
/**
* 获取锁的名字
*/
String getName();
/**
* 这个叫终端锁操作,表示该锁可以被中断 假如A和B同时调这个方法,A获取锁,B为获取锁,那么B线程可以通过
* Thread.currentThread().interrupt(); 方法真正中断该线程
*/
void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException;
/**
* 这个应该是最常用的,尝试获取锁
* waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败
* leaseTime 锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完)
*/
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
/**
* 锁的有效期设置为 leaseTime,过期后自动失效
* 如果 leaseTime 设置为 -1, 表示不主动过期
*/
void lock(long leaseTime, TimeUnit unit);
/**
* Unlocks the lock independently of its state
*/
boolean forceUnlock();
/**
* 检查是否被另一个线程锁住
*/
boolean isLocked();
/**
* 检查当前线线程是否持有该锁
*/
boolean isHeldByCurrentThread();
/**
* 这个就明了了,检查指定线程是否持有锁
*/
boolean isHeldByThread(long threadId);
/**
* 返回当前线程持有锁的次数
*/
int getHoldCount();
/**
* 返回锁的剩余时间
* @return time in milliseconds
* -2 if the lock does not exist.
* -1 if the lock exists but has no associated expire.
*/
long remainTimeToLive();
}
Demo:
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("").setDatabase(1);
RedissonClient redissonClient = Redisson.create(config);
RLock disLock = redissonClient.getLock("mylock");
boolean isLock;
try {
/**
* 尝试获取锁的最大等待时间是 100 秒,超过这个值还没获取到,就认为获取失败
* 锁的持有时间是 10 秒
*/
isLock = disLock.tryLock(100, 10, TimeUnit.MILLISECONDS);
if (isLock) {
//做自己的业务
Thread.sleep(10000);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
disLock.unlock();
}
Reission的加锁方法
@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
// 获取等锁的最长时间
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
//取得当前线程id(判断是否可重入锁的关键)
long threadId = Thread.currentThread().getId();
// 【核心点1】尝试获取锁,若返回值为null,则表示已获取到锁,返回的ttl就是key的剩余存活时间
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
return true;
}
// 还可以容忍的等待时长 = 获取锁能容忍的最大等待时长 - 执行完上述操作流程的时间
time -= System.currentTimeMillis() - current;
if (time <= 0) {
//等不到了,直接返回失败
acquireFailed(waitTime, unit, threadId);
return false;
}
current = System.currentTimeMillis();
/**
* 【核心点2】
* 订阅解锁消息 redisson_lock__channel:{$KEY},并通过await方法阻塞等待锁释放,解决了无效的锁申请浪费资源的问题:
* 基于信息量,当锁被其它资源占用时,当前线程通过 Redis 的 channel 订阅锁的释放事件,一旦锁释放会发消息通知待等待的线程进行竞争
* 当 this.await返回false,说明等待时间已经超出获取锁最大等待时间,取消订阅并返回获取锁失败
* 当 this.await返回true,进入循环尝试获取锁
*/
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
//await 方法内部是用CountDownLatch来实现阻塞,获取subscribe异步执行的结果(应用了Netty 的 Future)
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
unsubscribe(subscribeFuture, threadId);
}
});
}
acquireFailed(waitTime, unit, threadId);
return false;
}
// ttl 不为空,表示已经有这样的key了,只能阻塞等待
try {
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
// 来个死循环,继续尝试着获取锁
while (true) {
long currentTime = System.currentTimeMillis();
ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
return true;
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
currentTime = System.currentTimeMillis();
/**
* 【核心点3】根据锁TTL,调整阻塞等待时长;
* 1、latch其实是个信号量Semaphore,调用其tryAcquire方法会让当前线程阻塞一段时间,避免在while循环中频繁请求获锁;
* 当其他线程释放了占用的锁,会广播解锁消息,监听器接收解锁消息,并释放信号量,最终会唤醒阻塞在这里的线程
* 2、该Semaphore的release方法,会在订阅解锁消息的监听器消息处理方法org.redisson.pubsub.LockPubSub#onMessage调用;
*/
//调用信号量的方法来阻塞线程,时长为锁等待时间和租期时间中较小的那个
if (ttl >= 0 && ttl < time) {
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
}
} finally {
// 获取到锁或者抛出中断异常,退订redisson_lock__channel:{$KEY},不再关注解锁事件
unsubscribe(subscribeFuture, threadId);
}
}
上边介绍的分布式锁,在某些极端情况下仍然是有缺陷的
客户端长时间内阻塞导致锁失效
客户端 1 得到了锁,因为网络问题或者 GC 等原因导致长时间阻塞,然后业务程序还没执行完锁就过期了,这时候客户端 2 也能正常拿到锁,可能会导致线程安全的问题。
Redis 服务器时钟漂移
如果 Redis 服务器的机器时间发生了向前跳跃,就会导致这个 key 过早超时失效,比如说客户端 1 拿到锁后,key 还没有到过期时间,但是 Redis 服务器的时间比客户端快了 2 分钟,导致 key 提前就失效了,这时候,如果客户端 1 还没有释放锁的话,就可能导致多个客户端同时持有同一把锁的问题
单点实例安全问题
如果 Redis 是单机模式的,如果挂了的话,那所有的客户端都获取不到锁了,假设你是主从模式,但 Redis 的主从同步是异步进行的,如果 Redis 主宕机了,这个时候从机并没有同步到这一把锁,那么机器 B 再次申请的时候就会再次申请到这把锁,这也是问题
为了解决这些个问题 Redis 作者提出了 RedLock 红锁的算法,在 Redission 中也对 RedLock 进行了实现。
Redis 官网对 redLock 算法的介绍大致如下:
客户端在多个 Redis 实例上申请加锁,必须保证大多数节点加锁成功
解决容错性问题,部分实例异常,剩下的还能加锁成功
大多数节点加锁的总耗时,要小于锁设置的过期时间
多实例操作,可能存在网络延迟、丢包、超时等问题,所以就算是大多数节点加锁成功,如果加锁的累积耗时超过了锁的过期时间,那有些节点上的锁可能也已经失效了,还是没有意义的
释放锁,要向全部节点发起释放锁请求
如果部分节点加锁成功,但最后由于异常导致大部分节点没加锁成功,就要释放掉所有的,各节点要保持一致。
主从复制,或者叫 主从同步,是指将一台 Redis 服务器的数据,复制到其他的 Redis 服务器。前者称为 主节点(master),后者称为 从节点(slave)。且数据的复制是 单向 的,只能由主节点到从节点。
Redis 主从复制支持 主从同步 和 从从同步 两种,后者是 Redis 后续版本新增的功能,以减轻主节点的同步负担。
以下三种方式是完全等效的:
第一阶段是主从库间建立连接、协商同步的过程,主要是为全量复制做准备。在这一步,从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。
第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。
第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer
中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。
一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为 基于长连接的命令传播,可以避免频繁建立连接的开销。
在 Redis 2.8 之前,如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新进行一次全量复制,开销非常大。
从 Redis 2.8 开始,网络断了之后,主从库会采用 增量复制 的方式继续同步。
增量复制的原理主要是靠主从节点分别维护一个 复制偏移量,有了这个偏移量,断线重连之后一比较,之后就可以仅仅把从服务器断线之后缺失的这部分数据给补回来了。
全量复制中有 replication buffer 这样的缓存区来保存 RDB 文件生成后收到的所有写操作,增量复制中也有一个缓存区,叫 repl_backlog_buffer ,默认是 1M。当主从库断连后,主库会把断连期间收到的写操作命令,写入 replication buffer,同时也会把这些操作命令也写入 repl_backlog_buffer 这个缓冲区。
可以从以下几个方面来优化Redis主从就集群:
上图 展示了一个典型的哨兵架构图,它由两部分组成,哨兵节点和数据节点:
在复制的基础上,哨兵实现了自动化的故障恢复功能:
我们先回顾下前边介绍的几种 Redis 高可用方案:持久化、主从同步和哨兵机制。但这些方案仍有痛点,其中最主要的问题就是存储能力受单机限制,以及没办法实现写操作的负载均衡。
集群由多个节点(Node)组成,Redis 的数据分布在这些节点中。集群中的节点分为主节点和从节点:只有主节点负责读写请求和集群信息的维护;从节点只进行主节点数据和状态信息的复制。
再结合集群的配置信息就能够知道这个 key 值应该存储在哪一个具体的 Redis 节点中,如果不属于自己管,那么就会使用一个特殊的 MOVED 命令来进行一个跳转,告诉客户端去连接这个节点以获取数据: