写点什么

分布式定时器

  • 2022 年 7 月 28 日
  • 本文字数:4346 字

    阅读完需:约 14 分钟

分布式定时器

本文介绍了一种基于时间轮数据结构思想,采用 redis 存储,构建分布式定时器的方案


1 定时器应用场景

定时器在企点系统中应用的非常广泛。比如以下场景:


场景 1) 企点使用了云的 trtc 服务,trtc 系统会给业务方推送各种事件。这些事件可能是乱序的,而且事件可能会丢失。为了解决这些问题,业务需要做一些事情来保障业务逻辑。企点的做法是为每个事件设置一个定时器,如果事件在规定的时间内正常达到,则删除定时器;如果事件丢失,会触发对应的超时逻辑。


场景 2) 质检功能需要对电话录音进行智能语音识别。运营商是先给 url 到业务方,但是此时录音的数据并没有 ready,业务方需要在收到录音通知后启动一个定时器,等待一段时间后才能处理录音的内容。


2 分布式定时器具备的功能


相信大部分人都实现过单机定时器。比如 STL 的 map,自身就是有序的,这个数据结构非常适合实现定时器。实现一个单机定时器相对比较简单,但是,单机定时器不具备可扩展、数据持久化、数据的可靠性。如果业务要使用分布式定时器,一般的诉求会包含以下几点要求.


1: 基本操作: 支持定时器的添加、更新、删除操作等操作.


2: 事件回调可靠性: 定时器事件超时之后,要保证业务至少能被获取一次


  1. 数据可靠性: 定时器事件需要持久化,不能丢失.


4: 实时性: 定时器支持秒级的延迟。考虑到网络延时,如果 1 秒内有大量定时器到期,定时器系统需要最大可能的准时达到。接入的业务需要能容忍秒级左右的回调通知偏差。


5: 高性能: 在保证实时性的情况下,系统要能满足大量业务的接入.


6: 高可用: 任何实例挂掉之后,后续实例继续提供服务,避免单点故障的影响。


7: 多租户: 能供多个业务使用


3 业界实现方案


  1. 由于 redis 的 zset 天然具备排序功能,本身就是一个定时器的数据结构。直接采用 zset,并配合分布式锁来轮询 zset。该架构比较简单,但是缺点是面对海量定时器超时场景,并发的上限只能在单机,不能水平扩展,容易造成定时器事件的积压,实时性不高。

  2. 类似一些分布式消息队列,自带延时队列的功能。但是缺乏删除和修改定时器的能力.


开源界的方案不能很好的满足企点的场景,于是我们设计了一套分布式定时器,来满足业务的需求.


4 企点分布式定时器设计

4.1 定时器数据结构:

定时器实现技术里面,有一种时间轮的思想。定义一个时钟周期和步长,当指针每走一步,会执行当前时钟上的所有定时事件。每一个时钟刻度到期之后,时钟刻度上的这些定时事件如何最快的执行对应的操作,我们可以采用并发的技术来提高执行效率。因为该数据结构对大量定时器过期非常友好,所以我们采用该数据结构作为我们定时器的方案。



如何在分布式场景下存储时间轮是定时器的设计核心。一种办法是自己设计一个数据结构存储在磁盘,但是带来的问题是太复杂了。需要考虑数据复制、存储、数据分片等等一系列分布式系统需要考虑的问题。那么我们能不能采用云上存储系统来存储这种数据结构呢?我们采用腾讯云提供的 redis 来存储这样的数据结构。



  1. 时间指针: 对于每个定时器的时间指针,采用 string 结构。key 是每个定时器租户的 id,value 指向当前时间轮的轮盘位置。

  2. 时间轮:时间轮上的每一个刻度上保存着这一秒所有的定时器事件,如何让这个列表能分段读取,是选择数据结构的关键。我们选择了 redis 的 zset,每一秒刻度指向一个 redis zset 结构,redis zset 存储这一秒定时事件 ID 的列表。zset 支持通过 zrange 取一个范围,每个范围可以变成一个可以并发执行的任务。

  3. 用户数据: 使用 string 结构,key 是定时器 ID,value 是业务数据。在定时器到期之后,会将这个业务数据带给业务.


4.2 架构


