为啥懒 Redis 是更好的 Redis

英文原文:Lazy Redis is better Redis

前言

大家都知道 Redis 是单线程的。真正的内行会告诉你,实际上 Redis 并不是完全单线程,因为在执行磁盘上的特定慢操作时会有多线程。目前为止多线程操作绝大部分集中在 I/O 上以至于在不同线程执行异步任务的小型库被称为 bio.c: 也就是 Background I/O。

然而前阵子我提交了一个问题,在问题里我承诺提供一个很多人(包括我自己)都想要的功能,叫做“免费懒加载”。原始的问题在这

问题的根本在于,Redis 的 DEL 操作通常是阻塞的。因此如果你发送 Redis “DEL mykey” 命令,碰巧你的 key 有 5000万个对象,那么服务器将会阻塞几秒钟,在此期间服务器不会处理其他请求。历史上这被当做 Redis 设计的副作用而被接受,但是在特定的用例下这是一个局限。DEL 不是唯一的阻塞式命令,却是特殊的一个命令,因为我们认为:Redis 非常快,只要你用复杂度为 O(1) 和 O(log_N) 的命令。你可以自由使用 O(N) 的命令,但是要知道这不是我们优化的用例,你需要做好延迟的准备。

这听起来很合理,但是同时即便用快速操作创建的对象也需要被删除。在这种情况下,Redis 会阻塞。

第一次尝试

对于单线程服务器,为了让操作不阻塞,最简单的方式就是用增量的方式一点点来,而不是一下子把整个世界都搞定。例如,如果要释放一个百万级的对象,可以每一个毫秒释放1000个元素,而不是在一个 for() 循环里一次性全做完。CPU 的耗时是差不多的,也许会稍微多一些,因为逻辑更多一些,但是从用户来看延时更少一些。当然也许实际上并没有每毫秒删除1000个元素,这只是个例子。重点是如何避免秒级的阻塞。在 Redis 内部做了很多事情:最显然易见的是 LRU 淘汰机制和 key 的过期,还有其他方面的,例如增量式的对 hash 表进行重排。

刚开始我们是这样尝试的:创建一个新的定时器函数,在里面实现淘汰机制。对象只是被添加到一个链表里,每次定时器调用的时候,会逐步的、增量式的去释放。这需要一些小技巧,例如,那些用哈希表实现的对象,会使用 Redis 的 SCAN 命令里相同的机制去增量式的释放:在字典里设置一个游标来遍历和释放元素。通过这种方式,在每次定时器调用的时候我们不需要释放整个哈希表。在重新进入定时器函数时,游标可以告诉我们上次释放到哪里了。

适配是困难的

你知道这里最困难的部分是哪里吗?这次我们是在增量式的做一件很特别的事情:释放内存。如果内存的释放是增量式的,服务器的内容增长将会非常快,最后为了得到更少的延时,会消耗调无限的内存。这很糟,想象一下,有下面的操作:

WHILE 1
    SADD myset element1 element2 … many many many elements
    DEL myset
END

如果慢慢的在后台去删除myset,同时SADD调用又在不断的添加大量的元素,内存使用量将会一直增长。

好在经过一段尝试之后,我找到一种可以工作的很好的方式。定时器函数里使用了两个想法来适应内存的压力:

1.检测内存趋势:增加还是减少?以决定释放的力度。

2.同时适配定时器的频率,避免在只有很少需要释放的时候去浪费CPU,不用频繁的去中断事件循环。当确实需要的时候,定时器也可以达到大约300HZ的频率。
这里有一小段代码,不过这个想法现在已经不再实现了:

/计算内存趋势,只要是上次和这次内存都在增加,就倾向于认为内存趋势
 是增加的 */

if (prev_mem < mem) mem_trend = 1;
   
mem_trend *= 0.9; /* 逐渐衰减 */
   
int mem_is_raising = mem_trend > .1;

   
/* 释放一些元素 */
   
