APP下载

千亿级资料:《高并发下 Redis 业务优化实践》

消息来源:baojiabao.com 作者: 发布时间:2024-05-18

报价宝综合消息千亿级资料:《高并发下 Redis 业务优化实践》

Redis 对于从事互联网技术工程师来说并不陌生,几乎所有的大中型企业都在使用 Redis 作为快取数据库,但是对于绝大多数企业来说只会用到它的最基础的 KV 快取功能,还有很多 Redis 的高阶功能可能都未曾认真实践过。今天在这一个小时的时间我会围绕 Redis,分享在平时的日常业务开发中遇到的 9 个经典案例,希望通过此次分享可以帮助大家更好的将 Redis 的高阶特性应用到日常的业务开发中来。

一、KV 快取

第一个是最基础也是最常用的就是 KV 功能,我们可以用 Redis 来快取使用者资讯、会话资讯、商品资讯等等。下面这段程式码就是通用的快取读取逻辑。

这个过期时间非常重要,它通常会和使用者的单次会话长度成正比,保证使用者在单次会话内尽量一直可以使用快取里面的资料。当然如果贵公司财力雄厚,又极致注重效能体验,可以将时间设定的长点甚至干脆就不设定过期时间。当资料量不断增长时,就使用 Codis 或者 Redis-Cluster 丛集来扩容。

除此之外 Redis 还提供了快取模式,Set 指令不必设定过期时间,它也可以将这些键值对按照一定的策略进行淘汰。开启快取模式的指令是:config set maxmemory 20gb ,这样当内存达到 20gb 时,Redis 就会开始执行淘汰策略,给新来的键值对腾出空间。这个策略 Redis 也是提供了很多种,总结起来这个策略分为两块:划定淘汰范围,选择淘汰算法。比如我们线上使用的策略是 allkeys-lru。这个 allkeys 表示对 Redis 内部所有的 key 都有可能被淘汰,不管它有没有带过期时间,而volatile只淘汰带过期时间的。Redis 的淘汰功能就好比企业遇到经济寒冬时需要勒紧裤腰带过冬需要进行一轮残酷的人才优化。它会选择只优化临时工呢,还是所有人一律平等都可能被优化。当这个范围圈定之后,会从中选出若干个名额,怎么选择呢,这个就是淘汰算法。最常用的就是 LRU 算法,它有一个弱点,那就是表面功夫做得好的人可以逃过优化。比如你乘机赶紧在老板面前好好表现一下,然后你就安全了。所以到了 Redis 4.0 里面引入了 LFU 算法,要对平时的成绩也进行考核,只做表面功夫就已经不够用了,还要看你平时勤不勤快。最后还一种极不常用的算法 —— 随机摇号算法,这个算法有可能会把 CEO 也给淘汰了,所以一般不会使用它。

二、分散式锁

下面我们看第二个功能 —— 分散式锁,这个是除了 KV 快取之外最为常用的另一个特色功能。比如一个很能干的资深工程师,开发效率很快,程式码质量也很高,是团队里的明星。所以呢诸多产品经理都要来烦他,让他给自己做需求。如果同一时间来了一堆产品经理都找他,它的思路呢就会陷入混乱,再优秀的程序员,大脑的并发能力也好不到哪里去。所以呢他就在自己的办公室的门把上挂了一个请勿打扰的牌子,当一个产品经理来的时候先看看门把上有没有这个牌子,如果没有呢就可以进来找工程师谈需求,谈之前要把牌子挂起来,谈完了再把牌子摘了。这样其它产品经理也要来烦他的时候,如果看见这个牌子挂在那里,就可以选择睡觉等待或者是先去忙别的事。如是这位明星工程师从此获得了安宁。

这个分散式锁的使用方式非常简单,就是使用 Set 指令的扩充套件引数如下

一定要设定这个过期时间,因为遇到特殊情况 —— 比如地震(程序被 kill -9,或者机器宕机),产品经理可能会选择从窗户上跳下去,没机会摘牌,导致了死锁饥饿,让这位优秀的工程师成了一位大闲人,造成严重的资源浪费。同时还需要注意这个 owner_id,它代表锁是谁加的 —— 产品经理的工号。以免你的锁不小心被别人摘掉了。释放锁时要匹配这个 owner_id,匹配成功了才能释放锁。这个 owner_id 通常是一个随机数,存放在 ThreadLocal 变数里(栈变数)。

