说明:写这篇文章时,我还是一个从电子工程师转行到软件开发没多久的新手程序员,文章的记录现在看来真是让人汗颜,充满了浓浓的小作坊风格。但这就是成长不是吗?


背景描述

近几年 redis 应用的越来越多,正好现在手上的一个项目就用到了。简化一下需求,就是要实现一个 worker 程序,该程序所有的工作就是去 redis 中的一个任务链表 tasklist 里面去取任务,执行一些处理操作。

分工明确化

从始至终我们持有的一个观点就是,这个 worker 的功能要极度简化,就是去从 tasklist 中取任务,执行操作,并根据任务执行成功与否进行相关的结果记录。至于这个 tasklist 怎么来的,里面的任务是由谁来插入,worker 一概不管。这样的话,有几个好处:

  • worker 的角色明确,目标清晰。直接的好处就是代码容易编写。
  • 设计思想比较通用
  • tasklist 的插入在不同的场景下有不同的方式,最常见的是根据某个不断增长的 log 中取任务,插入任务。而这个工作很灵活,通过脚本语言更方便实现。

关于重试

worker 从 tasklist 中领取到一个任务来后,就开始执行相关的操作。如果一切顺利,就万事大吉。但在软件设计中,考虑异常情况几乎已经是广大码农们本能的反应了。不是我们天生心思缜密,而是这些都是通过一些惨痛的教训得来的。提到异常处理,在这里多说两句,讲一个亲身经历的例子:

我在上一家公司工作时,工作内容主要是版本发布。每天有几十个分布在全国各地的程序员要把它们的工作成果即代码,合入到我这里来,我给编译一个版本,自己进行基本的验证,如果有问题,则解决问题或者回退,如果没有问题则版本迭代向前,并把这个版本发布出来给专职的测试人员进行进一步测试。这个工作是枯燥无味的,因此在那段时间就琢磨着怎么把编译、发布版本用脚本来完成。随手翻了几页 shell 脚本的书,于是我就堆砌了一堆命令行。其中有一个操作是这样的,执行编译后,cd 到一个 release 目录,再用 ftp 上传到另一台发布版本的机器上。问题在于,以前每次都是手动操作,所以能确保 release 目录下有结果(如果没结果就是编译、打包错误了,早就去联系人解决了),而事实是当编译、打包失败时,这个 release 目录根本就不会生成。因此,在脚本执行 cd 时会出错,但是出错了由于我没有进行相关的处理,脚本并不会退出,而是在当前工作目录,继续欢快的执行 ftp 上传。于是终于有一次(事实是就是第二天),我把当前工作目录下的所有的源文件给当做版本发布出去了。结果很惨烈,一堆测试的兄弟们电话过来责问,领导也把我一顿批评。这就是想当然的认为 cd 不会发生错误的结果。

同样,在这个 tasklist 设计中,如果 worker 执行当前任务失败后,该怎么处理?很自然的想到了重试,给每个任务三次重试的机会。我一拍脑袋,把执行失败的任务放入到一个失败者队列,当 worker 遍历完 tasklist 队列后,则再把失败者队列的任务全部重新插入到 tasklist 里面。worker 继续执行遍历操作,这样来三次为止。想到这一点,我还是挺沾沾自喜的,因为通过使用 redis 的 RPOPLPUSH 操作,是如此容易的将一个队列的任务全部插到另一个队列里面。而且这样,worker 的核心处理过程,仍然是在处理 tasklist 链表,不用再特意去处理失败者链表。

这么想着,也这么干着,撸起袖子就把代码写了。拿到公网测试,是要写一个 crontab 脚本,该脚本每分钟执行一次,确保系统中有五个 worker 进程在劳动。在测试的一刹那,我想到出问题了。当把执行失败的任务放入到失败者队列,并在 tasklist 队列遍历完后,再把失败者队列插入到 tasklist 里面,每一次重试过程都这么重复,直到重试三次仍然失败后,worker 进程退出。而此时这些失败的任务仍然是在失败者队列里面。当下一个 worker 进程继续来扫完 tasklist 后,又会再次对失败者队列执行相同的操作。

我们的目标是给失败的任务三次重新来过的机会,而不是无数次的复活再死去。

之所以会出现这样的问题,在于,起初并没有想到我们的 worker 会被用一种什么样的方式来调用。对于一个 worker 进程而言,确实能保证只给予失败者三次重试的机会,因为让你执行三次后,我完成任务后就主动身亡了,你当然不会再继续复活了。可是,不管你进程什么时候死去,怎样死去, redis 都在那里,每一个 worker 进程都会去处理同样的 tasklist 队列和失败者队列。同样的一个任务,如果它就是没救了该死了,第一个 worker 进程让它复活三次后又让它死去,第二个进程又让它复活三次,再让它死去。而我们的系统中同时运行着五个 worker 进程。

想了想怎么解决这个问题。我又一拍脑袋,跟同事说,我们再搞一个永久死亡队列,一个任务死了三次后就放到这个永久死亡队列里面,以后再也不管它了。同事汗了一下说,在任务执行失败后,就把任务继续放到 tasklist 队列里面,而不是放到失败者队列,执行三次失败后,再放入到失败者队列,这个失败者队列就是永久死亡队列。里面的任务不会再被执行。

我愣了半天,这么简单啊,我怎么没想到。

联想

这个例子拿医生救人来类比最合适了。假设最开始一个医院只有一个医生,而病人很多,排着队等着抢救。他医术高超,可总有病入膏肓的病人他无能为力。这个时候,他把这些奄奄一息的人放到另外一个屋子里,因为只有他一个医生,他要去继续抢救其他更有希望生还的人。他抢救完了所有人后,他在来到那个屋子抢救那些基本没救的可怜的人。把那些人再弄到手术室里排着队,一个一个抢救,直到三遍以后,如果仍然有人治愈不了,他也无力回天了。

后来,医院规模扩大了,现在可以有五个医生同时看病了。如果还是按照以前的制度,每个医生碰到无法救治的病人,就把他们放到另一个队伍里, 直到第一个队伍的病人看完了之后,再把第二个队伍的没有治愈的病人放到第一个队伍里面,来把这个队伍处理三遍。如果医生们一辈子只上一天班,其实也没多大问题。可是,每天都有新的病人进来,无论是出于职业道德还是相关规定,医生们每天都要去处理正常的队列和无法治愈的队列。这样那些奄奄一息的人们每天都会被五个医生轮流看很多遍。

正确的做法是,用一个本子记录下来每个治愈失败的病人重复治愈了多少次,如果这个病人此次治愈无效,则让他回到队尾继续排队,给他的重试次数加一,每个医生在治疗每个病人之前,都会先查看这个本子,看这个病人的重试记录。到了三次,则宣布救治无效。关键在于,这个本子是所有的医生都可以看到的,有据可查,而不是都只在心里记下自己给病人重复治疗了多少次。

通过这个例子可以看到啊,好的设计之所以好,很可能是因为它简单明了。而如何要达到简单明了,则是功力所在了。