背景是前几天的一次 P0 事故,我们当做数据库用的 redis 满了,触发了配置的 LRU 逐出策略,逐出了 20min 的 key,导致大概有 2w 用户当天的学习数据丢失,在处理过程中有很多值得总结的点,于是写下这篇博客。
详细背景
之前有个需求是给用户统计所有已学词的去重数量,这个需求听起来很简单,但是考虑业务的历史原因,用户的词被分成了三个部分,一部分存在 mysql,一部分是冷数据存在 oss,还有一部分是生词本,那就是另一个服务了,需要调 rpc 接口获取。因此计算时需要汇总这三部分做去重,是一个计算任务比较重的操作,但是直到这里,听起来还是很简单,无非就是用户词量大加上 IO 请求,计算会比较慢(经过统计最长其实也是大几百毫秒的级别,很少有秒级别的),只需要把接口做成异步轮询的就可以了。
但是考虑到这个功能会被暴露在一个流量比较大的入口,按月活 70w 计算,可能每天会被触发 100w+ 次,每天 100w 次这种比较重的计算任务,其实服务器会承担很大的计算压力,人也是,可能一天到晚都会听到接口报警信息,于是考虑出最终的方案是离线计算和在线计算相结合的模式,和feed流的推拉模式很像,上线前我们会预热月活用户的总量数据防止一上线就有大量计算任务落在 mysql 上,并且每天凌晨计算日活用户的总量数据,存到 redis 里,这样用户每天只需要一次简单的 get 操作,他可以看到自己的单词总量和更新日期,并且如果有需要,用户也可以主动调用计算的接口,异步的更新这个 key。
这个最终方案是比较合理的,不过也是会高度依赖 redis 的,毕竟会固定的存下月活用户的总量数据,而且预热、计算这些步骤也都是会丢到异步任务队列里去做的(我们的队列用了 redis 做持久化),这个 P0 事故的导火索就是在这里:
在跑月活用户的时候为了速度,会每个用户丢一个异步任务到队列里,大概会有 300w 左右的任务,因为生产者太快,消费者比较慢,导致任务出现了堆积。其实堆积本身是无所谓的,只不过消费慢一点而已,这本身也只是一个预热脚本。但是坏就坏在这个项目的当做队列用的 redis 和当做数据库用的 redis 是同一个,当队列堆积的越来越多,把 redis 打满之后,悲剧就发生了。
造成的后果
当 redis 满了之后,按照我们的配置,是会根据 LRU 的策略来逐出一些 key,给新请求腾出空间的。这个过程持续了 20min,而悲剧的是我们的用户当天的学习数据是会从 mysql 加载到 redis,然后学完之后再落盘回 mysql 的…所以有一部分用户的学习数据还没落盘就被删除…
事后统计,大概逐出了 170w 个 key,不过不幸中的万幸是,用户每天的学习数据是会存两天的,按 LRU 策略,最近最少未使用的有很大一部分是昨天的 key,这个删了是无所谓的,因为旧的数据一旦学完就会落盘回 mysql,而且这里面也有很大一部分 key 是异步任务的 key,所以最终影响的用户不会特别夸张,但也有很多了,最后算出来是 2w 多个。
处理过程中值得总结的点
Q1:为啥 redis 满之前没有报警?
其实是有的,只不过是在中午吃饭期间,所以报警被忽略了,至于选在中午跑的原因嘛…一是之前这个脚本 dry run 过一次,同时也跑了一部分用户计算过一次,从试运行结果来看,速度不慢且没有任何报错,所以当时评估风险不是很大,并且这个脚本可能要跑的比较久,我们不太想半夜还盯着它,于是就尽早的开始跑了。不过没有注意到报警这一点还是有问题的,这是不应该的,如果及早看到这个报警,那就不会有后面那么多事了。
Q2: 为啥不从备份恢复 redis?
因为这个 redis 存了所有日活用户今日的学习数据,学习进度等,逐出的用户毕竟是少数,如果为了这少数用户恢复了备份,那么大多数用户的学习进度就会丢失,那么事情的严重性就上了一个等级。
既然事故已经发生了,那我们还是好好总结吧,毕竟遇到这么一个 P0 也是可遇不可求,还是能好好学到一些经验的:
-
首先最大的一个点就是 redis 需要按功能区分开,当做数据库用的必然要先保证它的可靠性,当打满之后不应该配置逐出策略,而是应该让它存不了新数据,并且必然不能让它和消息队列,缓存这些混用。对于大公司而言,有专门的运维或者 dba 来管这个,一般问题都不大,但对于小公司,大多直接用了云服务,运维也是又开发兼带着做,走 Devops 那一套,所以还是有必要重视一下的,毕竟这个策略是默认配了的。
-
上线一个依赖 redis 的需求前需要好好评估一下内存使用情况,其实这个需求上之前是评估过了的,月活数据总共也只会占用不到 3g,但是没有考虑到的一点是我们的 redis 是数据库和消息队列混用的,消息队列堆积是之前没有估算的。这个估算,其实可以做在 CI 里,当提交了一个脚本到代码库,mr 里一定要带上对资源的相关描述,CI 里可以根据正则对有没有这个描述做检查,在最开始就严格把控。
-
重视 human retry,我们是一家学习平台,用户来的第一目的肯定是学习,当他发现进来之后学习不了,肯定是会主动重试的,但是因为他当天学习数据丢失,所以重试会失败,而我们一开始处理的重点是放在统计受影响的用户范围,浪费了很多时间。而让有问题的用户能够强制重新学习的补丁代码直到很晚才上线,其实用户能重新学习,那么在我们的学习机制下他当天的学习数据是可以认为能自动修复的。我们处理时的思维还是仅仅局限于工程师的思维,没有从用户角度出发。不过 human retry 是把双刃剑,在这个例子里,如果及时处理它会带来正面效果,但如果是在其它场景,比如超卖了,human retry 会导致情况恶化,这一点其实是需要我们在处理问题的最开始就要想到的。
-
尽量不要让消息队列产生堆积,可以从四个地方控制,一是 worker 的数量不要太多,二是 worker 的并发数可以设置的少一点,三是可以给消费者加一个 ratelimit 限速,四是生产者角度的限速,这些应该都是可以配置的,k8s 里可以直接指定每个 worker 部署时的副本数,而我们使用的消息队列也可以指定每个 worker 的并发数,实施起来很方便。如果你使用的是其他技术栈,我相信这些也都是可以配置的,如果不可以,那说明那种方案是有问题的,其实也应该把它改造成可以配置。
大概就总结出了这四大点,其他的和业务紧密相关的就不放出来了。人生第一次处理 P0 事故,虽然大部分时间都在帮两位大佬打杂,太刺激了…那天晚上接近 12 点才下班,凌晨 3 点多才睡着…
希望世界和平,永远也没有 bug!