一、简介
无论是客户端,还是后台,缓存都是重要的组件,缓存主要的目的就是针对提升读多写少的业务场景的性能,现在大多数的业务场景读多写少,并且微服务的架构下,服务之间频繁交互,缓存更是必备的组件。
- 为什么引入缓存可以提升性能?
我们无论怎么提升存储的性能,也无法解决需要依赖计算得出数据的性能问题,比如点赞数,评论数,转发数。其次就像之前文章九、微服务:容错设计之补偿事务)提到的需要检测该用户是否为广告平台注册用户,如果是就去存储中获取该注册用户信息。每打开app一次,就需要去存储中查一次。所有流量直接请求到数据库,数据库很有可能宕机,而且90%的用户不会是广告平台注册的用户。针对这种可以设计一个快照,在redis中存一份唯一的标识id,比如iOS的Idfa。先用缓存挡住大多数的量,只有真实的注册用户才会去存储读详细数据。很简单的业务场景,使用缓存就可以解决性能问题。
注意:本篇主要列出缓存相关的典型问题以及设计重点,其他不做更详细的描述,针对这些注意点,Google一下,很多解决方案。部分篇幅主要以实践,分析go-cache的实现
二、缓存更新模式
缓存更新获取一般就是以下几种模式。
2.1、Cache Aside 模式
- 客户端读取Cache数据,未命中cache,客户端读数据库,读取成功后客户端将数据更新到cache。
- 客户端读取Cache数据,读取到了返回。
- 客户端更新Cache数据,先更新数据库,更新数据库成功后让缓存失效。
2.2、Read/Write Through 模式
- Read Through :客户端读取Cache服务,未命中,Cache服务读取数据库,并且自己更新数据,对客户端透明。
- Write Through :客户端更新数据的时候,命中Cache则更新缓存,Cache服务自身去更新数据库。未命中Cache,直接去更新数据库。
2.3、Write Behind Caching 模式
更新数据时,只更新缓存。缓存服务异步批量去更新数据库,比如按照时间周期,定期更新一次,等等。
- 优势:因为只更新缓存(内存),所以I/O非常快。合并多次更新去操作数据库,并且更新数据库是异步的,因此性能客观。
- 缺点:非强一致性,如果在未到更新数据库的时间节点之前,系统崩溃,和可能丢失数据。
算法领域:要么时间换空间,要么空间换时间。同理,强一致性与高性能也是有冲突的。根据业务来设定自己更新策略
三、缓存设计重点
3.1、缓存独立
在微服务架构中,如果A服务有20个实例,那么如果缓存不独立,那么20个实例都需要在对应机器上构建缓存。并且随着服务的运行。很有可能造成20个缓存副本不一致,增加系统的复杂性。
- 优势:缓存独立可以降低系统复杂度,避免多态机器部署,节省资源。
- 缺点:独立缓存如果出现问题,可能导致大量请求直接穿透到DB。
针对缺点,需要保证缓存服务的高可用,高性能,很多公司在生产环境中会使用Redis来构建缓存系统。
3.2、缓存穿透
3.2.1、存储存在数据
访问缓存与数据库中都存在的数据,所有数据生成缓存可能需要大量时间或消耗大量资源,在生成过程中,所有的请求没有命中Cache,直接穿透到数据库。
- 针对以上情况,可以通过分页缓存来解决。比如一个视频分类页:有推荐,关注,搞笑,娱乐,体育等多个tab,根据用户点击的tab,以及分页来计算缓存。基本上缓存10页数据也就差不多了。(但是需要注意爬虫,爬虫不像真实用户,不会从第一页翻到最后一页,爬虫会从第一页数据,爬取到最后一页。需要注意设置好限流,保证数据库不会发生雪崩)
3.2.2、存储不存在数据
访问缓存与数据库中都不存在的数据,如果缓存没有命中,按照策略去DB中查询,但是数据库也没有查到。下一次依旧会穿透到数据库,如果是客户端写错字段,那么可能会造成意想不到问题,甚至拖垮数据库。
- 针对以上情况,可以无论是否在数据库中查询到,我们都为该字段在缓存中建立一个默认值,然后该字段的缓存有效期设置的时间短一些(防止后面数据库中该字段有值了,很久得不到更新)。避免所有的请求都直接穿透到数据库。
3.3、分布式锁
使用Cache Aside模式更新缓存的时候,先更新数据库,成功后,让缓存失效。那么问题来了,如果大量更新操作,那么大量的缓存会失效,下次访问,所有的请求直接穿透Cache去数据库获取数据,然后在更新缓存。可能造成数据库的宕机。
- 针对这种问题,可以使用分布式锁,只有拿到锁的实例才能去更新缓存。分布式锁可以使用ZooKeeper,redis等实现。
3.4、热点缓存
针对热点数据,要多副本缓存,为什么这么做?缓存服务在高,也存在性能瓶颈。例如一个大V拥有千万粉丝,该大V发了一个视频,大量人来围观。因为针对热点数据做了多副本缓存,大量的请求被打散到不同的缓存服务上去获取数据。减轻了系统压力。副本的有效期要设置不同时间,防止同时失效,造成雪崩。
3.5、小结
这么多策略,方案,仅仅是抽样化的理论,基本的建议方案,缓存的策略应该是根据业务场景,根据业务发展来实际设定的。缓存服务自更新,客户端更新,甚至是异步批量更新,这些策略不是定死的。可以灵活运用,甚至是根据不同的业务场景,组合使用。
缓存设计并不复杂,但是需要注意细节,这些细节没有考虑到,可能在某一天海量请求下,直接导致雪崩。缓存是牺牲强制一致性,换来高性能,确定好自己的业务是否需要引入缓存。
四、实践
实践以分析go-cache的实现为目标。
4.1、使用
1 | import ( |
4.2、Set实现
设置缓存使用读写锁,发过设置DefaultExpiration,根据初始化的设置,缓存有效时间为5分钟。e为缓存失效的时间点。每个Item包含一个我们缓存的元素和该元素过期的时间节点。
1 | func (c *cache) Set(k string, x interface{}, d time.Duration) { |
4.3、Get实现
获取数据的时候使用读写锁。读是判断该数据是否过期,如果过期返回失败。
1 | func (c *cache) Get(k string) (interface{}, bool) { |
4.4、过期缓存清除
缓存过期后,需要清除掉,这里的清除时间就是Cache初始化的时候。
1 | c := cache.New(5*time.Minute, 10*time.Minute) |
实现原理很简单,就是设置一个定时器,定期去执行清空操作。runJanitor是我们的定时器,runtime.SetFinalizer的目的是为了如果发生内存不足,触发GC操作的时候,主动释放Cache。
1 | func newCacheWithJanitor(de time.Duration, ci time.Duration, m map[string]Item) *Cache { |
j.Run(c)启动定时器。触犯定时调用DeleteExpired。清除过期数据。
1 | func runJanitor(c *cache, ci time.Duration) { |
DeleteExpired使用读写锁,遍历所有item。找到过期缓存直接delete掉。
1 | // Delete all expired items from the cache. |
代码很容易读。更详细的可以直接查看go-cache源码。