乐趣区

细说双Buffer缓冲池

前言
缓冲机制是对数据持久化的延迟,减少不必要的 IO,提高数据落盘的效率。本文将会详细探讨拥有双 Buffer 的缓冲池(下文统称 TwinsBufferPool)是如何实现的,读者可以依此推广,得到 N -Buffer 的实现原理。
在此篇文章中,缓冲区(Buffer)和缓冲池(BufferPool)是两个重要的概念,很明显,两者构成了一个包含与被包含的关系,一个缓冲池内可以有一个或者多个缓冲区协同工作,缓冲池中的所有缓冲区被组织成了一个环形队列,一前一后的两个缓冲区可以互相替换角色。
当然,在整个过程中,还会有其他辅助工具的出现,在下文都会逐一阐述。
一、设计要点
1、可扩展性。毫无疑问,可扩展性是对一个设计良好的软件的一项基本要求,而一个软件的可扩展的地方通常是有很多处的,这在某种程度上会依赖于编程者的经验,如果仅仅局限于产品需求,可能会严重限制了软件的可扩展性。缓冲池是一种相对通用的中间件,扩展点相对比较多,比如:缓冲区数量可指定,线程安全与否,缓冲区阈值调配等等。
2、易用性。设计出来的中间件应该是对用户友好的,使用过程中不会有繁琐的配置,奇形怪状的 API,更不能有诸多不必要的 Dependencies,如果能做到代码无侵入性,那就非常完美了。基于这个要求,TwinsBufferPool 做成了一个 Spring Boot Starter 的形式,加入到项目里的 dependencies 中即可开启使用。
3、稳定性。这就是衡量一个中间件好坏的重要 KPI 之一,从外观上看,同样是一艘船,破了一个洞和完好无缺将会是一个致命的区别,用户期望自己搭上了一艘完整的船,以便能航行万里而无忧。
4、高效性。说到稳定性,那就不得不说高效了,如果能帮助用户又好又快的解决问题,无疑是最完美的结果。关于 TwinsBufferPool 的稳定性和高效性两个指标,会在文中附上 jemeter 的压测结果,并加以说明。
二、设计方案
这一小节将会给出 TwinsBufferPool 完整的设计方案,我们先从配置说起。
每个参数都会提供默认值,所以不做任何配置也是允许的。如下是目前 TwinsBufferPool 能提供的配置参数(yml):
buffer:
capacity: 2000
threshold: 0.5
allow-duplicate: true
pool:
enable-temporary-storage: true
buffer-time-in-seconds: 120
下面附上参数说明表:

以上参数比较浅显易懂,这里重点解释 enable-temporary-storage 和 buffer-time-in-seconds 这两个参数。
根据参数说明,很明显可以感受到,这两个参数是为了预防突发情况,导致数据丢失。因为缓冲区都是基于内存的设计的,这就意味着缓冲的数据随时处于一种服务重启,或者服务宕机的高风险环境中,因此,才会有这两个参数的诞生。
因为 TwinsBufferPool 良好的接口设计,对于以上两个参数的实现机制也是高度可扩展的。TwinsBufferPool 默认的是基于 Redis 的实现,用户也可以用 MongoDB,MySQL,FileSystem 等方式实现。由此又会衍生出另外一个问题,由于各种异常情况,导致临时存储层遗留了一定量的数据,需要在下次启动的时候,恢复这一部分的数据。
总而言之,数据都是通过 flush 动作最终持久化到磁盘上。

因为大多数实际业务场景对于缓冲池的并发量是有一定要求的,所以默认就采用了线程安全的实现策略,受到 JDK 中 ThreadPool 的启发,缓冲池也具备了自身状态管理的机制。如下列出了缓冲池所有可能存在的状态,以及各个状态的流转。
/**
* 缓冲池暂未就绪
*/
private static final int ST_NOT_READY = 1;

/**
* 缓冲池初始化完毕,处于启动状态
*/
private static final int ST_STARTED = 2;

/**
* 如果安全关闭缓冲池,会立即进入此状态
*/
private static final int ST_SHUTTING_DOWN = 3;

/**
* 缓冲池已关闭
*/
private static final int ST_SHUTDOWN = 4;

/**
* 正在进行数据恢复
*/
private static final int ST_RECOVERING = 5;

通过上述的一番分析,设计的方案也呼之欲出了,下面给出主要的接口设计与实现。

