本文作者:伍佰(周斯航)
云音乐曲库缓存经过多年的实践和改善,形成了一套自有的缓存使用体系,并取得了很好的效果。本文将以实战为主,介绍曲库缓存设计的动机和思路,帮助读者了解背后的原因,并在其他场景中借鉴相似的思路。
背景知识
缓存基础介绍
缓存是系统设计中,用于提升底层系统访问能力的一种技术手段,它同样作用于云音乐的各个系统中,一种常用的缓存使用调用链路如下:
转化为时序图,如下图所示:
整个缓存的数据放入,是采用懒加载的方式,先取缓存,取到则返回,取不到则透过到下一层,拿到后会回写当前层的缓存,这是整个云音乐缓存使用的整体思路。
在正式进入实战之前,介绍一些概念数据:
- 一次简单的DB操作,耗时在 0.5~0.6ms
- 一次简单缓存操作(非本机),耗时在 0.5~0.6ms
- 一次简单的本机缓存操作,耗时在 0.2~0.3 ms
云音乐曲库读是整个云音乐服务中接口调用量最高的几个之一,曲库读整体服务的rpc峰值调用qps能够达到 50w+ (双机房累加),通过多种缓存使用的尝试及调优,并最终从以下角度进行考虑并实践,得到较好的效果。
曲库数据的特点
很多中间件、组件等设计,在考虑设计时,都会朝通用化方式去实现,而契合业务场景的特点,则更能将性能做到极致,曲库的缓存实现,是与曲库数据特性有着深度的联系,具体如下:
- 读多写少
- 可以读写分离
- 数据变化秒级延迟用户不敏感
- 热点数据集中
- 通过List(列表)获取数据的场景很多,有大量 MultiGet 操作
有上述特点的业务场景,都可以参考曲库的缓存使用姿势。
实战场景讲解
实战场景1:缓存的高并发保障
日常对曲库读服务的高并发保障中,主要会遇到以下两个问题:
-
歌曲(尤其是热门歌曲)发布时,短时间内会出现大量热点请求,此时由于数据冷启动,缓存没有存储对应数据,会有大量请求直接访问数据库,引起数据库压力瞬间增大。
-
针对预售但暂未入库的歌曲,上游有持续不断的请求,此时由于数据库和缓存都没有数据,导致请求都进入数据库查询,给数据库带来极大的压力。
以下是曲库读缓存服务针对这两个问题进行优化的策略。
场景1:保障热点数据的获取
曲库将缓存服务分两级进行部署:在最靠近数据库层部署了一套分布式Memcache作为中心缓存,用于缓存歌曲数据;在曲库读服务的主机侧部署本地Memcache缓存,用于缓存最热门的歌曲数据。为了防止发布瞬间出现的缓存击穿现象,曲库采用了缓存穿刺的做法,具体做法如下:
对于缓存中的 Key-Value ,将每个Value变成这样一个对象:
java
public static class HoleWrapper<T> implements Serializable {
private long expire; // 对象的过期时间
private T target; // 对象本身
}
即每个在缓存中的对象,都带上自身的过期时间,这样在获取对象的时候,就知道缓存是否快过期了,如果能得到这个信息,结合业务特点 对于秒级延迟不敏感、热点数据集中 ,则可以这么进行设置,在曲库,我们称之为 穿刺 :
- 通过 key 获取 HoleWrapper
- 查看 HoleWrapper中的 expire 是否 快过期(快过期:可以定义5min、1h)
- 如果是,当前线程将获取到的 HoleWrapper 的 expire 时间延长,并放入缓存(此操作耗时较少)
- 当前线程向下穿透到下一层取数据,并将最新数据进行更新
时序图如下:
穿刺 体现在步骤3中,此处不能完全杜绝击穿的风险,但由于缓存操作远远快于DB操作,这样产生击穿的概率就下降了极多;有了穿刺,对于热点数据就能很好的做好防护,并且qps越高、越热点,越能体现优势。
场景2:数据库不存在的数据请求的保障
如何保障数据库不存在的数据请求,是缓存优化中比较经典的"防穿透"问题,又一个简单而通用的思路:
从缓存取不到的数据,在数据库中也没有取到,这时也可以在缓存中写入一个特殊值进行标记,缓存时间的设置可以视情况确定(如果主动清理可以设置长一点、否则短一点)
由于这种做法比较通用,故而在曲库封装的缓存代码中,将其通用化封装,即对于下面时序图,第四步进行设置:
实战场景2:缓存扩缩容
场景1:缓存容量够,但性能不够时,如何进行扩容
在热门歌曲或大型活动期间,此时缓存的容量足够存储需要缓存的数据,但缓存本身的性能可能会出现瓶颈(例如缓存上限qps是20w,此时系统压力达到30w),此时会新增多个缓存集群,每个集群缓存同样的数据内容,以提升缓存的性能,本方法也被称为 横向扩容(Scale Out) 。
横向扩容需要考虑以下两个问题:
-
如何保障多组缓存数据是一样的?
-
新扩展的缓存集群冷启动,如何防止大量请求打到db的问题?
为了解决这两个问题,曲库的最佳实践是设计了一个缓存代理,所有的缓存操作均通过代理进行执行,代理对于缓存命令的执行形式为:随机读、顺序写
-
读
-
写
通过这种方式,可以保障在一定的时间范围内,多个缓存集群缓存的数据能够基本一致。
在解决了一致性问题后,还需要保障扩容阶段的系统稳定性。此时我们通过配置缓存访问权重的方式实现缓存预热,短时间内只有很少的读请求能够进入新集群,由于代理顺序写的逻辑,在一段时间后,新集群会缓存足够多的数据,此时再通过修改代理配置,使新缓存能够提供读请求。
注:曲库提供的这套横向扩容的缓存方案比较适合"读多写少"的场景,在频繁写的场景下,由于需要频繁的更新缓存,本套方案的性能可能会降低。
场景2:缓存性能够,容量不够时,如何进行扩容
随着曲库数据量的逐步变大,缓存的占用量也越来越高,扩容缓存一个简单的做法,就是在单个缓存集群上增加更多资源,以提升缓存的容量。这种办法被称为纵向扩容(Scale Up)。
纵向扩容最可能出现的问题是由于节点增多,如果使用普通哈希算法存储缓存,如果只有一组缓存(大部分场景都够用),可能会导致扩容后缓存全部失效,此时会导致极高的系统风险。下图对风险进行了详细介绍:
-
扩容前:
-
扩容后:
为了解决这个问题,我们采用了一致性哈希算法来进行缓存的存储,通过这种方法,可以降低缓存集群内节点扩缩容带来的系统风险。本文不过度赘述一致性哈希算法的原理,感兴趣的读者可以参考5分钟理解一致性哈希算法。
实战场景3:缓存清理
曲库数据的特点是读多写少,且可以接受数据变更后秒级的延迟。基于这种特点,我们设计了异步缓存清理的方案。其中在设计缓存key-value时需要遵循这样的原则:
- 所有的缓存清理,由于曲库数据支持秒级延迟的特点,可以进行异步清理
- 所有的缓存清理,由数据库变更(binlog消息)消息触发
- 所有关联的Key,可以由单条binlog生成
只要遵循这样的设计,曲库缓存的清理就可以变得比较轻巧,可以采用监听数据库binlog的形式进行异步清理。
场景1:缓存数据出现变化时,如何保障一致性
场景1是比较基础的缓存清理场景,在此不做过多描述,需要注意的是如果是多级缓存,需要从缓存的部署形式分析,按离数据库从近到远的形式进行清理。(例如监听数据库binlog后,先清理中心缓存,再清理本地缓存。)
曲库的最佳实践是只有清理中心缓存的服务直接监听binlog消息,在清理完中心缓存后再将消息转发到另一个消息队列,清理本地缓存的服务监听新的消息队列,这样就能实现有序清理缓存的目的。在清理本地缓存时,我们提供了一个清理sdk插件,嵌入曲库读服务,每个服务在启动时会实例化一个独立的消费者,这样虽然对业务有部分侵入,但由于每个消费者只需要清理本地缓存,曲库读服务的扩缩容会变得异常简便,也更适用于当前容器化部署的形式。具体流程图如下:
场景2:缓存数据结构出现变化时,如何保障一致性
如果某个缓存对象的数据结构发生了变化(例如新加了一个字段),此时需要把该类型对应已缓存的对象全部清理。
在这里,我们采用了一个简单做法:不去主动清理已存在的缓存,而是想办法把这部分缓存"失效"掉(线上服务访问不到)。主要的做法是利用了构建缓存key的生成器,在生成缓存key的时候添加一个"缓存版本"。后续如果遇到需要清理所有缓存的时候,只需要把缓存版本进行升级,就可以达到访问不到老缓存,重新从数据库获取数据的效果。
注:通过升级版本号的方案其实是无法精确清理所有缓存对象的情况下的trade off,升级版本号后,在发布服务时需要注意缓慢灰度发布,否则可能会造成大规模的缓存雪崩现象。
总结
以上,是曲库缓存使用的实践历程,涉及的细节较多,不同业务场景可以参考不同的考虑方式进行部分借鉴。
后续曲库缓存的发展方向,是将元数据中额MetaData数据与状态数据分开,并将MetaData数据进行纯静态化处理,结合业务数据变化的特点,将状态部分数据的降级等引入考虑,进行更深度的缓存使用。
最后
更多岗位,可进入网易招聘官网查看 hr.163.com/