找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1583

积分

0

好友

202

主题
发表于 昨天 04:36 | 查看: 5| 回复: 0

在系统设计与面试准备中,缓存模式几乎是后端工程师和架构师的必考题。然而,多数候选人在面对“如何解决数据一致性问题”这一追问时,常因知识面不全或理解不深而陷入困境。事实上,许多缓存模式本身就可能是一致性问题的制造者。

本文将系统性地拆解 Cache Aside、Read Through、Write Through、Write Back、Refresh Ahead、Singleflight 等主流缓存模式,剖析其核心逻辑、潜在的一致性风险及优化方案,帮助你在技术面试与项目实践中构建清晰、深刻的认知体系。

1. 面试准备策略

准备缓存相关的面试,不能止步于死记硬背。作为资深开发者或架构师,你需要:

  1. 能够绘制每种模式的时序图,理解其数据流转。
  2. 反思所在项目的实际应用:采用了哪些模式?是否因此引发过生产事故?如何解决?
  3. 思考缓存更新与数据库更新的编排顺序是否存在一致性隐患。

透彻理解缓存模式,不仅是应对数据一致性难题的关键,更是后续解决缓存穿透、击穿与雪崩等技术挑战的基石。为便于理解,我们建立一个简化的模型:应用服务同时操作缓存(Cache)和数据库(DB)。

2. Cache Aside(旁路缓存)

这是业界应用最广泛的模式。其核心思想是:缓存仅作为辅助存储,应用程序作为“总指挥”,直接与数据库交互,并全权负责维护缓存状态。

写操作流程

  1. 更新数据库:先将数据变更持久化到数据库。
  2. 删除缓存:随后直接删除对应的缓存 Key,而非更新。

Cache Aside 写操作流程图:先更新数据库,再删除缓存

读操作流程

  1. 查询缓存:首先尝试从缓存获取数据。
  2. 缓存命中:命中则直接返回。
  3. 缓存回填:若未命中,则查询数据库,由应用程序显式地将数据写入缓存,再返回结果。

Cache Aside 读操作流程图:先读缓存,未命中则读库并回填

亮点与深度追问

问题一:写操作为何必须“先更新DB,后删除Cache”?顺序能否颠倒?
绝对不能颠倒。采用“先删缓存,后更新DB”在高并发下极易导致脏数据难以自动恢复。
假设库存为100,线程A(写)欲改为99,线程B(读)并发查询:

  1. 线程A删除缓存。
  2. 线程B缓存未命中,查询DB(此时A尚未更新DB),读到旧值100。
  3. 线程B将旧值100回填缓存。
  4. 线程A更新DB为99。
    结果:DB=99,Cache=100,缓存脏数据直至过期。

问题二:“先更新DB,后删除Cache”就能保证强一致吗?
理论上仍存在极低概率的不一致,但工程上可忽略。这发生在缓存刚好失效且并发读写的极端时序下:

  1. 线程A(读)缓存未命中,读DB得旧值100。
  2. 线程B(写)在A读库后、回填前介入,完成DB更新(改为99)并删除缓存。
  3. 线程A将旧值100回填缓存。
    结果:DB=99,Cache=100。由于数据库写操作(涉及I/O、锁)通常远慢于内存操作,这种时序发生的概率极低。

3. 同步更新模式

此模式并非标准设计模式,而是业务代码直接操作缓存与数据库的“自然写法”。业务代码将缓存视为独立数据源。

写操作:业务代码控制写入顺序,通常优先写数据库。
同步更新写操作流程图:业务代码依次写入缓存和数据库

读操作:同样由业务代码处理缓存命中与回填逻辑。
同步更新读操作流程图:业务代码先读缓存,未命中则读库并回填

为何优先写库? 因为数据库是数据的“最终真理源”。只要DB写入成功,业务即告成功。缓存写入失败可通过过期或下次读取恢复,这体现了最终一致性思想。

然而,同步更新无法解决并发写导致的一致性问题。看以下场景:
并发写导致数据不一致示意图
假设库存初始为100:

  1. 线程1更新DB为10。
  2. 线程2快速更新DB为20并更新缓存为20。
  3. 线程1更新缓存为10。
    结果:DB=20,Cache=10,数据不一致。

4. Read Through(读穿透)

为减轻业务代码负担,Read Through 模式诞生。业务方只向缓存要数据,若缓存未命中,则由缓存组件自身负责从数据库加载并更新自己。

Read Through 模式流程图:业务代码读缓存,缓存组件负责未命中时的数据加载

其写操作通常与“同步更新”保持一致,因此在数据一致性方面面临相同挑战。但其高光点在于异步化改造。

异步加载方案

变种一:异步回写
从数据库查询到数据后,立刻返回给业务,然后异步将数据写入缓存。
异步回写流程图:读请求同步返回数据,异步回填缓存

变种二:全异步加载
缓存未命中时,直接返回默认值或错误码,由缓存组件在后台异步完成数据加载与缓存更新。
全异步加载流程图:未命中时立即返回默认值,后台异步加载数据

场景选择:变种二对响应时间极致优化,但业务需容忍短暂数据降级。变种一适用于缓存写入操作本身很耗时的场景。

5. Write Through(写穿透)

与读穿透对应,Write Through 模式下,业务方只写入缓存,然后由缓存组件代理完成数据库的更新。

Write Through 模式流程图:业务代码写缓存,缓存组件负责写数据库

其读操作与 Cache Aside 一致。该模式同样面临并发写的一致性问题,且同样可引入异步机制。

异步写的权衡

