缓存的常见套路
缓存的几种模式
在高并发场景下,缓存在数据库前面挡住了大量的查询流量,减轻了数据库的压力。对于缓存的更新,通常有以下几种模式:
| 模式 | 优点 | 缺点 |
|---|---|---|
| Cache Aside | 1. 实现简单 | 1. 需要调用方维护缓存和数据库的更新逻 2. 代码侵入性大 |
| Read/Write Through | 1. 引入缓存管理组件,缓存和数据库的维护对应用方是透明的 2. 应用代码侵入性小,逻辑清洗 | 1. 引入缓存管理组件,实现复杂 |
| Write Behind Caching | 1. 读写直接与缓存打交道,异步批量更新数据库,性能最好 2. 缓存和数据库的维护对应用方是透明的 | 1. 实现最复杂 2. 存在数据丢失风险 3. 一致性最弱 |
Cache Aside Pattern
这种模式应用最为广泛,其逻辑如下:
- 读过程:先读缓存,若命中则返回;否则从数据库中查询数据,写入缓存并返回
- 写过程:先更新数据库,然后让缓存失效

为什么不是先失效缓存,再更新数据库?
在这种情况下,当同时存在2个并发的读和写请求容易导致脏数据:
- 写请求A先失效缓存,但还未更新数据库
- 读请求B查询缓存未命中,然后查询数据库,查询出旧值并写入缓存
- 写请求A更新数据库,此时缓存中的数据就出现了不一致,并一直脏下去

为此,可以使用缓存延时双删策略,也就是在更新数据库后睡眠一会儿(比如1秒),然后再次失效缓存。这个睡眠时间应大于读取业务逻辑数据的耗时,如果数据库使用了读写分离架构,这个睡眠时间还应大于主从同步的延时时间。为了提高吞吐量,可在线程里异步实现延时双删。
为什么是失效缓存而不是更新缓存?
如果是更新缓存,存在2个问题:
- 同时2个并发写请求时可能导致脏数据
- 违背数据懒加载
导致脏数据的情形:
- 写请求A更新数据库
- 写请求B更新数据库,并成功更新缓存
- 写请求A最后更新缓存。此时写请求A的数据已经是脏数据,造成了数据不一致。

违背数据懒加载的含义是:有些缓存值需要经过复杂的计算得出,如果每次更新数据都更新缓存,但后续一段时间内并没有命中该缓存,就会浪费大量的计算性能,完全可以在后续读取时再去计算即可,这样可以避免不必要的计算消耗,降低计算开销。
Cache Aside Pattern也会出现不一致的问题
先更新数据库再失效缓存理论上也可能出现问题,但实际出现的概率非常低。导致数据不一致的情形:
- 读请求A读取缓存未命中,然后读取数据库成功
- 写请求B更新数据库成功,并失效缓存成功
- 读请求A将查询数据库的结果写入到缓存中,而此时请求A写入的数据已是脏数据,就造成了数据不一致。

但是,要造成数据不一致,需要读请求先于写请求,但后于写请求返回。但是数据库的查询耗时会小于写入耗时,因此这种情况出现的概率很小。
Read/Write Through Pattern
在Cache Aside Pattern中,应用放维护数据库和缓存的读写,导致应用方数据库和缓存的维护侵入业务代码,数据层的耦合较大,代码的复杂性增加。而在Read/Write Through Pattern中,调用方无需管理缓存和数据库调用,抽象出一层缓存管理组件来负责缓存和数据库的读写维护,将更新数据库的操作由缓存自己代理了,调用方直接和缓存管理组件交互,缓存和数据库对于调用方来说视为一个整体,由此解耦业务代码。
Read Through:在查询操作中更新缓存,也就是当缓存失效时,由缓存管理组件查询数据库然后写入缓存,并返回给应用。

Write Through:当更新数据时,将请求发送给缓存管理组件,由缓存管理组件同步更新数据库和缓存。

Write Behind Caching Pattern
Write Behind又叫Write Back,类似于Linux文件系统的Page Cache算法。
Write Behind模式与Write Through模式类似,区别在于Write Through是同步更新数据库,而Write Behind是异步的。也就是,在更新数据时,只更新缓存,不更新数据库,缓存管理组件异步地批量更新数据库。异步更新数据库的过程叫flush,触发flush的条件可以是定时或达到一定的容量阈值时。并且,在flush时可以使用批量写、合并写等策略,有效减少了更新数据的频率。这样做的优点是读写响应非常快,吞吐量很高。但带来的问题是,数据不是强一致的,而且如果缓存在flush到数据库之前发生了宕机就会丢失数据。另外,Write Behind的实现逻辑较为复杂,因为它需要跟踪有哪些数据是脏的,需要刷到持久层上。操作系统的Write Back会在Cache需要失效时才会被真正持久化,如内存不足、进程退出等,该操作又叫lazy write。

