本文根据读与写流量对高并发场景进行分类,列举了十余种高并发场景和实战技巧,希望能给读者带来一些新的思路与启发。
前言
在快手这样的超高 DAU 应用里做研发工作,很容易遇到高并发业务场景,因此多积累一些高并发实战技巧是很有价值的。
本文总结了一些在具体高并发场景下的实战方案,文章较长,感兴趣的同学可以收藏起来慢慢看,如果有同学在阅读过程中发现了问题或者有更好的思路,希望能在评论区中指正、探讨~
高并发场景分类
高并发说到底其实是对数据的高流量读与写,因此可以基于读写操作的流量高低对高并发场景进行简单的分类:
- 读 qps 高
- 写 qps 高
- 读 qps 高 且 写 qps 高
- 读 qps 高的场景:在日常工作中其实很常见,例如
kconf、kswitch就是典型的高频读取的例子。落到具体的业务中的话,比如直播的打榜活动往往会预先根据主播的粉丝量、收费能力进行赛道划分,赛道划分数据在整个活动期间都会高频读取。 - 写 qps 高的场景:典型的高并发场景,例如抢红包、直播间点赞计数、库存扣减等。
- 读 qps 高写 qps 也高的场景:例如直播间在线观众榜、直播长链接信令服务等。
下文将根据这三个分类列举出相应的实战技巧,这些技巧将适用于对应分类下更加细分的场景。
场景分类一:读 qps 高
实战技巧——冗余写、随机读
实战场景
写流量不高,且量级较小的单 key 缓存数据的高流量读取场景,如果读取的 qps 超过了一定阈值(比如 redis 的单实例 10 万 qps),但又不想或不能引入内存缓存机制,可以采取该方案。
具体方案
将数据写入缓存时,分为 N 个 key,冗余存储 N 份。读取数据时,random(1,N) 随机获取一个 key 进行查询,从而实现流量分流的效果,避免单 key 流量过高。该方案分流的同时也放大了写流量,所以具体冗余的份数需要根据实际的写流量和读流量进行权衡。
另外如果是在 redis-twemproxy 中使用该方案的话,由于 twemproxy 采用的是一致性 hash 算法,可能出现多个冗余 key 仍然落在相同 redis 实例的情况,解决方案有两种:
- 适当扩大冗余份数。
- 使用多个 redis 集群,将冗余的 key 进行 hash 后写到不同集群中去。
实战技巧——内存缓存
实战场景
内存缓存适用于数据读取流量很高,且需要减少远端 I/O 和重复计算的场景,例如公司的 kconf 组件、大型活动的榜单等业务场景。
具体方案
- 数据量较小,可以选择 client 侧缓存:
- 好处是减少数据查询 I/O 时间,节省 server 侧部署成本。
- 缺点是每个 client 内都有相同的缓存数据,会有一定的内存资源浪费,且不同 client 间的缓存更新时间差异可能较大,容易出现数据查询不一致的问题。
- 数据量较大,可以选择 server 侧内存缓存:
- 好处是节省内存,缓存逻辑有变时无需 client 侧部署更新,缓存数据的更新可以更频繁,减少数据不一致的时间窗口。
- 缺点是如果请求量比较高,需要消耗较大的部署成本。
- 数据特别庞大,可以选择条件性内存缓存:
- 只缓存重要的、被访问概率高的数据,例如主播维度的榜单内存缓存,可以只缓存 10w+ 粉丝量的主播,这类主播的榜单查询流量往往更高,缓存命中率也更高。
- 请求量非常庞大:
- 且 client 非常多导致 server 侧 load 源数据的压力也大,可以将上述 3 种机制结合起来使用,也可以参考下面将要介绍的技巧。
实战技巧——基于 zk 的 client 侧内存缓存同步更新机制
实战场景
使用了 client 侧内存缓存,但 client 非常多,数据 reload 频率需要控制得比较慢(否则会对 server 层带来压力),但又需要在源数据更新后,短时间内同步最新数据到所有 client 的内存缓存业务场景。
具体方案
引入了 zk (ZooKeeper) 来进行数据更新事件的广播推送,所有 client 收到广播后,主动进行一次内存缓存的更新。这个方案可以减少 client 之间的数据不一致时间窗口,也可减少 client 的数据更新频率。
(该方案在快手架构组已经提供了相应的实现,可通过 LocalCacheBuilder 的 enableNotify 启用)
需要注意的是,源数据的更新事件如果过于频繁会造成广播风暴和缓存更新风暴,此时需要对更新事件进行聚合处理以减少广播频率。
实战技巧——Redis 分 shard 存储
实战场景
需要缓存大量的用户名单,既要高效的判断用户是否存在于名单内,又需要对名单进行遍历。
例如打榜活动需要事先根据主播的收入水平划分出不同赛道,每个赛道都是独立的榜单,并且活动期内需要对指定赛道内的主播进行发私信等需要遍历的操作。
具体方案
由于要进行遍历,需要使用 redis 的 set、zset 数据结构,但名单量级太大,存储到单 key 会导致大 key 问题。因此可以根据用户 id 进行 hash 后根据合适的 shard 数量取模,将用户存储到具体的 shard 中。
此方案使得每个 redis key 对应的内存大小可控,避免了大 key 问题的风险,同时使得在集群环境下,每个 redis 实例上的查询流量趋于平衡。
实战技巧——内存布隆过滤器
实战场景
布隆过滤可用来高效判断一个元素是否存在于一个集合(小概率存在假阳性,需要二次判断),因此可以用来作为流量漏斗。
实际场景例如某个功能只对少部分用户开放,功能入口的查询流量很高,需要高性能的判断用户是否拥有该功能权限,但权限查询接口扛不住这么高的流量,此时使用布隆过滤则可以过滤掉所有无权限用户的请求,仅处理少部分可能有权限的用户查询即可。
具体方案
由于源数据查询服务性能有限,增加了一层对外查询服务,在该服务启动时增加钩子,需要从源数据查询服务拉取到全量的源数据构建内存中的 BloomFilter,构建完成后再开始对外提供服务。若源数据会更新,则运行期间需要定时 reload 源数据以重新构建 BloomFilter。
对外查询服务通过 BloomFilter 来拦截掉大部分明确不存在于源数据集中的查询请求,从而使得源数据查询服务得到保护。
实战技巧——Redis Bloom & RoaringBitmap
实战场景
对于内存布隆过滤器来说,如果源数据集过大,查询全量数据集构建 BloomFilter 的过程将非常缓慢,会极大的影响服务启动速度与数据集更新速度,大量的 I/O 也会拉低整个服务的性能。
此时可以引入分布式的布隆过滤实现,现成的实现方案是 redis 插件 RedisBloom(kcc 已支持)。
具体方案
大数据集通常存储在 hive 中,需要通过一个刷数据的 task 加载到 redis 里,初始化好数据后即可对外提供服务。
如果查询流量较高,为了避免热 key 问题,可以参照上文提到的分 shard 方案,将 1 个 key 拆分为适当数量的 shard 用于均摊流量。
另外如果是数值型数据的存在与否判断,可以考虑使用 RoaringBitmap(咆哮位图,一种压缩位图实现),对于内存占用的压缩率和操作性能都比 redis 自身基于字符串实现的 bitmap 要高,kcc 团队目前已支持 RoaringBitmap 相关指令。
场景分类二:写 qps 高
实战技巧——流量削峰
实战场景
业务的峰值写流量请求很高,超出了写数据服务的性能瓶颈,如果不做任何保护处理会导致写数据的服务或者存储层过载引起故障。
流量削峰手段可以将峰值流量打平到服务可承受的程度,保证业务正常运行。
具体方案:下面介绍几种流量削峰技巧
MQ 削峰
- 将写流量请求转为消息发送到消息队列,消费者会根据本地消费能力以一定的速率从队列中获取消息进行消费处理。
- 当峰值流量到来时,由于整个消费者集群的消费能力达到上限后就不会继续加速对下游服务的调用了,所以不会造成下游服务过载。
- 但是如果消费能力过低,消费不过来的消息会暂时堆积在消息队列中,也会导致丢消息、消费 delay 严重等问题,因此需要在消费速度和性能保护之间找到平衡点。
内存削峰
- 诸如“给作品点赞”,“送礼物加积分”等业务场景的数据写入是可以被聚合处理的,例如在 1s 内发生 100 次点赞,可以在内存中将这 100 次请求累加起来,最后只调用一次 +100 的写请求,而不是每次点赞都调用一次 +1 的写请求,大大减少了实际执行的请求数量。公司内部架构组提供的
BufferTrigger组件就是用来应对这种场景的。 - 该方案的缺点当机器/容器发生重启时会导致数据丢失,因此需要实现优雅退出,所以在使用
BufferTrigger组件时我们会搭配TermHelper工具。但如果是kill -9强杀或者内存掉电的情况就无法避免数据丢失了,因此使用内存聚合方案处理的数据需要业务能容许一定的数据丢失。
- 诸如“给作品点赞”,“送礼物加积分”等业务场景的数据写入是可以被聚合处理的,例如在 1s 内发生 100 次点赞,可以在内存中将这 100 次请求累加起来,最后只调用一次 +100 的写请求,而不是每次点赞都调用一次 +1 的写请求,大大减少了实际执行的请求数量。公司内部架构组提供的
多层削峰
- 如果写流量特别大的场景,可以考虑使用多层削峰,例如将正常的链路:
api -> db改为api -> bufferTrigger -> Kafka -> consumer -> bufferTrigger -> db。 - 该方案结合了 MQ 削峰与内存削峰,并且在 API 层和 consumer 层都使用了
bufferTrigger,这样一来可以大大降低流量的数量级。
- 如果写流量特别大的场景,可以考虑使用多层削峰,例如将正常的链路:
流量打散
- 流量打散也是一种常用的流量削峰手段,例如快手在 2020 年春晚的视频红包上就应用了此方案,用户抢红包之前会先播放一段明星拜年的视频,在此期间用户可以对视频进行点赞互动,当视频播放完成后弹出抢红包按钮,用户点击后展示获得的金额。在这个过程里,客户端实际在视频播放期间就已经随机找了个时间请求服务端接口得到红包金额数据了,等用户看完视频点击抢红包按钮时,客户端只是做了展示操作而已。
- 具体的方案为,服务端预先下发一个随机打散时长配置给客户端,例如 20s,客户端本地
random(0,20)得到一个实际执行请求的时机,到时后再真正请求服务端接口。 - 当业务侧接受一定的请求时延,甚至有适当的交互形式时,流量打散是一种非常有效的削峰技巧。
实战技巧——随机丢弃
实战场景
写请求量很高,且丢弃一部分请求也不影响最终业务效果的场景,比如抢红包、弹幕等业务,可以使用随机丢弃。
具体方案
接到写请求后,根据一定的随机策略决定是否要处理本次写请求,如果不需处理则做一个降级的默认返回无需进行 I/O。
为了减少请求被丢弃对用户体验的影响,可以在交互层做一些优化,诸如弹幕这样的场景,可以在主态模拟一条发送成功的弹幕显示给用户看。
实战技巧——计算预处理
实战场景
定时开奖、倒计时抢红包等计算逻辑复杂且瞬时写流量高的业务场景,可以使用计算预处理方案来解决。
具体方案
像定时开奖、倒计时抢红包这样的业务,在倒计时结束的时间点肯定是流量最高的时候,如果此时用户发来请求,服务端才实时判断剩余金额、概率计算、金额扣减等操作的话,一方面需要解决并发场景下的数据一致性问题,一方面也要考虑服务性能是否能兜住。
不妨转换一个思路会发现,开奖场景下哪些用户可以获奖、获得什么奖励,抢红包场景下红包要拆多少份,每份随机多少金额,这些数据都是可以被提前计算好并且存储下来的,没必要等到最后一刻实时计算。
因此可以启动定时任务,将倒计时期间的开奖、红包记录查询出来提前进行分奖、拆包,并存储到缓存内,当用户执行请求时,服务端只需要返回缓存内的结果数据即可。
实战技巧——同步写缓存、异步写 db
实战场景
类似秒杀这样需要实时返回用户秒杀结果是成功还是失败的场景,为了避免瞬时流量对 db 造成太大压力,可以选择同步写缓存,异步写 db 的方案。
具体方案
在秒杀玩法开始前,从 db 中读取库存配置写入缓存。秒杀开始后用户请求到来时,直接扣除缓存中的库存,根据缓存返回的结果来告知用户是否秒杀成功,对于秒杀成功的请求发一条成功日志到消息队列,由专门的消费者集群来异步将结果写入 db,并且执行后续的业务逻辑。
该方案里的缓存可以使用分布式缓存,也可以使用内存缓存,无论选择哪一类缓存,保证不要超卖,尽量避免少卖即可。
实战技巧——分库分表,db 硬抗
上述的的应对方案基本都做了一个假设,就是数据库是扛不了多少流量的,所以想尽了办法来保护它。
但实际上如果进行了合理的 db 集群拆分、分库分表、主从同步优化等操作后,db 也是能扛住非常可观的读写流量的,并且 db 的资源使用的是机械硬盘或者固态硬盘,都比内存要便宜,资源申请起来不那么心疼。
因此如果 1 个集群、1 个库、1 张表扛不住的话,那就申请 10 个集群、100 个库、1000 张表来扛,相信你的 db,它可以的!
场景分类三:读 qps 高、写 qps 高
实战技巧——读写分离
实战场景
以直播间在线观众榜为例,日常的读 qps 就已经是万级别了,当有大主播引导观众打开榜单给榜一大哥点关注时更是能打出十万级别的 qps。于此同时,直播间内观众的送礼、点赞、进出直播间等诸多高频次行为都可能造成榜单数据产生变化,因此在线榜单的读写流量对于服务端来说都具备很高的压力。
本节会介绍一种对此类场景更具针对性方案,也就是读写分离。
具体方案
将用户各类行为产生的榜单数据写到 redis 集群里,然后启动一个专门的任务定时将 redis 中的榜单数据刷入具有更高查询性能的 memcached 集群中,并基于 memcached 提供榜单数据的对外查询服务。
在本方案中,对于 redis 集群,只需要扛住高流量的写入即可,对于 memcachd 集群,只需要扛住高流量的外部查询即可。
由此可以看出读写分离方案的关键在于隔离读/写流量,避免两者同时作用于相同的数据源,读流程和写流程之间的数据传输则由一个独立的中间服务进行。
总结
不同领域的高并发的业务场景层出不穷,应对方案也千变万化,上文中的实战技巧是笔者基于自身在直播领域的研发经验与日常的积累见闻总结而来,只是在高并发的海洋边上,偶然拾起了一两块小小的贝壳,文中方案如有不当之处,还望不吝赐教。
最后希望本文能够抛砖引玉,为阅读了本文的同学带来一定的思路和启发。