分布式多级缓存系统设计与实战
一、缓存系统概述
如上图,是一次最基本的网络请求。用户请求从界面(浏览器或 App 界面)到网络转发、应用服务再到存储(数据库或文件系统),然后返回到界面呈现内容。
随着互联网的普及,内容信息越来越复杂,用户数和访问量越来越大,我们的应用需要支撑更多的并发量,同时我们的应用服务器和数据库服务器所做的计算也越来越多。但是往往我们的应用服务器资源是有限的,数据库每秒能接受的请求次数也是有限的。
如何能够有效利用有限的资源来提供尽可能大的吞吐量?是每个开发同学绕不开的课题。一个有效的办法就是引入缓存,打破标准流程,如下图 1 到 4 每个环节中请求可以从缓存中直接获取目标数据并返回,从而减少计算量,有效提升响应速度,让有限的资源服务更多的用户。
缓存可以应用上图 1 到 4 个的各个环节中,且不同环节缓存策略略有不同。本文将主要从 3 和 4 点讲解缓存的使用。
二、缓存架构演变
2.1. 无缓存架构
如上图,是一次最基本的网络请求。请求从网络层直接请求到 DB。此时请求耗时最大卡点在数据库的磁盘 IO 上。
2.2. 引入分布式缓存数据库
针对 2.1 无缓存架构的数据库磁盘 IO 耗时,可添加了一道缓存数据库例如 redis。借助缓存中间件,可消除数据库的 IO 瓶颈。快速返回数据。如下图:
通过缓存数据库 1、可防止流量直接打到数据库层,减缓数据库压力。2、缓存快速返回,可提高请求查询速率。
2.2.1 为什么选择 redis?
纯内存操作,无磁盘 IO 耗时
key-value 数据库,时间复杂度 O(1),相比数据库的 O(Log n),访问速度更快
IO 多路复用线程模型,IO 阶段无阻塞
此时系统卡点在缓存数据库的网络通信上。即使缓存数据库读取数据很快,但是和应用服务间仍然隔着一层网络通信。
2.3. 引入 JVM 本地缓存
针对 2.2 缓存数据库架构,访问缓存数据库的网络通信问题,可在 JVM 应用层添加本地缓存,解决网络 IO 问题。如下图:
在应用内部新增本地缓存,使流量在应用层直接返回。避免进一步访问到 redis。
本架构虽然可大大提高数据读取速率,但其成本也是更高的。
需要在多台 JVM 机器上冗余缓存,对内存要求高。
缓存在多台 JVM 实例,数据一致性维护成本高。
建议根据自身业务场景,从以下 3 方面考量是否才有本地缓存。
业务访问量 QPS
硬件资源内存是否充足
变更场景是否频繁
常用本地缓存
JDK MAP
guavaCache
Caffeine Cache
2.3.1 数据读取流程
按优先级依次从本地、redis、DB 中读取数据。实现了本地(一级缓存)、缓存数据库(二级缓存)和 DB 的多级缓存架构。
三、痛点和优化
3.1 数据一致性问题
存在多级缓存,虽然大大提高了数据的读取速率。但是数据散落在各个不同的区域,数据一致性就是一个绕不过去的问题。特别是针对本地缓存,同时散落在多个多台 JVM 实例中。数据变更时,必须同步修改 redis、本地缓存和 DB。以下是基于 canal + 广播消息实现的一致性异步处理方案。
DB 修改数据
通过监听 canal 消息,触发缓存的更新
针对 redis 缓存中,因为集群中只共享一份,直接同步缓存即可
针对本地缓存,因为集群中存在多分,且分散在不同的 JVM 实例中。故再借助广播 MQ 机制,通知到各个业务实例。同步本地缓存
3.1.1 同步缓存机制
直接删除缓存,查询时直接加载
优点:操作简单
缺点:未命中缓存时,取重新加载。此次查询请求慢。
重新加载缓存
优点:提前设置缓存,查询效率高
注意:此方案同步缓存,为先 DB 操作、后异步同步缓存。会存在短暂 DB 和缓存不一致场景。需根据自身业务场景考量,如有必要,可前置删除缓存,再 DB 操作。
3.2. 热点 key 监控
以上架构,系统缓存只能被动加载。只有 key 被访问后,系统才能触发加载。在高并发的情况下,如一直出现缓存穿透,大量流量请求到数据库,对数据库还是很大的考验。所以优秀的缓存系统,应该能自动识别出热点 key。前置将数据缓存下来。
3.2.1 热点 key 探测
引入缓存中间调度服务:热点 key 探测中间服务器概念
业务实例汇总 key 访问情况并将上报到“热点 key 探测中间服务器”。
“热点 key 探测中间服务器”根据各业务实例上报的信息,识别该 key 是否为热点。
“热点 key 探测中间服务器”将识别结果通知到各业务实例。
如若为热点 key:业务实例自动预热缓存,等待流量访问。
如若非热点 key:业务实例释放该热点 key,释放内存占用。
四、缓存注意事项
4.1 key 设计
长度短:redis key 越短,占用内存越小
高命中率:命中率不高,缓存意义不大
4.1.1 value 设计
尽可能小,避免出现 big key
redis 是单线程机制,big key 会阻塞后续请求。
仅缓存必要的字段,不必要字段,及时瘦身
改少读多
变更频繁的数据不建议缓存,频繁的数据变更会导致缓存实现和一致性同步问题,反而会损耗系统性能
计算逻辑复杂的结果
4.1.2 缓存穿透
访问一个不存在的 key。由于实际上并不存在,所以每次都会访 DB
解决方案
缓存空值或默认对象(依据业务场景)
布隆过滤器
4.1.3 缓存击穿
某个 key 瞬间访问量过大,但突然过期,导致大部分流量打到了 DB
解决方案:
使用互斥锁
只有获得锁的线程才能去 DB 读取数据,并填充到缓存中
永不过期
资源保护
histrix 保护,对 DB 的访问限流
4.1.4 缓存雪崩
由于大部分 key 设置了相同的失效时间,某一时间大量缓存同时失效,导致大部分流量瞬间打到 DB,导致 DB 压力过大。
解决方法:
key 使用不同的过期时间,或者加一个随机时间
五、实战经验
评估预计占用的缓存大小,避免占满 redis 集群和 JVM 内存
评估预计 QPS,如 2.2 架构。大量从 redis 中获取对象,会涉及平凡的对象反序列化操作,此处存在耗 CPU 操作。
严格禁止 bigKey。redis 的单线程模型,出现 bigKey 会严重降低 redis 服务吞吐量。
必须设置过期时间
六、踩坑记录
6.1. 本地缓存被污染
由于缓存在 JVM 内部,且保存在老年代。业务方拿去使用的时候,直接修改了缓存的数据,导致缓存数据不正确。
解决方法:
取对象时,直接 copy 一份。(复制对象耗 CPU,不推荐)
将缓存对象设置成不可编辑。(推荐)
6.2. 缓存计算结果,而不是响应结果
缓存的 value 是 Response 对象,首次请求失败,导致缓存的数据为 response.success=false。后续所有命中均操作失败。
解决方法:
将缓存结果由 Response,调整为实际的计算结果
6.3. 本地内存彪高,触发频繁 full GC
初次引入本地缓存(之前是 redis )。将大量数据缓存在本地,导致 JVM 内存彪高。
解决方法:
1、引入本地缓存前考虑预计内存,进而考虑是否值得接入本地缓存。
2、仅缓存热点 key,非热点 key 不缓存在本地
6.4. 降级到 redis 缓存,CPU 彪高
为优化 JVM 内存,将本地缓存降级到 redis。QPS 高场景,触发大量序列化和 young GC,导致系统 CPU 彪高。
解决方法:
评估 QPS,考虑是否可降级
仅缓存热点 key,非热点 key 不缓存在本地
七、总结
在计算机世界里,缓存无处不在。但不管缓存系统如何设计,其本质都是空间换时间。也就是提升数据的获取速率。
缓存系统的设计各有千秋、各有优劣。没有最优秀的架构,只有最适合的架构。应该根据自身实际业务情况考虑缓存架构的设计。并从缓存命中率、数据库压力、数据一致性、系统吞吐量等综合评估设计的合理性。
作者:政采云技术
链接:https://juejin.cn/post/7225634879152570405
来源:稀土掘金
评论