官方其实并不推荐这种方式,因为它在丛集模式下会产生锁丢失的问题 —— 在主从发生切换的时候。官方推荐的分散式锁叫 RedLock,作者认为这个算法较为安全,推荐我们使用。不过掌阅这边一直还使用上面最简单的分散式锁,为什么我们不去使用 RedLock 呢,因为它的运维成本会高一些,需要 3 台以上独立的 Redis 例项,用起来要繁琐一些。另外呢 Redis 丛集发生主从切换的概率也并不高,即使发生了主从切换出现锁丢失的概率也很低,因为主从切换往往都有一个过程,这个过程的时间通常会超过锁的过期时间,也就不会发生锁的异常丢失。还有呢就是分散式锁遇到锁冲突的机会也不多,这正如一个公司里明星程序员也比较有限一样,总是遇到锁排队那说明结构上需要优化。

三、延时伫列

下面我们继续看第三个功能 —— 延时伫列。前面我们提到产品经理在遇到“请勿打扰”的牌子时可以选择多种策略,1. 干等待 2. 睡觉 2. 放弃不干了 3. 歇一会再干。干等待就是 spinlock,这会烧 CPU,飙高 Redis 的QPS。睡觉就是先 sleep 一会再试,这会浪费执行绪资源,还会增加响应时长。放弃不干呢就是告知前端使用者待会再试,现在系统压力大有点忙,影响使用者体验。最后一种呢就是现在要讲的策略 —— 待会再来,这是在现实世界里最普遍的策略。这种策略一般用在讯息伫列的消费中,这个时候遇到锁冲突该怎么办?不能抛弃不处理,也不适合立即重试(spinlock),这时就可以将讯息扔进延时伫列,过一会再处理。

有很多专业的讯息中介软件支援延时讯息功能,比如 RabbitMQ 和 NSQ。Redis 也可以,我们可以使用 zset 来实现这个延时伫列。zset 里面储存的是 value/score 键值对,我们将 value 储存为序列化的任务讯息,score 储存为下一次任务讯息执行的时间(deadline),然后轮询 zset 中 score 值大于 now 的任务讯息进行处理。

当消费者是多执行绪或者多程序的时候,这里会存在竞争浪费问题。当前执行绪明明将 task_json 从 zset 中轮询出来了,但是通过 zrem 来争抢时却抢不到手。这时就可以使用 LUA 指令码来解决这个问题,将轮询和争抢操作原子化,这样就可以避免竞争浪费。

为什么我要将分散式锁和延时伫列一起讲呢,因为很早的时候线上出了一次故障。故障发生时线上的某个 Redis 伫列长度爆表了,导致很多异步任务得不到执行,业务资料出现了问题。后来查清楚原因了,就是因为分散式锁没有用好导致了死锁,而且遇到加锁失败时就 sleep 无限重试结果就导致了异步任务彻底进入了睡眠状态不能处理任务。那这个分散式锁当时是怎么用的呢?用的就是 setnx + expire,结果在服务升级的时候停止程序直接就导致了个别请求执行了 setnx,但是 expire 没有得到执行,于是就带来了个别使用者的死锁。但是后台呢又有一个异步任务处理,也需要对使用者加锁,加锁失败就会无限 sleep 重试,那么一旦撞上了前面的死锁使用者,这个异步执行绪就彻底熄火了。因为这次事故我们才有了今天的正确的分散式锁形式以及延时伫列的发明,还有就是优雅停机,因为如果存在优雅停机的逻辑,那么服务升级就不会导致请求只执行了一半就被打断了,除非是程序被 kill -9 或者是宕机。

四、定时任务

分散式定时任务有多种实现方式,最常见的一种是 master-workers 模型。master 负责管理时间,到点了就将任务讯息仍到讯息中介软件里,然后worker们负责监听这些讯息伫列来消费讯息。著名的 Python 定时任务框架 Celery 就是这么干的。但是 Celery 有一个问题,那就是 master 是单点的,如果这个 master 挂了,整个定时任务系统就停止工作了。

