查看原文
其他

独家|前端高性能队列应用实践探秘

李祎 58技术 2022-03-15

导语

bull可以让node快速实现异步调用、流量削峰、分布式定时任务,进一步打破前端在高并发、分布式方面的限制。
本篇内容涵盖前端遇到的一些复杂的应用场景及实践经验,希望能给大家提供一些不一样的思路。


背景

2019年的今天,nodejs已经成为了前端研发必备的技术,几乎所有的前端团队都会涉及nodejs开发,nodejs在前端的应用场景,主要包括以下几个:

  • 命令行工具:各种支持前端业务团队快速开发的脚手架,功能一般包括创建项目、编译项目、启动调试项目等。

  •  中间层:为了解决跨域而出现的接口代理服务,以及为提升页面展示性能而出现的页面渲染服务等。

  •   和前端相关平台的后端服务:比如配置平台,api管理平台等等。

这些场景都是在打造前端基础设施,使其更加完善,但是有时候前端也会遇到一些相对复杂的场景,比如面向用户或处理数据的服务,需要实现轻量级的异步调用、流量削峰和分布式定时任务,这时会使用到队列框架。

选型

后端有非常成熟的消息队列框架,比如kafka、rocketmq等,但是这些队列中间件对于前端来说并不算友好,因为首先他们是Java开发的,在Java体系中使用,是前端人员并不熟悉的技术栈,学习成本较高,对接公司服务的成本也相对较高,需要开发node客户端。其次,还是过于重量级了,比如rocketmq一般是独立集群部署,占用服务器资源非常多。

所以,前端如果想使用队列,就必须挑选基于node开发的、更适合的轻量级框架。比较常见的包括Kue、Bull、Bee、Agenda。下面的表格展示了四个框架的特点,可以清晰的看出四者的区别:

Kue是TJ大神的早期作品,最早流行起来的queue框架,但是更新不太频繁。Bull 是目前功能最完善的框架,同时支持Jobs和Messages。Bee在开发构成中参考了Bull,更加专注于小粒度任务的处理,并极大的优化了这种场景的性能,同时也只提供相对小的功能集。以上三种框架均基于redis,而Agenda是基于mongo的。

经过权衡,我们在线上报错收集场景中选用了Bull,基于以下几点原因:

  • 线上报错是发生时间短、请求峰值高的场景,所以Rate Limiter是必须的;
  • Bull 有强大的分布式Jobs处理能力,使前端的定时任务开发变的更加高效和合理;
  •  Bull 基于redis,目前58内部Redis的应用变得更为主流,mongo逐渐被其他服务替代,所以我们也会顺应公司的趋势选择基于redis的框架;

  • Bull 的更新相对频繁,有比较好的项目交流,同时很多web框架都有Bull的中间件,而且Bull有多个可视化项目更加方便管理和查看。
流量削峰
早期,我们使用了常见的node的服务架构,架构图如下:

上报错误的请求会先打到node进程,由node进程经过简单处理后,基于业务直接对MySQL操作,表面上看似乎没什么问题,但是一旦出现线上错误集中爆发的情况,海量请求产生海量的SQL执行,会对MySQL服务造成极大的压力,如果存在效率较低的慢SQL,更是雪上加霜。
上报错误信息的业务逻辑相对简单,静默上报对用户无感知,用户也不关心是否真的处理成功,其瓶颈仅仅是对数据库的操作,比如保存错误环境信息等,那么可以引入Bull,把架构变更为:

node进程接收到请求后,把上报数据构建为一个bull的job,加入提前创建好的queue中,然后直接返回success。bull构建的队列是分布式队列,只要命名相同,同一个集群的不同节点的不同node进程都可以向同一个queue添加job。进入queue的job,会在queue中按照指定的顺序执行(默认为先进先出),一个job执行完,再执行下一个,可以在对queue设置数量限制,如果并发加入的job过多,超过了限制的阈值,则会对超过阈值的job延迟处理,加入Delay有序集合。通过这种设计,并发带来的压力会大大降低,当然这也有可能会造成队列的消息积压,需要用临时构建新的消费者的方式去快速处理消息,后面等流量降低再恢复正常处理。最终,我们的错误收集平台的稳定性得到大幅提升,达到了99.99%,也扛住了业务年初冲量时瞬时100QPS的流量高峰。

分布式定时任务

