网站左侧固定广告代码,wordpress首页是什么意思,自营店网站建设,网站开发软件开发项目文章内容已经收录在《面试进阶之路》#xff0c;从原理出发#xff0c;直击面试难点#xff0c;实现更高维度的降维打击#xff01; 文章目录 电商-多级缓存架构设计多级缓存架构介绍多级缓存请求流程负载均衡算法的选择轮询负载均衡一致性哈希负载均衡算法选择 应用层 Ngi…文章内容已经收录在《面试进阶之路》从原理出发直击面试难点实现更高维度的降维打击 文章目录 电商-多级缓存架构设计多级缓存架构介绍多级缓存请求流程负载均衡算法的选择轮询负载均衡一致性哈希负载均衡算法选择 应用层 Nginx 本地缓存实现热点数据存放互联网公司中的热点数据探测系统 数据缓存优化数据缓存过期时间 增量化缓存重建 缓存数据一致性保证Redis 数据一致性方案一同步删除缓存 - 旁路缓存策略方案二同步删除缓存 - 延时双删方案三异步删除缓存 - 基于 binlog 实现 本地缓存数据一致性本地双缓存策略 缓存最终一致性的保证总结 [电商-多级缓存架构设计] [多级缓存架构介绍][多级缓存请求流程][负载均衡算法的选择] [轮询负载均衡][一致性哈希][负载均衡算法选择] [应用层 Nginx 本地缓存实现] [热点数据存放][互联网公司中的热点数据探测系统] [数据缓存优化] [数据缓存过期时间] [增量化缓存重建] [缓存数据一致性保证] [Redis 数据一致性] [方案一同步删除缓存 - 旁路缓存策略][方案二同步删除缓存 - 延时双删][方案三异步删除缓存 - 基于 binlog 实现] [本地缓存数据一致性] [本地双缓存策略] [缓存最终一致性的保证][总结]
电商-多级缓存架构设计
多级缓存架构介绍
多级缓存架构在互联网电商场景中经常使用因为读、写请求量级较大RT 要求较低这里讨论的多级缓存架构仅以读请求性能提升为目的
为什么多级缓存架构可以提升读性能
缓存的本质就是 数据冗余 通过将数据一层一层冗余放在离用户更近、容量更小、价格更贵、速度更快的存储系统上以此来提升系统的访问性能
MySQL 作为数据库层数据存储成本低但同样访问速度较慢相比于 Redis 来说因为它是基于磁盘文件来进行读取的每次读、写数据都需要发生磁盘 IO一般来说对于比较热点的数据尽量避免它们的访问路径达到 MySQL 层而是在前置层就拦截下来
常用的多级缓存架构为本地缓存 分布式缓存 Redis DB这样的架构一般情况下足以满足大部分场景了但是如果想要支撑更高量级的查询请求就需要将缓存进一步前置来抗下请求
各层的性能瓶颈分析
DB 层的性能瓶颈显然在于读取数据需要进行的 磁盘 IO 因此想要进一步提升性能需要将数据从磁盘转移到速度更快的内存中RedisRedis 层的性能瓶颈在于 网络 IO JVM 应用请求 Redis 获取数据需要发起 IO 请求因此想要进一步提升性能需要减少 IO 消耗JVM 本地缓存JVM 本地缓存的性能瓶颈在于 Tomcat 服务器单个 Tomcat 服务器处理的并发请求数量有限根据请求的响应时间不同可能每秒处理几百、几千个请求通过增加 Tomcat 服务的数量也可以增加并发请求性能在 K8s 上就是多 Pod 部署这里主要讲如何通过缓存前置提升性能因此可以将数据的响应前置到 Tomcat 服务器之前NginxNginx 是高性能的 Web 服务器并发请求处理数量可以达到几万因此可以选择在 Nginx 本地内存存储部分数据、或者在 Nginx 层通过 Lua 脚本直接访问 Redis 的数据这样性能瓶颈就可以由 Tomcat 转到 Nginx 上来单机 Nginx 的性能有限因此使用两层 Nginx 的架构接入层 Nginx 和应用层 Nginx接入层 Nginx 负责将请求负载均衡到多个应用层 Nginx 上用作流量分发而应用层 Nginx 接近业务层处理业务逻辑用作热点缓存的读取 互联网公司常用多级缓存架构
缓存的层次越多容易造成数据不一致的可能性就越大、设计的复杂性越高并不是缓存层次越多越好这里只是列举多级缓存的方案、策略具体选择还要根据实际情况来
互联网常用的多级缓存为 JVM 本地缓存 Redis 缓存通过两级缓存可以满足大部分场景对于极其热点的数据可以采用 Nginx JVM 本地缓存 Redis 缓存三级缓存架构
JVM 缓存一般会存放一些热点数据提升热点数据访问性能避免热点数据将 Redis 分片打垮
由于 JVM 本地缓存容量有限如果仅仅在查询 Redis 数据时同时将数据放到本地缓存那么本地缓存的命中率是不高的
因此通常需要一个热点探测系统探测到热点 key再将热点数据放到本地缓存以此来提升缓存的命中率互联网大厂都有自研的热点探测系统比如得物的 Burning、京东的 hotkey 多级缓存请求流程
用户请求进入到接入层 Nginx 之后会经过负载均衡算法进行分发
用户请求被 负载均衡 到各个应用层 Nginx 上在应用层 Nginx 上读取 本地缓存 降低对热点数据后端服务的冲击如果 Nginx 本地缓存未命中则读取 Redis 缓存 作为第二层缓存 减少对 Tomcat 集群的压力如果 Redis 缓存未命中则进入到 JVM 应用层先读取 JVM 本地内存如果 JVM 本地内存未命中则访问 Redis 集群如果 Redis 集群未命中则访问 DB
多级缓存架构如何应用
可以看到整个访问请求的流程是比较复杂的看起来复杂其实不需要一次性把各个级别的缓存全部使用上而是根据实际情况来逐步对缓存架构进行完善
初始先不增加缓存使用 DB当发现 DB 存在瓶颈再使用 Redis 作为前置缓存优化当 Redis 不足以支撑查询请求可以将部分热数据放到 JVM 本地内存这样就进一步减少了访问 Redis 的网络 IO 消耗当发现 JVM 内存无法支撑更多的查询请求也就是 Tomcat 服务器支撑的并发请求数量达到了瓶颈就可以考虑将缓存进一步前置到 Nginx 层Tomcat 服务器可以支撑几百、几千的并发而 Nginx 可以支撑几万的并发两者性能不处于一个量级将访问频率更高的数据放到应用层 Nginx 的本地内存或者基于 OpenResty在 Nginx 层去访问 Redis 中的数据以此来提升访问性能比如变化频率较低的数据可以放在 nginx 的本地内存变化频率较高的数据可以通过 nginx lua 去访问 redis 缓存
接下来逐个介绍流程中相关的一些细节如负载均衡算法的选择、应用层 Nginx 读取本地缓存的实现方式、缓存更新的脏写问题等等
负载均衡算法的选择
从接入层 Nginx 负载均衡到多个应用层 Nginx 需要负载均衡算法来进行节点选择负载均衡算法有很多
常用的有轮询和一致性哈希
这里也主要介绍这两种负载均衡算法
轮询负载均衡
轮询的优势在于
对请求的负载更加均衡
缺点在于
相同的请求会被转发到不同的节点因此随着节点的增多缓存命中率不断降低
一致性哈希
一致性哈希的优势在于
相同的请求会被路由到同一台机器上如果出现节点宕机只会少部分缓存数据失效
缺点在于
相同的请求路由到同一台机器上会导致大量请求集中在某台机器上
负载均衡算法选择
当负载较低此时我们更追求缓存的命中率因此可以使用 一致性哈希当负载较高此时对热点数据的访问请求较多更希望让热点数据在多节点都存储此时更加追求请求的平均分散就不建议使用一致性哈希算法可以选择 轮询 来处理热点数据的访问
为什么负载较高时不建议使用一致性哈希算法呢
比如在 Redis Cluster 中就没有使用一致性哈希算法而是对数据进行 CRC16 校验之后key 对 16384 取模来决定放到数据放到哪一个槽位中比如有 3 个节点 node1 包含 0 到 5460 号哈希槽node2 包含 5461 到 10922 号哈希槽node3包含 10922 到 16383 号哈希槽这样就可以将数据放到对应的 Redis 节点中去
如果使用一致性哈希算法数据 key 会对 2^32 取模比如有 3 个节点每个节点都会负责一部分数据如下图 但是当其中一个节点挂掉之后顺时针方向向下的机器节点就要多承担 1/3 的流量假如当每个 Redis 节点负载都很高的情况下此时 n0 节点挂掉则 n1 节点需要承担 2/3 的流量就可能导致 n1 节点也挂掉之后 n2 节点也会挂掉导致整个集群的雪崩 应用层 Nginx 本地缓存实现
在应用层 Nginx 本地缓存可以使用 Lua Shared Dict 来实现Lua Shared Dict 是 OpenResty 提供的功能
OpenResty 是一个强大的 Web 平台将 Nginx 和 Lua 语言集成起来可以通过 Lua 开发相关业务逻辑进行热点数据的查询、获取等操作
通过 Nginx Lua可以有两种获取数据的方式
从 Nginx 本地内存获取数据从远程 Redis 中获取数据
热点数据存放
应用层 Nginx 会进行第一层的数据处理并且在这里会存储热点数据因此需要在这一层对热点数据进行统计
应用层 Nginx 会将请求上报到 热点发现系统 热点发现系统可以进行热点数据的统计并且将热点数据进行推送推送到应用层 Nginx 本地缓存
热点数据的存放位置不仅可以放在应用层 Nginx还可以放在 JVM 本地缓存中可以灵活选择
互联网公司中的热点数据探测系统
热点数据发现系统得物研发了 Burning京东零售研发了 hotkey都用于进行热点数据的探测这里以京东 hotkey 来介绍
热点探测系统主要应用的场景
MySQL、Redis 中频繁被访问的数据恶意攻击、爬虫请求、机器人
当出现热点 key 之后每秒出现几百万的访问请求会瞬间导致其所在的 Redis 分片集群瘫痪导致该 Redis 分片上其他的数据也无法访问因此热点 key 会成为 Redis 的性能瓶颈
以往热点 key 常使用二级缓存来解决即 JVM 本地缓存 Redis 缓存当去 Redis 缓存读取数据之后也向 JVM 本地缓存放一份由于 JVM 本地缓存容量有限这样的方式会导致热点数据命中率较低
因此需要一个统一的热点 key 的探测方案将探测出来的热点数据推送到 JVM 本地缓存JVM 本地缓存的访问性能相比于 Redis 缓存的访问性能高出很多因为不存在网络 IO 的开销
热点 key 参考资料
京东 hotkeyhttps://mp.weixin.qq.com/s/xOzEj5HtCeh_ezHDPHw6Jw得物 Burninghttps://tech.dewu.com/article?id23
数据缓存优化
数据缓存过期时间
对于缓存数据的加载有两种
设置过期时间适合热点、易更新数据如库存数据缓存几秒可以短时间内不一致不设置过期时间适合非热点、长期访问数据如用户信息、店铺信息、类别、订单等信息
对于 缓存数据淘汰 来说设置过期时间的数据到时间后就会自动删除而不设置过期时间的数据我们需要控制缓存的大小当缓存空间满了之后通过淘汰策略进行数据的删除
对于 淘汰策略 来说有多种算法可供选择 LRULeast Recently Used最近最少使用 根据访问 时间 淘汰最久未被访问的数据但是对于大批量数据访问来说会导致缓存命中率下降 LFULeast Frequently Used最近最不常用 根据访问 频率 淘汰最不常访问的数据如果访问内容发生较大变化会导致缓存命中率下降 ARCAdaptive Replacement Cache自适应缓存替换算法结合了 LRU、LFU 两者的优势既能根据 时间 又能根据 频率 进行数据的淘汰
对于缓存的加载可以使用 缓存旁路模式 先写数据库再写缓存对于更新来说先更新数据库再删除缓存
增量化缓存重建
对于复杂数据来说缓存重建的成本较高可以通过两个步骤减少缓存重建成本
对复杂数据进行维度划分根据增量数据的变更只进行对应维度的缓存重建
比如对于商品来说有多个维度基础信息、图片、规则、介绍等等哪个维度的数据更新了就只需要重建对应维度的缓存数据维度化之后的缓存冲减成本大大降低 缓存数据一致性保证
缓存数据一致性即在数据库数据发生变更后需要对缓存中的数据进行更新来保证缓存数据的一致性
多级缓存架构下不同级别缓存的特性存在不同因此缓存数据的更新策略也会存在不同
对于分布式缓存 Redis 来说一份数据在 Redis 中只存储一份可以存储较多的数据而对于 JVM 缓存来说热点数据会在每个 JVM 节点上都存储一份并且本地缓存容量较小只存储少量数据
因此这两种缓存存在一定的差异性对应的 缓存更新策略 也会不同
Redis 数据一致性
Redis 的数据一致性一般通过两种方式来保证
同步删除缓存数据在更新接口内部通过延时双删来保证数据的一致性异步删除缓存数据通过监听数据库的 binlog 日志来实现缓存数据的更新
方案一同步删除缓存 - 旁路缓存策略 对于缓存数据的一致性使用通用方案就可以保证大部分场景下的数据一致性基于实现成本和数据一致性的考虑旁路缓存策略 是一种比较通用的缓存一致性更新策略流程为
读场景先从缓存读取数据如果命中直接返回如果未命中则去数据库中读取写场景下先更新数据库中的数据之后再去失效对应的缓存
使用缓存的目的是提升系统性能但同时也失去了一定的数据一致性因此使用缓存的场景一定是可以容忍短暂的数据不一致问题的那么因此也就没有必要为了保证比较强的数据一致性去投入较大的实现成本
在旁路缓存策略中在极端情况下读写操作时序错乱时也会发生数据不一致的问题。如下不过这种属于极其小概率事件了解即可
线程 A写操作线程 B读操作读取缓存未命中读取数据库旧值更新数据库数据失效缓存将数据库旧值放入缓存脏数据
方案二同步删除缓存 - 延时双删 基于上边的问题也有优化方案可以减少发生这种事件的概率比如 延时双删 即两次删除缓存如下
第一次删除缓存是为了更快的达到最终一致性效果第二次会延时一段时间后再次去删除缓存就是为了删除可能存在的脏数据
其次需要考虑延时时间的设置 脏数据来源于读操作读操作的耗时最多就是去读取数据库从节点上的旧数据那么这里的延时时间需要保证大于读操作时间 数据库主从同步延时时间
虽然这种方案实现起来简单但也存在不足 延迟时间是预估的并不一定准确 延迟等待第二次删除缓存会阻塞操作存在性能消耗可以使用异步线程来完成第二次删除
不过通过延时双删已经可以保证比较好的数据一致性了
方案三异步删除缓存 - 基于 binlog 实现
除了延时双删还存在其他的缓存更新策略如 基于 binlog 实现缓存更新
阿里巴巴开源了 Canal 就是监听 binlog 来完成缓存更新工作原理
Canal 模拟 MySQL 的从库向主库发送数据同步请求MySQL 主库向 Canal 发送数据同步的 binlogCanal Server 解析 binlog 并存储应用创建 Canal 客户端与 Canal Server 通信获取对应 binlog完成对应业务操作
这里直接通过客户端和 Canal Server 通信存在性能问题同一时刻只能一个客户端和 Canal Server 通信单节点难以承受较大数据规模的缓存更新任务
因此从 Canal 1.1.1 版本之后Canal Server 支持将 binlog 投递至 MQ通过 MQ 可以实现多个客户端去消费完成大量数据的缓存更新任务
不过针对互联网大多数场景来说完全没有必要使用 Canal 来完成缓存数据的更新通过缓存旁路策略完全可以满足数据一致性需要再引入 Canal 会导致架构复杂并需要维护 Canal 的可用性实现成本较高
基于 binlog 保证缓存数据一致性的流程
具体实现方式通过 Canal RocketMQ 来保证缓存数据库的一致性
对于数据直接更新 DB 的情况通过 canal 监控 MySQL 的 binlog 日志并且发送到 RocketMQ 中MQ 的消费者对数据进行消费并解析 binlog过滤掉非增删改的 binlog那么解析 binlog 数据之后就可以知道对 MySQL 中的哪张表进行 增删改 操作了
接下来只需要拿到这张表在 Redis 中存储的 key再从 Redis 中删除旧的缓存即可 关于 binlog 消费一致性的保证摘自 Canal 仓库 Wiki
binlog本身是有序的写入到mq之后如何保障顺序是很多人会比较关注在issue里也有非常多人咨询了类似的问题这里做一个统一的解答
1、canal目前选择支持的kafka/rocketmq本质上都是基于本地文件的方式来支持了分区级的顺序消息的能力也就是binlog写入mq是可以有一些顺序性保障这个取决于用户的一些参数选择
2、canal支持MQ数据的几种路由方式单topic单分区单topic多分区、多topic单分区、多topic多分区
canal.mq.dynamicTopic主要控制是否是单topic还是多topic针对命中条件的表可以发到表名对应的topic、库名对应的topic、默认topic namecanal.mq.partitionsNum、canal.mq.partitionHash主要控制是否多分区以及分区的partition的路由计算针对命中条件的可以做到按表级做分区、pk级做分区等
3、canal的消费顺序性主要取决于描述2中的路由选择举例说明
单topic单分区可以严格保证和binlog一样的顺序性缺点就是性能比较慢单分区的性能写入大概在2~3k的TPS多topic单分区可以保证表级别的顺序性一张表或者一个库的所有数据都写入到一个topic的单分区中可以保证有序性针对热点表也存在写入分区的性能问题该方式保证同一张表的所有 binlog 都投递到同一个分区中保证同一张表的 binlog 日志有序单topic、多topic的多分区如果用户选择的是指定table的方式那和第二部分一样保障的是表级别的顺序性(存在热点表写入分区的性能问题)如果用户选择的是指定pk hash使用表中的主键进行 hash选择投递到哪一个分区该方式可以保证主键相同的数据的 binlog 可以有序的方式那只能保障的是一个pk的多次binlog顺序性 ** pk hash的方式需要业务权衡这里性能会最好但如果业务上有pk变更或者对多pk数据有顺序性依赖就会产生业务处理错乱的情况. 如果有pk变更pk变更前和变更后的值会落在不同的分区里业务消费就会有先后顺序的问题需要注意
如果看完之后仍然感觉不太理解可以自行搜索一下 RocketMQ 如何保证有序性来结合理解一下
本地缓存数据一致性
对于本地缓存来说通常一些热点数据会放在本地缓存这部分热点数据的量通常很小因此可以采取 主动更新 过期时间 的方式去刷新本地缓存
对于不容易发生变化的数据比如促销信息可以设置过期时间为 30min再配合定时任务比如 1min 更新一次去刷新本地缓存数据对于变化比较频繁的数据可以将过期时间设置为 1min再配合定时任务比如 30ms 更新一次去刷新本地缓存数据 本地双缓存策略
在本地使用单缓存可能会存在一定的 RT 尖刺也就是当本地缓存过期时此时读请求需要去远程获取数据导致响应时间存在波动因此可以考虑使用双缓存策略同时创建两份本地缓存
两份缓存的过期时间不同
第一份缓存的过期时间设置为写之后的 30min 过期强制写之后 30min 过期保证可以即使刷新缓存第二份缓存的过期时间设置为读、写之后的 40min 过期这样可以延迟第二份缓存的过期时间
读、写操作还是以第一份缓存为主如果读操作发现第一份缓存没有数据再去第二份缓存获取对应数据如果都没有则远程读取数据同时放入到两份缓存中
这样当第一份缓存过期之后第二份缓存中还存在数据因此读操作可以从第二份缓存中获取数据在这期间第一份缓存就完成了数据的加载第二份缓存作为备用在第一份缓存过期时可以保证对外提供数据当第一份缓存加载好之后读请求仍然从第一份缓存中读取数据
创建本地双缓存细节如下
Bean(name promotion)
public CacheString, Result promotionCache() {int rnd ThreadLocalRandom.current().nextInt(10);return Caffeine.newBuilder()// 设置过期时间为 30min再加上随机时间.expireAfterWrite(30 rnd, TimeUnit.MINUTES).initialCapacity(20).maximumSize(100).build();
}Bean(name promotionBack)
public CacheString, Result promotionCacheBack() {int rnd ThreadLocalRandom.current().nextInt(10);return Caffeine.newBuilder()// 设置最后一次访问后的过期时间.expireAfterAccess(40 rnd, TimeUnit.MINUTES).initialCapacity(20).maximumSize(100).build();
}本地双缓存的刷新
对于本地双缓存的刷新需要通过定时任务来完成刷新即发现第一份缓存或者第二份缓存中的数据失效了就去远程获取数据放入到本地双缓存中
Async
Scheduled(initialDelay5000*60,fixedDelay 1000*60)
public void refreshCache(){// 1、检查是否开启本地缓存if(isAllowLocalCache()){// 2、获取本地缓存的 keyString cacheKey ...;// 3、如果发现某一个缓存的数据失效就去完成缓存数据的加载if(null promotionCache.getIfPresent(cacheKey) || null promotionCacheBack.getIfPresent(cacheKey)){// 4、从远程获取数据比如 Redis 或者 DBResult result getFromRemote();if(null ! result){// 5、将数据放入双缓存if(null promotionCache.getIfPresent(brandKey)) {promotionCache.put(brandKey,result);}if(null promotionCacheBack.getIfPresent(brandKey)) {promotionCacheBack.put(brandKey,result);}} else {log.warn(从远程获得{} 数据失败, cacheKey);}}}
}缓存最终一致性的保证
无论如何保证缓存数据都可能存在不一致的情况因此需要一种措施来保证缓存数据的最终一致性
这种措施也就是 过期时间
只要对缓存数据设置过期时间最后缓存数据就一定会删除那么也就一定会达到最终一致性
总结
综上介绍了多种保证缓存一致性的解决方案软件工程没有绝对的银弹在真正使用场景中需要从业务场景对数据不一致时间、实现成本、维护成本等多个方面进行评估选择适合的方案并在真实场景中压测、实验来对比方案的优劣
最后再说一下既然使用缓存肯定就没办法保证绝对的一致性
比如在 Linux 内核中使用了 PageCache在内存中 来优化 IO 性能所有的 IO 操作数据并不是直接写入到磁盘中而是先放入到了 PageCache 中再统一时间将 PageCache 的数据刷入到磁盘中以此来提升磁盘 IO 的效率
那么在服务器异常关机的情况下丢失数据的原因就是数据没有及时的从 PageCache 中刷入到磁盘中可以发现在操作系统层面上也会存在缓存数据丢失的问题那么在软件层面上就更不可避免地会出现数据不一致的情况了