另一种实现方式是 multi-master 模型。这个模型什么意思呢,就类似于 Java 里面的 Quartz 框架,采用数据库锁来控制任务并发。会有多个程序,每个程序都会管理时间,时间到了就使用数据库锁来争抢任务执行权,抢到的程序就获得了任务执行的机会,然后就开始执行任务,这样就解决了 master 的单点问题。这种模型有一个缺点,那就是会造成竞争浪费问题,不过通常大多数业务系统的定时任务并没有那么多,所以这种竞争浪费并不严重。还有一个问题它依赖于分散式机器时间的一致性,如果多个机器上时间不一致就会造成任务被多次执行,这可以通过增加数据库锁的时间来缓解。

现在有了 Redis 分散式锁,那么我们就可以在 Redis 之上实现一个简单的定时任务框架。

如果你觉得 Quartz 内部的程式码复杂的让人看不懂,分散式文件又几乎没有,很难折腾,可以试试 Redis,使用它会让你少掉点头发。

五、频率控制

如果你做过社群就知道,不可避免总是会遇到垃圾内容。一觉醒来你会发现首页突然会某些莫名其妙的广告帖刷屏了。如果不采取适当的机制来控制就会导致使用者体验收到严重影响。

控制广告垃圾贴的策略非常多,高阶一点的通过 AI,最简单的方式是通过关键词扫描。还有比较常用的一种方式就是频率控制,限制单个使用者内容生产速度,不同等级的使用者会有不同的频率控制引数。

频率控制就可以使用 Redis 来实现,我们将使用者的行为理解为一个时间序列,我们要保证在一定的时间内限制单个使用者的时间序列的长度,超过了这个长度就禁止使用者的行为。它可以是用 Redis 的 zset 来实现。

图中绿色的部门就是我们要保留的一个时间段的时间序列资讯,灰色的段会被砍掉。统计绿色段中时间序列记录的个数就知道是否超过了频率的阈值。

下面的程式码控制使用者的 ugc 行为为每小时最多 N 次

六、服务发现

技术成熟度稍微高一点的企业都会有服务发现的基础设施。通常我们都会选用 zookeeper、etcd、consul 等分散式配置数据库来作为服务列表的储存。它们有非常及时的通知机制来通知服务消费者服务列表发生了变更。那我们该如何使用 Redis 来做服务发现呢?

这里我们要再次使用 zset 资料结构,我们使用 zset 来储存单个服务列表。多个服务列表就使用多个 zset 来储存。zset 的 value 和 score 分别储存服务的地址和心跳的时间。服务提供者需要使用心跳来汇报自己的存活,每隔几秒呼叫一次 zadd。服务提供者停止服务时,使用 zrem 来移除自己。

这样还不够,因为服务有可能是异常终止,根本没机会执行钩子,所以需要使用一个额外的执行绪来清理服务列表中的过期项

接下来还有一个重要的问题是如何通知消费者服务列表发生了变更,这里我们同样使用版本号轮询机制。当服务列表变更时,递增版本号。消费者通过轮询版本号的变化来重载入服务列表。

但是还有一个问题,如果消费者依赖了很多的服务列表,那么它就需要轮询很多的版本号,这样的 IO 效率会比较低下。这时我们可以再增加一个全域性版本号,当任意的服务列表版本号发生变更时,递增全域性版本号。这样在正常情况下消费者只需要轮询全域性版本号就可以了。当全域性版本号发生变更时再挨个比对依赖的服务列表的子版本号,然后载入有变更的服务列表。

七、位图

掌阅的签到系统做的比较早,当时使用者量还没有上来,设计上比较简单,就是将使用者的签到状态用 Redis的 hash 结构来储存,签到一次就在 hash 结构里记录一条,签到有三种状态,未签到、已签到和补签,分别是 0、1、2 三个整数值。

这非常浪费使用者空间,到后来签到日活过千万的时候,Redis 储存问题开始凸显,直接将内存飚到了 30G+,我们线上例项通常过了 20G 就开始报警,30G 已经属于严重超标了。

这时候我们就开始着手解决这个问题,去优化储存。我们选择了使用位图来记录签到资讯,一个签到状态需要两个位来记录,一个月的储存空间只需要 8 个字节。这样就可以使用一个很短的字串来储存使用者一个月的签到记录。

