(一)读缓存
冷热分离、查询分离、分库分表虽然解决了单个请求在大数据量下查询速度慢的问题,以及写入数据快的问题。但是并没有解决流量非常大的问题。假如突然来了很大一批请求,那么就会拖垮数据库。读缓存则是进一步优化查询效率,同时防止数据库宕机。关于缓存最常见的方法是本地缓存,google guaua种有一个内存缓存模块,但是需要占用JVM内存。还有一种是分布式缓存,常见的分布式缓存中间件有Memcached, MongoDB,redis.
MongoDB因为其读写速度相比其他数据库比较快一般被作为数据库使用。作为缓存一般还是使用Memcached和redis, 但是redis更流行,原因有三:
数据结构: redis数据结构更丰富,且往数据结构中存放对象更快。比如使用Memcached保存list缓存的对象,在新增一个数据时,Memcached需要读取整个list,再反序列化后写入数据,接着序列化存储回MemCached. 对于redis,可以直接塞入数据并存储,简单快捷。
持久化: Memcached不支持持久化,对于意外宕机Memcached会丢失数据,但是Redis是有持久化功能的,其中的AOF持久化可以根据配置设置更新文件的落盘时机。
集群: Memcached的集群设计非常简单,客户端根据Hash值直接判断存储的节点。Redis的集群在高可用,主从,冗余,Failover等方面都有所考虑。
何时存储缓存
查询请求使用缓存的逻辑:先尝试从缓存中读取数据。2)若缓存中没有数据或者数据过期,再从数据库中读取数据保存到缓存中。3)最终把缓存数据返回给调用方。
这种逻辑唯一麻烦的地方是,当用户发来大量的并发请求时,它们会发现缓存中没有数据,那么所有请求会同时挤在第2步,此时如果这些请求全部从数据库读取数据,就会让数据库崩溃。
数据库的崩溃可以分为3种情况:
单一数据过期或者不存在【热点数据失效】,这种情况称为缓存击穿。解决方案:第一个线程如果发现Key不存在,就先给Key加锁,再从数据库读取数据保存到缓存中,最后释放锁。如果其他线程正在读取同一个Key值,那么必须等到锁释放后才行。
数据大面积过期或者Redis宕机,这种情况称为缓存雪崩。解决方案:设置缓存的过期时间为随机分布或设置永不过期即可。
一个恶意请求获取的Key不在数据库中,这种情况称为缓存穿透。解决方案:①在业务逻辑中直接校验,在数据库不被访问的前提下过滤掉不存在的Key,这种可以考虑使用使用布隆过滤器,既然缓存和数据库都没有,干脆就挡在最外面。②针对恶意请求的Key存放一个空值在缓存中,防止恶意请求骚扰数据库。
如何更新缓存
1 先更新缓存,再更新数据库:
先更新缓存,在更新数据库,更新数据库的时候,需要进行回滚,数据库有回滚机制,但是redis缓存不支持事务回滚,这样就需要redis把之前的值先保留一份,以便下次回滚,
但是假如在redis回滚之前,另一个线程进行读操作,那么这个时候就可能返回的是更新后,但是还没来得及回滚的值。
2 先更新数据库,再更新缓存:
两个线程同时操作,A线程先更新数据库,B线程更新数据库,再更新缓存,A最后更新缓存,A线程更新的数据库被B线程覆盖,B线程更新的缓存被A线程覆盖,造成数据库与缓存的不一致性
3 先删除缓存,在更新数据库
先删除缓存,后更新数据库,假如线程A把缓存删除了,但是还没来得及更新数据库,但是另一个线程也进行读操作时,就会读到数据库的旧值,并且把它放回了缓存,之后A更新了数据库,这样就容易造成缓存与数据库的不一致。同时读的数据也是脏数据
4 先更新数据库,再删除新缓存
线程A先更新了数据库,但是线程B要访问缓存,这是缓存还没来得及被线程A删除,因此返回的是旧值,但是数据库的值已经被更新了,那么仍然存在数据不一致的问题。
5 先删除缓存,再更新数据库,再删除缓存
这个方案其实和先更新数据库,再删除缓存差不多,因为还是会出现类似的问题:假设线程A要更新数据库,先删除了缓存,这一瞬间线程C要读缓存,先把数据迁移到缓存;然后线程A完成了更新数据库的操作,这一瞬间线程B也要访问缓存,此时它访问到的就是线程C放到缓存里面的旧数据。
不过组合5出现类似问题的概率更低,因为要刚好有3个线程配合才会出现问题(比先更新数据库,再删除缓存的方案多了一个需要配合的线程),出现的机率更低,因此是一个比较不错的选择。
6 基于订阅binlog的同步机制异步更新缓存(最终一致性)
缓存的高可用设计
设计高可用方案时,需要考虑5个要点。
1)负载均衡:是否可以通过加节点的方式来水平分担读请求压力。
2)分片:是否可以通过划分到不同节点的方式来水平分担写压力。
3)数据冗余:一个节点的数据如果失效,其他节点的数据是否可以直接承担失效节点的职责。
4)Failover:任何节点失效后,集群的职责是否可以重新分配以保障集群正常工作。
5)一致性保证:在数据冗余、Failover、分片机制的数据转移过程中,如果某个地方出了问题,能否保证所有的节点数据或节点与数据库之间数据的一致性(依靠Redis本身是不行的)。
如果对缓存高可用有需求,可以使用Redis的Cluster模式,以上5个要点它都会涉及。而Redis的哨兵模式由于只有一个主承担写的压力,其它从节点分担读的压力,在扩容和分片时不支持。
缓存的监控:
缓存上线以后,还需要定时查看其使用情况,再判断业务逻辑是否需要优化,也就是所谓的缓存监控。在查看缓存使用情况时,一般会监控缓存命中率、内存利用率、慢日志、延迟、客户端连接数等数据。
(二)写缓存
利用缓存技术和异步加载技术可以将查询数据的速度进行优化,这种适合读数据请求量大或者读数据响应时间慢的场景。而不能应对写数据请求量大的场景,也就是说写请求多时,数据库还是会支撑不住,这种就需要引入写缓存现在有一个场景,需要将每秒2万左右的数据插入到数据库中,显然单节点的数据库承受不了这么高的效率。当然,我们可以通过分库分表来实现,但是这个改动方案比较大,分库分表需要考虑SQL组合,数据库路由、查询结果合并等,对之前的查询业务代码改动比较大,那么还有没有更简单的方案,对写操作进行一个缓冲,原来的业务逻辑不变。这里我们介绍的就是将大量的写操作先放到性能很高的缓冲地带,以此保证洪峰期间先冲击缓冲地带,之后再从缓冲地带异步、匀速地迁移到数据库中。
写缓存的定义:
什么是写缓存?写缓存的思路是后台服务接收到用户请求时,如果请求校验没问题,数据并不会直接落库,而是先存储在缓存层中,缓存层中写请求达到一定数量时再进行批量落库。
这里所说的缓存层实际上指的就是写缓存。它的意义在于利用写缓存比数据库高几个量级的吞吐能力来承受洪峰流量,再匀速迁移数据到数据库。
写缓存实现思路:
具体实施过程中要考虑6个问题。
1写请求与批量落库这两个操作同步还是异步?
写请求如果和批量落库是同步操作,那么写请求的响应时间会很慢,但是实时一致性比较高,后面的查询可以马上看到结果。
如果是异步操作,那么写请求马上可以返回操作成功给用户,但是后面是异步更新数据库。后面的查询可能不能返回最新的数据,实时性不太好。
那么应该使用哪种模式呢?先看下两者的复杂度:
同步的实现原理是写请求提交数据时,写请求的线程被堵塞或者等待,等待批量落库后再发送信号给写请求的线程,这个线程落库成功后,给用户响应。这个过程可能出现以下问题:
用户针需要等待多久?用户不可能无限期等待,此时需要给批量落库设置一个时间窗,比如每100毫秒落库一次。
如果批量落库超时了应该怎么办?写请求不可能无限期等待,此时就需要给写请求线程的堵塞设置一个超时时间。
如果批量落库失败了怎么办?是否需要重试,多久重试一次。
如果写请求一直堵塞,直到成功再返回吗?那需要重试几次?
针对于异步,以上第2和4点不需要考虑。因此异步的实现方式相比同步更简单一点。
2 如何触发批量落库
关于触发批量落库的逻辑可以分三种:
1 写请求满足特定N次就落库,这种方式将访问数据库的频率降低为1/N. 但存在长时间凑不齐N次,此时就无法落库。
2 每隔一个时间窗就落库一次,但是流量太大,大到这个时间窗内的写请求数据库无法承受,也存在风险。当然针对于这种,如果这个时间窗内超过了一定数量,可以采取分批更新。
3 上述两种方式的结合,可以避免方案一数量不足、无法落库的情况,也避免了方案二因为瞬时流量大而使待插入数据堆积太多的情况。
3 缓冲数据存储在哪里
缓冲数据可以存在本地缓存,但是有服务器宕机,数据丢失的风险,也可以考虑存放在redis,当然可以使用MQ来当缓存层,因为MQ本身就是用来进行流量削峰的。MQ的使用本人经常使用,这里就写缓存使用的redis,我们这里继续探讨使用redis作为写缓存的场景。
需要说明的是redis是支持一次从中取多少条数据的。
4 缓存层并发操作需要注意什么
关于多个insert语句同时执行,MySQL官方指出会根据排队情况按顺序执行,但是多个insert语句并行执行未必就比单线程insert快,所以我们只需保证一次只有一个线程执行批量落库即可。
5 批量落库失败了怎么办
批量落库的步骤总共可分为三步:1 当前线程从缓存中获取所有数据;2 当前线程批量保存到数据库; 3 当前线程从缓存中删除对应数据
如果第1步失败了,则交由后面的落库线程自动处理。如果第2步失败了,可以采用事务进行包裹,失败就回滚,下次直接从第一步开始。如果第三步失败了也不用回滚,下次继续执行第1步和第2步,
但是为了保证不插入重复的数据,因此需要第2步保证数据库的幂等。可以使用唯一索引,这样就会自动忽略重复插入(当插入数据时,如果违反了唯一索引的规则,数据库系统会抛出错误并拒绝插入操作)
6 Redis的高可用配置
因为是先将用户的写操作缓存起来,所以必须保证缓存的数据不会出现丢失的情况,这就要求做好Redis的数据备份,即使出现宕机也尽可能少的丢失数据。redis持久化有两种,一种是RDB快照,另一种是AOF。
此外还要考虑单点故障,因此需要用到集群。
写缓存可以缓解写数据请求量大,压跨数据库的风险。但是只能短时缓解,如果写请求长期非常大,这种方案是解决不了的。