原文:caching-strategies-and-how-to-choose-the-right-one 翻译、注释:Reisen
缓存是提升系统性能最简单也最有效的方法之一 。毕竟,数据库有时会显得有些慢(哪怕是 NoSQL 数据库),而在现代系统开发中,速度就是一切 。
如果运用得当,缓存不仅能缩短响应时间、减轻数据库负载,还能有效节约成本 。然而,缓存策略多种多样,选对策略对系统表现有着决定性的影响 。
💡 核心法则: 系统的缓存策略完全取决于数据特性与数据访问模式(Data Access Patterns) —— 换句话说,取决于你的数据是如何被写入和读取的 。
在选择策略前,可以先思考以下几个技术问题:
-
系统是写密集型(Write-heavy)但读取频率较低吗?(例如:基于时间的日志)
-
数据是一次写入、多次读取吗?(例如:用户档案(User Profile))
-
返回的数据是否总是唯一的?(例如:搜索查询)
显而易见,一款移动游戏“前 10名 排行榜(Leaderboard)”系统所采用的缓存策略,与一个聚合并返回用户档案的服务是截然不同的 。
下面,我们来深入解析几种主流的缓存策略。
1. 旁路缓存 (Cache-Aside)
这或许是实际项目中最常被使用的缓存方法 。在这种模式下,缓存“靠边站(On the side)”,应用程序直接与缓存和数据库两端进行通信 。缓存和主数据库之间没有任何直接的连接,所有的交互逻辑都由应用程序亲自处理 。
🔄 工作原理
-
应用程序首先检查缓存 。
-
如果数据存在,即缓存命中(Cache Hit),直接读取数据并返回给客户端 。
-
如果数据不存在,即缓存缺失(Cache Miss),应用程序需要执行额外的工作:查询数据库获取数据、返回给客户端,同时将数据写入缓存,以便后续对相同数据的读取能够直接命中 。
⚖️ 优缺点与适用场景
-
最适合读密集型(Read-heavy)工作负载:如 Redis 和 Memcached 的经典应用 。
-
具备缓存故障弹性(Resilience):如果缓存集群宕机,系统仍能通过直接访问数据库维持运行 。(注:如果系统在高峰期遭遇缓存雪崩,由于请求全部涌向数据库,响应时间可能会变得极差,甚至导致数据库瘫痪 。)
-
数据模型灵活性:缓存中的数据模型可以与数据库不同。例如,可以将多个复杂查询聚合后的响应结果,直接以某个 Request ID 为 Key 存入缓存 。
-
数据不一致风险:在使用 Cache-Aside 时,最常见的写策略是直接写入数据库 。这会导致缓存与数据库的数据产生不一致 。开发者通常会引入 TTL(生存时间),在数据过期前容忍读取脏数据 ;若对数据新鲜度(Freshness)有强要求,则需要手动使缓存失效(Invalidate)或搭配其他写策略 。
2. 读穿透缓存 (Read-Through)
Read-Through 缓存与数据库处于同一线上(In-line) 。当发生缓存缺失(Cache Miss)时,它会自动从数据库加载缺失的数据,填充缓存,并将其返回给应用程序 。
Cache-Aside 和 Read-Through 都是延迟加载(Lazy Loading)的——也就是说,只有在数据第一次被读取时,才会进入缓存 。
⚖️ 与 Cache-Aside 的关键区别
-
职责划分:在
Cache-Aside中,应用程序负责从数据库抓取数据并填充缓存 ;而在Read-Through中,这一逻辑通常由缓存库(Library)或独里的缓存提供商原生支持,应用层无需关心 。 -
数据模型:与
Cache-Aside不同,Read-Through缓存中的数据模型必须与数据库保持一致 。
⚖️ 优缺点与适用场景
-
适用场景:最适合相同数据被高频反复读取的读密集型场景(例如:热门新闻报道) 。
-
缺点:当数据首次被请求时,必然会导致缓存缺失,并带来加载数据库的额外延迟惩罚 。
-
解决方案:开发者通常会通过手动执行查询来“预热(Warming / Pre-heating)”缓存 。同样,它也会遇到数据不一致的问题,需要结合写策略来解决 。
3. 写穿透缓存 (Write-Through)
在这种写策略中,数据首先被写入缓存,接着被同步写入数据库 。缓存与数据库同样是在线(In-line)的,所有的写操作必须“穿透”缓存才能到达主数据库 。
🔄 工作原理
-
应用程序直接将数据写入缓存 。
-
缓存同步更新主数据库 。当写入完成时,缓存和数据库拥有完全相同的值,从而完美保证了数据一致性 。
⚖️ 优缺点与适用场景
-
缺点:由于每次都需要执行两次写操作(先写缓存,再写数据库),它引入了额外的写入延迟(Write Latency) 。
-
最佳拍档:
Write-Through单独使用时略显鸡肋,但当它与Read-Through配合使用时,就能发挥威力 。它不仅具备Read-Through的所有优势,还能提供强一致性保证,让开发者免受缓存失效(Cache Invalidation)逻辑的困扰(前提是所有写操作都必须经过缓存) 。 -
典型代表:Amazon DynamoDB Accelerator (DAX) 就是一个完美的
Read-Through / Write-Through缓存示例 。它位于 DynamoDB 和应用程序之间,全权托管读写请求 。
4. 写绕过 (Write-Around)
在这种模式下,数据直接写入数据库,完全绕过缓存 。只有当这些数据后续被读取时,它才会通过读策略进入缓存 。
⚖️ 优缺点与适用场景
-
最佳拍档:可以与
Read-Through或Cache-Aside组合使用 。 -
适用场景:在数据“一次写入,极少读取”甚至“不再读取”的业务场景下表现优异 。例如:实时日志(Real-time logs)或聊天室历史消息(Chatroom messages) 。这种模式可以避免那些写入后无人问津的“垃圾数据”塞满缓存空间 。
5. 写回/异步写 (Write-Back / Write-Behind)
在这种策略中,应用程序将数据写入缓存,缓存层在存储数据后,立刻向应用程序返回成功响应 。直到随后的某个时间点,缓存才会异步(Asynchronously)且批量地将数据刷入(Write-back)数据库 。
🔄 与 Write-Through 的本质区别
-
Write-Through:数据写入缓存是同步(Synchronously)更新到主数据库的 。 -
Write-Back:数据写入缓存是异步(Asynchronously)更新到主数据库的 。从应用视角来看,写入速度极快,因为只要缓存更新完了就可以直接返回响应 。
⚖️ 优缺点与适用场景
-
优势:极大提升了写入性能,是写密集型(Write-heavy)工作负载的福音 。
-
混合场景:与
Read-Through结合时,非常适合混合型工作负载(Mixed Workloads),能确保最近更新和访问的数据始终在缓存中可用 。 -
削峰降本:对数据库故障有一定的容忍度(即使数据库短暂宕机也能写入缓存) 。如果缓存支持批处理(Batching)或合并写入(Coalescing),它可以大幅减少对数据库的实际写入次数,从而减轻数据库压力,并在数据库按请求量计费时(如 DynamoDB)显著降低成本 。
-
致命缺点:一旦缓存层发生故障或崩溃,未同步的数据可能会永久丢失 。
-
现实应用:很多开发者利用 Redis 来兼顾
Cache-Aside(读)和Write-Back(写),以更好地吸收高并发下的流量洪峰 。其实,大多数关系型数据库的存储引擎内部(如 MySQL 的 InnoDB)默认就开启了 Write-Back 机制:查询和修改先写入内存(Buffer Pool),后续再异步刷新(Flush)到磁盘上 。
🛠️ 总结与避坑指南
| 缓存策略 | 读操作 | 写操作 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| Cache-Aside | 先查缓存,缺失则查库并回写 | 直接写库 | 弹性高、数据模型灵活 | 可能读到脏数据(需靠 TTL 缓解) | 通用场景、读密集型 |
| Read-Through | 缓存缺失时,由缓存框架自动查库 | — | 应用层代码干净、适合高频读 | 首次读取有延迟(需缓存预热) | 读密集型、相同数据高频请求 |
| Write-Through | — | 先写缓存,缓存同步写库 | 数据强一致性 | 写入延迟较高(两次写操作) | 配合 Read-Through 实现强一致性 |
| Write-Around | — | 直接写库,绕过缓存 | 避免冷数据污染缓存空间 | 首次读取必定 Cache Miss | 一次写入、极少读取(如日志、聊天记录) |
| Write-Back | — | 只写缓存,异步批量刷库 | 写入性能极高、可合并写降低数据库压力 | 缓存宕机有丢失数据的风险 | 写密集型、高并发削峰 |
⚠️ 选错策略的代价
在架构设计中,必须仔细评估业务目标、理解数据的读写模式,从而选择最匹配的单一策略或组合策略 。
如果选错了策略,系统不仅无法享受缓存带来的红利,反而可能引入额外的延迟 。例如:如果你在数据“写多读少”的场景下错误地选择了 Write-Through / Read-Through,而不是 Write-Around / Read-Through,你的缓存中很快就会充斥着无人问津的垃圾冷数据 。
在内存资源昂贵、服务器成本高企的真实高并发、高吞吐系统中,内存永远是不够用的 。因此,结合业务场景选择正确的缓存策略,至关重要 。