如何优雅地实现一个闹钟服务

我们在开发互联网产品的时候,经常会遇到这样的业务场景,例如:

我们在电商网站下了一个订单,电商平台可能要求我们一定时间内完成支付,否则订单就会被自动取消;

我们在工作协同平台上预约了一个会议,在会议即将开始前15分钟,协同平台会自动给与会者发一个通知提醒;

我们为经纪人提供的产品中,经纪人预约了某个时间前往看房,到时候系统会提前发通知提醒经纪人;

在互联网产品中,类似的需要在未来的某个不长的时间后做某事的场景非常多,这个功能看起来很简单,但并不见得广大工程师们都能实现的好。

在面试工程师的过程中,我时常会基于这种场景问一些类似的问题,看看候选人用计算机工程方法解决实际问题的思路。遗憾的是,大多数候选人很快给出的答案是:这个很简单,我写一个定时任务,定时执行一段代码,这段代码会去查询数据库,看看有没有超时需要取消的订单、有没有即将到时需要提醒的会议、有没有需要提醒的看房预约等。并且讲了很多在 Java 或其他语言体系中比较成熟的定时任务实现工具,例如经常会被提到的 Quartz、Spring-Task 等,有的甚至用 Linux 的 crontab 实现定时驱动。

遇到这样的回答,我往往会进一步提出下列几个问题:

如果现实中这样的请求量很大怎么办?比如你想象一下淘宝这样的电商平台每秒钟产生的订单量;

如果实际场景要求的时间精度非常高怎么办?比如要求精确到秒甚至更高。

这时候就会意识到上述粗暴的解决方案行不通,因为:

当数据量很大的时候,相应的业务数据库(例如上面第一个例子中的订单库)中需要被扫描检查的数据特别多,这势必需要定时地去访问大量的数据,从中找到寥寥无几的中标者。这是一种极大的浪费和低效,弄不好会把数据库给拖跨,导致整个系统性能低下;

精度要求很高的时候,上述方法就更行不通了,那个定时任务必须高频运行,这势必雪上加霜,甚至因为每次检查大量数据本身就需要很长时间,根本就不能实现想要的精度;

而且程序中存在大量的定时任务真的是一件很糟糕的事情,因为你不知道某个时间程序会批量干了什么事情,影响大量的数据,如果出现故障将很难排查。

在计算机工程思想中,有一条原则就是,要让计算机尽量少做工作,特别是少做无用的工作。这样才可以在有限的计算机资源情况下做更多的事情,节省成本,获得更好的性能体验,进而为用户提供更好的产品体验。甚至在数据量高速增加的情况下,性能也不会有明显的降低,以及必要时可以通过简单的 Scale-Out 扩容。我们所熟知的各种基础算法和数据结构,如二分查找法、HashTable、B+Tree 等都是通过指数降低时间复杂度而获得很好的时间成本优势进而解决了很多实际问题的。

数据结构和算法存在的重要目的之一就是让我们通过事先组织或摆放好数据,并通过某种巧妙的计算方式,用较小的成本去应对大量的数据问题的。

说到这里,我回忆起很多年前刚刚毕业踏入软件工程师这个职业时,我最尊敬的启蒙导师给我的启发。当时我们做一个大型的移动通讯网络交换系统,在电话呼叫的时候,有很多这样的定时器场景,例如当对方振铃120秒后需要自动挂断;挂断电话的时候如果对方没有回应,就可能导致稀缺的有线或无线资源被一直占用而耗尽,所以设备间的连接需要定时 heartbeat。这就是通信网络中各种协议(protocol)必须要考虑的场景,全球互通的电信网络就是这样工作的,我们可以想象成类似互联网 TCP 协议中的建立连接和关闭连接这样的场景。因为系统中有非常多的模块,且一个市或省的电信局每天可能有几千万个呼叫,我们不希望每个模块的工程师都单独去实现定时逻辑,而且因为这些程序是在特定的电路板上工作的,计算资源非常有限,且时间准确度要求非常苛刻(我记得当时我们团队的名字就是 Real-Time Team)。我的导师将这个任务交给了我,当时我也是一时找不到好方法实现,他就说:你仔细观察一下你家的闹钟是怎么工作的。于是我跑到某个会议室拿来一个下图这种转盘式的钟表盯着看了一下午,于是茅塞顿开。