异步写库:写入缓存后立刻返回成功,后台异步写库。风险是若缓存组件宕机,数据会永久丢失。
异步写库流程图:写缓存后立即返回,异步更新数据库

异步刷缓存:同步写库保证安全,但异步刷新缓存。适用于“写库快,刷缓存慢”的特殊场景。
异步刷缓存流程图:同步写数据库,异步更新缓存

6. Write Back(回写)

为追求极致写性能,Write Back 模式诞生:写操作只更新缓存并立即返回,数据库的更新被推迟到缓存数据过期或被逐出时,由后台守护组件触发。

Write Back 模式核心流程:写缓存,监听过期事件异步回写数据库

其最大风险是缓存宕机会导致未刷盘的脏数据丢失,故适用于对数据丢失有一定容忍度的场景。

但一个反直觉的亮点是:在集中式缓存(如Redis)下,Write Back 在一致性表现上可能优于其他模式

逻辑分析

  1. 写一致性:所有读写都在缓存闭环内完成,业务方读写自洽。数据库虽滞后,但对业务无感。
    Write Back 写一致性闭环示意图
  2. 读一致性隐患:当缓存失效,读请求需回源数据库时,可能发生经典读写并发冲突。
    Write Back 读并发隐患示意图
  3. 解决方案:使用 Redis 的 SETNX 指令。读请求回填缓存时,仅当 Key 不存在才执行写入,避免覆盖写请求已更新的新值。
    使用 SETNX 解决回填冲突示意图

因此,除了数据丢失风险,Write Back 能较好地缓解数据不一致问题。

7. Refresh Ahead(预刷新/CDC)

随着 CDC 技术普及,此模式流行起来。业务方只写数据库,通过监听数据库 Binlog(如使用 Canal)来异步刷新缓存。

Refresh Ahead (CDC) 模式架构图

它将缓存更新逻辑从业务代码解耦,但仍存在“写库后到缓存刷新前”的时间窗口不一致。同样面临读写并发问题:
Refresh Ahead 模式下的读写并发问题
解决方案同 Write Back:读请求回填缓存时使用 SETNX

8. Singleflight

这并非读写模式,而是一种流量控制模式。其核心是:当缓存未命中时,针对同一 Key 的大量并发查询,只允许一个请求去数据库加载数据,其余请求阻塞等待该结果。

Singleflight 模式示意图:多个相同请求合并为一个

其核心价值在于保护数据库,防止缓存击穿导致雪崩,特别适用于热点数据(Hot Key)场景。

9. 删除缓存与延迟双删

这是业务中最常见的策略之一:更新数据时,先更新数据库,然后直接删除缓存。也可结合 Write Through,让缓存组件更新完DB后自删。

先更新数据库再删除缓存流程图
Write Through 结合自删缓存流程图

为何是“删”而非“更新”?这体现了懒加载思想,避免无效的缓存计算与更新。

但“先改库后删缓存”仍有理论上的不一致风险,即“读线程缓存未命中”与“写线程”并发:
删除缓存的并发不一致场景
为解决此极低概率问题,引入了延迟双删策略。

延迟双删(Delayed Double Delete)

基本流程:写数据库 -> 删缓存 -> 延迟N毫秒 -> 再次删缓存。首次删除可选。
延迟双删基本流程

第二次删除的目的,是清除可能在第一次删除后、延迟期间被读请求回填的脏数据。
延迟双删解决脏数据回填示意图

理论上,若读请求在第二次删除后才回填,仍会不一致,但概率极低。其代价是降低了缓存命中率并增加了系统复杂度。延迟时间需覆盖主从同步延迟及业务处理耗时。

10. 模式选择与工程实践

面试官常问:“项目中该选哪种?” 答案是:没有银弹,需根据业务权衡

  • 强一致性场景(如金额):可能直接绕过缓存,或使用分布式锁,牺牲性能保一致。
  • 常规业务:延迟双删是一个性价比高的方案,能覆盖绝大多数问题。
  • 写多读少或可丢失场景(如统计):可考虑 Write Back。

亮点:装饰器模式实现

要展示代码架构能力,可提及使用装饰器模式统一封装缓存逻辑。以下是一个 Read Through 的伪代码示例:

// 1. 定义标准接口
type Cache interface {
    Get(key string) any
    Set(key string, val any)
}

// 2. 定义 ReadThrough 装饰器
type ReadThroughCache struct {
    c  Cache                 // 基础缓存实例
    fn func(key string) any  // 数据加载函数
}

// 3. 实现 Get 方法,植入 Read Through 逻辑
func (r *ReadThroughCache) Get(key string) any {
    // 先查缓存
    val := r.c.Get(key)
    // 缓存未命中
    if val == nil {
        // 调用回源函数加载数据
        val = r.fn(key)
        // 回写缓存
        r.c.Set(key, val)
    }
    return val
}

通过此类设计,可以规范团队缓存使用,并轻松扩展出 Singleflight 等能力。

11. 总结

本文系统分析了 Cache Aside、同步更新、Read Through、Write Through、Write Back、Refresh Ahead、Singleflight 以及删除缓存、延迟双删等策略。其核心矛盾永远是性能与一致性的博弈

面试中,能够清晰指出每种模式在何种并发场景下会出问题,并给出如 SETNX 或延迟双删等解决方案,你将展现出超越大多数候选人的深度。掌握这些知识,不仅有助于应对技术面试,更是构建稳健、高性能分布式系统的必备技能。




上一篇:信创产业全解析:核心技术、产业链布局与国产化发展现状
下一篇:开源CLI工具Kode:多模型协作与子代理并行,重塑终端开发体验
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-11 13:58 , Processed in 0.194577 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表