<jupyter_start><jupyter_text>原文:  一、前言从我第一次使用Redis来帮助加速算法运行速度开始，就把Redis应用在了各个项目中，每次带来的体验都非常得好，python多进程+Redis的使用帮助我把单进程运行十几个小时的程序加速到了只需要10分钟左右，也帮助我把本来需要运行十几分钟的项目加速到了几十秒就能运行结束，同时我也喜欢Redis项目本身的小巧和精致，再加上Redis目前业界使用的非常多，也常作为面试题目出现，所以在这里计划写些关于Redis的介绍，预计总共写两篇，第一篇主要介绍Redis的整体的一些设计和思想，第二篇会主要介绍Redis集群的一些研究，希望能帮助大家熟悉认识Redis，并鼓励在你的项目中能尝试使用Redis。本篇主要会涉及到如下内容：- Redis是什么- 为什么Redis速度能够这么快- Redis支持写入的数据结构都有哪些及其底层实现方式是什么- 内存资源稀缺，能够存储的键值数目有限，当Redis键值存不下时，该如何淘汰掉已有的键- Redis进程在内存中存储数据，如果Redis进程崩溃了，进程中的数据会丢失，那么Redis如何利用持久化来尽可能的保证数据的安全性- Redis的python客户端及一些常用的高效使用手段 二、Redis是什么Redis的全称是REmote DIctionary Server，是一个高效的内存键值数据库，常被用来做分布式的高速缓存，相比较我们常规使用的Mysql、MongoDB等数据库，Redis的最大特点在于数据读写全部在内存中进行，进而带来极大的效率优势。相比较其他的内存键值存储系统如Memcached， Redis支持更多的数据结构，极大的提升了使用的易用性。同时Redis采用典型的CS架构, 并且有着非常丰富的不同语言客户端支持，本篇文章的最后也会向大家介绍同步和异步模式下的两个python语言的Redis客户端使用。![pic](../../assets/cs.jpg)Redis采用CS架构 三、Redis为什么这么快Redis最大的好处就是快，Redis为什么能做到这么快呢？主要的原因有三点- **数据读写都在内存中完成**。从下图中我们可以看出，即使使用SSD，内存的读写速度要比外存的数据的读写速度快1000倍左右，如果你的电脑还没装上SSD，还是机械硬盘，那内存的读写速度比硬盘的读写速度就要快100000倍，那么基于内存的数据库的读写速度优势自然就是巨大的。![pic](../../assets/cache.jpg)不同存储层次的访问速度对比- **单线程请求处理**，这个主要是实现上的选择。也许同学会有疑惑，为什么不采用多线程进行并行读写呢？这里主要的原因仍然是Redis基于内存读写，多线程并行对数据读取的确能带来好处，但是同样带来了数据写入时**锁的开销以及线程切换的开销**。再大量的写入情况下，过多的锁带来的时间消耗比多线程带来的多核利用优势更大。- **I/O多路复用技术**。I/O多路复用我们又称之为事件驱动，Redis基于epoll等技术实现了网络部分响应的同步非阻塞I/O模式。Redis的I/O主要集中在了读写socket上，同步阻塞下，向客户端发送数据的时候，Redis需要一直等到对应客户端的socket可写才会去写，直到写完了再服务下一个请求，使用epoll等系统调用，把socket是否可读写的状态监控交给了操作系统，即Redis只会在操作系统告知其可读或者可写的socket文件的时候采取读写，进而节省了等IO的时间。关于epoll的具体介绍可以参考这一篇文章**[链接](https://blog.csdn.net/HDUTigerkin/article/details/7517390)。**以上三点是Redis为什么这么快的原因（**划重点，敲黑板！！**），内存读写是最主要的，其他两个技术选型对此也有所帮助。 四、Redis支持的数据结构我们要把数据存到内存里面，怎么存呢？理论上来讲，内存KV数据存储其实只需要支持字符串数据存取就能支持所有的数据类型存储了，至于列表、字典的存储，我们只需要将数据进行序列化就行。缺点就是用户每次要修改数据都要获得所有的数据，修改结束之后还得把所有的数据再传回去，这样不但增加了每次网络的传输数据体积，而且使用体验也不是很好，因为需要用户自己来解析数据，事实上这就是Memcached的做法。Redis为了提高易用性，支持了更加丰富的数据结构，最常用的便是**String、List、Hash、Set、Sorted Set**五种。接下来我们一一介绍五种数据结构，主要介绍其特点和底层实现，这样我们就好估计每种数据结构的操作时间复杂度。 StringString和我们常规理解的字符串基本一致，主要存储序列化后的字符串，支持写入原生字符串也支持写入数字类型。**String的存取复杂度均为O(1)**。主要支持的操作如下表|命令|含义||:---|:---||SET| 设置键值||GET| 获得给定的键||DEL| 删除给定的键| ListList即为列表，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个值 HashHash可以理解为我们常规使用的字典数据结构，Redis采用**散列表**来实现Hash， 一个Hash结构里面可以存在很多的key和value，Hash是Redis比较推荐使用的一种数据结构，据说内存使用会更好，具体我还没有研究。在我们的项目里，我们主要用Hash保存用户的token信息来帮助快速验证用户是否已登录。**Hash中的键值存取效率可以认为是O（1）**，Hash结构操作的主要命令如下表|命令|含义||:---|:---||HSET| 向Hash中添加Key|HGET|获取Hash中给定的Key值|HKEYS| 获取Hash中所有的Key SetSet是集合，满足集合**确定性、无序性、唯一性**三个性质，可以用来进行元素的**去重**操作。集合的底层实现仍然采用**散列表**，所以单个元素的存取可以认为是**O(1)**的时间复杂度，同时Redis支持对不同的集合的**交并等计算**，集合的操作命令主要如下：|命令|含义||:---|:---||SADD| 向集合中添加元素|SISMEMBER|判断键是否在集合中|SMEMBERS| 获取集合中所有的键|SREM |删除集合中给定的键 Sorted SetSorted 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被写满了后，用户在往里面写Redis就会报错，**拒绝写入数据**。这种策略叫**noeviction**。 随机淘汰随机抽样淘汰即Redis随机选取一些键然后进行删除，这样带来的问题是用户也不知道哪些键被删除了，可能用户吃着火锅唱着歌，回头一看，自己的数据没了！那显然是很糟糕的，但Redis提供了这样一个选项，用不用那自然是用户的选择问题了。**根据随机抽样的集合不同又细分为两个策略，从所有的键中随机抽取就是allkeys-random, 从只设置了过期时间的键集合中进行抽取，就是volatile-random** LRU策略LRU（Least Recently Used）是最近最少使用原则，策略就是淘汰掉最近最不常用的键，每次用户访问某个键的时候，Redis就会记录这个键的访问时间，如果一个键距离上次访问已经太久没有被访问到了，那么Redis就认为这个键用户用不上了，就会把键清除掉。按照标准的LRU算法，我们应该统计所有键中最不常用的键，然后淘汰掉他，但是Redis是单线程响应用户请求的，不能每次都遍历所有的键来进行检查，否则就会严重的影响到服务的响应。所以**Redis采用一种随机抽样的方法。即每次随机抽取K个键值，然后淘汰掉这K个键中上次访问时间最早的的那个键**。同样，针对随机收取的集合不同又细分为两个策略，从所有的键中进行抽取，就是allkeys-lru策略，从只设置了过期时间的键集合中进行抽取，就是**volatile-lru**策略。**volatile-lru**策略是**比较推荐**的一种策略。关于LRU的策略，Redis的源码实现如下，我加了注释，还比较易懂.```c......            /* 如果选择了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集群中介绍。以及需要划重点的是，**即使有持久化措施，仍然会有少量数据丢失的问题，**因为备份是每隔一段时间进行的，如果两个备份操作之间机器坏了，那么这期间的数据修改就会因为没来得及备份就被丢失掉，所以一般**不建议**把Redis做常规存储手段，更多的做热数据缓存。  七、talk is cheap, show me the code redis-py和aredis这部分主要介绍两个python的Redis客户端，[redis-py](https://pypi.org/project/redis/)和[aredis](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。<jupyter_code>import redis

r = redis.Redis(host='localhost', port=6379, db=0)
r.set('foo', 'bar')
r.get('foo')
r.close()
import asyncio
from aredis import StrictRedis

async def example():
    client = StrictRedis(host='127.0.0.1', port=6379, db=0)
    await client.flushdb()
    await client.set('foo', 1)
    assert await client.exists('foo') is True
    await client.incr('foo', 100)

    assert int(await client.get('foo')) == 101
    await client.expire('foo', 1)
    await asyncio.sleep(0.1)
    await client.ttl('foo')
    await asyncio.sleep(1)
    assert not await client.exists('foo')

# loop = asyncio.get_event_loop()
# loop.run_until_complete(example())<jupyter_output><empty_output><jupyter_text>流水线再介绍一个Redis中常用的用来降低网络通信对于程序运行速度影响的小技巧：流水线。Redis客户端和服务器的请求响应过程如下图所示，客户端发送一个命令，等待服务器返回结果之后再提交下一个命令。如果网络情况比较差，我们就会需要花许多的时间来等待服务器的响应。一种解决方案就是利用上文提到的aredis，可以在等待响应的同时切换协程做点其他的计算。另一种解决方案就是把的命令打包一起发送，然后等服务器计算完了之后把结果一起返回来，把命令打包一起发送就是流水线的概念。其中流水线又分为事务型流水线和非事务型流水线，限于篇幅，这两个概念可自行查阅资料进行了解。![pic](../../assets/redis1.jpg)客户端与服务器端的交互![pic](../../assets/redis2.jpg)使用pipeline，进行多个命令一起提交代码如下：<jupyter_code>import redis
r = redis.Redis()
# 使用一个pipeline
pipeline = r.pipeline()
pipeline.set("thu", "No.1")
pipeline.set("xxu", "No.2")
# 把所有的命令打包发送给服务器
pipeline.execute()<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.1164622482277484, 'fraction_numerical': 0.011252391133115788, 'mean_word_length': 8.608648648648648, 'pattern_counts': {'":': 0, '<': 10, '<?xml version=': 0, '>': 11, 'https://': 3, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 1, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 5, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '2', 'dir': 'jupyter-structured-clean-dedup', 'id': '628963', 'license': 'MIT', 'n_tokens_mistral': 6503, 'n_tokens_neox': 7566, 'n_words': 202, 'path': '框架/Redis/Redis详解（1）——为什么我们都需要了解Redis.ipynb', 'repo_name': 'wanZzz6/Modules-Learn', 'size': '8887'}