孙玄:毕业于浙江大学,现任转转公司首席架构师,技术委员会主席,大中后台技术负责人(交易平台、基础服务、智能客服、基础架构、智能运维、数据库、安全、IT 等方向);前58集团技术委员会主席,高级系统架构师;前百度资深研发工程师;
【架构之美】微信公众号作者;擅长系统架构设计,大数据,运维、机器学习等技术领域;代表公司多次在业界顶级技术大会 CIO 峰会、Artificial、Intelligence、Conference、A2M、QCon、ArchSummit、SACC、SDCC、CCTC、DTCC、Top100、Strata+、Hadoop World、WOT、GITC、GIAC、TID等发表演讲,并为《程序员》杂志撰稿 2 篇。
公司业务系统(比如:电商系统)中有大量涉及定时任务的业务场景,例如:实现买卖双方在线沟通的 IM 系统,为了确保接收方能够收到消息,服务端一般都会有重试策略,即服务端在消息发出的一段时间内,如果没收到接收方的确认信息,则重新发送消息。
这就是一个典型的定时任务场景—消息发出等待固定的时间后,触发消息重发逻辑,重发逻辑首先判断所发消息是否收到确认信息,如果没有就将对应的消息再发送一次。类似的场景有很多,例如:自动取消长时间未支付的订单、买家收货一段时间以后自动确认打款等等。
应对上述场景比较粗暴的解决方案是定时扫库,例如:业务将订单的支付超时时间定义为2小时。可以每1分钟扫一次订单库,将超时订单取消。
显然,此方案不够优雅,主要问题如下:
1)增加数据库读压力;
2)不够精确,会有最长1分钟的滞后;
扫库的方案一般体量不大时可以使用,当业务发展到一定规模后就不再适用。对IM消息重发秒级别的定时需求,只能增加扫库的频率,但过于频繁的扫库很可能会将数据库拖垮。显然需要更优雅的技术方案解决定时任务问题。
时间轮算法可以高效的处理定时任务,并且有非常高的精度。我们以 IM 的消息重发功能为例介绍下时间轮算法的应用。假设消息发出 15 秒后触发重发逻辑,可以设计如图 1 所示的数据结构:
1)一个包含 15 个元素的数组,数组每个元素指向一个链表,可以理解为 15 个桶;
2)Current Pos 指向数组中某个桶,每秒钟向下移动一次,指向下个桶;
3)如果 Current Pos 已经指向最后一个桶,移动时返回数组头部,指向第一个桶;
4)发消息时将相关信息放入 Current Pos 指向的桶中(作为链表中的一个元素)。
根据图 1 可以看出,Current Pos 的下一个桶(图 1 数组中下标 5)中的数据,就是所有已经发出 15 秒的消息,我们可以遍历链表,取出数据,逐个触发消息重发逻辑。
需要注意的是 Current Pos 是一个循环指针(指向数组末端后,下次偏移会重新指向数组头)。由此我们可以用更形象的方式描述这个结构,把数组首尾相连,形成一个“轮子”,也就是时间轮。如图 2 所示:
上面介绍的时间轮是将数据放在应用进程内存中的,可靠性比较差,我们可以进一步优化,将时间轮放到公共的存储中,很自然的会想到使用 Redis。可以用 Redis 中的 List 和 String 两种数据类型实现时间轮。设计多个 List,每个List 相当于时间轮中的一个桶,再用一个 String 保存当前 List 的 Key。如图 3 所示:
应用程序通过修改 Current Pos 的 Value 实现时间轮指针的移动。很轻松的将进程内存中的时间轮放到了 Redis 中,提高了数据可靠性,同时可以多个实例访问时间轮,避免了单点问题。
新的问题来了,现在我们看到的时间轮,可以用来触发秒级别的定时任务,但如果时间跨度比较大,例如小时或者天级别的定时场景,我们就需要一个非常“大”的轮子,将会占用非常多的内存资源。显然不是最优的方案,我们可以继续优化,使用磁盘文件+内存时间轮结合的方案,如图 4 所示:
1)将数据(需要触发的事件)按触发时间分散存储在多个文件中;
2)每个文件负责存储触发时间在指定区间内的事件,例如:文件A负责区间为 2019 年 11 月 21 日 14 点~2019 年11 月 21 日 14 点 30,则所有在这个时间区间内触发的事件都会存储在这个文件中;
3)内存中只装载最近半小时要触发的事件,并以时间轮形式组织。 应用程序需要选择合适的时机将文件装载到内存,并建立时间轮索引,控制好时间轮转动,将到期事件触发即可。
到现在为止,上面介绍的模型已经可以满足业务的定时任务需求,但它还只是一个功能逻辑,我们不能让每个有需求的业务方都去实现一个时间轮,重复造轮子。所以需要将方案进一步地下沉,抽象为一个基础的中台服务,提供通用的延时触发能力,对外提供服务。系统架构设计如图 5 所示:
业务模块与延时服务的交互可以使用 RPC Over TCP,但是对于延时服务来说,需要调用业务模块的 RPC 接口来触发延时任务,延时服务与业务模块耦合,不利于系统的稳定性,同时业务也需要实现回调接口,侵入比较大,易用性也不强。我们自然可以想到使用消息队列解耦,新的架构如图 6 所示:
看到这里很多同学会说,直接用延时消息不是更好嘛?为什么还要花这么大的篇幅,把事情搞这么复杂(架构设计哲学在于大道至简)。确实是这样,但问题在于不是所有的消息队列都支持延时消息,更不是都能支持任意时间的延时,例如:现在使用非常广泛的 RocketMQ 对延迟消息的支持就不是很友好:只能支持固定几个档位,不能支持任意时间的延迟。所以为了能够满足业务需求,我们使用 外部服务 + Redis + MQ 的方案(图 6),以较低的投入快速实现任意时间的延时消息。
图 6 架构设计依赖了外部服务以及 Redis 等来实现延时消息,由于引入过多的组件,整体服务稳定性会受影响,并不是最好的实现方案。更优雅的方案可以通过改造MQ来实现,把时间轮逻辑做到 MQ 内部。下面以 RocketMQ 为例介绍延时消息的实现方案,RocketMQ 消息存储模型如图 7 所示:
1)消息按顺序存储在 CommitLog 文件中;
2)Dispatch 线程将消息按主题分发到不同的 Queue 中。
基于RocketMQ实现延时消息,除了实现时间轮算法外还会涉及哪些改动?
1)延时消息的特殊处理;
2)主从Broker之间的数据同步;
3)Broker的故障恢复;
4…
可见,实际情况要复杂的多,还有很多点需要注意,这里也没有全部列出,欢迎大家在留言区讨论补充。
针对同一业务需求,会有多种技术方案,单从技术角度看很容易判断出方案的好坏,但我们看问题需要多角度和多维度,不能只关注于方案本身,从上面延时消息实现来看,最优雅的方案同时也是最复杂、实现难度最大方案。反之,借助外部组件可以用较低的投入实现同样的使用效果,虽然有缺陷,但对业务来说感受不到差别,所以我们选择技术方案时不一定要过于追求完美,要结合公司实际情况和团队技术实力,计算投入产出比(ROI),作出合理选择。
架构师最核心的能力是根据场景给出优雅的解决方案,适合就是最好的,既不保守设计又不过度设计。NX的架构师,是把复杂问题简单化,简单问题做没;SB(SomeBody)的架构师,刚好相反,是把简单问题复杂化,复杂问题搞不定!
希望您是一个NX的架构师!那么问题来了,做一个NX的架构师,需要具备哪些思维方式?欢迎留言交流。