size_t workdone = lazyfreeStep(LAZYFREE_STEP_SLOW);

   
/* 根据现有状态调整定时器频率 */
   
if (workdone) {
       
    if (timer_period == 1000) timer_period = 20;

    if (mem_is_raising && timer_period > 3)
           
        timer_period--; /* Raise call frequency. */
       
    else if (!mem_is_raising && timer_period < 20)

        timer_period++; /* Lower call frequency. */
   
} else {
       
  timer_period = 1000;    /* 1 HZ */
   
}

这是一个小技巧,工作的也很好。不过郁闷的是我们还是不得不在单线程里执行。要做好需要有很多的逻辑,而且当延迟释放(lazy free)周期很繁忙的时候,每秒能完成的操作会降到平时的65%左右。
如果是在另一个线程去释放对象,那就简单多了:如果有一个线程只做释放操作的话,释放总是要比在数据集里添加数据来的要快。

当然,主线程和延迟释放线程直接对内存分配器的使用肯定会有竞争,不过 Redis 在内存分配上只用到一小部分时间,更多的时间用在I/O、命令分发、缓存失败等等。
不过,要实现线程化的延迟释放有一个大问题,那就是 Redis 自身。内部实现完全是追求对象的共享,最终都是些引用计数。干嘛不尽可能的共享呢?这样可以节省内存和时间。例如:SUNIONSTORE 命令最后得到的是目标集合的共享对象。类似的,客户端的输出缓存包含了作为返回结果发送给socket的对象的列表,于是在类似 SMEMBERS 这样的命令调用之后,集合的所有成员都有可能最终在输出缓存里被共享。看上去对象共享是那么有效、漂亮、精彩,还特别酷。

但是,嘿,还需要再多说一句的是,如果在 SUNIONSTORE 命令之后重新加载了数据库,对象都取消了共享,内存也会突然回复到最初的状态。这可不太妙。接下来我们发送应答请求给客户端,会怎么样?当对象比较小时,我们实际上是把它们拼接成线性的缓存,要不然进行多次 write() 调用效率是不高的!(友情提示,writev() 并没有帮助)。于是我们大部分情况下是已经复制了数据。对于编程来说,没有用的东西却存在,通常意味着是有问题的。
事实上,访问一个包含聚合类型数据的key,需要经过下面这些遍历过程:

key -> value_obj -> hash table -> robj -> sds_string

如果去掉整个 tobj 结构体,把聚合类型转换成 SDS 字符串类型的哈希表(或者跳表)会怎么样?(SDS是Redis内部使用的字符串类型)。

这样做有个问题,假设有个命令:SADD myset myvalue,举个例子来说,我们做不到通过client->argv[2] 来引用用来实现集合的哈希表的某个元素。我们不得不很多次的把值复制出来,即使数据已经在客户端命令解析后创建的参数 vector 里,也没办法去复用。Redis的性能受控于缓存失效,我们也许可以用稍微间接一些的办法来弥补一下。
于是我在这个 lazyfree 的分支上开始了一项工作,并且在 Twitter 上聊了一下,但是没有公布上下文的细节,结果所有的人都觉得我像是绝望或者疯狂了(甚至有人喊道 lazyfree 到底是什么玩意)。那么,我到底做了什么呢?

把客户端的输出缓存由 robj 结构体改成动态字符串。在创建 reply 的时候总是复制值的内容。
把所有的 Redis 数据类型转换成 SDS 字符串,而不是使用共享对象结构。听上去很简单?实际上这花费了数周的时间,涉及到大约800行高风险的代码修改。不过现在全都测试通过了。
把 lazyfree 重写成线程化的。
结果是 Redis 现在在内存使用上更加高效,因为在数据结构的实现上不再使用 robj 结构体(不过由于某些代码还涉及到大量的共享,所以 robj 依然存在,例如在命令分发和复制部分)。线程化的延迟释放工作的很好,比增量的方式更能减少内存的使用,虽然增量方式在实现上与线程化的方式相似,并且也没那么糟糕。现在,你可以删除一个巨大的 key,性能损失可以忽略不计,这非常有用。不过,最有趣的事情是,在我测过的一些操作上,Redis 现在都要更快一些。消除间接引用(Less indirection)最后胜出,即使在不相关的一些测试上也更快一些,还是因为客户端的输出缓存现在更加简单和高效。

