[toc]
引言
持久化层和缓存层的一致性问题也通常被称为「双写一致性问题」,“双写”意为数据既在数据库中保存一份,也在缓存中保存一份。对于一致性来说,包含强一致性和弱一致性,强一致性保证写入后立即可以读取,弱一致性则不保证立即可以读取写入后的值,而是尽可能的保证在经过一定时间后可以读取到,在弱一致性中应用最为广泛的模型则是最终一致性模型,即保证在一定时间之后写入和读取达到一致的状态。
我们一般会拿换 Redis 做什么呢?
- 缓存。提高性能,降低 RT,提高吞吐量
- 分布式锁
为什么 Redis 和 MySQL 数据可能不一致
- Redis 和 MySQL 是两个独立的组件,无法保证它们之间的原子性
性能和一致性做了不同的选择:
- Redis 为了追求性能,不保证 ACID
- MySQL 为了追求一致性,保证 ACID,但牺牲了性能
- 网络的不确定性,无法保证客户端请求的先后顺序
缓存不一致场景分析
基于极端场景分析
先写 数据库 再删 缓存(Cache Aside)
- 读:读缓存。不存在,读数据库写缓存
- 写:写数据库后,再删缓存
写失效,读更新
缓存不一致场景
- 读时更新缓存,遇到写数据库操作,读请求却晚于写请求执行
- 主从延迟
- 缓存删除失败
先写 数据库 再写 缓存
- 读:读缓存。不存在,读数据库写缓存
- 写:写数据库,再写缓存(双写)
在读和写都可能会触发写缓存的操作
缓存不一致场景
- 情况一:会遇到与 Cache Aside 类似的情况。读时更新缓存,遇到写数据库操作,读请求却晚于写请求执行
- 情况二:写入数据库之后,写缓存之前,存在另一个线程写数据库和缓存。会用旧的缓存将新的缓存覆盖
解决方案
- 分布式锁。保证写数据库和写缓存的原子性
- 乐观锁。写缓存时比较版本号,确保新值不被覆盖
先写 缓存 再写 数据库
- 读:读缓存。不存在,读数据库写缓存
- 写:写缓存,再写 DB(双写)
缓存不一致的场景
写缓存成功,写数据库失败,无法保证原子性,无法回滚
如果此时缓存被其它请求读取,就会导致数据不一致。因为我们只能认为写入数据库的数据才是“真正存在”的正确数据
解决方案
分布式事务。写数据库失败后,将原本的缓存删除
先删 缓存 再写 数据库
- 读:读缓存。不存在,读数据库写缓存
- 写:先删 缓存 再写 数据库
缓存不一致的场景
- 过早地删除缓存,会导致「缓存击穿」问题
- 在写入数据库之前,另一个线程发现缓存不存在,读取了旧的值更新缓存(在流量比较大的场景,会更频繁地出现)
解决方案
- 每次读写都加锁
- 延迟双删
延迟双删
解决上面方案中,删缓存后,又被缓存旧数据的情况
- 读:读缓存。不存在,读数据库写缓存
写:先删 缓存 再写 数据库。等待一定时间后,再删除一次缓存
- 第一次删除:避免其它线程读取到旧缓存
- 第二次删除:确保将当前线程写入期间,把其它线程更新的旧缓存删除
存在问题
如何把控等待的时间
- 太短,没有用
- 太长,增加响应时间。虽然可以异步去做,但如果期间真的存在旧的缓存,过长的等待时间会导致数据不一致的窗口变长
- 两次删除操作,会增加代码复杂性
- 过早删除缓存,存在「缓存击穿」的风险
并不能有效减少缓存不一致的问题出现的可能性,成本大于收益
异步写入
写回(Write Back)
数据先写入缓存,异步批量写入数据库,充分利用内存写性能强的特点
- 读:读缓存。不存在,读数据库写缓存
- 写:写缓存后立即返回。异步写数据库
批量 UPDATE
// 给视频点赞,每次点赞就是 1 条 SQL,需要执行 3 次
UPDATE t_video SET like = like + 1 WHERE id = '1';
UPDATE t_video SET like = like + 1 WHERE id = '1';
UPDATE t_video SET like = like + 1 WHERE id = '1';
// 聚合 3 条 SQL,执行 1 次
UPDATE t_video SET like = like + 3 WHERE id = '1';
缓存不一致的场景
- 内存的数据有丢失的风险
- 写数据库不一定能成功,此时需要通过分布式事务回滚
- 缓存不一致窗口时间 = 等待聚合时间 + 执行写入 SQL 时间
应用场景
对写性能要求高,可以容忍一部分数据的不一致
手动/定时更新缓存
手动/定时更新缓存,比如可以对即将上线的活动预热
问题在于,很难确定哪些缓存是热点
监听 binlog 异步更新缓存
- 写:直接写数据库,通过 Canal 监听 binlog,异步更新缓存
- 读:直接读取缓存
业务代码侵入性低,只需要考虑数据库更新,不需要考虑缓存的写入
优缺点
- 在数据库更新后,MQ 消息之前,缓存是不一致的,链路长,有一定的延迟
- 实现较为复杂,需要额外维护 Canal 和 MQ
再进一步:本地缓存+Redis+MySQL
使用本地缓存,相比直接使用 Redis 的好处:
- 提高读取性能。避免了网络延迟、序列化/反序列化的开销
- 缓解 Redis 热 key 问题。只需要在本地读取缓存,不需要访问 Redis
本地缓存是去中心化的思路,将计算和存储放在靠近请求产生的地方。使用本地缓存的问题:
- 如果将全量数据存到 JVM 中,内存都拿来缓存数据而不是处理业务。通过限制本地缓存的大小+LRU,避免内存无限制的增长
- 额外的编码复杂性
- 缓存一致性问题。本地缓存与 Redis,Redis 与 MySQL 的一致性
足够短的过期时间
使用 Cache Aside,并设置足够短的过期时间
比如 1 秒钟。那么至少本地缓存和 Redis 的不一致时间能控制在 1 秒钟
- 优点:简单,不需要太复杂的逻辑解决缓存一致性
- 缺点:不一致的窗口取决于过期时间,过期时间越长,数据不一致窗口就越旧,同时占用内存的时间就越长;设置时间太短,缓存命中率低,没什么收益
MQ 异步让缓存失效
监听 MySQL binlog,监听到数据更新,同时给每个 JVM 进程,将对应的本地缓存失效
- 优点:可以相对及时地让本地缓存过期
- 缺点:成本高
扩展:其它场景的缓存一致性
HTTP 强制缓存 & 协商缓存
- 第一次访问资源,正常请求资源,服务端返回 Cache-Control
- 第二次访问资源,资源没有过期,不需要请求服务器,直接使用本地缓存(强制缓存),状态码为 200(from disk cache)
第三次访问资源,资源已经过期,向服务器发送 If-None-Match 和 If-Modified-Since 请求头,协商缓存。服务器通过校验 ETag 或 Last-Modified,判断是否资源是否被修改过:
- 没有被修改,状态码 304(Not Modified),响应体不需要带任何值
- 被修改,正常请求资源,状态码 200
解决缓存一致性的方案
过期时间、ETag、Last-Modified
CPU 缓存一致性
CPU L1 核心是独立的,需要保证核心与核心之间的一致性。主要依赖 MESI 协议:
- Modified(M):数据被当前核心修改,与内存不一致,其他核心无副本
- Exclusive(E):数据仅当前核心有缓存,与内存一致
- Shared(S):多个核心共享该数据,所有缓存与内存一致
- Invalid(I):缓存行无效(其他核心修改了数据)
任意一对快取,对应快取行的相容关系:
工作流程
- 核心 A 修改数据时,会先将其他核心的对应缓存行标记为 Invalid,然后更新自己的缓存为 Modified
- 核心 B 读取该数据时,会触发总线嗅探(Bus Snooping),发现数据已失效,从核心 A 或内存重新加载
解决缓存一致性的方案
硬件和 MESI 协议的支持
CPU 与内存的一致性
CPU 缓存的性能远比内存更好。所以实际读写数据的时候,都是基于 CPU 缓存来操作,减少对内存的频繁写入(尤其是多次修改同一缓存行时)
工作原理
- 数据修改仅写入缓存,不立即更新内存
- 当缓存行被替换(如 LRU 淘汰)时,才将脏数据写回内存
- 通过脏位(Dirty Bit)标记缓存行是否需要回写
解决缓存一致性的方案
脏页写回
(图源见图片右下角)
Linux 的 Page Cache
Page Cache 是磁盘的缓存。用于加快磁盘数据的读写:
延迟写入:当进程写入文件时,数据首先被写入 Page Cache 中的内存页(标记为“Dirty”),而非立即同步到磁盘。应用程序无需等待磁盘 I/O 完成。内核通过以下方式异步刷盘:
- 由内核线程定期将脏页写回磁盘
- 触发条件包括脏页占比超阈值、时间间隔到期或系统内存不足
- 合并连续 I/O:会将短时间内对同一磁盘区域的多次写入合并为一次 I/O 操作(尤其是顺序写入)。减少磁盘寻址次数
- 预读:当检测到顺序写入模式时,内核预读后续数据到 Page Cache,避免写入时因磁盘寻址阻塞(时间局部性和空间局部性)
int fd = open("data.txt", O_WRONLY);
write(fd, "hello", 5); // 只写 page cache
fsync(fd); // 真正 flush 到磁盘
为什么电脑非正常关机(比如断电),会丢失数据?
Page Cache 还没来得及刷盘,内存数据会丢失
如果是正常关机,操作系统能保证内存数据,一定都能写入到磁盘
解决缓存一致性的方案
脏页写回
MySQL 磁盘数据与内存的 Buffer Pool
Buffer Pool 是 InnoDB 引擎的核心内存区域,用于缓存表数据和索引数据。设计思想和 Page Cache 非常像
当查询需要访问数据时:
- 首先检查 Buffer Pool 中是否存在所需数据页
- 如果存在(命中),直接从内存读取
- 如果不存在(未命中),从磁盘读取数据页到 Buffer Pool
同时由于 redo log 已经落盘,「脏页」哪怕丢了,也能通过 redo log 恢复数据
扩展:为什么 MySQL 的「唯一索引」写入性能为什么比「普通索引」更慢?
唯一索引与普通索引读取性能完全一致,但写入性能却不如普通索引
先介绍下 Change Buffer。Change Buffer 是 Buffer Pool 的一部分,用于缓存「非唯一二级索引页」的修改操作。当这些索引页不在 Buffer Pool 中时,相关的变更会先记录在 Change Buffer 中,而不是立即从磁盘读取索引页进行更新:
- 执行 DML 操作需要更新非唯一二级索引
- 检查索引页是否在 Buffer Pool 中
- 如果不在,将变更信息记录到 Change Buffer
- 后续当该索引页被读取到 Buffer Pool 时,合并变更(称为 merge 操作)
普通索引和唯一索引 Change Buffer 上的区别:
- 普通索引:非唯一变更可以先记录在 Change Buffer,延迟合并到索引页
- 唯一索引:必须立即检查唯一性,无法使用 Change Buffer 优化,必须直接写入到磁盘
Buffer Pool 与 Page Cache
MySQL 默认配置下,存在“双缓冲”
- 正常读写文件:Page Cache
- MySQL 读写数据:Buffer Pool -> Page Cache
Buffer Pool 功能和 Page Cache 很多相同的地方。所以可以考虑绕过 page cache,提高读写性能
解决缓存一致性的方案
脏页写回
使用建议
- 必须设置「过期时间」,保证最终一致性。缓存过期后,下次更新缓存,大概率就能一致了
- 缓存数据时,避免使用「分布式事务」和「锁」。因为使用缓存的意义,就是为了提高读性能,而使用「分布式事务」和「锁」会大幅降低读和写的性能。同时,既然使用缓存了,说明此时并不是「强一致」的常见
根据业务决定合适的方案
读多写少:
- Cache Aside。简单高效,缓存会出现不一致,但概率极低
- 本地缓存。性能好,但在非热点场景,命中率低
- 监听 binlog 异步更新缓存。能很好地保证缓存的最终一致性,但是实现复杂
读多写多:
- 不使用缓存。因为刚写入的缓存立即被删除了,缓存命中率低,不如不用缓存
- Write Back. 写入内存的数据可能会丢失
补充
- 最终一致性指的是,一定时间后,数据一定能达到一致。这里的“一定时间”,在平常是很模糊。但是在缓存一致性的场景中,“一定时间”就是缓存的过期时间
- 单节点「分布式锁」不存在一致性问题,因为分布式锁只存储在一个 Redis 中,也不会存储到 MySQL。在主从场景中,需要考虑主从节点的一致性
- 缓存不一致问题是并发问题,是概率问题。如果没什么流量,选择哪一种方案都区别不大,因为出现缓存不一致的场景往往都非常极端
结语
在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性能是有冲突的。软件设计从来都是取舍 Trade-Off
既要性能、又要保证强一致性,在缓存一致性场景中就是伪命题。关键是看自己能为了得到什么,愿意牺牲什么。所以工程上,我们往往选择 Cache Aside,通过概率的方式赌它不会出现缓存不一致,从而避免为了维护缓存一致性导致的性能问题
参考
https://juejin.cn/post/7373136303179792395
https://cloud.tencent.com/developer/article/2197853
封面:Kapxapius
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。