在高并发系统中,缓存是提升性能的关键手段。但是,如何保证缓存与数据库的数据一致性,一直是令初学者头疼的问题。本文将深入探讨缓存更新的各种策略,帮助你在实际项目中做出正确的技术选型。

一、什么是缓存?

缓存(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 知识小窗 | 面试高频:缓存一致性经典问题

面试题:更新数据时,应该先删除缓存还是先更新数据库?两种顺序分别存在哪些问题?

方案一:先删除缓存,再写数据库

  • 流程:先删除缓存 → 再更新数据库。
  • 问题
    • 在高并发场景下,可能出现如下时序:
      1. 线程A先删除缓存。
      2. 线程B读缓存,发现没有命中,去数据库读到旧值并写入缓存。
      3. 线程A再写数据库,数据已更新,但缓存却是旧值,导致脏数据。
    • 这种情况称为缓存脏读缓存不一致

方案二:先写数据库,再删除缓存(推荐)

  • 流程:先更新数据库 → 再删除缓存。
  • 问题
    • 极端情况下,删除缓存操作失败(如网络抖动),会导致数据库已更新但缓存未删除,下次读到的还是旧数据。
    • 但这种情况可以通过重试机制消息队列补偿等手段缓解。
    • 该方案能最大程度减少并发下的数据不一致问题,是业界主流做法。

总结

  • 实际开发中,推荐“先写数据库,再删缓存”,并配合重试/补偿机制,保证最终一致性。
  • 也可以引入延迟双删、异步消息等手段进一步提升一致性。
    :::

三、主动更新的三种实现模式

主动更新是保证缓存一致性的最可靠手段,但根据架构设计的不同,它又可以细分为三种主流的实现模式。

模式一:Cache Aside Pattern(旁路缓存模式)

定义缓存与数据库分离,由业务代码(调用者)负责维护缓存和数据库的一致性。这是目前最常用、最经典的缓存模式。

工作流程

读操作(Read):

  1. 应用先查询缓存(Cache)。
  2. 如果缓存命中(Cache Hit),直接返回数据。
  3. 如果缓存未命中(Cache Miss):
    • 从数据库(Database)查询数据。
    • 将数据写入缓存。
    • 返回数据给调用者。

写操作(Write):

  1. 先更新数据库(重要!)。
  2. 再删除缓存(而不是更新缓存)。

为什么是”删除缓存”而不是”更新缓存”?

  • 避免并发写问题:多个线程同时更新缓存和数据库时,可能导致缓存与数据库不一致。
  • 减少无效写入:如果缓存更新后很快又被覆盖,那前一次的更新就是浪费。
  • 延迟加载(Lazy Loading):删除缓存后,下次读取时再从数据库加载最新数据,避免缓存中存储不常用的数据。

适用场景

  • 读多写少的业务场景。
  • 需要灵活控制缓存逻辑的业务。
  • 开发团队对缓存机制有较好的理解。

优点

  • 实现简单直观,开发者完全掌控缓存逻辑。
  • 灵活性高,可以针对不同业务定制化缓存策略。
  • 行业最佳实践,大量开源项目和公司采用。

缺点

  • 缓存逻辑侵入业务代码,增加代码复杂度。
  • 需要开发者小心处理并发问题(如双写一致性、缓存击穿)。

代码示例(Java + Redis)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 读操作
public User getUserById(Long id) {
// 1. 先查缓存
String cacheKey = "user:" + id;
String userJson = redisTemplate.opsForValue().get(cacheKey);

if (userJson != null) {
// 缓存命中,直接返回
return JSON.parseObject(userJson, User.class);
}

// 2. 缓存未命中,查数据库
User user = userMapper.selectById(id);
if (user != null) {
// 3. 写入缓存(设置30分钟过期)
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
}

return user;
}

// 写操作
public void updateUser(User user) {
// 1. 先更新数据库
userMapper.updateById(user);

// 2. 删除缓存
String cacheKey = "user:" + user.getId();
redisTemplate.delete(cacheKey);
}

模式二:Read/Write Through Pattern(读写穿透模式)

定义缓存与数据库整合为一个服务,应用程序只与缓存层交互,由缓存层自动维护与数据库的同步。业务代码无需关心缓存与数据库的一致性问题。

工作流程

Read Through(读穿透):

  1. 应用查询缓存服务。
  2. 如果缓存命中,返回数据。
  3. 如果缓存未命中,缓存服务自动从数据库加载数据,写入缓存,并返回数据。

Write Through(写穿透):

  1. 应用向缓存服务写入数据。
  2. 缓存服务先更新缓存,再同步更新数据库
  3. 只有当数据库更新成功后,缓存更新才算完成。

适用场景

  • 需要对业务代码进行解耦,隐藏缓存实现细节。
  • 使用专业的缓存框架或中间件(如 MyBatis 二级缓存、Spring Cache)。
  • 对一致性要求高,且能接受同步写的性能损耗。

优点

  • 业务代码简洁,无需关心缓存实现。
  • 缓存与数据库的一致性由缓存层保证。

缺点

  • 写操作性能较差:每次写入都要同步更新数据库。
  • 缓存服务必须可靠,否则会成为单点故障。
  • 灵活性较差,难以针对特定业务定制缓存策略。

实际应用

  • MyBatis 二级缓存。
  • Spring Cache 抽象(配合 CacheManager)。
  • 一些 ORM 框架的内置缓存机制。

模式三:Write Behind Pattern(异步缓存写入模式)

定义:也称为 Write Back。应用程序只操作缓存,缓存的变更会异步批量地写入数据库。这种模式下,数据库的写入被延迟了,性能最高,但一致性最弱。

工作流程

  1. 应用向缓存写入数据(如 Redis)。
  2. 缓存立即返回成功。
  3. 后台线程异步地将缓存的变更批量刷新到数据库(可能合并多次写入)。

适用场景

  • 写密集型场景,如日志记录、监控数据、计数器。
  • 对数据一致性要求不高,可以容忍数据丢失(如缓存服务器宕机)。
  • 需要极致性能优化的场景。

优点

  • 写性能极高,因为只需写内存,无需等待数据库 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):内容分发网络,将静态资源分发到离用户更近的边缘节点,降低访问延迟、减轻源站压力,同时具备一定的防护与加速能力。


结语

本文聚焦缓存的核心概念、更新策略、主动更新模式以及常见问题的防护思路,希望为分布式与高并发场景下的选型和落地提供一个清晰的参考。这次博客就写到这里,欢迎各位读者阅读!有问题欢迎看我主页联系我!