最后我把增量式的延迟释放实现从分支里删除,只保留了线程化的实现。

关于 API 的一点备注

不过 API 又怎么样了呢?DEL 命令仍然是阻塞的,默认还跟以前一样,因为在 Redis 中 DEL 命令就意味着释放内存,我并不打算改变这一点。所以现在你可以用新的命令 UNLINK,这个命令更清晰的表明了数据的状态。

UNLINK 是一个聪明的命令:它会计算释放对象的开销,如果开销很小,就会直接按 DEL 做的那样立即释放对象,否则对象会被放到后台队列里进行处理。除此之外,这两个命令在语义上是相同的。

我们也实现了 FLUSHALL/FLUSHDB 的非阻塞版本,不过没有新增的 API,而是增加了一个 LAZY 选项,说明是否更改命令的行为。

不只是延迟释放

现在聚合数据类型的值都不再共享了,客户端的输出缓存也不再包含共享对象了,这一点有很多文章可做。例如,现在终于可以在 Redis 里实现线程化的 I/O,从而不同的客户端可以由不同的线程去服务。也就是说,只有访问数据库才需要全局的锁,客户端的读写系统调用,甚至是客户端发送的命令的解析,都可以在线程中去处理。这跟 memcached 的设计理念类似,我比较期待能够被实现和测试。

还有,现在也可以在其他线程实现针对聚合数据类型的特定的慢操作,可以让某些 key 被“阻塞”,但是所有其他的客户端不会被阻塞。这个可以用很类似现在的阻塞操作的方式去完成(参考blocking.c),只是增加一个哈希表保存那些正在处理的 key 和对应的客户端。于是一个客户端请求类似 SMEMBERS 这样的命令,可能只是仅仅阻塞住这一个 key,然后会创建输出缓存处理数据,之后在释放这个 key。只有那些尝试访问相同的 key 的客户端,才会在这个 key 被阻塞的时候被阻塞住。
所有这些需求起了更激烈的内部变化,但这里的底线我们已很少顾忌。我们可以补偿对象复制时间来减少高速缓存的缺失,以更小的内存占用聚合数据类型,所以我们现在可依照线程化的 Redis 来进行无共享化设计,这一设计,可以很容易超越我们的单线程。在过去,一个线程化的 Redis 看起来总像是一个坏主意,因为为了实现并发访问数据结构和对象其必定是一组互斥锁,但幸运的是还有别的选择获得这两个环境的优势。如果我们想要,我们依然可以选择快速操作服务,就像我们过去在主线程所做的那样。这包含在复杂的代价之上,获取执行智能(performance-wise)。

计划表

我在内部增加了很多东西,明天就上线看上去是不现实的。我的计划是先让3.2版(已经是unstable状态)成为候选版本(RC)状态,然后把我们的分支合并到进入unstable的3.4版本。

不过在合并之前,需要对速度做细致的回归测试,这有不少工作要做。

如果你现在就想尝试的话,可以从Github上下载lazyfree分支。不过要注意的是,当前我并不是很频繁的更新这个分支,所以有些地方可能会不能工作。

(0)

