从业务层面上的堆数据库下性能瓶颈的解决方案:
分库分表、读写分离
程序员修神之路--略懂数据库集群读写分离而已
缓存
缓存 (Cache):本质是数据交换的一段缓冲区,也可以称为一种存储数据的组件,主要用于减小数据交换双方速度不匹配的问题。
缓存在计算机世界里是一个常见并且不可忽视的一个重要因素,几乎遍布于各个领域。例如 CPU 的一级缓存,二级缓存;浏览器的缓存等。使用缓存时要认识到缓存的数据具有有效期,也就是说可能随时会消失。虽然类似 radis 这些组件都提供数据持久化的功能,这样数据就不会消失。但还要考虑两点:
- 当组件提供持久化功能时,必然会发生磁盘的 IO 操作,而磁盘 IO 的操作必然会大大降低缓存组件的性能,那缓存的价值还有吗?
- 缓存的数据在时间定义上是一种临时性的数据,如果做了持久化,这种临时性的意义就不存在了,而且还占用了磁盘的存储空间
缓存的常见存储介质为内存,但这并不意味只有内存可以存储缓存数据。缓存的作用是提供高速的读写功能,所以如果设备足够快,理论上都可以作为缓存使用,比如现在的 SSD,在一些性能不太严格和敏感的场景下就可以作为存储缓存数据的介质。
缓存应用场景
理论上,任何需要提高访问速度的环节都可以加入缓存。但系统加入缓存模块会在一定程度上增加系统的复杂度,所以在是否引入缓存的问题上,需要根据业务场景来平衡。一般符合以下几种特征的数据可以考虑引入缓存模块:
- 数据很少变动:最适合缓存,因为基本不涉及缓存的更新操作,只需将数据加载到缓存即可。
- 🌰 网站用到的 js,css 等静态资源,用户登录之后生成的 session 信息等。
- 🌰 CDN 服务:很多大型网站都会利用CDN来加速一些不变资源的访问速度,比如一些图片,视频等。由于用户访问这些资源的本源需要跨越多个主干网,在速度上较慢,而 CDN 恰恰弥补了这个缺陷,所以这里可把 CDN 看成是一种缓存的服务。
- 热点数据:热点数据最大的特点是发生时间不定,流量峰值不定,最有可能导致系统瘫痪,是开发中要加缓存的主要原因。
- 热点数据的缓存不容易设计,因为带有单点属性。🌰 假设缓存服务器有100个节点,此时发生了某个热点新闻,该新闻的缓存在0号节点,大量的请求会被路由到0号节点,很有可能会导致0号节点垮掉,如果0号节点垮掉,基于故障转移策略,流量瞬间会转移到另外一个节点,然后这个节点会垮掉,以此类推。缓存虽然提高了系统的整体吞吐量,但在应对有针对性的流量高峰时需要单独针对。这也是分布式系统要解决的问题,以上的热点数据场景,最简单粗暴的方法是缓存副本,一份缓存数据存多份副本,类似于MySQL的读写分离方案,多份副本同时提供读取操作。除此之外,这种场景下我推荐使用进程内缓存代替分布式缓存,因为进程内缓存在访问速度上要比需要跨越网络的分布式缓存要快很多。
- 耗时操作:若数据的计算代价或者获取代价很大,但不太会频繁变动 or 变动较频繁但系统对数据的一致性要求不高,则也适合进行缓存。
缓存的淘汰
当缓存数据大于缓存介质容量时,需要一种缓存替换算法来淘汰旧数据,保证新数据能正常缓存。
现在主流的有以下几种淘汰策略:
- LFU (Least Frequently Used):缓存系统会记住每条缓存数据被访问的频率,会优先淘汰最不常用的数据。
- LRU (Least Recently Used): 缓存系统会记住每条数据最后的访问时间,会优先淘汰长时间未被访问的数据
- ARC (Adaptive Replacement Cache):这个缓存算法同时跟踪记录LFU和LRU,以及驱逐缓存条目,来获得可用缓存的最佳使用,它被认为是性能最好的缓存算法之一,介于LRU和LFU之间,能够记忆效果和自调,当然开发肯定会比较复杂。
- FIFO:基于队列的原理的淘汰算法,先进先出。这种算法比较简单,现实中使用比较少是因为这种业务场景比较少。
缓存实现方式
进程内缓存
指缓存和应用程序在同一个进程内,在获取缓存数据时无需跨越网络。所以是访问速度最快的一种方式。
进程内缓存一般用在单机或者小型系统中,但是,在整体架构实现了一致性的前提下,也可用于大型系统,🌰 在多个服务器节点的情况下,假如用户A的信息缓存在0号节点,如果有一种机制能保证用户A的所有请求都只会到达0号节点,这个时候利用进程内缓存就完全没有问题。最典型的像Actor模型应用。
进程外缓存
指缓存数据和应用程序是隔离的,位于不同的进程内。
这里又可把进程外缓存划分为单机版本和分布式版本,单机版本会存在单机故障问题。分布式版本通常被称为分布式缓存,是基于分布式理论的一种架构模式。它突破了单机缓存的容量限制和单机故障问题,虽然在访问速度上比进程内缓存要慢很多,但是相比较磁盘 IO 操作要快的多,所以现在很多大型系统都喜欢用分布式缓存来提高性能。像用的最多的Redis在3.0版本之后就提供了集群方案。
缓存的一些不足:
- 缓存和数据源的一致性问题
- 缓存命中问题
- 缓存的雪崩穿透问题
- 缓存的并发竞争问题
- 缓存适合读多写少的系统
- 引入缓存组件会给系统设计带来一定的复杂度
- 缓存会加大运维的成功以及排查bug的成本
缓存一致性
数据一致性问题是分布式系统不可避免的一个痛点,虽然可利用分布式事务做到数据一致性,但在实际系统架构设计中,还是推崇尽量避免分布式事务。缓存和数据库数据的一致性,在产生原理上,和分布式数据的一致性类似。凡是处于不同物理位置的两个操作,如果操作的是相同数据,都会遇到一致性问题。
系统最常见的操作流程:
- 数据的请求首先查询缓存中是否存在该数据
- 若数据命中缓存(在缓存中存在)则直接返回数据;若数据没有命中缓存(缓存中不存在),则去数据库中取数据
- 从数据库中取回数据,然后把数据写入缓存
可见对数据库的操作和对缓存的操作是两个不同阶段的操作,在任何一个操作过程中都会发生线程安全问题。🌰 两个线程同时查询缓存时,可能两个线程都没有命中缓存,就会同时查询数据库,然后两个线程同时回写缓存。并发读操作,在多数情况下缓存和数据库数据还能保持一致,若并发写操作,更有可能导致缓存和数据库数据的不一致。🌰 以最常见的用户积分场景为例,每个用户都有自己的积分,发生以下过程:
- 线程A根据业务会把用户id为1的积分更新成100,线程B根据业务会把用户id为1的积分更新成200
- 在数据库层面,线程A和线程B肯定不存在并发情况,因为数据库用锁来保证了ACID(假如是mysql等关系型数据库),无论数据库中最终的值是100还是200,我们都假设正确。假设更新数据库的顺序是先A后B,则数据库中的值为200
- 线程A和线程B在回写缓存时,很可能操作缓存的顺序是先B后A(因为网络调用存在不确定性),这个时候缓存内的值会被更新成100,发生了缓存和数据库不一致的情况
可见,上述出现缓存和数据库数据不一致的根本原因是,多个线程同时操作数据,且对缓存和数据库数据更改的两个操作不是原子的。因此根本的解决发方案是:将两个操作合并成逻辑上的一个原子操作,or 相同数据只允许一个线程操作。
分布式锁
利用分布式锁将对缓存和数据库数据的两个操作封装为逻辑上的一个操作。具体流程为:
- 每个想要操作缓存和数据库的线程都必须先申请分布式锁
- 如果成功获得锁,则进行数据库和缓存操作,操作完毕释放锁
- 如果没有获得锁,根据不同业务可以选择阻塞等待或者轮训,或者直接返回的策略
分布式锁在一定程度上会降低系统的性能,而且分布式锁的设计要考虑到down机和死锁的意外情况。而最常见的分布式锁的实现方式就是利用 redis。
PS:利用分布式锁也是解决分布式事务的一种方案。
单线程
发生缓存和数据库不一致的原因在于多个线程的同时操作,如果相同的数据始终只会有一个线程去操作,也能避免不一致的情况。🌰 nodejs,可以充分利用 nodejs 单线程的优势;Actor模型,Actor模型在对于同样的对象上可以看做是单线程模式。
单线程的模式基本上和分布式锁的方案类似,只不过单线程不需要锁就可以实现操作的顺序化,这也是单线程的优势所在。
删除缓存
相比于分布式锁的方案,实际更常用删除缓存的方式:在可能发生数据不一致的场景下,以数据库为主,操作完数据库后,不更新缓存而是删除缓存。
这在一定意义上相当于只操作数据库,把需要维护的两个数据源变成了一个数据源。
❗️这种方案利用缓存的过期时间来保证数据的最终一致性,因此可在一些能容忍数据暂时不一致的场景下采用此方案。
该方案的缺点是:若相同的数据被频繁更新,则缓存会被频繁删除,当有读请求的时候又会被频繁的从数据库加载,缓存命中率不高。所以这种方案更适用于那种对缓存命中率不敏感的系统。
其他方案
以缓存为主,应用程序只和缓存组件通信,持久化数据库由缓存组件负责。
该方案还需考虑:
- 数据从缓存持久化到数据采用什么样的解决方案,是同步进行还是异步进行呢?
- 在新数据请求的时候,如果缓存不存在,要采用什么样的方式来填充数据?
- 如果缓存模块挂掉了该怎么办?
以缓存为主的方案的优势是数据优先进入IO速度快的设备,对于那些请求量大,但是可以容忍一定数据丢失的应用非常合适,🌰 应用 log 数据的收集系统,这种系统其中一个最大的特点就是可以容忍一定数据的丢失,但是并发的请求数会非常大。所以可利用缓存设备前置的方案来应对这种应用场景。
缓存设计问题
在一个高并发系统中,核心功能的缓存命中率一般要保持在90%以上甚至更高,如果低于这个命中率,整个系统可能就面临着随时被峰值流量击垮的可能。
如果按照传统的缓存和DB的流程,一个请求到来的时候,首先会查询缓存中是否存在,如果缓存中不存在则去查询对应的数据库。假如系统每秒的请求量为10000,而缓存的命中率为60%,则每秒穿透到数据库的请求数为4000,对于关系型数据库mysql来说,每秒4000的请求量对于分了一主三从的Mysql数据库架构来说也已经足够大了,再加上主从的同步延迟等诸多因素,这个时候你的 mysql 已经行走在 down 机边缘了。
缓存的最终目的,是在保证请求低延迟的情况下,尽最大努力提高系统的吞吐量。
缓存系统的设计中可能导致系统崩溃的原因有:缓存穿透、缓存雪崩
缓存穿透
缓存穿透:当一个请求到来的时候,在缓存中没有查找到对应的数据(缓存未命中),业务系统不得不从数据库(这里其实可以笼统的成为后端系统)中加载数据。
发生缓存穿透的原因:
- 请求的数据在缓存和数据中都不存在:此时若按照一般的缓存设计,每次请求都会到数据库查询一次,然后返回不存在,这种场景下,缓存系统几乎没有起任何作用。
- 在正常的业务系统中,发生这种情况的概率比较小,就算偶尔发生,也不会对数据库造成根本上的压力。但若系统中有死循环的查询 or 被黑客攻击,故意伪造大量的请求来读取不存在的数据而造成数据库的down机,🌰 如果系统的用户id是连续递增的int型,黑客很容易伪造用户id来模拟大量的请求。
- 请求的数据在缓存中不存在,在数据库中存在:这是正常情况,因为缓存的容量有限,不可能把所有业务数据都放到缓存。优先把访问最频繁的热点数据放入缓存系统,就能利用缓存的优势抗住主要的流量来源,而剩余的非热点数据,就算是有穿透数据库的可能性,也不会对数据库造成致命压力。
发生缓存穿透是不可避免的,我们能做的是尽量避免大量的请求发生穿透。解决缓存的穿透问题本质上是要解决怎么样拦截请求的问题,一般情况下会有以下几种方案:
回写空值
当请求的数据在数据库中不存在的时候,缓存系统可以把对应的key写入一个空值,这样当下次同样的请求就不会直接穿透数据库,而直接返回缓存中的空值了。
该方法要注意几点:
- 当有大量的空值被写入缓存系统中,同样会占用内存,不过理论上不会太多,完全取决于key的数量。而且根据缓存淘汰策略,可能会淘汰正常的数据缓存项。
- 空值的过期时间应该短一些,比如正常的数据缓存过期时间可能为2小时,可以考虑空值的过期时间为10分钟,这样做一是为了尽快释放服务器的内存空间,二是如果业务产生相应的真实数据,可以让缓存的空值快速失效,尽快做到缓存和数据库一致。
//获取用户信息
public static UserInfo GetUserInfo(int userId){//从缓存读取用户信息var userInfo = GetUserInfoFromCache(userId);if (userInfo == null){//回写空值到缓存,并设置缓存过期时间为10分钟CacheSystem.Set(userId, null,10);}return userInfo;
}
bloom filter
bloom filter:将所有可能存在的数据通过多种哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力。
bloom filter 优势:占用内存非常小,判断一个数据不存在100%正确。
缺点:会存在假阳性,不支持删除数据。
缓存雪崩
缓存雪崩:指缓存中数据大批量同时过期,造成查询数据库数据量巨大,引起数据库压力过大导致系统崩溃。
缓存穿透 🆚 缓存雪崩:缓存穿透是指缓存中不存在数据而造成会对数据库造成大量查询,而缓存雪崩是因为缓存中存在数据但同时大量过期。二者本质相同,都是对数据库造成了大量的请求。
多个缓存key同时失效的场景是产生雪崩的主要原因,有以下几种解决方案:
设置不同过期时间
给缓存的每个key设置不同的过期时间是最简单的防止缓存雪崩的手段,整体思路是给每个缓存的key在系统设置的过期时间之上加一个随机值,或者干脆是直接随机一个值,有效的平衡key批量过期时间段,消掉单位之间内过期key数量的峰值。
public static int SetUserInfo(int userId){//读取用户信息var userInfo = GetUserInfoFromDB(userId);if (userInfo != null){//回写到缓存,并设置缓存过期时间为随机时间var cacheExpire = new Random().Next(1, 100);CacheSystem.Set(userId, userInfo, cacheExpire);return cacheExpire;}return 0;
}
后台单独线程更新
这种场景下,可以把缓存设置为永不过期,缓存的更新不是由业务线程来更新,而是由专门的线程去负责。当缓存的key有更新时候,业务方向mq发送一个消息,更新缓存的线程会监听这个mq来实时响应以便更新缓存中对应的数据。不过这种方式要考虑到缓存淘汰的场景,当一个缓存的key被淘汰之后,其实也可以向mq发送一个消息,以达到更新线程重新回写key的操作。
缓存的可用性和扩展性
和数据库一样,缓存系统的设计同样需要考虑高可用和扩展性。虽然缓存系统本身的性能已经比较高了,但是对于一些特殊的高并发的热点数据,还是会遇到单机的瓶颈。🌰 假如某个明星出轨了,这个信息数据会缓存在某个缓存服务器的节点上,大量的请求会到达这个服务器节点,当到达一定程度的时候同样会发生down机的情况。类似于数据库的主从架构,缓存系统也可以复制多分缓存副本到其他服务器上,这样就可以将应用的请求分散到多个缓存服务器上,缓解由于热点数据出现的单点问题,提高可用性。
和数据库主从一样,缓存的多个副本也面临着数据的一致性问题,同步延迟问题,还有主从服务器相同key的过期时间问题。
至于缓存系统的扩展性同样的道理,也可以利用“分片”的原则,利用一致性哈希算法将不同的请求路由到不同的缓存服务器节点,来达到水平扩展的要求,这一点和应用的水平扩展道理一样。
关于一致性hash
参考链接
程序员修神之路--听说你会缓存?
程序员修神之路--谈了千百遍的缓存数据的一致性问题
程序员修神之路--缓存架构不够好,系统容易瘫痪
程序员修神之路--高并发下为什么更喜欢进程内缓存