1. 接入层:

  1. 接入层主要负责定时器协议解析,提供增加、删除、更新定时器等基本操作。

2. 调度器:

功能

定时器里面有大量租户,每个租户里面又有大量定时器事件到期,如何将这些事件从 redis 存储里面,快速的发送到消息队列。需要引入调度器这个角色,调度器负责统一调度这个过程,将哪些过期的定时器事件变成任务,交给 work 执行。


实现细节


  1. 任务分配

调度器通过配置中心,拿到所有租户的 appid,通过 zookeeper 协调到本实例需要处理的 appid。对于每个 appid,开启一个协程执行如下流程。


调度过程。调度器取当前时间刻度,获取当前时间刻度有多少任务。如果任务过大,会将任务拆分成子任务。比如一秒钟有 10 万个事件超时,会分成 10 个 1 万个事件的小任务。调度器将这些子任务投递到任务排队队列,同时设置好任务的状态。work 从任务排队队列里面将任务取走,执行完成之后,设置任务状态。调度器定时器扫描任务状态是否都已经执行完成。如果执行完成,会将当期时间刻度向后拨动一秒。


机器时间不准容错。腾讯内部的每台机器,都会使用 ntp 来做了时间校准。但是如果 access 所在的机器和调度器所在机器的时间有偏差,会导致调度器已经调度到下一秒了,access 却将这个事件写入成功了。这样会导致定时器事件丢失。对于这种情况,在调度器这块,每次取当前时间刻度的定时器事件数量的时候,会取前 5 秒,如果前面 5 秒还有数据,会将时间刻度往回拨。同时,如果 access 判断用户设置的时间比调度器当前时间刻度的时间减去 5 秒还要小的话,会拒绝该事件写入。所以即使机器上的时间有些许偏差,数据也不会丢。



  1. 任务数据结构


任务的分配、任务的状态在调度过程中非常关键,目前 zookeeper 没有云上托管服务,所以我们没有采用 zookeeper 作为存储,而是使用 redis 作为任务管理和分配的存储。任务的数据结构如下:



  1. 高可用



appid 的分配: 定时器的实例并不是只有一个干活,而是每个实例都可以调度 appid。调度器的实例注册到 zookeper 的路径。通过 zk,获取当前有多少个调度器节点。如果 hash(appid) % 节点个数 = 注册到 zk 的数组下标,该 appid 就被调度器实例执行。


高可用: 像大多数经典的架构一样,定时器采用 zookeeper 来保持高可用。当一个实例挂了,定时器通过 watch zookeeper 的 path 感知到实例的变化,该故障机器所执行的 appid 将被分配到其它实例的机器。


3.worker 执行器

功能

负责将 scheduler 分发的到期任务投递到 kafka 消息队列。效率和可靠性是 worker 的核心考量。


实现细节


  1. 流程:

a) 任务 polling 协程通过 SPOP + LUA 脚本定期扫描 Redis 中排队任务,对任务进行认领。将任务更新为执行中,并定时上报任务执行心跳,当 scheduler 发现任务无心跳并没有执行完成则会重试此任务。


b) 认领后的任务会被再划分为进程级别的子任务进行并发执行。


c) 工作协程通过子任务队列进行子任务抢占。


d) 抢占到的工作协程会拉取 Redis 中 TimerID 列表以及对应的 UserData,写入 kafka 进行到期事件投递。


e) kafka client 在投递成功后在回调协程中进行计数统计,计数统计用于监控父任务执行进度,当父任务中所有事件执行成功则标识任务执行成功。当父任务执行成功或者失败则同步更新 redis 中任务状态。Scheduler 基于任务状态进行任务重试或者任务成功确认。


  1. 实时性:

因为定时器的时间精度为秒级,当 1s 内大量定时事件到期时,work 需要尽量保证在 1s 内处理完成全部到期事件。worker 采用了如下优化手段:

a) 提升并发: 如果每秒有 1 百万定时器到期,scheduler 分发给 work 的任务,可能是 10 万个定时器事件,在 worker 进程里面对这 10 万个定时器事件做并发处理是满足实时性的必要手段?worker 会对一个任务再次拆分成子任务,每一个子任务顺序投递 300 个到期事件,子任务与子任务可以并发处理,达到提升并发的目的。


