服务器缓存,是由计算机硬件系统中的缓存扩展而来,是一种大幅度提高读取速度的机制。在架构设计实践中,缓存往往用于在高并发场景下缓解数据库压力。
目录
1.缓存的应用场景
类似于硬件系统中的缓存,应用架构中的缓存的作用同样是加速读的速度。此外,缓存系统往往独立于数据库,因此缓存与数据库中的数据在到达下一个同步时间节点前,两者的数据可能会产生不一致的情况。综上两点,缓存的应用场景主要是以下两点:
- 系统的读请求数量远大于写请求数量
- 系统对实时数据一致性的要求不是非常高
2.缓存系统设计模式及其问题
根据不同缓存系统在设计时,面对读请求和写请求不同的数据访问、更新操作次序,分为三种模式:
- Cache Aside Pattern
- Read/Write Through Pattern
- Write Behind Caching
2.1 Cache Aside Pattern
Cache Aside Pattern,即缓存旁路模式,是最常用的缓存设计模式。
2.1.1 Cache Aside Read
缓存旁路模式的读请求按照命中与否分为两种,如下图:
- 缓存命中:直接从缓存中读取数据并返回
- 缓存失效:从数据库中读取数据,放到缓存中并返回
2.1.2 Cache Aside Write
缓存旁路模式的写请求,是先更新数据库,再淘汰缓存,如下图:
2.1.3 缓存淘汰与更新的取舍
在缓存旁路模式的写请求中,进行的操作是淘汰缓存,那么为什么不是更新缓存呢?考虑并发写场景,如果是更新缓存的话,可能会发生以下情况:
两个写请求A和B并发执行,在更新数据库时,先执行了A更新后B更新,此时数据库内的数据为B更新后的结果;在更新缓存时,先执行了B更新,后执行了A更新,此时缓存内的数据为A更新后的结果,与数据库内的数据发生不一致。发生这个问题后,在一段时间内(缓存过期,或是下一个写请求到来),调用方获取到的数据全都是脏数据。
2.1.4 先淘汰缓存再更新数据库的问题
如果将缓存旁路模式的写请求的顺序交换,变成先淘汰缓存,再更新数据库会发生什么问题呢?考虑并发读写场景,先淘汰缓存再更新数据库,可能会发生以下情况:
两个请求:写请求A和读请求B并发执行。写请求A先淘汰缓存,随后读请求B访问缓存必然读不到数据,需要从数据库读取数据。随后,读请求B先于写请求A从数据库中读到数据,并将数据放入缓存中,随后写请求B更新了数据库。此时,缓存中为写请求A更新前的数据,而数据库中是写请求A更新后的数据,两者发生数据不一致问题。同样的,在一段时间内,调用方获取到的数据全都是脏数据。
2.1.5 读写并发数据不一致
同样是并发读写的情况,缓存旁路模式似乎也会存在数据不一致问题:
发生了数据不一致的问题,缓存里是读请求B从数据库中读到并写入的旧数据,数据库中是写请求A更新后的数据。
对于数据库而言,读请求(select操作)相较于写请求(update)快得多;在上述案例中,读请求必须先于写请求从数据库中读到数据,再晚于写请求淘汰缓存,同时发生的概率很低。可以通过设置一个较小的缓存过期时间,或是使用数据库日志订阅工具订阅binlog实现延时淘汰缓存,来减少会从缓存中读到脏数据的时间。
2.2 Read/Write Through Pattern
Read/Write Through Pattern,即读/写穿透模式。在读/写穿透模式中,缓存具有更多的“自主权”,服务仅从缓存中取数据而不访问数据库,由缓存自己负责更新、淘汰数据以维持数据一致性。
2.2.1 Read Through
读穿透按照命中与否分为两种,如下图:
- 缓存命中:直接从缓存中读取数据并返回
- 缓存失效:通知缓存从数据库取数据更新缓存,再从缓存中读取
2.2.2 Write Through
写穿透类似于读穿透,同样分为缓存命中和缓存失效两种情况,如下图:
- 缓存命中:直接更新缓存,由缓存负责更新数据库
- 缓存失效:直接更新数据库
2.3 Write Behind Caching Pattern
Write Behind Caching Pattern,类似于linux内核中页缓存的write back机制,是由缓存保持数据,并批量异步更新到数据库中的模式。这种设计模式在设计上缓存与数据库在大部分时间都是不一致的,但是所有的请求只读缓存,所以对服务整体而言不会读到脏数据。
2.3.1Read
2.3.2 Write
写请求与读请求类似,同样要根据命中缓存与否开辟新的缓存块:
2.3.3 开辟缓存块
在开辟缓存块时,考虑到存在这种情况:缓存的空间已被用尽,需要淘汰掉部分缓存来缓存新的数据(如redis使用的是LRU算法)。此时,需要考虑被淘汰的数据是否已经在数据库中存在,需要进行一次判定:
3. 缓存系统常见问题
3.1 缓存穿透
缓存穿透是指,某一个本不存在的key被高并发访问,导致服务不断访问数据库,但由于返回空结果集而不放入到缓存中,使得缓存失去意义。
- 缓存空对象:即服务器返回空结果集时依然放入缓存中,设置值为空标识符(可以是null(如果允许)、空字符串、空对象、空集合等)
- 黑名单:使用黑名单机制过滤不存在的key,使得不存在的key直接被挡在服务外
3.2 并发缓存失效读请求
在服务并发较高的时候,如果一个key在失效以后被多次并发访问,会发生在数据库上出现一个高并发读的情况,如下图:
可见,在第一个到达的读请求从数据库中读到数据并放入缓存之前,期间所有的读请求全部都会访问数据库获取数据,一方面使得数据库的压力陡然增加,另一方面大量消耗了服务的性能,使得服务对其他读请求的执行效率降低。
解决这一问题的主要方法是,缓存查询操作上锁,将后到的相同读请求阻塞,直到第一个读请求将数据库中的数据更新到缓存中,如下图:
锁虽然阻塞了后续一段时间内所有的读请求,但时间上总的时间并没有发生变化,因为后续所有的读请求都成功访问了缓存,可以直接从缓存中读取数据返回。
3.3 缓存雪崩
缓存雪崩是指很多key在同一时间过期,导致在高并发到来时缓存失效,所有的压力都压在数据库上。针对这一问题分两类情况讨论:可预期高并发和不可预期高并发。
- 在可预期高并发场景下(如双十一的秒杀、游戏维护开服),既然高并发的到来使可预期的,那么就可以通过预先缓存的方式应对高并发。
- 在不可预期的高并发场景下,优化的方向是避免大量的key在同一时间过期。可以通过添加随机变量的方式来避免集中过期的情况,即失效时间=基础失效时间+随机值,这样每一个缓存的过期时间就会相互错开,从而避免集中失效的情况发生。
3.4 数据库主从同步间隔导致数据不一致
在数据库主从、主库只负责写、从库只负责读的场景下,亦容易发生缓存数据不一致问题,场景如下:
两个并发请求:写请求A和读请求B,A更新数据库后淘汰缓存,此时B读取缓存失败,从从库中读到尚未同步的旧数据并写入缓存中,缓存与数据库发生不一致。
这个场景的优化方向是减少数据不一致情况存在的时间。使用数据库日志订阅工具订阅从库的binlog日志,当从库的日志更新时,使用订阅工具淘汰缓存中的数据。这样可以保证间隔大于主从数据库同步间隔的读请求一定不会读到脏数据,这个同步时间可以根据需求设置为一个很短的时间,MySQL的默认同步时间间隔是1分钟。
4. 总结
缓存本质上是一种为了提高访问效率而牺牲强一致性的设计,因此针对有强一致性要求的系统,使用缓存是不那么合适的;同样的,针对于频繁写的系统也是不适合使用缓存的。缓存的不一致性主要表现在并发读写的场景下,尤其是数据库层使用了主从读写分离的架构。
关于缓存的效果,一定是命中率越高越好,在缓存的key不是由运维人员根据请求分析后自主决定的情况下,决定缓存命中情况的主要是缓存的失效时间(过期时间)。失效时间太短,就可能导致每过一段时间同一个key就需要到数据库中查询一遍,使命中率降低;失效时间太长,就可能导致缓存中大量的key既不被访问又不失效,内存资源被大量浪费,同时新的key在缓存时需要使用LRU之类的算法重新分配内存空间,导致缓存性能的下降。
参考文献
- http://www.cnblogs.com/llzhang123/p/9037346.html
- https://mp.weixin.qq.com/s/4J3oM1j5hcLq4w4TdSEMPg
- https://blog.cdemi.io/design-patterns-cache-aside-pattern/
- https://www.jianshu.com/p/d96906140199
- https://docs.microsoft.com/en-us/azure/architecture/patterns/cache-aside
- http://www.cnblogs.com/llzhang123/p/9037346.html
- https://www.cnblogs.com/dinglang/p/6133501.html
- https://www.cnblogs.com/uglyliu/p/6223253.html
- https://www.jianshu.com/p/8a032aa3a8e9