五种基本数据类型
https://www.cnblogs.com/ysocean/p/9080942.html 底层数据结构
五大数据类型
- help @类型 查看该类型的所有命令, 例: help @list
- help 方法 查看方法说明, 例: help incr
String类型
最常用, 最基本的数据类型, String类型是二进制安全的, 能保存像图片、音频、视频、压缩文件这样的二进制数据。
C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
举个例子,如果有一种使用空字符来分割多个单词的特殊数据格式,如图所示,那么这种格式就不能使用C字符串来保存,因为C字符串所用的函数只会识别出其中的"Redis",而忽略之后的"Cluster"。 比如: ‘r’ ‘e’ ‘d’ ‘i’ ‘s’ ‘\0’ ‘c’, c语言字符串会忽略后’\0’后面的字符。
- 字符串 embstr
- 数字 → incr key
- bitmap
String部分命令
- mset 同时设置多个key、value, 原子的, 同时成功或者失败, 同时会节省网络传输的性能消耗, 好像还有个批量处理
127.0.0.1:6379[7]> mset k1 v1 k2 v2
OK
127.0.0.1:6379[7]> keys *
1) "k2"
2) "k1"
127.0.0.1:6379[7]> get k1
"v1"
127.0.0.1:6379[7]> get k2
"v2"
- set key value nx ex 秒
127.0.0.1:6379[7]> help set
SET key value [EX seconds] [PX milliseconds] [NX|XX]
summary: Set the string value of a key
since: 1.0.0
group: string
127.0.0.1:6379[7]> set k1 hhhh nx ex 20 //k1上面已经设置过, 故失败
(nil)
127.0.0.1:6379[7]> set k3 hhhh nx ex 20
OK
127.0.0.1:6379[7]> get k3
"hhhh"
127.0.0.1:6379[7]> ttl k3
(integer) 10
127.0.0.1:6379[7]> ttl k3
(integer) 5
127.0.0.1:6379[7]>
-
incr、incrby、decr、decrby
可以实现限制登录, 比如5分钟登录三次, 失败就不能再登录了, 设置过期时间5分钟, incr key不会重置过期时间。
127.0.0.1:6379[7]> set k4 2
OK
127.0.0.1:6379[7]> get k4
"2"
127.0.0.1:6379[7]> incr k4
(integer) 3
127.0.0.1:6379[7]> get k4
"3"
127.0.0.1:6379[7]> incrby k4 5
(integer) 8
127.0.0.1:6379[7]> decr k4
(integer) 7
127.0.0.1:6379[7]> decrby k4 2
(integer) 5
127.0.0.1:6379[7]>
注: 一个key设置了过期时间, 新set时没有设置过期时间, 那么则会变为永不过期。
String底层数据结构
命令: object encoding key
- embstr
- int
- bitmap
hash类型
是一个键值对的集合, 类比于 Java里面的 Map<String,Map<String,Object>> 集合。
部分命令
- HSET key field value
- HGET key field
- HDEL key field [field …] summary: Delete one or more hash fields
- HEXISTS key field 查看哈希表中指定域field是否存在, 存在返回1, 不存在返回0
- HGETALL key 返回哈希表中所有域field和值value
- HINCRBY key field increment 哈希表中指定域field的值加上增量increment
- HINCRBYFLOAT key field increment 增加浮点数
- HKEYS key 获取hash表中所有域field
- HVALS key 获取hash表中所有value
- HLEN key 获取hash表中所有域的数量
- 更多见: help @hash 命令
hash底层数据结构
object encoding hashKey —> ziplist
list列表类型
有序(存入的顺序), 可以添加一个元素到列表的头部(左边)或者尾部(右边), 可以重复, 底层是链表结构。
部分命令
- LPUSH key value [value …] 将一个或多个value插入到列表的表头(左边), 多个value时从左到右依次插入表头
- LPUSHX key value 存在列表时, 才插入, 不存在则不创建不做任何操作
- LPOP key 弹出列表头部的元素, 最左边的
- LRANGE key start stop summary: Get a range of elements from a list 可使用负数下标, -1表示最后一个, -2倒数第二个
- LREM key count value Remove elements from a list count为移除的数量以及方向, 具体没试
- **LSET key index value ** summary: Set the value of an element in a list by its index
- LINDEX key index summary: Get an element from a list by its index 只获取不移除
- LINSERT key BEFORE|AFTER pivot value key或pivot不存在时不进行任何操作
127.0.0.1:6379[7]> help linsert
LINSERT key BEFORE|AFTER pivot value
summary: Insert an element before or after another element in a list
since: 2.2.0
group: list
127.0.0.1:6379[7]> linsert listKey after 12 24
(integer) 3
127.0.0.1:6379[7]> lrange listKey 0 -1
1) "haha"
2) "12"
3) "24"
127.0.0.1:6379[7]>
-
LLEN key 返回列表的长度, key不存在时, 返回0
-
LTRIM key start stop 对列表进行修剪, 不在指定区间的元素都会被移除
-
**RPUSH key value [value …] ** 将一个或多个value插入到列表的尾部(右边), 多个value时从左到右依次插入尾部
-
RPOP key 弹出列表尾部的元素, 最右边的
-
RPUSHX key value 存在列表时, 才插入, 不存在则不创建不做任何操作
-
RPOPLPUSH source destination summary: Remove the last element in a list, prepend it to another list and return it
-
**BLPOP key [key …] timeout ** summary: Remove and get the first element in a list, or block until one is available
-
BRPOP key [key …] timeout summary: Remove and get the last element in a list, or block until one is available
-
BRPOPLPUSH source destination timeout summary: Pop a value from a list, push it to another list and return it; or block until one is available
应用
- 栈: lpush + lpop
- 队列: lpush + rpop
- 有限集合: lpush + ltrim
- 消息队列: lpush + brpop
list底层数据结构
object encoding listKey —> quicklist
set类型
无序, 不可重复,
命令
- SADD key member [member …] summary: Add one or more members to a set
- SCARD key summary: Get the number of members in a set
- SDIFF key [key …] 返回第一个key的集合和后面key的集合的差集 , 例: k1: aa bb, k2: bb cc, sdiff k1 k2:aa
127.0.0.1:6379[7]> sadd setK aa bb aa
(integer) 2
127.0.0.1:6379[7]> scard setK
(integer) 2
127.0.0.1:6379[7]> sadd setK2 bb cc
(integer) 2
127.0.0.1:6379[7]> sdiff setK setK2
1) "aa"
127.0.0.1:6379[7]>
- SDIFFSTORE destination key [key …] 同上面, 并将结果保存到新的结果集, 如destination 已存在, 则覆盖
- SINTER key [key …] 取交集
- SUNION key [key …] 返回并集
- SUNIONSTORE destination key [key …] 取并集并存储到新的集合
- SDIFFSTORE destination key [key …] 同上面, 并将结果保存到新的结果集, 如destination 已存在, 则覆盖
- SISMEMBER key member 判断元素是否在set中, 0: 不在, 1: 在
- **SMEMBERS key ** 返回集合中所有元素
- SMOVE source destination member 移动source中的元素到destination中, 若source没有, 则不操作, 若destination中有则覆盖
- **SPOP key [count] **随机移除set中count个元素
- count大于元素个数时, 返回整个集合
- count为负数时, 返回的元素可能有重复, 个数为绝对值 实际测试不行
- SRANDMEMBER key [count] 随机返回count个元素, 但是不移除, 和上面pop不同
- SREM key member [member …] summary: Remove one or more members from a set
- **SSCAN key cursor [MATCH pattern] [COUNT count] **
- *sscan setKey 0 match h COUNT 3 **
- 每次调用返回一个新的游标 cursor, 用户下次迭代时使用这个作为参数, 来延续之前的迭代过程
- sscan命令参数为0时, 服务器开始新一轮的迭代, 返回0时, 表示迭代已经结束
- count: 每次返回多少元素(但是不能保证?) 默认值为10
- matcn: 和keys命令一样, 可以提供一个glob风格的模式参数, 让命令返回给定模式匹配的元素
set典型应用场景
利用集合的交并集特性,比如在社交领域,我们可以很方便的求出多个用户的共同好友,共同感兴趣的领域等。
set 底层数据结构
object encoding setK —> “hashtable”
zset数据类型
help @**sorted_set **
有序的set
部分命令
- 菜鸟: https://www.runoob.com/redis/redis-sorted-sets.html
- **ZADD key [NX|XX] [CH] [INCR] score member [score member …] **
- 如果member已经是有序集中的元素, 那么更新score值
- score可以是整数或者双精度浮点数
- ZCARD key 返回有序集的元素个数
- ZCOUNT key min max 计算在有序集合中指定区间分数的成员个数
- ZINCRBY key increment member 有序集合中对指定成员的分数加上增量 increment
- ZINTERSTORE destination numkeys key [key …] 计算给定的一个或多个有序集的交集并将结果集存储在新的有序集合 destination 中
- ZLEXCOUNT key min max 在有序集合中计算指定字典区间内成员数量?** 和上面zcount区别?**
- ZRANGE key start stop [WITHSCORES] 通过索引区间返回有序集合指定区间内的成员, 根据score排好序的, 从小到大
- ZRANGEBYLEX key min max [LIMIT offset count] 通过字典区间返回有序集合的成员
- ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT] 通过分数返回有序集合指定区间内的成员
- ZRANK key member 返回有序集合中指定成员的索引
- ZREM key member [member …] 移除有序集合中的一个或多个成员
- ZREMRANGEBYLEX key min max 移除有序集合中给定的字典区间的所有成员
- ZREMRANGEBYRANK key start stop 移除有序集合中给定的排名区间的所有成员
- ZREMRANGEBYSCORE key min max 移除有序集合中给定的分数区间的所有成员
- ZREVRANGE key start stop [WITHSCORES] 返回有序集中指定区间内的成员,通过索引,分数从高到低
- ZREVRANGEBYSCORE key max min [WITHSCORES] 返回有序集中指定分数区间内的成员,分数从高到低排序
- ZREVRANK key member 返回有序集合中指定成员的排名,有序集成员按分数值递减(从大到小)排序
- ZSCORE key member 返回有序集中,成员的分数值
- **ZUNIONSTORE destination numkeys key [key …] ** 计算给定的一个或多个有序集的并集,并存储在新的 key 中
- ZSCAN key cursor [MATCH pattern] [COUNT count] 迭代有序集合中的元素(包括元素成员和元素分值)
zset典型应用场景
和set数据结构一样,zset也可以用于社交领域的相关业务,并且还可以利用zset 的有序特性,还可以做类似排行榜的业务。
zset底层数据结构
object encoding zsetK —> “ziplist” 、 skipList
和元素数量以及元素长度有关
zipList
:满足以下两个条件[score,value]
键值对数量少于128个;- 每个元素的长度小于64字节;
skipList
:不满足以上两个条件时使用跳表、组合了hash
和skipList
hash
用来存储value
到score
的映射,这样就可以在O(1)
时间内找到value
对应的分数;skipList
按照从小到大的顺序存储分数skipList
每个元素的值都是[socre,value]
对
底层数据结构
命令: object encoding key
- String : embStr、int、bitmap
- hash : ziplist
- list : quickList
- set : hashtable
- zset : ziplist 、skiplist
https://www.cnblogs.com/ysocean/p/9080942.html#_label3
高级数据类型
管道
高并发场景下, 网络开销成为瓶颈时, 使用管道来实现突破
一个完整的交互流程如下:
- 客户端进程调用
write()
把消息写入到操作系统内核为Socket分配的send buffer中 - 操作系统会把send buffer中的内容写入网卡,网卡再通过网关路由把内容发送到服务器端的网卡
- 服务端网卡会把接收到的消息写入操作系统为Socket分配的recv buffer
- 服务器进程调用
read()
读取消息然后进行处理 - 处理完成后调用
write()
把返回结果写入到服务器端的send buffer - 服务器操作系统再将send buffer中的内容写入网卡,然后发送到客户端
- 客户端操作系统将网卡内容读到recv buffer中
- 客户端进程调用
read()
从recv buffer中读取消息并返回
现在我们把命令执行的时间进一步细分:
命令的执行时间 = 客户端调用write并写网卡时间+一次网络开销的时间+服务读网卡并调用read时间++服务器处理数据时间+服务端调用write并写网卡时间+客户端读网卡并调用read时间
这其中除了网络开销,花费时间最长的就是进行系统调用write()
和read()
了,这一过程需要操作系统由用户态切换到内核态,中间涉及到的上下文切换会浪费很多时间。
使用管道时,多个命令只会进行一次read()
和wrtie()
系统调用,因此使用管道会提升Redis服务器处理命令的速度,随着管道中命令的增多,服务器每秒处理请求的数量会线性增长,最后会趋近于不使用管道的10倍。
HyperLogLog
pv: (Page View)访问量, 即页面浏览量或点击量
UV(Unique Visitor)独立访客,统计1天内访问某站点的用户数(以cookie为依据);访问网站的一台电脑客户端为一个访客。
IP(Internet Protocol)独立IP数,是指1天内多少个独立的IP浏览了页面,即统计不同的IP浏览用户数量
实际上目前还没有发现更好的在 大数据场景 中 准确计算 基数的高效算法,因此在不追求绝对精确的情况下,使用概率算法算是一个不错的解决方案。
概率算法 不直接存储 数据集合本身,通过一定的 概率统计方法预估基数值,这种方法可以大大节省内存,同时保证误差控制在一定范围内。
HyperLogLog 提供不精确的去重计数方案,虽然不精确但是也不是非常不精确,标准误差是 0.81%,这样的精确度已经可以满足上面的 UV 统计需求了。
命令
# 添加元素到HyperLogLog中
PFADD key value
# 返回基数估算值
PFCOUNT key value
geo
Redis GEO 主要用于存储地理位置信息,并对存储的信息进行操作,该功能在 Redis 3.2 版本新增。
发布/订阅 (pub/sub)
不能持久化, 5.0版本新增了Stream数据结构, Redis Stream 提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。
命令
1 | [PSUBSCRIBE pattern [pattern …]](https://www.runoob.com/redis/pub-sub-psubscribe.html) 订阅一个或多个符合给定模式的频道。 |
2 | [PUBSUB subcommand [argument [argument …]]](https://www.runoob.com/redis/pub-sub-pubsub.html) 查看订阅与发布系统状态。 |
3 | PUBLISH channel message 将信息发送到指定的频道。 |
4 | [PUNSUBSCRIBE [pattern [pattern …]]](https://www.runoob.com/redis/pub-sub-punsubscribe.html) 退订所有给定模式的频道。 |
5 | [SUBSCRIBE channel [channel …]](https://www.runoob.com/redis/pub-sub-subscribe.html) 订阅给定的一个或多个频道的信息。 |
6 | [UNSUBSCRIBE [channel [channel …]]](https://www.runoob.com/redis/pub-sub-unsubscribe.html) 指退订给定的频道。 |
示例:
#发布者 向 runoobChat 发布了 "Learn redis by runoob.com"消息
redis 127.0.0.1:6379> PUBLISH runoobChat "Learn redis by runoob.com"
(integer) 1
# 订阅者的客户端会显示如下消息
1) "message" // 消息
2) "runoobChat" // key
3) "Learn redis by runoob.com" //消息内容
Stream
不能持久化, 5.0版本新增了Stream数据结构, Redis Stream 提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。
延迟队列
sorted set实现, score设置为时间戳
使用zadd key score1 value1命令生产消息,使用zrangebysocre key min max withscores limit 0 1消费消息最早的一条消息。
https://blog.csdn.net/u010634066/article/details/98864764
线程模型
主从复制数据同步
锁
可靠性
首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
- 互斥性。在任意时刻,只有一个客户端能持有锁。
- 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
- 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
- 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
关键点
- 原子命令加锁
set key random_value NX EX 30000, 30秒根据业务而定
- random_value: value的值需要所有客户端获取的值都是唯一的, 设置随机数是为了更安全的释放锁, 释放锁的时候需要判断key是否存在, 且对应的值是否和我指定的值一样, 一样的才能释放
- 释放锁整个过程涉及获取、判断、删除三个操作, 为了保障原子性, 需要使用lua脚本。
上锁代码、解锁代码
依赖
jedis客户端
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
上锁代码
public class RedisTool {
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
/**
* 尝试获取分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识, 客户端唯一
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
释放锁代码
需要借助lua脚本保证原子性
public class RedisTool {
private static final Long RELEASE_SUCCESS = 1L;
/**
* 释放分布式锁(lua脚本实现,保证原子操作)
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// evald() 是将lua代码交给Redis执行
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
集群模式
单点模式可以保证上面可靠性的124点, 但是无法保证容错性。
为了避免单节点挂掉的问题, 可以使用集群的方式, redis集群有主从模式、哨兵模式、Redis Cluster模式三种。
- 主从模式: 会把数据同步到从节点, 但是在主节点宕机后需要手动切换;
- 哨兵模式: 可以理解为主从模式的升级版, 该模式下会对响应异常的主节点进行主观下线或者客观下线的操作,并进行主从切换。它可以保证高可用;
- redis cluster: 保证高并发, 数据分片处理, 不同的key根据crc16算法落在不同的槽中, 分为16384个槽位
集群模式问题
由于节点之间是采用异步通信的方式。如果刚刚在 Master 节点上加了锁,但是数据还没被同步到 Salve。这时 Master 节点挂了,它上面的锁就没了,等新的 Master 出来后(主从模式的手动切换或者哨兵模式的一次 failover 的过程),就可以再次获取同样的锁,出现一把锁被拿到了两次的场景。
锁都被拿了两次了,也就不满足安全性了。一个安全的锁,不管是不是分布式的,在任意一个时刻,都只有一个客户端持有。
Redlock
在 Redis 的分布式环境中,我们假设有 N 个 Redis Master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。
前面已经描述了在单点 Redis 下,怎么安全地获取和释放锁,我们确保将在 N 个实例上使用此方法获取和释放锁。
在下面的示例中,我们假设有 5 个完全独立的 Redis Master 节点,他们分别运行在 5 台服务器中,可以保证他们不会同时宕机。
从官网上我们可以知道,一个客户端如果要获得锁,必须经过下面的五个步骤:
- 获取当前 Unix 时间,以毫秒为单位。
- 依次尝试从 N 个实例,使用相同的 key 和随机值获取锁。在步骤 2,当向 Redis 设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以避免服务器端 Redis 已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个 Redis 实例。
- 客户端使用当前时间减去开始获取锁时间(步骤 1 记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是 3 个节点)的 Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤 3 计算的结果)。
- 如果因为某些原因,获取锁失败(没有在至少 N/2+1 个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功)。
通过上面的步骤我们可以知道,只要大多数的节点可以正常工作,就可以保证 Redlock 的正常工作。这样就可以解决前面单点 Redis 的情况下我们讨论的节点挂掉,由于异步通信,导致锁失效的问题。
但是,还是不能解决故障重启后带来的锁的安全性的问题。你想一下下面这个场景:
我们一共有 A、B、C 这三个节点。
- 客户端 1 在 A,B 上加锁成功。C 上加锁失败。
- 这时节点 B 崩溃重启了,但是由于持久化策略导致客户端 1 在 B 上的锁没有持久化下来。
- 客户端 2 发起申请同一把锁的操作,在 B,C 上加锁成功。
- 这个时候就又出现同一把锁,同时被客户端 1 和客户端 2 所持有了。
(接下来又得说一说Redis的持久化策略了,全是知识点啊,朋友们)
比如,Redis 的 AOF 持久化方式默认情况下是每秒写一次磁盘,即 fsync 操作,因此最坏的情况下可能丢失 1 秒的数据。
当然,你也可以设置成每次修改数据都进行 fsync 操作(fsync=always),但这会严重降低 Redis 的性能,违反了它的设计理念。(我也没见过这样用的,可能还是见的太少了吧。)
而且,你以为执行了 fsync 就不会丢失数据了?天真,真实的系统环境是复杂的,这都已经脱离 Redis 的范畴了。上升到服务器、系统问题了。
所以,根据墨菲定律,上面举的例子:由于节点重启引发的锁失效问题,总是有可能出现的。
为了解决这一问题,Redis 的作者又提出了延迟重启(delayed restarts)**的概念。
意思就是说,一个节点崩溃后,不要立即重启它,而是等待一定的时间后再重启。等待的时间应该大于锁的过期时间(TTL)。这样做的目的是保证这个节点在重启前所参与的锁都过期。相当于把以前的帐勾销之后才能参与后面的加锁操作。
但是有个问题就是:在等待的时间内,这个节点是不对外工作的。那么如果大多数节点都挂了,进入了等待。就会导致系统的不可用,因为系统在TTL时间内任何锁都将无法加锁成功。
Redlock 算法还有一个需要注意的点是它的释放锁操作。
**释放锁的时候是要向所有节点发起释放锁的操作的。**这样做的目的是为了解决有可能在加锁阶段,这个节点收到加锁请求了,也set成功了,但是由于返回给客户端的响应包丢了,导致客户端以为没有加锁成功。所有,释放锁的时候要向所有节点发起释放锁的操作。
你可以觉得这不是常规操作吗?
有的细节就是这样,说出来后觉得不过如此,但是有可能自己就是想不到这个点,导致问题的出现,所以我们才会说:细节,魔鬼都在细节里。
Redisson
Redisson是redis推荐使用的实现分布式锁的框架。
代码实现
依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.15.6</version>
</dependency>
配置注入
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient getClient(){
Config config = new Config();
// 单点模式
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
// redis-cluster模式
// config.useClusterServers()
// .addNodeAddress("redis://127.0.0.1:6379")
// .addNodeAddress("redis://127.0.0.1:6380")
// .addNodeAddress("redis://127.0.0.1:6381");
// sentinel 哨兵模式, 参数应该是这么写吧, 参数: String... address
// config.useSentinelServers()
// .addSentinelAddress("redis://127.0.0.1:26379", "redis://127.0.0.1:26380", "redis://127.0.0.1:26381")
RedissonClient redisson = Redisson.create(config);
return redisson;
}
}
使用
@RestController
@RequestMapping
public class TestController {
@Autowired
private RedissonClient redisson;
@GetMapping("test")
public void test(String lockName){
RLock lock = redisson.getLock(lockName);
try{
// 1. 最常见的使用方法, 无返回值, 默认30秒释放锁 internalLockLeaseTime
// lock.lock();
// 2. 支持过期解锁功能,10秒钟以后自动解锁, 无需调用unlock方法手动解锁, 无返回值
// lock.lock(10, TimeUnit.SECONDS);
// 3. 尝试加锁,最多等待2秒,上锁以后30秒自动解锁
boolean res = lock.tryLock(2, 30, TimeUnit.SECONDS);
if(res){ //成功
System.out.println("1处理业务");
System.out.println("1业务处理完成");
}
} catch (Exception e) {
e.printStackTrace();
} finally {
// 4. 释放锁 lock.isHeldByCurrentThread() 判断是否当前线程持有锁
System.out.println("1释放锁");
lock.unlock();
}
}
}
常用方法总结
1、lock.lock()
最常见的使用方法, 无返回值, 默认30秒释放锁 internalLockLeaseTime, 但是实际业务处理睡50秒, 50秒后执行完才释放(因为有看门狗续期, 具体内容看后面内容)
2、lock.lock(10, TimeUnit.SECONDS);
支持过期解锁功能,10秒钟以后自动解锁, 无需调用unlock方法手动解锁, 无返回值
3、lock.tryLock(2, 30, TimeUnit.SECONDS)
尝试加锁,最多等待2秒,上锁以后30秒自动解锁, 返回值boolean
注意加不上锁的时候, finally中需要判断一下是否获取到再释放 lock.isHeldByCurrentThread()
可重入锁
源码实现是一段lua脚本如下:
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}
根据上面参数列表, 可以看到脚本后第一个参数就是key, 后两个参数(变长数组)就是两个参数, 三个参数说明:
- Collections.singletonList(getRawName()): 就是锁名称, 也就是redisson.getLock(lockName)中传的参数;
- unit.toMillis(leaseTime): 过期时长, 默认值为: internalLockLeaseTime 30秒;
- getLockName(threadId): 返回的是uuid + ThreadId
再来分析上面的lua脚本(加锁流程):
首次加锁:(lockName为锁名)
其实就是前半部分lua脚本
- exists命令判断lockName是否存在;
- 不存在则使用hincrby命令, 创建lockName, 注意是个hash结构
- 设置过期时间
- 首次加锁成功redis中数据结构如下:
localhost:0>hgetall lockName // 锁名
1) "35865ccc-a28b-4226-bddc-577a001e4dd0:70" //uuid + 线程id
2) "1" //第一次为1
可重入原理:(lockName为锁名)
其实就是后半部分lua脚本, 上半部分前提: lockName存在
- hexists判断uuid + 线程id这个hashKey是否存在
- 存在则hincrby加1
- 设置过期时长
lua最后一行
"return redis.call('pttl', KEYS[1]);",
就是redis key存在, hash ksy不存在的场景, 说明加锁的不是当前线程, 返回当前锁的剩余时间。
寻址
int result = CRC16.crc16(key.getBytes()) % MAX_SLOT;
crc16(lockName) % 16384 找到哈希槽
然后后面如果使用的是redis-cluster集群的话就是确定hash槽了(实际过程肯定是在执行lua之前, 代码看到这就先写了)
整个流程(看门狗在后面)
看门狗(维护锁)
实际使用中, lock.lock()如果当前业务线程还在执行, 并不会过30秒默认值释放, 原因就是看门狗续租。
如果指定过期时间, 则不会进行看门狗续租。
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getRawName() + " expiration", e);
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
return;
}
if (res) {
// reschedule itself
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.singletonList(getRawName()),
internalLockLeaseTime, getLockName(threadId));
}
看门狗的一部分重点逻辑就在 renewExpiration
方法这里:
- 延迟调度,延迟时间为:
internalLockLeaseTime / 3
,就是 10s 左右后会调度这个 TimerTask; - 异步续租:逻辑都在 renewExpirationAsync 里面;
- 递归调用:当续租成功之后,重新调用
renewExpiration
自己,从而达到持续续租的目的; - 当然也不能一直无限续租,所以中间有一些判断逻辑,就是用来中断续租的。
看门狗总结:
- 只有在未指定锁超时时间时才会使用看门狗;
- 看门狗默认续租时间是 10s 左右,
internalLockLeaseTime / 3
; - 可以通过 Config 统一设置看门狗的时间,设置
lockWatchdogTimeout
参数即可。
杜兰特(儒猿管理员):
3、剖析
1、客户端线程在底层是如何实现加锁的?
(1)先定位master节点:
通过key计算出CRC16值,再CRC16值对16384取模得hash slot,通过这个hash slot定位redis-cluster集群中的master节点
(2)加锁:
加锁逻辑底层是通过lua脚本来实现的,如果客户端线程第一次去加锁的话,会在key对应的hash数据结构中添加线程标识UUID:ThreadId 1,指定该线程当前对这个key加锁一次了。
杜兰特(儒猿管理员):
2、客户端线程是如何维持加锁的?
当加锁成功后,此时会对加锁的结果设置一个监听器,如果监听到加锁成功了,也就是返回的结果为空,此时就会在后台通过watchdog看门狗机制、启动一个后台定时任务,每隔10s执行一次,检查如果key当前依然存在,就重置key的存活时间为30s。
维持加锁底层就是通过后台这样的一个线程定时刷新存活时间维持的。
TODO
@所有人
3、分享结束
本次技术分享《图解Redis分布式锁源码-可重入锁的八大机制-上》到此结束,内容较多,大家可以认真看看,好好消化
下次将继续分享《图解Redis分布式锁源码-可重入锁的八大机制-下》,大纲如下:
1:其他线程加锁失败时,底层是如何实现阻塞的? —循环调用tryAcquire方法尝试加锁
2:客户端宕机了,锁是如何释放的?
3:客户端如何主动释放持有的锁?
4:客户端尝试获取锁超时的机制在底层是如何实现的?
5:客户端锁超时自动释放机制在底层又是如何实现的?
可重入锁互斥
其他线程加锁失败时, 底层是如何实现阻塞的?
源码定位: org.redisson.RedissonLock#lock(long, java.util.concurrent.TimeUnit, boolean)
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
// lock acquired
// 加锁失败返回当前锁剩余时间
if (ttl == null) {
return;
}
RFuture<RedissonLockEntry> future = subscribe(threadId);
if (interruptibly) {
commandExecutor.syncSubscriptionInterrupted(future);
} else {
commandExecutor.syncSubscription(future);
}
try {
while (true) {
// 循环加锁直到成功 多线程都在竞争, 非公平锁, 公平锁见后续章节
ttl = tryAcquire(-1, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
break;
}
// waiting for message
if (ttl >= 0) {
try {
future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
if (interruptibly) {
throw e;
}
future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
} else {
if (interruptibly) {
future.getNow().getLatch().acquire();
} else {
future.getNow().getLatch().acquireUninterruptibly();
}
}
}
} finally {
unsubscribe(future, threadId);
}
// get(lockAsync(leaseTime, unit));
}
总结:
- 可重入锁的互斥是依靠 Redis Lua 脚本来保证的;
- 加锁失败会返回当前锁的剩余时间;
- 加锁失败后,会在 Java 代码中使用 while 循环一直尝试加锁。
可重入锁释放
主动释放锁
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
主要就是个释放锁的lua脚本
- 如果锁不存在,直接返回 null;
- 如果锁存在,则对锁的重入次数 -1;
-
- 剩余重入次数大于 0,重新设置过期时间,返回 0; —> 这里设置过期时间的原因, 重新赋值后不设置值, 就是永不过期
- 剩余重入次数不大于 0,删除 redis key 并发布消息,返回 1;
主动释放锁这块考虑的不仅仅是对 key 进行处理,因为可能存在重入锁,所以会先对 redis key 对应的 hash value 进行递减,相当于减去重入次数。
被动释放锁
相比较主动释放,自动释放就比较容易理解了。
- 当服务宕机时,看门狗不再看门,那么最多 30s 之后锁被自动释放;
- 当设置锁的时间时,锁到了时间,自动释放。
公平锁
redisson.getFairLock(“anyLock”);
RLock fairLock = redisson.getFairLock("anyLock");
fairLock.lock();
总结:
- Redis Hash 数据结构:存放当前锁,Redis Key 就是锁,Hash 的 field 是加锁线程,Hash 的 value 是 重入次数;
- Redis List 数据结构:充当线程等待队列,新的等待线程会使用 rpush 命令放在队列右边;
- Redis sorted set 有序集合数据结构:存放等待线程的顺序,分数 score 用来是等待线程的超时时间戳。
读写锁
Semaphore和CountDownLatch
Redisson 除了提供了分布式锁之外,还额外提供了同步组件,Semaphore 和 CountDownLatch。
缓存
什么是缓存穿透?缓存击穿?缓存雪崩?
缓存穿透
缓存穿透是指缓存和数据库中都不存在的数据, 比如id=-1或者特别大不存在的数据, 攻击请求会导致数据库压力大;
解决方法:
- 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
- 从缓存没取到, 从数据库也没取到, 可以在缓存中设置key-null, 过期时间设置短一点, 比如30秒, 设置太长会导致正常情况也没法使用, 这杨可以防止攻击用户使用同一个id暴力攻击
- 使用布隆过滤器
缓存击穿
缓存击穿是指缓存中没有但是数据库中有的数据(一般是缓存到期), 这时由于并发用户多, 同时读缓存没有读取到数据, 又同时读数据库, 引起数据库压力瞬间增大, 造成过大压力。
解决方法:
- 设置热点数据永不过期
- 加互斥锁, 互斥锁参考代码:
说明:
![](https://gitee.com/the_OnlyOne/document/raw/markdown-picture/2020/20210719155400.png)
1. 缓存中有数据, 直接13行走缓存返回;
2. 缓存中没有数据, 第一个进入的线程获取锁, 并从数据库中更新数据到缓存, 没有释放锁之前, 其他进入的线程会等待100ms后再次尝试从缓存中取数据。这杨防止了所有线程都去数据库取数据并更新缓存的情况出现。
3. 当然这是简化处理,理论上如果能根据key值加锁就更好了,就是线程A从数据库取key1的数据并不妨碍线程B取key2的数据,上面代码明显做不到这点。
缓存雪崩
缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是, 缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方法:
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
- 如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
- 设置热点数据永远不过期。
布隆过滤器
使用birmap实现, 4.0版本开始支持布隆过滤器, redis需要单独安装bloom filter;
添加key过程
-
根据hash1算法对key取hash, 然后在bitmap中将对应位置设置为1;
-
根据hash2算法对key取hash, 然后在bitmap中将对应位置设置为1;
-
…通过多个hash方法进行上述操作, 更新bitmap中几个位置为1;
判断key是否存在过程:
- 根据hash1算法对key取hash, 然后在bitmap中找到对应位置是否为1;
- 根据hash2算法对key取hash, 然后在bitmap中找到对应位置是否为1;
- …通过多个hash算法对key取hash, 判断bitmap中对应位置是否为1, 如果有一个不为1, 则肯定不存在!!!
综上
布隆过滤器可以判断一个key肯定不存在!!!但是不能判断一个key肯定存在!!!
优缺点
- 优点
- 使用bitmap, 占用内存小, 且插入和查询速度快;
- 缺点
- 随着数据的增加, 误判率会增加;
- 无法判断数据一定存在!
- 无法删除数据!!
redis实现
一种是redis中安装bloom filter, 然后使用Redission客户端使用;
package com.ys.rediscluster.bloomfilter.redisson;
import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
public class RedissonBloomFilter {
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.14.104:6379");
config.useSingleServer().setPassword("123");
//构造Redisson
RedissonClient redisson = Redisson.create(config);
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("phoneList");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%
bloomFilter.tryInit(100000000L,0.03);
//将号码10086插入到布隆过滤器中
bloomFilter.add("10086");
//判断下面号码是否在布隆过滤器中
System.out.println(bloomFilter.contains("123456"));//false
System.out.println(bloomFilter.contains("10086"));//true
}
}
guava实现
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>19.0</version>
</dependency>
/**
* @author : zlc
* @create : 2021-07-19 16:52
* @desc : guava工具包实现, Google提供的工具包, 应用层实现的, 无法用在分布式环境下吧
**/
public class GuavaBloomFilterTest {
public static void main(String[] args) {
BloomFilter<String> bloomFilter =
BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), 100000, 0.01);
bloomFilter.put("10086");
System.out.println(bloomFilter.mightContain("123456")); //false
System.out.println(bloomFilter.mightContain("10086")); //true
}
}
为什么单线程那么快 选主算法 持久化机制 过期策略 内存淘汰算法
缓存与数据库不一致怎么办 主从不一致怎么办