1. 缓存系统概述
如上图,是一次最基本的网络请求。用户请求从界面(浏览器或 App 界面)到网络转发、应用服务再到存储(数据库或文件系统),然后返回到界面呈现内容。
随着互联网的普及,内容信息越来越复杂,用户数和访问量越来越大,我们的应用需要支撑更多的并发量,同时我们的应用服务器和数据库服务器所做的计算也越来越多。但是往往我们的应用服务器资源是有限的,数据库每秒能接受的请求次数也是有限的。如何能够有效利用有限的资源来提供尽可能大的吞吐量?是每个开发同学绕不开的课题。一个有效的办法就是引入缓存,打破标准流程,如下图1到4每个环节中请求可以从缓存中直接获取目标数据并返回,从而减少计算量,有效提升响应速度,让有限的资源服务更多的用户。
image.png
缓存可以应用上图1到4个的各个环节中,且不同环节缓存策略略有不同。本文将主要从3和4点讲解缓存的使用。
2. 缓存架构演变
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. 痛点和优化
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 探测中间服务器概念
- 1、业务实例汇总 key 访问情况并将上报到“热点 key 探测中间服务器”。
- 2、“热点 key 探测中间服务器”根据各业务实例上报的信息,识别该 key 是否为热点。
- 3、“热点 key 探测中间服务器”将识别结果通知到各业务实例。
- 如若为热点 key:业务实例自动预热缓存,等待流量访问。
- 如若非热点 key:业务实例释放该热点 key,释放内存占用。
详情见:参考
4. 缓存注意事项
4.1 key 设计
- 长度短:redis key 越短,占用内存越小
- 高命中率:命中率不高,缓存意义不大
4.1.1 value 设计
- 尽可能小,避免出现 big key
redis 是单线程机制,big key 会阻塞后续请求。
仅缓存必要的字段,不必要字段,及时瘦身
- 2、改少读多
变更频繁的数据不建议缓存,频繁的数据变更会导致缓存实现和一致性同步问题,反而会损耗系统性能
3、计算逻辑复杂的结果
4.1.2 缓存穿透
访问一个不存在的 key。由于实际上并不存在,所以每次都会访 DB
- 解决方案
- 缓存空值或默认对象(依据业务场景)
- 布隆过滤器
4.1.3 缓存击穿
某个 key 瞬间访问量过大,但突然过期,导致大部分流量打到了 DB
- 解决方案
- histrix 保护,对 DB 的访问限流
- 只有获得锁的线程才能去 DB 读取数据,并填充到缓存中
- 1.使用互斥锁
- 2.永不过期
- 3.资源保护
4.1.4 缓存雪崩
由于大部分 key 设置了相同的失效时间,某一时间大量缓存同时失效,导致大部分流量瞬间打到 DB,导致 DB 压力过大。
- 解决方法
- key 使用不同的过期时间,或者加一个随机时间
5. 实战经验
- 评估预计占用的缓存大小,避免占满 redis 集群和 JVM 内存
- 评估预计 QPS,如2.2架构。大量从 redis 中获取对象,会涉及平凡的对象反序列化操作,此处存在耗 CPU 操作。
- 严格禁止 bigKey。redis的单线程模型,出现 bigKey 会严重降低 redis 服务吞吐量。
- 必须设置过期时间
6. 踩坑记录
6.1. 本地缓存被污染
由于缓存在 JVM 内部,且保存在老年代。业务方拿去使用的时候,直接修改了缓存的数据,导致缓存数据不正确。
- 解决
取对象时,直接 copy 一份。(复制对象耗 CPU,不推荐)
将缓存对象设置成不可编辑。(推荐)
6.2. 缓存计算结果,而不是响应结果
缓存的 value 是 Response 对象,首次请求失败,导致缓存的数据为response.success=false。后续所有命中均操作失败。
- 解决
- 将缓存结果由 Response,调整为实际的计算结果
6.3. 本地内存彪高,触发频繁 full GC
初次引入本地缓存(之前是 redis )。将大量数据缓存在本地,导致 JVM 内存彪高。
- 解决
引入本地缓存前考虑预计内存,进而考虑是否值得接入本地缓存。
仅缓存热点 key,非热点 key 不缓存在本地
6.4. 降级到 redis 缓存,CPU 彪高
为优化 JVM 内存,将本地缓存降级到 redis。QPS 高场景,触发大量序列化和young GC,导致系统 CPU 彪高。
- 解决
评估 QPS,考虑是否可降级
仅缓存热点 key,非热点 key 不缓存在本地
7. 总结
在计算机世界里,缓存无处不在。但不管缓存系统如何设计,其本质都是空间换时间。也就是提升数据的获取速率。
缓存系统的设计各有千秋、各有优劣。没有最优秀的架构,只有最适合的架构。应该根据自身实际业务情况考虑缓存架构的设计。并从缓存命中率、数据库压力、数据一致性、系统吞吐量等综合评估设计的合理性。