b) 降低延时: 事件投递的耗时主要消耗在 kafka 的生产,保证 kafka 的高性能生产是保证投递时效的核心。kafka 生产高性能主要依赖 producer 的批量、压缩机制来降低 I/O 频次,broker 的顺序读写、文件分段、pagecache、零拷贝机制来提高消息处理速度。worker 基于 kafka client 提供的异步生产接口进行到期事件投递,可以很好的满足性能要求,但是带来的副作用是异步生产会将数据在 client 的进程中进行缓存、批量发送,这样会带来当机器宕机时导致的事件丢失,需要采用回调确认的机制来确保消息的可靠投递。


c) 快速失败: 当 kafka 生产失败或者 redis 读取失败时,任务已经执行异常,为保证到期事件的可靠投递,快速将任务置为失败,触发 scheduler 的任务重调度,加快重试速度。


d) Redis 请求耗时: 在 worker 投递过程中,每一个到期事件都需要拉取 redis 中 timerid 对应的 userdata, 通过 redis pipeline 的使用,可以极大提高 redis 的响应速度。


可靠性:定时器服务中到期事件是业务数据,在保证实时性的同时,如何保证到期事件不丢失和尽量保证事件不重复,支持投递 at least once 语义。worker 借助如下手段来支持:


a) kafka 服务端数据可靠:


kafka 提供 partition 维度的容灾机制,我们将 topic 副本数设为 3,最小同步副本数量(min.insync.replicas)设为 2,即是:除了每个 partition 的 leader 外,至少要有 1 个副本同步完成才算消息生产成功。unclean.leader.election.enable 参数设为 false,即是: 非 ISR(与 leader 保持同步的副本列表)中的副本不能够参与 partition 选主。


b) kafka 生产端数据可靠:


kafka producer 对消息写入提供三种机制, request.required.acks = 1 标识,client 等待 leader 写本地日志成功则认为生产成功。request.required.acks = 0 标识,client 不等待服务端返回即认为生产成功。request.required.acks = -1 标识,client 等待 follower 副本确认接收数据成功则认为生产成功。worker 选择 request.required.acks = -1 满足数据的可靠生产。kafka client 的异步生产,发送缓存区中数据会被批量压缩发送到 broker 端,在服务宕机情况下发送缓存区数据有概率丢失导致到期事件丢失。好在 kafka client 提供了异步发送结果回调机制,worker 基于此机制在异步回调协程中对事件投递结果进行确认统计,确保事件被正确投递。


c) kafka 消费端数据可靠:


  1. kafka 提供 commit 机制,可以保证数据在未 commit 时,在 client 重连时会从未 commit 位置继续消费,借此机制,定时器 client 可以确保消息至少被消费一次。

  2. 因为定时器任务重试,重复生产等原因会有小概率导致定时事件在 client 端被重复消费到,timer client 基于带过期时间 LRU 缓存对 timerID 进行去重,尽量确保事件不重。


d) 任务竞争问题:


  1. Redis 的任务认领通过 SPOP,确保一个 taskID 只会被一个 worker 进程拉取从而减少少竞争。

  2. 在极端情况下可能发生,一个 taskID 被 scheduler 重复分配,为确保任务被 worker 进程独占,基于 redis 单线程特性,借助 LUA 脚本可以满足 redis 的事务,worker 使用 LUA 脚本在更新任务状态时,将任务状态表中 workerID 与本进程进行绑定认领,其它进程无法再认领此任务,确保竞争发生后任务不会重复执行。


  1. 管理中心:定时器管理模块主要负责对集群和 appid 的管理。集群的的名字服务名称、k8s 地址、配置中心地址等都被登记在此。不同的业务,通过申请不同的 appid 来做隔离。每个 appid 对应不同的 kafka 的 topic、kafka 集群地址、kafka 版本等。如下是分布式定时器管理界面:




本文转载自:腾讯企点技术团(ID:TencentQiDianTech)

原文链接:https://mp.weixin.qq.com/s/yvW-K-76oF2_YuDBJm4dRw

用户头像

专注企业Saas场景技术分享与交流。 2021.08.12 加入

腾讯企点研发团队,专注企业Saas场景技术分享与交流。

评论

发布
暂无评论
分布式定时器_redis_腾讯企点技术团队_InfoQ写作社区