缓存是一种提高系统读性能的常见技术,对于读多写少的应用场景,我们经常使用缓存来进行优化。
例如对于用户的余额信息表account(uid, money),我们在缓存中建立uid到money的键值对,能够极大降低数据库的压力。
有了数据库和缓存两个地方存放数据之后(uid->money),每当需要读取相关数据时(money),操作流程一般是这样的:
- 读取缓存中是否有相关数据,uid->money
- 如果缓存中有相关数据money,则返回【这就是所谓的数据命中“hit”】
- 如果缓存中没有相关数据money,则从数据库读取相关数据money【这就是所谓的数据未命中“miss”】,放入缓存中uid->money,再返回
缓存的命中率 = 命中缓存请求个数/总缓存访问请求个数 = hit/(hit+miss)
那么问题来了, 当数据money发生变化的时候:
- 是更新缓存中的数据,还是淘汰缓存中的数据呢?
- 是先操纵数据库中的数据再操纵缓存中的数据,还是先操纵缓存中的数据再操纵数据库中的数据呢?
- 缓存与数据库的操作,在架构上是否有优化的空间呢?
更新/淘汰
- 更新缓存:数据不但写入数据库,还会写入缓存。缓存不会增加一次miss,命中率高
- 淘汰缓存:数据只会写入数据库,不会写入缓存,只会把数据淘汰掉
上述场景,只是简单的把余额money设置成一个值,那么:
- 淘汰缓存的操作为deleteCache(uid)
- 更新缓存的操作为setCache(uid, money)
更新缓存的代价很小,此时我们应该更倾向于更新缓存,以保证更高的缓存命中率。
如果余额是通过很复杂的数据计算得出来的,例如业务上除了账户表account,还有商品表product,折扣表 discount 等。更新缓存的代价很大,此时我们应该更倾向于淘汰缓存。
先操作数据库 vs 先操作缓存
对于一个不能保证事务性的操作,一定涉及“哪个任务先做,哪个任务后做”的问题,解决这个问题的方向是:如果出现不一致,谁先做对业务的影响较小,就谁先执行。
由于写数据库与淘汰缓存不能保证原子性,谁先谁后同样要遵循上述原则。
- 假设先写数据库,再淘汰缓存: 第一步写数据库操作成功,第二步淘汰缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致。
- 假设先淘汰缓存,再写数据库: 第一步淘汰缓存成功,第二步写数据库失败,则只会引发一次Cache miss。
结论:数据和缓存的操作时序,结论是清楚的:先淘汰缓存,再写数据库。
分布式下的一致性
在分布式环境下,数据的读写都是并发的,上游有多个应用,通过一个服务的多个部署(为了保证可用性,一定是部署多份的),对同一个数据进行读写,在数据库层面并发的读写并不能保证完成顺序,也就是说后发出的读请求很可能先完成(读出脏数据):
- 发生了写请求A,A的第一步淘汰了cache
- A的第二步写数据库,发出修改请求
- 发生了读请求B,B的第一步读取cache,发现cache中是空的
- B的第二步读取数据库,发出读取请求,此时A的第二步写数据还没完成,读出了一个脏数据放入cache
在数据库层面,后发出的请求 4 比先发出的请求 2 先完成了,读出了脏数据,脏数据又入了缓存,缓存与数据库中的数据不一致出现了。
能否做到先发出的请求一定先执行完成呢?常见的思路是“串行化”
这里,用任务队列也是不行的。因为从队列读任务并执行也是并发的。不同线程虽然按顺序从队列读取了值,但并不保证他们执行的顺序是读出的顺序。除非只有一个线程来执行,但这样效率就非常低。
另外,通常的业务会有多个数据库连接,多个服务。对于不同服务、不同数据库连接。这些都不能保证串行化。所以,我们要想保证数据串行化,可以考虑从数据上下手。让同一个数据的访问能串行化。
可以尝试:
- 修改服务,id取模选取服务连接,能够保证同一个数据的读写都落在同一个后端服务上
- 修改数据库DB连接池,id取模选取DB连接,能够保证同一个数据的读写在数据库层面是串行的
主/从架构下的一致性
在主从同步,读写分离的数据库架构下,有可能出现脏数据入缓存的情况,此时串行化方案不再适用了。如:
单库情况
- 请求A发起一个写操作,第一步淘汰了cache,然后这个请求因为各种原因在服务层卡住了(进行大量的业务逻辑计算,例如计算了1秒钟)
- 请求B发起一个读操作,读cache,cache miss
- 请求B继续读DB,读出来一个脏数据,然后脏数据入cache
- 请求A卡了很久后终于写数据库了,写入了最新的数据
主从同步,读写分离的情况
- 请求A发起一个写操作,第一步淘汰了cache
- 请求A写数据库了,写入了最新的数据
- 请求B发起一个读操作,读cache,cache miss
- 请求B继续读DB,读的是从库,此时主从同步还没有完成,读出来一个脏数据,然后脏数据入cache
- 最后数据库的主从同步完成了
这种情况请求 A 和请求 B 的时序是完全没有问题的,是主动同步的时延(假设延时1秒钟)中间有读请求读从库读到脏数据导致的不一致。
既然旧数据就是在那1s的间隙中入缓存的,是不是可以在写请求完成后,再休眠1s,再次淘汰缓存,就能将这1s内写入的脏数据再次淘汰掉呢?虽然是可以的,但如果我们同步去做这个操作,会让请求阻塞 1 秒,这肯定是无法接受的,大大降低了写请求的吞吐量,增长了处理时间。
既然无法同步做,可以想到异步去做。做一个异步的任务,在 1 秒后再淘汰一次 cache。
这样会在业务逻辑中加入额外的处理。如果不想在业务逻辑中做这一步,还可以做一个读取 binlog的逻辑,分析 binlog,然后处理缓存。