Redis(Remote Dictionary Server)是一个开源的高性能键值数据库。与传统的关系型数据库不同,Redis的数据存储是基于内存的,因此在读取和写入数据时具有极高的速度。Redis支持多种数据类型,使其在不同的应用场景中都能发挥出色的性能。
一、Redis数据类型
1. 字符串(String)
字符串是Redis中最基本的数据类型,它既可以存储文字字符串,也可以存储整数和浮点数。
1.1 应用场景
- 缓存:将热点数据存储在Redis字符串类型中,提高访问速度。
- 计数器:利用Redis的自增和自减操作,实现计数功能,如用户访问量、点赞数等。
- 分布式锁:基于Redis的原子操作,实现分布式系统中的锁机制。
1.2 常用命令
set 设置值(命令选项:ex 为键设置秒级过期时间,px 为键设置毫秒级过期时间 ,nx 键必须不存在,才可以设置成功 ,xx 与nx相反,键必须存在,才可以设置成功,用于更新)
setex 设置值, 作用和ex选项是一样的
setnx 设置值, 作用和nx选项是一样的
get 获取值 (如果要获取的键不存在,则返回nil 空 )
mset 批量设置值
mget 批量获取值
getset 和set一样会设置值,但是不同的是,它同时会返回键原来的值
incr 自增1 (值必须是整数,否则报错)
incrby 自增指定数字
decr 自减1
decrby 自减指定数字
incrbyfloat 自增浮点数
append 向字符串尾部追加值
strlen 返回字符串长度(每个中文占3个字节)
setrange 设置指定位置的字符(下标从0开始计算)
getrange 截取字符串(需要指明开始和结束的偏移量,截取的范围是个闭区间)
1.3 底层结构
Redis 的String类型使用 **SDS(简单动态字符串)(Simple Dynamic String)**作为底层的数据结构实现,它是一种可以动态扩展和收缩的字符串。SDS结构如下:
- free:未使用的字节数
- len:已使用的字节数
- buf[]:实际存储字符串的字节数组
2. 哈希(Hash)
哈希类型用于存储键值对集合,适合表示对象。
2.1 应用场景
- 用户信息:将用户信息存储在哈希类型中,方便查询和更新。
- 配置信息:将配置信息存储在哈希类型中,便于管理。
- 购物车:将商品信息存储在哈希类型中,实现购物车功能。
2.2 常用命令
hset 设值,带field和value
hsetnx 设置(关系就像set和setnx命令一样)
hget 取值,带field
hdel 删除一个或多个field,返回结果为成功删除field的个数。
hlen 计算field个数
hmset 批量设值
hmget 批量取值
hexists 判断field是否存在
hkeys 获取所有field
hvals 获取所有value
hgetall 获取所有field与value
hincrby 增加(就像incrby和incrbyfloat命令一样,但是它们的作用域是filed)
hstrlen 计算field的value的字符串长度
2.3 底层结构
哈希类型的底层结构是字典(dict),它是一个数组+链表的结构。字典结构如下:
- dictType:操作哈希表的方法
- rehashidx:rehash的进度
- ht[]:两个哈希表,用于实现渐进式rehash
3. 列表(List)
列表类型是一个双向链表结构,适用于存储有序元素。
3.1 应用场景
- 消息队列:利用列表的有序特性,实现消息队列功能。
- 微博时间轴:将用户微博按时间顺序存储在列表中,方便展示。
- 商品评论:将商品评论存储在列表中,按时间顺序展示。
3.2 常用命令
lpush 向左插入(插入a,b,c,b 形成d->c->b->a的链表结构)
rpush 向右插入(插入a,b,c,d 形成a->b->c->b的链表结构)
linsert 在某个元素前或后插入新元素(具体见命令提示)
lpop 从列表左侧弹出(把列表最左侧的元素弹出且删除)
rpop 从列表右侧弹出(把列表最右侧的元素弹出且删除)
lrem 对指定元素进行删除(从列表中找到等于value的元素进行删除)
ltirm 按照索引范围修剪列表(比如保留列表中第0个到第1个元素 ltirm key 0 1)
lset 修改指定索引下标的元素
lindex 获取列表指定索引下标的元素
lrange 获取指定范围内的元素列表(不会删除元素 lrange key 0 -1命令可以从左到右获取列表的所有元素)
llen 获取列表长度
blpop 阻塞式从列表左侧弹出(把列表最左侧的元素弹出且删除),没有元素就会阻塞,也支持设定阻塞时间,单位秒
brpop 阻塞式从列表右侧弹出(把列表最右侧的元素弹出且删除),没有元素就会阻塞,也支持设定阻塞时间,单位秒
3.3 底层结构
列表类型的底层结构是快速列表(quicklist),它是一个双向链表+压缩列表的结构。快速列表结构如下:
- quicklistNode:快速列表节点,包含前驱、后继节点指针、压缩列表等
- count:快速列表中的元素总数
- fill:填充因子,用于调整压缩列表的长度
- compress:压缩深度,用于调整压缩列表的压缩程度
4. 集合(Set)
集合类型用于存储无序且不重复的元素。
4.1 应用场景
- 标签:将用户标签存储在集合中,实现标签功能。
- 好友关系:利用集合的交集、并集操作,实现社交功能。
- 抽奖活动:利用集合的随机弹出元素操作,实现抽奖功能。
4.2 常用命令
sadd 添加元素(允许添加多个,返回结果为添加成功的元素个数)
srem 删除元素(允许删除多个,返回结果为成功删除元素个数)
scard 计算元素个数
sismember 判断元素是否在集合中(如果给定元素element在集合内返回1,反之返回0)
srandmember 随机从集合返回指定个数元素(指定个数如果不写默认为1)
spop 从集合随机弹出元素(如果不写默认为1,注意,既然是弹出,spop命令执行后,元素会从集合中删除)
smembers 获取所有元素(不会弹出元素)
--集合间操作命令
sinter 求多个集合的交集
suinon 求多个集合的并集
sdiff 求多个集合的差集
--将交集、并集、差集的结果保存(结果保存在destination key中)
sinterstore destination key [key ...]
suionstore destination key [key ...]
sdiffstore destination key [key ...]
4.3 底层结构
集合类型的底层结构是整数集合(intset)或字典(dict)。整数集合结构如下:
- encoding:编码方式,用于表示集合中元素的类型
- length:集合中的元素个数
- contents[]:实际存储元素的数组
5. 有序集合(ZSet)
有序集合类型是在集合类型的基础上,为每个元素添加了一个分数,用于排序。
5.1 应用场景
- 排行榜:根据分数排序,实现排行榜功能。
- 延迟任务:利用有序集合的分数作为时间戳,实现延迟任务功能。
- 商品推荐:根据用户喜好分数,实现商品推荐功能。
5.2 常用命令
zadd 添加成员(返回结果代表成功添加成员的个数)
zadd命令还有四个选项nx、xx、ch、incr 四个选项
nx: member必须不存在,才可以设置成功,用于添加。
xx: member必须存在,才可以设置成功,用于更新。
ch: 返回此次操作后,有序集合元素和分数发生变化的个数
incr: 对score做增加,相当于后面介绍的zincrby
zcard 计算成员个数
zscore 计算某个成员的分数
zrank 计算成员的排名(从分数从低到高返回排名)
zrevrank 计算成员的排名(从分数从高到低返回排名)
zrem 删除成员(允许一次删除多个成员,返回结果为成功删除的个数)
zrange 返回指定排名范围的成员(从低到高返回)、withscores选项,同时会返回成员的分数
zrevrange 返回指定排名范围的成员(从高到低返回)、withscores选项,同时会返回成员的分数
zrangebyscore 返回指定分数范围的成员(同时min和max还支持开区间(小括号)和闭区间(中括号),-inf和+inf分别代表无限小和无限大、withscores选项,同时会返回成员的分数)
zcount 返回指定分数范围成员个数
zremrangebyrank 按升序删除指定排名内的元素
zremrangebyscore 删除指定分数范围的成员
--集合间操作命令
zinterstore 交集
zunionstore 并集
5.3 底层结构
有序集合类型的底层结构是跳跃表(zskiplist)和字典(dict)。跳跃表结构如下:
- level[]:跳跃表中的层
- span[]:跨度,用于计算元素在跳跃表中的排名
- backward:后退指针
- score:元素的分数
- obj:元素的值
二、底层数据结构解析
1. 压缩列表
压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。
- zlbytes,记录整个压缩列表占用对内存字节数
- zltail,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量
- zllen,记录压缩列表包含的节点数量;
- zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)
压缩列表节点包含三部分内容:
- prevlen,记录了「前一个节点」的长度
- encoding,记录了当前节点实际数据的类型以及长度
- data,记录了当前节点的实际数据
在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。因此压缩列表不适合保存过多的元素。
2. 跳表
跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位。
(链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表)
如果我们要在链表中查找 33 这个元素,只能从头开始遍历链表,查找 6 次,直到找到 33为止。此时,复杂度是 O(N),查找效率很低
- 为了提高查找速度,我们来增加一级索引:从第一个元素开始,每两个元素选一个出来作为索引。这些索引再通过指针指向原始的链表。
例如,从前两个元素中抽取元素 1 作为一级索引,从第三、四个元素中抽取元素 11 作为一级索引。此时,我们只需要 4 次查找就能定位到元素 33 了
2. 我们还想再快,可以再增加二级索引:从一级索引中,再抽取部分元素作为二级索引。例如,从一级索引中抽取 1、27、100 作为二级索引,二级索引指向一级索引。这样,我们只需要 3 次查找,就能定位到元素 33 了。
这种本质就是空间换时间的算法。
3. 底层结构的选择
List
在 Redis 3.2 版本之前
Redis 的 List 类型底层数据结构可以由双向链表或压缩列表实现。如果列表元素个数小于 512 个且每个元素的值都小于 64 字节,则 Redis 会使用压缩列表作为底层数据结构;否则,Redis 会使用双向链表作为底层数据结构。
在 Redis 3.2 版本之后
List 类型底层数据结构只由 quicklist 实现,代替了双向链表和压缩列表。
String 类型的底层实现只有一种数据结构,也就是简单动态字符串。而 List、Hash、Set 和 Sorted Set 这四种集合类型,都有两种底层实现结构。
Hash
当一个Hash类型的键值对数量比较少时,Redis会使用压缩列表(ziplist)来表示Hash。当Hash类型的键值对数量较多时,会使用哈希表(hashtable)来表示Hash。哈希表在元素数量较多时具有更好的性能。
Sorted Set
当Sorted Set类型的成员数量较少(元素数量小于配置的压缩列表最大元素数量限制,默认为128)且成员的值较短时,Redis会使用压缩列表(ziplist)来表示Sorted Set。
当Sorted Set类型的成员数量较多或成员的值较长时,会使用跳表(skiplist)来表示Sorted Set。跳表在有序集合类型中提供了高效的范围查询操作。
Set
当Set类型的元素数量较少(元素数量小于配置的哈希最大压缩列表元素数量限制,默认为512)时,Redis会使用压缩列表(ziplist)来表示Set。当Set类型的元素数量较多时,会使用哈希表(hashtable)来表示Set。
三、高级数据类型
1. Bitmap
Redis的Bitmap是一种特殊的数据类型,它并不是一个独立的数据结构,而是基于字符串类型的一种用法。Bitmap提供了一种高效的方式来处理布尔值的状态,特别适合用于统计大量的布尔值信息,比如用户是否在线、用户是否点击过某个链接、日活跃用户统计等。
Bitmap的底层实现是基于字符串类型的,实际上它就是一个特殊的字符串。在Redis中,字符串可以存储二进制数据,而Bitmap正是利用了这一特性。Bitmap中的每个位都对应字符串中的一个字节,实际上,Bitmap是通过操作字符串中的位来实现的。
Bitmap的结构非常简单,它就是一个由二进制位组成的数组。在Redis中,你可以将Bitmap看作是一个很大的位数组,数组的每个位置都能存储0或1。由于Redis的字符串最大长度是512MB,所以Bitmap最大可以表示2^32个位。
常用命令:
SETBIT key offset value:设置Bitmap中指定偏移量(offset)的位值。
GETBIT key offset:获取Bitmap中指定偏移量的位值。
BITCOUNT key [start end]:统计Bitmap中指定范围内1的个数。
BITPOS key bit [start] [end]:查找Bitmap中第一个指定的位值(0或1)的位置。
BITOP operation destkey key [key ...]:对多个Bitmap进行位运算(AND、OR、XOR、NOT)。
使用场景:
- 用户活跃度统计:使用Bitmap来记录用户是否每天活跃,每个用户对应一个位,0表示不活跃,1表示活跃。可以为一个用户的30天活跃度使用一个Bitmap。
- 大量唯一性判断:比如判断一个用户是否已经领取过奖励,可以使用Bitmap来标记,每个用户对应一个位。
- 实时分析:比如实时统计网站页面的UV(Unique Visitor,独立访客),可以使用Bitmap来记录访问过该页面的所有用户ID。
2. 布隆过滤器
布隆过滤器(Bloom Filter)是一种空间效率极高的数据结构,用于测试一个元素是否属于集合。它的核心思想是通过多个哈希函数来将一个元素映射到一个位数组中,从而判断该元素是否可能存在于集合中。布隆过滤器的特点是高效、节省空间,但有一定的误判率。
布隆过滤器的用途非常广泛,我们这里只介绍缓存穿透问题。
缓存穿透:
缓存穿透是指客户端请求的数据在缓存中未命中,并且需要从后端存储(如数据库)中查询,但由于某种原因(如数据不存在、数据库故障等)无法成功获取数据,导致请求绕过缓存直接对后端存储造成压力的现象。
缓存穿透可能会对系统性能造成严重影响,尤其是在高并发场景下,大量的穿透请求会压垮数据库,甚至导致系统崩溃。
如何解决缓存穿透?
- 缓存空值:
当缓存未命中且从后端存储查询得到空值时,可以在缓存中设置一个空值或特定的标记,设置一个较短的过期时间。这样,后续的请求将会命中缓存,而不是继续查询后端存储。
这种方法的缺点是可能会占用额外的缓存空间存储空值。并且在缓存有效期内可能造成缓存和数据库数据不一致。 - 限流和熔断:
对查询请求进行限流,当请求量超过一定阈值时,拒绝部分请求或降级服务,防止大量请求直接打向后端存储。
使用熔断机制,当后端存储出现故障或响应过慢时,自动熔断,返回错误或缓存中的数据。 - 异步更新缓存:
当缓存未命中且从后端存储查询到数据时,异步更新缓存,减少后续请求的穿透。
这种方法的缺点是第一次请求的响应时间可能会较长。 - 数据预处理:
对于热点数据,可以在系统低峰期预先加载到缓存中,减少在高峰期的穿透请求。 - 多级缓存:
使用多级缓存策略,例如本地缓存(如Guava Cache、Caffeine)和分布式缓存(如Redis、Memcached)结合使用,提高缓存命中率。 - 合理设计缓存键:
避免使用过于复杂的缓存键,减少缓存未命中的情况。 - 布隆过滤器:
使用布隆过滤器(Bloom Filter)来预先判断数据是否存在。布隆过滤器是一个高效的数据结构,用于测试一个元素是否属于集合,它可以以很小的空间代价判断一个元素是否可能存在于集合中。
在缓存之前加入布隆过滤器,如果布隆过滤器判断数据不存在,则直接返回,不查询缓存和后端存储。
这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少。
redis 中的布隆过滤器:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.12.3</version>
</dependency>
import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
/*Redisson底层基于位图实现了一个布隆过滤器,使用非常方便*/
public class RedissonBF {
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
//构造Redisson
RedissonClient redisson = Redisson.create(config);
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("phoneList");
//初始化布隆过滤器:预计元素为20000L,误差率为3%
bloomFilter.tryInit(20000L,0.03);
//将号码1~10086插入到布隆过滤器中
for(int i =1;i<=10086 ;i++){
bloomFilter.add(String.valueOf(i));
}
//判断下面号码是否在布隆过滤器中
System.out.println("996:BF--"+bloomFilter.contains("996"));//true
System.out.println("10086:BF--"+bloomFilter.contains("10086"));//true
System.out.println("10088:BF--"+bloomFilter.contains("10088"));//false
System.out.println("10096:BF--"+bloomFilter.contains("10096"));//false
System.out.println("10340:BF--"+bloomFilter.contains("10340"));//?
//布隆过滤器(送入布隆过滤器的元素,判断是一定在的)
// for(int i =1;i<=10086 ;i++){
// if(!bloomFilter.contains(String.valueOf(i))){
// System.out.println("送入BF的不一定在:"+i);
// }
// }
}
}
误判问题:
- 通过 hash 计算不存在的,一定不在集合
- 通过 hash 计算存在的,不一定存在,因为可能存在 hash 冲突
3. HyperLogLog
Redis的HyperLogLog是一种用于估算集合中唯一元素数量的数据结构。它是一种概率数据结构,通过极少的内存来提供对巨大数据集的唯一元素数量的近似计算。HyperLogLog的内存占用远小于传统的去重方法,如使用集合来存储所有唯一元素。
如果你负责开发维护一个大型的网站,有一天产品经理要网站每个网页每天的 UV 数据(每个页面有多少个不同的用户访问过),然后让你来开发这个统计模块,你会如何实现?
1、Redis的incr做计数器是不行的,因为这里有重复,重复的用户访问一次,这个count就加1是不行的。
2、使用set集合存储所有当天访问过此页面的用户 ID,当一个请求过来时,我们使用 sadd 将用户 ID 塞进去就可以了。通过 scard 可以取出这个集合的大小,这个数字就是这个页面的 UV 数据。
不过使用set集合,这就非常浪费空间。如果这样的页面很多,那所需要的存储空间是惊人的。
3、如果你需要的数据不需要太精确,那么可以使用HyperLogLog,Redis 提供了 HyperLogLog 数据结构就是用来解决这种统计问题的。
HyperLogLog 提供不精确的去重计数方案,虽然不精确但是也不是非常不精确,Redis官方给出标准误差是0.81%,这样的精确度已经可以满足上面的UV 统计需求了。
常用命令:
pfadd key element [element …] 向HyperLogLog 添加元素,如果添加成功返回1:
pfcount key [key …] 计算一个或多个HyperLogLog的独立总数
pfmerge destkey sourcekey [sourcekey ... ] 求出多个HyperLogLog的并集并赋值给destkey
代码演示:
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
/*HyperLogLog测试UV与set的对比*/
public class HyperLogLogTest {
public static void main(String[] args) {
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
JedisPool jedisPool = new JedisPool(jedisPoolConfig, "127.0.0.1", 6379, 30000);
Jedis jedis1 = null;
try {
jedis1 = jedisPool.getResource();
for(int i=0;i<10000;i++){ //1万个元素
jedis1.pfadd("hyper-count","user"+i);
}
long total = jedis1.pfcount("hyper-count");
System.out.println("实际次数:" + 10000 + ",HyperLogLog统计次数:"+total);
} catch (Exception e) {
e.printStackTrace();
} finally {
jedis1.close();
}
Jedis jedis2 = null;
try {
jedis2 = jedisPool.getResource();
for(int i=0;i<10000;i++){ //1万个元素
jedis2.sadd("set-count","user"+i);
}
long total = jedis2.scard("set-count");
System.out.println("实际次数:" + 10000 + ",set统计次数:"+total);
} catch (Exception e) {
e.printStackTrace();
} finally {
jedis2.close();
}
}
}
同时使用debug命令测试一下他们的存储的比较,只有之前的1 /10
可以看到,HyperLogLog内存占用量小得惊人,但是用如此小空间来估算如此巨大的数据,必然不是100%的正确,其中一定存在误差率。前面说过,Redis官方给出的数字是0.81%的失误率。
4. GEO
Redis的GEO(地理空间)功能是Redis 3.2版本引入的一项特性,它允许我们存储地理空间信息(如经纬度坐标)并对这些信息进行操作。GEO功能主要适用于那些需要地理位置查询的应用场景,比如打车应用、地图服务、位置感知型游戏等。
4.1 GEO数据类型
GEO功能并不是一种全新的数据类型,而是基于Redis的有序集合(ZSet)实现的。它使用有序集合来存储地理空间信息,其中每个成员的分数 score 对应于该成员的经度(longitude),而成员的值则对应于纬度(latitude)。
4.2 GEO常用命令
GEO功能提供了一系列命令来操作地理空间数据:
GEOADD key longitude latitude member [longitude latitude member ...]
:将一个或多个经纬度坐标和成员添加到指定的键中。GEOPOS key member [member ...]
:获取指定成员的经纬度坐标。GEODIST key member1 member2 [unit]
:计算两个成员之间的距离,可以指定单位(如米、千米等)。GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count]
:查询指定经纬度周围一定距离内的成员。GEORADIUSBYMEMBER key member radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count]
:查询指定成员周围一定距离内的其他成员。
4.3 使用场景
GEO功能适用于以下场景:
- 位置查询:查找附近的餐馆、商店、用户等。
- 范围搜索:查找特定区域内的地点或用户。
- 距离计算:计算两个位置之间的距离。
4.4 优点
- 高效的地理位置查询:GEO功能能够快速地进行地理位置相关的查询操作。
- 简单的API:GEO命令简单易用,易于集成到应用中。
4.5 缺点
- 精度问题:GEO功能使用的是平面地球模型,因此在极地区域可能会有较大的误差。
- 内存占用:GEO功能基于有序集合实现,存储大量的地理位置信息可能会导致较大的内存占用。
4.6 使用示例
> GEOADD cities 116.4074 39.9042 "Beijing"
(integer) 1
> GEOADD cities 121.4737 31.2304 "Shanghai"
(integer) 1
> GEOPOS cities "Beijing"
1) 1) "116.40742397155780588"
2) "39.90421444863895813"
> GEODIST cities "Beijing" "Shanghai" km
"1067.3868"
> GEORADIUS cities 116.4074 39.9042 500 km
1) "Beijing"
在上面的示例中,我们添加了北京和上海的经纬度信息,然后获取了北京的经纬度坐标,计算了北京和上海之间的距离,并查询了北京附近500公里内的城市。
总结来说,Redis的GEO功能提供了一种高效、简单的地理空间数据处理方式,适用于需要地理位置查询的应用场景。通过有序集合作为底层存储结构,GEO功能能够快速地进行位置查询和距离计算。