环形缓冲区:https://zh.wikipedia.org/wiki/環形緩衝區html
维基百科的解释是:它是一种用于表示一个固定尺寸、头尾相连的缓冲区的数据结构,适合缓存数据流。java
底层数据结构很是简单,一个固定长度的数组加一个写指针和一个读指针。git
只要像这张图同样,把这个数组辦弯,它就成了一个 RingBuffer。github
那它到底有什么精妙的地方呢?数据库
我最近作的项目正好要用到相似的设计思路,因此翻出了之前在点评写的 Puma 系统,看了看之前本身写的代码。顺便写个文章总结一下。编程
Puma 是一个 MySQL 数据库 Binlog 订阅消费系统。相似于阿里的 Canel。设计模式
Puma 会假装成一个 MySQL Slave,而后消费 Binlog 数据,并缓存在本地。当有客户端链接上来的时候,就会从本地读取数据给客户端消费。数组
若是用很通常的设计,一个单独的线程会从 MySQL 消费数据,存到本地文件中。而后每一个客户端的链接都会有一个线程,从本地文件中读取数据。缓存
没错,初版就是这么简单粗暴,固然,它是有效的。数据结构
不用作性能测试就知道,系统压力大了之后这里一定会是一个瓶颈。读写数据会有一点延时,若是多个客户端同时读取同一份数据又会形成不少的浪费。而后数据的编码解码还会有很多损耗。
因此这里固然要加一个缓存了。
一个线程写数据到磁盘,而后它能够同时把数据传递给各个客户端。
听起来像发布者订阅者模式?也有点像生产者消费者模式?
可是,它并不只仅是发布者订阅者模式,由于这里的“发布者”和“订阅者”是彻底异步的,并且每一个“订阅者”的消费速度是不同的。
它也不只仅是生产者消费者模式,由于这里的“消费者”是同时消费全部数据,而不是把数据分发给各个“消费者”。
它们的消费速度不同,还会出现缓存内的数据过新,“消费者”不得不去磁盘读取。
感受这里的需求是两种设计模式的结合。
不只如此,这是一套高并发的系统,怎么保证性能,怎么保证数据一致性?
因此,这个缓存看上去简单,其实它不简单。
目标明确后,看看 Java 的并发集合中有什么能知足需求的吧。
第一个想到的就是BlockingQueue
,为每个客户端建立一个BlockingQueue
,BlockingQueue
内部是经过加锁来实现的,虽然锁冲突不会不少,但高并发的状况下,最好仍是能作到无锁。
当时正好看到了一系列介绍 Disruptor 的文章:传送门
因此就在想能不能把 RingBuffer 用来解决咱们的问题呢?
看完了 RingBuffer 的基本原理后,就要开始用它来适应咱们的系统了。这里遇到了几个问题:
如何支持多个消费者
如何判断当前有无新数据,如何判断当前数据是否已经被新数据覆盖
如何保证数据一致性
这个问题简单,原始的 RingBuffer 只有一个写指针和一个读指针。
要支持多个消费者的话,只要为每一个消费者建立一个读指针便可。
RingBuffer 的一个精髓就是,写指针和读指针的大小是会超过数组长度的,写入和读取数据的时候,是采用writeIndex % CACHED_SIZE
这样的形式来读取的。
为何要这么作?这就是为了解决判断有无新数据和数据是否已被覆盖的问题。
假设我内部2个指针,分别叫nextWriteIndex
,nextReadIndex
。
那么判断有无新数据的逻辑就是if (nextReadIndex >= nextWriteIndex)
,返回true
的话就是没有新数据了。
而判断数据是否被覆盖的逻辑就是if (nextReadIndex < nextWriteIndex - CACHED_SIZE)
,返回true
的话就是数据已经被覆盖了。
拿实际数据举个例子:
一个长度为10的 RingBuffer,内部是一个长度为10的数组。
此时nextWriteIndex
=12,意味着它下一次写入的数据会在 12%10=2 上。此时,可读的有效范围是 2~11,对应的数组内的索引就是 2, 3, 4, 5, 6, 7, 8, 9, 0, 1。
因此,当nextReadIndex
=12 的时候,会读到最老的数据2,这是老数据,不是新数据,此时表示没有行数据了。
当nextReadIndex
=1 的时候,是新数据,而不是想要的老数据,老数据已经被覆盖掉了,此时它没办法从缓存里读数据了。
最棘手的第三个问题来了,这个系统是要支持高并发的,若是是同步的操做,上面的代码没有任何问题。或者说,若是是同步的代码,干吗还要用 RingBuffer 呢?
上面写入和读取,都有两步操做,更改数据和更改索引,按照逻辑上来说,它们应该是强一致性的。只能加锁了?若是要加锁,为什么不直接用BlockingQueue
?
因此,是否能够经过什么方法,高并发和最终一致性呢?
直接贴代码吧,根据代码一步步分析:
public class CachedDataStorage { private static final int CACHED_SIZE = 5000; private final ChangedEventWithSequence[] data = new ChangedEventWithSequence[CACHED_SIZE]; private volatile long nextWriteIndex = 0; public void append(Object dataValue) { data[(int) (nextWriteIndex % CACHED_SIZE)] = dataValue; nextWriteIndex++; } public Reader createReader() { return new Reader(); } public class Reader { private Reader() { } private volatile long nextReadIndex = 0; public Object next() throws IOException { if (nextReadIndex >= nextWriteIndex) { return null; } if (nextReadIndex <= nextWriteIndex - CACHED_SIZE) { throw new IOException("data outdated"); } Object dataValue = data[(int) (nextReadIndex % CACHED_SIZE)]; if (nextReadIndex <= nextWriteIndex - CACHED_SIZE) { throw new IOException("data outdated"); } else { nextReadIndex++; return dataValue; } } } }
咱们来一步步分析,先看内部的Reader
和createReader()
方法,每来一个客户端就会建立一个Reader
,每一个Reader
会维护一个nextReadIndex
。
而后看append()
方法,能够说没有任何逻辑,直接写入数据,修改索引就结束了。可是,别小看了这两个步骤的操做顺序。
好了,到了最复杂的next()
方法了,这里可就大有讲究了。
一进来马上执行if (nextReadIndex >= nextWriteIndex)
,用来判断当前是否还有更新的数据。
由于写入的时候是先写数据再改索引,因此可能会出现明明有数据,可是这里认为没数据的状况。
可是并无关系,咱们更关注最终一致性,由于咱们要的是确保这里必定不会读错数据,而不必定要确保这里有新数据就要马上处理。就算这一轮没读到,下一轮也必定会读取到了。
下一步是这一行if (nextReadIndex <= nextWriteIndex - CACHED_SIZE)
,判断想要读取的数据有没有被新数据覆盖。等一下,这里为何和上面介绍的不同?
上面写的是<
,而这里倒是<=
。上面提到,同步操做的状况下,用<
是没有问题的,可是这里的异步的。
写入数据的时候,可能会出现数据已被覆盖,而索引未被更新的问题,因此这样子判断能够保证不会读错数据。
既然上下边界都检查过了,那么就读取数据吧!就当这里准备读数据的时候,写数据的线程居然又写入了好多数据,致使读出来的数据已经被覆盖了!
因此,必定要在读完数据后,再次检查数据是否被覆盖。
最终,整个过程实现了无锁,高并发和最终一致性。
在 Puma 系统中,启用缓存和关闭缓存,一写五读的状况下,性能整整提升了一倍。测试仍是在我 SSD 上进行的,若是是传统硬盘,提高会更明显。
代码实现完,就能够和BlockingQueue
对比一下了。
首先,RingBuffer 彻底是无锁的,没有任何锁冲突。而利用BlockingQueue
的话它内部会加锁,虽然锁冲突不会不少,可是没锁确定比有锁好。并且,当 Writer 往多个BlockingQueue
中顺序写入数据的时候,会有相互影响。而利用 RingBuffer 实现的话,不管有多少 Reader,都不会影响写入性能。
而后是内存上的优点,每次多一个 Reader 仅仅是多一个对象,Reader
对象内部也只有一个变量,占用内存很是很是小。而使用BlockingQueue
的话,须要建立的就不只仅是一个对象了,会有一系列的东西。
目前看来,该实现能知足咱们的需求且无明显缺点,并且已经在系统中平稳运行了将近一年了。在我离职以前的最后一个项目,就包括为点评订单系统接入 Puma,订单系统在活动期间的写入 QPS 很是高。但对 Puma 来讲也是毫无压力的,最终的瓶颈都不是在 Puma 上,而是在目标数据库的写入性能上。
首先,这部分的代码能够在这里找到:传送门
完整的代码还包含了老数据被覆盖无数据可读时的数据源切换逻辑,还有当无消费者时关闭 RingBuffer 的逻辑。上面的代码已经被简化了不少,想看完整代码的话能够在上面的连接中看到。
之后有空还会再介绍更多 Puma 中遇到的问题和解决的思路。
而后谈谈高并发系统的设计。
Java 并发编程的第一重境界是善用各类锁,尽可能减小锁冲突,不能有死锁。
第二重境界就是善用 Java 的各类并发包,Java 的并发包里有的是无锁的,例如AtomicLong
中用了CAS
;有的是用了各类手段减小锁冲突,例如ConcurrentHashMap
中就用了锁分段技术。总体效率都很是高,能熟练应用后也能写出很高效的程序。
再下一个境界就很是搞脑子了,每每是放弃了强一致性,而去追求最终一致性。其中会用到AtomicLong
等无锁,或锁分段技术,而且经常会把它们结合起来用。就像上面那部分代码,看似简单,但实际上却要把各类边界条件思考地很全面,由于是最终一致性,因此中间的状态很是多。