相关推荐

  • Redis操作命令总结

    一.key pattern 查询相应的key (1)redis允许模糊查询key 有3个通配符  *.?.[] (2)randomkey:返回随机key (3)type key:返回key存储的类型 (4)exists key:判断某个key是否存在 (5)del key:删除key (6)rename key newkey:改名 (7)renamenx key newkey:如果newkey不存在则修改成功 (8)move key 1:将key移动到1数据库 (9)ttl key:查询key的

  • 64位Windows下安装Redis教程

    Redis对于Linux是官方支持的,安装和使用没有什么好说的,普通使用按照官方指导,5分钟以内就能搞定.详情请参考:http://redis.io/download 但有时候又想在windows下折腾下Redis,可以从redis下载页面看到如下提示: 复制代码 代码如下: Win64 Unofficial The Redis project does not directly support Windows,  however the Microsoft Open Tech group de

  • Redis中5种数据结构的使用场景介绍

    一.redis 数据结构使用场景 原来看过 redisbook 这本书,对 redis 的基本功能都已经熟悉了,从上周开始看 redis 的源码.目前目标是吃透 redis 的数据结构.我们都知道,在 redis 中一共有5种数据结构,那每种数据结构的使用场景都是什么呢? String--字符串 Hash--字典 List--列表 Set--集合 Sorted Set--有序集合 下面我们就来简单说明一下它们各自的使用场景: 1. String--字符串 String 数据结构是简单的 key-

  • Linux下Redis的安装和部署

    一.Redis介绍 Redis是当前比较热门的NOSQL系统之一,它是一个key-value存储系统.和Memcache类似,但很大程度补偿了Memcache的不足,它支持存储的value类型相对更多,包括string.list.set.zset和hash.这些数据类型都支持push/pop.add/remove及取交集并集和差集及更丰富的操作.在此基础上,Redis支持各种不同方式的排序. 和Memcache一样,Redis数据都是缓存在计算机内存中,不同的是,Memcache只能将数据缓存到

  • redis常用命令、常见错误、配置技巧等分享

    1. redis查看当前所有的key 复制代码 代码如下: KEYS * 2. 查看当前redis的配置信息 复制代码 代码如下: CONFIG GET * 3. MISCONF Redis is configured to save RDB snapshots, but is currently not able to persist on disk. Commands that may modify the data set are disabled. Please check Redis

  • 30个php操作redis常用方法代码例子

    redis的操作很多的,以前看到一个比较全的博客,但是现在找不到了.查个东西搜半天,下面整理一下php处理redis的例子,个人觉得常用一些例子.下面的例子都是基于php-redis这个扩展的. 1,connect 描述:实例连接到一个Redis. 参数:host: string,port: int 返回值:BOOL 成功返回:TRUE;失败返回:FALSE 示例: 复制代码 代码如下: <?php  $redis = new redis();  $result = $redis->conne

  • 超强、超详细Redis数据库入门教程

    [本教程目录] 1.redis是什么 2.redis的作者何许人也 3.谁在使用redis 4.学会安装redis 5.学会启动redis 6.使用redis客户端 7.redis数据结构 – 简介 8.redis数据结构 – strings 9.redis数据结构 – lists 10.redis数据结构 – 集合 11.redis数据结构 – 有序集合 12.redis数据结构 – 哈希 13.聊聊redis持久化 – 两种方式 14.聊聊redis持久化 – RDB 15.聊聊redis持

  • 为啥懒 Redis 是更好的 Redis

    英文原文:Lazy Redis is better Redis 前言 大家都知道 Redis 是单线程的.真正的内行会告诉你,实际上 Redis 并不是完全单线程,因为在执行磁盘上的特定慢操作时会有多线程.目前为止多线程操作绝大部分集中在 I/O 上以至于在不同线程执行异步任务的小型库被称为 bio.c: 也就是 Background I/O. 然而前阵子我提交了一个问题,在问题里我承诺提供一个很多人(包括我自己)都想要的功能,叫做"免费懒加载".原始的问题在这 问题的根本在于,Red

  • RedisTemplate访问Redis的更好方法

    目录 开始准备 RedisTemplate JPA Repository Cache 总结 开始准备 开始之前我们需要有Redis安装,我们采用本机Docker运行Redis, 主要命令如下 docker pull redis docker run --name my_redis -d -p 6379:6379 redis docker exec -it my_redis bash redis-cli 前面两个命令是启动redis docker, 后两个是连接到docker, 在使用redis-

  • Windows下安装Redis及使用Python操作Redis的方法

    首先说一下在Windows下安装Redis,安装包可以在https://github.com/MSOpenTech/redis/releases中找到,可以下载msi安装文件,也可以下载zip的压缩文件. 下载zip文件之后解压,解压后是这些文件: 里面这个Windows Service Documentation.docx是一个文档,里面有安装指导和使用方法. 也可以直接下载msi安装文件,直接安装,安装之后的安装目录中也是这些文件,可以对redis进行相关的配置. 安装完成之后可以对redi

  • Redis概述及linux安装redis的详细教程

    1.Redis是什么 性能极高,Redis能读的速度是110000次/s,写的速度是81000次/s . Redis支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用. Redis不仅仅支持简单的key-value类型的数据,同时还提供list,set,zset,hash等数据结构的存储. Redis支持数据的备份,即master-slave模式的数据备份. 2.Redis特点 性能极高,Redis能读的速度是110000次/s,写的速度是81000次/s . Re

  • 使用redis实现延迟通知功能(Redis过期键通知)

    Redis 过期监听场景 业务中有类似等待一定时间之后执行某种行为的需求 , 比如 30 分钟之后关闭订单 . 网上有很多使用 Redis 过期监听的 Demo redis配置 把notify-keyspace-events Ex 这一行的注释打开 项目demo工程 项目结构如下图 maven依赖 <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apa

  • 详解redis脚本命令执行问题(redis.call)

    1.redis-cli命令行中执行: # 调用redis命令设置缓存 # 不传参数 eval "return redis.call('set', 'name1', 'Tom')" 0 # 传入1个值参数 eval "return redis.call('set', 'name2', ARGV[1])" 0 "Tom" # 传入1个键名参数和1个值参数 eval "return redis.call('set', KEYS[1], ARG

  • 使用PHP导出Redis数据到另一个Redis中的代码

    从某个 Redis db 导出数据到另一个 Redis db 的PHP脚本: 复制代码 代码如下: $from = '127.0.0.1:6200/6';$to   = '127.0.0.1:6200/8'; $from_redis = redis_init($from);$to_redis   = redis_init($to); $keys  = $from_redis->keys('*');$count = 0;$total = count($keys);foreach($keys as

  • 分布式缓存Redis与Memcached的优缺点区别比较

    目录 Memcache与Redis的区别都有哪些? 1).存储方式 2).数据支持类型 3).使用底层模型不同 4),value大小 redis相比memcached有哪些优势? 使用redis有哪些好处? 为什么Memcached和Redis如此流行? 不仅是其具有超高的性能,还因为相对来说他们都非常简单. 对程序员来说上手使用Memcached或Redis相当容易. 安装和设置并集成到系统中可能只需要几分钟时间. 因此花费一点点时间和精力就能立刻大幅提升系统性能 —— 通常是提升一个数量级.

  • Django项目如何配置Memcached和Redis缓存?选择哪个更有优势?

    对于中大型网站而言,使用缓存减少对数据库的访问次数是提升网站性能的关键手段之一.在Django项目生产环境中最常用的缓存后台是Memcached和Redis.今天小编就手把手教你如何在Django项目中配置Memcached和Redis作为缓存后台.那么它们两个到底哪个更好呢? 本文会对比这两个存储系统并在文末给出答案. Memcache缓存 Memcache是一个高性能的分布式内存对象缓存系统,是Django原生支持的最快最有效的缓存系统.Memcached的优点是速度快,属于分布式缓存,支持

  • Redis官方ORM框架比RedisTemplate更优雅

    目录 RedisOM简介 JDK 11安装 使用 总结 RedisOM简介 之前在SpringBoot项目中,我一直使用RedisTemplate来操作Redis中的数据,这也是Spring官方支持的方式.对比Spring Data对MongoDB和ES的支持,这种使用Template的方式确实不够优雅!最近发现Redis官方新推出了Redis的专属ORM框架RedisOM,用起来够优雅,推荐给大家! SpringBoot实战电商项目mall(50k+star)地址:github.com/mac

随机推荐