《服务器开发技术、方法与实用解决方案》
在系统刚启动或活动刚开始时,如果缓存中没有数据,那么大量请求将直接访问数据库。如果瞬时访问流量巨大,则可能导致数据库因过载而宕机,甚至引发系统雪崩。因此需要将缓存的数据加载到缓存系统中,以减轻数据库的压力
本地缓存,在应用启动时进行缓存预热。如在Spring中实现InitializingBean接口中的afterProperitesSet方法实现
但是该策略仅适合预热本地缓存,且与应用深度耦合
如果预热场景涉及的数据规模非常大,可以采用任务调度预热
使用分布式任务调度中间件定时、周期触发缓存预热数据。常用的中间件有阿里的SchedulerX、蚂蚁的AntScheduler、XXL-JOB、ElasticJob
分布式任务调度中间件的基本架构:
预热步骤
周期调度:数据规模巨大时,无法通过一次调度完成缓存预热,可以采用周期调度,指定每个调度周期预热的数据量。为了避免在数据加载环节重复服务数据,需要对已预热过的数据打标,如更新预热时间、预热版本号等。在加载数据时,通过标记来识别、加载数据
任务调度预热和应用启动预热都需要定制开发,对业务应用有入侵。
可以采用模拟请求预热,通过模拟用户请求来实现数据预热,如模拟调用数据查询接口。该方式对业务应用的侵入非常小
缓存大多是基于内存实现的,空间相对有限。为了高效利用存储空间,当缓存触及设定的空间上限时,通常需要借助淘汰算法将部分数据移除
LRU(最近最少使用)算法根据数据的历史访问记录优先淘汰最近未被使用的数据,核心思想为:如果数据最近被访问过,那么将来被访问的频率会更高。
实现一般采用Hash表+双向链表,将被访问的数据放到链表头部,淘汰链表尾部的数据
LRU算法简单、高效,但是一些偶发性、周期性的批量操作会导致LRU算法的缓存命中率急剧下降。如一次性读取大量数据块,这些数据块会占用大量空间,并淘汰最近最少使用的数据。但是这些数据并非是经常被访问的数据,从而导致缓存命中率低下
LFU(最近最不常用)算法根据数据的历史访问频次来淘汰数据,核心思想为:如果数据最近被使用的频率高,则将来大概率会被再次使用
LRU倾向于保留最近使用的数据,LFU倾向于保留使用频率较高的数据。LFU能避免偶发性情况对命中率的影响,但是一旦访问内容发生较大变化,LFU需要更长的时间来适应,因为历史的频次记录会使已无用的数据保留较长一段时间
ARC(自适应缓存替换)算法兼具LRU和LFU的优点,既可以根据时间进行优化,缓存最近使用的数据,又可以根据频率进行优化,同时还能根据负载来灵活调整缓存策略。ARC的命中率要显著优于LRU和LFU。
实现原理见P218
- 是否可以先更新缓存,再更新数据库?
一旦出现缓存成功而更新数据库失败的情况,会导致缓存错误值,导致业务上无法接受的一致性问题- 是否可以先失效缓存,再更新数据库?
通常数据库的写操作耗时大于读操作,一旦更新数据库期间有读请求并未命中缓存,会导致写回的数据为脏数据- 为什么是失效缓存,而不是更新缓存?
如A、B两个线程都执行写操作,A线程早于B执行(B数据是最新的),但A线程晚于B线程更新缓存,这样就会导致缓存中的数据是A线程更新的旧值,而不是B线程更新的最新值- 先更新数据库,再失效缓存是否存在问题?
可能存在更新数据成功,失效缓存失败,导致缓存脏数据的问题。这种场景最常用的解决方案是为缓存设置失效时间,到期自动失效
事实上,保证缓存和数据库的强一致性是非常困难的,一般须通过Paxos或2PC来保证一致性,但这些协议耗时或实现复杂,实用性不高。在大多数使用缓存场景中,通常有一个重要前提,可接受弱一致性
Read/Write Through 模式将更新数据库的操作交由缓存代理,从应用的角度看,缓存是主存储,应用层的读写操作均面向缓存,缓存服务代理对数据库的读写
Read/Write Through 模式同步更新数据库和缓存,而Write Behind Caching只更新缓存,缓存会异步批量更新数据库(类似Linux的Writeback机制)
但是该机制无法保证数据强一致性,且可能会丢失数据(没有完美的机制)
缓存雪崩是指缓存因为某些原因(如大量key集中过期、服务器宕机等)导致大量查询请求到达服务端数据库,造成数据库压力骤增、甚至宕机、进而引起整个系统崩溃的现象
为缓存设置较长的有效期,保证缓存在业务高峰期一定不会过期
本地缓存作为一级缓存,分布式缓存作为二级缓存。多级缓存相结合,可以有效分担查询请求压力,防止雪崩
如果以上策略均失效,在高并发场景下大量Key依旧集中过期或热点Key过期,则需要控制通过查询数据库重建缓存的请求量,常用策略是加锁和限流
若缓存为命中,则对Key加锁,只有获得锁的线程才能访问数据库并加载数据重建缓存,最后释放锁。未获得锁的线程直接返回空结果或休眠一段时间后重试
缺点是不能保证系统的吞吐量,有损用户体验
缓存穿透是指查询一条缓存和数据库都不存在的数据,会导致查询这条数据的请求会穿透缓存,直接查询数据库,最后返回空。如果用户发起大量请求去查询这条根本不存在的数据,则会对数据库造成极大压力
未不存在的Key缓存一个空值,对于之后的请求会命中缓存中的空值,直接返回空数据,减少对数据库的访问。
缺点:
布隆过滤器可以在不查询数据库的情况下准确地判断数据是否一定不存在,从而过滤请求
布隆过滤器由一个固定大小的二进制向量(或位图)和一系列映射函数组成。元素被加入到布隆过滤器中时,该元素经k个哈希函数生成k个哈希值,然后将位图对应的k个点置为1。
如果需要判断某个元素是否在布隆过滤器中,只需对给定元素进行相同的哈希计算得到k个哈希值,如果有任何一个点为0,则说明这个元素一定不在布隆过滤器中
提前将真实存在的数据的Key(如商品Id)添加到布隆过滤器中,用户查询时,若不存在,则直接返回;若存在,继续查询缓存,缓存未命中则进一步查询数据库
实现方案:
缺陷:
布谷鸟过滤器支持删除元素,但是存在误删的概率,且插入操作的复杂度高,且会随着插入元素的增加而增加
当大量用户集中访问热点数据时,压力将汇聚于单一缓存服务器实例,可能导致热点数据所在的服务器过载,缓存服务不可用
将热点数据提前缓存在应用服务器的本地内存中,对于数据查询请求,首先查询本地缓存,命中则直接返回
缺陷:
多副本策略,将热点数据复制多份,分别缓存在多台缓存服务器上,减轻缓存热点导致的单台缓存服务器压力
实践中,散列数量一般与分布式缓存集群的服务器数量相等。
实现方案,如Redis Cluster