先提前准备好的多级缓存实现

865885b0881611ebb6edd017c2d2eca2.png

安全感,是尘埃落定的安稳,是不离不弃的笃信。

背景

在以Redis作为缓存方案的前提下,随着数据量的增大和数据结构的复杂造成的性能问题、访问请求数量的增加网络IO所占的资源不可忽略、Redis故障等问题接踵而至,一个多级缓存的实践是刻不容缓的。

多级缓存的命名暂且为MLC(Multi-layer-cache)

公司拥有B2B的项目电商商家数量和类型很多,作为供应商时不时会做一些”商品秒杀”,”商品推广”,”商品竞价”等活动,导致下单汇总、库存物流、商品详情等链路应用存在缓存热点访问的情况,当然在每次活动推广前热点数据可以在内网模拟测试,交由数据工程师大致分析出热点数据,这样解决热点数据的不确定性等因素。但在访问数据时,应用层少量热点key访问产生大量向缓存系统的访问请求:占据宽带资源,冲击缓存系统,最终影响应用层整体稳定性。

  • 在原有项目基础上实施多级缓存解决方案的痛点
    • 效果验证:如何让应用层查看本地缓存命中率、热点 key 等数据,验证多级缓存效果
    • 数据一致性:应用层缓存和分布式缓存各层缓存之间的数据一致性问题
    • 安全接入:尽量减少接入中对系统的伤害

主要解决热点探测本地缓存,减少热点访问时对下游分布式缓存服务的冲击,避免影响应用服务的性能及稳定性。

多级缓存方案的准备

本地缓存选择

EhCache:其作为java进程内部的一个缓存框架,具有灵活性强,轻小等特点,稳定、流行应用的兼容是其优势。

  • 内存容量问题
    • EhCache支持两种默认的存储方式,一种是全部基于内存的模式,另外一种可以存在磁盘。
    • EhCahe同时提供了LRU、LFU、FIFO淘汰算法,基础属性支持热配置、支持的插件多,灵活的使用方式。
    • EhCache提供了监听器模式,比如在缓存管理器监听器,缓存监听器,方便在缓存相关的操作行为的时候实现广播和特定的功能需要以及满足更多的场景。
  • 分布式方面的应用
    • EhCache在分布式方面存在天生的缺陷,稳定性,一致性一直存在问题。
    • RMI模式
      • Java内部天生支持RMI为我们带来了不必要的引入,调用者只需要注重key和value之间序列化协议。
      • 手动配置,配置集群需要的手动编写其他节点信息,在实际开发中线上线下环境的差异自然是配置信息的不同,且人工操作易出错。
      • 自动发现机制,利用多播的实现方式实现集群中的节点的发现。较与手工配置更灵活简单且不易出错。
    • JGroup的模式
      • 配置文件复杂
      • 对比RMI模式提供基于TCP的单播模式和UDP的广播模式,同时依赖第三发的包。
    • EhCache Server
      • 独立缓存服务器,内部使用EhCache作为缓存,利用上两者方式进行集群(EhCache Server 相当于一个包装者,对外打包)
      • 对外提供编程语言无关的接口,其基于Http的RESTful或者是SOAP。

总结EhCache:EhCache支持粒度为元素级别的缓存操作和淘汰算法。Spring的框架和注解都支持EhCache,其生态圈丰富与稳定。在许多生产环境中都是使用EhCache,易于上手

Caffieine是一种高性能的缓存库,几乎是基于Java 8的最佳缓存库。从设计Guava缓存和ConcurrentLinkedHashMap的经验中Caffeine提供一个内存缓存

  • Caffeine具有以下的优点
    • 可以自动加载数据到cache,这个操作方式可以是异步的操作,当然同时支持异步的刷新操作。
    • 可以基于容量或者时间频率的回收和过期策略
    • Key和value通过弱引用或者软引用包装,在gc的策略有一定的优势。
    • 在key进行回收或者删除操作的时候有监听器,通知操作
    • 在缓存访问具有统计功能
    • 当然Caffeine在缓存的功能特性还有很多。
  • 缺点
    • 受应用内存的限制,容量有限。
    • 没有持久化
    • 分布式环境下无法数据同步

