如果实现一个分布式延迟队列需要考虑哪些方面?至少有这些:数据存在何处、数据以何种格式存储、如何消费、怎样避免重复消费、消费是如何保证顺序、数据在被消费过程中失败了是否会丢失、重试策略……
前段时间想着自己实现个延迟队列,发现curator已经实现,这里就来分析下。
public class DistributedDelayQueueDemo {
private static CuratorFramework client = CuratorFrameworkFactory.newClient("localhost:2181", new ExponentialBackoffRetry(3000, 2));
private static String path = "/queue/0001";//队列对应的zk路径
private static DistributedDelayQueue delayQueue;
/**
* 使用静态块启动
*/
static {
client.start();
}
public static void main(String[] args) throws Exception{
QueueConsumer consumer = new QueueConsumer() {
@Override
public void consumeMessage(String s) throws Exception {
System.out.println("consume data:"+s+",currentTime:"+System.currentTimeMillis());
}
@Override
public void stateChanged(CuratorFramework curatorFramework, ConnectionState connectionState) {
System.out.println("state changed");
}
};
delayQueue = QueueBuilder.builder(client, consumer, new QueueSerializer() {
@Override
public byte[] serialize(String t) {
try {
return t.getBytes("utf-8");
}catch (UnsupportedEncodingException e){
e.printStackTrace();
}
return null;
}
@Override
public String deserialize(byte[] bytes) {
return new String(bytes);
}
},path).buildDelayQueue();
delayQueue.start();//start方法里新开了子线程去执行完之后会进行回调consumeMessage方法
System.out.println("delay queue built");
delayQueue.put("a",System.currentTimeMillis() + 4000);//延迟4s
delayQueue.put("b",System.currentTimeMillis() + 10000);
delayQueue.put("c",System.currentTimeMillis() + 100000);
System.out.println("put ended");
TimeUnit.MINUTES.sleep(14);
delayQueue.close();
TimeUnit.SECONDS.sleep(5);
client.close();
}
}
这个是客户端使用的一个例子
private static CuratorFramework client = CuratorFrameworkFactory.newClient("localhost:2181", new ExponentialBackoffRetry(3000, 2));
这一行获取CuratorFramework对象,这里指定了重试策略,后期会专门写文章进行分析。另外需要说明的是这里会实例化ZooKeeper类里的ClientCnxn类,ClientCnxn是ZK客户端的核心类,负责客户端与服务端的通信,它有两个重要的成员(内部类)SendThread(IO读写、心跳检测、断连重试)与EventThread(事件处理:WatchEvent等)。
delayQueue.start()是在进行消费;下面的delayQueue.put()则是在进行数据的存储。
好了,重点看下delayQueue.start()方法,上面的例子使用的是curator-recipes 2.12.0版本,这里源码分析使用的是5.0.0-SNAPSHOT.
public void start() throws Exception
{
//使用CAS更新状态,latent:潜在的,隐藏的。这里的state变量没使用static修饰
if ( !state.compareAndSet(State.LATENT, State.STARTED) )
{
throw new IllegalStateException();
}
/**
* 主要节点:
* PERSISTENT持久节点,节点创建后就一直存在,直到有删除操作来主动清除这个节点,创建该节点的客户端失效也不会消失
* PERSISTENT_SEQUENTIAL持久顺序节点,此节点特性与上面的一致,额外的是,每个父节点会记录每个子节点的顺序
* EPHEMERAL临时节点,临时节点的生命周期与客户端会话绑定,客户端失效那这个节点就会被自动清除
*EPHEMERAL_SEQUENTIAL临时顺序节点,带顺序的临时节点,可用来实现分布式锁
*/
try
{
//创建队列的zk节点,模式为PERSISTENT,使用父容器
/**
* 1,使用观察者模式(监听器)
* 2,使用回调处理队列的子节点信息
* 3,使用一个版本号对子节点列表时行本地缓存
* 使用不可变list对子节点列表进行包装
* 使用版本号跟踪,避免ABA问题
* 子节点信息使用一个内部类进行封装
* 最后的forPath指定要操作的ZNode
* https://www.jianshu.com/p/998cd2b471ef
*/
//模式为PERSISTENT
client.create().creatingParentContainersIfNeeded().forPath(queuePath);//创建一个空节点
}
catch ( KeeperException.NodeExistsException ignore )//节点创建失败
{
// this is OK
}
if ( lockPath != null )
{
//根据外部传入参数,决定是否创建对应分布式锁的zk节点
try
{
client.create().creatingParentContainersIfNeeded().forPath(lockPath);
}
catch ( KeeperException.NodeExistsException ignore )
{
// this is OK
}
}
//如果不是生产者角色,或者设定了有界队列,则启动子节点缓存,isProducerOnly:false
if ( !isProducerOnly || (maxItems != QueueBuilder.NOT_SET) )
{
//底层callback里的path与上面的queuePath值一样
childrenCache.start();// 这个地方很重要,runLoop里就是从childrenCache里了取的数据
}
//如果不是生产者模式,则异步执行runLoop方法,isProducerOnly:false
if ( !isProducerOnly )
{
//这个线程池用来从队列里拉取消息
service.submit
(
new Callable
进入到start方法里,如果状态是LATENT且设置STARTED成功,则创建一个持久有续的节点,紧接着就是childrenCache.start();这个方法特别重要,我们可以往里面跟进一下,看代码:
/**
* 1,使用观察者模式(监听器)
* 2,使用回调处理队列的子节点信息
* 3,使用一个版本号对子节点列表进行本地缓存
* @param watched
* @throws Exception
*/
private synchronized void sync(boolean watched) throws Exception
{
if ( watched )//watched为true
{
//watch与callback分别为ChildCache对象里的两个属性
//callback的回调里设置了child.setData()
//forPath函数获取了所有的子节点
client.getChildren().usingWatcher(watcher).inBackground(callback).forPath(path);
}
else
{
client.getChildren().inBackground(callback).forPath(path);
}
}
是的,使用了zk的监听器,当有节点或节点里的数据发生变化时,客户端会收到服务端的通知,并进行callback回调,回调里的逻辑还是有必要看一下:
/**
* 使用一个版本号对子节点列表进行本地缓存
* @param newChildren
*/
private synchronized void setNewChildren(List newChildren)
{
if ( newChildren != null )
{
Data currentData = children.get();
//使用不可变list对子节点列表进行包装
//使用版本号跟踪,避免ABA问题
//使用AtomicReference进行原子化包装
//子节点使用一个内部类进行封装
//runLoop方法里会有children.get()取节点数据
children.set(new Data(newChildren, currentData.version + 1));
System.out.println(children.get().children+",:"+children.get().version);
notifyFromCallback();//通知等待的消费者
}
}
将新的数据与新的版本号关联后nofifyAll,这里通知的使用后面马上说到。接下来是使用一个线程池获取元素,入口是runLoop:
private void runLoop()
{
long currentVersion = -1;
long maxWaitMs = -1;
try {
while ( state.get() == State.STARTED )
{
try
{
//根据版本号获取数据,ChildrenCache>data>child/version
//在processChildren方法里有一个闭锁,如果currentVersion对应的数据没处理完,则会一直阻塞
ChildrenCache.Data data = (maxWaitMs > 0) ? childrenCache.blockingNextGetData(currentVersion, maxWaitMs, TimeUnit.MILLISECONDS) : childrenCache.blockingNextGetData(currentVersion);
currentVersion = data.version;//1
List children = Lists.newArrayList(data.children);
//节点进行排序
sortChildren(children); // makes sure items are processed in the correct order
if ( children.size() > 0 )
{
//get(0)只需要判断第一个元素的时间就行,第一个元素未到执行时间那其余的也不会到,只要有一个元素到期就进行处理
//下面处理的时候还有一个判断时间是否到期
maxWaitMs = getDelay(children.get(0));//使用的是DistributedDelayQueue里的getDelay()方法,
if ( maxWaitMs > 0 )//未到执行的时间,那就等待maxWaitMs
{
continue;
}
}
else//还没有元素
{
continue;
}
processChildren(children, currentVersion);//元素进行处理
}
catch ( InterruptedException e )
{
// swallow the interrupt as it's only possible from either a background
// operation and, thus, doesn't apply to this loop or the instance // is being closed in which case the while test will get it //swallow the interrupt exception,因为只有两种情形才会出现中断异常,一种是没在这里进行的后台的操作;
//另一种是测试的时候
}
}
}
catch ( Exception e )
{
log.error("Exception caught in background handler", e);
}
}
在blockingNextGetData方法里从ChildrenCache里取元素,如果没有则会wait,如果有了元素会被唤醒,这也就是上面提到的notifyAll.
接着来看元素处理的方法:
private void processChildren(List children, long currentVersion) throws Exception
{
//这里定义的信号量局部变更而非静态全局变量,那就可以判断这个信号量是用来控制当前方法体里的多个线程执行的先后顺序的
final Semaphore processedLatch = new Semaphore(0);//定义一个信号量
final boolean isUsingLockSafety = (lockPath != null);//false
int min = minItemsBeforeRefresh;//0,控制队列调度消息的最小数量
System.out.println("+++++++++++++++++"+Thread.currentThread().getName()+",children: "+children.size()+",version:"+currentVersion);
for ( final String itemNode : children ) {
if ( Thread.currentThread().isInterrupted() )//判断当前线程中断状态,不改变线程状态
{
processedLatch.release(children.size());//创建children.size个许可,release与acquire并不一定要成对出现
break;
}
if ( !itemNode.startsWith(QUEUE_ITEM_NAME) )
{
log.warn("Foreign node in queue path: " + itemNode);
processedLatch.release();
continue;
}
if ( min-- <= 0 )
{
//refreshOnWatch拉取消息后是否异步调度消费,这里为true
//如果版本号不匹配,则添加children.size()个许可,且中断循环,最后获取children.size个许可,返回到上层的while循环
//所以这里的semaphore的作用是保证一个版本号对应的数据都能被处理完
//
//版本号不相等,说明有新的数据被添加,就需要跳出循环去获取新的数据。那这里就会有这样一个问题:本来执行到这个方法体里说明是有节点已经到了可执行的时间,但由于有
//新的数据添加,会导致应该执行的节点会被推迟
if ( refreshOnWatch && (currentVersion != childrenCache.getData().version) )
{
processedLatch.release(children.size());//这个是为了不阻塞方法的最后一行
break;
}
}
//时间上不符合执行条件
if ( getDelay(itemNode) > 0 )
{
processedLatch.release();//再增加一个许可
continue;
}
//这个线程池用来处理取出来的消息
//示例中使用的是Executors.newCachedThreadPool();它的特点是当任务数增加时,线程池会对应添加新的线程来处理,线程池不会有大小限制,依赖于jvm能够创建的大小
//for遍历出一个节点就会创建一个线程进行处理(所以使用这种线程池时对应需要执行的任务不能太耗时)
executor.execute
(
new Runnable()
{
@Override
public void run()
{
try
{
if ( isUsingLockSafety )//false
{
processWithLockSafety(itemNode, ProcessType.NORMAL);
}
else//执行这个分支
{
processNormally(itemNode, ProcessType.NORMAL);
}
}
catch ( Exception e )
{
ThreadUtils.checkInterrupted(e);
log.error("Error processing message at " + itemNode, e);
}
finally
{
//处理完一个itemNode,添加一个许可,直到currentVersion下的节点全被处理完(children.size个),主线程才会获取相应的许可
processedLatch.release();
}
}
}
);
}
//主线程将任务提交给了线程池后就会执行到acquire这里阻塞,但只有线程池里的任务都执行完release latch之后,主线程才不再阻塞,
//也就是主线程一定要等到currentVersion里的节都处理完才行
processedLatch.acquire(children.size());
}
1,这里使用了Semaphore保证所一个version对应的数据都被处理完。2,同样这里也使用了线程池
最后是真正处理元素的地方:
private boolean processNormally(String itemNode, ProcessType type) throws Exception
{
System.out.println("thread name========================"+Thread.currentThread().getName());
try {
//queuePath:/queue/0001
//itemPath:queue-|171EA3DEECC|0000000019 String itemPath = ZKPaths.makePath(queuePath, itemNode);//获取parent+child全路径(queue-|171EA3DEECC|0000000019)
Stat stat = new Stat();//数据节点的节点状态信息
byte[] bytes = null;
if ( type == ProcessType.NORMAL )
{
bytes = client.getData().storingStatIn(stat).forPath(itemPath);//获取对应节点上的数据("a")
}
if ( client.getState() == CuratorFrameworkState.STARTED )
{
//删除节点上的数据,消息投递之后就被移除,不会等待消费端调用完成。想要等待消费端消费成功返回后再删除,可以设置lockPath
//如果消费者异常中断可以再次被消费
//先删除再进行数据处理>优:处理比较简单,进入下次循环的时候不用再考虑需要删除的数据。劣:数据删除后进行处理的时候如果失败,数据就丢掉了。
//先进行数据处理再删除>优:数据比较安全。劣:进入下次循环时需要判断是否是已处理的数据,需要对数据进行打标
client.delete().withVersion(stat.getVersion()).forPath(itemPath);
}
if ( type == ProcessType.NORMAL )
{
processMessageBytes(itemNode, bytes);
}
return true;
}
catch ( KeeperException.NodeExistsException ignore )
{
// another process got it
}
catch ( KeeperException.NoNodeException ignore )
{
// another process got it
}
catch ( KeeperException.BadVersionException ignore )
{
// another process got it
}
return false;
}
先删除元素再处理元素,上面的processMessageBytes方法就是处理元素的入口。