常见的数据类型和应用场景
我们都知道,redis 中有五种常见的数据类型,分别是:String(字符串)、Hash(哈希)、List(列表)、Set(集合)、Zset(有序集合)。
不过呢,以上这五种都是比较常见的,还有几种不常见的,比如:BitMap、HyperLog、GEO、Stream。这几种不常见的都是后来随着 Redis 的版本更新迭代才出现的。
Redis 的这些数据类型还是挺重要的。因为在面试的时候,面试官可能会问我们 比如 String 数据类型有什么典型的应用场景吗?
所以,我们在学习这些数据类型的时候,还要注意每种数据类型的应用场景是什么。下面,我们就按顺序来学习一下 Redis 中所有的数据类型吧。
String
Redis 的 String 类型是 Redis 最基础的数据类型之一,也是最常用的数据结构。它在 Redis 中是一个简单的键值对存储,值可以是字符串、数字、二进制数据等。
定义
在 Redis 中,String 类型是一个键值对(key-value)的映射。每个键(key)对应一个字符串(value)。String 类型的值可以是任何字节序列,包括文本、二进制数据、数字等。
- key:是 Redis 中的唯一标识符,可以是任意的字节序列,最大为 512MB。
- value:可以是一个普通的字符串、二进制数据或数字,大小最大可以达到 512MB。
Redis 的 String 类型是最基本的数据类型,也是所有其他数据结构的基础,这是因为哈希、列表、集合等都可以通过 String 来存储。
内部实现
String 类型的底层数据结构的实现主要基于 整数 和 简单动态字符串(SDS)。
Redis 在处理 String 类型时,如果值是一个整数,它会采用整数编码进行存储。这种方式能最大化地节省内存,并提升性能。因为 Redis 会直接将整数值存储在内存中,而不需要进行额外的字符串转换或内存分配。
特点:
- 当 String 类型的值是整数时,Redis 会将其存储为一个 整数值,而不是一个字符串。
- 这种存储方式非常紧凑,因此可以节省大量内存,尤其是在处理大量数字时。
- 采用这种方式时,Redis 对整数值进行存储和操作非常高效。
如果 String 类型的值是一个普通的字符串(即文本、二进制数据等),Redis 会使用 简单动态字符串(SDS) 来存储。
SDS 是 Redis 自定义的一种字符串类型,它比 C 语言的 char * 类型更高效。SDS 通过将字符串长度和容量信息嵌入到字符串数据的前面,避免了 C 字符串常见的空字符终止符问题,并且提供了更高效的字符串操作。
SDS的内存结构是这样的:
字段 | 描述 |
---|---|
len |
字符串的实际长度(不包括空字符)。 |
alloc |
SDS 分配的内存大小,表示 SDS 当前预分配的空间。 |
flags |
标识 SDS 类型的额外标志。 |
buf |
存储字符串的缓冲区,即实际存储字符数据的地方。 |
SDS 的优点也是显而易见的:
- 避免了 C 字符串的空字符终止符:SDS 字符串没有使用空字符来标识字符串结尾,因此能够直接保存字符串的长度,避免了操作过程中的低效。
- 预分配空间,避免频繁内存分配:SDS 会预分配一定的空间,在字符串长度增加时,只有在超过当前容量时才进行扩展。这样可以减少内存的分配次数。
- 高效的字符串操作:支持快速的长度查询(通过 Len 字段),时间复杂度是O(1),因此操作字符串时可以避免遍历整个字符串。
- 二进制安全:SDS 不依赖于字符编码,可以存储任意二进制数据。
常用指令
以下是一些常见的指令:
- SET key value:设置键值对,如果键已经存在,更新其值。
- GET key:获取指定键的值。如果键不存在,返回
null
。
- DEL key:删除指定的键。如果键不存在,返回
0
。
- MSET key1 value1 key2 value2 …:一次设置多个键值对。
- MGET key1 key2 …:一次获取多个键的值。
- INCR key:将指定键的值加 1。如果键不存在,会创建并初始化为 0。
- DECR key:将指定键的值减 1。
- APPEND key value:将指定值追加到已有的字符串值后面。
- GETSET key value:先获取键的值,再设置该键的新值。
- STRLEN key:获取指定键的字符串长度。
- SETNX key value:仅当键不存在时,才设置键值。
应用场景
缓存对象
- 直接缓存整个对象的 JSON
直接缓存整个对象的 JSON 是指将对象转换为一个 JSON 格式的字符串并存储在 Redis 中。
比如有一个用户对象:
可以通过 JSON 序列化将其转换为一个 JSON 字符串:
然后使用 SET 命令将整个 JSON 字符串进行存储:
这种情况适用于对象较小、变化不频繁或者读取操作远远多于更新操作的场景。
- 将 key 进行分离
将对象的各个属性拆分成独立的 Redis key,并使用 Redis 的 MSET 和 MGET 批量操作来存取这些属性值。
有一个用户对象,想将其分解为多个 Redis 键:
你可以将每个字段分别存储为不同的键:
当需要批量获取一个对象的多个属性时,可以使用 MGET 来高效地获取:
这种方法适用于对象属性变化频繁、对象较大且只需要频繁访问部分属性的场景。
常规计数
每篇文章的阅读量可以通过 Redis String 来存储,每次有用户阅读该文章时,就对对应的计数值进行加一操作。这个方式非常适合 高并发 的场景,因为 Redis 支持 原子性增量,并且 Redis 在内存中操作数据,非常高效。
设置计数键:为每篇文章定义一个唯一的 Redis key。这个 key 可以使用文章的 ID 来命名,比如 article:{id}:views ,其中 {id} 是文章的 ID。
如果文章的 ID 为 123 ,我们可以使用如下 Redis key:
使用 INCR 命令进行计数:INCR 命令用于 整数值的自增,并且是 原子性操作,即使在并发情况下也能保证正确性。每次用户阅读文章时,我们使用 INCR 命令对对应的文章阅读量进行加一操作。
用户每次阅读文章时执行以下命令:
这会将 article:123:views 的值加一。如果该键不存在,Redis 会自动初始化为 0,然后加一。
获取当前文章的阅读量:使用 GET 命令读取对应的 Redis 键的值。
获取 article:123:views 的当前值:
设置过期时间:你可以使用 EXPIRE 命令为文章的阅读量设置过期时间。
如果你希望在 24 小时内计算阅读量,24 小时后数据自动清除,可以这样做:
增量计数优化:如果需要对多个文章进行计数,可以利用 MSET 命令同时为多个文章进行阅读量更新:
分布式锁
在分布式环境中,多个客户端可能会尝试同时获取一个共享资源的锁。为了确保只有一个客户端能够成功获取锁,我们可以使用 String 类型来实现一个分布式锁。
实现方式是使用 SET 命令设置一个键,表示该资源已经被锁定。通过 NX 参数,确保只有当该键不存在时才能成功设置,即当资源没有被锁定时,才能获取锁。通过 EX 参数设置锁的过期时间,避免死锁。 当客户端完成任务后,通过删除锁的键来释放锁。
我们来分析一下这个命令:
- lock_key:锁的键,可以使用资源的标识符(比如文章 ID、商品 ID)作为键的一部分。
- unique_value:锁的值,一般可以是一个唯一的值,比如客户端的标识(例如 UUID)。确保同一个客户端不会在同一时刻获取多个锁。
- NX:仅在键不存在时设置锁(即只有没有获取锁的其他客户端时才可以获取锁)。
- EX 30:设置锁的过期时间为 30 秒,防止因某些原因客户端崩溃或者无法释放锁而导致死锁。
上述的这条命令是用来加锁的,那么如何实现解锁呢?
实际上,解锁的过程就是将 lock_key 这个键删除的过程。但是在删除的时候,需要先判断 unique_value 是否为客户端,如果是,则可以删除,否则就无法删除。也就是说,这个锁是谁加的,谁才有权利解锁。
解锁的过程是先判断,再删除,所以需要用到 Lua 脚本来实现原子性:
通过使用 String 的 SET 命令和 Lua 脚本,就实现了分布式锁。
共享 Session 信息
在 单体应用架构 中,通常将 Session 信息保存到服务器端,来存储用户的登录状态。当每次用户发起请求时,服务器通过 Session ID 来识别该用户的会话状态,从而知道该用户是否已登录,以及用户的相关信息(如权限、角色等) 。
但是在分布式系统中,每个服务器的内存是独立的,Session 信息只能在当前服务器上有效。如果用户的请求被路由到不同的服务器,新的服务器就无法获取该用户之前的会话数据,就会出现重复登陆的问题。
为了解决这个问题,我们可以使用 Redis 来集中存储 Session 信息。集中式存储的优势, 就是在分布式环境中,多个应用服务器可以通过访问 Redis 来获取和更新会话信息,无论请求被路由到哪个服务器,都会得到相同的会话数据,这样就解决了多个服务器之间数据无法共享的问题。
List
List 提供了一个简单的线性表形式,允许我们将多个元素按顺序存储在一个列表中。在 Redis 中,List 类型非常适合用于实现队列、栈等数据结构,并且提供了非常高效的插入、删除等操作。List 的最大长度是 2^32 – 1 。
定义
Redis 中的 List 类型是一个简单的双向链表,允许我们以顺序的方式存储多个元素。每个 List 都是一个由多个元素组成的集合,且可以在两端进行插入和删除操作,它支持按顺序读取和操作数据。
- 键:List 通过一个键来进行访问的,每个 List 都有一个唯一的键。
- 值:是一个有序的字符串列表,可以包含任何有效的 Redis 字符串。
特点:
- 双向链表:实现基于双向链表,允许在两端进行高效的插入和删除操作。
- 顺序存储:元素按照插入顺序排列,从左到右(LIFO 或 FIFO)。
内部实现
List 类型是使用双向链表或者压缩列表来实现的。
双向链表的内部存储结构包括节点和指针:
- 节点:每个 List 中的元素都是一个链表节点。每个节点包含一个值和两个指针,分别指向前一个节点和后一个节点。
- 指针:通过前后指针,Redis 可以在 List 的两端进行快速的插入和删除操作。
如果列表中的元素数量小于 512 个并且每个元素的字节大小都小于 64 ,那么 List 就会使用压缩列表来存储数据。否则,List 就会使用双向链表。
在 redis 3.2 版本之后,出现了 quicklist,由它来替代双向链表和压缩列表。
quicklist** **是双向链表和压缩列表的结合体,它将多个压缩列表串联起来,既保证了插入和删除操作的高效性,又减少了内存的占用。
常用指令
下面是一些常见的指令:
插入和删除操作
- LPUSH key value [value …]:将一个或多个元素插入到列表的左端(头部)。如果列表不存在,创建一个新列表。
- RPUSH key value [value …]:将一个或多个元素插入到列表的右端(尾部)。如果列表不存在,创建一个新列表。
- LPOP key:移除并返回列表的第一个元素(左端)。如果列表为空,返回
nil
。
- RPOP key:移除并返回列表的最后一个元素(右端)。如果列表为空,返回
nil
。
- LREM key count value:从列表中删除指定的元素。如果
count
为正,删除从左边开始的元素;如果count
为负,删除从右边开始的元素;如果count
为 0,删除所有与value
匹配的元素。
- LINSERT key BEFORE|AFTER pivot value:在列表的某个元素之前或之后插入新元素。
查询操作
- LRANGE key start stop:获取列表指定范围内的元素,范围从
start
到stop
(包含)。索引从 0 开始,负数表示从列表尾部开始(-1 是最后一个元素)。
- LLEN key:获取列表的长度。
- LINDEX key index:获取列表中指定索引位置的元素。如果索引超出范围,返回
nil
。
- LSET key index value:将列表中指定索引位置的元素设置为新的值。如果索引超出范围,返回错误。
其他操作
- BRPOP key [key …] timeout:阻塞式地移除并返回列表的最后一个元素。如果列表为空,会阻塞直到超时。
- BLPOP key [key …] timeout:阻塞式地移除并返回列表的第一个元素。
应用场景
消息队列
List 经常被用来实现消息队列,通过使用 LPUSH 和 RPOP 或者 RPUSH 和 LPOP 操作,我们可以实现一个简单的消息队列。
- 生产者将任务添加到队列的头部,也就是 LPUSH。
- 消费者从队列的尾部取出任务,也就是 RPOP。
通过这两个命令,我们就可以实现一个简单的消息队列。
但是,一个可靠的消息队列,必须要具备消息保序、处理重复消息和保证消息可靠性这三个特点。那我们就来看看应该如何实现。
1、消息保序
其实我们使用 LPUSH 和 RPOP 这两个命令,就已经能够保证消息是有序的了。但是,还有一个潜在的问题。
那就是当生产者往 List 中添加消息时,List 并不会主动的通知消费者此时有新的消息进来了,所有消费者就需要在队列的另一端不断地调用 RPOP 命令,来确认是否有新的消息进来。如果有,就可以读取到;如果没有,就会返回一个空值。
这样的话,在读取不到值的时候,就会造成 CPU 不必要的消耗,性能就比较差。那这种情况就可以使用 BRPOP 来实现消息的阻塞式读取。
BRPOP 是 Redis 提供的一个阻塞式命令,专门用于从列表中读取并移除元素。如果列表为空,BRPOP 会让消费者进入阻塞状态,直到列表中有新元素可以返回,或者超时为止。这样就能有效避免轮询和不必要的请求,减少系统负担。
2、处理重复消息
为了确保消费者能够判断和避免重复消息的处理,通常需要以下两个方面的要求:
- 每个消息都有一个全局的 ID。
每个消息都必须有一个唯一 ID,List 并不会生成 ID,所以需要我们来生成,比如可以用 UUID 生成一个。这个全局的 ID 用于确保每个消息是唯一的,并且能够在消费者端进行唯一标识和追踪。全局 ID 通常是消息的元数据的一部分,在生产者发送消息时附带在每个消息中。
- 消费者需要记录已经处理过的消息的 ID。
每当消费者处理一个消息时,可以将该消息的 ID 添加到一个集合中。然后,消费者在处理新消息之前,会首先检查消息 ID 是否已经存在于该集合中。如果存在,则说明这个消息已经被处理过,消费者将跳过该消息,从而避免重复处理。
3、保证消息的可靠性
采用一种 消息备份 机制,常见的做法是在 读取消息之后,将其备份到另一个列表,以确保消息在处理过程中能够被恢复或追踪。这样即使在处理过程中出现异常或者消费失败,备份的消息仍然存在,可以重新尝试处理。
步骤是这样的:
- 创建两个队列,一个用来存储待处理的消息的队列 task_queue, 另一个用来存储消息的备份的队列backup_queue。
- 消费者使用 BRPOP 从 task_queue 中阻塞式读取消息。
- 消费者处理消息之前,先将该消息存入备份队列 backup_queue 。
- 消费者处理消息后,如果处理成功,可以继续从 task_queue 中删除消息。
- ****如果消费者在处理过程中出现异常,可以从备份队列中恢复未处理的消息。
Hash
定义
哈希数据类型是一个键值对(field – value)集合,它将多个键值对存储在一个键中,就像是一个小型的字典。每个哈希键可以包含多个字段(field),每个字段都有一个对应的值(value)。这样就使得哈希类型特别适合用于存储和管理对象,因为可以将对象的各个属性作为字段,属性值作为对应的值存储在哈希中。
内部实现
Redis 的哈希数据类型内部采用两种数据结构来实现:压缩列表(ziplist)和哈希表(hashtable)。
- 压缩列表(ziplist):当哈希中包含的键值对数量小于 512 ,也就是参数 hash-max-ziplist-entries,并且每个字段和值的长度都小于 64 字节,也就是参数 hash-max-ziplist-value ,Redis 会使用压缩列表来存储哈希数据。压缩列表是一种紧凑的存储结构,它将多个元素紧凑地存储在一起,通过特殊的编码方式来节省内存空间。
- 哈希表(hashtable):如果不满足上述条件,Redis 会切换到哈希表来存储。哈希表是一种基于哈希算法的数据结构,它能够提供快速的查找、插入和删除操作。Redis 的哈希表实现采用链地址法来处理哈希冲突,确保在高并发情况下依然能够高效地处理数据。
常用命令
- HSET key field value:向指定的哈希键 key 中设置一个字段 field 及其对应的值 value 。
- HGET key field:从指定的哈希键 key 中获取字段 field 的值。
- HGETALL key:返回指定哈希键 key 的所有字段和值。
- HDEL key field [field…]:删除指定哈希键 key 中的一个或多个字段。
- HEXISTS key field:检查指定哈希键key中是否存在字段field。如果存在返回1,不存在返回0。
应用场景
缓存对象
在 Web 应用中,经常需要缓存用户信息、商品信息等对象。使用 Redis 的哈希数据类型可以将对象的各个属性存储在一个哈希中,通过一个键来管理整个对象。
比如我想缓存一本书:
然后获取这些信息:
其实 String 也可以用来缓存对象。如果使用 String 缓存对象,通常是将整个对象序列化为一个字符串,比如 JSON 格式的字符串,然后存储在 String 类型的 value 中。
那我们应该如何选择呢?
对象结构简单且变化频率低,就用 String 类型;需要对对象的部分字段单独操作并且变化频率高,就用 Hash 类型。
购物车功能
购物车通常包含以下几个部分:
- 商品ID(item_id)
- 商品的数量(quantity)
- 商品的单价(price)
可以把每个购物车的商品信息存储在一个唯一的 key 下,每个商品作为一个字段( field ),而商品的属性(如数量、单价)作为对应字段的值( value )。
假设我们用 user_id 作为哈希的 key ,每个商品的 item_id 作为哈希的 field,商品的数量和单价作为字段的 value 。当用户将商品添加到购物车时,我们使用 HSET 操作来设置商品的数量和价格。
如果用户选择增加或减少商品的数量,可以使用 HINCRBY
来增减数量。
这个命令将用户 1 的购物车中 item:101 商品的数量增加 1。
总之,Hash 中的 HSET 、HINCRBY 、HDEL 和 HGETALL 这些命令,使得实现添加商品、修改数量、删除商品和查询购物车等功能变得非常容易。
Set
定义
Set 是一个无序的字符串集合。它有以下三个特点:
- 无序性:元素没有顺序,是随机存储的。
- 唯一性:元素是唯一的,不允许有重复元素。如果插入相同的元素,Redis 会自动忽略重复项。
- 支持集合操作:可以进行集合操作,如交集、并集、差集等,可以方便地进行集合运算。
一个集合最多可以存储 2^32-1 个元素。
内部实现
Set 类型在内部实现上实际上有两种不同的实现方式:哈希表 和 **整数集合 **。
- 如果全部是整数,并且元素数量小于 512 个,Redis 会使用 整数集合 来存储数据。
- 如果不满足上述条件,Redis 就会使用 **哈希表 **来实现 Set 。
常用命令
以下是一些最常用的命令:
- SADD:向 Set 中添加一个或多个元素。如果元素已经存在,Redis 会忽略该元素。
- SREM:从 Set 中移除一个或多个元素。如果元素不存在,Redis 会忽略该元素。
- SMEMBERS:获取 Set 中所有的元素。
- SISMEMBER:检查某个元素是否存在于 Set 中。
- SCARD:获取 Set 中元素的数量(即 Set 的大小)。
- SPOP:从 Set 中随机移除并返回一个元素。如果 Set 为空,则返回
nil
。
- SDIFF:返回一个或多个 Set 之间的差集。
- SUNION:返回一个或多个 Set 之间的并集。
- SINTER:返回一个或多个 Set 之间的交集。
应用场景
1、去重
去重用户操作:存储某用户访问的不同页面 ID,保证每个页面仅计数一次。
去重投票或者点赞:在投票或点赞系统中,确保每个用户只能投票或点赞一次,避免重复投票。
2、标签系统
存储用户兴趣标签:存储某个用户的兴趣标签,并且保证兴趣标签是唯一的。
3、社交网络
存储用户的好友列表:记录用户的好友,保证列表中没有重复的用户。
存储用户的粉丝列表:记录某用户的粉丝,确保每个粉丝只能出现一次。
计算共同好友:通过 SINTER 命令,可以计算两个用户之间的共同好友。
4、实现集合运算
用户兴趣的交集:查找两个用户的共同兴趣标签。
5、限制条件
一次性优惠券:记录已经领取某个优惠券的用户,因为对于一次性使用的优惠券,要避免用户重复领取。
Zset
定义
Redis 中的是一个 不重复 的字符串集合,每个元素都关联一个 分数(score)。Zset 中的元素是按照分数的升序排列的,分数相同的元素会根据字典序排列。
- 不重复性:元素是唯一的,不能插入重复的元素。
- 分数:每个元素都有一个关联的分数,分数用于对元素进行排序。
- 排序:自动按照分数从小到大排列元素。
简单来说,Zset 就是一个有序的集合,元素有顺序,并且每个元素都有一个与之关联的分数,分数决定了排序的顺序。
内部实现
Zset 的内部实现有两种:跳表 和 **压缩列表 **。
- 如果元素数量较少(少于 128 个元素),且这些元素是简单的整数类型或短字符串,Redis 会使用压缩列表。
- 如果元素较多,或者元素包含了很多复杂的字符串或对象(不是简单的整数或小字符串),Redis 会选择跳表。
常用命令
- ZADD:向 Zset 中添加一个或多个元素。如果元素已经存在,更新该元素的分数。
- ZRANGE:返回 Zset 中指定区间内的元素(按分数升序排序)。可以使用
WITHSCORES
选项同时返回元素的分数。
- ZREVRANGE:与 ZRANGE 类似,但按分数降序排列返回元素。
- ZSCORE:返回某个元素的分数。
- ZINCRBY:增加某个元素的分数。如果该元素不存在,则会创建该元素并设置分数。
- ZREM:从 Zset 中移除一个或多个元素。
- ZRANK 和 ZREVRANK:返回元素在 Zset 中的排名(按分数升序排序)。ZRANK 返回元素的排名,ZREVRANK 返回按分数降序排序时的排名。
应用场景
游戏排行榜
在一个游戏中,玩家的分数可以通过 Zset 存储,玩家的 ID 为元素,分数为分数值,Zset 会自动根据分数对玩家进行排序。
社交平台的热度排行
在一个社交平台中,可以使用 Zset 来存储帖子或话题的热度,热度分数可以根据点赞数、评论数等来计算。Zset 会根据热度分数自动排序,方便获取最热的帖子或话题。
姓名排序
假设要根据 姓名的字典顺序 对一组姓名进行排序,可以将 姓名的 ASCII 码(或者其字典顺序) 作为 分数(score),而将 姓名本身 作为 元素(member) 存储在 Zset 中。这样,Zset 就会根据 字典顺序 排序姓名。
有一组姓名:Alice 、Bob 、Charlie,按姓名的字典顺序来排序:
这里将字母的顺序(数字)作为分数,Zset 会自动根据这些分数对姓名进行排序。
按字典顺序升序排序(从 A 到 Z):
这个命令会按字典顺序返回所有姓名,WITHSCORES 会显示每个姓名的分数。
返回结果:
BitMap
定义
BitMap 并不是一个独立的数据类型,而是一个 特殊的操作模式,它允许你将 Redis 的 字符串(String) 类型当作位数组来使用。也就是说,BitMap 实际上是利用 Redis 的 String 类型来进行位操作的,它允许你对单个字节的单个位进行操作。因为 bit 是计算机中最小的单位,所以用它来存储就会非常节省空间。
BitMap 有以下三个特点:
- 每一位(bit) 可以存储一个 布尔值,即 0 或 1 。
- 大小是可变的,可以通过 Redis 的命令来设置和获取不同位置的位。
- 通过字符串类型来实现的,每个字节存储多个二进制位,因此非常高效地利用内存。
每个位都可以通过索引访问,索引从 0 开始。
内部实现
BitMap 使用的是 字符串(String) 类型的特殊操作。每个字符串可以用来表示一个 位数组,每个字符都存储一个或多个二进制位(byte)。因此,BitMap 的存储结构与 Redis 的 字符串 数据类型共享相同的内部存储机制。BitMap 就相当于是一个 bit 数组。
常用命令
- SETBIT:设置指定位置的位为 1 或 0。
- GETBIT:获取指定位置的位值(0 或 1)。
- BITCOUNT:计算给定的 BitMap 中有多少个 1(即已设置为
true
的位)。
- BITOP:执行位运算(如 AND、OR、XOR)操作,结果存储在新的 BitMap 键中。
- BITFIELD:执行多个位操作并返回结果,支持读取和修改多个位字段。
应用场景
签到统计
将每个用户的签到状态用一个 位 来表示,1 表示用户已经签到,0 表示用户未签到。
我们将用户的 ID 映射到位的位置上,用户 ID 为 1001 的用户的签到状态存储在第 1001 位,ID 为 1002 的用户的签到状态存储在第 1002 位,以此类推。
比如用户 1001 今天已经签到:
这里的 signin:2025 是存储 2025 年签到状态的 BitMap 键,1001 是用户 ID,1 表示该用户已签到。
使用 GETBIT 命令查询某个用户的签到状态。
比如检查用户 1001 是否已签到:
使用 BITCOUNT 命令可以统计 BitMap 中为 1 的位数,即统计已签到的用户数量。
比如统计 2025 年所有已签到的用户数量:
判断用户登录态
同样的,每个位代表一个用户的登录状态,将用户的 ID 映射到位的位置上。当用户登录时,设置对应位为 1;当用户登出时,设置对应位为 0。
用户 1001 登录:
当用户 1001 登出:
查询用户 1001 是否已登录:
统计所有已登录的用户数:
HyperLogLog
定义
HyperLogLog 是一种概率算法,主要用于估算基数,即某个数据集合中的不同元素的数量。它与传统的精确计数方法不同,使用了一种统计方法,通过存储少量的数据来估算大规模集合中的不同元素数量。
HyperLogLog 是通过概率统计的方法来实现的,因此它不需要存储所有的元素,而只需要使用少量内存来估算结果。每个 HyperLogLog 键只需要花费 12 KB 的内存,就可以计算将近 2^64 个不同元素的基数。但是,它一定程度的误差,通常误差在 0.81% 左右。
内部实现
HyperLogLog 主要基于哈希函数和桶来实现基数估算。核心原理是通过哈希算法将每个元素映射到一个桶中,并通过计算哈希值的前导零的数量来估算集合中的不同元素的数量。
整个过程非常复杂,简单来描述的话可以分为这四步:
- 哈希映射:每个元素通过一个哈希函数映射到一个较大的空间中。Redis 使用的是 MurmurHash 或 SHA1 等哈希函数来生成元素的哈希值。
- 桶:HyperLogLog 使用一个 桶数组,每个桶存储一个值,表示哈希值前导零的数量。每次新的元素加入时,哈希值的前导零的数量会更新对应桶的值。
- 前导零统计:对每个元素进行哈希后,查看其哈希值中的前导零的数量。然后将该数量记录到桶中,桶的值是该元素哈希值前导零数量的最大值。通过统计所有桶的最大值,HyperLogLog 可以估算出集合的基数。
- 估算基数:当所有元素都映射到桶中后,HyperLogLog 根据桶的最大前导零数量来估算基数。该算法可以通过数学公式计算得到一个接近实际值的基数。
常用命令
HyperLogLog 的命令,就三个:
- PFADD:将一个或多个元素添加到 HyperLogLog 中。每个元素会通过哈希映射到桶中,用于估算基数。
- PFCOUNT:返回 HyperLogLog 中不同元素的估算数量,即基数。
- PFMERGE:将多个 HyperLogLog 合并为一个新的 HyperLogLog。这个命令用于将不同的数据源中的 HyperLogLog 基数进行合并,以便一起计算基数。
应用场景
UV 计数
HyperLogLog 可以用于估算某个网站、API 或应用的 唯一访问者数。每个用户或访问者可以视为一个独特的元素,使用 HyperLogLog 来估算不同用户或 IP 地址的数量。
比如我想估算网站的独立访客数:
GEO
定义
Redis 中的 GEO 数据类型允许你存储一组地理坐标(经度和纬度)。每个地点或物体都由 经度、纬度 和 名称 三部分组成。在 Redis 中,这些地理数据可以通过一系列 命令 来存储、查询、计算距离、查找范围内的地点等。
通过 GEO 类型,Redis 可以执行地理空间查询(如范围查询、距离计算等)。
内部实现
GEO 是基于 Geohash(地理哈希)和跳表实现的。Geohash 是一种空间索引技术,将地理坐标(经纬度)转换为一个短字符串。它还将经纬度空间分割成不同的网格,并将这些网格编码成字符串。跳表的作用就是来存储每个地点的 Geohash 值。
常用命令
- GEOADD:将一个或多个地理位置添加到指定的 GEO 数据集合中,每个位置包含名称、经度和纬度。
- GEODIST:计算两个地点之间的距离,支持不同的单位(如米、千米、英里等)。
- GEOPOS:获取一个或多个地点的经纬度坐标。
- GEORADIUS:返回给定半径范围内的所有地点,可以通过经纬度和半径进行范围查询。
比如,获取半径 50 千米内的地点,查询位置为 Palermo :
- GEORADIUSBYMEMBER:与 GEORADIUS 类似,但是查询是基于某个现有地点(而不是经纬度)来进行的。
比如,获取 Palermo 半径 50 千米内的所有地点:
- GEOLIST:返回指定的地理位置列表。
应用场景
商店/门店定位
GEO 数据类型可以用于存储和查询多个商店或门店的位置,用户可以通过其经纬度查询周围的门店,类似于在电子商务或商场应用中的门店查询。
用户在应用中输入自己的位置,通过 GEORADIUS 查找附近 5 公里内的所有商店:
外卖配送
外卖配送服务通常需要根据用户位置来计算配送范围,Redis 的 GEO 数据类型可以用来高效地查询用户位置周围的可配送商店或餐厅。
使用 GEORADIUSBYMEMBER 命令查找用户位置附近的商家,然后计算配送费用和时间:
打车服务
在打车应用中,GEO 类型可以用于存储所有出租车的位置,并实时获取附近的出租车供用户选择。
用户查询附近的可用出租车:
社交网络中的地理标记
在社交平台中,用户可以根据地理位置发布带有位置信息的动态,其他用户可以基于这些位置信息进行查找,类似于“签到”功能。
根据用户的当前位置,查找附近的朋友或动态:
地理广告定位
地理广告定位是许多广告系统中常见的场景。商家可以根据用户所在位置推送附近的广告内容。
根据用户当前位置,推送附近的广告商家:
旅游推荐系统
GEO 存储景点位置,并根据用户当前所在位置推荐附近的景点或活动。
用户在旅游应用中输入当前经纬度,获取 20 公里内的所有景点:
Stream
介绍
Stream 是 Redis 5.0 版本新增加的数据类型,在 Stream 没有出现之前,消息队列的实现方式有很多问题,比如:
- 用 List 实现的消息队列不能重复消费。
- 在发布订阅模式下,不能可靠的保存消息。
基于出现的问题,Stream 就出现了。
Stream 是一种日志结构化的队列,用于管理一组数据流。每个 Stream 都由多个消息组成,每个消息由唯一ID 和多个字段-值对组成。
Stream 的核心特性有以下这些:
- 顺序存储:每个消息在 Stream 中都有一个唯一的 ID,这个 ID 是由 Redis 自动生成的,ID 具有时间戳顺序。
- 消息持久化:消息被写入 Stream 后,默认会持久化到磁盘(取决于 Redis 的持久化配置)。
- 消费者组支持:Stream 支持消费者组,允许多个消费者共享消费消息。
- 高效的消息消费:支持通过消费者组进行消息消费,支持 消息确认 和 消息ACK,确保消息的可靠消费。
- 支持历史数据查询:Stream 支持按时间顺序查询消息,可以实现 实时流处理 和 历史数据回溯。
内部实现
Stream 内部是基于 双端链表 和 **字典 **实现的。每条消息是链表中的一个节点,节点存储消息的 ID 和字段-值对。
Stream 数据结构的组成:
- 消息 ID:每条消息都有一个唯一的 ID,格式为 时间戳 + 序列号,例如:1609459200000-0 。时间戳表示消息的创建时间,序列号确保同一时间戳下的消息按顺序排列。
- 消息字段和值:每条消息可以包含多个字段和值,类似于哈希表的键值对。
- 消费者组:支持消费者组,每个消费者组可以有多个消费者。每个消费者负责从队列中读取消息,并通过 消息确认机制(ACK) 确保消息被可靠消费。
- 双端链表:使用双端链表存储消息,可以高效地从两端添加和删除消息。
- 字典:用于管理消费者组的状态(例如消息的ACK状态)。
常用命令
- XADD:向 Stream 中添加消息。消息包括一个唯一的 ID 和一组字段-值对。
向名为 mystream 的 Stream 中添加一条消息:
这里 * 表示让 Redis 自动生成一个消息 ID。
- XRANGE:从 Stream 中查询指定范围的消息。可以指定消息 ID 范围,或者查询所有消息。
查询 mystream 中的所有消息:
- XREAD:从 Stream 中读取消息。可以从多个 Stream 中同时读取消息,支持阻塞读取。
阻塞读取 mystream 中的最新消息:
- XGROUP:用于管理消费者组,允许多个消费者共享消费消息。
创建消费者组 mygroup,基于 mystream:
- XREADGROUP:消费者组读取消息。每个消费者从消费者组的消息队列中读取消息,并根据需要进行 ACK 确认。
消费者 consumer1 从消费者组 mygroup 中读取消息:
- XACK:确认消息已经被消费者成功处理。通过 ACK,Redis 可以确保消息不会被重复消费。
确认消费者组 mygroup 中的某条消息已被处理:
- XTRIM:修剪 Stream,删除已处理的旧消息,保持 Stream 的大小。
保持 mystream 中的消息数不超过 1000:
应用场景
消息队列
生产者使用 XADD 命令将消息插入到 Stream 中。该命令允许生产者为每条消息指定多个字段和字段值(即消息的内容)。
有一个生产者向 Redis Stream mystream 插入消息,消息内容为 “user_id”: 123, “action”: “login” 。
- mystream:Stream 的名称。
- *:表示 Redis 自动生成消息的 ID(时间戳-序列号)。
- user_id 123 action login:消息的字段和值,即消息的内容。
在执行 XADD 时,Redis 会为每个消息生成一个 唯一的 ID,作为消息的标识。
比如插入成功之后,可能会返回这样的一个 ID 。
消息 ID 是由 Redis 自动生成的,格式为 时间戳-序列号。拿上面这个 ID 来说,其中 1624410901000 是时间戳,0 是序列号。时间戳是当前时间(毫秒级),序列号的含义就是从几开始编号的。
消费者通过使用 XREAD 命令从一个或多个 Stream 中读取消息。
假设有一个名为 mystream 的 Stream,消费者可以使用以下命令来从该 Stream 读取消息:
这将从 mystream Stream 中从 最早的消息(ID 为 0)开始读取所有消息。
XREAD 返回的数据格式是这样的:
XREAD 可以和 BLOCK 进行配合实现消息的阻塞式读取。
阻塞读取的基本逻辑是这样的:
- 如果消费者有新消息可以读取,XREAD 会立刻返回这些消息。
- 如果当前没有新消息,XREAD 会根据 BLOCK 参数的设置阻塞等待:
- 如果 BLOCK 设置为 大于 0 的毫秒数,消费者会等待最多指定的时间(例如 5000 毫秒即 5 秒)来等待新消息。
- 如果 BLOCK 设置为 0,则表示永久阻塞,直到有消息。
- 如果超时且没有新消息,XREAD 会返回空结果。
- 如果有新消息,XREAD 会返回从指定 ID 开始的消息。
比如你有一个名为 mystream 的 Stream,消费者希望从中读取消息,并希望在没有新消息时阻塞 5 秒钟。如果 5 秒内有新消息,返回新消息;如果没有新消息,则返回空。就可以使用这个命令:
0 代表从消息队列中的第一个消息开始读取。
基于 XADD 和 XREAD 这两个命令,我们就实现了一个简易的消息队列:
其实,上述这些功能,用 List 也能实现。但是,Stream 中还有一些特有的功能。
Stream 可以通过 XGROUP CREATE 命令来为某个 Stream 创建一个消费者组,并指定一个消费起始点(通常是流中的第一个消息)。
比如有一个名为 mystream 的 Stream,我想为它创建一个名为 mygroup 的消费组。
这样就创建了一个消费组 mygroup ,并指定从 mystream 中的第一个消息开始消费。
如果想从 Stream 中最新的未消费消息开始消费,可以这样写:
消费者读取消息组则可以使用 XREADGROUP 命令。消费组的机制 确保每个消息只被一个消费者消费。
- group_name:消费组的名称。
- consumer_name:消费者的标识。
- stream_name:指定要读取的 Stream 的名称。
- ID id1:指定从哪个消息 ID 开始读取。如果第一次读取,可以指定从最后未消费的消息开始读取(用
>
)。 - COUNT count:可选参数,指定每次读取的消息数量。
- BLOCK milliseconds:可选参数,指定阻塞时间(毫秒)。如果没有新消息时,消费者将阻塞等待。
消费组的一个核心目的就是实现 负载均衡。当一个消息队列中有大量消息需要消费时,单个消费者可能会成为瓶颈,处理消息的速度会受限于该消费者的处理能力。而消费组的出现可以让多个消费者共同消费消息,实现了消息的 并行消费,从而提高了系统的吞吐量。
现在,我们来看 Stream 中的一个很重要的概念,叫做 PENDING List ,也就是内部队列。 它包含了被消费者读取但未进行确认的消息。每当消费者读取消息时,Redis 会将消息记录在 PENDING List 中。当消费者处理完消息并发送确认( XACK )时,消息就会从 PENDING List 中被移除。
比如,消费者 consumer1 从 Stream mystream 中读取消息:
Redis 会将这些消息分配给 consumer1,并将它们添加到 PENDING List 中,表示这些消息被 consumer1 读取了,但尚未确认。
把消息添加到 PENDING List 时,会包含消息 ID ,消费者名称以及未确认的消息数量。
当消费者 consumer1 处理完该消息并调用 XACK 命令进行确认时,Redis 会将该消息从 PENDING List 中移除。
确认之后,PENDING List 中就不再包含该消息。
既然用 redis 中的 stream 可以实现消息队列,用专业的中间件比如 RabbitMQ 也可以实现消息队列,那么这两种不同方式实现的消息队列有什么不同呢?
我们要知道,如果想实现一个合格的消息队列,必须要做到消息不会丢失并且消息是可以堆积的。
而且,一个消息队列的组成无非就是三部分:生产者、中间件和消费者。
如果想保证消息不会丢失,那就只需要保证这三个环节都不会丢失消息不就行了嘛。
我们按照顺序来看一看这三个环节在 Stream 中到底会不会丢消息。
- 从生产者来说,如果生产者向中间件发送消息的时候能正常收到 ack 响应,那么就说明这条消息没有丢失。当然,在实际的情况中,可能会出现一些异常情况,但是可以让生产者进行重复发送。也就是,只要没收到 ack 那就重新发送,这样的话,就可以保证生产者不会丢消息。
- 从中间件来说, Stream 中的消息默认是保存在内存中的,因此如果 Redis 崩溃了,内存中的数据就会丢失,就会导致消息丢失。那可能有小伙伴会说,redis 不是有持久化吗?是,redis 确实有持久化,但是持久化的过程是异步的呀,当然会丢数据的。并且在主从复制中,如果主从节点需要切换,也是有可能丢数据的。
- 从消费者来说,我们刚刚已经提到了 PENDING List。消费者读取消息后需要通过 XACK 命令确认消息。只有确认了的消息才会从 PENDING List 中移除,确保消息不会被重复消费。如果消息没有被确认,Redis 会将这些未确认的消息保留在 PENDING List 中,等待重新分配给其他消费者。
通过上面的分析,我们能够知道,redis 是无法保证中间件不会丢数据的。
那 redis Stream 中的消息可以堆积吗?
如果 消费者 从 Redis Stream 中读取消息的速度低于 生产者 写入消息的速度,消息会在 Stream 中 堆积,即消息数量会持续增加。redis 是基于内存的,如果消息持续堆积可能导致 Redis 占用过多内存,甚至可能导致内存溢出。
基于以上这个问题,Redis Stream 允许使用 MAXLEN 配置来控制 Stream 中消息的最大数量。如果消息数量超出了设定的最大值,Redis 会丢弃最旧的消息,从而避免消息堆积。但是这样就会导致消息丢失。
总之,redis 可能会丢消息,并且在消息堆积的时候,会导致内存紧张。
如果业务场景比较简单,允许部分消息丢失并且消息堆积的可能性很小,是可以使用 redis stream 来作为消息队列的。但是如果业务场景比较复杂,不允许消息丢失,那还是老老实实用专业的消息队列中间件。
总结
这篇文章我们讲了 redis 中的九大数据类型,其中有五种比较常见的,分别是 String、List、Hash、Set、Zset。
- String 主要用于 缓存对象、常规计数、分布式锁和共享 session 信息。
- List 主要用于消息队列。
- Hash 主要用于缓存对象和购物车功能。
- Set 主要用于去重、标签系统、集合运算、社交网络。
- Zset 主要用于游戏排行榜、社交平台的热度排行、姓名排序。
这五种数据类型是比较基础的,在面试的时候被问到的频率也是最高的。
随着 redis 版本的更新,又出现了四种新的数据类型,分别是 BitMap、HyperLogLog、GEO、Stream。
- BitMap 主要用于签到统计、判断用户登录态。
- HyperLogLog 主要用于 UV 计数。
- GEO 主要用于 商店定位、外卖配送、打车服务、地理广告定位、旅游系统。
- Stream 主要用于实现消息队列,适用于简单的业务场景。
好了,这篇文章就讲到这里,感谢观看。