原因:
用缓存,主要有两个用途:高性能、高并发。
高性能
非实时变化的数据-查询mysql耗时需要300ms,存到缓存redis,每次查询仅仅1ms,性能瞬间提升百倍。
高并发
mysql 单机支撑到2K QPS就容易报警了,如果系统中高峰时期1s请求1万,仅单机mysql是支撑不了的,但是使用缓存的话,单机支撑的并发量轻松1s几万~十几万。
原因是缓存位于内存,内存对高并发的良好支持。
常见的缓存问题:
1、缓存与数据库双写不一致
2、缓存雪崩、缓存穿透
3、缓存并发竞争
1、如何保证缓存与数据库的双写一致性?
最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern。
- 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
- 更新的时候,先更新数据库,然后再删除缓存。
为什么是删除缓存,而不是更新缓存?-> 用到缓存才去算缓存-lazy加载思想。
非高并发场景数据不一致问题:
先修改数据库,再删除缓存。如果缓存删除失败,导致缓存中是旧数据。
解决方法:
先删除缓存,再修改数据库。如果缓存删除失败,则整个操作失败,如果修改数据库失败,缓存已为空,则请求数据时,会重新加载数据库的数据,
虽然都是旧数据,但保持了数据一致性。
高并发场景数据不一致问题:
先删除了缓存,然后要去修改数据库,此时还没修改。(定义为步骤A)
一个请求过来,去读缓存,发现缓存空了,去查询数据库(定义为步骤B1)。查到了修改前的旧数据,放到了缓存中。(定义为步骤B2)
随后数据变更的程序完成了数据库的修改。此时数据库和缓存数据不一致了。
解决方法:
定义一个FIFO的阻塞队列,例如:LinkedBlockingQueue,将步骤A和步骤B放入同一个队列中。步骤A必然在步骤B的前面。
当场景发生了上述步骤B1时,只有2个情况:缓存已删除,数据库已修改或者数据库还未修改。不考虑已修改的正常情况,则步骤A必然已发生。
则可以在,步骤A和步骤B1发生时均按照数据唯一标识(ID)入同一个队列。步骤A先于步骤B1入队,按照FIFO的方式,步骤A会先完成,则问题
理论上得以解决。
注意事项:
- 高并发场景下肯定会有同一个数据多个步骤B的出现,可以过滤去重。即:队列中已存在则不用再入队了。
- 由于步骤B已变成异步读请求,基于我们的高并发场景,需要考虑读超时的问题。如果请求还在等待时间范围内,不断轮询发现可以取到值了,那么就直接返回;如果请求等待的时间超过一定时长,那么这一次直接从数据库中读取当前的旧值。
- 如果大量的数据更新频繁,导致队列中堆积大量的更新操作,然后大量的读请求超时,最后导致大量的请求直接走数据库。则需要根据具体业务模拟测试峰值,部署多个应用分摊更新操作。
2、缓存雪崩、缓存穿透-> 请移步这篇文章 缓存雪崩、缓存穿透
3、Redis的并发竞争问题
场景:
- redis的并发竞争问题,主要是发生在并发写竞争。
- redis本身是单线程,不存在并发问题,但我们在使用过程中会存在并发问题:更新操作分成了3步骤,读取数据,数据操作,设新值回去。
例如:redis有一个key=“product_num”,value=10, 此时有2个客户端同时对这个key做加1操作,预期结果是value=12。
但有这样的情况:第一个客户端还未设新值回去的时候第2个客户端获取到值,为10,则2个客户端最终操作结果value=11,与预期不符!
解决方案:
- 利用redis自带的 incr 命令。
- CAS乐观锁。
某个时刻,多个系统实例都去更新某个 key。可以基于 zookeeper 实现分布式锁。每个系统通过 zookeeper 获取分布式锁,确保同一时间,只能有一个系统实例在操作某个 key,别人都不允许读和写。
另:写mysql数据库时保存一个时间戳(或者version),从 mysql 查询的时候,时间戳也带出来。
每次要写DB前,先判断一下当前这个 value 的时间戳是否比缓存里的 value 的时间戳要新。如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据。