从这篇文章开始,我们将学习缓存在架构中如何使用,主要解决数据库读取频繁的问题。
一、案例
现在我们有一个电商系统,系统同每个商品都有详情页,详情页内容包含:图片、价格、详情、评价、优惠活动、类似商品以及近期交易情况等。一个页面需要执行十几条sql语句,如果详情页后期再增加别的内容的话,需要执行的sql语句有可能多大几十条,那么这时详情页就会打开的很慢。 为了解决这个问题我们首先想到的是将数据存储到本地缓存中,商品Id作为key,详情作为value,用户获取商品详情时,根据id从本地缓存中取。但是这里有个问题,服务器的本地缓存是有限的,比如一条缓存占用空间大小为500K,一共5万条数据,那么一共需要25G左右的内存,那如果是50万条、500万条数据呢?使用本地缓存这个方法显然不行,那么我们可以使用分布式缓存,将所有的缓存数据统一存储在一个地方,所有的应用服务器从这个地方读取数据,就这么简单。既然已经确认使用的缓存技术,那么下一步就该选择使用什么中间件了。
二、中间件选型
目前使用较多的缓存中间件有:Memcached、MongoDB和Redis。我们对缓存中间件选型应从数据结构、是否支持持久化、是否支持集群以及性能方面进行考虑。下面我们就来具体分析一下这三个中间件的这四个方面。
- 数据结构:Memcached的数据结构是简单的Key-Value结构,它存在一个问题如果在其中存入的是List类型的数据,那么每次在向这个List更新一条数据时都会取出整个List,序列化后将更新的数据写入,然后再次反序列化存入Memcached中。Redis 的数据结构支持 string、list、set、hash、bitmap等类型,更新List数据只需要发送一个请求即可,Redis会帮助我们将数据更新到List中。MongoDB 支持的数据结构非常松散,是类似json的bson格式,因此可以存储复杂的数据类型。
- 持久化:如果操作系统挂掉了,那么存储在 Memcached 的数据就会随即丢失,虽然目前最新的版本中增加了可重启缓存,但是这是也是在正常重启的情况下才会保证数据不丢失,非正常情况下数据依然会丢失。Redis和MongoDB支持持久化,即使是系统宕机挂掉了,数据丢失的可能性很低。
- 集群:Memcached的集群设计比较简单,客户端根据hash值来判断应该访问哪个Memcached节点。Redis的集群设计比较复杂,因为它兼顾了高可用、主从、冗余等方面。MongoDB 有三种集群部署模式,分别为:主从复制(Master-Slaver)、副本集(Replica Set)和分片(Sharding)。
- 性能:在实际项目中发现MongoDB的性能与Memcached和Redis的性能来说较低。
根据以上四个方面的对比,我们最终选定 Redis作为缓存技术中间件。
TIP:在实际开发中应该根据具体情况来选定使用哪种缓存中间件技术,本文的案例比较适合Redis。
三、缓存数据存在的问题
我们在使用缓存数据的时候会出下三个问题:
- 缓存击穿
- 缓存雪崩
- 缓存穿透
这三个问题都发生在大量并发请求出现,并且缓存中没有数据或者存储的数据过期,每个请求都需要从数据库中读取数据并将新数据存储在缓存中的情况。下面我们来看一下这三个问题如何解决。
3.1 缓存击穿
所谓缓存击穿就是指某个数据过期或者不存在,请求向数据库读取数据。 要解决这个问题,我们可以在第一个线程发现要访问的Key不存在时,就给这个Key枷锁,然后再从数据库中读取数据并将数据保存在缓存中,最后再释放这个Key上的锁。如果有其他线读取这个Key,就必须等Key上的锁释放后才能继续读取,具体加锁方案可以参考前面的文章来实现。
3.2 缓存雪崩
所谓缓存雪崩指的是数据大面积过期或者Redis宕机,所有请求都去访问数据库。 要解决它,我们可以设计缓存过期时间为随机分布,甚至可以设置永不过期(当然,永不过期这种方式不推荐)。
3.3 缓存穿透
所谓缓存穿透指的是请求要求访问一个及不存在于缓存中也不存在于数据库中的数据。 一般缓存穿透时恶意请求造成的,要解决这个问题我们可以在数据库不被访问的情况下过滤掉不存在的key,或者也可以给不存在的key在缓存中存入空值。
四、缓存预热
上面说的三个是问题,下面我们载说一下缓存预热。所谓的缓存预热就是在用户请求数据前将数据存储在缓存中。 我们可以在每天访问量小的时间段内将数据库数据存储到缓存中,这样当大量的请求到来时就可以直接从缓存中读取数据,而不用从数据库中读取数据,减轻了数据库的压力。
五、怎么更新
更新缓存一共两部:更新数据库、更新缓存。在更新缓存时我们应该考虑四个问题:
- 先更新数据库还是先更新缓存
- 更新缓存时删除后再添加新的数据还是直接更新数据?
- 如果第一步成功但第二步失败了怎么处理?
- 多个线程更新同一个数据该怎么处理?
怎对上面的问题给出了五种解决方法。
5.1 先更新缓存,再更新数据库
因为 Redis 不支持回滚事务,因此如果数据库更新失败,那么我们就必须手动回滚Redis里的数据。具体做法如下:
- 先保存旧数据;
- 然后将新数据跟新到缓存中;
- 接着将新数据更新到数据库中;
- 如果数据库更新失败就将缓存中的数据还原回原来的数据。
这种做法存在一个问题,例如缓存中的值是1,线程A保存原值1,并将缓存中的值改为2,然后去更新数据库,当线程A已经更改完缓存但还没开始更新数据库时,线程B又来更新了,它将缓存中的值2保存起来,然后将缓存中的值改为3,并将值3更新进了数据库。这时线程A更新数据库失败了,就要执行缓存的回滚操作,但是原本保存的值1已经被线程B变成了2,那么线程A执行缓存回滚的时候就会将缓存中的值改为2。但是呢,线程B已经更新成功数据库,这时的数据库值就和缓存中的值不对应了,怎么办?一定有小伙伴说那就对缓存和数据库加锁,然其他线程暂时不能访问。这种方法固然可行,但是这也出现了其他线程能不能读取数据的问题。例如线程A更新数据库失败,准备回滚缓存数据,这时线程B要访问缓存的值,它获取到的值是什么值?以上场景就是我们经常说的***事务隔离级别场景***。因为这个方法在我们只需要访问缓存的情况下就去考虑事务隔离级别的逻辑,成本太高,因此放弃此方法。
5.2 先删除缓存,再更新数据库
这个方法的优点是在数据库更新失败后不需要回滚缓存,但是却引发了两个严重的问题:
- 线程A删除缓存后,开始更新数据库,但是线程B在线程A更新完数据库之前读取缓存中的值,发现Key没有在缓存中,于是访问数据库并将Key对应的值存储在缓存中,这时线程A完成了对数据库的更新。那么这就出现了缓存中的值时旧值,数据库中的值是新值的情况。
- 为解决第一个问题,我们可以给Key加锁,但是向数据库写的操作是一个很费时的操作,如果这时有大量的读这个Key的请求进来就会不断的堆积。
这种方法也不合适,废弃掉。
5.3 先更新数据库,再更新缓存
这个方法的优点也是在数据库更新失败后不需要回滚缓存。但是我们考虑这么一种情况,数据库更新成功了,但是缓存没更新成功,怎么办?这里我们一般会加入重试机制,但是如果使用的重试机制存在延迟的情况,那么也会出现缓存和数据库的数据不一致的情况。 另一个问题,线程A和B同时更新同一个数据,但是线程A先完成了数据库更新,将数据库的值变成了2,之后线程B有将数据库的值变成了3,但是线程B又先于线程A完成对缓存的更新,那么这就出现了线程A将缓存中的值改为了2,但是数据库中的值确是3的情况。 因此这个方法废弃。
5.4 先更新数据库,再删除缓存
这个方法降低了5.3中提到的i两个问题出现的概率,但是依然存在一个小问题,当线程A更新完数据后,但没有删除缓存前,线程B来访问缓存,这时线程B获取到的数据就是旧数据。要降低这个问题出现的概率我们可以看一下下面这个方法。
5.5 先删除缓存,再更新数据库,最后再删除缓存
这个方法降低了上一个方法中所提的问题出现的概率,因为在线程A删除缓存后,更新数据库前如果线程B访问缓存发现缓存中不存在Key的数据,会将数据存数据库中迁移到换从中,这时另一个线程C也来访问缓存,那么它访问的数据就是线程B迁移的数据,但是这个方法在更新完数据库再次删除缓存,这样就避免了后续线程访问缓存时访问到旧数据的问题,进而降低了访问到旧数据的概率。
Tip:
- 本小结所说的删除缓存,就是将对应的数据删除,而不更新,让后续线程对缓存更新;
- 并不是每个项目都适合方法五,要根据实际项目来决定
六、高可用设计
在进行高可用设计时,应遵循如下五点:
- 负载均衡:可以通过增加节点来降低读请求压力;
- 分片:划分不同节点降低读请求压力;
- 数据冗余:某个或多个节点失效后,其他节点可以承担失效节点的职责;
- Failover:节点失效后,集群可以重新分配职责,帮正集群工作正常;
- 保证一致性:在数据迁移或者数据冗余过程中,如果出现失败的情况,应保证缓存节点数据和数据库数据以及各节点之间的数据完全一致。
七、缓存监控
缓存监控就是在缓存上线后,定时查看缓存的使用情况,判断缓存是否需要优化。可以从以下几个方面查看缓存的使用情况:
- 命中率
- 内存使用率
- 慢日志
- 延迟时间
- 客户端链接数量
具体的缓存监控工具,根据使用不同的缓存中间件来选择,在这里不做讨论。
八、总结
以上内容时目前大多是系统所使用的方案,但是它只是针对大量读请求的情况,对于对于大量写请求的请情况,我将在下一篇文章讲解。