干货!如何实现一个分布式定时器( 三 )


6.2 时间轮空转问题由于1000w分表,肯定是大部分Bucket为空,时间轮的指针推进存在低效问题 。联想到在饭店排号时,常有店员来登记现场尚存的号码,就是因为可以跳过一些号码,加快叫号进度 。同理,为了减少这种“空推进”,Kafka引入了DelayQueue,以bucket为单位入队,每当有bucket到期,即queue.poll能拿到结果时,才进行时间的“推进”,减少了线程空转的开销 。在这里类似的,我们也可以做一个优化,维护一个有序队列,保存表不为空的时间戳 。大家可以思考一下如何实现,具体方案不再详述 。
6.3 限频由于定时器需要写kv,还需要回调通知业务方 。因此需要考虑对调用下游服务做限频,保证下游服务不会雪崩 。这是一个分布式限频的问题 。这里使用到的是微信支付的限频组件 。保证1.任务插入时不超过定时器管理员配置的频率 。2.Notifier回调通知业务方时不超过业务方申请接入时配置的频率 。这里保证了1.kv和事件中心不会压力太大 。2.下游业务方不会受到超过其处理能力的请求量的冲击 。
6.4 分布式单实例容灾出于容灾的目的,我们希望Daemon具有容灾能力 。换言之若有Daemon实例异常挂起或退出,其他机器的实例进程可以继续执行任务 。但同时我们又希望同一时刻只需要一个实例运行,即“分布式单实例” 。所以我们完整的需求可以归纳为**“分布式单实例容灾部署”** 。
实现这一目标,方式有很多种,例如:

  • 接入“调度中心”,由调度中心来负责调度各个机器
  • 各节点在执行任务前先分布式抢锁,只有成功占用锁资源的节点才能执行任务
  • 各节点通过通信选出“master"来执行逻辑,并通过心跳包持续通信,若“master”掉线,则备机取代成为master继续执行
主要从开发成本,运维支撑两方面来考虑,选取了基于chubby分布式锁的方案来实现单实例容灾部署 。这也使得我们真正执行业务逻辑的机器具有随机性 。
6.5 可靠交付这是一个核心问题,如何保证任务的通知满足At-least-once的要求?
我们系统主要通过以下两种方式来保证 。
1.任务达到时即存入tablekv持久化存储,任务成功通知业务方才设置过期(保留一段时间后删除),故而所有任务都是落地数据,保证事后可以对账 。
2.引入可靠事件中心 。在这里使用的是事件中心的普通消息,而非事务消息 。实质是当做一个高可用性的消息队列 。
这里引入消息队列的意义在于:
  • 将任务调度和任务执行解耦(调度服务并不需要关心任务执行结果) 。
  • 异步化,保证调度服务的高效执行,调度服务的执行是以ms为单位 。
  • 借助消息队列实现任务的可靠消费 。
事件中心相比普通的消息队列还具有哪些优点呢?
  • 某些消息队列可能丢消息(由其实现机制决定),而事件中心本身底层的分布式架构,使得事件中心保证极高的可用性和可靠性,基本可以忽略丢消息的情况 。
  • 事件中心支持按照配置的不同事件梯度进行多次重试(回调时间可以配置) 。
  • 事件中心可以根据自定义业务ID进行消息去重 。
事件中心的引入,基本保证了任务从Scheduler到Notifier的可靠性 。
当然,最为完备的方式,是增加另一个异步Daemon作为兜底策略,扫出所有超时还未交付的任务进行投递 。这里思路较为简单,不再详述 。
6.6 及时交付若同一时间点有大量任务需要处理,如果采用串行发布至事件中心,则仍可能导致任务的回调通知不及时 。这里自然而然想到采用多线程/多协程的方式并发处理 。在本系统中,我们使用到了微信的BatchTask库,BatchTask是这样一个库,它把每一个需要并发执行的RPC任务封装成一个函数闭包(返回值+执行函数+参数),然后调度协程(BatchTask的底层协程为libco)去执行这些任务 。对于已有的同步函数,可以很方便地通过BatchTask的Api去实现任务的批量执行 。Daemon将发布事件的任务提交到BatchTask创建的线程池+协程池(线程和协程数可以根据参数调整)中,充分利用流水线和并发,可以将任务List处理的整体时延大大缩短,尽最大努力及时通知业务方 。
6.7 任务过期删除从节省存储资源考虑,任务通知业务成功后应当删除 。但删除应该是一个异步的过程,因为还需要保留一段时间方便查询日志等 。这种情况,通常的实现方式是启动一个Daemon异步删除已完成的任务 。我们系统中,是利用了tablekv的自动删除机制,回调通知业务完成后,除了设置任务状态为完成外,同时通过tablekv的update接口设置kv的过期时间为1个月,避免了异步Daemon扫表删除任务,简化了实现 。


推荐阅读