/ redis

Job queue rollback

背景

我们的异步队列是Tasktiger,一个背后有商业公司支持的开源项目。

之前是Pyres,一个Resque的Python实现,因为Github在使用ResqueInstagram在使用Pyres,我们应该可以用一段时间。

后来Pyres长久不再维护,积累了一些bug以及性能问题让我们不得不选择更换一个异步队列系统,期初考虑过Celery,那是一个很成熟很强大的队列系统,以至于我很难一下子搞清它。

问题

在使用Tasktiger后有时会遇到已经处理完毕的task又重新被enqueue的情况,如果所有task都是幂等的(Idempotent),那这也不是问题,第二次执行task会因为之前处理过而不至于再做一次。然而并不是所有task都完成了幂等改造,所以这对于我们来说是个严重问题,但当时并不能稳定重现。

探索

怀疑过许多地方,后来发现在queue redis上执行BGREWRITEAOF就能避免这个问题,于是把原先一个月执行一次的AOF重写操作变成了每天执行一次,大概在备份数据之前,因为备份需要停机,如果不在这之前做,可能备份导致重启服务就会让很多已经处理过的task重复处理,例如可能会给会员发多条短信和微信提醒。


就在昨天,因为重启queue redis导致很多task重复处理,这个问题终究没有被解决,于是花了大约2天时间,找到了导致问题的根源。

之前猜测是不是Tasktiger哪里没写好导致,毕竟这个项目在Github上只有不到500个star,而redis有几万,广大用户基础更容易发现奇怪bug,而在Tasktiger上遇到某个bug,也许你就是世界上第一个遇到的。基于这个假设我看了很多源代码,没有找到可靠证据,而且基于一个简单事实:一个task从最开始不存在,到出现在Tasktiger中,到处理完被删除,如果是Tasktiger的问题,那它从哪里得到数据构造出一个task呢?难道是redis的问题吗?

queue redis一般都会启用AOF,所有修改数据的命令和值被记录到AOF中,等redis重启时,会执行AOF文件中的命令,这样redis就有了和重启前一模一样的数据。在AOF中记录着某个task从出现到消失的全过程,难道是redis没完成load就接受服务了?从日志上看并不是这样,在没load完执行操作会得到异常。

看了下生产环境的queue状态,基本没有正在处理的task,停掉queue redis,下载AOF文件,在本地让redis启动并load这份AOF,不启动Tasktiger的worker,只静静地看queue的状态,发现load完后有很多task被重新放回了queue,此时如果有worker,一定会重复执行。

到这里难道能确定是redis的问题吗?redis的AOF并不能让redis回到之前的状态?如果是这样的话redis怎么会被广泛应用?

于是找了一个task,从AOF中看看Tasktiger是如何处理它的。

处理过程没发现异常,只是Tasktiger用了很多lua脚本操作数据,例如有个脚本是:当一个key不在给定的某几个zset中时删除这个key。这个操作不复杂,但你无法只用redis完成,因为redis只是一个数据存储,无法处理复杂逻辑,所以只能使用lua完成,当然很多语都可以完成这个逻辑,只不过redis支持执行lua脚本,算作是对redis操作的补充。

因为有了一个具体task,查看和它相关的所有操作后,发现除非是在某一步lua脚本未执行时才会导致这个task被放在active queue中而不是被移除。

于是我构造了一份AOF,最开始的部分是一堆SCRIPT LOAD指令,把Tasktiger需要的脚本都load到redis中,这样后续操作才可以使用EVALSHA,这份构造好的AOF也通过了redis-check-aof检查,被load后再次查看queue状态,竟然正常了,和停redis之前一致!看来问题出在执行这些脚本的地方。

在redis中执行lua脚本有两种做法,第一种是使用EVAL,这种做法是把脚本内容一并贴上,每次执行都需要这样,如果总是重复执行某个特定脚本就会因为传输了大量脚本内容而使得效率不够高,于是有了第二种执行方法——EVALSHA,使用这种需要脚本的SHA1,当执行SCRIPT LOAD后,redis返回的内容就是脚本的SHA1,load过的脚本在手工flush前或者redis重启前都是有效的。SCRIPT LOADBGREWRITEAOF后并不会存在,虽然你还是可以通过EVALSHA调用脚本。

那么这两种执行方法对于AOF文件记录内容有差别吗?没有,执行EVALSHA也会被记录成EVAL,如果你在AOF中发现有EVALSHA那就奇怪了。在生产环境下载的AOF中,有不少EVALSHA,为何没有被记录成EVAL?因为这些EVALSHA并不是redis执行的,而是Tasktigerpipeline

导致出现回滚问题的原因是:BGREWRITEAOF后,由于SCRIPT LOAD被去掉,redis重启后如果有使用EVALSHA调用脚本的地方会出错,因为那个脚本并没有被redis load

不是说EVALSHA会被翻译成EVAL放在AOF中吗?是的,不过Tasktiger自己实现了一个redis pipeline,这也是个lua脚本,在这个lua脚本中是需要调用其他lua脚本的。调用方法包含两种,大多数都是EVALSHA由于redis禁止在lua中使用redis.call执行EVALSHA调用另一个脚本(并不是所有redis命令都可以在redis.call使用),Tasktiger使用了其他方法来调用,算是一种hack。

由于redis的限制加上Tasktiger的hack,导致在BGREWRITEAOF后,如果有pipeline脚本的调用发生,下次重启redis会使得之前做过的task被重新放回queue,因为移除操作的脚本还没有load,所以移除失败了,看起来就像是task被重新放回queue

目前我们针对这个问题的解决方法是:勤快点BGREWRITEAOF,每小时一次,重启redis之前再做一次