为了提高系统吞吐量,我们通常在业务架构中引入缓存层。
缓存通常使用内存存储来实现,比如Redis/Memcached以及应用内缓存GuavaCache/DjangoCache。
所有的查询操作先访问缓存,若未缓存该数据再从数据库中查询该数据并把它写入缓存中。
缓存中的数据是不进行持久化的,所以再进行写入操作的时候需要将数据写入数据库并处理缓存中的旧数据。
为了保证数据库与缓存中的数据一致,更新的逻辑则较为复杂。
content:
缓存更新与一致性
当执行写操作时,需要保证读取到的数据与数据库中持久化的数据是一致的,因此需要对缓存进行更新。
因为涉及到数据库和缓存两部分,操作难以保证原子性。因此我们必须考虑某个操作失败可能带来的数据不一致问题。
更新缓存有两种方式:
- 删除失效缓存,读取时会因为未命中缓存而从数据库中读取新的数据并更新到缓存中
- 更新缓存,直接将新的数据写入缓存覆盖过期数据
更新缓存和更新数据库有两种顺序:
- 先更新数据库后更新缓存
- 先更新缓存后更新数据库
两两组合公有四种更新策略,现在我们逐一进行分析:
1. 先更新数据库,再删除缓存
若数据库更新成功,删除缓存操作失败,则此后读到的都是缓存中过期的数据,造成不一致问题。
2. 先更新数据库,再更新缓存
这种方案与第一种方案类似,若数据库更新成功,缓存更新失败则会造成数据不一致问题。
因为直接更新了缓存,会避免缓存未命中减少数据库压力。
3. 先删除缓存,再更新数据库
若数据库写入延时较大,此种方案可能出现风险。 考虑这样的情景:
存在两个并发操作,一个更新操作一个查询操作,更新操作清除缓存后,查询操作没有命中缓存。
因为写入尚未完成,查询操作从数据库中读出了旧的数据写入缓存中,导致缓存中存在的一直是脏数据。
作者认为,出现并发读写的概率要高于数据库或缓存操作失败的概率。且操作失败很容易从日志中发现并进行修复,而并发异常很难发现和处理。
4. 先更新缓存,再更新数据库
若缓存更新成功数据库更新失败, 则此后读到的都是未持久化的数据。因为缓存中的数据是易失的,这种状态非常危险。
因为数据库因为一致性约束而导致写入失败的可能性较高,所以这种策略风险较大。
总结
在设计更新策略时,我们需要考虑多个方面的问题:
- 对系统吞吐量的影响:比如更新缓存会比删除缓存减少数据库查询请求
- 并发安全性:若试图并发读写同一条数据系统能否正常工作,如在执行写操作时如何处理另外的写请求或读请求。比如先淘汰缓存再更新数据库的方式并发风险较大
- 更新失败的影响:若执行过程中某个操作失败,如何对业务影响降到最小
- 检测和修复故障的难度: 如先淘汰缓存再更新数据库的方式并发读写导致的故障难以检测和修复
作者倾向于采用先更新数据库再更新缓存的方案,这种方案具有较好并发安全性和较高吞吐量。若缓存更新失败则很容易通过日志进行检测和修复。
这些更新策略都只能保证缓存和数据库的最终一致性不能保证强一致性,即缓存和数据库中的数据有一段时间内不一致。
若对强一致性有要求可以采用2PC或是Paxos算法,但是它们对性能有较大的损耗。
参考资料:
异步更新
缓存更新的逻辑复杂耗时较长,一种思路是采用异步方式更新缓存。
即API服务器收到写请求后只更新数据库,同时另外一个服务会订阅数据库更新,异步服务收到消息后执行缓存更新。
这种操作和文件系统异步地将数据写入磁盘的方法非常相似。
参考资料:
缓存穿透
为了避免无效数据占用缓存,我们通常不会在缓存中存储空对象,但这种策略会造成缓存穿透问题。
若要查询的数据不存在,那么当然不可能从缓存中查到这个数据,按照缓存失效即访问数据库的逻辑,所有对不存在数据的查询都会到达数据库,这种现象称作缓存穿透。
为了减少无意义的数据库访问,我们可以缓存特殊的对象表示数据不存在。
比如一个读操作查询id为2333的用户信息,缓存没有命中然后查询数据库得知该用户不存在,于是我们在缓存中写入User:2333 -> {}
表示该用户不存在。
下一次查询User:2333的信息时,会在缓存中读取到表示用户不存在的特殊对象{}
,因此直接返回而不会访问数据库。
缓存并发
在较高并发的情况下一个缓存如果失效,在完成缓存重建前可能会有多个线程试图查询该数据,因为这些查询都无法命中缓存因此转而查询数据库,最终导致多个线程重复查询数据库重复设置缓存的情况。
这种情况会降低系统的吞吐量加重数据库负担,可以采用分布式锁服务来避免这个问题。(使用分布式锁是为了让多个应用线程都可以使用锁)
某个线程出现缓存未命中时即对该缓存加写锁,此时其它试图访问该数据的线程会被阻塞。当缓存重建完成写锁被释放,其它线程即可获取读锁,从缓存中获得数据。
因为每次访问缓存前都要获取锁,这种方式会一定程度上影响性能,使用时应谨慎评估。