业务应用
约 8692 字大约 29 分钟
2025-04-24
下面将讨论一个非常重要的问题(阅读到这里您对 Redis
的基本操作基本是差不多的了,该了解一些 Redis
的主要功能了),我们学会使用 Redis
后,该如何接入到我们自己项目中?在哪里应该使用 Redis
?在哪里不应该使用 Redis
?我们需要解决的是 Why
的问题。相信我,Why
的问题永远要比 What
的问题要重要得多。
吐槽:老实说,专用的
RabbitMQ, Apache Kafka
消息队列可以替代Redis
作为消息队列的部分,而使用Mongodb
文档数据库可以替代Redis
作为文档数据库的部分。因此从简单作为数据库上划分还是无法找到Redis
的定位,因此下面从业务功能上来划分会更容易找到Redis
在架构中的定位。
1.从数库上划分
1.1.内存数据库
类似 MySQL
等数据库,把 Redis
当作内存中的数据库来使用也是不错的选择,快是很快,但是一定要注意持久化的问题。这一点只需要知道对数据类型的操作和 API
即可,比较容易理解。
1.2.文档数据库
Redis
通常作为键值存储系统,而不是传统的文档数据库(如 MongoDB
)。但可以通过存储 JSON
或类似的文档数据类型,将其用于模拟文档数据库。
- 实现方式:可以将文档存储为
Redis
的字符串(String
)或哈希表(Hash
)。 - 用途:适用于需要高性能读写、低延迟操作和实时数据处理的场景,特别是非结构化数据,且以键值对方式存储。
- 例子:将用户信息存储为
JSON
格式的字符串,或将对象的属性存储为哈希表。
并且有 Hash
这样的数据结构在,整个表结构时稀疏的,不会有关系型数据库加字段困难的难题(不过也因此缺少了强大而复杂的关系查询)。
使用 Redis
作为文档数据库大抵有以下三种方案:
- 原生字符串类型 优点:实现简单,针对个别属性变更也很灵活。 缺点:占用过多的键,内存占用量较大,同时用户信息在
Redis
中比较分散,缺少内聚性,所以这种方案基本没有实用性。 - 序列化字符串类型 优点:针对总是以整体作为操作的信息比较合适,编程也简单。同时,如果序列化方案选择合适,内存的使用效率很高。 缺点:本身序列化和反序列需要一定开销,同时如果总是操作个别属性则非常不灵活。
- 哈希类型 优点:简单、直观、灵活。尤其是针对信息的局部变更或者获取操作。 缺点:需要控制哈希在
ziplist
和hashtable
两种内部编码的转换,可能会造成内存的较大消耗。
1.3.向量数据库
向量数据库 是一种专门用于存储和查询向量(数字序列)的数据库,通常用于处理需要快速相似性搜索的非结构化数据(如文本、图像、音频等)。在这种数据库中,数据被转化为向量,并在向量空间中进行存储和检索。
- 实现方式:数据通过机器学习模型(如
Word2Vec, BERT
等)转化为向量后,存储在数据库中。查询时,数据库通过计算向量之间的距离或相似度,找到与查询向量最相似的数据。 - 用途:适用于需要基于相似性进行快速查询的场景,例如图像搜索、语义搜索、推荐系统等。向量数据库能高效地处理大规模数据集,并提供高效的相似性检索。
- 例子:将图像特征或文本嵌入(如文本的词向量)存储在向量数据库中,当用户查询时,数据库通过计算向量之间的相似度返回相关的图片或文本内容。
待补充...
注
吐槽:机器学习的部分我学习的不多,待补充...
2.从功能上划分
2.1.缓存功能(内存运行)
2.1.1.概念
使用 Redis
做缓冲层,处理绝大多数的数据请求,而 MySQl
作为存储层,负责对重要数据进行持久。缓存功能可以减少对 MySQL
的访问次数,提高对应用的响应速度。
// 缓存功能
UserInfo getUserInfo(long uid) {
String key = "user:info:" + uid; // 根据 uid 得到 Redis 的键
String value = Redis 执行命令: get key; // 尝试从 Redis 中获取对应的值
// 如果缓存命中(hit)
if (value != null) {
UserInfo userInfo = JSON 反序列化(value); // 假设我们的用户信息按照 JSON 格式存储
return userInfo;
}
// 如果缓存未命中(miss)
if (value == null) {
UserInfo userInfo = MySQL 执行 SQL: select * from user_info where uid = <uid>; // 从数据库中,根据 uid 获取用户信息
if (userInfo == null) { // 如果表中没有 uid 对应的用户信息
// 响应 404
return null;
}
// 将用户信息序列化成 JSON 格式
String value = JSON 序列化(userInfo);
// 写入缓存,为了防止数据腐烂(rot), 设置过期时间为 1 h(3600 s)
Redis 执行命令: set key value ex 3600;
// 返回用户信息
return userInfo;
}
}
重要
补充:缓存方式的对比。
- 原生字符串类型:通过多个键存储每个属性(如
set user:1:name James
,set user:1:age 23
,set user:1:city Beijing
)- 优点:实现简单,针对单个属性的变更较为灵活。
- 缺点:占用过多的键,内存消耗较大,且用户信息在
Redis
中分散,缺少内聚性,因此这种方式通常不具备实际应用价值。
- 序列化字符串类型(如 JSON 格式):将整个用户对象序列化后存储为一个字符串(如
set user:1 "serialized_user_data"
)- 优点:适用于需要整体操作的场景,编程简单,且内存使用效率较高。
- 缺点:序列化和反序列化会带来一定的性能开销,不适合频繁操作单个属性,灵活性较差。
- 哈希类型:使用
Redis
的哈希结构存储用户信息(如hmset user:1 name James age 23 city Beijing
)- 优点:简洁、直观且灵活,特别适用于频繁操作单个属性的场景。
- 缺点:需要管理哈希表的编码方式(
ziplist
和hashtable
),可能会导致内存消耗较大。
不过,尽管我们知道这个原理,但是如何把已经存储在 MySQL
上的数据缓存到 Redis
上呢?哪些需要缓存?哪些不需要缓存?怎么缓存?什么时候缓存?没有命中的策略是什么?
2.1.2.策略
但是在什么时候缓存哪一些数据呢?
2.1.2.1.定期生成
每隔一定的周期(比如一天/一周/一月),对于访问的数据频次进行统计,挑选出访问频次最高的前 N%
的数据。
例如在搜索引擎中,用户通过输入“查询词”进行搜索。查询词可以分为高频词和低频词,其中高频词是大家经常搜索的内容(如鲜花、蛋糕、同城交友、不孕不育等),而低频词则较少被搜索。搜索引擎会将用户的搜索行为以日志形式详细记录,包括用户、时间和查询词等信息。随后,系统会定期对这些日志进行统计分析。由于日志数据量通常非常巨大,统计过程需要借助大数据处理工具(如 Hadoop
或 Spark
)来完成,最终生成“高频词表”,用于优化搜索服务。
重要
补充:什么是 Hadoop
和 Spark
呢?
Hadoop
是一个开源的大数据处理框架,主要用于存储和处理大规模数据集。它由两个主要组件构成,HDFS
和MapReduce
。- 特点:
- HDFS:分布式存储系统,用于存储海量数据,数据被分割成小块,并分布到多台机器上。
- MapReduce:分布式计算模型,用于并行处理数据。通过
Map
阶段将数据分配到不同节点,Reduce
阶段合并处理结果。 - 高可靠性:通过数据副本机制保障数据的容错性。
- 适合批处理:
Hadoop
主要适用于批处理任务,处理大规模、离线的数据集。
- 适用场景:适用于需要存储和处理海量数据的场景,通常是批量处理数据,如日志分析、数据挖掘等。
- 特点:
Spark
是一个快速且通用的大数据处理引擎,提供了比Hadoop MapReduce
更高效的计算能力。Spark
的核心是内存计算,它通过将数据加载到内存中进行快速处理,从而显著提高了处理速度。- 特点:
- 内存计算:
Spark
将数据存储在内存中,而不是像Hadoop
那样存储在磁盘上,减少了磁盘I/O
操作,提高了计算速度。 - 灵活性:支持批处理、流处理(
Spark Streaming
)、机器学习(MLlib
)、图计算(GraphX
)等多种计算任务。 - 易用性:提供了比
Hadoop
更简洁的API
,支持Python, Scala, Java, R
等多种编程语言。 - 与 Hadoop 兼容:可以和
Hadoop
配合使用,Spark
可以读取HDFS
中的数据并进行计算,或者作为Hadoop
的替代方案。
- 内存计算:
- 适用场景:适用于需要快速处理数据的场景,如实时数据流处理、机器学习、交互式分析等。
- 特点:
2.1.2.2.实时生成
上述定期生成的做法比较延时,有时候无法应对突发情况,例如中国春节期间的热搜词条。先在 Redis
配置文件中设定缓存容量上限(maxmemory
)。
接下来把用户每次查询,如果在 Redis
查到了,就直接返回。如果 Redis
中不存在,就从数据库中查,把查到的结果同时也写入 Redis
。如果缓存已经满了(达到上限),就会触发缓存淘汰策略,把一些“相对不那么热门”的数据淘汰掉,按照上述过程,持续一段时间后 Redis
内部的数据自然就是“热门数据”了。
淘汰策略类似内存的淘汰策略:
- 先进先出
- 淘汰最久未使用
- 淘汰访问次数最少的
- 随机淘汰
细化的话为:
volatile-lru
:当内存不足以容纳新写入数据时,从设置了过期时间的 key 中,使用 LRU(最近最少使用) 算法进行淘汰。allkeys-lru
:当内存不足以容纳新写入数据时,从所有 key 中,使用 LRU(最近最少使用) 算法进行淘汰。volatile-lfu
(Redis 4.0新增):当内存不足以容纳新写入数据时,在过期的 key 中,使用 LFU(最不常用) 算法进行删除 key。allkeys-lfu
(Redis 4.0新增):当内存不足以容纳新写入数据时,从所有 key 中,使用 LFU(最不常用) 算法进行淘汰。volatile-random
:当内存不足以容纳新写入数据时,从设置了过期时间的 key 中,随机淘汰数据。allkeys-random
:当内存不足以容纳新写入数据时,从所有 key 中,随机淘汰数据。volatile-ttl
:在设置了过期时间的 key 中,根据过期时间进行淘汰,越早过期的优先被淘汰。(类似于 FIFO,但仅限于过期的 key)noeviction
(默认策略):当内存不足以容纳新写入数据时,新写入操作会报错,不会进行任何数据淘汰。
2.1.3.问题
2.1.3.1.缓存穿透
什么是缓存穿透? 缓存穿透是指访问的
key
在Redis
和数据库中都不存在。这种情况下,查询请求不会被缓存,当该key
再次访问时,依然会访问到数据库。这会导致数据库承担大量请求,增加数据库的压力。为何产生缓存穿透?
- 业务设计不合理:例如缺少必要的参数校验,导致非法的
key
也被查询。 - 开发/运维误操作:不小心将部分数据从数据库中误删。
- 黑客恶意攻击:攻击者通过构造非法的
key
来频繁查询数据库,导致缓存失效。
- 业务设计不合理:例如缺少必要的参数校验,导致非法的
如何解决缓存穿透?
- 严格校验查询参数:对查询的
key
进行合法性校验。例如,如果查询的 key 是用户的手机号,那么需要校验该key
是否符合合法的手机号格式。 - 将不存在的 key 存储到 Redis:对于数据库中不存在的 key,也可以将其存储到
Redis
,值可以设置为一个空字符串 (""
) 或者一个特殊标识。这样可以避免后续频繁访问数据库。 - 使用布隆过滤器:布隆过滤器可以先判断
key
是否存在,如果不存在,则直接返回,不会继续查询数据库。
- 严格校验查询参数:对查询的
2.1.3.2.缓存雪崩
- 什么是缓存雪崩? 缓存雪崩是指短时间内大量的
key
在缓存中失效,导致数据库压力骤增,甚至可能直接宕机。原本Redis
作为MySQL
的保护层,能够抵挡很多外部请求压力。一旦这个保护层失效,数据库需要直接承担所有请求的压力,可能导致数据库崩溃。 - 为何产生缓存雪崩?
- Redis 挂掉:如果
Redis
服务突然宕机,所有的缓存请求都会直接访问数据库,导致数据库压力激增。 - 大量的 key 同时过期:当大量的
key
在缓存中设置了相同的过期时间,且这些key
在短时间内同时过期,缓存失效的请求会瞬间涌入数据库,导致数据库压力过大。
- Redis 挂掉:如果
- 如何解决缓存雪崩?
- 部署高可用的 Redis 集群:通过高可用的
Redis
集群可以避免单点故障,增强Redis
服务的稳定性。同时,建立完善的监控和报警系统,及时发现Redis
服务异常,避免大规模缓存失效。 - 不为 key 设置过期时间或设置带有随机因子的过期时间:避免大量
key
在同一时刻过期,可以为缓存的key
设置不同的过期时间,或者在过期时间上加上随机时间因子,从而平衡缓存的过期时间,减小瞬时失效的风险。
- 部署高可用的 Redis 集群:通过高可用的
2.1.3.缓存击穿
- 什么是缓存击穿? 缓存击穿是指热点
key
突然过期,导致大量请求直接访问数据库,进而对数据库造成巨大压力,甚至可能导致数据库宕机。这种情况类似于缓存雪崩,但它通常发生在某个热点key
过期的特殊情况下。 - 如何解决缓存击穿?
- 基于统计发现热点 key,并设置永不过期:通过监控和分析,找出热点
key
,将其设置为永不过期,从而避免频繁的缓存失效和数据库压力。 - 进行必要的服务降级:比如,在访问数据库时使用分布式锁,限制同时请求数据库的并发数,避免多个请求同时访问数据库,减少数据库压力。
- 基于统计发现热点 key,并设置永不过期:通过监控和分析,找出热点
2.1.4.操作
不过要想做到缓存,需要把 MySQL
数据映射为 Redis
数据,例如将表的单行记录转化为 String: 键-值
的形式直接存储 JSON
字符(或者干脆使用 RedisJSON
模块)、用 Hash: 键-值
的方式存储多个表的单行记录、用 List/Set: 键-值
存储一对多关系的多个值。
2.2.计数器功能(计数指令)
许多网站的计数器功能也可以增加用户的体验,这点也可以使用 Redis
来实现。
// 排行榜功能
// 检查用户是否已经观看过该视频
boolean checkUserPlayStatus(long userId, long vid) {
String key = "video:" + vid + ":user:" + userId; // 生成 Redis 键
String value = Redis 执行命令: get key;
return value == null; // 如果值为空, 说明用户尚未播放过
}
// 在统计某视频的播放次数
long incrVideoCounter(long vid, String dimension) {
String key = "video:" + vid + ":" + dimension; // 生成 Redis 键(dimension 是维度)
long count = Redis 执行命令: incr key; // 执行 Redis 命令, 增加视频播放次数
return count; // 返回当前播放次数
}
// 标记用户已观看
void markUserAsPlayed(long userId, long vid) {
String key = "video:" + vid + ":user:" + userId;
Redis 执行命令:set key "played";
}
// 持久化到数据库
void asyncPersistToDatabase(long vid) {
String key = "video:" + vid;
String value = Redis 执行命令:get key;
if (value != null) {
// 异步写入数据库(可以使用消息队列或定时任务)
MySQL 执行 SQL: update video_info set play_count = <count> where vid = <vid>;
}
}
int main() {
// ...
if (!checkUserPlayStatus(userId, vid)) {
long count = incrVideoCounter(vid, "view");
markUserAsPlayed(userId, vid);
}
asyncPersistToDatabase(vid);
return 0;
}
重要
补充:计数器的实现还需要考虑很多,防作弊、按不同维度计数、避免单点节点故障问题、数据持久化到底层数据源等。
2.3.共享会话功能(分布架构)
Redis
很适合集中存储会话的 Session
数据。一个 Web
服务中,后端拥有自己的服务器,因此得到的用户 Session
信息(例如用户登录信息)也会保存在自己的后端服务器中。
Session
不算是需要强持久化的数据,哪怕是丢失了只需要重新登陆即可(但是不能反复登陆太多次,影响用户体验),因此可以考虑在每个后端服务器上安装 Redis
存储 Session
数据,这样用户登陆的时候非常高效。
我们可以把 Redis
作为后端服务器中的 Session
数据库。
用户登录时,生成
Session ID
并存储到Redis
// 生存 Session ID #include <iostream> #include <string> #include <map> #include <ctime> #include <uuid/uuid.h> // 用于生成 Session ID // Redis 客户端模拟 class Redis { public: // 模拟 Redis 存储数据 void set(const std::string& key, const std::string& value, int expire_seconds) { // 存储数据并设置过期时间 session_store[key] = value; } // 模拟 Redis 获取数据 std::string get(const std::string& key) { return session_store.count(key) ? session_store[key] : ""; } // 模拟删除数据 void del(const std::string& key) { session_store.erase(key); } private: std::map<std::string, std::string> session_store; }; // 用户信息结构 struct User { std::string username; std::time_t login_time; std::time_t last_access_time; std::map<std::string, std::string> user_data; }; // 全局的 Redis 客户端对象 Redis redis; // 生成 Session ID std::string generate_session_id() { uuid_t id; uuid_generate(id); char uuid_str[37]; uuid_unparse(id, uuid_str); return std::string(uuid_str); } // 设置 Cookie (模拟) void set_cookie(const std::string& name, const std::string& value) { // 这里假设已经有设置 Cookie 的方式 std::cout << "Setting Cookie: " << name << "=" << value << std::endl; } // 用户登录 std::string user_login(const std::string& username, const std::string& password) { // 验证用户名密码 if (username == "user" && password == "password") { // 生成 Session ID std::string session_id = generate_session_id(); // 创建用户 Session 数据 User user = {username, std::time(0), std::time(0), {{"email", "user@example.com"}}}; // 序列化用户数据 std::string session_data = "username=" + user.username + ";login_time=" + std::to_string(user.login_time); // 将 Session 数据存储到 Redis redis.set(session_id, session_data, 3600); // 设置过期时间为1小时 // 将 Session ID 设置到 Cookie set_cookie("session_id", session_id); return "Login successful, Session created."; } else { return "Invalid username or password."; } }
用户请求时,前端服务器从
Cookie
获取Session ID
,查询Redis
获取用户信息// 获取 Cookie (模拟) std::string get_cookie(const std::string& name) { // 这里假设已经有获取 Cookie 的方法 return "sample_session_id"; // 返回一个假设的 Session ID } // 处理用户请求 std::string handle_request() { // 从 Cookie 获取 Session ID std::string session_id = get_cookie("session_id"); if (!session_id.empty()) { // 从 Redis 获取 Session 数据 std::string session_data = redis.get(session_id); if (!session_data.empty()) { // 找到 Session 数据,表示用户已经登录 return "Welcome back, your session data: " + session_data; } else { // Session 已过期或无效 return "Session expired, please log in again."; } } else { return "No session found, please log in."; } }
用户登出时,删除
Redis
中的Session
数据// 用户登出 std::string user_logout(const std::string& session_id) { // 从 Redis 中删除 Session 数据 redis.del(session_id); // 删除 Cookie 中的 Session ID std::cout << "Deleting Cookie: session_id" << std::endl; return "You have been logged out."; }
注
吐槽:有的时候是真的可以把 Redis
看作内存,MySQL
看作磁盘,不过都是升级版...
2.4.用户验证功能(消息腐烂)
Redis
提供的过期功能非常适合做验证码功能。
// 验证码功能
// 发送验证码
String sendValidationCode(String phoneNumber) {
String key = "shortMsg:limit:" + phoneNumber;
// 尝试设置 key 为 1,并且设置过期时间为 60 秒,NX 表示只有在 key 不存在时才会设置成功
boolean r = Redis 执行命令: set key 1 ex 60 nx;
if (!r) {
// 如果之前已经设置过验证码限制, 尝试增加计数(这样做的目的的方便限制规定时间内验证码获取次数)
long count = Redis 执行命令: incr key;
if (count > 5) {
// 超过一分钟 5 次限制,不能再发送验证码
return null;
}
}
// 生成随机的 6 位数验证码
String validationCode = generateRandomValidationCode();
String validationKey = "validation:" + phoneNumber;
// 将验证码存储在 Redis 中,设置过期时间为 5 分钟(300 秒)
Redis 执行命令:set validationKey validationCode ex 300;
// 返回验证码,随后可以通过短信发送给用户
return validationCode;
}
// 校验验证码
boolean validateCode(String phoneNumber, String validationCode) {
String validationKey = "validation:" + phoneNumber;
// 从 Redis 中获取存储的验证码
String value = Redis 执行命令:get validationKey;
if (value == null) {
// 没有找到验证码记录,验证失败
return false;
}
// 比较用户输入的验证码与存储在 Redis 中的验证码是否一致
if (value.equals(validationCode)) {
return true; // 验证成功
} else {
return false; // 验证失败
}
}
2.5.分布式锁功能(存在则失败)
2.5.1.实现思路
在一个分布式系统中,多个节点可能会访问同一个公共资源,此时需要通过锁来进行互斥控制,以避免出现类似于“线程安全”问题。然而,Java
的 synchronized
或 C++
的 std::mutex
等锁机制只能在当前进程中生效,它们无法在多个进程、多个主机的分布式场景下提供互斥保护。
因此,在分布式系统中,必须使用分布式锁来确保多个节点对共享资源的访问不会发生冲突。分布式锁能够跨越不同的进程和主机,确保在整个分布式系统中,同一时刻只有一个节点能够访问某个资源,从而避免资源冲突和数据不一致问题。
举个来例子,考虑买票的场景,现在车站提供了若干个车次,每个车次的票数都是固定的。现在存在多个服务器节点,都可能需要处理这个买票的逻辑:先查询指定车次的余票,如果 余票 > 0
,则设置 余票值 -= 1
。这显然是存在线程安全问题的,并且由于有多个服务器,需要引入 Redis
集群进行分布式锁的管理。
- 买票服务器1 需要先访问
Redis
,尝试设置一个键值对。假设key
为车次,value
为任意值(如1
)。 - 如果该操作成功设置了键值对,表示当前没有其他节点对该车次加锁,此时服务器1可以进行数据库的读写操作。操作完成后,
服务器1
会删除Redis
上的该键值对。 - 如果 买票服务器2 在此时也尝试买票,它也会向
Redis
写入相同的键(车次)。但此时Redis
会发现该车次的key
已经存在,说明服务器1
已经持有锁。此时,服务器2会等待或者暂时放弃操作。
在这个场景中,Redis
提供了 setnx
操作,非常适合用来实现分布式锁。setnx
的功能是:如果 key
不存在,则设置 key
和 value
,如果 key
已经存在,则操作失败。这种机制能够确保只有一个服务器能成功获得锁,从而避免多个服务器同时对同一车次进行操作。
但是上述这个方案不完整,您还需要考虑一些情况。
2.5.2.过期时间
当 服务器1
加锁之后, 开始处理买票的过程中, 如果 服务器1
意外宕机了,就会导致解锁操作(删除该 key
)不能被执行。就可能引起其他服务器始终无法获取到锁的情况。为了解决这个问题,可以在设置 key
的同时引入过期时间。即这个锁最多持有多久,就应该被释放。这种情况下,最好使用 set ex nx
的方式,同时设置键并且要求过期时间。
- NX: 只在
key
不存在时设置值 - EX: 设置键的过期时间,单位是秒
重要
补充:如果分开操作,例如作 setnx
之后,再来一个单独的 expire
,由于 Redis
的多个指令之间不存在关联,并且即使使用了事务也不能保证这两个操作都一定成功,因此就可能出现 setnx
成功,但是 expire
失败的情况(事务仅仅只是保证执行顺序,不保证成功),此时仍然会出现无法正确释放锁的问题。
2.5.3.校验标识
对于 Redis
中写入的加锁键值对,其他的节点也是可以删除的。比如 服务器1
写入一个 "001": 1
这样的键值对,服务器2
是完全可以把 "001"
给删除掉的。我们当然可以保证 服务器2
不会进行这样的 "恶意删除"
操作,不过不能保证因为一些 bug
导致 服务器2
把锁误删除了。为了解决上述问题,我们可以引入一个 校验 id
。比如可以把设置的键值对的值,不再是简单的设为一个 1
,而是设成服务器的编号,形如 "001": "服务器 1"
。这样就可以在删除 key
(解锁)的时候,先校验当前删除 key
的服务器是否为当初加锁的服务器,如果是,才能真正删除;不是,则不能删除。
// 伪代码
String key = [要加锁的资源 id];
String serverId = [服务器的编号];
// 加锁, 设置过期时间为 10s
redis.set(key, serverId, "NX", "EX", "10s");
// 执行各种业务逻辑, 比如修改数据库数据
doSomeThing();
// 解锁, 删除 key, 但是删除前要检验下 serverId 是否匹配
if (redis.get(key) == serverId) {
redis.del(key);
}
不过这么做也有一个问题,解锁逻辑是两步操作 get
和 del
,这会导致原子问题。不过我们可以使用 Lua
脚本解决这种问题。
-- 解锁脚本
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end;
一个 lua
脚本会被 Redis
服务器以原子的方式来执行,用事务虽然可以,但是不够 Lua
灵活...
2.5.4.引看门狗
不过万一键值对提前过期了,然而加锁的客户端没有打算释放锁怎么办?把这个过期时间设置的足够长,比如 30s
,是否能解决这个问题呢? 很明显,设置多长时间合适, 是无止境的。即使设置再长,也不能完全保证就没有提前失效的情况。而且如果设置的太长了,万一对应的服务器挂了。此时其他服务器也不能及时的获取到锁。因此相比于设置一个固定的长时间, 不如动态的调整时间更合适。
所谓 watch dog(看门狗)
本质上是加锁的服务器上的一个单独的线程,通过这个线程来对锁过期时间进行续约。
注意
注意:这个看门狗线程是业务服务器上的,不是 Redis
服务器的,因此业务服务器挂掉,看门狗也会挂掉,不过由于没有人续约,Redis
服务器时间到了就会把键值对删除。
2.5.5.集群加锁
在实践中,Redis
通常是以集群的形式部署的(至少是主从模式,而不是单机部署)。在这种情况下,可能会出现如下极端的情况(概率比较小,但是客观存在):
服务器1
向Redis
的master
节点进行加锁操作,这个写入key
的过程刚刚完成,但在此时master
节点挂掉- 随后,
slave
节点升级成新的master
节点,但由于刚才的写入操作尚未来得及同步到slave
,此时新的master
不包含刚才的key
- 这样,
服务器2
仍然可以向新的master
节点写入key
,从而绕过加锁操作,导致加锁失败
为了解决这个问题,Redis
的作者提出了 Redlock 算法,它能够确保在分布式环境中,即使 Redis
集群中的节点发生故障,也能够维持分布式锁的正确性和一致性。
其原理就是:我们引⼊一组 Redis
节点,其中每一组 Redis
节点都包含一个主节点和若干从节点,并且组和组之间存储的数据都是一致的,相互之间是“备份关系”(而并非是数据集合的一部分,这点有别于 Redis cluster
)。加锁的时候,按照一定的顺序,写多个 master
节点。在写锁的时候需要设定操作的“超时时间”。比如 50ms
,即如果 setnx
操作超过了 50ms
还没有成功,就视为加锁失败。因此简单类说,加锁操作不能只写给一个 Redis
节点,而要写就写多个 Redis
节点!分布式系统中任何一个节点都是不可靠的,最终的加锁成功结论是 "少数服从多数的"
。
重要
补充:当然,分布式锁已经有很多库实现好了,我之前介绍的几个第三方库就实现了这些功能。
重要
补充:利用 Redis
这种特性,完全可以实现其他锁,例如可重入锁、公平锁、读写锁等,并且逻辑更加复杂...
2.6.消息队列功能(阻塞列表 List)
由于 Redis
有阻塞式的列表类型,因此天生就可以作为简单的生产者消费者模型来实现,而由于 Redis
本身支持分布式架构,因此可以作为简易的消息队列。生产者客户端(这是对于 Redis
而言)可以使用 lpush
从列表左侧插入元素,多个消费者客户端(这是对于 Redis
而言)使用 brpop
命令阻塞式的抢夺队列中的队首元素(并且由于单线程的特点无需解决争夺锁的问题)。
并且还可以实现“频道”的概念,一个频道对应一个列表,一个列表就是一个消息队列,每个消息队列每个时刻都只能有一个消费者抢得到数据。
2.7.用户标签功能(无权集合 Set)
好的,Redis
的集合类型非常适合用于标签功能的实现。集合类型的特点是没有重复元素,可以用于表示一组用户的兴趣标签。通过集合操作,可以方便地实现不同标签之间的交集、并集和差集,帮助我们进行精准的用户推荐和兴趣分析。
假设我们有两个用户:
- 用户
A
对娱乐和体育感兴趣,分别有标签entertainment
和sports
- 用户
B
对历史和新闻感兴趣,分别有标签history
和news
我们希望通过 Redis
集合来管理这些标签,并进行一些操作,例如找出两个用户的共同标签、找出喜欢同一标签的用户等。
设置用户标签:首先使用
Redis
的集合(SET
)来存储每个用户的兴趣标签。# 设置用户标签 # 用户 A 的兴趣标签 SADD user:A tags:entertainment tags:sports # 用户 B 的兴趣标签 SADD user:B tags:history tags:news
找到共同的兴趣标签:通过
Redis
的SINTER(交集)
命令,可以找出两个用户共同的标签。例如,找出用户A
和用户B
的共同兴趣标签。# 找到共同的兴趣标签 SINTER user:A user:B
找到喜欢相同标签的用户:如果有多个用户并希望找到哪些用户对某个标签感兴趣,可以利用集合的
SISMEMBER(判断某个元素是否在集合中)
命令来查找。# 找到喜欢相同标签的用户 # 用户 A 和用户 B 的兴趣标签已经存储在集合中,可以通过SISMEMBER来检查 SISMEMBER user:A tags:sports # 返回 1,表示用户A感兴趣 SISMEMBER user:B tags:sports # 返回 0,表示用户B不感兴趣
基于共同标签进行推荐:如果我们想要基于标签进行用户推荐,可以通过
SUNION
(并集)来找到对相同标签感兴趣的所有用户。这里通过SUNION
得到的是对entertainment
标签感兴趣的所有用户。# 基于共同标签进行推荐 # 找出对 'entertainment' 标签感兴趣的用户 SADD user:C tags:entertainment SUNION user:A user:B user:C tags:entertainment
这种基于集合的标签功能,能够帮助电商平台、社交平台等更好地进行个性化推荐,提升用户的体验和粘性。
2.8.排行系统功能(带权列表 Zset)
Zset
的聚合搜索,加上带权,非常适合作为排行系统,排行的系统是注重动态的,需要实时按照时间、阅读量、点赞量来更新,时刻维护热榜。
这里是简化后的 Redis 操作,主要用于管理用户的赞数和排名:
添加用户赞数:使用
zadd
添加初始赞数,使用zincrby
增加赞数# 添加用户赞数 zadd user:ranking:2022-03-15 3 james zincrby user:ranking:2022-03-15 1 james
**取消用户赞数:**使用
zrem
删除用户# 取消用户赞数 zrem user:ranking:2022-03-15 tom
获取赞数最多的前 10 用户:使用
zrevrange
获取前10
名# 获取赞数最多的前 10 用户 zrevrange user:ranking:2022-03-15 0 9
获取用户信息和分数
使用哈希类型存储用户信息:
# 使用哈希类型存储用户信息 hgetall user:info:tom
使用
zscore
获取用户分数:# 使用 zscore 获取用户分数 zscore user:ranking:2022-03-15 mike
使用
zrank
获取用户排名:# 使用 zrank 获取用户排名 zrank user:ranking:2022-03-15 mike
这些操作可以帮助管理用户的点赞、删除用户、获取用户排名和分数等信息。
File not found