SENSORO 智慧城市平台中的延时队列设计
文/升哲科技 刘鹏
背景
升哲科技是一家物联网与人工智能领域的国家高新技术企业、独角兽企业。
要打造物联智慧城市平台,在业务中涉及到各种延时任务的需求,例如设备定时空气开关,定时更新设备状态,定时提醒等等,基于这些需求,需要一个可靠、实时、海量的延时队列服务作为基础设施。
那么延时队列是什么呢?延时队列不同于消息队列按照先入先出(FIFO)的顺序来消费,而是根据消息指定时间延时消费。延时队列的使用在我们日常应用也非常多,比如:
· 在电商平台购物,在 30 分钟内没有支付自动取消订单;
· 待处理的工单超过 1 天未处理,二次发送提醒。
以上场景往往都需要延时队列实现。
早期延时队列的实现采用了数据库扫表方式,服务定期查询到期的任务,再通过 Kafka 来中转消息。当任务量小,延时精度要求低时扫表方式还能应对,然而随着业务增长、任务数量不断增多,延时时间精度要求也变高,扫表的方式已经无法满足我们的业务,于是我们开始探索新的技术方案来支撑百万级任务的延时队列。
延时队列的设计目标
1.高可用:多副本部署,保证服务不出现单点故障;
2.可扩展:可随着业务量增长来扩容,同时生产消费的请求延时也要低;
3.兼容旧接口,保证旧的服务不需要做任何修改;
4.消息传递可靠,至少保证一次送达。
技术选型
在开源社区已经存在一些解决方案:
考虑到运维难度和可扩展性,最终我们选择了开源项目 Lmstfy 作为基础来进行二次开发,选择 Lmstfy 的原因如下:
● 无状态服务,使用 Redis 来持久化,Redis 的高可用方案已经非常成熟,在公/私有云都有 Paas 服务可使用;
● 支持扩容,可以配置多个 Redis 集群;
● 提供 Java/Go/Rust/PHP 客户端,监控面板完善;
● 采用 Golang 开发,高并发性能优秀,也方便后续二次开发。
整体架构设计
1.Delayer:无状态服务,提供给业务服务调用,兼容旧接口,在 Delayer 这一层直接操作 Redis 实现了任务删除和更新任务等等功能;
2.Lmstfy:无状态服务,提供延时队列基础服务,底层实现采用;
3.Redis Sentinel 集群:保证 Redis 发生故障时自动主备切换。
基础概念
● namespace - 用于隔离业务,也可以通过配置 namespace 绑定不同的 Redis 集群;
● queue - 队列,用区分同一业务不同消息类型;
● job - 业务定义的业务,主要包含以下几个属性:
○ id: 任务 ID,全局唯一;
○ delay: 任务延时下发时间, 单位是秒;
○ tries: 任务最大重试次数,tries = N 表示任务会最多下发 N 次;
○ ttr(time to run): 任务预期执行时间,超过 ttr 则认为任务消费失败,触发任务自动重试。
数据存储
Lmstfy 的 Redis 存储由四部分组成:
● Timer: 使用 ZSET 结构来存储延时任务,Score 即任务的到期时间来排序;
● Ready queue - 使用 LIST 结构,存储已经到期的延时任务,实现 FIFO 消费;
● Deadletter- 使用 LIST 结构,消费失败(重试次数到达上限)的任务,可以手动重新放回到队列;
● Job pool – string 类型,存储消息 meta 信息;
● Job mapping - string - 存储应用自定义 id 和 job 的关联关系。
创建任务
创建任务会生成一个 Job ID, Job ID 包括写入时间戳、随机数和延时时长,然后将任务的 meta 信息写入 Redis,Key 为 j/{namespace}/queue/{id} ,当任务延时时间(delay)= 0,(实时消息队列我们使用 Kafka)表示不需要延时则直接写到 Ready Queue(List),当延时时间(delay) = n(n > 0),表示需要延时,将延时加上当前系统时间作为绝对时间戳写到 Timer(sorted set),Timer 的实现是利用 ZSET 根据绝对时间戳进行排序,再由一个 goroutine 定期轮询将到期的任务通过 redis lua script 来将数据转移到 Ready Queue(List)中。
任务消费
支持延时的任务队列本质上是两个数据结构的结合: Ready Queue (LIST)和 Sorted Set。
Sorted Set 用来实现延时的部分,将任务按照到期时间戳升序存储,随后定期将到期的任务迁移至 Ready Queue(LIST)。
任务的具体内容只会存储一份在 Job pool 里面,其他的如 Ready Queue 只是存储 Job id,这样可以节省内存空间。
任务更新和删除
Lmstfy 本身不支持删除和更新,我们在 Delayer 层中在创建任务同时在 Redis 中创建了一个 Mapping Key,客户端可以自定一个 ID 关联到 Job id ,Delayer 提供了删除和更新(先删除再创建)API,我们业务还需要支持多次执行的功能,在处理 Job Ack 时根据任务参数重新插入队列,结合我们二次开发整体结构如下:
性能表现
通过本地限定 1 核 CPU 压测生产消息数据如下:
200 万任务量占内存 600MB+,其中包括 mapping key 导致 key 数量翻倍。
以下是单核 CPU 的环境下压测结果,任务创建可高达 1500TPS:
延时任务到期时间比较分散的情况下,消费表现如下接 800TPS:
总结
封装 lmstfy 的方案已足够支撑当前的使用场景,但还是有一些不足之处,比如:
● 在 Delayer 中操作 Redis 中的任务,无法保证原子性;
● 任务创建和消费另外会多一次网络请求,产生不必要的开销;
● 无法支持循环任务;
● Lmstfy 采用 HTTP 协议,无法发挥更好性能。
未来,我们计划融合两个服务,完善任务 CRUD 功能,减少网络开销,并采用 GRPC 来替换 HTTP 协议通讯。
版权声明: 本文为 InfoQ 作者【SENSORO 升哲科技】的原创文章。
原文链接:【http://xie.infoq.cn/article/36cf261315d88df1f16765025】。文章转载请联系作者。
评论