头图

[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 中,而不是立即从磁盘读取索引页进行更新:

  1. 执行 DML 操作需要更新非唯一二级索引
  2. 检查索引页是否在 Buffer Pool 中
  3. 如果不在,将变更信息记录到 Change Buffer
  4. 后续当该索引页被读取到 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


牛肉烧烤屋
1 声望0 粉丝