redis经常作为mysql的缓存,以提升用户访问速度、降低mysql服务的压力,但是这样就会涉及到mysql和redis数据同步问题:
我们得尽量保证,修改完数据库后,redis缓存也能同步成新的数据,否则,redis层一直用旧的数据拦截请求,更新会很长时间不起作用(除非缓存过期)。
缓存延时双删
| 步骤 | 核心使命 | 如果没有它,会发生什么? |
|---|---|---|
| ① 先删缓存 | 制造“空窗期”,避免更新期间有人读到明确的旧缓存。 | 更新过程中,读请求会一直命中旧缓存,造成短暂脏读。 |
| ② 更新数据库 | 持久化新值,这是最终的事实标准。 | (无意义,这是必做项) |
| ③ 延时 | 等待并发读请求完成“旧数据回填”操作。 | 在进行数据库更新之前,如果有一个查询请求提前查询到了数据库的旧数据,或者说因为隔离性等原因看到旧数据,就会把旧数据回填到redis缓存中,第二次如果删除执行太早(没有延时),“旧回填数据”就会发生在第二次删除之后,等于白删。 |
| ④ 再删缓存 | 清除被并发读线程误回填的旧缓存,保证最终一致性。 | 缓存中可能会永久残留旧数据(直到过期),导致长期不一致。 |
需要注意的是:
- 缓存延时双删保证的是最终一致性,而不是实时一致性,总有一些时候缓存设Mysql数据库的内容不一致,我们能做的只是尽力缩短不一致的时间。
- 另外,这个方法也可能失效,假设在进行数据库更新之前,一个查询请求提前查询到了数据库的旧数据,或者说因为隔离性等原因看到旧数据,等到延时时间过了之后才把旧数据回填到redis缓存中,这样redis中仍旧还会长期存放旧数据。
- 延时可能降低缓存命中率,降低性能,造成mysql服务压力增大。
缓存版本号
每一条数据多存储一个版本号,一条修改请求这样进行:
- 首先直接修改mysql中的数据,并把版本号++
- 然后修改redis缓存,对比当前数据库和redis缓存的版本号,如果大于,则更新,否则,说明更加新的修改已经先其一步回填,操作取消
- 因为不会删除缓存,所以不会出现旧数据回填的情景
这种方法不需要延时等待,性能较高,但是实现较为复杂,而且仍旧会出现数据短暂不一致的的情况(不过相对于缓存延时双删它结果更加稳定,一般不会有失效问题)。
Binlog订阅(Canal+MQ)
一个标准的Canal+MQ方案的架构如下:
业务应用:只负责更新MySQL数据库,完全不操作Redis缓存。
MySQL:数据变更时,将变更细节写入
Binlog日志。Canal Server:伪装成从库,实时获取
Binlog,解析后作为生产者将变更事件发送到MQ。消息队列(MQ):作为消息的缓冲和分发中心,保证消息可靠投递。
缓存同步服务:作为消费者从MQ订阅消息,根据事件内容(如更新、删除)执行对应的Redis操作(更新或删除缓存)。
这种方法的问题在于缓存更新延时比较大数据不一致的情况比较久而且实现复杂,优点在于业务代码与缓存彻底解耦(业务代码只操作数据库),同时解决了缓存版本号的一个漏洞:如果两个更新是修改一条数据的不同部分,另一个修改可能因为版本号小(后更新数据库的范围先更新缓存)而更新不到缓存上。
redis执行失败
无论哪种同步方法,其实都害怕redis执行失败,一旦失败,由于数据库修改了缓存却修改失败,就会造成长时间的数据不一致问题。
所以一般来说每种方法都要搭配一个队列,队列里存放执行失败的redis语句,设置一个线程来反复执行之前失败的语句,直到执行成功。