通过以上的讲解,也不难理解 BufferPool 定义的接口。缓冲池的整个生命周期,以及内部的一些运作机制都得以体现。值得注意的是,在设计上,将缓冲池和存储层做了逻辑分离,使得扩展性进一步得到增强。
存储相关的接口包含了一些简单的 CURD,目前默认是用 Redis 作为临时存储层,MongoDB 作为永久存储层,用户可以根据需要实现其他的存储方式。
下图展现的是 TwinsBufferPool 的实现方式,DataBuffer 是缓冲区,必须依赖的基础元素。因为设计的是环形队列,所以依赖了 CycleQueue,这个环形队列的 interface 也是自定义的,在 JDK 中没有找到比较合适的实现。

值得注意的是,BufferPool 接口定义是灵活可扩展的,TwinsBufferPool 只是提供了一种基于环形队列的实现方式,用户也可以自行设计,使用另外一种数据结构来支撑缓冲池的运作。
三、压测报告
使用的是个人的 PC 电脑,机器的配置如下:
处理器:i5-7400 CPU 3.00GHZ 四核
内存:8.00GB
操作系统:Windows10 64 位 基于 x64 的处理器
运行环境如下:
jdk 1.8.0_144
SpringBoot_2.1.0,内置 Tomcat9.0
Redis_v4.0.1
MongoDB_v3.4.7
测试工具:
jemeter_v5.1
总共测试了四组参数,每组参数主要是针对最大容量,阈值和最大缓冲时间三个参数来做调整。
第一组:
buffer:
capacity: 1000
threshold: 0.8
pool:
buffer-time-in-seconds: 60
第二组:
buffer:
capacity: 5000
threshold: 0.8
pool:
buffer-time-in-seconds: 60
第三组
buffer:
capacity: 5000
threshold: 0.8
pool:
buffer-time-in-seconds: 300
第四组
buffer:
capacity: 10000
threshold: 0.8
pool:
buffer-time-in-seconds: 300
总共采集了 9 个指标:CPU 占用率,堆内存 /M,线程数,错误率,吞吐量 /sec,最长响应时间 /ms,最短响应时间 /ms,平均响应时间 /ms,数据丢失量。
限于篇幅,只展示 4 个指标:堆内存,数据丢失量,平均响应时间,吞吐量。
总体来看,随着每秒并发量的增加,各项指标呈现了不太乐观的趋势,其中最不稳定的是第四组参数,波动较为明显,综合表现最佳的是第二组参数,其次是第三组。
数据丢失量是一个比较让人关心的指标,从图中可以得知,在并发量达到 4000 的时候,开始有数据丢失的现象,而造成这一现象的原因并非是 TwinsBufferPool 实现代码的 Bug,而是请求超时导致的“Connection refused”,因为每个 Servlet 运行容器都会有超时机制,如果排队请求时间过长,就是直接被拒绝了。因此,看数据丢失量和错误率曲线,这两者是一致的。如果设置成不超时,那么将是零丢失量,零错误率,所带来的代价就是平均响应时间会拉长。
因为受限于个人的测试环境,整个测试过程显得不是很严谨,得出来的数据也并不是很完美,不过,我这里提供了一些优化调整的建议:
1、硬件环境。正所谓“巧妇难为无米之炊”,如果提供的硬件性能本身就是有限的话,那么,在上面运行的软件也难以得到正常的发挥。
2、软件架构。这个想象的空间很大,其中有一种方案我认为未来可以纳入到 RoadMap 中:多缓冲池的负载均衡。我们可以尝试在一个应用中启用多个缓冲池,通过调度算法,将缓冲数据均匀的分配给各个缓冲池,不至于出现只有一个缓冲池“疲于奔命”的状况,最起码系统的吞吐量会有所提升。
3、其他中间件或者工具的辅助,比如加上消息中间件可以起到削峰的作用,各项指标也将会有所改善。
4、参数调优。这里的参数指代的不仅仅是缓冲池的参数,还有包括最大连接数,最大线程数,超时时间等诸多外部参数。
四、总结
本文详细阐述了双 Buffer 缓冲池的设计原理,以及实现方式,并对 TwinsBufferPool 实施了压测,也对测试结果进行了一番分析。

欢迎关注我的微信订阅号:技术汇。
如果想查看完整的测试报告,可在订阅号内回复关键词:测试报告,即可获取到下载链接。
如果想深入研究 TwinsBufferPool 源码的读者,可在订阅号内回复关键词:缓冲池。

退出移动版