介绍
定时器(Timer)是业务开发中常用的组件,主要用于执行延迟通知任务。本文根据笔者在工作中的实践,介绍如何利用部门最常用的组件,快速实现一个业务中常用的分布式定时器服务。同时介绍了过程中遇到的一些问题的解决方案,希望能为类似场景提供一些解决方案。文章作者:刘若愚,腾讯WXG后端开发工程师。
1.什么是定时器?
定时器是一种在指定时间开始执行任务的工具(也有定期重复执行任务的定时器,这里不讨论)。它常常与延迟队列的概念联系在一起。那么什么场景下需要使用定时器呢?
我们先来看以下业务场景:
订单未付款时如何及时关闭订单并退回库存?如何定期查看处于退款状态的订单是否退款成功?新创建的店铺N天内未上传商品。系统如何知道这些信息并发送激活短信?为了解决上述问题,最简单、最直接的方法就是定期扫表。每个业务都要维护自己的表扫描逻辑。随着业务的增长,我们会发现表扫描部分的逻辑会非常相似。我们可以考虑把这部分逻辑从具体的业务逻辑中抽出来,变成公共的部分。这时候定时器出来了。
2.定时器的本质
计时器本质上是一种数据结构:距离截止日期越近的任务,其优先级越高。它提供了以下基本操作:
添加新任务删除删除任务Run 执行到期任务/到期通知对应业务处理Update 更新到期时间(可选) Run 通常有两种工作方式: 1、轮询,每隔一个时间片查找哪些任务已经到期; 2. 睡眠/唤醒,不断寻找最晚截止日期的任务,过期则执行;否则,就睡觉直到它们过期。在睡眠期间,如果添加或删除任务,则截止日期的最新任务可能会发生变化,线程将被唤醒并再次执行1的逻辑。
其设计目标通常包括以下要求:
支持任务提交(消息发布)、任务删除、任务通知(消息订阅)等基本功能。消息传输可靠性:消息进入延迟队列后,保证至少被消费一次(过期通知保证At-least-once,追求Exactly-once)。数据可靠性:数据需要持久化以防止丢失。高可用:至少要支持多实例部署。一个实例挂掉后,还有备份实例继续提供服务,并且可以水平扩展。实时性:尽力按时传递信息,允许一定的时间误差,且误差范围可控。 3. 数据结构
我们先来说说定时器的数据结构。定时器通常离不开延迟队列。什么是延迟队列?顾名思义,它是一个带有延迟功能的消息队列。延迟队列的底层通常可以使用以下数据结构之一来实现:
有序链表,这是最直观、最好理解的。堆,应用示例有Java JDK中的DelayQueue、Go的内置定时器等。 时间轮/多级时间轮,应用示例有Linux内核定时器、Netty工具类HashedWheelTimer、Kafka内部定时器等。这里重点介绍时间轮。时间轮是一个环形结构,可以想象成一个时钟。它被分成许多格子。每个格子代表一段时间(定时器越短,精度越高),用一个List来保存该格子上所有到期的任务,后面跟着一个指针。随着时间的流逝,每次旋转一帧,并执行相应列表中的所有到期任务。该任务通过取模来确定应放置哪个网格。示意图如下:
时间轮
如果任务的时间跨度很大,数量也很大,传统的单轮时间轮会导致任务的轮次非常大,单格的任务列表会很长,会持续很长时间时间。这时,Wheel就可以按照时间粒度进行分类(类似于水表的思路)。示意图如下:
多层次时间轮
时间轮是一种比较优雅的实现,如果采用多级时间轮的话效率是比较高的。
4、行业实施方案
业界定时器/延迟队列的工程实践通常是基于以下方案来实现:
基于Redis ZSet实现。使用一些内置延迟选项的队列实现,如RabbitMQ、Beanstalkd、腾讯TDMQ等。基于Timing-Wheel时间轮算法实现。 5. 计划细节
介绍完定时器的背景知识后,我们来看一下我们系统的实现。我们先来看看需求背景。在我们集团的实际业务中,存在延迟任务的需求。典型的应用场景是:商户发起扣款请求后,立即向用户发出预扣款通知,并在24小时后完成扣款;或者向用户发放优惠券,并通知用户优惠券3天后过期。基于这样的需求背景,我们介绍了定时器的开发需求。
我们首先调查了公司内部和外部的计时器实现,以避免重复发明轮子。例如,我们调查了公司外部的Quartz、有赞的延迟队列,以及公司内部的PCG tikker、TDMQ等,以及微信支付内部包括营销、扣缴、支付评分等团队的一些实施方案。最后,考虑到可用性、可靠性、易用性、时效性、编码风格、运维成本等,我们决定参考前人的一些优秀技术方案,结合我们团队的技术积累和组件来设计和实现地位。定时器方案。
首先确定定时器的存储数据结构。这里借鉴了时间轮的思想,使用微信团队最常用的存储组件tablekv来进行任务的持久化存储。使用tablekv的原因是它天然支持uin分表,而且分表数量可以达到千万级。其次,单表支持的记录数非常高,读写效率也非常高。也可以通过与mysql同一张表来指定。条件过滤任务。
我们的目标是达到秒级的时间戳精度,并且只需要在任务到期时通知业务方一次。因此,我们的解决方案的主要思想是基于tablekv按照任务执行时间进行分表,即使用用户指定的start_time(时间戳)作为分表的uin,即时间轮桶。为什么不使用多个时间轮呢?主要原因是,首先KV支持单表上亿级数据,其次KV子表的数量可以非常大。例如,如果我们使用1000 万个子表,那么大约需要115 天才能将它们哈希到同一个子表中。因此,暂时不需要使用多个时间轮。
最终我们采用的子表数量为1000w,uin=timestamp mod 子表数量。这里有一点需要注意。通过mod表的个数进行密钥收敛,避免时间戳递增导致密钥无限膨胀的问题。示例图如下所示:
kv时间轮
任务持久化存储后,我们使用一个Daemon程序定期执行表扫描任务,取出过期的任务,最后传输请求中的业务信息(添加任务时带上的biz_data)。定时器将其透传,无需关注其具体内容。 ) 回调通知业务方。这样看来,过程还是很简单的。
这里的扫描过程和上面提到的时间轮算法类似。会有一个指针(这里我们不妨称之为time_pointer)不断向后移动,以保证不遗漏任何桶任务。这里我们使用commkv(可以简单理解为可以key-value形式读写的kv,其底层实现仍然基于tablekv)来存储CurrentTime,也就是当前处理的时间戳。 Daemon每次轮询时都会通过GetByKey接口获取CurrentTime。如果大于当前机器时间,就会休眠一段时间。如果小于等于当前机器时间,则取出tablekv中CurrentTime为uin的子表的TaskList进行处理。当轮询结束时,CurrentTime 加一,然后通过SetByKey 设置回commkv。我们可以将这部分工作模式简称为Scheduler。
Scheduler拿到任务后,只需要回调通知业务方即可。如果同步通知业务方,由于业务方的超时情况不可控,任务的交付时间可能会更长,从而会减慢该时间点任务的整体通知进度。因此,很自然地想到使用异步解耦。即任务发布到事件中心(微信内部高可用、高可靠的消息平台,支持事务性和非事务性消息)。由于任务到事件中心的传递时间理论上只有几十ms ,当任务量级不大时,可以在1s内处理完毕,此时time_pointer会紧跟当前时间戳,当需要处理大量任务时,需要采用多线程/多协程的方法用于并发处理,保证任务按时交付,Broker订阅事件中心的消息,Broker收到消息后会回调通知业务方,所以Broker还起到了Notifier.整体架构图如下:
*架构图
主要模块包括:
任务扫描守护进程:扮演调度程序的角色。扫描所有到期任务,将其交付给事件中心,并让其通知broker。经纪人的Notifier通知业务方。
Timer Broker:集成了业务接入和Notifier的功能。
任务状态图如下所示,只有两种状态。当任务成功插入kv后,就处于pending状态。当任务移除成功并通知业务方成功后,就处于完成状态。
状态图
六、实施细节及难点的思考
下面对上述方案中涉及的几个技术细节进行进一步说明。
1、业务隔离
通过biz_type定义不同的业务类型。不同的biz_type可以定义不同的优先级(目前不支持),biz_type信息保存在任务中。
业务信息(主键为biz_type)通过海外配置中心进行配置和管理。促进新服务的访问和配置更改。访问服务时,需要在配置中添加回调通知信息、回调重试限制、回调频率限制等参数。业务隔离的目的是为了防止每个接入服务受到其他服务的影响。因为我们的定时器目前是用来支持我们团队内部的业务的,所以我们只是采取了针对不同的服务实现不同的业务限频规则的策略,并没有做太多的优化工作,所以就不赘述了。
2.时间轮旋转问题
由于有1000万个分米,大部分桶一定是空的,时间轮的指针前进存在效率低下的问题。这让我想起,在酒店排队时,服务员经常会到现场登记剩余的号码,因为可以跳过一些号码,以加快拨打号码的速度。同样的,为了减少这种“空推”,Kafka引入了DelayQueue,以桶为单位加入队列。每当一个桶过期时,即queue.poll能拿到结果的时候,时间就会“前移”,减少了线程空转的开销。与这里类似,我们也可以做一个优化来维护有序队列并在表不为空时保存时间戳。大家可以想想如何实施,具体方案就不细说了。
3、频率限制
由于定时器需要写kv,因此还需要回调来通知业务方。因此,需要考虑限制下游服务的调用频率,保证下游服务不崩溃。这是一个分布式频率限制问题。这里使用的是微信支付的限频组件。保证1、任务插入不超过定时器管理员配置的频率。 2、Notifier对业务方的回调通知不得超过业务方申请接入时配置的频率。这样可以保证1.kv和事件中心不会承受太大的压力。 2、下游业务方不会受到超出其处理能力的请求的影响。
4、分布式单实例容灾
出于容灾的目的,我们希望Daemon具有容灾能力。也就是说,如果某个Daemon实例挂起或者异常退出,其他机器上的实例进程可以继续执行任务。但同时我们希望同一时间只需要运行一个实例,即“分布式单实例”。所以我们完整的需求可以概括为“分布式单实例容灾部署”。
实现这一目标的方法有很多,例如:
访问“调度中心”,负责对各台机器进行调度;各节点在执行任务前分布式抢锁,只有成功占用锁资源的节点才能执行任务;每个节点通过通信选出一个“master”来执行逻辑,并通过心跳包继续通信。如果“主机”离线,备用机将接替主机继续执行。主要考虑开发成本和运维支持,选择基于Chubby分布式锁的方案来实现单实例容灾部署。这也使得真正执行业务逻辑的机器变得随机。
5. 可靠的交付
这是一个核心问题。如何保证任务通知满足At-least-once的要求?
我们的系统主要通过以下两种方式来保证这一点。
1、任务到达后,存储到tablekv持久化存储中。任务在设置过期前成功通知业务(保留一段时间然后删除)。因此,所有的任务都是有依据的数据,保证事后可以对账。
2.引入可靠的事件中心。这里使用的是事件中心的普通消息,而不是交易消息。本质上,它是作为一个高可用的消息队列来使用的。
这里引入消息队列的意义在于:
任务调度和任务执行解耦(调度服务不需要关心任务执行结果)。异步,保证调度服务的高效执行。调度服务的执行以毫秒为单位。使用消息队列实现任务的可靠消费。事件中心相比普通消息队列有什么优势?
有些消息队列可能会丢失消息(由其实现机制决定),但Event Center本身的底层分布式架构使得Event Center能够保证极高的可用性和可靠性,消息的丢失基本可以忽略不计。事件中心支持根据不同配置的事件梯度进行多次重试(回调时间可配置)。事件中心可以根据自定义业务ID 删除重复消息。事件中心的引入基本上保证了从Scheduler到Notifier任务的可靠性。
当然,最彻底的方式是再添加一个异步Daemon作为掩盖策略,扫除所有已超时未交付的任务。这里的思路比较简单,不再详细描述。
6、交货及时
如果同一时间点有大量任务需要处理,如果采用串行发布到事件中心的方式,任务的回调通知仍然可能会延迟。人们很自然地想到使用多线程/多协程进行并发处理。在这个系统中,我们使用微信的BatchTask库。 BatchTask 是一个库,它将每个需要并发执行的RPC 任务封装成一个函数闭包(返回值+执行函数+参数),然后调度协程(BatchTask 的底层协程是libco)来执行这些任务。对于现有的同步功能,您可以通过BatchTask API轻松实现任务的批量执行。 Daemon将发布事件的任务提交到BatchTask创建的线程池+协程池(线程和协程的数量可以根据参数调整),充分利用管道和并发性,可以大大缩短任务的整体延迟清单处理并最大限度地争取及时通知业务方。
7、过期删除任务
从节省存储资源的角度来看,任务通知服务应该在服务成功后删除。但删除应该是一个异步的过程,因为需要保留一段时间,方便日志查询等。这种情况下,通常的实现方式是启动一个Daemon来异步删除已完成的任务。在我们的系统中,使用了tablekv的自动删除机制。回调通知业务完成后,除了设置任务状态完成外,还通过tablekv的update接口将kv的过期时间设置为1个月,这样就避免了异步Daemon的表扫描和删除操作。任务,简化实施。
八、其他风险事项
1、由于time_pointer的CurrentTime初始值设置为第一次运行的Daemon实例的机器时间,并且每次轮询时都会比较当前Daemon实例的机器时间与CurrentTime的差值,机器时间的错误可能会影响任务的正常调度。考虑到现网所有机器都运行时间校正脚本,这个问题基本可以忽略。
2、本系统的架构对事件中心有很强的依赖性。定时器的可用性和可靠性取决于事件中心的可用性和可靠性。虽然目前事件中心的可用性和可靠性都很高,但如果要考虑所有的异常情况,事件中心的暂时不可用,或者订阅者消息出队的延迟和累积,都是需要认真对待的问题。一种解决方案是使用MQ进行双链路消息传递,解决单点依赖事件中心的问题。
结论