目录
一、对缓存中间件的诉求
1.1 我们为什么需要缓存中间件
我们一直使用关系型数据库作为我们几乎是唯一的数据存储方案。关系型数据库在对复杂结构的数据的组织上、持久性和一致性控制上有巨大的优势。但磁盘数据库无论如何进行查询优化,速度上终究无法和内存读写相提并论。而随着客户数据量越来越大、并发量越来越高、客户场景越来越复杂,我们对数据访问效率的要求也在提高。此时,引入缓存中间件成了我们一定要考虑的事情。
在2022年,一听到缓存中间件,我们首先想到的依然是redis。但我们团队长期以来并没有充分地利用起redis提高系统性能,依然大量依赖于关系型数据库处理数据的存储和读写。为提高系统整体性能,尝试引入新的缓存中间件解决我们的问题。
1.2 缓存的分类
我将缓存解决方案划分为两大类别:弱势缓存和强势缓存。
1.1.1 弱势缓存
第一类,是以Redis为首的弱势缓存。这类缓存强调的是极高的读性能和写性能,一般作为高并发场景下,高速的应用服务和较低速的磁盘数据库之间的缓存。
弱势缓存为提高读写效率,舍弃了强一致性,追求最终一致性,数据结构简单,因此,基于弱势缓存设计的应用系统,通常以磁盘关系型数据库的数据为准,缓存中更倾向于存储一些相对静态稳定的基础数据,用于辅助关系型数据库。对于数据的更新模式,也更偏向于追加,而不是大并发下的频繁更新,应用系统不会完全信任缓存中的数据。这种效率优先,对数据准确性要求不高的方向与如今的互联网行业(尤其是toC领域)的需求十分契合。
1.1.2 强势缓存
而对于业务数据模型较复杂,对数据实时性和准确性要求较高的金融行业、企服行业,更需要的是一个强一致性的数据存储,在引入缓存之前,关系型数据库承担了这一角色。
业务特性导致我们几乎无法接受为了读写效率牺牲数据准确性。复杂业务场景下,我们需要一个强一致性和高实时性的、对数据结构有更强的表达能力的,能描述部分逻辑表达的内存数据库。能满足这些要求的产品,我称其为强势缓存。因为这些特性可以让内存数据库作为我们数据的基准,而让关系型数据库作为一个持久化的备份,进一步降低磁盘的访问率,提高内存的存在感,让一个完整业务流程中的数据流转可以在内存中可靠地完成。
二、什么是Apache Geode
Apache Geode正是满足我给出的强势缓存定义的一款内存数据库产品。它是商业内存数据网格Geofirm的开源版本,已经在金融支付领域和12306等大型订购网站中经受住了考验。
2.1 Apache Geode的架构
2.1.1 通信拓扑
2.1.2 服务发现
Apache Geode提供了定位器(Locator)进程,为参与缓存的所有成员(Client、Server、其它的Locator)提供其他成员的发现和负载均衡。Locator既可以和其他Geode进程部署在一起,也可以独立部署,独立部署可以更好地保证定位器的可靠性和可用性(因为一旦一起的Geode进程挂了,这个Locator也很难幸免于难)。可以部署多个Locator共同起作用,client连接时可以选择连接到哪个Locator上。
Client可以配置连接到哪个Server服务器,但更合理的配置方式是连接到某个Locator上,由这个Locator为Client分配一个负载较低的Server,Client启动后只会和Locator沟通一次,在获知被分配到的Server的IP和端口之后,每次读写都会直接连接到Server上。
2.1.3 数据存储形式和区域
市面上大部分的内存数据存储,都将数据按键值对的格式进行存放,Geode也是如此。但与Redis等简单的KV不同,Geode将KV数据们按数据区域(Region)进行组织。对于不同的区域可以单独配置(如是否分区或需要副本)。
数据区域可以类比于关系型数据库中的表的概念,是一系列结构相同的数据结构的集合。实际上,在实现上数据区域就是一个ConcurrentMap<K, V>,其键就是一条数据的唯一性标识(类型任意,只要重写了equals和hashcode以便于Region确认键的唯一),其值是一个表达完整数据概念的对象,这样其实也让一条数据中按类的成员又划分出了列的概念。基于这种类似关系型数据库的存储模式,Geode提供了一种类似于SQL的查询语言,称为OQL,并支持多区域查询(类似于连表查询)。
下面是一个OQL查询的小例子:
class DictPlatform implements DataSerializable { short platformId; String name; String status; } class TestServiceImpl { public void query() { String queryString = "SELECT dp.platformId, dp.name FROM /dict_platform dp WHERE dp.status >= 0;" QueryService queryService = cache.getQueryService(); Query query = queryService.newQuery(queryString); SelectResults results = (SelectResults)query.execute(); DictPlatform p = (DictPlatform)results.iterator().next(); } }
- 2.1.3.1 区域的分布式存储和复制
数据区域可配置的类型主要为 Partitioned , Replicated , Distributed non-replicated , Non-distributed 这四种。下面重点介绍前两种类型。
- Partitioned 分区区域
如果某个区域数据量很大,一个成员放不下,可以将这个区域划分为多个bucket,分别存储在不同的server上,为了保证高可用,可以让不同的bucket的副本分布在多个server上,以某个server上的bucket作为master,很类似于Apache Kafka的设计。当存储不够,可以增加新的server,增加新server后的需要发起重平衡,重平衡不需要停机,但可能会导致正在执行的事务失败。
可以从任何一个副本中读取到数据,如果和Client联系的那个Server没有想访问的分区的副本,需要经过server间的一跳,将请求转给目标server。因此,分区的读性能稍差。 - Replicated 复制区域
如果某个区域数据量不大,为了提高读性能,复制区域可以将区域中所有数据完整地复制给其它副本,这样所有server中保存着完全相同的数据。
- Partitioned 分区区域
2.1.4 数据量的控制和热点数据
Geode有两种模式控制内存中的数据规模,持久化和失效。但无论如何,热点数据都通过最近最少使用(LRU)算法来判断。
- 2.1.4.1 持久化
持久化(Persisted) 和 溢出(Overflowed) 是配合使用的两个概念,配置共同使用持久化和溢出之后,所有数据都会被复制到磁盘,在内存中只保留热点数据的值,但所有数据的键都会被保留,以便于确认数据在磁盘上是否存在。内存中数据量达到阈值后(指定条数或内存负载),排在LRU队尾的数据会被溢出到磁盘中(也即删除),当这个键对应的数据又一次被访问到,这个键会被恢复到内存中。
磁盘中的值和内存中的值在应用程序看来没有任何区别。 - 2.1.4.2 失效
驱逐(Eviction) 和 到期(Expiration) 是可配置的两种数据失效的方式,总的来说,失效就是值当数据满足某些条件时,就从内存中删除掉。
- 驱逐
驱逐是指当内存中的数据量到达一定阈值时,将LRU队尾的数据销毁。这个阈值可以是条数或占用的内存大小。驱逐和溢出并不是互斥的,溢出实际上是一种不会丢失数据的驱逐。 - 到期
到期是指按时间销毁掉冷数据,到期有两种计算方式,一种是从数据创建或更新开始计算,被称为TTL(Time to live),另一种是上次被访问开始计算,被称为Idle timeout。第二种更适用于热点数据的存储,第一种更适合于业务要求的定时失效的数据。
- 驱逐
三、Apache Geode是否能满足我们的需要
3.1 性能
3.2.1 吞吐量和延迟
读写吞吐量由并发主存储器数据结构和高度优化的分发基础结构提供。 应用程序可以通过同步或异步复制在内存中动态复制数据,以实现高读取吞吐量,或者跨多个系统成员对数据进行分区,以实现高读写吞吐量。 如果数据访问在整个数据集中相当平衡,则数据分区会使聚合吞吐量翻倍。 吞吐量的线性增加仅受骨干网容量的限制。
优化的缓存层最大限度地减少了线程和进程之间的上下文切换。 它管理高度并发结构中的数据,以最大限度地减少争用点。 如果接收器可以跟上,则与对等成员的通信是同步的,这使得数据分发的延迟保持最小。 服务器以序列化形式管理对象图,以减少垃圾收集器的压力。
客户端可以将单个数据请求直接发送到持有数据key的服务器,从而避免多跳以定位已分区的数据。 客户端中的元数据标识正确的服务器。
3.2.3 索引
Geode的查询支持索引以提高查询效率。实际上,Geode维护了一个键和索引值之间关系的数据结构(一般是一颗B树),并支持范围查询。
但和所有的关系型数据库一样,索引能带来收益的前提是良好的索引设计,而且必然会带来写效率的降低。
3.2 CAP
我们知道分布式系统存在CAP不可能三角,即一个分布式系统最多只能同时满足 一致性(Consistency) 、 可用性(Availability) 和 分区容错性(Partition tolerance) 这三项中的两项。下面从一致性、可用性和分区容错性上分析Apache Geode的性能优劣。
一致性
Apache Geode的定位是一款强一致性的内存数据库。一致性的破坏有两种渠道: 副本更新的延迟或失败 和 并发更新 。
- 对于分区区域,即使该分区配置了冗余的副本,也只允许在主副本上按顺序写入,写入过程中会进行锁定,防止并发更新。对主副本的写入,必须在同步地执行对冗余副本的写入后才算成功。
- 对于复制区域的写操作,Geode会保证所有的副本都成功执行写入后,才返回成功。Geode并发地执行对多个server的数据分发,但仍会降低写入的效率。
对于复制区域的并发更新可能击中任何一个副本,这样就出现了并发问题。Geode在更新前提供了一致性检查,检测并一致地解决并发和无序更新。这个一致性检查实际上是使用版本号和时间戳来保证,多个更新到来时,只保留最高版本的更新请求。如果有多个相同版本的更新请求,每个成员都有一个资格ID,最高资格ID的成员的更新生效。
可用性
- 对于复制区域,每个server上都保存着相同的数据,因此一个server宕机完全不影响数据的正常读写。
- 对于分区区域,为了保证高可用性,可以对分区设置冗余副本,在某个server宕机后,Geode会自动地将副本标记为master,当可用的副本数量到达一个阈值后,Geode会启动一个线程,启用一个新的server作为新的副本的据点,将所有数据复制到这个新的server上并标记为可用副本。这个过程是否立即执行是可配置的,也可以等一会看宕机的server是否能恢复。
上面提到的持久化能力也提高了区域的灾备能力,Geode允许以分区为维度进行落盘持久化,并在一个新的server启动后,将磁盘中的内容恢复到内存中。
分区容错性
Apache Geode作为分布式内存数据库,天然地满足了分区容错性。
3.3 复杂业务场景的需要
3.2.1 事务支持
Geode提供了ACID事务的能力,但基于乐观锁,重新定义了ACID。
- 原子性
乐观事务通过使用预约系统提供原子性并实现速度,而不是使用传统的两阶段锁行关系数据库技术。这种保留阻止了其他交叉事务的完成,允许提交检查冲突,并在对数据进行更改之前以全有或全无的方式保留资源。在本地和远程完成所有更改之后,将释放预订。在预订系统中,交叉事务将被简单地丢弃。避免了获取锁的序列化。 - 一致性
一致性要求在事务中编写的数据必须遵守为受影响区域建立的键和值约束。请注意,事务的有效性是应用程序的责任。 - 隔离
隔离是事务状态对系统组件可见的级别。Geode事务具有可重复的读隔离。一旦为给定的键读取提交的值,它总是返回相同的值。如果事务中的写操作删除了已读取的键的值,则后续的读操作将返回事务引用。
默认配置在流程线程级别隔离事务。当一个事务正在进行时,它的更改只在运行该事务的线程中可见。同一进程中的其他线程和其他进程中的线程在提交操作开始之前不能看到更改。在开始提交之后,更改在缓存中是可见的,但是访问更改数据的其他线程可能会看到事务的部分结果,从而导致脏读。但可以通过修改配置避免脏读。 - 持久性
关系数据库通过使用磁盘存储进行恢复和事务日志记录来提供持久性。Geode针对性能进行了优化,不支持事务的磁盘持久性。
3.2.2 Functions
可以在Geode服务中注册一些函数,应用程序只需要发送函数的名称就可以执行函数内容。
3.2.3 连续查询
这是一个类似于消息队列的机制,Client向Server发布一个OQL查询,当Server执行了会导致这个OQL的查询结果发生变更的事件后,会将这个事件和新的结果通知给Client,Client可以基于此做一些特殊的操作。比如我们在缓存中管理库存量,发布一个连续查询 "SELECT * FROM /stock s WHERE s.stock_num + +s.purchase_num < 100",当这个查询收到值时,就向采购领域发布采购预警事件。
3.2.4 异构
Geode提供了多种序列化方式,当想要在异构的系统中使用Geode时,可以不使用Java的序列化,而是其它更通用的序列化方式,如PDX或DataSerializer,PDX可以不用反序列化整个数据对象,就读到其中的字段的值,还能兼容多版本的对象;DataSerializable则提供了更快速的序列化。