深入理解缓存更新策略:聊聊常见业务场景怎么用缓存?
在高并发系统中,缓存是提升性能的关键手段。但是,如何保证缓存与数据库的数据一致性,一直是令初学者头疼的问题。本文将深入探讨缓存更新的各种策略,帮助你在实际项目中做出正确的技术选型。
一、什么是缓存?
缓存(Cache) 是一种存储技术,它将频繁访问的数据保存在读取速度更快的存储介质中,以减少对慢速存储(如数据库、磁盘)的访问次数,从而提升系统性能。
缓存的本质
缓存的核心思想是空间换时间。通过在内存中保存热点数据的副本,我们可以:
- 减少数据库压力:避免大量请求直接打到数据库。
- 提升响应速度:内存读取速度远超磁盘 IO,响应时间从毫秒级降至微秒级。
- 提高系统吞吐量:单位时间内可以处理更多请求。
缓存的层次
缓存并不只是指 Redis,它是一个广泛的概念,存在于计算机系统的各个层次:
- CPU 缓存:L1、L2、L3 缓存,减少对内存的访问。
- 浏览器缓存:缓存静态资源(如图片、CSS、JS),减少 HTTP 请求。
- CDN 缓存:内容分发网络,将资源缓存到离用户更近的节点。
- 应用缓存:如本地内存缓存(Guava Cache、Caffeine)、分布式缓存(Redis、Memcached)。
- 数据库缓存:MySQL 的 Buffer Pool、查询缓存等。
在本文中,我们主要讨论的是应用层的分布式缓存,特别是以 Redis 为代表的缓存中间件。
二、缓存更新策略概览
缓存中的数据不可能永久存在,它需要根据业务需求和资源限制进行更新或淘汰。常见的缓存更新策略有三种:内存淘汰、超时剔除、主动更新。
1. 内存淘汰(Memory Eviction)
定义:当缓存空间不足时,系统根据特定算法自动删除部分数据,为新数据腾出空间。
实现方式:
- LRU (Least Recently Used):淘汰最久未使用的数据。
- LFU (Least Frequently Used):淘汰使用频率最低的数据。
- FIFO (First In First Out):淘汰最早进入缓存的数据。
- Random:随机淘汰。
适用场景:
- 数据访问频率差异大(热点数据明显)。
- 对数据一致性要求不高,可以容忍一定时间的数据陈旧。
- 内存资源有限,需要自动管理缓存容量。
优点:
- 无需手动干预,完全自动化。
- Redis 自带多种淘汰策略(
maxmemory-policy)。
缺点:
- 被动触发,只有在内存不足时才会执行。
- 可能淘汰掉仍然有价值的数据。
- 无法保证数据一致性。
2. 超时剔除(Time-To-Live, TTL)
定义:在写入缓存时为数据设置一个过期时间(TTL),到期后数据自动失效。
实现方式:
- Redis:
SET key value EX 3600(设置 3600 秒后过期)。 - Guava Cache:
expireAfterWrite(10, TimeUnit.MINUTES)。
适用场景:
- 数据有明确的时效性(如验证码、短信验证、优惠券)。
- 能够容忍短时间内的数据不一致。
- 读多写少的场景,数据更新频率较低。
优点:
- 实现简单,只需在写入时设置 TTL。
- 自动过期,减少缓存与数据库不一致的时间窗口。
缺点:
- 过期时间难以精确控制:时间设短了,缓存频繁失效;设长了,数据陈旧。
- 在数据更新后,旧数据仍然会在缓存中存活一段时间(直到 TTL 到期)。
- 无法应对突发的数据更新需求。
3. 主动更新(Active Update)
定义:在数据发生变更时,业务代码主动介入,同步或异步地更新缓存,确保缓存与数据库的一致性。
适用场景:
- 对数据一致性要求高的核心业务(如商品库存、账户余额、订单状态)。
- 数据更新频繁,不能容忍较长时间的不一致。
- 需要精确控制缓存的生命周期。
优点:
- 一致性最强,可以在数据变更时立即同步缓存。
- 灵活可控,开发者可以自定义更新逻辑。
缺点:
- 实现复杂度高,需要在业务代码中处理缓存逻辑。
- 可能带来额外的性能开销(如同步写缓存的延迟)。
:::tip 知识小窗 | 面试高频:缓存一致性经典问题
面试题:更新数据时,应该先删除缓存还是先更新数据库?两种顺序分别存在哪些问题?
方案一:先删除缓存,再写数据库
- 流程:先删除缓存 → 再更新数据库。
- 问题:
- 在高并发场景下,可能出现如下时序:
- 线程A先删除缓存。
- 线程B读缓存,发现没有命中,去数据库读到旧值并写入缓存。
- 线程A再写数据库,数据已更新,但缓存却是旧值,导致脏数据。
- 这种情况称为缓存脏读或缓存不一致。
- 在高并发场景下,可能出现如下时序:
方案二:先写数据库,再删除缓存(推荐)
- 流程:先更新数据库 → 再删除缓存。
- 问题:
- 极端情况下,删除缓存操作失败(如网络抖动),会导致数据库已更新但缓存未删除,下次读到的还是旧数据。
- 但这种情况可以通过重试机制、消息队列补偿等手段缓解。
- 该方案能最大程度减少并发下的数据不一致问题,是业界主流做法。
总结:
- 实际开发中,推荐“先写数据库,再删缓存”,并配合重试/补偿机制,保证最终一致性。
- 也可以引入延迟双删、异步消息等手段进一步提升一致性。
:::
三、主动更新的三种实现模式
主动更新是保证缓存一致性的最可靠手段,但根据架构设计的不同,它又可以细分为三种主流的实现模式。
模式一:Cache Aside Pattern(旁路缓存模式)
定义:缓存与数据库分离,由业务代码(调用者)负责维护缓存和数据库的一致性。这是目前最常用、最经典的缓存模式。
工作流程:
读操作(Read):
- 应用先查询缓存(Cache)。
- 如果缓存命中(Cache Hit),直接返回数据。
- 如果缓存未命中(Cache Miss):
- 从数据库(Database)查询数据。
- 将数据写入缓存。
- 返回数据给调用者。
写操作(Write):
- 先更新数据库(重要!)。
- 再删除缓存(而不是更新缓存)。
为什么是”删除缓存”而不是”更新缓存”?
- 避免并发写问题:多个线程同时更新缓存和数据库时,可能导致缓存与数据库不一致。
- 减少无效写入:如果缓存更新后很快又被覆盖,那前一次的更新就是浪费。
- 延迟加载(Lazy Loading):删除缓存后,下次读取时再从数据库加载最新数据,避免缓存中存储不常用的数据。
适用场景:
- 读多写少的业务场景。
- 需要灵活控制缓存逻辑的业务。
- 开发团队对缓存机制有较好的理解。
优点:
- 实现简单直观,开发者完全掌控缓存逻辑。
- 灵活性高,可以针对不同业务定制化缓存策略。
- 行业最佳实践,大量开源项目和公司采用。
缺点:
- 缓存逻辑侵入业务代码,增加代码复杂度。
- 需要开发者小心处理并发问题(如双写一致性、缓存击穿)。
代码示例(Java + Redis):
1 | // 读操作 |
模式二:Read/Write Through Pattern(读写穿透模式)
定义:缓存与数据库整合为一个服务,应用程序只与缓存层交互,由缓存层自动维护与数据库的同步。业务代码无需关心缓存与数据库的一致性问题。
工作流程:
Read Through(读穿透):
- 应用查询缓存服务。
- 如果缓存命中,返回数据。
- 如果缓存未命中,缓存服务自动从数据库加载数据,写入缓存,并返回数据。
Write Through(写穿透):
- 应用向缓存服务写入数据。
- 缓存服务先更新缓存,再同步更新数据库。
- 只有当数据库更新成功后,缓存更新才算完成。
适用场景:
- 需要对业务代码进行解耦,隐藏缓存实现细节。
- 使用专业的缓存框架或中间件(如 MyBatis 二级缓存、Spring Cache)。
- 对一致性要求高,且能接受同步写的性能损耗。
优点:
- 业务代码简洁,无需关心缓存实现。
- 缓存与数据库的一致性由缓存层保证。
缺点:
- 写操作性能较差:每次写入都要同步更新数据库。
- 缓存服务必须可靠,否则会成为单点故障。
- 灵活性较差,难以针对特定业务定制缓存策略。
实际应用:
- MyBatis 二级缓存。
- Spring Cache 抽象(配合 CacheManager)。
- 一些 ORM 框架的内置缓存机制。
模式三:Write Behind Pattern(异步缓存写入模式)
定义:也称为 Write Back。应用程序只操作缓存,缓存的变更会异步批量地写入数据库。这种模式下,数据库的写入被延迟了,性能最高,但一致性最弱。
工作流程:
- 应用向缓存写入数据(如 Redis)。
- 缓存立即返回成功。
- 后台线程异步地将缓存的变更批量刷新到数据库(可能合并多次写入)。
适用场景:
- 写密集型场景,如日志记录、监控数据、计数器。
- 对数据一致性要求不高,可以容忍数据丢失(如缓存服务器宕机)。
- 需要极致性能优化的场景。
优点:
- 写性能极高,因为只需写内存,无需等待数据库 IO。
- 可以合并多次写入,减少数据库压力(如多次 +1 操作可以合并为一次)。
缺点:
- 一致性最弱:缓存与数据库可能长时间不一致。
- 数据丢失风险:如果缓存服务器在数据未持久化前宕机,数据会丢失。
- 实现复杂,需要维护后台异步任务。
实际应用:
- 操作系统的页缓存(Page Cache)。
- 数据库的 Write-Ahead Logging (WAL)。
- 消息队列的异步持久化。
- Redis 的 AOF 异步刷盘(
appendfsync everysec)。
示例场景:
在电商秒杀场景中,用户的每次点击都会让商品的浏览量 +1。如果每次都同步写数据库,数据库会扛不住。此时可以:
- 先让 Redis 计数器 +1。
- 后台线程每隔 10 秒批量将 Redis 中的浏览量刷新到数据库。
四、三种主动更新模式对比
| 特性 | Cache Aside | Read/Write Through | Write Behind |
|---|---|---|---|
| 数据一致性 | 较强(取决于实现) | 强(同步写) | 弱(异步写) |
| 写性能 | 中等 | 较差(同步写数据库) | 极高(仅写缓存) |
| 读性能 | 高 | 高 | 高 |
| 实现复杂度 | 中等 | 低(框架封装) | 高 |
| 数据丢失风险 | 低 | 低 | 高 |
| 适用场景 | 读多写少 | 通用场景 | 写密集型 |
五、缓存常见三大问题
1. 缓存穿透(Cache Penetration)
现象:大量请求访问的是数据库中本不存在的数据(或恶意构造的 key),每次都直接落到数据库,缓存形同虚设,数据库被压垮。
典型场景:刷接口、弱口令/不存在的用户ID、无效商品ID。
解决思路:
- 缓存空值:不存在的数据也写入缓存一个占位符,设置较短 TTL,避免重复穿透(注意控制内存占用)。
- 参数校验/鉴权:对 ID、格式、签名做校验,非法请求直接拦截。
- 布隆过滤器(Bloom Filter):在缓存前加一层布隆过滤器判断 key 是否“可能存在”,不存在的直接拒绝;误判率可通过位图大小和哈希函数个数调优。
- 限流:对高频异常访问限流或封禁。
2. 缓存雪崩(Cache Avalanche)
现象:在同一时间段,大量缓存 key 同时过期或 Redis 整体不可用,瞬间大量请求落到数据库,导致数据库雪崩。
解决思路:
- 过期时间加随机抖动:TTL 设定一个基准值再叠加随机值,避免同一时间批量过期。
- 热点数据预热 / 分批加载:上线前或高峰前预先加载缓存,或分批设置过期时间。
- 限流、熔断、降级:在缓存不可用时保护数据库。
- Redis 高可用:哨兵 / Cluster,多副本;持久化(AOF/RDB);必要时多级缓存(本地 + 分布式)。
3. 缓存击穿(Cache Breakdown / Hot Key)
现象:某个热点 key 过期的瞬间,恰好有大量并发请求,全部打到数据库,造成瞬时压力峰值。
解决思路:
- 互斥锁(Mutex)方案:读取缓存 miss 时,先用
SETNX获取锁;拿到锁的线程去查库并回填缓存,其他线程短暂等待或快速失败,避免并发击穿。 - 逻辑过期方案:缓存中存储业务数据 + 逻辑过期时间。即使逻辑过期也先返回旧数据,同时后台异步线程去刷新缓存,适合读多写少的热点数据。
- 热点 key 永不过期 + 后台刷新:对极热点数据可不设置物理 TTL,由定时/异步任务刷新;需防止脏数据,可配合版本号或双缓存。
- 限流/降级兜底:防止极端情况下数据库被打爆。
附录:常见名词速览
限流(Rate Limiting):通过令牌桶、漏桶或固定窗口等算法限制单位时间内的请求数,保护后端服务不被突发流量压垮。
熔断(Circuit Breaker):当下游服务连续失败到达阈值时,临时“断路”快速失败,避免持续占用资源;一段时间后再尝试半开恢复,代表实现有 Hystrix/Resilience4j。
降级(Degrade/Fallback):在高峰或异常时主动降低服务质量,例如返回兜底数据、关闭非核心功能、延迟某些任务,以保证核心链路可用。
哨兵(Redis Sentinel):Redis 官方的高可用组件,监控主从实例健康状况,主库故障时自动选举新的主库并通知客户端完成故障切换。
CDN(Content Delivery Network):内容分发网络,将静态资源分发到离用户更近的边缘节点,降低访问延迟、减轻源站压力,同时具备一定的防护与加速能力。
结语
本文聚焦缓存的核心概念、更新策略、主动更新模式以及常见问题的防护思路,希望为分布式与高并发场景下的选型和落地提供一个清晰的参考。这次博客就写到这里,欢迎各位读者阅读!有问题欢迎看我主页联系我!