当前位置: 首页 > 科技观察

开始吧!Redis在海量数据、高并发下的优化实践

时间:2023-03-18 18:12:32 科技观察

Redis对于互联网技术工程师来说并不陌生。几乎所有的大中型企业都在使用Redis作为缓存数据库。但是对于绝大多数企业来说,只会用到它最基本的KV缓存功能,Redis的很多高级功能可能并没有认真去实践。掌阅工程师钱文品将为您带来:《Redis 在海量数据和高并发下的优化实践》主题分享。他将围绕Redis分享日常业务开发中遇到的9个经典案例,希望本次分享能够帮助大家更好地将Redis的高级特性应用到日常业务开发中。电子书阅读软件ireader总用户数约5亿,月活跃用户5000万,日活跃用户近2000万。服务器端有1000多个Redis实例和100+个集群,每个实例的内存控制在20G以下。第一个KV缓存是最基础的,也是最常用的就是KV函数。我们可以使用Redis来缓存用户信息、session信息、商品信息等。下面代码是通用的缓存读取逻辑:defget_user(user_id):user=redis.get(user_id)ifnotuser:user=db.get(user_id)redis.setex(user_id,ttl,user)//设置缓存过期时间返回userdefsave_user(user):redis.setex(user.id,ttl,user)//设置缓存过期时间db.save_async(user)//异步写入数据库这个过期时间很重要,一般是单session与用户的长度成正比,以确保用户在单个会话中可以尽可能多地使用缓存中的数据。当然,如果你的公司财力雄厚,又很注重性能体验,你可以把时间设置的长一点,甚至根本不设置过期时间。当数据量持续增长时,使用Codis或Redis-Cluster集群来扩容。此外,Redis还提供了缓存模式。Set命令不需要设置过期时间,也可以按照一定的策略淘汰这些键值对。开启缓存模式的命令是:configsetmaxmemory20gb,这样当内存达到20GB时,Redis就会开始执行淘汰策略,为新的键值对腾出空间。Redis也为这个策略提供了很多策略。综上所述,本策略分为两部分:定义淘汰范围和选择淘汰算法。比如我们线上使用的策略是Allkeys-lru。这个Allkeys是指Redis内部的所有Key都可以被淘汰,不管有没有过期时间,而Volatile只淘汰有过期时间的。Redis的淘汰功能就像是企业遇到经济寒冬需要勒紧腰带进行一轮残酷的人才优化。它会选择只优化临时工,还是可能对所有人进行同等优化。划定这个范围的时候,会从中选出几个地方。如何选择?这就是淘汰算法。最常用的是LRU算法,它有一个弱点,就是表面上做得很好的人可以逃避优化。比如你趁机在上司面前好好表现,那你就安全了。所以当Redis4.0引入LFU算法时,需要对通常的结果进行评估。光做表面功夫是不行的,还要看平时勤不勤快。最后,还有一种非常不常见的算法:随机抽奖算法。这个算法也可能淘汰CEO,所以一般不用。分布式锁再来看第二个功能:分布式锁,这是除了KV缓存之外另一个最常用的特性。比如,一个非常有能力的高级工程师,开发效率快,代码质量高,是团队中的明星。所以很多产品经理都要找他麻烦,让他给自己提要求。如果一堆产品经理同时找他,他的思路就会陷入混乱。再优秀的程序员,大脑的并发能力也好不到哪去。所以他在办公室的门把手上挂了一个请勿打扰的牌子。产品经理来了,看看门把手上有没有这样的标志。之前我们只好挂牌子,讨论完再拿下来。这样一来,当其他产品经理要打扰他的时候,如果看到这个牌子挂在那里,他们可以选择睡觉等着,或者先去做其他事情。就这样,这位明星工程师从此获得了安宁。这个分布式锁的使用很简单,就是set命令的扩展参数如下:#locksetlock:$user_idowner_idnxex=5#releaselockifredis.call("get",KEYS[1])==ARGV[1]thenreturnredis.call("del",KEYS[1])elsereturn0end#equivalenttodel_if_equalslock:$user_idower_id必须设置这个过期时间,因为特殊情况,比如地震(进程被Kill-9,或者机器isdown),产品经理可能会选择跳窗,没有机会下架,造成死锁和饥饿,让这个优秀的工程师成为闲人,造成资源的严重浪费。同时需要注意owner_id,代表谁加了锁,以及产品经理的工号。以防您的锁被他人意外取下。释放锁时必须匹配owner_id,匹配成功才能释放锁。这个owner_id通常是一个随机数,保存在ThreadLocal变量(栈变量)中。其实官方并不推荐这种方式,因为在集群模式下,当发生主从切换时,会出现锁丢失的问题。官方推荐的分布式锁叫做RedLock。作者认为这个算法比较安全,推荐大家使用。但是掌阅一直使用的是上面最简单的分布式锁。我们为什么不使用RedLock?因为它的运维成本会更高,而且需要3个以上独立的Redis实例,使用起来比较麻烦。另外Redis集群主从切换的概率也不高。即使发生主从切换,锁丢失的概率也很低,因为主从切换往往有一个过程,这个过程的时间通常会超过锁的过期时间,不会出现锁异常丢失的情况。另外分布式锁遇到锁冲突的机会也不多。就像一个公司的明星程序员比较有限,总是遇到锁队列就说明结构需要优化了。延迟队列我们继续看第三个函数,延迟队列。前面我们提到,产品经理遇到“请勿打扰”品牌可以选择多种策略:等待休眠,放弃,休息一下,然后等待:就是Spinlock,会烧CPU,增加Redis的性能。服务质量。休眠:休眠一会再试,会浪费线程资源,增加响应时间。Giveupandquit:通知前端用户稍后再试。现在系统压力很大,有点忙,影响用户体验。最后一个是我们现在要讨论的策略,稍后再回过头来:这是现实世界中最常见的策略。这种策略一般用在消息队列的消费上。这时候遇到锁冲突怎么办?不能丢弃不处理,不适合立即重试(Spinlock)。这时候就可以把消息丢进延迟队列了。我稍后再处理。支持延迟消息功能的专业消息中间件有很多,比如RabbitMQ、NSQ。Redis也可以,我们可以用Zset来实现这个延迟队列。Zset存储Value/Score键值对。我们将Value存储为序列化的任务消息,将Score存储为下一个任务消息运行的时间(Deadline),然后轮询Zset中Score值大于Now的任务消息。处理。#生产延迟消息zadd(queue-key,now_ts+5,task_json)#消费延迟消息whileTrue:task_json=zrevrangebyscore(queue-key,now_ts,0,0,1)iftask_json:grabbed_ok=zrem(queue-key,task_json)ifgrabbed_ok:process_task(task_json)else:sleep(1000)//breakfor1s当consumer是多线程或多进程时,这里会出现竞争和浪费的问题。当前线程明明是从zset中轮询task_json的,但是在通过zrem争抢的时候却抓不到。这时候可以使用LUA脚本来解决这个问题,将轮询和竞争操作原子化,避免竞争浪费。localres=nillocaltasks=redis.pcall("zrevrangebyscore",KEYS[1],ARGV[1],0,"LIMIT",0,1)if#tasks>0thenlocalok=redis.pcall("zrem",KEYS[1],tasks[1])ifok>0thenres=tasks[1]endendreturnres为什么要把分布式锁和延迟队列放在一起说,因为??很早就有上线故障。故障发生时,线上某个Redis队列的长度超过表,导致很多异步任务执行失败,业务数据出现问题。后来我才知道原因。是因为分布式锁没有用好,导致死锁,而当锁失效时,Sleep无限重试导致异步任务完全进入睡眠状态,无法处理任务。那么这个分布式锁当时是怎么使用的呢?它使用了Setnx+Expire。结果,在服务升级时停止进程,直接导致个别请求执行了Setnx,却没有执行Expire,给个别用户带来了死锁。但是后台还有一个异步任务处理,同样需要锁定用户。如果锁失败,它将无限重试Sleep。那么,一旦命中了之前的死锁用户,异步线程就会彻底关闭。因为这个意外,才有了今天正确的分布式锁形式和延迟队列的发明,还有优雅关闭,因为如果有优雅关闭的逻辑,那么服务升级就不会导致请求只执行到一半中断,除非进程是Kill-9或崩溃。定时任务分布式定时任务的实现方式有很多种,最常见的一种是Master-Workers模型。Master负责管理时间,时间一到就把任务消息丢到消息中间件中,然后Workers负责监听这些消息队列去消费消息。著名的Python定时任务框架Celery就是这样做的。但是Celery有个问题,就是Master是单点的。如果Master挂了,整个定时任务系统就会停止工作。另一种实现是Multi-Master模型。这个模型是什么意思?它类似于Java中的Quartz框架,使用数据库锁来控制任务并发。会有多个进程,每个进程都会管理时间。时间一到,就会使用数据库锁来竞争执行任务的权利。被抢占的进程会有机会执行任务,然后开始执行任务。这解决了Master的问题。单点问题。这种模式有一个缺点,就是会造成竞争浪费,但是通常大部分业务系统并没有那么多的定时任务,所以这种竞争浪费并不严重。另一个问题是它依赖于分布式机器时间的一致性。如果多台机器上的时间不一致,任务会执行多次。这可以通过增加数据库锁定时间来缓解。有了Redis分布式锁之后,我们可以在Redis之上实现一个简单的定时任务框架:#registertimedtaskhsettasksnametrigger_rule#gettimedtasklisthgetalltasks#scrambletasksetlock:${name}truenxex=5#taskListchange(rollingupgrade)#轮询版本号,有变化就重新加载任务列表,有时间变化的任务重新调度头发。LifeisShort,我用Redishttps://github.com/pyloque/taskino频率控制,如果你做过社区,你就知道总是遇到垃圾邮件是不可避免的。一觉醒来,你会发现首页会突然被一些莫名其妙的广告贴刷屏。未能建立适当的机制来控制这种情况可能会导致严重损害用户体验。有许多策略可以控制垃圾帖子。更高级的是通过人工智能,最简单的方法是通过关键字扫描。另一种常用的方法是频率控制,它限制了单个用户的内容生产速度,不同级别的用户会有不同的频率控制参数。可以使用Redis实现频率控制。我们将用户行为理解为一个时间序列。我们必须保证单个用户的时间序列的长度限制在一定的时间段内。如果长度超过这个长度,用户的行为将被禁止。可以用Redis的Zset来实现:图中绿色部分是我们要保留的某个时间段的时间序列信息,灰色部分会被截掉。统计绿色段中时间序列记录的条数,可以知道是否超过频率阈值。#以下代码控制用户的ugc行为最多每小时N次hist_key="ugc:${user_id}"withredis.pipeline()aspipe:#记录当前行为pipe.zadd(hist_key,ts,uuid)#Reserve1一个小时内的行为序列pipe.zremrangebyscore(hist_key,0,now_ts-3600)#获取一个小时内的行为数pipe.zcard(hist_key)#设置过期时间保存内存pipe.expire(hist_key,0)#Batchexecution_,_,count,_=pipe.exec()returncount>N服务发现技术成熟度稍高的企业,都会有服务发现的基础设施。通常我们会选择Zookeeper、Etcd、Consul等分布式配置数据库作为服务列表的存储。他们有非常及时的通知机制来通知服务消费者服务列表的变化。那么我们如何使用Redis来进行服务发现呢?这里我们又要用到Zset这个数据结构了,我们用Zset来保存单个服务列表。多个服务列表使用多个Zsets来存储。Zset的Value和Score分别存放服务地址和心跳时间。服务提供商需要使用心跳来报告自己的生存情况,每隔几秒调用一次Zadd。当服务提供商停止服务时,使用Zrem将其自身移除。zaddservice_keyheartbeat_tsaddrzremservice_keyaddr这个还不够,因为服务可能异常终止,没有机会执行hook,所以需要额外的线程清理服务列表中的过期项:zremrangebyscoreservice_key0now_ts-30#30s没来心跳,然后还有一个重要的问题就是如何通知消费者服务列表发生了变化。这里我们也使用了版本号轮询机制。当服务列表更改时增加版本号。消费者通过轮询版本号的变化来重新加载服务列表。ifzadd()>0||zrem()>0||zremrangebyscore()>0:incrservice_version_key但是还有一个问题,如果消费者依赖了很多服务列表,那么它需要轮询很多版本号,所以IO效率会比较低。这时候我们可以再添加一个全局版本号。当任何服务列表版本号发生变化时,全局版本号将递增。这样,一般情况下,消费者只需要轮询全局版本号即可。当全局版本号变化时,将依赖的服务列表的子版本号一一比较,然后加载变化的服务列表:https://github.com/pyloque/captain。位图阅读器的登录系统开发的比较早。当时,用户数量还没有增加。设计比较简单,就是将用户的登录状态存储在Redis的Hash结构中,每次登录在Hash结构中记录一条记录。有签到、未签到、签到、重签三种状态,分别是0、1、2的三个整数值:hsetsign:${user_id}2019-01-011hsetsign:${user_id}2019-01-021hsetsign:${user_id}2019-01-032...这是对用户空间的浪费。后来,当日登录超过1000万时,Redis存储问题开始凸显,直接把内存拉到30G+,而我们在线的实例一般在20G后就开始报警,已经严重超过30G了。这时候,我们开始着手解决这个问题,优化存储。我们选择使用位图来记录签到信息。一个签到状态需要两位来记录,一个月的存储空间只需要8个字节。这样,可以用一个很短的字符串来存储用户一个月的签到记录。优化效果非常明显,内存直接减少到10G。由于查询全月签到状态的API调用非常频繁,接口的通信量也小了很多。但是位图也有缺点。它的底层是一个字符串,字符串是一个连续的存储空间。位图将自动展开。比如一个大的位图有8M位,只有最后一位为1,其他位全为0,也会占用1M的存储空间,是非常严重的浪费。于是就有了咆哮位图的数据结构,将大的位图分段存储,全为0的段不需要存储。此外,每个段都设计了一个稀疏存储结构。如果该段上设置为1的位不多,则只能存储它们的偏移整数。这样,位图的存储空间就得到了非常显着的压缩。这张咆哮的位图在大数据精准统计领域非常有价值。有兴趣的同学可以了解一下:https://juejin.im/post/5cf5c817e51d454fbf5409b0关于这个签到系统,前面提到模糊统计,如果产品经理需要知道这个签到的详细情况,每天和每月的活动情况呢?通常我们会直接追究责任,请联系数据部门。但是,数据部门的数据往往不是很实时。前一天的数据往往要等到第二天才能出来。离线计算通常安排在一天一次。那么如何实现实时的活动统计呢?最简单的方案就是在Redis中维护一个Set集合,来一个用户,就sadd,集合的最终大小就是我们需要的UV数。但这种空间浪费实在是太严重了,光是为了一个号就存放这么庞大的藏品,似乎不划算。那我们该怎么办呢?这时候可以使用Redis提供的HyperLogLog模糊计数功能。它是一种具有一定误差的概率计数,误差约为0.81%。但是占用空间很小,最底层是位图,最多只占用12K的存储空间。而当计数值比较小时,位图采用稀疏存储,空间占用就更小了。#纪录Userpfaddsign_uv_${day}user_id#获取记录条数pfcountsign_uv_${day}微信公众号文章阅读数可以用,可以完成网页的UV统计。但是如果产品经理非常在意数字的准确性,比如某项统计需求直接与钱挂钩,那么可以考虑前面提到的咆哮位图。使用起来稍微复杂一些,需要提前将用户ID序列化为一个整数。Redis本身没有提供咆哮位图的功能,但是有一个开源的RedisModule可以开箱即用:https://github.com/aviggiano/redis-roaring。布隆过滤器最后,我们将讨论布隆过滤器。如果一个系统即将有大量新用户涌入,那将是非常有价值的。可以显着降低缓存的穿透率,减轻数据库的压力。新用户的涌入不一定是因为业务系统的大规模部署,也有可能是因为外部的缓存穿透攻击。defget_user_state0(user_id):state=cache.get(user_id)ifnotstate:state=db.get(user_id)or{}cache.set(user_id,state)返回状态defsave_user_state0(user_id,state):cache.set(user_id,state)db.set_async(user_id,state)比如上面是本业务系统的用户状态查询接口代码。现在来了一个新用户,它会先检查缓存中是否有这个用户的状态数据。因为是新用户,肯定缓存不在这里。然后它必须检查数据库,但是没有数据库。如果这么大量的新用户瞬间涌入,可以预见数据库的压力会比较大,会出现大量的空查询。我们非常希望Redis中有这样一个Set,里面存放着所有用户的ID,这样通过查询Set集合,就可以知道是否有新用户来了。当用户数量非常多时,维护这样一个集合所需的存储空间非常大。这时候就可以使用Bloomfilter,它相当于一个Set,但又不同于Set,它需要的存储空间要小得多。例如,你需要64个字节来存储一个用户ID,而布隆过滤器只需要1个多字节来存储一个用户ID。但是它存储的不是用户ID,而是用户ID的指纹,所以会有一定的小概率误判。它是一个具有模糊过滤功能的容器。当它说用户标识不在容器中时,它肯定不在。当它说用户ID在容器中时,99%的时间是正确的,1%的时间是错误的。不过在这种情况下,这种误判不会造成问题,误判的代价只是缓存穿透。相当于1%的新用户没有受到Bloomfilter的保护直接渗透到数据库查询,而剩下的99%的新用户可以被Bloomfilter有效阻断,避免缓存渗透。defget_user_state(user_id):exists=bloomfilter.is_user_exists(user_id)ifnotexists:return{}returnget_user_state0(user_id)defsave_user_state(user_id,state):bloomfilter.set_user_exists(user_id)save_user_state0(user_id,state)布隆过滤器的原理有一个很好的打个比方,那就是在冬天的一块白雪覆盖的地面上,如果你走在上面,它会留下你的脚印。如果地上有你的脚印,可以断定你大概率到过这里,但不一定是真的。也许别人的鞋子和你的一模一样。但是如果地上没有你的脚印,那么可以100%确定你没有到过这个地方。作者:钱文品(老钱)简介:十年互联网分布式高并发技术老手,目前是掌阅服务器技术专家。精通Java、Python、Golang等计算机语言,开发游戏、制作网站、编写消息推送系统和MySQL中间件,实现过开源ORM框架、Web框架、RPC框架等。