优化后的效果非常明显,内存直接降到了 10 个G。因为查询整个月的签到状态 API 呼叫的很频繁,所以界面的通讯量也跟着小了很多。

但是位图也有一个缺点,它的底层是字串,字串是连续储存空间,位图会自动扩充套件,比如一个很大的位图 8m 个位,只有最后一个位是 1,其它位都是零,这也会占用1m 的储存空间,这样的浪费非常严重。

所以呢就有了咆哮位图这个资料结构,它对大位图进行了分段储存,全位零的段可以不用存。另外还对每个段设计了稀疏储存结构,如果这个段上置 1 的位不多,可以只储存它们的偏移量整数。这样位图的储存空间就得到了非常显著的压缩。

八、模糊计数

前面提到这个签到系统,如果产品经理需要知道这个签到的日活月活怎么办呢?通常我们会直接甩锅——请找资料部门。但是资料部门的资料往往不是很实时,经常前一天的资料需要第二天才能跑出来,离线计算是通常是定时的一天一次。那如何实现一个实时的活跃计数?

最简单的方案就是在 Redis 里面维护一个 set 集合,来一个使用者,就 sadd 一下,最终集合的大小就是我们需要的 UV 数字。但是这个空间浪费很严重,仅仅为了一个数字要储存这样一个庞大的集合似乎非常不值当。那该怎么办?

这时你就可以使用 Redis 提供的 HyperLogLog 模糊计数功能,它是一种概率计数,有一定的误差,误差大约是 0.81%。但是空间占用很小,其底层是一个位图,它最多只会占用 12k 的储存空间。而且在计数值比较小的时候,位图使用稀疏储存,空间占用就更小了。

微信公众号文章的阅读数可以使用它,网页的 UV 统计它都可以完成。但是如果产品经理非常在乎数字的准确性,比如某个统计需求和金钱直接挂钩,那么你可以考虑一下前面提到的咆哮位图。它使用起来会复杂一些,需要提前将使用者 ID 进行整数序列化。Redis 没有原生提供咆哮位图的功能,但是有一个开源的 Redis Module 可以拿来即用。

九、布隆过滤器

最后我们要讲一下布隆过滤器,如果一个系统即将会有大量的新使用者涌入时,它就会非常有价值,可以显著降低快取的穿透率,降低数据库的压力。这个新使用者的涌入不一定是业务系统的大规模铺开,也可能是因为来自外部的快取穿透攻击。

比如上面就是这个业务系统的使用者状态查询界面程式码,现在一个新使用者过来了,它会先去快取里查询有没有这个使用者的状态资料,因为是新使用者,所以肯定快取里没有。然后它就要去查数据库,结果数据库也没有。如果这样的新使用者大批量瞬间涌入,那么可以预见数据库的压力会比较大,会存在大量的空查询。

我们非常希望 Redis 里面有这样的一个 set,它存放了所有使用者的 id,这样通过查询这个 set 集合就知道是不是新使用者来了。当用户量非常庞大的时候,维护这样的一个集合需要的储存空间是很大的。这时候就可以使用布隆过滤器,它相当于一个 set,但是呢又不同于 set,它需要的储存空间要小的多。比如你储存一个使用者 id 需要 64 个字节,而布隆过滤器储存一个使用者 id 只需要 1个字节多点。但是呢它存的不是使用者 id,而是使用者 id 的指纹,所以会存在一定的小概率误判,它是一个具备模糊过滤能力的容器。

当它说使用者 id 不在容器中时,那么就肯定不在。当它说使用者 id 在容器里时,99% 的概率下它是正确的,还有 1% 的概率它产生了误判。不过在这个案例中,这个误判并不会产生问题,误判的代价只是快取穿透而已,相当于有 1% 的新使用者没有得到布隆过滤器的保护直接穿透到数据库查询,而剩下的 99% 的新使用者都可以被布隆过滤器有效的挡住,避免了快取穿透。

布隆过滤器的原理有一个很好的比喻,那就是在冬天一片白雪覆盖的地面上,如果你从上面走过,就会留下你的脚印。如果地面上有你的脚印,那么就可以大概率断定你来过这个地方,但是也不一定,也许别人的鞋正好和你穿的一模一样。可是如果地面上没有你的脚印,那么就可以 100% 断定你没来过这个地方。

2019-10-16 22:11:00

相关文章