在高并发的业务场景下,数据库大多数情况都是用户并发访问最薄弱的环节。所以,就需要使用redis做一个缓冲操作,让请求先访问到redis,而不是直接访问MySQL等数据库。
然而在工程技术领域并没有100%完美的解决方案,正如这里,读取缓存步骤一般没有什么问题,但是一旦涉及到数据更新:数据库和缓存更新,就容易出现缓存(Redis)和数据库(MySQL)间的数据一致性问题。我们所说的一致性就是在要求:
- 缓存不能读到脏数据
- 缓存可能会读到过期数据,但要在可容忍时间内实现最终一致
- 这个可容忍时间尽可能的小
要想同时满足上面三条,可以采用读请求和写请求串行化,串到一个内存队列里去,这样就可以保证一定不会出现不一致的情况。但是,串行化之后,就会导致系统的吞吐量会大幅度的降低,要用比正常情况下多几倍的机器去支撑线上请求。
无脑增加机器肯定不是我们软件开发人员应该考虑的,所以,在这里,我们讨论三种常见方法:
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
在分析之前需要说明的是:如果允许缓存可以稍微的跟数据库偶尔有不一致的情况,那就不要使用缓存方案来解决数据库的瓶颈问题,但是如果你的系统不是严格要求 缓存和数据库必须保持一致性的话,那么可以尝试使用这里讨论的方案尝试解决缓存和数据库数据一致性问题。
1、先更新数据库,再更新缓存
这种方法是大家普遍反对的,原因集中在下面两点:
从线程安全角度看:如果有同时两个线程A和B更新数据库,正常情况下我们希望A首先执行完毕,然后B再执行,但是由于网络抖动等原因,B先执行了更新,接着A又去更新,那么就会有如下情形:
B更新了数据库------>B更新缓存---->A更新数据库------>A更新缓存
这么操作加大了脏数据产生的可能性,因为A、B线程可能就是对同一个数据进行了更新,那么后发出B请求的数据就理论上就比A的新,因此按照这种策略解决缓存一致性不可取。
从业务角度看:
- 如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致数据压根还没读到,缓存就被频繁的更新,浪费性能。
- 如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。相比之下,删除缓存更为适合。
2、先删除缓存,再更新数据库
该方案同样会导致不一致。同时有请求A和请求B进行更新操作,那么会出现:
- 请求A进行写操作,检查发现缓存存在,于是首先了删除缓存
- 请求B接下来查询发现缓存不存在
- 请求B去数据库查询得到旧值
- 请求B将旧值写入缓存
- 请求A将新值写入数据库
上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。
解决方案:
- 先删除缓存
- 再写数据库
- 休眠一定时间(例如1秒或200ms),再次删除缓存。这么做,可以将缓存脏数据再次删除。
然而,这种方式回影响到系统的吞吐量,读入高并发场景不是特别适用。
3、先更新数据库,再删除缓存
这种方案是很多工程采用的方案,我们来看下是否一定安全。假设有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生:
- 缓存刚好失效
- 请求A查询数据库,得一个旧值
- 请求B将新值写入数据库
- 请求B删除缓存
- 请求A将查到的旧值写入缓存
这样,脏数据就产生了,然而上面的情况是假设在数据库写请求比读请求还要快。实际上,工程中数据库的读操作的速度远快于写操作的。
如果缓存删除失败了怎么办?
答:启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。

读取binlog后分析 ,利用消息队列,推送更新各台的redis缓存数据。这样一旦MySQL中产生了新的写入、更新、删除等操作,就可以把binlog相关的消息推送至Redis,Redis再根据binlog中的记录,对Redis进行更新。
阿里有一款开源框架—canal就是来做这个事情的,通过该框架可以对MySQL的binlog进行订阅,而canal正是模仿了mysql的slave数据库的备份请求,使得Redis的数据更新达到了相同的效果。