具体了解前往官网学习Caffeine官网

自建缓存当觉得开源Cache不够透明、不够细节或者对于项目是重量级,那可以选择自建缓存组件。虽说是重复造轮子但也是吹牛的资本,同时我们能够更加的维护好自己核心组件。

  • 如你只需要存几个值,HashMap是很好的选择。
  • 如果你要保证线程安全,可以使用ConcurrentHashMap构建自己的缓存组件。
  • 关于淘汰机制可以自己实现对象来控制,给相应的key添加一个过期时间来处理,后台开启一个线程进行过期策略的处理,当然可以继承LinkedHashMap对象进行LRU和FIFO的淘汰算法进行过期key的处理和管理。

在选择本地缓存中以稳定,性能优秀的条件选择Caffeine,从官网中Caffeine与其他的性能对必:
image.png
关于Caffeine的使用与底层实现还需要后面花大把时间学习,所以时间成本也要考虑进去。

分布式缓存选择

由于原项目已使用Redis,为了不增加系统复杂度,则选择Redis作为分布式缓存

  • 当然选择Redis的原因不止一点
    • 丰富的数据结构
    • LUA脚本(Lua scripting)
    • LRU驱动事件
    • 持久化
    • 通过 Redis哨兵(Sentinel和自动分区(Cluster)提供高可用性

    ……

Caffeine和Redis的优缺点让他们可以有效的互补。

如对于分布式缓冲的选型想有更多的参考,点击缓存组件选型

MLC 整体架构

image.png

  • MLC 整体架构如上图,共分为二层
    • 存储层:提供基础的kv数据存储能力,同时也提供最终DB。
    • 应用层:提供统一客户端给应用服务使用,内置“热点探测”、“本地缓存”等功能,对业务透明。

应用层的定位:sdk,对业务透明,热点,控制并发(限流)。

随着来自性能的压力逐渐增大,同样储存层也需要数据切割。此时可在两层之间添加代理层,为应用层提供统一的缓存使用入口及通信协议,承担分布式数据水平切分后的路由功能转发工作。
当然代理层的增加帮助应用层与储存层的解耦,同时带来架构的复杂性降低可用性。

现主要考虑获取数据的细节数据一致性问题及在应用层客户端的热点探测等功能。

多级缓存的读写

方案整理

在分布式系统中,缓存和数据库同时存在时,如果有写操作的时候,要明确缓存之间写入顺序。读写操作维护方案需要一步步推理出来。
在一个时间点上出现双写操作时:
image.png

  • 先操作数据库,再操作缓存:则步骤应该为[3,2,1,6,5,4]。在后面读操作加载缓存后也不会出现数据一致性问题。

在一个时间点上出现读写操作时:
image.png

  • 先操作缓存,再操作数据库:如果步骤顺序按照[1,2,3,4,5,6,7,8]走下去那没问题,但是当步骤顺序为[1,2,3,4,6,7,5,8]、[1,3,4,2,6,7,8,5]等时老数据会存在于缓存,每次读都是老数据,缓存与数据与数据库数据最终不一致问题。

  • 先操作数据库,再操作缓存:如果步骤顺序按照[3,2,1,4,5,6,7,8]走下去,在允许脏读的情况下达到数据的最终一致性。显然这个方案没有太大的缺陷,但是有可能步骤[1,2]中任一个删除缓存失败,虽然概率比较小,且明显优于上个方案,则采用这个方案。

为保证读写、双写都能正常运行所以采取写操作先操作数据库,再操作缓存方案。

Cache Aside Pattern,其作为多级缓存的一个上乘实践方案数据的

  • 读取请求详细步骤为:

    • 每次读取数据,都从cache里读
    • 如果读到了,则直接返回,称作 cache hit
    • 如果读不到cache的数据,则从db里面捞一份,称作cache miss
    • 将读取到的数据,塞入到缓存中,下次读取的时候,就可以直接命中
  • 写请求详细步骤如下:

    • 将变更写入到数据库中
    • 删除缓存里对应的数据

由于读写中存在删除缓存失败的问题MLC作为分布式应用本地缓存在服务之间是隔离的,为了保证数据最终一致性的问题,需要对操作步骤进行填充。

方法一:将数据更新和缓存删除动作,放在一个事务里,同进退。

方法二:缓存删除动作失败后,重试一定的次数。如果还是不行,大概率是缓存服务的故障,这时候要记录日志,在缓存服务恢复正常的时候将这些key删除掉。

方法三:再多一步操作,先删缓存,再更新数据,再删缓存。这样虽然操作多一些,但也更保险一些。

方法四:
image.png

课堂小知识:binlog 作为MySQL的日志文件用于记录数据更新或者潜在更新(比如DELETE语句执行删除而实际并没有符合条件的数据),同样用于mysql主从复制中。

上面流程图中DB->asyn update->Redis Cache可以通过数据库的binlog来异步淘汰key,可以借助第三方工具如阿里的canal将binlog数据收集并发送至MQ然后通过ACK机制确认处理,这样保证了DB->Redis Cache的数据一致性。DB->Redis Cache之间使用binlog这个方案增加系统的复杂度,选择适合的方案。

依旧会存在脏读(删除缓冲操作失效,来了读请求,会出现脏数据,又或者读请求在从数据库获取数据后准备更新Cache时候,完成了一个写请求导致脏数据)。

延伸拓展:既然缓存同步模式有那么多可以选择,如Read Through PatternWrite Through PatternWrite Behind Caching Pattern,要如何选择呢?业务、业务、业务,重要的事说三遍。这些模式使用的也非常广泛,但由于对业务大多数是无感知的,很多人并不会专门整理。他们大多数存在于中间件中或者比较底层的数据库中实现的,写业务代码可能接触不到这些东西。比如Write Behind Caching,先落地到缓存,然后有异步线程缓慢的将缓存中的数据落地到DB中。这个设计的好处就是让数据的I/O操作飞快,因为异步,write back还可以合并对同一个数据的多次操作,所以性能提高非常可观。在你使用这个模式前你得评估一下你的数据是否可以丢失,以及你的缓存容量是否能够经得起业务高峰的考验。但它现在和我们的业务需求没关系。

pub/sub

上节已经基本处理好二级缓存Redis Cache与DB之间交互的流程,MLC作为分布式应用可搭建集群,要处理好一级缓存Caffeine与二级Redis Cache之间的数据更新保证数据一致性。

当一台服务中MLC进行数据删除/更新时,如何关联其他服务的MLC的一级缓存呢?

保证多机数据一致性的方式一般有两种,一种是推模式,这种方式实时性好,但是推的消息有可能会丢;另一种是拉模式,压力分摊,数据也不会丢失,但是实时性不好。

MLC主要结合推/拉两种模式来保证多机数据的一致性。

拉主要是基于消息偏移量的方式,而推主要是基于redis的pub/sub机制。实现上,push:基于原子的广播模式,pull:基于定时任务。
image.png

public class RedisListMessageListener implements RedisPubSubListener<String, Object>{
	@Override
    public void message(String channel, String message) {
    	// update time

    	// pull message
    }
}
复制代码

offset偏移量,offset表示该服务处理缓存消息的位置,每次处理消息后就更新offset的位置。

  • 允许消息的重复消费
    • 热点key删除动作:因为一级缓存即使删除,也会根据二级缓存重建。
    • 热点key获取动作:获取数据都是先删除再从DB获取。

然而关于list数据的清除可以根据需求进行相应的清理。

关于pub/sub重连问题可以设计成定时任务,将最后一次处理推消息的时间A和最后一次处理拉消息的时间B记录下来。如果时间相差大于8s则认为断线,然后发起重连尝试,同时获取Redis最新偏移量到本地,处理数据并更新本地偏移量与更新时间。

  • 为什么使用redis pub/sub 实现一级缓存的更新同步
    • 使用缓存本来就允许脏读,所以有一定的延迟是允许的 。
    • redis本身是一个高可用的数据库,并且删除动作不是一个非常频繁的动作所以使用redis原生的发布订阅在性能上是没有问题的。

同样Zookeeper也可以作同步方案,MLC可以对zookeeper指定的热点缓存对应的znode进行监听,如果有变化他立马就可以感知到了。其实pub/sub更适合作为自己公司项目中此次MLC本地缓存的同步方案,因为公司的项目体量不是很大,且Zookeeper的任务量已经很繁重,故采用Redis的pub/sub。

缓存设计细节

image.png

课堂小知识:缓存击穿。有某个key经常被查询或者这是个冷门key时,这时候突然有大量有关这个key的访问请求,这样会导致大并发请求直接穿透缓存,请求数据库,瞬间对数据库的访问压力增大。

  • 缓存击穿的预防
    • 当多个请求获取某个缓存不存在的值时,先获取锁(实现使用的是Redis分布式锁,lock key为Golbal index + key)
    • 未获取锁的请求堵塞
    • 获取锁的请求从数据库获取值,并update 至缓存
    • 堵塞线程被唤醒后,先从缓存判断key是否存在,如存在直接返回
    • 如不存在,重复上述操作

但当DB并不存在key的value,上述的操作反而加重了系统的负担,且影响上层系统的性能。所以在设计中需要设置空值对象来防止上述无用的操作,缺点就是消耗内存空间(在是否使用空值上需要对业务进行权衡,代码实现中也需要设置使用空值开关)

public final class NullObject implements Serializable {
    private static final long serialVersionUID = 1454545343454684645L;

    public static final Object INSTANCE = new NullObject();
}
复制代码

image.png

  • 缓存穿透的预防

课堂小知识:缓冲穿透。当用户在查询一条数据的时候,而此时数据库和缓存却没有关于这条数据的任何记录,而这条数据在缓存中没找到就会向数据库请求获取数据。它拿不到数据时,是会一直查询数据库,这样会对数据库的访问造成很大的压力。

在向DB获取数据的时候需要经过过滤器,排除掉可避免的数据库压力。

MLC 本地缓存

现阶段项目基于Spring Cache实现,基于注解(annotation)配合Redis,为了安全接入MLC借鉴Spring Cache的思想使用AOP + Annotation等技术实现缓存与业务逻辑的解耦,在需要对查询结果进行缓存的地方,使用注解标记,基于 spring.data.redis包及Lettuce,使用RedisTemplate编写业务代码,最终通过基于Netty的连接实例(StatefulRedisConnection)与缓存服务端储存层Redis做请求交互。对注解进行“热点发现”+“本地缓存”功能的代理,从而完成功能的透明接入。

整体结构

image.png

  • 模块划分

    • Luttuce-client:Java 应用与缓存服务端交互的直接入口,接口定义与原生 Luttuce-Client+ SpringBoot jpa 无异;
    • MLC SDK:实现“热点发现+本地缓存”功能的SDK封装
    • 缓存集群:由代理层和存储层组成,为应用客户端提供统一的分布式缓存服务入口;
  • 基本流程

    • key 值获取
      • Java 应用调用get 获取key的缓存值时,询问MLC SDK该key当前是否是热点key。
      • 对于 热点key ,直接从本地缓存获取热点key的value值,不去访问 缓存集群 ,从而将访问请求前置在应用层。
      • 对于非热点key,通过Callable回调从缓存集群拿到value值。
      • 将每次key的访问请求,会由通信模块记录下来(异步记录),实现“热点探测”。
    • key值删除
      • Java 应用调用evice 获取key的缓存值时,使用通信模块向Redis 发布push
      • MLC 订阅者执行删除动作
    • 热点发现
      • 热点发现系统(后续会讲到)收到来自MLC的key访问记录,在配置时间间隔内统计key的访问次数
      • 向Redis 发起push,告诉MLC 集群本地缓存哪些key,删除哪些key
      • MLC订阅者通过Luttuce-client 向缓存集群获取数据

热点发现

结合公司硬件性能,一般普通系统单实例部署机器可能就一个4核8G的机器,留给本地缓存的空间是很少的,所以本地缓存Caffeine基本只放缓存热点数据

热点数据是一个怎么样的存在?一般出现缓存热点的时候,你的每秒并发肯定是很高的,可能每秒都几十万甚至上百万的请求量过来,这都是有可能的,如某个活动商品的数据、某个热点信息介绍。

在关于如何进行热点发现设计的时候,跟数据工程师交流了一下,了解到可以在MLC对外接口嵌入大数据的流式计算技术来进行实时数据访问次数的统计,比如storm、flink等(我是一个都不懂)。

然后在实时数据访问次数统计的过程中,根据自己的规则比如发现一秒之内,某条数据突然访问次数超过了500,就直接立马把这条数据判定为是热点数据,然后将这个发现出来的热点数据写入Reids然后pub/sub发布给其他MLC。
image.png
虽然这是方案减轻了自己的负担,但要结合项目考虑,添加了系统的复杂度。在流式计算中比如storm系统,他可以做到同一条数据先分散在很多机器里进行本地计算,最后再汇总局部计算结果到一台机器进行全局汇总,为了性能还要保证可用性与集群。访问请求storm系统同样消耗I/O访问量如此大是不可忽视的。

来自有赞技术的博客有赞透明多级缓存解决方案(TMC)有提到热点发现的实现,有详细的解释,拿来照猫画虎自己实现一下。

限流熔断保护

MLC应该专门加一个对热点数据访问的限流熔断保护措施。一超过就可以熔断掉,不让请求缓存集群,直接返回一个空白信息,然后用户稍后会自行再次重新刷新页面之类的。

LongAdder作为访问统计,通过应用层自己直接加限流熔断保护措施,可以很好的保护后面的缓存集群、数据库集群之类的不要被打死(前面也介绍了缓存击穿的预防,双重保障让系统更健壮)。基于定时任务处理并统计所有需要熔断的key。

public class LimitStat {
    private String key;
    private LongAdder longAdder;
    private long time;
    ...
    public LimitStat(String key) {
    	// 熔断限制规则
    }

    public LimitStat recreate() {
    	// 重建
    }

    public LimitStat decrement() {
    	// 减少并判断
    }
    ...
}
复制代码

在处理命中率简单思路就是缓存的命中和未命中使用LongAdder在内存先暂存,通过定时任务同步到redis并重置LongAdder。在集群环境中,为了保证数据准确性在同步数据到redis的时候需要加一个分布式锁。
caffeine本身也提供命中统计:

	@Bean
	public Cache myCache() {
		return new CaffeineCache("local1", Caffeine.newBuilder()
				.maximumSize(35000)
				.expireAfterWrite(1000, TimeUnit.MINUTES)
				.recordStats() // 记录hit和miss信息
				.build(), true);
	}
复制代码
Cache<String, Object> cache = Caffeine.newBuilder()
    //自定义数据采集器
    .recordStats(() -> new StatsCounter() {
        // Override method
}).build();
复制代码

结尾

关于热点探测与具体实现是独立分割,随着更优秀更高效的探测的算法出来,可以无感知的插入。在以后的进化之路上可以再拓展灵活的配置、实现业务的“热点阈值”、“热点key探测数量”、并且进行自由配置以达到更好的使用效果。如有进展继续更新文章。

当然公司在一步步成长,我也希望方案更快的应用。

最后,五一假期快乐!!!
image.png

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享