Redis 简析
概述
什么是 Redis
REmote DIctionary Server(Redis) 是一个由 Salvatore Sanfilippo 写的 key-value 存储系统,是跨平台的非关系型数据库。
Redis 是一个开源的使用 ANSI C 语言编写、遵守 BSD 协议、支持网络、可基于内存、分布式、可选持久性的键值对 (Key-Value) 存储数据库,并提供多种语言的 API。
Redis 通常被称为数据结构服务器,因为值(value)可以是字符串 (String)、哈希 (Hash)、列表 (list)、集合 (sets) 和有序集合 (sorted sets) 等类型。
Redis 是完全开源的,遵守 BSD 协议,是一个高性能的 key-value 数据库。
Redis 与其他 key-value 缓存产品有以下三个特点:
- Redis 支持数据的持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用。
- Redis 不仅仅支持简单的 key-value 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。
- Redis 支持数据的备份,即 master-slave 模式的数据备份。
Redis 特性
速度快
正常情况下,Redis 执行命令的速度非常快,官方给出的数字是读写性能可以达到 10 万/秒,当然这也取决于机器的性能,但这里先不讨论机器性能上的差异,只分析一下是什么造就了 Redis 除此之快的速度,可以大致归纳为以下四点:
- Redis 的所有数据都是存放在内存中的,表 1-1 是谷歌公司 2009 年给出的各层级硬件执行速度,所以把数据放在内存中是 Redis 速度快的最主要原因。
- Redis 是用 C 语言实现的,一般来说 C 语言实现的程序“距离”操作系统更近,执行速度相对会更快。
- Redis 使用了单线程架构,预防了多线程可能产生的竞争问题和线程切换开销。使用多路 I/O 复用模型,非阻塞 IO。
- 作者对于 Redis 源代码可以说是精打细磨,数据结构简单,对数据操作也简单,Redis 中的数据结构是专门进行设计的。
表 1-1
基于键值对的数据结构服务器
几乎所有的编程语言都提供了类似字典的功能,例如 Java 里的 map、Python 里的 dict,类似于这种组织数据的方式叫作基于键值的方式,与很多键值对数据库不同的是,Redis 中的值不仅可以是字符串,而且还可以是具体的数据结构,这样不仅能便于在许多应用场景的开发,同时也能够提高开发效率。Redis 的全称是 REmote Dictionary Server,它主要提供了 5 种数据结构:字符串、哈希、列表、集合、有序集合,同时在字符串的基础之上演变出了位图(Bitmaps)和 HyperLogLog 两种神奇的“数据结构”,并且随着 LBS(Location Based Service,基于位置服务)的不断发展,Redis3.2 版本中加入有关 GEO(地理信息定位)的功能
丰富的功能
除了 5 种数据结构,Redis 还提供了许多额外的功能:
- 提供了键过期功能,可以用来实现缓存。
- 提供了发布订阅功能,可以用来实现消息系统。
- 支持 Lua 脚本功能,可以利用 Lua 创造出新的 Redis 命令。
- 提供了简单的事务功能,能在一定程度上保证事务特性。
- 提供了流水线(Pipeline)功能,这样客户端能将一批命令一次性传到 Redis,减少了网络的开销。
简单稳定
Redis 的简单主要表现在三个方面。首先,Redis 的源码很少,早期版本的代码只有 2 万行左右,3.0 版本以后由于添加了集群特性,代码增至 5 万行左右,相对于很多 NoSQL 数据库来说代码量相对要少很多,也就意味着普通的开发和运维人员完全可以“吃透”它。其次,Redis 使用单线程模型,这样不仅使得 Redis 服务端处理模型变得简单,而且也使得客户端开发变得简单。最后,Redis 不需要依赖于操作系统中的类库(例如 Memcache 需要依赖 libevent 这样的系统类库),Redis 自己实现了事件处理的相关功能。
客户端语言多
Redis 提供了简单的 TCP 通信协议,很多编程语言可以很方便地接入到 Redis,并且由于 Redis 受到社区和各大公司的广泛认可,所以支持 Redis 的客户端语言也非常多,几乎涵盖了主流的编程语言,例如 Java、PHP、Python、C、C++、Nodejs
持久化
通常看,将数据放在内存中是不安全的,一旦发生断电或者机器故障,重要的数据可能就会丢失,因此 Redis 提供了两种持久化方式:RDB 和 AOF,即可以用两种策略将内存的数据保存到硬盘中,这样就保证了数据的可持久性
主从复制
Redis 提供了复制功能,实现了多个相同数据的 Redis 副本,复制功能是分布式 Redis 的基础
高可用和分布式
Redis 从 2.8 版本正式提供了高可用实现 Redis Sentinel,它能够保证 Redis 节点的故障发现和故障自动转移。Redis 从 3.0 版本正式提供了分布式实现 Redis Cluster,它是 Redis 真正的分布式实现,提供了高可用、读写和容量的扩展性。
Redis 使用场景
Redis 数据类型
Redis 主要有 5 种数据类型,包括 String,List,Set,Zset,Hash,满足大部分的使用要求
数据类型 | 可以存储的值 | 操作 | 应用场景 |
---|---|---|---|
STRING | 字符串、整数或者浮点数 | 对整个字符串或者字符串的其中一部分执行操作对整数和浮点数执行自增或者自减操作 | 做简单的键值对缓存 |
LIST | 列表 | 从两端压入或者弹出元素对单个或者多个元素进行修剪,只保留一个范围内的元素 | 存储一些列表型的数据结构,类似粉丝列表、文章的评论列表之类的数据 |
SET | 无序集合 | 添加、获取、移除单个元素检查一个元素是否存在于集合中计算交集、并集、差集从集合里面随机获取元素 | 交集、并集、差集的操作,比如交集,可以把两个人的粉丝列表整一个交集 |
HASH | 包含键值对的无序散列表 | 添加、获取、移除单个键值对获取所有键值对检查某个键是否存在 | 结构化的数据,比如一个对象 |
ZSET | 有序集合 | 添加、获取、删除元素根据分值范围或者成员来获取元素计算一个键的排名 | 去重但可以排序,如获取排名前几名的用户 |
Redis 可以用于哪些场景
- 缓存
缓存机制几乎在所有的大型网站都有使用,合理地使用缓存不仅可以加快数据的访问速度,而且能够有效地降低后端数据源的压力。Redis 提供了键值过期时间设置,并且也提供了灵活控制最大内存和内存溢出后的淘汰策略。 - 排行榜系统
排行榜系统几乎存在于所有的网站,例如按照热度排名的排行榜,按照发布时间的排行榜,按照各种复杂维度计算出的排行榜,Redis 提供了列表和有序集合数据结构,合理地使用这些数据结构可以很方便地构建各种排行榜系统。 - 计数器应用
计数器在网站中的作用至关重要,例如视频网站有播放数、电商网站有浏览数,为了保证数据的实时性,每一次播放和浏览都要做加 1 的操作,如果并发量很大对于传统关系型数据的性能是一种挑战。Redis 天然支持计数功能而且计数的性能也非常好,可以说是计数器系统的重要选择。 - 社交网络
赞/踩、粉丝、共同好友/喜好、推送、下拉刷新等是社交网站的必备功能,由于社交网站访问量通常比较大,而且传统的关系型数据不太适合保存这种类型的数据,Redis 提供的数据结构可以相对比较容易地实现这些功能。 - 消息队列系统
消息队列系统可以说是一个大型网站的必备基础组件,因为其具有业务解耦、非实时业务削峰等特性。Redis 提供了发布订阅功能和阻塞队列的功能,虽然和专业的消息队列比还不够足够强大,但是对于一般的消息队列功能基本可以满足。 - 分布式锁
在分布式场景下,无法使用单机环境下的锁来对多个节点上的进程进行同步。可以使用 Redis 自带的 SETNX 命令实现分布式锁,除此之外,还可以使用官方提供的 RedLock 分布式锁实现。
Redis 不能用于哪些场景
数据规模过大
站在数据规模的角度看,数据可以分为大规模数据和小规模数据,我们知道 Redis 的数据是存放在内存中的,虽然现在内存已经足够便宜,但是如果数据量非常大,例如每天有几亿的用户行为数据,使用 Redis 来存储的话,基本上是个无底洞,经济成本相当的高。
冷数据存储
站在数据冷热的角度看,数据分为热数据和冷数据,热数据通常是指需要频繁操作的数据,反之为冷数据,如果将冷数据放在 Redis 中,基本上是对于内存的一种浪费
持久化
持久化就是把内存的数据写到磁盘中去,防止服务宕机了内存数据丢失。
Redis 支持 RDB 和 AOF 两种持久化机制
RDB
RDB 持久化是把当前进程数据生成快照保存到硬盘的过程,触发 RDB 持久化过程分为手动触发和自动触发。
手动触发
手动触发分别对应 save 和 bgsave 命令:
- save 命令:阻塞当前 Redis 服务器,直到 RDB 过程完成为止,对于内存比较大的实例会造成长时间阻塞,线上环境不建议使用。
- bgsave 命令:Redis 进程执行 fork 操作创建子进程,RDB 持久化过程由子进程负责,完成后自动结束。阻塞只发生在 fork 阶段,一般时间很短。
自动触发
Redis 内部还存在自动触发 RDB 的持久化机制,例如以下场景:
- 使用 save 相关配置,如”save m n”。表示 m 秒内数据集存在 n 次修改时,自动触发 bgsave。
- 如果从节点执行全量复制操作,主节点自动执行 bgsave 生成 RDB 文件并发送给从节点。
- 执行 debug reload 命令重新加载 Redis 时,也会自动触发 save 操作。
- 默认情况下执行 shutdown 命令时,如果没有开启 AOF 持久化功能则自动执行 bgsave。
bgsave 执行流程
- 执行 bgsave 命令,Redis 父进程判断当前是否存在正在执行的子进程,如 RDB/AOF 子进程,如果存在 bgsave 命令直接返回。
- 父进程执行 fork 操作创建子进程,fork 操作过程中父进程会阻塞,通过 info stats 命令查看 latest_fork_usec 选项,可以获取最近一个 fork 操作的耗时,单位为微秒。
- 父进程 fork 完成后,bgsave 命令返回“Background saving started”信息并不再阻塞父进程,可以继续响应其他命令。
- 子进程创建 RDB 文件,根据父进程内存生成临时快照文件,完成后对原有文件进行原子替换。执行 lastsave 命令可以获取最后一次生成 RDB 的时间,对应 info 统计的 rdb_last_save_time 选项。
- 进程发送信号给父进程表示完成,父进程更新统计信息,具体见 info Persistence 下的 rdb_* 相关选项。
RDB 的优缺点
RDB 的优点:
- RDB 是一个紧凑压缩的二进制文件,代表 Redis 在某个时间点上的数据快照。非常适用于备份,全量复制等场景。比如每 6 小时执行 bgsave 备份,并把 RDB 文件拷贝到远程机器或者文件系统中(如 hdfs),用于灾难恢复。
- Redis 加载 RDB 恢复数据远远快于 AOF 的方式。
RDB 的缺点:
- RDB 方式数据没办法做到实时持久化/秒级持久化。因为 bgsave 每次运行都要执行 fork 操作创建子进程,属于重量级操作,频繁执行成本过高。
- RDB 文件使用特定二进制格式保存,Redis 版本演进过程中有多个格式的 RDB 版本,存在老版本 Redis 服务无法兼容新版 RDB 格式的问题。
针对 RDB 不适合实时持久化的问题,Redis 提供了 AOF 持久化方式来解决。
AOF
AOF(append only file)持久化:以独立日志的方式记录每次写命令,重启时再重新执行 AOF 文件中的命令达到恢复数据的目的。AOF 的主要作用是解决了数据持久化的实时性,目前已经是 Redis 持久化的主流方式。
AOF 的工作流程操作:命令写入(append)、文件同步(sync)、文件重写(rewrite)、重启加载(load)
流程如下:
- 所有的写入命令会追加到 aof_buf(缓冲区)中。
- AOF 缓冲区根据对应的策略向硬盘做同步操作。
- 随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写,达到压缩的目的。
- 当 Redis 服务器重启时,可以加载 AOF 文件进行数据恢复。
AOF 重写
随着命令不断写入 AOF,文件会越来越大,为了解决这个问题,Redis 引入 AOF 重写机制压缩文件体积。AOF 文件重写是把 Redis 进程内的数据转化为写命令同步到新 AOF 文件的过程。
重写后的 AOF 文件为什么可以变小?有如下原因:
- 进程内已经超时的数据不再写入文件。
- 旧的 AOF 文件含有无效命令,如 del key1、hdel key2、srem keys、seta111、set a222 等。重写使用进程内数据直接生成,这样新的 AOF 文件只保留最终数据的写入命令。
- 多条写命令可以合并为一个,如:lpush list a、lpush list b、lpush list c 可以转化为:lpush list a b c。
当触发 AOF 重写时,流程如下:
- 执行 AOF 重写请求。
- 父进程执行 fork 创建子进程,开销等同于 bgsave 过程。
- 主进程 fork 操作完成后,继续响应其他命令。所有修改命令依然写入 AOF 缓冲区并根据 appendfsync 策略同步到硬盘,保证原有 AOF 机制正确性。由于 fork 操作运用写时复制技术,子进程只能共享 fork 操作时的内存数据。由于父进程依然响应命令,Redis 使用“AOF 重写缓冲区”保存这部分新数据,防止新 AOF 文件生成期间丢失这部分数据。
- 子进程根据内存快照,按照命令合并规则写入到新的 AOF 文件。每次批量写入硬盘数据量由配置 aof-rewrite-incremental-fsync 控制,默认为 32MB,防止单次刷盘数据过多造成硬盘阻塞。
- 新 AOF 文件写入完成后,子进程发送信号给父进程,父进程更新统计信息,具体见 info persistence 下的 aof_* 相关统计。父进程把 AOF 重写缓冲区的数据写入到新的 AOF 文件。使用新 AOF 文件替换老文件,完成 AOF 重写。
重启加载
AOF 和 RDB 文件都可以用于服务器重启时的数据恢复。下图表示 Redis 持久化文件加载流程。
- AOF 持久化开启且存在 AOF 文件时,优先加载 AOF 文件。
- AOF 关闭或者 AOF 文件不存在时,加载 RDB 文件。
- 加载 AOF/RDB 文件成功后,Redis 启动成功。
- AOF/RDB 文件存在错误时,Redis 启动失败并打印错误信息。
AOF 的优缺点
AOF 的优点
- AOF 比 RDB 可靠。你可以设置不同的 fsync 策略:no、everysec 和 always。默认是 everysec,在这种配置下,redis 仍然可以保持良好的性能,并且就算发生故障停机,也最多只会丢失一秒钟的数据。
- AOF 文件是一个纯追加的日志文件。即使日志因为某些原因而包含了未写入完整的命令(比如写入时磁盘已满,写入中途停机等等), 我们也可以使用 redis-check-aof 工具也可以轻易地修复这种问题。
- 当 AOF 文件太大时,Redis 会自动在后台进行重写:重写后的新 AOF 文件包含了恢复当前数据集所需的最小命令集合。整个重写是绝对安全,因为重写是在一个新的文件上进行,同时 Redis 会继续往旧的文件追加数据。当新文件重写完毕,Redis 会把新旧文件进行切换,然后开始把数据写到新文件上。
- AOF 文件有序地保存了对数据库执行的所有写入操作以 Redis 协议的格式保存, 因此 AOF 文件的内容非常容易被人读懂, 对文件进行分析(parse)也很轻松。如果你不小心执行了 FLUSHALL 命令把所有数据刷掉了,但只要 AOF 文件没有被重写,那么只要停止服务器, 移除 AOF 文件末尾的 FLUSHALL 命令, 并重启 Redis , 就可以将数据集恢复到 FLUSHALL 执行之前的状态。
AOF 的缺点
- 对于相同的数据集,AOF 文件的大小一般会比 RDB 文件大。
- 根据所使用的 fsync 策略,AOF 的速度可能会比 RDB 慢。通常 fsync 设置为每秒一次就能获得比较高的性能,而关闭 fsync 可以让 AOF 的速度和 RDB 一样快。
复制
在分布式系统中为了解决单点问题,通常会把数据复制多个副本部署到其他机器,满足故障恢复和负载均衡等需求。Redis 也是如此,它为我们提供了复制功能,实现了相同数据的多个 Redis 副本。
参与复制的 Redis 实例划分为主节点(master)和从节点(slave)。默认情况下,Redis 都是主节点。每个从节点只能有一个主节点,而主节点可以同时具有多个从节点。复制的数据流是单向的,只能由主节点复制到从节点。
拓扑结构
Redis 的复制拓扑结构可以支持单层或多层复制关系,根据拓扑复杂性可以分为以下三种:一主一从、一主多从、树状主从结构。
一主一从结构
一主一从结构是最简单的复制拓扑结构,用于主节点出现宕机时从节点提供故障转移支持
一主多从结构
一主多从结构(又称为星形拓扑结构)使得应用端可以利用多个从节点实现读写分离。
对于读占比较大的场景,可以把读命令发送到从节点来分担主节点压力。
对于写并发量较高的场景,多个从节点会导致主节点写命令的多次发送从而过度消耗网络带宽,同时也加重了主节点的负载影响服务稳定性。
树状主从结构
树状主从结构(又称为树状拓扑结构)使得从节点不但可以复制主节点数据,同时可以作为其他从节点的主节点继续向下层复制。
通过引入复制中间层,可以有效降低主节点负载和需要传送给从节点的数据量。
当主节点需要挂载多个从节点时为了避免对主节点的性能干扰,可以采用树状主从结构降低主节点压力。
复制流程
从图中可以看出复制过程大致分为 6 个过程:
- 保存主节点(master)信息。
- 从节点(slave)内部通过每秒运行的定时任务维护复制相关逻辑,当定时任务发现存在新的主节点后,会尝试与该节点建立网络连接。
- 发送 ping 命令。
- 权限验证。如果主节点设置了 requirepass 参数,则需要密码验证,从节点必须配置 masterauth 参数保证与主节点相同的密码才能通过验证;如果验证失败复制将终止,从节点重新发起复制流程。
- 同步数据集。主从复制连接正常通信后,对于首次建立复制的场景,主节点会把持有的数据全部发送给从节点,这部分操作是耗时最长的步骤。
- 命令持续复制。当主节点把当前的数据同步给从节点后,便完成了复制的建立流程。接下来主节点会持续地把写命令发送给从节点,保证主从数据一致性。
数据同步
Redis 在 2.8 及以上版本使用 psync 命令完成主从数据同步,同步过程分为:全量复制和部分复制。
- 全量复制:一般用于初次复制场景,Redis 早期支持的复制功能只有全量复制,它会把主节点全部数据一次性发送给从节点,当数据量较大时,会对主从节点和网络造成很大的开销。
- 部分复制:用于处理在主从复制中因网络闪断等原因造成的数据丢失场景,当从节点再次连上主节点后,如果条件允许,主节点会补发丢失数据给从节点。因为补发的数据远远小于全量数据,可以有效避免全量复制的过高开销。
psync 命令运行需要以下组件支持:
- 主从节点各自复制偏移量。
- 主节点复制积压缓冲区。
- 主节点运行 id。
复制偏移量
参与复制的主从节点都会维护自身复制偏移量。主节点(master)在处理完写入命令后,会把命令的字节长度做累加记录。
从节点(slave)每秒钟上报自身的复制偏移量给主节点,因此主节点也会保存从节点的复制偏移量。
从节点在接收到主节点发送的命令后,也会累加记录自身的偏移量。
通过对比主从节点的复制偏移量,可以判断主从节点数据是否一致。
复制积压缓冲区
复制积压缓冲区是保存在主节点上的一个固定长度的队列,默认大小为 1MB,当主节点有连接的从节点(slave)时被创建,这时主节点(master)响应写命令时,不但会把命令发送给从节点,还会写入复制积压缓冲区。
由于缓冲区本质上是先进先出的定长队列,所以能实现保存最近已复制数据的功能,用于部分复制和复制命令丢失的数据补救。
主节点运行 ID
每个 Redis 节点启动后都会动态分配一个 40 位的十六进制字符串作为运行 ID。运行 ID 的主要作用是用来唯一识别 Redis 节点,比如从节点保存主节点的运行 ID 识别自己正在复制的是哪个主节点。如果只使用 ip+port 的方式识别主节点,那么主节点重启变更了整体数据集(如替换 RDB/AOF 文件),从节点再基于偏移量复制数据将是不安全的,因此当运行 ID 变化后从节点将做全量复制。
全量复制
全量复制是 Redis 最早支持的复制方式,也是主从第一次建立复制时必须经历的阶段。
流程说明:
- 发送 psync 命令进行数据同步,由于是第一次进行复制,从节点没有复制偏移量和主节点的运行 ID,所以发送 psync-1。
- 主节点根据 psync-1 解析出当前为全量复制,回复 +FULLRESYNC 响应。
- 从节点接收主节点的响应数据保存运行 ID 和偏移量 offset。
- 主节点执行 bgsave 保存 RDB 文件到本。
- 主节点发送 RDB 文件给从节点,从节点把接收的 RDB 文件保存在本地并直接作为从节点的数据文件,接收完 RDB 后从节点打印相关日志。
- 对于从节点开始接收 RDB 快照到接收完成期间,主节点仍然响应读写命令,因此主节点会把这期间写命令数据保存在复制客户端缓冲区内,当从节点加载完 RDB 文件后,主节点再把缓冲区内的数据发送给从节点,保证主从之间数据一致性。
- 从节点接收完主节点传送来的全部数据后会清空自身旧数据。
- 从节点清空数据后开始加载 RDB 文件,对于较大的 RDB 文件,这一步操作依然比较耗时,可以通过计算日志之间的时间差来判断加载 RDB 的总耗时。
- 从节点成功加载完 RDB 后,如果当前节点开启了 AOF 持久化功能,它会立刻做 bgrewriteaof 操作,为了保证全量复制后 AOF 持久化文件立刻可用。
通过分析全量复制的所有流程,我们会发现全量复制是一个非常耗时费力的操作。它的时间开销主要包括:
- 主节点 bgsave 时间。
- RDB 文件网络传输时间。
- 从节点清空数据时间。
- 从节点加载 RDB 的时间。
- 可能的 AOF 重写时间。
正因为全量复制的成本问题,Redis 实现了部分复制功能。
部分复制
部分复制主要是 Redis 针对全量复制的过高开销做出的一种优化措施。当从节点(slave)正在复制主节点(master)时,如果出现网络闪断或者命令丢失等异常情况时,从节点会向主节点要求补发丢失的命令数据,如果主节点的复制积压缓冲区内存在这部分数据则直接发送给从节点,这样就可以保持主从节点复制的一致性。补发的这部分数据一般远远小于全量数据,所以开销很小。
- 当主从节点之间网络出现中断时,如果超过 repl-timeout 时间,主节点会认为从节点故障并中断复制连接。
- 主从连接中断期间主节点依然响应命令,但因复制连接中断命令无法发送给从节点,不过主节点内部存在的复制积压缓冲区,依然可以保存最近一段时间的写命令数据,默认最大缓存 1MB。
- 当主从节点网络恢复后,从节点会再次连上主节点。
- 当主从连接恢复后,由于从节点之前保存了自身已复制的偏移量和主节点的运行 ID。因此会把它们当作 psync 参数发送给主节点,要求进行部分复制操作。
- 主节点接到 psync 命令后首先核对参数 runId 是否与自身一致,如果一致,说明之前复制的是当前主节点;之后根据参数 offset 在自身复制积压缓冲区查找,如果偏移量之后的数据存在缓冲区中,则对从节点发送 +CONTINUE 响应,表示可以进行部分复制。
- 主节点根据偏移量把复制积压缓冲区里的数据发送给从节点,保证主从复制进入正常状态。
心跳
主从节点在建立复制后,它们之间维护着长连接并彼此发送心跳命令。
主从心跳判断机制:
- 主从节点彼此都有心跳检测机制,各自模拟成对方的客户端进行通信。
- 主节点默认每隔 10 秒对从节点发送 ping 命令,判断从节点的存活性和连接状态。
- 从节点在主线程中每隔 1 秒发送 replconf ack {offset} 命令,给主节点上报自身当前的复制偏移量。
内存
Redis 所有的数据都存在内存中,当前内存虽然越来越便宜,但跟廉价的硬盘相比成本还是比较昂贵,因此如何高效利用 Redis 内存非常重要。
内存消耗
Redis 进程内消耗主要包括:自身内存+对象内存+缓冲内存+内存碎片,其中 Redis 空进程自身内存消耗非常少。
对象内存
对象内存是 Redis 内存占用最大的一块,存储着用户所有的数据。Redis 所有的数据都采用 key-value 数据类型,每次创建键值对时,至少创建两个类型对象:key 对象和 value 对象。对象内存消耗可以简单理解为 sizeof(keys)+sizeof(values)。
缓冲内存
缓冲内存主要包括:客户端缓冲、复制积压缓冲区、AOF 缓冲区。
- 客户端缓冲指的是所有接入到 Redis 服务器 TCP 连接的输入输出缓冲。输入缓冲无法控制,最大空间为 1G,如果超过将断开连接。
- 复制积压缓冲区:Redis 在 2.8 版本之后提供了一个可重用的固定大小缓冲区用于实现部分复制功能,根据 repl-backlog-size 参数控制,默认 1MB。对于复制积压缓冲区整个主节点只有一个,所有的从节点共享此缓冲区,因此可以设置较大的缓冲区空间,如 100MB。
- AOF 缓冲区:这部分空间用于在 Redis 重写期间保存最近的写入命令。AOF 缓冲区空间消耗用户无法控制,消耗的内存取决于 AOF 重写时间和写入命令量,这部分空间占用通常很小。
内存碎片
内存分配器为了更好地管理和重复利用内存,分配内存策略一般采用固定范围的内存块进行分配。当存储的数据长短差异较大时,以下场景容易出现高内存碎片问题:
- 频繁做更新操作,例如频繁对已存在的键执行 append、setrange 等更新操作。
- 大量过期键删除,键对象过期删除后,释放的空间无法得到充分利用,导致碎片率上升。
出现高内存碎片问题时常见的解决方式如下:
- 数据对齐:在条件允许的情况下尽量做数据对齐,比如数据尽量采用数字类型或者固定长度字符串等,但是这要视具体的业务而定,有些场景无法做到。
- 安全重启:重启节点可以做到内存碎片重新整理,因此可以利用高可用架构,如 Sentinel 或 Cluster,将碎片率过高的主节点转换为从节点,进行安全重启。
内存管理
Redis 主要通过控制内存上限和回收策略实现内存管理。
控制内存上限
Redis 使用 maxmemory 参数限制最大可用内存。限制内存的目的主要有:
- 用于缓存场景,当超出内存上限 maxmemory 时使用 LRU 等删除策略释放空间。
- 防止所用内存超过服务器物理内存。
内存回收策略
Redis 的内存回收机制主要体现在以下两个方面:
- 删除到达过期时间的键对象。
- 内存使用达到 maxmemory 上限时触发内存溢出控制策略。
删除过期键对象
Redis 所有的键都可以设置过期属性,内部保存在过期字典中。Redis 采用惰性删除和定时任务删除机制实现过期键的内存回收。
惰性删除
惰性删除用于当客户端读取带有超时属性的键时,如果已经超过键设置的过期时间,会执行删除操作并返回空,这种策略是出于节省 CPU 成本考虑,不需要单独维护 TTL 链表来处理过期键的删除。但是单独用这种方式存在内存泄露的问题,当过期键一直没有访问将无法得到及时删除,从而导致内存不能及时释放。
定时任务删除
Redis 内部维护一个定时任务,默认每秒运行 10 次(通过配置 hz 控制)。定时任务中删除过期键逻辑采用了自适应算法,根据键的过期比例、使用快慢两种速率模式回收键。
流程说明:
- 定时任务在每个数据库空间随机检查 20 个键,当发现过期时删除对应的键。
- 如果超过检查数 25% 的键过期,循环执行回收逻辑直到不足 25% 或运行超时为止,慢模式下超时时间为 25 毫秒。
- 如果之前回收键逻辑超时,则在 Redis 触发内部事件之前再次以快模式运行回收过期键任务,快模式下超时时间为 1 毫秒且 2 秒内只能运行 1 次。
- 快慢两种模式内部删除逻辑相同,只是执行的超时时间不同。
内存溢出控制策略
当 Redis 所用内存达到 maxmemory 上限时会触发相应的溢出控制策略。具体策略受 maxmemory-policy 参数控制,Redis 支持 6 种策略,如下所示:
- noeviction:默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息(error)OOM command not allowed when used memory,此时 Redis 只响应读操作。
- volatile-lru:根据 LRU 算法删除设置了超时属性(expire)的键,直到腾出足够空间为止。如果没有可删除的键对象,回退到 noeviction 策略。
- allkeys-lru:根据 LRU 算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止。
- allkeys-random:随机删除所有键,直到腾出足够空间为止。
- volatile-random:随机删除过期键,直到腾出足够空间为止。
- volatile-ttl:根据键值对象的 ttl 属性,删除最近将要过期数据。如果没有,回退到 noeviction 策略。
每次 Redis 执行命令时如果设置了 maxmemory 参数,都会尝试执行回收内存操作。当 Redis 一直工作在内存溢出(used_memory>maxmemory)的状态下且设置非 noeviction 策略时,会频繁地触发回收内存的操作,影响 Redis 服务器的性能。
频繁执行回收内存成本很高,主要包括查找可回收键和删除键的开销,如果当前 Redis 有从节点,回收内存操作对应的删除命令会同步到从节点,导致写放大的问题。建议线上 Redis 内存工作在 maxmemory>used_memory 状态下,避免频繁内存回收开销。
内存优化
Redis 存储的所有值对象在内部定义为 redisObject 结构体,内部结构如下:
缩减键值对象
降低 Redis 内存使用最直接的方式就是缩减键(key)和值(value)的长度。
- key 长度:如在设计键时,在完整描述业务情况下,键值越短越好。如 user:{uid}:friends:notify:{fid}可以简化为 u:{uid}:fs:nt:{fid}。
- value 长度:值对象缩减比较复杂,常见需求是把业务对象序列化成二进制数组放入 Redis。首先应该在业务上精简业务对象,去掉不必要的属性避免存储无效数据。其次在序列化工具选择上,应该选择更高效的序列化工具来降低字节数组大小。
共享对象池
共享对象池是指 Redis 内部维护 [0-9999] 的整数对象池。创建大量的整数类型 redisObject 存在内存开销,每个 redisObject 内部结构至少占 16 字节,甚至超过了整数自身空间消耗。所以 Redis 内存维护一个 [0-9999] 的整数对象池,用于节约内存。除了整数值对象,其他类型如 list、hash、set、zset 内部元素也可以使用整数对象池。因此开发中在满足需求的前提下,尽量使用整数对象以节省内存。
注意:当设置 maxmemory 并启用 LRU 相关淘汰策略如:volatile-lru,allkeys-lru 时,Redis 禁止使用共享对象池
原因如下:
LRU 算法需要获取对象最后被访问时间,以便淘汰最长未访问数据,每个对象最后访问时间存储在 redisObject 对象的 lru 字段。对象共享意味着多个引用共享同一个 redisObject,这时 lru 字段也会被共享,导致无法获取每个对象的最后访问时间。如果没有设置 maxmemory,直到内存被用尽 Redis 也不会触发内存回收,所以共享对象池可以正常工作。
字符串优化
字符串对象是 Redis 内部最常用的数据类型。所有的键都是字符串类型,值对象数据除了整数之外都使用字符串存储。
字符串结构
Redis 没有采用原生 C 语言的字符串类型而是自己实现了字符串结构,内部简单动态字符串(simple dynamic string,SDS)。
Redis 自身实现的字符串结构有如下特点:
- O(1)时间复杂度获取:字符串长度、已用长度、未用长度。
- 可用于保存字节数组,支持安全的二进制数据存储。
- 内部实现空间预分配机制,降低内存再分配次数。
- 惰性删除机制,字符串缩减后的空间不释放,作为预分配空间保留。
字符串重构
字符串重构:指不一定把每份数据作为字符串整体存储,像 json 这样的数据可以使用 hash 结构,使用二级结构存储也能帮我们节省内存。同时可以使用 hmget、hmset 命令支持字段的部分读取修改,而不用每次整体存取。
编码优化
Redis 对外提供了 string、list、hash、set、zet 等类型,但是 Redis 内部针对不同类型存在编码的概念,所谓编码就是具体使用哪种底层数据结构来实现。编码不同将直接影响数据的内存占用和读写效率。使用 object encoding {key} 命令获取编码类型。
Redis 针对每种数据类型(type)可以采用至少两种编码方式来实现。
Redis 为什么对一种数据结构实现多种编码方式?
主要原因是 Redis 作者想通过不同编码实现效率和空间的平衡。比如当我们的存储只有 10 个元素的列表,当使用双向链表数据结构时,必然需要维护大量的内部字段如每个元素需要:前置指针,后置指针,数据指针等,造成空间浪费,如果采用连续内存结构的压缩列表(ziplist),将会节省大量内存,而由于数据长度较小,存取操作时间复杂度即使为 O(n2)性能也可满足需求。
控制键的数量
当使用 Redis 存储大量数据时,通常会存在大量键,过多的键同样会消耗大量内存。对于存储相同的数据内容利用 Redis 的数据结构降低外层键的数量,也可以节省大量内存。
通过在客户端预估键规模,把大量键分组映射到多个 hash 结构中降低键的数量。
hash 结构降低键数量分析:
- 根据键规模在客户端通过分组映射到一组 hash 对象中,如存在 100 万个键,可以映射到 1000 个 hash 中,每个 hash 保存 1000 个元素。
- hash 的 field 可用于记录原始 key 字符串,方便哈希查找。
- hash 的 value 保存原始值对象,确保不要超过 hash-max-ziplist-value 限制。
关于 hash 键和 field 键的设计:
- 当键离散度较高时,可以按字符串位截取,把后三位作为哈希的 field,之前部分作为哈希的键。如:key=1948480 哈希 key=group:hash:1948,哈希 field=480。
- 当键离散度较低时,可以使用哈希算法打散键,如:使 crc32(key)&10000 函数把所有的键映射到“0-9999”整数范围内,哈希 field
存储键的原始值。 - 尽量减少 hash 键和 field 的长度,如使用部分键内容。
使用 hash 结构控制键的规模虽然可以大幅降低内存,但同样会带来问题,需要提前做好规避处理。如下所示:
- 客户端需要预估键的规模并设计 hash 分组规则,加重客户端开发成本。
- hash 重构后所有的键无法再使用超时(expire)和 LRU 淘汰机制自动删除,需要手动维护删除。
- 对于大对象,如 1KB 以上的对象,使用 hash-ziplist 结构控制键数量反而得不偿失。
事务
Redis 事务可以一次执行多个命令, 并且带有以下三个重要的保证:
- 批量操作在发送 EXEC 命令前被放入队列缓存。
- 收到 EXEC 命令后进入事务执行,事务中任意命令执行失败,其余的命令依然被执行。
- 在事务执行过程,其他客户端提交的命令请求不会插入到事务执行命令序列中。
一个事务从开始到执行会经历以下三个阶段:
- 开始事务。
- 命令入队。
- 执行事务。
事务执行过程中,如果服务端收到有 EXEC、DISCARD、WATCH、MULTI 之外的请求,将会把请求放入队列中排队
事务相关命令
Redis 事务功能是通过 MULTI、EXEC、DISCARD 和 WATCH 四个原语实现的
Redis 会将一个事务中的所有命令序列化,然后按顺序执行。
- redis 不支持回滚,“Redis 在事务失败时不进行回滚,而是继续执行余下的命令”, Redis 的作者解释说不支持事务回滚是因为这种复杂的功能和 Redis 追求简单高效的设计主旨不符。
- 如果在一个事务中的命令出现错误,那么所有的命令都不会执行;
- 如果在一个事务中出现运行错误,那么正确的命令会被执行。
- WATCH 命令是一个乐观锁,可以为 Redis 事务提供 check-and-set (CAS)行为。 可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行,监控一直持续到 EXEC 命令。
- MULTI 命令用于开启一个事务,它总是返回 OK。 MULTI 执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当 EXEC 命令被调用时,所有队列中的命令才会被执行。
- EXEC:执行所有事务块内的命令。返回事务块内所有命令的返回值,按命令执行的先后顺序排列。 当操作被打断时,返回空值 nil 。
通过调用 DISCARD,客户端可以清空事务队列,并放弃执行事务, 并且客户端会从事务状态中退出。 - UNWATCH 命令可以取消 watch 对所有 key 的监控。
哨兵
Redis 的主从复制模式下,一旦主节点由于故障不能提供服务,需要人工将从节点晋升为主节点,同时还要通知应用方更新主节点地址,对于很多应用场景这种故障处理的方式是无法接受的。所以 Redis 从 2.8 开始正式提供了 Redis Sentinel(哨兵)架构来解决这个问题。
名词 | 逻辑结构 | 物理结构 |
---|---|---|
主节点 (master) | Redis 主服务/数据库 | 一个独立的 Redis 进程 |
从节点 (slave) | Redis 从服务/数据库 | 一个独立的 Redis 进程 |
Redis 数据节点 | 主节点和从节点 | 主节点和从节点的进程 |
Sentinel 节点 | 监控 Redis 数据节点 | 一个独立的 Sentinel 进程 |
Sentinel 节点集合 | 若干 Sentinel 节点的抽象组合 | 若干 Sentinel 节点进程 |
Redis Sentinel | Redis 高可用实现方案 | Sentinel 节点集合和 Redis 数据节点进程 |
应用方 | 泛指一个或多个客户端 | 泛指一个或多个客户端进程或线程 |
主从复制的问题
Redis 的主从复制模式可以将主节点的数据改变同步给从节点,这样从节点就可以起到两个作用:第一,作为主节点的一个备份,一旦主节点出了故障不可达的情况,从节点可以作为后备“顶”上来,并且保证数据尽量不丢失(主从复制是最终一致性)。第二,从节点可以扩展主节点的读能力,一旦主节点不能支撑住大并发量的读操作,从节点可以在一定程度上帮助主节点分担读压力。
但是主从复制有一个严重的问题:一旦主节点出现故障,需要手动将一个从节点晋升为主节点,同时需要修改应用方的主节点地址,还需要命令其他从节点去复制新的主节点,整个过程都需要人工干预。
为了自动化保证 Redis 的高可用性,Redis 提供了 Sentinel
Sentinel 的高可用性
当主节点出现故障时,Redis Sentinel 能自动完成故障发现和故障转移,并通知应用方,从而实现真正的高可用。
从逻辑架构上看,Sentinel 节点集合会定期对所有节点进行监控,特别是对主节点的故障实现自动转移。
整个故障转移的处理逻辑有下面 4 个步骤:
主节点出现故障,此时两个从节点与主节点失去连接,主从复制失败。
每个 Sentinel 节点通过定期监控发现主节点出现了故障。
多个 Sentinel 节点对主节点的故障达成一致,选举出 sentinel-3 节点作为领导者负责故障转移。
Sentinel 领导者节点执行故障转移。
故障转移后整个 Redis Sentinel 的拓扑结构如下:
通过上面介绍的 Redis Sentinel 逻辑架构以及故障转移的处理,可以看出 Redis Sentinel 具有以下几个功能:
- 监控:Sentinel 节点会定期检测 Redis 数据节点、其余 Sentinel 节点是否可达。
- 通知:Sentinel 节点会将故障转移的结果通知给应用方。
- 主节点故障转移:实现从节点晋升为主节点并维护后续正确的主从关系。
- 配置提供者:在 Redis Sentinel 结构中,客户端在初始化的时候连接的是 Sentinel 节点集合,从中获取主节点信息。
同时看到,Redis Sentinel 包含了若个 Sentinel 节点,这样做也带来了两个好处:
- 对于节点的故障判断是由多个 Sentinel 节点共同完成,这样可以有效地防止误判。
- Sentinel 节点集合是由若干个 Sentinel 节点组成的,这样即使个别 Sentinel 节点不可用,整个 Sentinel 节点集合依然是健壮的。
Sentinel 实现原理
Redis Sentinel 的基本实现原理,具体包含以下几个方面:Redis Sentinel 的三个定时任务、主观下线和客观下线、Sentinel 领导者选举、故障转移。
三个定时任务
- 每隔 10 秒,每个 Sentinel 节点会向主节点和从节点发送 info 命令获取最新的拓扑结构。
这个定时任务的作用具体可以表现在三个方面:
- 通过向主节点执行 info 命令,获取从节点的信息,这也是为什么 Sentinel 节点不需要显式配置监控从节点。
- 当有新的从节点加入时都可以立刻感知出来。
- 节点不可达或者故障转移后,可以通过 info 命令实时更新节点拓扑信息。
- 每隔 2 秒,每个 Sentinel 节点会向 Redis 数据节点的
__sentinel__:hello
频道上发送该 Sentinel 节点对于主节点的判断以及当前 Sentinel 节点的信息,同时每个 Sentinel 节点也会订阅该频道,来了解其他 Sentinel 节点以及它们对主节点的判断。
这个定时任务可以完成以下两个工作:
- 发现新的 Sentinel 节点:通过订阅主节点的
__sentinel__:hello
了解其他的 Sentinel 节点信息,如果是新加入的 Sentinel 节点,将该 Sentinel 节点信息保存起来,并与该 Sentinel 节点创建连接。 - Sentinel 节点之间交换主节点的状态,作为后面客观下线以及领导者选举的依据。
- 每隔 1 秒,每个 Sentinel 节点会向主节点、从节点、其余 Sentinel 节点发送一条 ping 命令做一次心跳检测,来确认这些节点当前是否可达。通过上面的定时任务,Sentinel 节点对主节点、从节点、其余 Sentinel 节点都建立起连接,实现了对每个节点的监控,这个定时任务是节点失败判定的重要依据。
主观下线和客观下线
主观下线
每个 Sentinel 节点会每隔 1 秒对主节点、从节点、其他 Sentinel 节点发送 ping 命令做心跳检测,当这些节点超过 down-after-milliseconds 没有进行有效回复,Sentinel 节点就会对该节点做失败判定,这个行为叫做主观下线。
客观下线
当 Sentinel 主观下线的节点是主节点时,该 Sentinel 节点会通过 sentinel ismaster-down-by-addr 命令向其他 Sentinel 节点询问对主节点的判断,当超过
Sentinel 领导者选举
故障转移的工作只需要一个 Sentinel 节点来完成即可,所以 Sentinel 节点之间会做一个领导者选举的工作,选出一个 Sentinel 节点作为领导者进行故障转移的工作。Redis 使用了 Raft 算法 实现领导者选举。
- 每个在线的 Sentinel 节点都有资格成为领导者,当它确认主节点主观下线时候,会向其他 Sentinel 节点发送 sentinel is-master-down-by-addr 命令,要求将自己设置为领导者。
- 收到命令的 Sentinel 节点,如果没有同意过其他 Sentinel 节点的 sentinelis-master-down-by-addr 命令,将同意该请求,否则拒绝。
- 如果该 Sentinel 节点发现自己的票数已经大于等于 max(quorum,num(sentinels)/2+1),那么它将成为领导者。
- 如果此过程没有选举出领导者,将进入下一次选举。
选举大致过程如下:
- s1(sentinel-1)最先完成了客观下线,它会向 s2(sentinel-2)和 s3(sentinel-3)发送 sentinel is-master-down-by-addr 命令,s2 和 s3 同意选其为领导者。
- s1 此时已经拿到 2 张投票,满足了大于等于 max(quorum,num(sentinels)/2+1)=2 的条件,所以此时 s1 成为领导者。
故障转移
领导者选举出的 Sentinel 节点负责故障转移,具体步骤如下:
在从节点列表中选出一个节点作为新的主节点,选择方法如下:
a)过滤:“不健康”(主观下线、断线)、5 秒内没有回复过 Sentinel 节点 ping 响应、与主节点失联超过 down-after-milliseconds*10 秒。
b)选择 slave-priority(从节点优先级)最高的从节点列表,如果存在则返回,不存在则继续。
c)选择复制偏移量最大的从节点(复制的最完整),如果存在则返回,不存在则继续。
d)选择 runid 最小的从节点。Sentinel 领导者节点会对第一步选出来的从节点执行 slaveof no one 命令让其成为主节点。
Sentinel 领导者节点会向剩余的从节点发送命令,让它们成为新主节点的从节点,复制规则和 parallel-syncs 参数有关。
Sentinel 节点集合会将原来的主节点更新为从节点,并保持着对其关注,当其恢复后命令它去复制新的主节点。
集群
Redis Cluster 是 Redis 的分布式解决方案,在 3.0 版本正式推出,有效地解决了 Redis 分布式方面的需求。当遇到单机内存、并发、流量等瓶颈时,可以采用 Cluster 架构方案达到负载均衡的目的。
Redis 数据分区
虚拟槽分区巧妙地使用了哈希空间,使用分散度良好的哈希函数把所有数据映射到一个固定范围的整数集合中,整数定义为槽(slot)。这个范围一般远远大于节点数,比如 Redis Cluster 槽范围是 0~16383。槽是集群内数据管理和迁移的基本单位。采用大范围槽的主要目的是为了方便数据拆分和集群扩展。每个节点会负责一定数量的槽。
Redis Cluser 采用虚拟槽分区,所有的键根据哈希函数映射到 0~16383 整数槽内,计算公式:slot=CRC16(key)&16383。每一个节点负责维护一部分槽以及槽所映射的键值数据。
Redis 虚拟槽分区的特点:
- 解耦数据和节点之间的关系,简化了节点扩容和收缩难度。
- 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据。
- 支持节点、槽、键之间的映射查询,用于数据路由、在线伸缩等场景。
节点通信
常见的元数据维护方式分为:集中式和 P2P 方式。Redis 集群采用 P2P 的 Gossip(流言)协议,Gossip 协议工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息。
通信过程说明:
- 集群中的每个节点都会单独开辟一个 TCP 通道,用于节点之间彼此通信,通信端口号在基础端口上加 10000。
- 每个节点在固定周期内通过特定规则选择几个节点发送 ping 消息。
- 接收到 ping 消息的节点用 pong 消息作为响应。
集群伸缩
Redis 集群提供了灵活的节点扩容和收缩方案。在不影响集群对外服务的情况下,可以为集群添加节点进行扩容也可以下线部分节点进行缩容。
Redis 集群可以实现对节点的灵活上下线控制。其中原理可抽象为槽和对应数据在不同节点之间灵活移动。
图中每个节点把一部分槽和数据迁移到新的节点 6385,每个节点负责的槽和数据相比之前变少了从而达到了集群扩容的目的。集群的水平伸缩的上层原理:集群伸缩=槽和数据在节点之间的移动。
缓存异常
缓存穿透
缓存穿透是指缓存和数据库中都没有的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。
解决方案
- 接口层增加校验,如用户鉴权校验,id 做基础校验,id<=0 的直接拦截;
- 从缓存取不到的数据,在数据库中也没有取到,这时也可以将 key-value 对写为 key-null,缓存有效时间可以设置短点,如 30 秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个 id 暴力攻击
- 采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力
缓存击穿
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。和缓存雪崩不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方案
- 设置热点数据永远不过期。
- 加互斥锁,互斥锁可以控制查询数据库的线程访问,但这种方案会导致系统的吞吐量下降,需要根据实际情况使用。
缓存雪崩
缓存雪崩是指缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。
解决方案
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
- 一般并发量不是特别多的时候,使用最多的解决方案是加锁排队。
- 给每一个缓存数据增加相应的缓存标记,记录缓存的是否失效,如果缓存标记失效,则更新数据缓存。