前端的定时任务通常会通过在linux服务器中,通过设置crontab来实现,但是存在一个问题,crontab是在集群的每一台服务器分别部署,是单机工具,自身缺乏分布式和集中管理的能力。所以为了简单,一般会在集群中挑选一个节点做某一个定时任务的部署,这种定时任务比较少,任务耗时比较少的时候还可以接受,当定时任务很多就会出现资源利用不合理、调配不及时的现象。

阿里的node框架egg也实现了定时任务功能,同样不支持分布式。但是给了两种解决方案,一种是类似于上面说的,通过配置写死服务器ip和定时任务的对应关系,不过如果是docker部署就不合适了,docker的ip会变,另一种是扩展自定义定时任务类型,把自己node进程当做被调度者,让其他分布式工具来调度管理,这是会让项目变的更加复杂。

经过调研,发现Bull本身就是基于redis分布式框架,又有强大的job管理能力,包含重复定时、沙箱处理等功能,可以完美的解决现存问题。把定时任务,作为job加入bull,设置启动时间,设置是否自定义进程,通过bull的调度分布式集群的进程去完成工作。


核心原理

那么Bull是如何实现分布式调度和按指定顺序高性能执行的呢?答案是Bull的底层工具Redis和Redis的brpoplpush命令,原理图如下:

Bull基于生产者消费者模型设计框架,利用redis的阻塞能力实现模型。
生产者是通过调用queue的add方法生产数据,即添加job,会向redis发起lpush或者 rpush命令向一个list中添加包含job的元素,如果list是nil,则会先创建list再添加。

redis的list类型天生支持用作消息队列,list类型是使用双向列表实现,支持从头部和尾部插入新的元素,并且效率非常高,即使list中已经存储了百万级的元素,也可以在常量时间插入完成。

消费者是通过绑定在queue的执行函数消费数据,在绑定执行函数时会向redis发起一个brpoplpush命令,这个命令将list中的最后一个元素返回给node进程。当list为nil,brpoplpush会阻塞连接,直到等待超时,或有另一个node进程的生产者添加job数据。当node进程处理完一个job,会再次发起brpoplpush。

一个集群包含多个节点服务器,部署着多个node进程,这些node进程会共同持有一个queue,每个node进程都可以生产数据和消费数据,意味着每个node进程都可以添加job,都会向redis发起brpoplpush命令,去争夺下一个需要被执行的job,redis执行哪个进程的brpoplpush命令,哪个进程就获得job,正是这种特性打造了一个非常简单又高效的分布式任务调度系统。


实践经验

在实践中,还需要注意几个问题:

  • 及时清理job。queue中的job执行完务必要清理,否则会导致两个问题:

       1)redis的keys会逐渐增加,最终导致内存被占满。

       2)redis服务器和node进程之间会频繁通信,交换list数据,node集群的节点服务器和redis服务器的网卡流量会非常大,而node因为会缓存job数据,内存也会逐渐增大,很容易超过node进程设置的max-old-space-size,最终不断重启。

  • redis与部署环境一一对应。通常公司会有多个部署环境,一般是生产环境、沙箱环境、测试环境,前端也经常会有本地环境。在非生产环境的部署中,尽量部署与研发环境分别对应的、不同的redis,否则会出现几个环境的node进程争夺job的问题,影响测试效率。
  • 构造可以JSON序列化的job。job构建的时候传入的是一个对象,请让这个对象可以被序列化成JSON字符串,不要增加方法,或者持有process、ctx等对象,因为要在redis中存储。


总结和规划

本文介绍的是在前端错误收集的场景下,如何利用Bull这个强大的node队列框架解决流量高峰,定时任务的分布式调度的问题。前端作为可以全栈开发的技术方向,在落地过程中总会遇到各技术方向都会遇到的问题,如何在前端体系下去实现,是摆在所有前端人前面的疑问,以上就是我们在摸索过程中积累的一些经验,如果有什么遗漏或者错误,欢迎大家指正,也欢迎大家一起相互交流继续探索。


参考文献

1. https://github.com/OptimalBits/bull

作者简介
李祎,58前端架构师 / 技术委员会委员

END


留言区分享你的评价、感想或实践经验,截止11月14日14:00点获赞数第一名的留言即可获得50元京东购物卡一张或技术类图书一本~


点击“在看”或分享至朋友圈让更多人看到哦~

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存