如何选择缓存模式
当一个系统引入缓存后,最大的挑战之一便是如何确保缓存与后端数据库的一致性。目前,常见的解决方案主要有Cache Aside、Read/Write Throught和Write Back这三种缓存更新策略。
Read/Write Throught策略
读操作方面,如果缓存失效,则从数据库回源数据并设置到缓存中;写操作时,会同时更新数据库和缓存。这种策略的优点是能够保证缓存不会因为更新而失效,从而避免大量请求直接进入数据库。但其缺点也较为明显,容易出现数据不一致的情况。
产生不一致的情况
如下图所示,当缓存失效后,系统从数据库查询到v1版本数据。此时,如果发生数据更新操作,在数据库和缓存都更新完成后(数据库为v2版本,Redis缓存也为v2版本),查询流程可能会将之前从数据库回源的v1版本数据写回缓存,最终导致数据库为v2版本,而Redis缓存为v1版本,出现数据不一致的情况。这种不一致状态会一直持续到下一次数据修改或者缓存再次失效。
一般来说,在查询操作不复杂的情况下,其速度通常快于写操作。但出现上述数据不一致情况的原因主要有以下两点:
- 如果查询操作复杂且未命中mysql buffer,可能会慢于写操作,毕竟写操作有redo log的优化,执行速度也较快。
- 网络波动也可能对数据读写的顺序和时效性产生影响。
同样并发更新也可能产生问题
在更新操作并发的情况下,也可能出现数据不一致的问题。例如,多个并发的写操作可能会导致缓存和数据库之间的数据状态混乱,具体表现为不同操作的先后顺序和数据覆盖问题,从而引发数据不一致。
Cache Aside策略
此策略即为前文提到的只读缓存模式。读操作时,如果命中缓存则直接返回数据;若未命中,则从后端数据库加载数据到缓存后再返回。写操作则直接更新数据库,然后删除缓存。这种策略的优势在于始终以后端数据库的数据为准,能够有效保证缓存和数据库的一致性。然而,其缺点是写操作会使缓存失效,对于查询请求频繁的业务,可能会导致大量请求回源到数据库。在实际软件开发中,当使用Memcached或Redis时,这是一种较为常用的方案。不过,该策略虽然避免了并发更新导致的不一致问题,但缓存失效导致的数据不一致情况仍然存在。
Write Back策略
这一策略类似于前文所述的读写缓存模式 + 异步写回策略。
写操作仅对缓存进行,操作相对简单,系统会定时将缓存数据同步回数据库。
读操作方面,如果命中缓存则直接返回数据;若未命中,则从数据库加载数据到缓存中。当缓存已满时,会先将需要淘汰的缓存数据写回到后端数据库,然后再将对应的数据放入缓存中。
此外,当数据即将过期时,还需判断是否为脏数据,若是,则需先进行持久化操作,然后再让数据过期。
这种策略的优点是写操作速度极快(因为只写缓存),但其缺点是如果数据还未来得及写入后端数据库,系统就发生异常,可能会导致缓存和数据库的数据不一致。这种策略常用于操作系统的Page Cache中,或者在应对大量写操作的数据库引擎中也较为常见。
全量缓存
这种适用于缓存内容较少,或性能要求极高的场景,比如一些系统配置;这时往往通过定时任务+事件监听的方式,将数据库修改同步到内存和redis,如果缓存中访问不到不会在回源到数据库。
如何解决不一致
重试机制
在缓存或数据库操作过程中,除了正常流程外,还需考虑操作发生异常时的处理方式。例如,当缓存操作成功但数据库操作失败,或者反之,都可能导致数据不一致的问题。
- 对于一致性要求不高的业务场景,可以根据业务特点设计好更新缓存和数据库的先后顺序,以此降低数据不一致的影响;或者通过给缓存设置较短的有效期,来缩短不一致状态的持续时间。
- 而对于需要严格保证缓存和数据库一致性,即确保两者操作原子性的场景,这就涉及到分布式事务问题了。常见的解决方案包括两阶段提交(2PC)、三阶段提交(3PC)、TCC、消息队列等。不过,这些方案通常较为复杂,一般应用于对一致性要求极高的业务场景。
利用消息队列重试
在实际应用中,一种常用的一致性解决方案是利用消息队列进行重试。当缓存修改或删除操作失败时,系统会异步发送一条消息,进行重试消费,以此确保缓存和数据库的一致性。
延迟双删
在正常流程下,使用Read/Write Throught和Cache Aside策略时,由于缓存失效可能会导致旧值被重新写入缓存。虽然这种情况发生的概率极低,但在数据库读写分离的架构下,主从同步延迟会增加数据不一致的概率。
读写分离导致的不一致示例
例如,在“先更新数据库,再删除缓存”的方案中,“读写分离 + 主从库延迟”可能会导致如下数据不一致的情况:
- 线程A更新主库,将X的值从1更新为2。
- 线程A删除缓存。
- 线程B查询缓存,未命中,于是查询从库,得到旧值X = 1。
- 从库完成与主库的同步,此时主从库中X的值都为2。
- 线程B将从库中查询到的旧值1写入缓存。
最终,X的值在缓存中为1(旧值),而在主从库中为2(新值),出现了数据不一致的情况。
延迟双删解决方案
针对上述问题,最简单的解决办法是进行延迟双删。即在删除缓存后,延迟一段时间再次删除缓存。延迟的时间需要通过具体的测试来确定,同时删除的逻辑也可以异步执行,并进行重试。需要注意的是,延迟双删只能减少数据不一致的可能性,并不能完全避免。
定时矫正
强一致
在实际应用中,要实现缓存和数据库的“强一致”是非常困难的。常见的实现强一致的方案,如2PC、3PC、Paxos、Raft等一致性协议,虽然能够保证数据的强一致性,但其性能往往较差,并且这些方案相对复杂,还需要考虑各种容错问题。
当我们引入缓存时,其主要目的是提升系统性能。在这种情况下,性能和一致性就如同天平的两端,难以同时兼顾。以我们前面提到的方案为例,在操作数据库和缓存的过程中,只要在操作完成之前有其他请求介入,就有可能查询到“中间状态”的数据。
如果非要追求强一致,那么在所有更新操作完成之前,必须禁止“任何请求”进入。虽然我们可以通过加“分布锁”的方式来实现这一目标,但这样做所付出的代价,很可能会超过引入缓存所带来的性能提升。
因此,既然决定使用缓存,就必须在一定程度上容忍“一致性”问题。我们所能做的,就是尽可能降低问题出现的概率。同时,我们也要清楚地认识到,缓存都设置有“失效时间”。即使在某些时间段内存在短期的数据不一致,我们仍然可以依靠缓存的失效时间来兜底,从而实现数据的最终一致性。
总结
- 引入缓存后,需要着重考虑缓存和数据库的一致性问题。可供选择的方案主要有“更新数据库 + 更新缓存”以及“更新数据库 + 删除缓存”这两种。
- “更新数据库 + 更新缓存”方案在并发场景下,难以保证缓存和数据库的数据一致性,并且还可能出现“缓存资源浪费”和“机器性能浪费”的问题。
- 在“更新数据库 + 删除缓存”的方案中,“先删除缓存,再更新数据库”在并发场景下依然存在数据不一致的风险。针对这一问题,解决方案是“延迟双删”,但延迟时间的评估具有一定难度。因此,通常推荐采用“先更新数据库,再删除缓存”的方案。
- 在“先更新数据库,再删除缓存”的方案下,为了确保数据库更新和缓存删除这两步操作都能成功执行,需要配合“消息队列”或“订阅变更日志”等方案。其本质是通过“重试”机制来保证数据的一致性。
- 在“先更新数据库,再删除缓存”的方案下,“读写分离 + 主从库延迟”同样会导致缓存和数据库的数据不一致。缓解这一问题的方案仍然是“延迟双删”,即凭借经验将“延迟消息”发送到队列中,延迟删除缓存。同时,还需要对主从库延迟进行有效控制,尽可能降低数据不一致情况发生的概率。