缓存一致性
一致性问题
由于引入缓存,数据就分散在不同的数据源。如果将读写缓存包含在数据库的事务控制内,会增加事务控制粒度与事务释放耗时,造成大量的数据库连接挂起,严重降低系统性能。因此,缓存和数据库的更新通常是两个事务,缓存和数据库的一致性问题是分布式一致性的问题范畴。造成问题的原因通常有以下两个层面:
- 业务层面:主要是选择缓存更新模式的不同导致,如Cache Aside Pattern在高并发的情况下可能造成不一致的情况,不过不同的更新方式造成不一致的概率不同。
- 系统层面:单个节点系统问题导致失败造成的不一致,如缓存服务宕机,网络抖动造成更新失败等。
解决方案
如果追求强一致性,可以采用强一致性协议,或将并行请求串行化,但这将严重降低系统的吞吐量。因此,大部分场景下,尤其是互联网场景下,大多是保证最终一致性。
重试机制
- 应用更新数据库,如果失败,那么更新失败事务回滚
- 应用删除缓存失败,将删除失败的key写入MQ
- 消费MQ得到缓存删除失败的key,重试删除缓存
缺点是,对业务线代码造成大量的侵入。
订阅binlog
引入一个binlog订阅中间件,订阅数据更新的binlog,从而解耦缓存更新过程。这样的中间件有databus、canal等。

- 应用更新数据库,binlog日志同步到binlog订阅中间件
- 缓存管理组件订阅binlog,并删除缓存,若删除失败则将缓存key写入MQ
- 缓存管理组件订阅缓存删除失败的key的MQ,重试删除缓存
另外,现在的数据库通常是主从架构来提升整体的查询QPS,因数据库的主从同步延迟,删除缓存后,如果此时从库的数据还未同步完成,新来的请求未命中缓存然后从从库中查询了已经过期的数据放到缓存中,也会造成数据的不一致。而通过订阅binlog的同步的延迟性,使得删除缓存的时序延后,进一步降低不一致的几率。
常见缓存问题
使用缓存减轻了高并发场景下的查询压力,但也带来了缓存访问时的一些风险,常见的缓存问题有:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 缓存穿透 | 访问的数据既不在缓存,也不在数据库 |
1. 限制非法请求 2. 缓存空对象 3. 使用布隆过滤器 |
| 缓存击穿 | 频繁访问的热点数据过期 | 1. 互斥锁 2. 热点数据不过期 |
| 缓存雪崩 | 大量数据同时过期 | 1. 分散过期时间 |
| 缓存服务宕机 |
1. 构建高可用缓存 2. 数据库限流与服务熔断降级 |
|
| 热点key | 热点key的访问压力过大 |
1. 多级缓存 2. 多副本 3. 迁移热点key |
| 大value/多key | 单个简单key存储的value很大 |
1. 拆分成几个key-value 2. 拆分成hash |
| hash、set、zset、list存储了过多的元素 | 1. 分桶拆分 | |
| 集群存储的key过多 |
1. 使用hash,旧key为field 2. 分桶转hash |
|
| 大bitmap或布隆过滤器 | 1. 拆分成多个独立的bitmap |
缓存穿透
原因
正常情况下,如果缓存设计比较合理,通常是能命中缓存的。但是,如果有大量的非法请求都去查询数据库中不存在的数据,也就是数据既不存在于缓存也不存在于数据库,那么请求每次都会打到数据库上,缓存就形同虚设,这种情况就称之为缓存穿透。
解决方案
1、限制非法请求
在上层业务做参数合法性校验,尽量避免非法参数的请求。
2、缓存空对象
对于数据库中不存在的key,缓存空对象,同时设置一个过期时间,之后再访问这个key将会从缓存中获取。
这种方式的优点在于实现简单,但是会占用缓存空间,如果空数据的命中率不高,而且遇到较多的非法请求时,会增加缓存空间的压力。
3、使用布隆过滤器
关于布隆过滤器的介绍详见之前的博客:一文了解布隆过滤器 - 木然轩
利用布隆过滤器存储所有的key,在向数据库中写入数据时,将key存储到布隆过滤器。查询缓存前,先查询布隆过滤器中是否存在,如果不存在就返回。布隆过滤器判断不存在的key则必然是不存在的,而布隆过滤器判断存在的key则不一定存在。也可以先查询缓存,如果未命中缓存,则先查询布隆过滤器快速判断数据是否存在,如果不存在就不需要再查询数据库了。