前面已经完成了数据持久层的讲解,接下来将围绕数据库数据频繁读写的问题探讨缓存层的实战,本篇文章,我们就来聊聊缓存界的"头号网红"------读缓存。这玩意儿大家常用到都快用出"包浆"了,所以基础操作就此掠过,着重对比下常见缓存方案的优劣。
1.业务场景:如何将十几秒的查询请求优化成毫秒级
这次针对的场景是查询商品详情页,随着商品详情页从简单到复杂,从简答的商品介绍到加入商品推荐、交易情况... 详情页的查询变得越来越慢,最后达到了十几秒。
系统里一共有5w多条商品,数据量不大。项目组开始考虑要怎么优化。重构数据库基本不可能,最好不要改动表结构。大家想到的方案也很通用,就是把大部分商品的详情数据缓存起来,少部分的数据通过异步加载 。比如,最近的成交数据通过异步加载,即用户打开商品详情页以后,再在后台加载最近的成交数据,并显示给用户。
关于缓存,最简单的实现方法就是使用本地缓存,即把商品详情数据放在JVM里面。在Google Guava中有一个内存缓存模块,可以利用它把所有商品的ID与商品详情信息一对一缓存至JVM内存中,用户获取商品详情数据时,系统会根据商品ID直接从缓存中读取数据,能大大提升用户页面的访问速度。
值得注意的是,需要使用jvm缓存数据的时候一定要计算好所需要的内存。 针对当前场景,一条商品大概占500kb的大小,再将这些数据缓存到本地的话,就要占用500KB×50000≈25GB内存。此时,假设商品服务有30个服务器节点,仅缓存商品数据就需要额外准备750GB的内存空间,这种方法显然不可取。
现在我们常用的方法是分布式存储,先将所有的缓存数据集中存储在同一个地方,而非重复保存到各个服务器节点中。
这就涉及接下来要讲的缓存中间件技术选型问题了。
2.缓存中间件技术选型(Memcached,MongoDB,Redis)
简单对比
使用MongoDB的公司最少,因为它只是一个数据库,由于它的读写速度与其他数据库相比更快,人们才把它当作类似缓存的存储。所以接下来就是比较Redis和Memcached,并从中做出选择。
目前,Redis比Memcached更流行,这里总结一下原因,共3点。
1.数据结构
Memcached更新列表繁琐,需要"取出反序列化-修改-序列化放回"的笨重操作;而Redis则一步到位,直接原地修改,高效又简单。
2.持久化
Memcached 本身是纯内存的,一宕机数据肯定就没了。虽然从 1.5.18 版本开始,它加个叫'Restartable Cache 可重启缓存'的功能,但它的原理是:重启时CLI先发信号给守护进程,然后守护进程将内存持久化至一个文件中,系统重启时再从那个文件中恢复数据。所以这招只在'正常关机'时管用,要是服务器突然崩了这种意外情况,数据照样丢,它处理不了。
而Redis是有持久化功能的。
3.集群
Memcached 的集群架构十分简洁,其核心机制是依赖客户端进行哈希计算以直接定位目标节点。相比之下,Redis 集群的设计则复杂许多,它全面考量了高可用性、主从复制、数据冗余及故障自动转移等分布式核心诉求,整体上构成了一个更为完备的分布式高可用解决方案。
基于上述对比与审慎评估,项目组最终选定 Redis 作为缓存中间件。在完成技术选型后,团队随即开始着手规划缓存的具体实施方案,首要考虑的问题便是数据写入缓存的时机。
3 缓存何时存储数据
3.1存储逻辑
当前使用的逻辑如下:
1)先尝试查询缓存
2)若缓存中没有数据或者数据过期,再从数据库中读取数据保存到缓存中。
3)最终把缓存数据返回给调用方。
此模式的主要风险在于,当缓存失效时,突发的并发请求会穿透至数据库,瞬时的高频读取可能导致数据库负载激增,最终可能导致数据库崩溃。
3.2常见问题
数据库的崩溃可以分为3种情况
1 缓存击穿 - 单一数据过期或者不存在
**解决方案:**第一个线程如果发现Key不存在,就先给Key加锁,再从数据库读取数据保存到缓存中,最后释放锁。
2 缓存雪崩 - 数据大面积过期或者Redis宕机
**解决方案:**设置缓存的过期时间为随机分布或设置永不过期即可。
3 缓存穿透 - 一个恶意请求获取的Key不在数据库
比如正常的商品ID是从1到50000,那么恶意请求就可能会故意请求50000以上的数据。这种情况如果不做处理,恶意请求每次进来时,肯定会发现缓存中没有值,那么每次都会查询数据库,虽然最终也没在数据库中找到商品,但是无疑给数据库增加了负担。
解决方案:
- 在业务逻辑中直接校验,在数据库不被访问的前提下过滤掉不存在的Key。
- 针对恶意请求的Key存放一个空值在缓存中,防止恶意请求骚扰数据库。(例如 列表缓存一个[],对象缓存一个null)
3.3缓存预热
上述讨论聚焦于请求抵达后,系统如何通过"查询缓存→未命中时查询数据库→回写缓存"的流程来应对。这种被动模式在缓存失效时,会不可避免地消耗额外的服务器资源与数据库连接。
因此,更优化的策略是在用户请求到达之前,主动将热点数据加载至Redis中,此过程即为缓存预热。具体实施上,通常选择在夜间等业务低峰期,预先执行加载任务,将所需数据存入缓存。如此一来,当高峰流量到来时,绝大部分查询便可直接命中缓存,从而从根本上减轻数据库的读取压力。
有同学讲到对于根据自己的业务逻辑选择性预热数据 ,比如抽奖业务用户进入抽奖页面就缓存一些用户抽奖用的数据,其实两者场景应用略有不同。缓存预热是于流量低谷期主动批量 加载数据,旨在应对高峰期的海量读取 压力。而根据用户行为动态缓存数据,是一种实时按需 的 "懒加载" 策略,其主要作用是分流写入 压力,两者解决的问题域不同。前者典型场景是高度可预测的集体性事件, 后者典型场景是用户行为驱动的个性化请求。
至此,关于缓存数据初始存储时机的问题已探讨完毕。接下来,我们将进入缓存更新策略的讨论。由于该环节同时涉及数据库与缓存的双写操作,其一致性与可靠性方案更为复杂,需要展开详细论述。
4.如何更新缓存
更新缓存的步骤特别简单,共两步:更新数据库和更新缓存。但这简单的两步中需要考虑很多问题。
1)先更新数据库还是先更新缓存?更新缓存时先删除还是直接更新?
2)假设第一步成功了,第二步失败了怎么办?
3)假设两个线程同时更新同一个数据,A线程先完成第一步,B线程先完成第二步怎么办?
其中,第1个问题就存在5种组合方案,下面逐一进行介绍。(3个问题因为紧密关联,下面就一起说明)
4.1 更新数据库 - 更新缓存
问题: 在 "先更新缓存,再更新数据库" 的组合下,若数据库更新失败,由于 Redis 不支持事务回滚,只能手动回滚缓存,这会引发复杂的数据一致性问题。
为了清晰展示这个困境,其核心冲突流程可以用下图概括:
sequenceDiagram participant A as 线程A participant Cache as 缓存 participant DB as 数据库 participant B as 线程B Note over Cache: 初始值: a A->>Cache: 1. 更新为 b Note over A: 保存旧值 a A->>Cache: 2. 保存旧值 a B->>Cache: 3. 更新为 c Note over B: 保存旧值 b A->>DB: 4. 更新数据库...失败! A->>Cache: 5. 回滚: 应改为? Note over A, Cache: 困境: 数据库当前值为c,<br>但线程A不知情,<br>若回滚到a则覆盖了c,数据错乱。
困境详解
- 问题的根源 :手动回滚需要知道"应该回滚到哪个值"。在多线程并发环境下,这个正确的值(上图中的
c)可能已经被其他线程修改,发起回滚的线程(线程A)无法感知。 - 加锁的可行性与其代价 :
- 可行吗? 可行。通过在更新缓存和数据库的整个过程中加分布式锁,可以强制串行化,从根本上杜绝上述并发问题。
- 代价是什么? 代价是系统的性能和复杂度 。
- 性能瓶颈:更新操作(特别是耗时久的数据库操作)会严重阻塞其他所有读写线程。
- 复杂性剧增 :这直接将我们引向了数据库领域的 "事务隔离级别" 问题(例如"可重复读"、"读已提交")。我们需要考虑:
线程A在更新过程中,线程C来读取缓存,应该让它看到最新的值
b,还是原来的值a?如果看到b,但数据库最终更新失败了,这就是脏读。
最终结论:不推荐使用"先更新缓存,再更新数据库"这个组合。 原因在于:我们仅仅是想使用缓存来提升读性能,但这个组合却迫使我们为了解决一致性问题,去实现一个重量级的、带锁的、需要考虑事务隔离级别的复杂方案。这无异于"杀鸡用牛刀",技术成本和带来的性能损耗远超过其收益。
4.2 删除缓存 - 更新数据库
此策略的优点是,若数据库更新失败,无需回滚缓存(因为缓存已删)。然而,它引入了一个更棘手的数据不一致 问题。其根本原因在于"删除缓存 "和"更新数据库 "这两个操作不是原子的,在并发读写场景下,会导致缓存中存入旧数据。
为了直观展示这一并发冲突,其典型时序困境可用下图说明:
- 常见的解决尝试与代价:
- 加锁 :为保证强一致性,可为该数据加分布式锁。写操作(删缓存+更新库)持有锁期间,所有并发的读操作必须等待。
- 代价 :系统可用性急剧下降。由于数据库更新(特别是复杂事务)通常较慢,这会导致大量读请求被长时间阻塞,违背了使用缓存提升性能的初衷。
结论:"先删缓存,再更新数据库"这一策略,在并发环境下会引发严重的缓存脏数据(旧值)问题。
若试图通过加锁 来强制实现一致性,则会以牺牲系统可用性(性能) 为代价。这实质上反映了分布式系统设计中经典的 "一致性"与"可用性"难以兼顾 的权衡困境(即CAP理论中的C与A的博弈)。
4.3 更新数据库 - 更新缓存
**问题1:**第一步(更新数据库)成功,第二步(更新缓存)失败
- **场景**:数据库更新成功,但后续缓存更新失败(网络异常、服务宕机等)。
- **后果**:数据库为新值,缓存为旧值,数据不一致。
- **常规解决**:采用重试机制,但重试延迟期间不一致窗口依然存在,且重试本身可能失败,处理复杂。
问题2:并发更新时序错乱
- **场景**:两个线程并发更新同一数据。
- **时序**:
1. 线程A更新数据库为 `a`
2. 线程B更新数据库为 `b`
3. 线程B更新缓存为 `b`
4. 线程A更新缓存为 `a`
- **结果**:数据库最新值为 `b`,缓存却为 `a`,再次不一致。
该组合虽然看似自然(先更新权威数据源,再同步缓存),但在分布式环境下,缺乏事务边界和时序保障,使得一致性问题难以规避。因此,在要求强一致或高并发的场景中,应避免直接使用此方案。
4.4 更新数据库 - 删除缓存
针对此方案,解决了方案3的问题2,不会出现并发更新缓存的问题,直接删除缓存数据谁先完成已经不重要了。对于方案3的问题1,方案4也有可能发生,但概率更低,因为redis删除操作要比更新操作简单。那么还有其他问题吗?假设线程A要更新数据,先完成第一步更新数据库,在线程A删除缓存之前,线程B要访问缓存,那么取得的就是旧数据。这是一个小小的缺陷。
4.5 删除缓存 - 更新数据库 - 删除缓存
这个方案跟方案4类似,也有可能遇到问题2类似的问题:线程A要更新数据库,先删除了缓存,线程B要读缓存,并且更新了缓存,线程A完成更新数据库;线程C也要访问缓存,此时就是访问的B读的旧数据。
不过这种数据不一致的情况出现概率比方案4更底,因为他需要3个线程配合才会出现问题。
相较于方案四 规避了第二部删除缓存失败的问题,因为缓存已经删除了.
在诸多无锁方案中,这是一个通过增加一次同步操作 ,在复杂度与一致性之间取得更好平衡的折中选择。它承认没有完美解,但致力于将不一致窗口和发生概率降到极低。
在实践中,第二次删除常被设置为延迟执行 (例如,在数据库更新完成后等待几百毫秒),目的是为了确保能清除掉在"更新数据库"期间可能被其他线程读入缓存的旧数据。这步"延迟"是对方案的常见增强,旨在进一步压缩不一致窗口。
不过这个组合也有一些问题要考虑,具体如下。
1)删除缓存数据后变相出现缓存击穿,此时该怎么办?此问题在前面已经给出了方案。
2)删除缓存失败如何重试?这个重试可以做得复杂一点,也可以做得简单一点。简单一点就是使用try...catch...,假设删除缓存失败了,在catch里面重试一次即可;复杂一点就是使用一个异步线程不断重试,甚至用到MQ。不过这里没有必要大动干戈。而且异步重试的延时大,会带来更多的读脏数据的可能性。所以仅仅同步重试一次就可以了。
3)不可避免的脏数据问题。虽然这个问题在组合5中出现的概率已经大大降低了,但是还是有的。关于这一点就需要与业务沟通,毕竟这种情况比较少见,可以根据实际业务情况判断是否需要解决这个瑕疵。
TIPS:讲到这里你可以发现,没有一个方案能够100%解决数据不一致的问题,这些操作的组合旨在不加锁限制性能的情况下 如何使得数据不一致的概率降到最低。任何一个方案都不是完美的,但如果剩下1%的问题需要花好几倍的代价去解决,从技术上来讲得不偿失,这就要求架构师去说服业务方,去平衡技术的成本和收益。
5.缓存的高可用设计
关于缓存高可用设计,其内容本可独立成章。但考虑到本书以场景实践为核心,而非理论详解,因此在此仅作概要性阐述,不展开讨论详细的实现机制,而是聚焦核心设计要点。
- 负载均衡:能否通过增加节点,以水平扩展的方式分散读请求压力。
- 数据分片:能否通过将数据划分到不同节点,以水平扩展的方式分散写压力与存储容量。
- 数据冗余:当单一节点数据失效时,其他节点是否持有副本数据,可立即接管其职责,保障服务不中断。
- 故障转移(Failover):在任一节点发生故障后,集群能否自动进行职责重新分配,确保整体持续可用。
- 一致性保证:在数据冗余、故障转移及数据再平衡的过程中,若出现意外,能否确保节点间或与底层数据源之间的数据一致性(注:这通常不能仅依赖缓存自身机制实现)。
若项目对缓存高可用有明确要求,Redis Cluster 模式是一个综合性解决方案,它完整涵盖了上述五个要点的设计。关于其具体配置与部署方式,可详细参阅 Redis 官方文档或相关专题教程。
6.缓存的监控
缓存上线后需建立持续的监控机制,通过关键指标分析来评估其效能并指导业务逻辑的调优。核心监控指标通常包括缓存命中率、内存使用率、慢查询日志、操作延迟及客户端连接数等。随着业务复杂度提升,可进一步扩展监控维度以覆盖更细致的场景。
实践中可依据技术栈与运维体系,选用自研监控平台或成熟的开源方案(如 RedisLive、Redis‑monitor 等)。监控工具的选型最终应结合实际的运维需求、技术储备与成本进行综合决策。
7.小结
这一篇关于"读缓存"的内容,虽然比较常规,但是......(看,这里也有个"但是")能切实支撑业务、解决痛点的技术,就是好技术!
目前,"读高并发"或"读响应慢"的场景,我们已经有了缓存这把利器。但数据库的"写压力"问题依然悬而未决。别走开,下一篇,我们将直面"写缓存"的挑战,看如何让数据库的写入操作也能"轻装上阵"。