应用场景
缓存是一种提高读性能
的常见技术。对于读多写少的应用,就可以使用缓存进行优化。
例如,网关中各个银行通道配置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
。每当需要读一个数据时,流程是这样的:
- 读取缓存是否有相关的数据。bnkNo->bnkNm
- 如果缓存中有数据,则返回缓存中的数据,这就是所谓的
数据命中[hit]
- 如果缓存中没有数据,就会去查询数据库,这就是所谓的
数据未命中[miss]
,最后把数据放入缓存,再返回。
缓存命中率[HitRate] = 命中[Hit] / (命中[hit] + 未命中[miss]) * 100%
像上面这个例子,目测缓存命中率会在95%以上!
写操作流程
上面读操作流程很简单,没有什么例外,但是写操作就很麻烦了,比如下面的问题:
- 是更新cache中的数据?还是淘汰cache中的数据?
- 是先操作db中的数据?还是先操作cache中的数据?
更新缓存
什么是更新缓存呢?就是写操作时,不但会把数据写入db中,还会把数据写入cache中(key相同就会替换,相当于更新了)。
优点:紧随写操作之后的那一次查询,不会miss,命中率稍有提高(也不算优点吧,提高的命中率忽略不计)。
淘汰缓存
什么是淘汰缓存?就是写操作时,只写入db,不会写入cache,不但不写入,还要删除。
优点:简单(也不算优点吧,更新也不难)
缺点:命中率稍有下降(也是忽略不计)。
如果仅仅从上面的例子中看,貌似更新缓存和淘汰缓存旗鼓相当,甚至更倾向于更新缓存。
但是,如果缓存中的value需要经过一系列的查询才能得到,那么,为了增加一次命中,可能就不值得了(毕竟代码中还要额外的写逻辑,可能还是在中间件中,我们都知道的中间件是不能带有业务逻辑的),这么来看的话,更新缓存的代价可能就更大了一些。
个人观点:淘汰缓存操作简单,缺点仅仅只是多一次miss,处理方式全世界统一,所以个人建议使用。
先操作db还是先操作cache
现在假设使用淘汰缓存作为对缓存的通用处理,现在又有下面两个问题:
- 先写数据库,再淘汰缓存
- 先淘汰缓存,再写数据库
正常不出意外的情况下,两种方案都可以,没有谁好谁坏,但是在一些极端的情况下就不好说了。
比如方案1,假如写数据库成功了,但是淘汰数据时失败了,这时候又没有事务能保证两步操作的原子性,那么就可能造成缓存中有脏数据,数据的不一致就这么产生了。
但是反过来,采用方案2,先淘汰缓存数据成功了,再写数据库时失败了,顶多就是缓存中的数据丢了,增加一次miss,不会产生脏数据,对业务的影响较小。
结论:db和cache的操作顺序是,先淘汰cache,再写db。
进一步优化
上述缓存框架有一个缺点,业务方不但需要关注cache和db,还需要对cache进行管理,如果多人团队开发,完全不知道在什么时机淘汰什么cache,因此,这里需要一个数据服务层,向上游提供一个优雅的接口,屏蔽缓存管理的细节,这样的话上游业务方就不需要关注这些根本没法关注的细节了。
遗留难点
当采用淘汰缓存方案时,在高并发的情况下,如果线程1已经淘汰了缓存,准备写数据时,恰好在这时,线程2过来读数据,发现缓存中没数据,然后就去请求数据库,再把数据库中的数据放入缓存,最后线程1才磨磨唧唧的把数据写入库中,结果就造成了cache与db不一致的问题。