数据库缓存架构的设计

应用场景

缓存是一种提高读性能的常见技术。对于读多写少的应用,就可以使用缓存进行优化。

例如,网关中各个银行通道配置bank_channel_conf(bnk_no, bnk_nm),业务需求是:

  • 查询通道信息,select bnk_no, bnk_nm from bank_channel_conf where bnk_no = ?,占请求总数的99%
  • 修改通道信息,update bank_channel_conf set bnk_nm = ? where bnk_no = ?,占请求总数的1%

由于大部分的请求是查询,我们在缓存中建立bnk_no到bnk_nm的键值对,或者bnk_no到BankChannelConf实体的键值对,可以很大程度的减少数据库的压力。

读操作流程

现在有两个地方存放银行通道的数据,一个是db,一个是cache。每当需要读一个数据时,流程是这样的:

  1. 读取缓存是否有相关的数据。bnkNo->bnkNm
  2. 如果缓存中有数据,则返回缓存中的数据,这就是所谓的数据命中[hit]
  3. 如果缓存中没有数据,就会去查询数据库,这就是所谓的数据未命中[miss],最后把数据放入缓存,再返回。

缓存命中率[HitRate] = 命中[Hit] / (命中[hit] + 未命中[miss]) * 100%

像上面这个例子,目测缓存命中率会在95%以上!

写操作流程

上面读操作流程很简单,没有什么例外,但是写操作就很麻烦了,比如下面的问题:

  • 是更新cache中的数据?还是淘汰cache中的数据?
  • 是先操作db中的数据?还是先操作cache中的数据?

更新缓存

什么是更新缓存呢?就是写操作时,不但会把数据写入db中,还会把数据写入cache中(key相同就会替换,相当于更新了)。

优点:紧随写操作之后的那一次查询,不会miss,命中率稍有提高(也不算优点吧,提高的命中率忽略不计)。

淘汰缓存

什么是淘汰缓存?就是写操作时,只写入db,不会写入cache,不但不写入,还要删除。

优点:简单(也不算优点吧,更新也不难)
缺点:命中率稍有下降(也是忽略不计)。

如果仅仅从上面的例子中看,貌似更新缓存和淘汰缓存旗鼓相当,甚至更倾向于更新缓存。

但是,如果缓存中的value需要经过一系列的查询才能得到,那么,为了增加一次命中,可能就不值得了(毕竟代码中还要额外的写逻辑,可能还是在中间件中,我们都知道的中间件是不能带有业务逻辑的),这么来看的话,更新缓存的代价可能就更大了一些。

个人观点:淘汰缓存操作简单,缺点仅仅只是多一次miss,处理方式全世界统一,所以个人建议使用。

先操作db还是先操作cache

现在假设使用淘汰缓存作为对缓存的通用处理,现在又有下面两个问题:

  1. 先写数据库,再淘汰缓存
  2. 先淘汰缓存,再写数据库

正常不出意外的情况下,两种方案都可以,没有谁好谁坏,但是在一些极端的情况下就不好说了。

比如方案1,假如写数据库成功了,但是淘汰数据时失败了,这时候又没有事务能保证两步操作的原子性,那么就可能造成缓存中有脏数据,数据的不一致就这么产生了。

但是反过来,采用方案2,先淘汰缓存数据成功了,再写数据库时失败了,顶多就是缓存中的数据丢了,增加一次miss,不会产生脏数据,对业务的影响较小。

结论:db和cache的操作顺序是,先淘汰cache,再写db。

进一步优化

上述缓存框架有一个缺点,业务方不但需要关注cache和db,还需要对cache进行管理,如果多人团队开发,完全不知道在什么时机淘汰什么cache,因此,这里需要一个数据服务层,向上游提供一个优雅的接口,屏蔽缓存管理的细节,这样的话上游业务方就不需要关注这些根本没法关注的细节了。

遗留难点

当采用淘汰缓存方案时,在高并发的情况下,如果线程1已经淘汰了缓存,准备写数据时,恰好在这时,线程2过来读数据,发现缓存中没数据,然后就去请求数据库,再把数据库中的数据放入缓存,最后线程1才磨磨唧唧的把数据写入库中,结果就造成了cache与db不一致的问题。