环境搭建
在编码之前,我们先要准备好工具。
不论是 osx、linux 还是 windows,我们都需要先准备好以下环境:
- PHP(推荐使用 php7+ )
- Mysql
- redis
我们使用 PHP 作为编程语言,来操作消息队列。
另外,分别使用 mysql、redis 来作为消息队列的队列容器。
分析与设计
目录总览
|--- Producer.php
|--- Consumer.php
|--- Queue.php
|--- Driver
|---|--- QueueI.php
|---|--- MysqlDriver.php
|---|--- RedisDriver.php
|---|--- Job.php
|--- queue.sql
文件作用介绍
- Producer.php
生产者,用于生成消息 - Consumer.php
消费者,用于消费消息 - Queue.php
队列操作类,为生产者和消费者提供一组统一的消息队列操作接口 - Driver/QueueI.php
队列操作接口,规范为Queue.php提供具体服务的驱动类,所有驱动类必须实现此接口,以确保有能力为Queue.php提供底层服务 - Driver/MysqlDriver.php
Mysql队列操作驱动,负责与Mysql的底层操作 - Driver/RedisDriver.php
Redis队列操作驱动,负责与Redis的底层操作 - Driver/Job.php
统一的消息结构(数据格式),可以参考下面《数据格式》的介绍理解此文件的作用 - queue.sql
这是一个 sql 文件,里面是 mysql 消息队列表的表结构
数据格式
接着,我们需要统一「消息」的格式。
我们知道,生产者和消费者,同时依赖于消息队列,它们对消息进行处理的时候,则依赖于消息格式。
统一的消息格式,就像是面向对象中的接口,使调用方和实现方,都在规范轨迹之内工作。
所以,我们添加了一个文件:
- Job.php
调用流程
实现框架
接口
Driver/QueueI.php
<?php
// +----------------------------------------------------------------------
// | QueueI.php
// +----------------------------------------------------------------------
// | Description: queue interface
// +----------------------------------------------------------------------
// | Time: 2018/12/19 上午11:17
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------
namespace Driver;
interface QueueI
{
/**
* @return array
* 查询tubes列表
* 一个系统需要多个队列,它们可能分别用于存储短信、邮件等,它们互相隔离。
*/
public function tubes(): array;
/**
* @param Job $job
* @return Job
* 向队列中存储一个消息(任务)
*/
public function put(Job $job): Job;
/**
* @param string $tube 需要指定从哪个队列接收任务
* @return Job
* 从队列接收一个消息(任务)
*/
public function reserve(string $tube): Job;
/**
* @param Job $job
* @return bool
* 删除某个消息(任务)
*/
public function delete(Job $job): bool;
/**
* @param string $tube
* @return array
* 获取某个队列中的消息列表
*/
public function jobs(string $tube): array;
}
工具类
Queue.php
<?php
// +----------------------------------------------------------------------
// | Queue.php
// +----------------------------------------------------------------------
// | Description: 队列工具
// +----------------------------------------------------------------------
// | Time: 2018/12/19 上午11:15
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------
class Queue
{
/**
* @param string $driver
* @param array $options
* 初始化
*/
public static function init($driver = 'Mysql',$options = [])
{
$class = "Driver\\$driver";
self::$driver = new $class($options);
}
public static function tubes(): array
{
return self::$driver->tubes();
}
public static function put(Job $job): Job
{
return self::$driver->put($job);
}
public static function reserve(string $tube = 'default'): Job
{
return self::$driver->reserve($tube);
}
public static function jobs(string $tube = 'default'): array
{
return self::$driver->jobs($tube);
}
public static function delete(Job $job): bool
{
return self::$driver->delete($job);
}
}
数据对象
Driver/Job.php
<?php
// +----------------------------------------------------------------------
// | Job.php
// +----------------------------------------------------------------------
// | Description: 任务对象
// +----------------------------------------------------------------------
// | Time: 2018/12/19 下午3:19
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------
namespace Driver;
class Job
{
public $id = null;
public $tube;
public $status;
public $job_data;
public $attempts;
public $sort;
public $reserved_at;
public $available_at;
public $created_at;
public static $field = [
'id', 'tube', 'status',
'job_data', 'attempts',
'sort', 'reserved_at',
'available_at', 'created_at',
];
public static $field_string
= 'id,tube,status,job_data,attempts,sort,' .
'reserved_at,available_at,created_at';
public static function arr2job($jobs)
{
$real_jobs = [];
foreach ($jobs as $v) {
if (!is_array($v)) {
$v = json_decode($v, true);
}
$real_jobs[] = new Job($v);
}
return $real_jobs;
}
public function __construct(array $data = [])
{
foreach ($data as $k => $v) {
$this->$k = $v;
}
$this->created_at = time();
$this->available_at = $this->created_at;
}
public function isEmpty()
{
return $this->job_data ? false : true;
}
}
## 实现 Mysql 驱动和 Redis 驱动
Mysql 驱动
数据库表结构
Driver/queue.sql
CREATE TABLE `queues` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`tube` varchar(30) NOT NULL DEFAULT 'default',
`status` enum('ready','reserved') DEFAULT 'ready',
`job_data` text NOT NULL,
`attempts` int(11) NOT NULL DEFAULT '0',
`sort` int(10) NOT NULL DEFAULT '100',
`reserved_at` int(11) DEFAULT NULL,
`available_at` int(11) DEFAULT NULL,
`created_at` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `queues_index` (`tube`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
字段意义可以参考《实现框架》一节中的 Job.php 注释
为tube加上索引,可以提升根据 tube 接收 job 的效率
驱动逻辑
Driver/MysqlDriver.php
<?php
// +----------------------------------------------------------------------
// | MysqlDriver.php
// +----------------------------------------------------------------------
// | Description: mysql驱动
// +----------------------------------------------------------------------
// | Time: 2018/12/19 上午11:17
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------
namespace Driver;
class MysqlDriver implements QueueI
{
private $conn; // 数据库连接
private $config; // 配置
private $table; // 表
private $select_suffix; // 查询的前缀
private $delete_suffix; // 删除的前缀
private $update_suffix; // 更新的前缀
private $insert_suffix; // 插入的前缀
public function __construct($options = [])
{
$this->config = $options;
$this->conn = new \PDO(
$this->config['dsn'],
$this->config['username'],
$this->config['password']
);
$field_string = Job::$field_string;
$this->table = $this->config['table'];
$this->select_suffix = "SELECT {$field_string} FROM {$this->table}";
$this->delete_suffix = "DELETE FROM {$this->table}";
$this->update_suffix = "UPDATE {$this->table}";
$this->insert_suffix = "INSERT INTO {$this->table}";
}
public function tubes(): array
{
$sql = "SELECT `tube` FROM {$this->table} GROUP BY `tube`";
$res = $this->conn->query($sql);
if (!$res) {
throw new \PDOException('查询错误:' . $sql . '-错误提示:' . json_encode($statement->errorInfo()));
}
return $res->fetchAll(\PDO::FETCH_ASSOC);
}
public function delete(Job $job): bool
{
if (!$job->id) {
throw new \Exception('job id 不能为空');
}
$sql = "{$this->delete_suffix} WHERE id = :id";
$statement = $this->conn->prepare($sql);
$res = $statement->execute([':id' => $job->id]);
return $res;
}
public function jobs(string $tube): array
{
$sql = "{$this->select_suffix} WHERE tube = :tube";
$statement = $this->conn->prepare($sql);
$res = $statement->execute([':tube' => $tube]);
if (!$res) {
throw new \PDOException('查询错误:' . $sql . '-错误提示:' . json_encode($statement->errorInfo()));
}
return Job::arr2job($statement->fetchAll(\PDO::FETCH_ASSOC));
}
public function put(Job $job): Job
{
// 组装sql
$sql = "{$this->insert_suffix}";
$field = '';
$prepare = '';
$value = [];
foreach (Job::$field as $v) {
if ($job->$v) {
$field .= "{$v},";
$prepare .= ":{$v},";
$value[":{$v}"] = $job->$v;
}
}
$field = '(' . trim($field, ',') . ')';
$prepare = '(' . trim($prepare, ',') . ')';
$sql = "{$sql} {$field} VALUES {$prepare}";
// 执行sql
$statement = $this->conn->prepare($sql);
$res = $statement->execute($value);
// 结果
if (!$res) {
throw new \PDOException("插入错误:" . $sql . '-错误提示:' . json_encode($statement->errorInfo()));
}
$job->id = $this->conn->lastInsertId();
return $job;
}
public function reserve(string $tube): Job
{
$time = time();
$over_time = $time - $this->config['ttr'];
$sql = "{$this->select_suffix} WHERE (status = 'ready' OR (status = 'reserved' AND reserved_at <= {$over_time})) AND available_at <= {$time} AND tube = :tube ORDER BY sort limit 1";
$statement = $this->conn->prepare($sql);
$res = $statement->execute([':tube' => $tube]);
if (!$res) {
throw new \PDOException('查询错误:', $sql);
}
if ($data = $statement->fetch()) {
$job = new Job($data);
$attempts = $job->attempts + 1;
$time = time();
$sql = "{$this->update_suffix} SET status='reserved',attempts = {$attempts},reserved_at = {$time} WHERE id = {$job->id}";
$rows = $this->conn->exec($sql);
if ($rows <= 0) {
throw new \PDOException('更新出错:' . $sql . '-错误提示:' . json_encode($statement->errorInfo()));
}
return $job;
}
return new Job();
}
}
关于mysql的实现,主要观察其实现接口每个方法的sql与流程,特别是reserve方法,当接收成功之后,还需更新该条消息的状态。
Redis 驱动
在这里,我们使用 redis 的 list 和 sorted-set 来实现数据的存储,因为它本身具备顺序和去重的特性。
redis 服务的搭建与操作,此处不做介绍,这里主要模拟实现消息队列
另外,你的 PHP 环境还需开启 Redis 扩展。
接下来,是驱动的实现。
Driver/RedisDriver.php
<?php
// +----------------------------------------------------------------------
// | RedisDriver.php
// +----------------------------------------------------------------------
// | Description: redis驱动
// +----------------------------------------------------------------------
// | Time: 2018/12/19 上午11:17
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------
namespace Driver;
class RedisDriver implements QueueI
{
private $conn;
private $config;
private $tubes_key;
public function __construct($options = [])
{
$this->conn = new \Redis();
$this->conn->connect($options['ip'], $options['port']);
if (isset($options['password'])) {
$this->conn->auth($options['password']);
}
$this->tubes_key = $options['tubes'];
}
public function tubes(): array
{
// 使用 sorted-set 存储当前拥有的队列,比如你 default、test、sms 队列
return $this->conn->zRange($this->tubes_key, 0, -1);
}
public function jobs(string $tube): array
{
return Job::arr2job($this->conn->lRange($tube, 0, -1));
}
public function put(Job $job): Job
{
// 维护 tube 集合,可实现不重复
$this->conn->zAdd($this->tubes_key, 1, $job->tube);
// 用 list 存储队列内容,返回的队列长度,就是这个 job 在 list 中的下标
if ($id = $this->conn->lPush($job->tube, json_encode($job))) {
$job->id = $id;
} else {
throw new \RedisException('插入失败');
}
return $job;
}
public function delete(Job $job): bool
{
// 在 redis 的 list 中不可使用 lRem 来删除具体项,具体原因,在后面测试一节描述
return true;
}
public function reserve(string $tube): Job
{
// redis 的rPop在接收时就会将 job 从 list 中删除,所以,没有 reserve 状态
if ($data = $this->conn->rPop($tube)) {
$job = json_decode($data, true);
}
return new Job($job ?? []);
}
}
实现生产者和消费者
自动加载
boot.php
<?php
// +----------------------------------------------------------------------
// | boot.php
// +----------------------------------------------------------------------
// | Description: 自动加载
// +----------------------------------------------------------------------
// | Time: 2018/12/19 下午5:51
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------
define('ROOT',realpath('./'));
spl_autoload_register(function ($class) {
$file_path = str_replace('\\', '/', $class);
$file = $file_path . '.php';
if (!file_exists($file)) {
throw new Exception('找不到文件:' . $file);
}
include_once $file;
return true;
});
Mysql 队列生产者
Producer.php
<?php
// +----------------------------------------------------------------------
// | Producer.php
// +----------------------------------------------------------------------
// | Description: 生产者
// +----------------------------------------------------------------------
// | Time: 2018/12/19 下午3:05
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------
include_once 'boot.php';
try {
Queue::init('Mysql', [
'dsn' => 'mysql:host=mysql;dbname=test',
'username' => 'root',
'password' => 'root',
'table' => 'queues',
'ttr' => 60,
]); // 队列初始化
// 生产者放入消息
$job = new Driver\Job([
'job_data' => json_encode(['order_id' => time(), 'user_id' => 0001]),
'tube' => 'test'
]);
$job = Queue::put($job);
} catch (Exception $e) {
var_dump($e->getMessage());
}
Mysql 队列消费者
Consumer.php
<?php
// +----------------------------------------------------------------------
// | Consumer.php
// +----------------------------------------------------------------------
// | Description: 消费者
// +----------------------------------------------------------------------
// | Time: 2018/12/19 下午4:55
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------
include_once 'boot.php';
try {
Queue::init('Mysql', [
'dsn' => 'mysql:host=mysql;dbname=test',
'username' => 'root',
'password' => 'root',
'table' => 'queues',
'ttr' => 60,
]);
while (1) {
// 死循环,使进程一直在cli中运行,不断从消息队列读取数据
$job = Queue::reserve('test');
if (!$job->isEmpty()) {
echo $job->job_data . PHP_EOL;
sleep(2);
if (Queue::delete($job)) {
echo "job was deleted" . PHP_EOL;
} else {
echo "delete failed" . PHP_EOL;
}
}
}
} catch (Exception $e) {
var_dump($e->getMessage());
}
Redis 队列生产者
RedisProducer.php
<?php
// +----------------------------------------------------------------------
// | RedisProducer.php
// +----------------------------------------------------------------------
// | Description: 生产者
// +----------------------------------------------------------------------
// | Time: 2018/12/19 下午3:05
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------
include_once 'boot.php';
try {
Queue::init('Redis', [
'ip' => 'redis',
'port' => 6379,
'tubes' => 'tubes'
]); // 队列初始化
// 生产者放入消息
$job = new Driver\Job([
'job_data' => json_encode(['order_id' => time(), 'user_id' => '0001']),
'tube' => 'default'
]);
$job = Queue::put($job);
echo $job->id . PHP_EOL;
} catch (Exception $e) {
var_dump($e->getMessage());
}
Redis 队列消费者
RedisConsumer.php
<?php
// +----------------------------------------------------------------------
// | RedisConsumer.php
// +----------------------------------------------------------------------
// | Description: 消费者
// +----------------------------------------------------------------------
// | Time: 2018/12/19 下午4:55
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------
include_once 'boot.php';
try {
Queue::init('Redis', [
'ip' => 'redis',
'port' => 6379,
'tubes' => 'tubes'
]); // 队列初始化
while (1) {
// 死循环,使进程一直在cli中运行,不断从消息队列读取数据
$job = Queue::reserve('default');
if (!$job->isEmpty()) {
echo $job->job_data . PHP_EOL;
sleep(2);
if (Queue::delete($job)) {
echo "job was deleted" . PHP_EOL;
} else {
echo "delete failed" . PHP_EOL;
}
}
}
} catch (Exception $e) {
var_dump($e->getMessage());
}
测试
环境
首先,确定好你的环境已经拥有这些内容:
- php
- redis
- mysql
- phpredis 扩展
而接下来,我们通过运行 Mysql 和 Redis 各自的生产者、消费者,来看消息队列是否已经在工作。
实例化 Queue
打开生产者和消费者,我们可以看到有这么一段代码:
Queue::init('Mysql', [
'dsn' => 'mysql:host=mysql;dbname=test',
'username' => 'root',
'password' => 'root',
'table' => 'queues',
'ttr' => 60,
]); // 队列初始化
或:
Queue::init('Redis', [
'ip' => 'redis',
'port' => 6379,
'tubes' => 'tubes'
]); // 队列初始化
如果前面实现环节,我们有达成共识,那么,相信这里你一定已经知道,这段代码是用来传入连接参数的。
你需要根据你运行的环境,去单独设置它。
测试步骤
- 打开 4 个 client ( cmd , terminal )窗口
- 在其中两个命令行中,分别运行如下命令:
php Consumer.php // 运行 Mysql 消费者
php RedisConsumer.php // 运行 Redis 消费者
运行成功后,这两个窗口将处于运行状态,两个消费者都会始终处于 reserve 循环中,直到接收到数据,才会输出。
- 再另外两个窗口,运行如下命令:
php Producer.php
php RedisProducer.php
小结
到此为止,我们的模拟消息队列就成功了。
我们可以看到,当我们运行生产者的时候,各自对应的消费者窗口中,就会弹出相关的信息。
这就是一个简单的消息队列案例。
但是,这里需要注意,这只是一个模拟案例,它帮助我们理解消息队列,但并不能应用于实际项目中。
我们可以推理一下,消息队列,本该可以使用在高并发的场景,但我们现在实现的消息队列,它还有许多漏洞,根本无法应用在实战场景中。
比如:
- mysql 驱动的 reserve 方法,它先接收,接收成功后再修改 job 的状态,这在高并发的情况下,将导致我们的 job 被重复接收,因为在「接收」和「修改状态」两个环节中间,可能会有另一个「请求」进来,此时的 job 还处于 ready 状态
- redis 驱动的 reserve 方法,使用了 redis 的 rPop 操作,这个方法,会在取出数据的同时,将数据从 list 中删除,那么,如果我们消费者处理失败,这个数据就有可能丢失
- redis 驱动的 put 方法,使用了 lPush 操作,此操作执行成功后,将会返回 list 的长度,我们的 redis 驱动会使用这个长度来作为该 job 的下标( id )。然而, list 的长度会不断变化,并发场景下,你 put 成功后,可能还在处理其他流程,此进程并未结束,此时若有消费者接收走一个消息,则 list 的长度就发生了变化,你在 put 时的 id 就无法与 list 中的数据对应起来