孙宇的技术专栏 大数据/机器学习

缓存设计--一致性

2015-07-08

阅读:


缓存是一种提高系统读性能的常见技术,对于读多写少的应用场景,我们经常使用缓存来进行优化。

例如对于用户的余额信息表account(uid, money),我们在缓存中建立uid到money的键值对,能够极大降低数据库的压力。

有了数据库和缓存两个地方存放数据之后(uid->money),每当需要读取相关数据时(money),操作流程一般是这样的:

  1. 读取缓存中是否有相关数据,uid->money
  2. 如果缓存中有相关数据money,则返回【这就是所谓的数据命中“hit”】
  3. 如果缓存中没有相关数据money,则从数据库读取相关数据money【这就是所谓的数据未命中“miss”】,放入缓存中uid->money,再返回

缓存的命中率 = 命中缓存请求个数/总缓存访问请求个数 = hit/(hit+miss)

那么问题来了, 当数据money发生变化的时候:

  • 是更新缓存中的数据,还是淘汰缓存中的数据呢?
  • 是先操纵数据库中的数据再操纵缓存中的数据,还是先操纵缓存中的数据再操纵数据库中的数据呢?
  • 缓存与数据库的操作,在架构上是否有优化的空间呢?

更新/淘汰

  • 更新缓存:数据不但写入数据库,还会写入缓存。缓存不会增加一次miss,命中率高
  • 淘汰缓存:数据只会写入数据库,不会写入缓存,只会把数据淘汰掉

上述场景,只是简单的把余额money设置成一个值,那么:

  1. 淘汰缓存的操作为deleteCache(uid)
  2. 更新缓存的操作为setCache(uid, money)

更新缓存的代价很小,此时我们应该更倾向于更新缓存,以保证更高的缓存命中率。

如果余额是通过很复杂的数据计算得出来的,例如业务上除了账户表account,还有商品表product,折扣表 discount 等。更新缓存的代价很大,此时我们应该更倾向于淘汰缓存。

先操作数据库 vs 先操作缓存

对于一个不能保证事务性的操作,一定涉及“哪个任务先做,哪个任务后做”的问题,解决这个问题的方向是:如果出现不一致,谁先做对业务的影响较小,就谁先执行

由于写数据库与淘汰缓存不能保证原子性,谁先谁后同样要遵循上述原则。

  • 假设先写数据库,再淘汰缓存: 第一步写数据库操作成功,第二步淘汰缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致。
  • 假设先淘汰缓存,再写数据库: 第一步淘汰缓存成功,第二步写数据库失败,则只会引发一次Cache miss。

结论:数据和缓存的操作时序,结论是清楚的:先淘汰缓存,再写数据库

分布式下的一致性

在分布式环境下,数据的读写都是并发的,上游有多个应用,通过一个服务的多个部署(为了保证可用性,一定是部署多份的),对同一个数据进行读写,在数据库层面并发的读写并不能保证完成顺序,也就是说后发出的读请求很可能先完成(读出脏数据):

  1. 发生了写请求A,A的第一步淘汰了cache
  2. A的第二步写数据库,发出修改请求
  3. 发生了读请求B,B的第一步读取cache,发现cache中是空的
  4. B的第二步读取数据库,发出读取请求,此时A的第二步写数据还没完成,读出了一个脏数据放入cache

在数据库层面,后发出的请求 4 比先发出的请求 2 先完成了,读出了脏数据,脏数据又入了缓存,缓存与数据库中的数据不一致出现了。

能否做到先发出的请求一定先执行完成呢?常见的思路是“串行化

这里,用任务队列也是不行的。因为从队列读任务并执行也是并发的。不同线程虽然按顺序从队列读取了值,但并不保证他们执行的顺序是读出的顺序。除非只有一个线程来执行,但这样效率就非常低。

另外,通常的业务会有多个数据库连接,多个服务。对于不同服务、不同数据库连接。这些都不能保证串行化。所以,我们要想保证数据串行化,可以考虑从数据上下手。让同一个数据的访问能串行化

可以尝试:

  • 修改服务,id取模选取服务连接,能够保证同一个数据的读写都落在同一个后端服务上
  • 修改数据库DB连接池,id取模选取DB连接,能够保证同一个数据的读写在数据库层面是串行的

主/从架构下的一致性

在主从同步,读写分离的数据库架构下,有可能出现脏数据入缓存的情况,此时串行化方案不再适用了。如:

单库情况

  1. 请求A发起一个写操作,第一步淘汰了cache,然后这个请求因为各种原因在服务层卡住了(进行大量的业务逻辑计算,例如计算了1秒钟)
  2. 请求B发起一个读操作,读cache,cache miss
  3. 请求B继续读DB,读出来一个脏数据,然后脏数据入cache
  4. 请求A卡了很久后终于写数据库了,写入了最新的数据

主从同步,读写分离的情况

  1. 请求A发起一个写操作,第一步淘汰了cache
  2. 请求A写数据库了,写入了最新的数据
  3. 请求B发起一个读操作,读cache,cache miss
  4. 请求B继续读DB,读的是从库,此时主从同步还没有完成,读出来一个脏数据,然后脏数据入cache
  5. 最后数据库的主从同步完成了

这种情况请求 A 和请求 B 的时序是完全没有问题的,是主动同步的时延(假设延时1秒钟)中间有读请求读从库读到脏数据导致的不一致。

既然旧数据就是在那1s的间隙中入缓存的,是不是可以在写请求完成后,再休眠1s,再次淘汰缓存,就能将这1s内写入的脏数据再次淘汰掉呢?虽然是可以的,但如果我们同步去做这个操作,会让请求阻塞 1 秒,这肯定是无法接受的,大大降低了写请求的吞吐量,增长了处理时间。

既然无法同步做,可以想到异步去做。做一个异步的任务,在 1 秒后再淘汰一次 cache。

这样会在业务逻辑中加入额外的处理。如果不想在业务逻辑中做这一步,还可以做一个读取 binlog的逻辑,分析 binlog,然后处理缓存。


Similar Posts

上一篇 Zookeeper

下一篇 分布式ID生成

评论