我们订闹钟的时候,其实是告诉闹钟,你在未来的几点几分几秒叫醒我。这个闹钟就像一个服务器程序,我们给他一个定时指令,他在未来给我们个通知。我们的各种客户端可以给他成千上万的定时指令,他这个服务要做的就是记住这些指令,并在每个对应的时间点给当初下指令的那个客户端发个通知,就像下图这样:

于是我们就可以设计这样一个数据结构,用一个环形的数组来模拟闹钟的转盘刻度(所谓环形数组就是当我们顺序移动数组下标到最后一个槽位时,再从第0个槽位开始继续移动),假设这个闹钟的时间精度是1秒且这个转盘转一圈正好是一天,每个数组槽位代表一天中的某个确定的秒数,那么这个数组的长度就是246060=86400。在每个数组槽位上我们挂一个链表,这个链表就是为了存放客户端注册的闹钟请求的。然后我们可以提供服务接口,当有人请求在未来某个时间需要提醒的时候,我们就将这个请求的信息记录在对应的时间槽位上的链表末尾,即在这个节点上记录下是谁要求在未来时钟走到这里时提醒他,当然请求者可能要求几天后(也就是超过了这个环的长度),那我们还要记录下时钟要转的圈数。

然后我们在这个服务中启动一个线程模拟秒针,每秒滴答运行一次,检查对应槽位的链表上是否有挂着节点,如果有并且圈数参数已经减到了0,那就给当初的请求者发一条提醒消息(比如可以用 Message Queue 发送)告诉他时间到了,你该干嘛干嘛吧。如果记录圈数的参数尚未减到0,就将他减1,等下次遇到他再说。这样一个基本的闹钟服务就设计好了。

这种经过一番设计的闹钟服务的好处是:

每次只检查少量的数据,数据访问和计算开销都非常小,成本极低;

精度可以做到很小,比如秒;

整个产品体系中各个业务模块需要闹钟提醒的时候,只需要调用API注册一个闹钟请求即可,剩下的就是等通知了,让业务开发非常简单敏捷;

业务系统每次离散地收到通知只需处理特定的数据,对业务系统不会有冲击,不会担心随着数据量变大而影响业务系统本身的性能。

你看,这种设计和现实中的闹钟是不是非常相似,有时候计算机世界的解决方案就是对现实世界的模仿,类似的例子有很多。而且这个解决方案中的数据模型和我们常见的 HashTable 非常相似,都是将大量数据分散到多个小的数据集中,让每次计算少做无用功。

当然,从工程上讲,上面只是描述了基本的设计原理,要让他成为一个真正可以稳定工作并可以平滑扩展的服务还有下列工作要做:

我们不能把这个闹钟的环只放在内存中,那样当服务器重启后之前所注册的闹钟请求都消失了,因此我们可以考虑将数据放在 Redis 这样的带有持久化功能的内存数据库中,当服务重启后要从之前中断的点做回放;

实际场景中,有些闹钟请求时间很长,比如好几天后,显然这样的数据都放在内存或 Redis 中比较浪费,我们可以把时间久远的数据当做“冷”数据暂时存放在 MySQL 这样的数据库中,并平滑地将近期可能使用的“热”数据加载进入环中;

有些请求并不需要那么高的精度,比如分钟就可以了,那么我们就可以实现多个不同精度的环;

当业务量持续扩大,一个服务器已经吃不消了,或者我们环上的链表太长了,我们就可以部署多台服务器,实现水平 Scale-Out 扩展。

你看,这么个看似简单的功能,实现起来要有一些思考和设计,巧妙地利用数据结构和简单的算法优雅、低成本地实现;工程上还要考虑稳定性,可扩展性等多个方面才能成为一个可用的服务。

因为进行了抽象设计并和特定的业务分离,实现起来就和特定的业务完全无关了,所以上述的设计实现工作量其实非常小。我们的基础平台服务开发工程师,在 Get 到这个工程场景后,只用了一天时间就完成了开发和上线工作,给迭代时间本来就很紧的业务开发团队提供服务,让业务产品可以非常敏捷和优雅地实现各种类似功能。

另外,在开源的 Netty 代码库中,有一个 HashedWheelTimer,其实现原理也是类似的,大家可以参考源代码:

https://github.com/netty/netty/blob/master/common/src/main/java/io/netty/util/HashedWheelTimer.java

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值