Redis 是一个高性能的内存数据结构存储系统,常用于缓存、会话存储、实时分析等场景。学习 Redis 是为了提升系统性能和响应速度。难点在于在业务过程中Redis的合理应用场景的考虑以及数据结构选型。
这里我主要描述怎么用Golang操作Redis而不是部署Redis。
Redis 是一个开源的内存数据结构存储系统,通常用作数据库、缓存和消息代理。它支持多种数据结构,如字符串、哈希、列表、集合、有序集合、位图、HyperLogLogs 和地理空间索引半径查询。Redis 内置了复制、Lua 脚本、LRU 驱动事件、事务和不同级别的磁盘持久化,并通过 Redis Sentinel 和 Redis Cluster 提供高可用性。
以下是 Redis 的一些关键特性:
- 高性能:Redis 是内存数据库,读写速度非常快,适用于需要快速响应的应用场景。其单线程架构避免了多线程上下文切换的开销,进一步提升了性能。
- 丰富的数据类型:支持多种数据结构,包括字符串、哈希、列表、集合、有序集合、位图、HyperLogLogs 和地理空间索引半径查询,满足不同的应用需求。
- 持久化:支持将数据持久化到磁盘,防止数据丢失。提供 RDB 快照和 AOF 日志两种持久化方式,用户可以根据需求选择合适的持久化策略。
- 高可用性:通过主从复制和 Redis Sentinel 实现高可用性。Redis Sentinel 提供监控、通知和自动故障转移功能,确保系统的高可用性。
- 分布式:支持 Redis Cluster,实现分布式存储和负载均衡。Redis Cluster 通过分片机制将数据分布到多个节点上,提供水平扩展能力。
- 扩展性:支持模块化扩展,用户可以通过编写 Redis 模块来扩展 Redis 的功能,满足特定的业务需求。
Redis 常用于缓存、会话存储、实时分析、消息队列等场景。其高性能和丰富的功能使其成为许多高并发、高性能应用的首选解决方案。
2.2.1 前言
在这之前,我们需要了解一下分布式系统里的一些概念
2.2.1.1 CAP 定理
CAP 定理是分布式系统中的一个基本理论,它指出在一个分布式数据存储系统中,不可能同时满足以下三个特性:
- 一致性(Consistency):所有节点在同一时间具有相同的数据。
- 可用性(Availability):每个请求都能收到一个(成功或失败)响应。
- 分区容忍性(Partition Tolerance):系统在遇到任意网络分区故障时,仍然能够继续运作。
根据 CAP 定理,一个分布式系统最多只能同时满足其中的两个特性,而不可能同时满足三个特性。
假设一个分布式系统能够同时满足一致性、可用性和分区容忍性,那么在实际操作中会遇到以下问题,导致性能下降:
- 一致性:为了保证所有节点在同一时间具有相同的数据,系统需要在每次数据更新时同步所有节点的数据。这意味着每次写操作都需要等待所有节点确认更新成功,才能返回成功响应。这会导致写操作的延迟增加,影响系统的整体性能。
- 可用性:为了保证每个请求都能收到一个响应,系统需要在任何时候都能处理读写请求,即使某些节点不可用或网络分区发生。这要求系统在处理请求时进行更多的冗余操作,增加了系统的复杂性和资源消耗。
- 分区容忍性:为了保证系统在网络分区的情况下仍然能够继续运作,系统需要在网络分区时继续处理请求,并在网络恢复后进行数据同步。这会导致在网络分区期间,系统需要处理更多的冲突和数据合并操作,进一步增加了系统的负担。
因此,在实际操作中,不可能同时满足一致性、可用性和分区容忍性(若均满足,则与单体式架构无异)。分布式系统设计者需要根据具体应用场景和需求,在一致性和可用性之间做出权衡,以避免性能下降。
2.2.1.2 Redis Cluster 运行机制
Redis Cluster 是 Redis 的分布式实现,通过分片机制将数据分布到多个节点上,实现水平扩展和高可用性。以下是 Redis Cluster 的主要运行机制:
数据分片:
- Redis Cluster 使用哈希槽(hash slot)来分片数据。整个键空间被划分为 16384 个哈希槽,每个键通过 CRC16 哈希函数计算得到一个哈希值,并映射到对应的哈希槽。
- 每个节点负责一部分哈希槽,数据根据哈希槽分布到不同的节点上。
节点通信:
- Redis Cluster 中的节点通过 Gossip 协议进行通信,定期交换状态信息,确保集群的一致性和健康状态。
- 每个节点都保存集群中其他节点的状态信息,包括节点的角色(主节点或从节点)、负责的哈希槽范围等。
主从复制:
- 为了实现高可用性,Redis Cluster 支持主从复制。每个主节点可以有一个或多个从节点,主节点负责处理读写请求,从节点负责备份数据。
- 当主节点发生故障时,从节点可以自动提升为主节点,继续提供服务。
故障检测和故障转移:
- Redis Cluster 通过 Gossip 协议和心跳机制检测节点的故障。当一个节点被多数主节点标记为下线(fail)时,集群会进行故障转移。
- 故障转移过程中,从节点会被提升为主节点,并接管故障主节点的哈希槽。
请求路由:
- 客户端在连接 Redis Cluster 时,会自动发现集群拓扑结构,并将请求路由到正确的节点。
- 如果客户端请求的键不在连接的节点上,节点会返回一个重定向命令(MOVED),客户端根据重定向信息访问正确的节点。
一致性保证:
- Redis Cluster 采用异步复制机制,主节点将写操作异步复制到从节点。虽然这种机制提高了性能,但在网络分区或节点故障时,可能会出现数据不一致的情况。
- 为了提高一致性,Redis Cluster 提供了 命令,允许客户端等待写操作被复制到指定数量的从节点后再返回。
2.2.1.3 Redis的AP特性
Redis 在 CAP 定理中主要满足 AP 特性,而在一致性方面做出了一定的妥协:
可用性:Redis 通过主从复制和 Redis Sentinel 提供高可用性。当主节点发生故障时,Redis Sentinel 可以自动进行故障转移,确保系统的高可用性。
分区容忍性:Redis Cluster 通过分片机制将数据分布到多个节点上,能够在网络分区的情况下继续提供服务。
一致性: Redis 在分布式环境下无法完全保证一致性。在网络分区的情况下,可能会出现数据不一致的情况。例如,当主节点和从节点之间的网络连接中断时,主节点上的数据更新无法及时同步到从节点,导致数据不一致。
2.2.2 Redis 的局限
- 内存限制:由于 Redis 是内存数据库,存储的数据量受限于可用内存大小。对于大规模数据存储,可能需要额外的内存扩展或分片机制。
- 数据持久化:虽然 Redis 提供了 RDB 快照和 AOF 日志两种持久化方式,但在极端情况下仍可能会有数据丢失的风险。
- 一致性问题:在分布式环境下,Redis 无法完全保证数据的一致性。在网络分区的情况下,可能会出现数据不一致的情况。
- 单线程架构:Redis 的单线程架构虽然简化了设计并提升了性能,但在某些高并发写入场景下,可能会成为瓶颈。
- 自动事务支持不足:Redis 对自动事务的支持较弱(在发布/订阅模式下,即便开启Async模式,也会出现性能问题——
都用Async了,为什么不用更专业的消息队列中间件呢?),例如在键过期(TTL)后发送消息的场景中,Redis 采用即发即忘(Fire And Forget)的方式,不保证消息必然可达,这可能会导致某些关键操作的可靠性问题。
2.2.3 推荐使用场景
临时数据存储:
- 缓存:需要快速访问频繁使用的数据,以减少数据库的负载和响应时间。Redis 的内存存储特性和高读写性能使其非常适合作为缓存层,能够显著提升系统的响应速度。
- 会话存储:需要存储用户会话信息,如登录状态、用户偏好等。Redis 提供的持久化机制和高可用性保证了会话数据的可靠性,同时其快速读写能力确保了用户体验的流畅性。
重要数据存储:
- 实时分析:需要对实时数据进行快速处理和分析,如实时统计、监控数据等。Redis 支持丰富的数据结构和高效的操作,能够快速处理和分析大量实时数据。
实时性要求高的数据存储:
- 排行榜和计数器:需要实现实时更新的排行榜和计数器,如游戏排行榜、网站访问计数等。Redis 的有序集合和原子操作特性使其能够高效地实现这些功能,确保数据的准确性和实时性。
- 地理位置数据存储:需要存储和查询地理位置数据,如定位服务、附近搜索等。Redis 提供的地理空间索引和半径查询功能使其能够高效地处理地理位置数据。
这里我用的是这个包,由于连接Redis过程比较简单,这里不再赘述
验证码
验证码具有临时性、高并发访问和快速读写的特性。验证码通常用于验证用户身份或防止恶意攻击,其有效期较短,一般在几分钟内失效。Redis 作为内存数据库,能够提供极高的读写速度和低延迟,确保验证码在生成、存储和验证过程中能够快速响应用户请求,提升用户体验。
此外,验证码的使用场景通常伴随着高并发访问,例如在用户登录、注册或进行敏感操作时,可能会有大量用户同时请求验证码。Redis 的高并发处理能力和单线程架构能够有效应对这种高并发场景,避免传统关系型数据库在高并发下可能出现的性能瓶颈。
Redis 还提供了丰富的数据结构和过期策略,可以方便地设置验证码的有效期,确保验证码在过期后自动删除,节省内存空间。同时,Redis 的持久化机制可以在系统重启或故障恢复后保留验证码数据,保证系统的可靠性
基础流程
- 生成验证码:当用户请求验证码时,服务端程序会生成一个随机验证码,并将其与用户标识(邮箱)关联;如果这个操作间隔较短(初始过期时间 - TTL < 规定操作间隔),则不会存储验证码。
- 存储验证码:将生成的验证码存储到 Redis 中,并设置过期时间(10 分钟),确保验证码在有效期内可用。
- 发送验证码:将生成的验证码通过邮件方式发送给用户。
- 验证验证码:用户提交验证码时,系统从 Redis 中获取对应的验证码并进行比对,验证成功后执行相应操作。
- 删除验证码:验证码验证成功或过期后,Redis 会自动删除该验证码,释放内存空间。
这里给出基础的Redis键值对操作示例
手动过期Token
在用户忘记密码的场景中,可以为 Token 设置一个带有 TTL 的过期时间(ExpiredAt)。如果当前时间超过了 Token 的 ExpiredAt,则该 Token 被视为无效。(手动过期)
手动设置Token过期时间通常具有临时性,通常在Token最大生命周期后失效。Redis 可以确保在过期后自动删除Token过期时间。
由于需要在生成、存储和验证过程中快速响应用户请求。Redis 作为内存数据库,能够提供极高的读写速度和低延迟。
基础流程
- 生成过期时间:当用户重置密码后,服务端程序会设置一个 Token 过期时间(当前时间戳+Token最大生命周期)。
- 存储过期时间:将生成的Token过期时间存储到 Redis 中,并设置TTL,确保之前的 Token 不可用。
- 验证 Token:用户提交 Token 时,系统从 Redis 中获取对应的 Token 过期时间并进行比对。如果用户提交的 Token 未超过 Token 过期时间,则验证成功。
这里给出在 Token 校验时的应用
视频投稿信息临时存储
视频投稿信息具有上述二者相似的特点,这里不再赘述。主要是想提及Redis的管道技术(Pipeline):
- 批量发送命令:客户端将多个命令打包成一个请求,一次性发送到 Redis 服务器,减少了网络延迟和开销。
- 顺序执行命令:Redis 服务器按照接收到的顺序依次执行这些命令,确保命令的执行顺序与客户端发送的顺序一致。
- 批量返回结果:Redis 服务器执行完所有命令后,将结果一次性返回给客户端,进一步减少了网络往返次数。
对于可能的批量操作,我们可以这样实现:
视频播放量
省略解释视频播放量虽然是持久的,但具有高频特性,我们完全可以用Redis实现(采用Sorted Set)
基础流程
记录访问 IP:
- 通过 命令将访问视频的 IP 地址添加到 Redis 集合中,定义键名为 ,键名需包含 IP 地址与 视频唯一标识符的组合信息。
- 如果添加成功,返回 ,否则返回错误信息。
检查 IP 是否已访问:
- 首先通过 命令检查集合是否存在。如果集合不存在,则返回 。
- 如果集合存在,通过 命令检查 IP 是否在集合中。如果 IP 在集合中,返回 ,否则返回 。
- 如果 IP 不在集合中,则增加视频播放量。
增加视频播放量:
- 通过 命令将视频的播放量在有序集合 中增加 1,键名为视频的唯一标识符。
- 如果增加成功,返回 ,否则返回错误信息。
删除访问 IP:
- 通过 命令将 从 Redis 集合中移除。
- 如果删除成功,返回 ,否则返回错误信息。
同时,基于Sorted Set的特性,可以实现排行榜功能(TopK)
点赞
省略解释
点赞的实现在网络上丰富多样,这里我主要谈谈我自己的想法。
在本次实践中,我的逻辑流程如下:
- 同步至数据库的只会是新的点赞信息(新的点赞信息存储于Redis的动态区)减少数据库的插入-删除操作开销。
- 将Redis分区,分为动态区和静态区,动态区主要用于新点赞信息的保存,静态区主要用于旧点赞信息的保存。用户点赞时,会先移除静态区的点赞信息(如果有),再将点赞信息添加到动态区。以此减少Redis的数据传输开销(对于单个存储区则必须获取全部的点赞信息)。
- 对于一个视频或类似属性的对象,在一段时间内,对于首个点赞操作,会开启一个执行同步任务的协程,这个同步任务延迟x分钟执行。在这x分钟内,任何点赞操作都无法开启执行同步任务的协程。只有当同步任务的协程完成任务后,才能够开启新的同步任务协程。以此完成类似于缓冲队列的功能以及减少非必要的同步操作。
动态区
动态区采用的是Sorted Set结构,该结构的数据操作有如下特性:
添加元素:
- 时间复杂度:O(log(N)),其中 N 是有序集合中的元素数量。
删除元素:
- 时间复杂度:O(log(N)),其中 N 是有序集合中的元素数量。
获取指定分数范围内的元素:
- 时间复杂度:O(log(N) + M),其中 N 是有序集合中的元素数量,M 是结果集的元素数量。
获取元素的分数:
- 时间复杂度:O(1)
动态区逻辑分析
- 当用户点赞时,会进行(不管静态区或动态区是否存在数据)、操作,此时的时间复杂度为 O(log(N)) + O(log(N)) = O(log(N))
- 当获取用户点赞信息时,会进行操作,此时时间复杂度为 O(1)
- 当获取对象点赞总数时,会进行操作,此时时间复杂度为 O(log(N) + M)
- 由于动态区的数据在绝大多数情况下远小于静态区数据(因为在较短时间内转移到静态区和数据库中),所以动态区的操作耗时在大多数情况下可以近似接近于 O(1) ,确保接口性能
静态区
注意,静态区仅存储已点赞数据,不存储取消点赞的数据
静态区采用的是Set结构,该结构的数据操作有如下特性:
添加元素:
- 时间复杂度:O(1)(平均)
删除元素:
- 时间复杂度:O(1)(平均)
检查元素是否存在:
- 时间复杂度:O(1)
获取集合的元素数量:
- 时间复杂度:O(1)
静态区逻辑分析
- 当用户点赞时,会进行操作,此时的时间复杂度为 O(1)
- 当获取用户点赞信息时,会进行操作,此时时间复杂度为 O(1)
- 当获取对象点赞总数时,会进行操作,此时时间复杂度为 O(1)
性能不必多说
题外话
为了保证点赞接口的性能,自然在调用点赞接口时不能涉及任何关系数据库操作(简单来说,如果你用的是MySQL,那么点赞接口最好不要看到任何一个MySQL的增删改查)
这是一个我的点赞接口实现(可以发现,1. 接口逻辑没有调用MySQL的增删改查 2.由于点赞是不重要的信息,接口逻辑开了另一个协程并忽略Redis操作可能返回的error):
上文提到,Redis对自动事务的适配较差,而由于我们小组的资金不足(内存就2GB,就算是一个简单的视频网站,仅仅2GB内存是完全不够的——节省点内存加个Gorse和ZincSearch),所以也不能直接使用消息队列。
问题在于,有些东西必须使用类似于消息队列的功能,比如说Redis中的点赞信息延迟同步至数据库。
自行实现消息队列
于是我就考虑自行实现一个还算能够使用的、高定制化的小组件(Scheduler)。
与一些定时任务框架不同,这个小组件专注于在资源有限的情况下,通过手动实现一个高定制化的任务调度组件,来替代自动事务和消息队列的功能。实现目的和主要功能分为以下几点:
目的
- 任务调度:通过调度器管理和调度延迟任务,确保任务在指定的延迟时间后执行。
- 高并发处理:通过分片机制(sharding)和分区互斥锁,解决单一互斥锁的性能瓶颈,提高并发处理能力。
- 任务去重:确保同一任务在一段时间内只能运行一次,避免重复执行。
- 任务强制覆盖:提供强制覆盖任务的功能,允许在任务已存在的情况下,停止旧任务并启动新任务。
- 日志记录:通过日志记录任务的启动、执行和停止情况,便于调试和监控。
主要功能
- 任务注册表:使用 sync.Map 存储任务的唯一标识符和取消函数,用于管理任务的状态。
- 任务启动:
- Start 方法:异步启动任务,并通过可选的通道返回任务是否被接受。
- StartWithReturn 方法:同步启动任务,并返回任务是否被接受。
- 任务强制启动:
- ForceStartTask 方法:异步强制启动任务,停止已存在的任务并启动新任务。
- ForceStartTaskWithReturn 方法:同步强制启动任务,停止已存在的任务并启动新任务。
- 任务取消:通过上下文(context)控制任务的取消,确保任务在取消后停止执行。
- 日志记录:根据配置记录任务的启动、执行和停止情况,支持静默模式和调试模式。
具体代码如下:
两层锁
为什么有两层锁
在这段代码中,我使用了两层锁机制:分区锁(partitionMutex)和 。这两层锁的解释分别如下:
分区锁(partitionMutex):
- 目的:解决单一互斥锁的性能瓶颈,提高并发处理能力。
- 实现方式:通过将任务分片(sharding),将任务分配到不同的分区,每个分区使用一个互斥锁来控制任务的并发执行。
- 作用:当任务启动时,根据任务的键计算其分片,并获取对应分区的互斥锁,确保同一分区内的任务不会并发执行。这种方式减少了锁的争用,提高了系统的并发处理能力。
- 必要性:如果去掉分区锁,多个任务可能会同时访问和修改 ,导致数据竞争(data race),从而引发 。
sync.Map:
- 目的:管理任务的状态,确保同一任务在同一时间只能运行一次,避免重复执行。
- 实现方式:使用 存储任务的唯一标识符和取消函数,用于管理任务的状态。
- 作用:在启动任务时,首先检查 中是否已存在相同标识符的任务。如果存在,则拒绝启动新任务;如果不存在,则将任务标识符和取消函数存储到 中,并启动任务。任务执行完成后,从 中删除任务标识符。
- 必要性:如果去掉 ,无法保证同一任务在同一时间只能运行一次,可能会导致同一任务被多次启动,造成逻辑错误和资源浪费,最终可能引发 。
Redis 官方建议在生产环境中避免使用 命令,主要是因为:
性能问题:
命令会扫描整个数据库来查找匹配的键,这在数据量较大时会导致严重的性能问题。
执行 命令时,Redis 需要遍历所有键,这会消耗大量的 CPU 和内存资源,导致 Redis 响应变慢。阻塞服务器:
命令是阻塞操作,在执行期间会阻塞 Redis 服务器,导致其他命令无法及时响应。
在高并发环境下,使用 命令可能会导致 Redis 服务器无法处理其他请求,影响整体性能和稳定性。生产环境不安全:
在生产环境中使用 命令可能会导致 Redis 服务器负载过高,影响其他业务的正常运行。
命令是非阻塞的,可以分批次地遍历键,避免一次性扫描整个数据库带来的性能问题,因此可以用 来代替 命令。
解决过程
以一段代码为例:
流程
- 初始化游标:定义一个 cursor,用于记录 SCAN 命令的游标。
- 循环扫描:使用 for 循环不断扫描 Redis 数据库中的键,直到扫描结束。
- 扫描键:调用 Scan 方法,使用 SCAN 命令查找匹配模式 的键,每次最多返回 1000 个键。keys 存储匹配的键,cursor 存储下一个扫描位置,err 存储可能的错误。
- 删除匹配的键:如果找到匹配的键(len(keys) > 0),调用 方法删除这些键。
- 检查游标:如果 cursor 为 0,表示扫描结束,退出循环。
Redis 在高并发场景下表现出色,尤其适用于缓存、会话管理和实时数据分析等应用。通过使用 SCAN 命令,可以高效地遍历和管理大量键,避免了 KEYS 命令带来的阻塞问题。此外,Redis 的持久化和复制功能也为数据的高可用性和可靠性提供了保障。