一个队列最基本的功能应该是入站和出站。一边把任务放到队列中,一边从队列中读取处理任务。
我们看一下laravel中对队列的设计,首先是接口的设计在\Illuminate\Contracts\Queue\Queue
相关的接口被我用虚线分成了3类,第一类是push,也就是入站。第二类是pop也就是出战,第三类是读取队列大小,获取和设置ConnectionName。
其中入站的方法中,pushOn 和 laterOn 分别是 push 和 later 的别名。 bulk 是批量的 push 他们都是把job放到队列中,pushRaw指的是把原始数据放入到队列中。
举个例子来说明下这两类方法,用户123登录了,我需要进行数据统计可以有两种做法,
1、我们把123放入到队列中那么可以通过pushRaw这个方法把123放入队列,等处理程序拿到123再去处理,进行数据统计。但是在laravel中我们往往不这么处理,参考第二中做法
2、我们定义一个Job,用123去初始这个Job,然后把Job放入到队列中。这两种方式的差异在于一个放的是数据,一个放的是Job。laravel对队列的封装都是用的这种方式。在实际处理的时候,是把Job中的数据通过pushRaw放入到队列中的。
class Job { /** * Create a new job instance. * * @return void */ public function __construct($userId) { $this->userId = $userId; } /** * Execute the job. * * @return void */ public function handle() { //处理相关数据 统计数据加1 } }
接下里我们以Redis队列为例看下具体的处理过程
一、首先看一下抽象类的封装
抽象类的代码主要分3分布,图中用实线进行了分割,对应接口的3分布,一部分是关于队列的。接口中对入站的操作有pushOn laterOn push later bulk pushRaw 6个方法的定义。具体的如站和出站的方法,应该会每个驱动都不一样,所以在抽象类中只实现了 pushOn laterOn 和 bulk,而没有实现 push later pushRaw
/** * Push a new job onto the queue. * * @param string $queue * @param string $job * @param mixed $data * @return mixed */ public function pushOn($queue, $job, $data = '') { return $this->push($job, $data, $queue); } /** * Push a new job onto the queue after a delay. * * @param string $queue * @param \DateTimeInterface|\DateInterval|int $delay * @param string $job * @param mixed $data * @return mixed */ public function laterOn($queue, $delay, $job, $data = '') { return $this->later($delay, $job, $data, $queue); } /** * Push an array of jobs onto the queue. * * @param array $jobs * @param mixed $data * @param string $queue * @return mixed */ public function bulk($jobs, $data = '', $queue = null) { foreach ((array) $jobs as $job) { $this->push($job, $data, $queue); } }
第二部分是createPayload,我们上面提到当把一个job放入队列的时候,实际是从job中提取信息,然后把信息通过pushRaw放入队列的。createPayload就是从job中读取信息以供pushRaw使用的。这一块的方法稍多一些,主要是createPayload通过createPayloadArray创建配置;createPayloadArray根据job的类型调用createObjectPayload或者createStringPayload。getDisplayName和getJobExpiration,而后者又需要相关的时间函数
第三部分是 getConnectionName 和 setConnectionName 的实现。size方法应该是每个驱动都不一样的。此外还提供了setContainer方法。
即成抽象类之后,队列必须实现的方法只剩下了push later pushRaw pop size。
二、RedisQueue中的具体实现
1、 push pushRaw
public function push($job, $data = '', $queue = null) { return $this->pushRaw($this->createPayload($job, $data), $queue); }
push是通过createPayload整理数据之后,通过pushRaw写入队列。pushRaw的代码也非常简单,获取redis连接,通过getQueue获取队列,也就是redis中的key,然后用rpush命令插入到列表的最右边。
public function pushRaw($payload, $queue = null, array $options = []) { $this->getConnection()->rpush($this->getQueue($queue), $payload); return json_decode($payload, true)['id'] ?? null; }
2、later laterRaw
和上面的方法对应later也是通过createPayload整理数据之后,通过laterRaw写入队列。
protected function laterRaw($delay, $payload, $queue = null) { $this->getConnection()->zadd( $this->getQueue($queue).':delayed', $this->availableAt($delay), $payload ); return json_decode($payload, true)['id'] ?? null; }
对比pushRaw的方法,看到laterRaw在在原有队列的名称后补充了':delayed'后缀,以有序集合的方式存储。也就是说他们存储的不是一个地方。那么出栈的时候是怎么读取的呢?我们看下pop
3、pop
public function pop($queue = null) { $this->migrate($prefixed = $this->getQueue($queue)); list($job, $reserved) = $this->retrieveNextJob($prefixed); if ($reserved) { return new RedisJob( $this->container, $this, $job, $reserved, $this->connectionName, $queue ?: $this->default ); } }
这个的处理相对比较麻烦一些。$this->migrate($prefixed = $this->getQueue($queue));对队列进行了合并,然后从队列中读取任务,返回RedisJob
3.1 队列的合并
/** * Migrate any delayed or expired jobs onto the primary queue. * * @param string $queue * @return void */ protected function migrate($queue) { $this->migrateExpiredJobs($queue.':delayed', $queue); if (! is_null($this->retryAfter)) { $this->migrateExpiredJobs($queue.':reserved', $queue); } } /** * Migrate the delayed jobs that are ready to the regular queue. * * @param string $from * @param string $to * @return array */ public function migrateExpiredJobs($from, $to) { return $this->getConnection()->eval( LuaScripts::migrateExpiredJobs(), 2, $from, $to, $this->currentTime() ); }
从代码中看到,首先是$queue和$queue.':delayed'的合并,然后对于设置了retryAfter的,再次用 $queue和$queue.':reserved'进行合并。合并的Lua脚本:
public static function migrateExpiredJobs() { return <<<'LUA' -- Get all of the jobs with an expired "score"... local val = redis.call('zrangebyscore', KEYS[1], '-inf', ARGV[1]) -- If we have values in the array, we will remove them from the first queue -- and add them onto the destination queue in chunks of 100, which moves -- all of the appropriate jobs onto the destination queue very safely. if(next(val) ~= nil) then redis.call('zremrangebyrank', KEYS[1], 0, #val - 1) for i = 1, #val, 100 do redis.call('rpush', KEYS[2], unpack(val, i, math.min(i+99, #val))) end end return val LUA; }
每次对不超过100个的超时数据,从from,rpush到了to,也就是从$queue.':delayed'和 $queue.':reserved'插入了 $queue。从这里能看到redis队列对顺序的保证并不是特别准确。比如:
放入第1个消息a到队列,
延迟1s放入第2个消息b到队列,
然后过了1s再放入第3个消息c到队列。
如果在放入第3个消息c之前开启了队列的处理,输出的是abc,但是如果在放入第三个消息后开始的队列处理,那输出的就是acb了。
3、size
public function size($queue = null) { $queue = $this->getQueue($queue); return $this->getConnection()->eval( LuaScripts::size(), 3, $queue, $queue.':delayed', $queue.':reserved' ); }
size的方法就相对简单了,对上面提到的3类消息$queue,$queue.':delayed'和 $queue.':reserved'求和,其中$queue是队列,$queue.':delayed'是上面提到的延迟放入队列的消息, $queue.':reserved'是真正处理中的消息。
Redis队列用了很多的Lua脚本,来保证操作的原子性。
4、deleteReserved deleteAndRelease
在处理成功之后需要通过deleteReserved删除保持的消息,在处理失败后通过deleteAndRelease删除保存的消息,并放回队列。
测试代码:
$redis = app(\Illuminate\Contracts\Queue\Factory::class)->connection('redis'); //$redis->pushRaw("hi time is ".time()); $redis->push(new \App\Jobs\Test(123 dispatch((new \App\Jobs\Test(1));