应用程序通常使用数据缓存来提高性能,尤其针对那些大量应用只读事务的应用程序更是如此。当数据发生变化时,这些应用程序会直接更新数据库。问题在于随着负载的增加,响应时间将随着更新的增长而延长。数据库并不擅于执行大量处理少量记录的并发事务。相对而言,处理批量事务才是数据库的强项。
实际上,随着额外负载的增加,响应时间急剧攀升,数据库就会耗用CPU或者磁盘。传统方式下,内存中缓存(in-memory caches)的存储会被限制,以满足JVM的空余内存空间。一旦我们需要缓存更多数量的数据,缓存就会不停地释放数据,从而为其他数据留出存储空间。而且,所需的记录必须被不停地读取,从而使得缓存无效,并将数据库直接暴露给完整的读操作负载。
目前已有多个可用的产品,包括IBM? WebSphere? eXtreme Scale,Oracle Coherence和Gigaspaces,它们均支持将一簇JVM的空闲内存作为缓存,而不是单个的JVM。这就使得合并JVM越多,缓存支持的规模就越大。如果这些JVM都是额外的具有CPU、存储器和网络的服务器,那么读请求的服务就是可伸缩的。通过使用write-behind技术,这些产品还提供更新请求的可伸缩服务。write-behind缓存的可伸缩性使得它可以解决极端事务处理(extreme transaction processing, XTP)场景。Gartner将XTP定义为:“一种应用风格,目的在于支持对分布式事务处理应用程序的设计、开发、部署、管理和维护,其特点在于对性能、可伸缩性、可用性、安全、可管理与可靠性的特别要求。”
本文将使用IBM WebSphere eXtreme Scale演示如何利用write-behind模式优化应用程序的性能。write-behind功能能够根据用户配置的间隔时间,异步地对后端数据库进行批量更新。这一方式具备的明显优势就是能够减少数据库调用,从而减少事务负载,更快地访问网格(grid)中的对象。它比write-through缓存具有更快的响应时间,后者对缓存的更新会导致对数据库的及时更新。使用write-behind,不再要求事务等待数据库的写操作完成。此外,它能够避免应用程序出现数据库错误,因为write-behind缓冲通过内存复制单元来保持变化,直到它将其传输给数据库。
在write-behind缓存中,数据的读取和更新通过缓存进行,与write-through缓存不同,更新的数据并不会立即传到数据库。相反,在缓存中一旦进行更新操作,缓存就会跟踪脏记录列表,并定期将当前的脏记录集刷新到数据库中。作为额外的性能改善,缓存会合并这些脏记录。合并意味着如果相同的记录被更新,或者在缓冲区内被多次标记为脏数据,则只保证最后一次更新。对于那些值更新非常频繁,例如金融市场中的股票价格等场景,这种方式能够很大程度上改善性能。如果股票价格每秒钟变化100次,则意味着在30秒内会发生30 x 100次更新。合并将其减少至只有一次。
在WebSphere eXtreme Scale中,脏记录列表会被复制,以保证JVM退出时仍然存在,用户可以通过设置同步和异步复制的数字,指定复制级别。同步复制意味着JVM退出时,没有数据丢失,但由于主进程必须等待获得变更信息的复制品,因此速度会慢一些。异步复制更快,但如果JVM在复制之前就退出,就会导致最近事务发生的变化可能会丢失。脏记录列表将使用大型的批事务写入到数据源中。如果数据源不可用,则网格会继续处理请求,之后再重新尝试。网格能够随着规模的变化提供更短的响应时间,因为变化会单独地提交到网格,即使数据库被停止,事务仍然能够提交。
Write-behind缓存并不能放之四海而皆准。write-behind的本质注定用户看到的变化,即使被提交也不会立即反映到数据库中。这种时间延迟被称为“缓存写延迟(cache write latency)”或“数据库腐败(database staleness)”;发生在数据库变更与更新数据(或者使得数据无效)以反映其变更的缓存之间的延迟则被称为“缓存读延迟(cache read latency)”或“缓存腐败(cache staleness)”。如果系统的每部分在访问数据时都通过缓存(例如,通过公共接口),那么,由于缓存总是保持最新的正确记录,采用write-behind技术就是值得的。可以预见,采用write-behind的系统,作出变更的唯一路径就只能是缓存。
不管是稀疏的缓存还是完整的缓存,都可以用于write-behind特性。稀疏缓存仅存储数据的子集,可以被延迟填充。它通常使用关键字进行访问,因为缓存中并非所有的数据都能够使用,使用缓存进行查询可能无法执行。完整缓存包含了所有的数据,但在首次加载的时候耗时更长。第三种可行之策则是兼取二者所长。首先在短时间内预装载数据的子集,然后延迟加载其余数据。预装载的子集数据大约在总数的20%,但它能够满足80%的请求。
采用这一方式使用缓存的应用程序适用的场景为:适用简单CRUD(创建,读取,更新和删除)模式的分区数据模型。
对于WebSphere eXtrem Scale而言,在objectgrid.xml配置中,通过将writeBehind属性添加到backingMap元素,就启用了write-behind功能,如下所示。参数的值使用语法“"[T(time)][;][C(count)]",用以指定数据库更新发生的时间。当到达设置的时间,或者队列集中的变化次数达到设定的count值,更新就会被写入到持久存储中。
列表1:write-behind配置的示例
<objectGrid name="UserGrid"> <backingMap name="Map" pluginCollectionRef="User" lockStrategy="PESSIMISTIC" writeBehind="T180;C1000" />
WebSphere eXtreme Scale使用了加载器读取内存中缓存的数据,以及将数据写入到数据库中。从WebSphere eXtreme Scale的6.1.0.3版本开始,包含了两个内建的加载器,它们与JPA提供器相交互,负责将关系数据映射到ObjectGrid集中,这两个内建的加载器分别为JPALoader和JPAEntityLoader。JPALoader用于缓存中存储POJO,JPAEntityLoader则用于缓存中存储ObjectGrid实体。
若要配置JPA加载器,就必须修改添加到META-INF目录下的objectgrid.xml和persistence.xml文件。加载器bean可以与必要的实体类名一起被加入到objectgrid.xml中。在objectgrid.xml中必须定义事务的回调函数,这样就可以接收事务提交或回滚的事件,并将其发送到JPA层。persistence.xml文件则表示一个特殊的JPA提供器,它针对具有提供器的特定属性的持久化单元。
考察一个在线的银行网站,当用户数增长时,响应时间变慢,且存在可伸缩性问题。他们需要在现有硬件环境下支持客户需求。接下来,我们就以这一案例来展示write-behind特性是如何帮助他们解决这一问题的。
与直接从数据库中拉取用户个人信息不同,我们首先会将数据库中取出的个人信息,预先加载到缓存中。这意味着缓存是为读请求服务,而不是数据库。在旧系统中,个人信息的更新同样是直接写入到数据库。在可接受的响应时间内,随着数据库服务器的超负荷,每秒钟发生的并发更新数将受到限制。新的系统则是将个人信息的变更写入到网格中,然后使用write-behind技术将这些变更推到数据库中。这就保证了服务的质量和性能,并完全解除了单实例数据库与读写个人信息操作之间的耦合。现在,客户只需要将更多的JVMs/server添加到网格中,就可以扩大个人信息服务的规模。数据库不再成为瓶颈,因为发送到后端的事务数得到了锐减。更快的响应时间带来更快的页面加载,大大改善了用户体验,有效地利用了个人信息服务器的规模,提高了可用性,因为数据库不再成为单一失败点。
对于本用例,我们对DB2O数据库采用了WebSphere eXtreme Scale以及OpenJPA提供器。这一场景的数据模型为User与UserAccount以及UserTransaction之间的一对多关系。下面为User类的代码片段,展现了这样一种关系:
列表2:用例的实体关系
@OneToMany(mappedBy = "user", fetch = FetchType.EAGER, cascade = { Cas-cadeType.ALL }) private Set<UserAccount> accounts = new HashSet<UserAccount>(); @OneToMany(mappedBy = "user", fetch = FetchType.EAGER, cascade = { Cas-cadeType.ALL }) private Set<UserTransaction> transactions = new HashSet<UserTransaction>();
示例代码包含了PopulateDB类,它能够加载某些用户数据到数据库中。DB2数据库的连接信息被定义在之前展示的persistance.xml文件中。在persistence.xml中的持久单元名(persistence unit name)将用于创建JPA EntityManagerFactory。用户对象会被创建,然后被批量地持久化到数据库中。
一旦数据库被加载,数据网格代理(data grid agents)就会预先加载缓存。记录被批量写入到缓存中,因此在客户端与服务器之间几乎没有产生通信。还可以使用多客户端(multiple clients)来加快准备时间。在准备缓存时,一些“热门”数据可以作为所有记录的子集,其余数据则可以采用延迟加载。预先加载缓存提高了缓存命中的几率,减少了从后端层中获取数据的需要。在这个例子中,匹配数据库记录的数据会被插入到缓存中,而不是从数据库中加载,从而加快了执行时间。
示例代码包含一个客户端驱动器,模仿在网格上的操作,用以演示write-behind缓存功能如何提高性能。客户端包含多个选项调整加载行为。下面的命令使用了10个线程,以每个线程200个请求将500k的记录加载到“UseGrid”网格中。
$JAVA_HOME/bin/java -Xms1024M -Xmx1024M -verbose:gc -classpath $TEST_CLASSPATH ogdriver.ClientDriver -m Map -n 500000 -g UserGrid -nt 10 -r 200 -c $CATALOG_1
使用write-behind特性能够显著提高性能。我们分别使用了write-through和write-behind技术运行示例代码,比较了各自的响应时间以及数据库的CPU占用率。插入到缓存中的数据与数据库中的记录相匹配,这就避免了缓存的准备时间,保证读取的响应时间一致,这样才能比较写的响应时间。列表3展示了write-through场景下的响应时间,读取数据花费了不到10ms,而写花费的时间达到了450——550ms。在执行过程中,网络跳转与磁盘I/O操作耗费了大多数时间。列表4展示了write-behind场景下的响应时间,在这种情况下所有的事务都被提交到网格中。从图表中可以看出,它所消耗的读和写的响应时间几乎相同,都消耗了2.5-5ms。这两个图表极为明显地说明,对于更新而言,write-through响应时间更长,而write-behind耗费的更新时间几乎与读取操作相同。如果添加更多的JVM,则能够增加缓存的容量,无需改变响应时间,数据库也不再成为瓶颈。
列表3:采用write-through缓存的响应时间图表
列表4:采用write-behind缓存的响应时间图表
数据库的CPU占用率图表展现了使用write-behind对后端加载的改善。列表5是一幅采用write-through产生的数据库CPU占用图。可以看见,在运行及时写入数据库的所有更新时,CPU的使用是连续的。使用时,CPU空闲状态的波动在4%到10-12%之间。采用write-through技术,后端加载保持了恒定的值。如果采用write-behind,在满足缓存的间隔时间内,后端的加载耗费了更低的CPU占用率,如列表6所示。该图显示,当数据库较为空闲时,占用率为4%,在三分钟间隔时间结束,或者更新记录数达到峰值时,会批量地将更新写入到磁盘中,此时CPU占用率会在短时间内达到12-16%。write-behind的配置可以调整,以满足自己的运行环境,这其中包括写事务的比例,相同记录更新的频率,以及数据库的更新延迟。
列表5:采用write-through缓存的数据库CPU占用率图表
列表6:采用write-behind缓存的数据库CPU占用率图表
本文重新审视了write-behind缓存技术,并展示了如何在WebSphere eXtreme Scale下实现这一特性。write-behind缓存功能减少了后端加载,降低了事务的响应时间,并将应用程序与后端失败隔离开。这些优势以及它配置的简单性,都使得write-behind缓存异常的强大。
感谢Billy Newport,Jian Tang,Thuc Nguyen,Tom Alcott和Art Jolin对撰写本文给予的帮助。
查看英文原文:Extreme Transaction Processing Patterns: Write-behind Caching