一, 前言
从我第一次使用 Redis 来帮助加速算法运行速度开始, 就把 Redis 应用在了各个项目中, 每次带来的体验都非常得好, python 多进程 + Redis 的使用帮助我把单进程运行十几个小时的程序加速到了只需要 10 分钟左右, 也帮助我把本来需要运行十几分钟的项目加速到了几十秒就能运行结束, 同时我也喜欢 Redis 项目本身的小巧和精致. 所以在这里计划写些关于 Redis 的介绍, 计划总共写两篇, 第一篇主要介绍 Redis 的整体的一些设计和思想, 第二篇会主要介绍 Redis 集群的一些研究, 希望能帮助大家熟悉认识 Redis, 并鼓励在你的项目中能尝试使用 Redis. 本篇主要会涉及到如下内容:
Redis 是什么
为什么 Redis 速度能够这么快
Redis 支持写入的数据结构都有哪些及其底层实现方式是什么
内存资源稀缺, 能够存储的键值数目有限, 当 Redis 键值存不下时, 该如何淘汰掉已有的键
Redis 进程在内存中存储数据, 如果 Redis 进程崩溃了, 进程中的数据会丢失, 那么 Redis 如何利用持久化来保证数据的安全性
Redis 的 python 程序实例及一些常用的高效使用手段
二, Redis 是什么
Redis 的全称是 REmote DIctionaryServer, 是一个高效的内存键值数据库, 相比较我们常规使用的 MySQL,MongoDB 等数据库, Redis 的最大特点在于数据读写全部在内存中进行, 进而带来极大的效率优势. 相比较其他的内存键值存储系统如 Memcached, Redis 支持更多的数据结构, 提升了使用的易用性. 同时 Redis 采用典型的 CS 架构, 并且有着非常丰富的不同语言客户端支持, 本篇文章的最后也会向大家介绍同步和异步模式下的两个 python 语言的 Redis 客户端使用.
Redis 采用 CS 架构
三, Redis 为什么这么快
Redis 最大的好处就是快, Redis 为什么能做到这么快呢? 主要的原因有三点
数据读写都在内存中完成. 从下图中我们可以看出, 即使使用 SSD, 内存的读写速度要比外存的数据的读写速度快 1000 倍左右, 如果你的电脑还没装上 SSD, 还是机械硬盘, 那内存的读写速度比硬盘的读写速度就要快 100000 倍, 那么基于内存的数据库的读写速度优势自然就是巨大的.
不同存储层次的访问速度对比
单线程请求处理, 这个主要是实现上的选择. 也许同学会有疑惑, 为什么不采用多线程进行并行读写呢? 这里主要的原因仍然是 Redis 基于内存读写, 多线程并行对数据读取的确能带来好处, 但是同样带来了数据写入时锁的开销以及线程切换的开销. 再大量的写入情况下, 过多的锁带来的时间消耗比多线程带来的多核利用优势更大.
I/O 多路复用技术. I/O 多路复用我们又称之为事件驱动, Redis 基于 epoll 等技术实现了网络部分响应的同步非阻塞 I/O 模式. Redis 的 I/O 主要集中在了读写 socket 上, 同步阻塞下, 向客户端发送数据的时候, Redis 需要一直等到对应客户端的 socket 可写才会去写, 直到写完了再服务下一个请求, 使用 epoll 等系统调用, 把 socket 是否可读写的状态监控交给了操作系统, 即 Redis 只会在操作系统告知其可读或者可写的 socket 文件的时候采取读写, 进而节省了等 IO 的时间. 关于 epoll 的具体介绍可以参考这一篇文章.
以上三点是 Redis 为什么这么快的原因, 内存读写是最主要的, 其他两个技术选型对此也有所帮助.
四, Redis 支持的数据结构
我们要把数据存到内存里面, 怎么存呢? 理论上来讲, 内存 KV 数据存储其实只需要支持字符串数据存取就能支持所有的数据类型存储了, 至于列表, 字典的存储, 我们只需要将数据进行序列化就行. 缺点就是用户每次要修改数据都要获得所有的数据, 修改结束之后还得把所有的数据再传回去, 这样不但增加了每次网络的传输数据体积, 而且使用体验也不是很好, 因为需要用户自己来解析数据, 事实上这就是 Memcached 的做法. Redis 为了提高易用性, 支持了更加丰富的数据结构, 最常用的便是 String,List,Hash,Set,Sorted Set 五种. 接下来我们一一介绍五种数据结构, 主要介绍其特点和底层实现, 这样我们就好估计每种数据结构的操作时间复杂度.
String
String 和我们常规理解的字符串基本一致, 主要存储序列化后的字符串, 支持写入原生字符串也支持写入数字类型. String 的存取复杂度均为 O(1). 主要支持的操作如下表
命令 | 含义 |
---|---|
SET | 设置键值 |
GET | 获得给定键的值 |
DEL | 删除给定的键 |
List
List 即为列表, List 在 Redis 底层采用的是双向链表实现的, 所以我们会发现 Redis 的 List 操作命令有左右之分, 比如 LPUSH,RPUSH, 实际上就是双端列表左右两端的存取. 对于列表的端点插入和查询时间复杂度为 O(1), 对于中间某个 index 的位置的值的获取以及对于 index 处于 [start, end] 的连续多个值的读取就是 O(n)的复杂度(n 为列表的长度), 在我们的项目中, 我们用 List 来存储疾病列表, 来帮助实现用户搜索疾病时的即时自动补全. 列表的主要命令如下:
命令 | 含义 |
---|---|
LPUSH/RPUSH | 向列表的左端 / 右端插入数据 |
LPOP/RPOP | 从列表的左端 / 右端删除数据 |
LRANGE/RANGE | 去除从左 / 右端开始计数的位置处于给定 [start, end] 之间的所有 value |
LINDEX/RINDEX | 删除从左 / 右端开始计数的第 INDEX 个值 |
Hash
Hash 可以理解为我们常规使用的字典数据结构, Redis 采用散列表来实现 Hash, 一个 Hash 结构里面可以存在很多的 key 和 value,Hash 是 Redis 比较推荐使用的一种数据结构, 据说内存使用会更好, 具体我还没有研究. 在我们的项目里, 我们主要用 Hash 保存用户的 token 信息来帮助快速验证用户是否已登录. Hash 中的键值存取效率可以认为是 O(1),Hash 结构操作的主要命令如下表
命令 | 含义 |
---|---|
HSET | 向 Hash 中添加 k:v |
HGET | 获取 Hash 中的给定 key 的值 |
HKEYS | 获取 Hash 中所有的 key |
Set
Set 是集合, 满足集合确定性, 无序性, 唯一性三个性质, 可以用来进行元素的去重操作. 集合的底层实现仍然采用散列表, 所以单个元素的存取可以认为是 O(1)的时间复杂度, 同时 Redis 支持对不同的集合的交并等计算, 集合的操作命令主要如下
命令 | 含义 |
---|---|
SADD | 向集合中添加元素 |
SISMEMBER | 判断键是否在集合中 |
SMEMBERS | 获取集合中所有的键 |
SREM | 删除集合中的给定的键 |
Sorted Set
Sorted Set 是有序集合, 满足集合唯一性的要求, 同时也满足有序的性质. 向 Sorted Set 中插入元素的时候需要同时指定一个 Score, 用于作为排序的标准, 如下面的这条命令, 我们向知乎热榜这个有序集合中插入一个文章的题目及其阅读量, 通过有知乎热榜这个有序结合我们可以方便的得到每天排名靠前的文章题目及其对应的阅读量. Sorted Set 的底层实现采用的是 Skip List, 所以其单个元素的存取效率可以近似认为是 O(logn)的. 有序集合的操作命令主要如下:
ZADD 知乎热榜 2000 如何看待 xxxxx
命令 | 含义 | 示例 |
---|---|---|
ZADD | 向有序集合中添加元素 | ZADD 知乎热榜 2000 如何看待 xxxx |
ZREM | 删除集合中的元素 | ZREM 知乎热榜 如何看待 xxxx |
ZRANGE | 获取有序集合中排名处于 [start, end] 之间的值 | ZRANGE 知乎热榜 0 10 |
ZSCORE | 获取集合中给定键的 score | ZSCORE 知乎热榜 如何看待 xxxx |
五, Redis 键淘汰策略
前文提过, Redis 的所有数据是存储在内存中的, 但是内存本身就是稀缺资源, 我们常规使用的笔记本内存只有 8G 或者 16G, 而且这个内存是给所有的进程使用的, Redis 作为我们运行的其中一个进程我们一般会限制 Redis 的使用内存上限, 比如 2G, 否则 Redis 就会把可用内存耗光. 2G 实际上能存储的键值是有限的, 那么如果用户把 Redis 的存储存满了该怎么办呢? 就像我们把家里的冰箱都装满了, 再想装东西就得扔掉一部分不吃的东西或者过期的东西一样, Redis 也会选择淘汰掉一些键来为新的键提供空间. 同时 Redis 支持用户给键值设置过期时间, 如果检查到某些键过期了, 就删除掉键来空余出空间. 为了方便管理, Redis 把所有设置了过期时间的键放到一个单独的列表里面进行维护. 这里我们主要介绍三类策略:
不淘汰策略
第一条淘汰键的策略就是不淘汰哈哈, 其实是表明 Redis 不主动清除键, 清除键的操作全部交给用户来决定, 如果用户始终不清除键, 当 Redis 被写满了后, 用户在往里面写 Redis 就会报错, 拒绝写入数据. 这种策略叫 noeviction.
随机淘汰
随机抽样淘汰即 Redis 随机选取一些键然后进行删除, 这样带来的问题是用户也不知道哪些键被删除了, 可能用户吃着火锅唱着歌, 回头一看, 自己的数据没了! 那显然是很糟糕的, 但 Redis 提供了这样一个选项, 用不用那自然是用户的选择问题了. 根据随机抽样的集合不同又细分为两个策略, 从所有的键中随机抽取就是 allkeys-random, 从只设置了过期时间的键集合中进行抽取, 就是 volatile-random
LRU 策略
LRU 策略就是淘汰掉最不常用的键, 每次用户访问某个键的时候, Redis 就会记录这个键的访问时间, 如果一个键距离上次访问已经太久没有被访问到了, 那么 Redis 就认为这个键用户用不上了, 就会把键清除掉. 按照标准的 LRU 算法, 我们应该统计所有键中最不常用的键, 然后淘汰掉他, 但是 Redis 是单线程响应用户请求的, 不能每次都遍历所有的键来进行检查, 否则就会严重的影响到服务的响应. 所以 Redis 采用一种随机抽样的方法. 即每次随机抽取 K 个键值, 然后淘汰掉这 K 个键中上次访问时间最早的的那个键. 同样, 针对随机收取的集合不同又细分为两个策略, 从所有的键中进行抽取, 就是 allkeys-lru 策略, 从只设置了过期时间的键集合中进行抽取, 就是 volatile-lru 策略. volatile-lru 策略是比较推荐的一种策略. 关于 LRU 的策略, Redis 的源码实现如下, 我加了注释, 还比较易懂
- ......
- /* 如果选择了 volatile-lru 或者 allkeys-lru 策略 */
- else if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU ||
- server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU)
- {
- /* 每次随机抽取 maxmeory_samples 个元素进行检查淘汰, 默认设置为 3*/
- for (k = 0; k <server.maxmemory_samples; k++) {
- sds thiskey;
- long thisval;
- robj *o;
- /* 随机抽取一个键 */
- de = dictGetRandomKey(dict);
- thiskey = dictGetKey(de);
- /* 如果用户设置的是 volatile-lru, 则从设置了有效期的集合中进行抽样 */
- if (server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU)
- de = dictFind(db->dict, thiskey);
- o = dictGetVal(de);
- thisval = estimateObjectIdleTime(o);
- /* 找到距离上次访问过去时间最久的键 */
- if (bestkey == NULL || thisval> bestval) {
- bestkey = thiskey;
- bestval = thisval;
- }
- }
- }
- ......
六, Redis 持久化策略
Redis 是把数据存储在自己进程的内存中, 但是如果 Redis 进程挂了或者说电脑断电了, 那么存储的数据就全部丢失了. 为了保证数据的安全性, 就需要把数据从内存的数据备份到硬盘上, 这就是持久化操作. 这样即使内存中的数据丢失了, 那么也可以从硬盘上把数据恢复出来. Redis 提供两种持久化策略: RDB 持久化和 AOF 持久化, 不要被这两个名字吓到, RDB,AOF 只是两种持久化文件的后缀名, 并不是什么神奇的策略. 都比较容易懂, 下面一一介绍.
RDB 持久化
RDB 持久化就是快照持久化, 即定期把内存中的数据全部拷贝保存到文件中. 我们前面提到 Redis 是单线程响应用户需求的, 如果把持久化这样涉及到大量 IO 的操作也放到这个线程中, 会严重影响服务的响应. 于是 Redis 采用 fork 一个子进程出来进行持久化. 但是我们都知道, fork 出来的子进程会拷贝父进程所有的数据, 这样理论上当 Redis 要持久化 2G 的内存数据的时候, 子进程也会占据几乎 2G 的内存, 那么 Redis 相关的进程内存占用就会达到 4G 左右, 这在数据比较小的时候还不严重, 但是比如你的电脑内存是 8G, 目前备份的 Redis 的数据本身体积是 5G, 那么按照上面的计算备份一定是无法进行的, 所幸在 Unix 类操作系统上面, 做了如下的优化: 在刚开始的时候父子进程共享相同的内存, 直到父进程或者子进程进行内存的写入后, 对被写入的内存共享才结束. 这样就会减少 Redis 持久化时对内存的消耗.
AOF 持久化
AOF(AppendOnlyFile)持久化就是 Redis 把每次的用户写操作日志 append 到一个文件中, 如果数据丢失了, 那么按照 AOF 文件的操作顺序再进行操作一遍就可以恢复数据, 而且这样每次我们都只需要写一小部分数据到文件中. 但是这样会带来一个什么问题呢? 由于程序一直在运行, 所以不停的会往 AOF 文件中添加写的操作日志, 这样终有一天 AOF 文件体积会大到不可想象. 所以就又有一个操作叫 AOF 重写用于删除掉冗余的命令, 比如用户对同一个 key 执行 100 遍 SET 操作, 如果不 AOF 重写, 那么 AOF 文件中就会有 100 条 SET 记录, 数据恢复的时候也需要操作 100 次 SET, 但实际上只有最后一条 SET 命令是真正有意义的, 所以 AOF 重写就会把前 99 条 SET 命令删除掉, 只保留最后一条 SET 命令, 这样不仅文件内存储的内容就变少了, Redis 恢复数据的速度也加快了.
除了上面两条策略, Redis 还支持主从备份, 这又是一块比较大的内容, 限于篇幅, 我们将主从备份放到第二篇的 Redis 集群中介绍.
七, talk is cheap, show me the code
Redis-py 和 aredis
又到了喜闻乐见的代码部分了. 这部分主要介绍两个 python 的 Redis 客户端, Redis-py https://pypi.org/project/redis/ 和 https://aredis.readthedocs.io/en/latest/ 前者是同步 Redis 客户端, 后者是异步 Redis 客户端. aredis 就是在 Redis-py 的基础上利用了协程的技术来重写了接口, 试图省去客户端等待服务器结果的时间. 如果你是本地机器使用 Redis, 那么使用前者就能很好的满足你的需求, 如果你使用的远端的 Redis 服务器而且网络还比较差的话, aredis 也许会有些帮助. 我之前尝试使用 aredis 客户端与本地运行的 Redis 服务器搭配使用, 发现性能下降了很多, 主要的原因就是因为本地 Redis 服务器网络延迟几乎为 0, 但过多的协程切换反而带来了高昂的开销. 我使用 Redis-py 客户端, 处理完需要 288s, 用 aredis 客户端处理完需要 340s, 后来我重写了客户端的一些接口, 把一些协程的接口改成了普通的函数接口, 减少了协程数目, 运行结束为 330s, 快了 10s.
- # 这里的代码直接从 Redis-py 的 documents 中粘过来的
- >>> import Redis
- >>> r = Redis.Redis(host='localhost', port=6379, db=0)
- >>> r.set('foo', 'bar')
- True
- >>> r.get('foo')
- 'bar'
- # aredis 的操作
- import aredis
- import asyncio
- loop = asyncio.get_event_loop()
- r = aredis.StrictRedis(loop=loop)
- async def set_key(key, value):
- await r.set(key, value)
- return
流水线
Redis 客户端和服务器的请求响应过程如下图所示, 客户端发送一个命令, 等待服务器返回结果之后再提交下一个命令. 如果网络情况比较差, 我们就会需要花许多的时间来等待服务器的响应. 一种解决方案就是利用上文提到的 aredis, 可以在等待响应的同时切换协程做点其他的计算. 另一种解决方案就是把所有的命令打包一起发送, 然后等服务器计算完了之后把结果一起返回来, 这就是流水线的概念.
逐个命令提交(图片出处: https://blog.csdn.net/w1lgy/article/details/84455579)
使用 pipeline, 进行多个命令一起提交(图片出处: https://blog.csdn.net/w1lgy/article/details/84455579)
代码如下:
- import Redis
- r = Redis.Redis()
- # 使用一个 pipeline
- pipeline = r.pipeline()
- pipeline.set("thu", "No.1")
- pipeline.set("xxu", "No.2")
- # 把所有的命令打包发送给服务器
- pipeline.execute()
八, 结语
本篇从 Redis 的单线程运行, 支持的数据结构, 到键驱逐策略以及持久化策略几个方面进行介绍, 试图给读者一个 Redis 的全貌, 这样使用的时候能对命令有更加清晰的了解, 而不只是拘泥于客户端提供的接口. 鼓励大家能尝试在自己的项目中使用 Redis, 相信我, 它会给你从未有过的船新体验 hh. 下篇会主要研究 Redis 集群的相关内容, 如果感兴趣的话, 可以考虑订阅下我的专栏 hh~.
来源: https://www.qcloud.com/developer/article/1546402