项目第七弹:消费者管理模块
一、为何要有这个模块?
在我们项目的前几弹当中,我们已经实现了:
交换机数据管理模块,队列数据管理模块,绑定信息管理模块,消息管理模块,虚拟机管理模块和路由匹配模块
二、消费者是否需要持久化?
在第六弹介绍虚拟机设计的时候,我们提到过,我们的项目模块划分:
消费者管理模块是描述模块,是对用户的描述,服务重启时,所有的TCP连接都要断开(我们暂且不考虑TCP的允许断线重连的机制),也就是说一旦服务重启,所有的消费者都会没有了
因此即使把消费者持久化,并成功恢复了,对应的用户也早就不存在了,因此持久化没有意义
三、怎么设计?
怎么设计?跟交换机那里一样,先抽象描述
在放到具体数据结构当中进行组织,当然,都要配上对应的访问操作接口
1.如何抽象描述?
1.回想一下基于生产消费模型的线程池
class threadpool
{
public:
using functor = std::function<void()>;
threadpool(int thread_num = 5)
{
thread_vec.resize(thread_num);
//消费者需要有一个线程函数[routine : 例行程序]
for (int i = 0; i < thread_num; i++)
thread_vec[i] = thread(std::bind(&threadpool::take, this));
}
// 生产者放数据
void put(functor func);
private:
// 消费者拿数据
void take();
queue<functor> func_pool; // 任务队列
vector<thread> thread_vec; // 线程vec
};
在这个线程池当中,我们可以将消费者(工作线程)抽象为:
- 线程标识(在这里是 : 下标)
- 例行程序(在这里是 : take)
但是他并不符合我们的预期,因为:
- 我们的消息队列是基于消息订阅的,消费者需要选择他想订阅的队列
- 我们的消费者是可以用自己的想法来处理消息的
【这才是消息中间件的灵魂】 - 我们的消息是基于发布确认机制的,消费者成功消费消息之后要对消息进行确认【ack】,此时我们才会删除那个消息
2.如何组织
那么该如何组织消费者呢?
首先,消费者是依附于队列而存在的,通过订阅与取消订阅跟对应的队列建立联系,因此势必需要队列名这一字段作为成员
其次,因为不同虚拟机当中可以存在同名队列,而且我们的消费者管理模块并不属于虚拟机管理模块当中,因此势必需要虚拟机名称这一字段作为成员来区分同名队列
因为虚拟机管理模块是资源模块,而消费者管理模块是描述模块,两者不适合整合为统一的一个模块,持久化要求也不同
所以两者通过虚拟机名称这一字段进行关联。从而实现高内聚,低耦合
这也是借助MySQL数据库表的连接相关设计知识来设计出的模块组织方案
3.消息处理与确认问题的解决
我们的消费者是可以用自己的想法来处理消息的
因此我们可以让消费者自己提供消息处理函数,所以消费者类当中要有一个回调函数
message BasicProperities
{
string msg_id = 1;
DeliveryMode mode = 2;
string routing_key = 3;
}
message Message
{
message ValidLoad
{
string body = 1;
BasicProperities properities = 2;
string valid = 3;
}
ValidLoad valid = 1;
uint64 offset = 2;
uint64 len = 3;
}
那这个回调函数的签名是什么样的?
我们就要从消费者需要知道消息的什么信息开始:
- 怎么处理这个消息
需要知道消息的内容是什么,是谁处理的:
const std::string &body
const std::string &consumer_tag
是consumer_tag处理了body这个消息
- 怎么确认这个消息
bool basicAck(const std::string &qname, const std::string &msg_id);
msg_id在BasicProperities当中,因此我们需要:
const BasicProperites* bp
那么这个队列名呢?
在回调函数当中其实是不需要的,至于为何,我们以后会介绍的
因此:
回调函数的签名:
using ConsumerCallback = std::function<void(const std::string &tag, const ns_proto::BasicProperites *bp, const std::string &body)>;
4.自动确认标志
有些消息不是非常重要,且直到我们消费者能够进行确认时,中间可能相隔时间较长【毕竟肯定需要走一个网络IO】
所以RabbitMQ提出一个自动确认标志,来让发布确认机制更加灵活
bool auto_ack;
5.消费者代码
/*
因为我们的消费者是一种描述模块,而不是资源模块,其存在是为了更好的描述和管理消费者
*/
using ConsumerCallback = std::function<void(const std::string &, const ns_proto::BasicProperities *, const std::string &)>;
struct Consumer
{
using ptr = std::shared_ptr<Consumer>;
Consumer() = default;
Consumer(const std::string &tag, const ConsumerCallback &callback, const std::string &vhost_name, const std::string &qname, bool auto_ack)
: _consumer_tag(tag), _callback(callback), _vhost_name(vhost_name), _qname(qname), _auto_ack(auto_ack) {
}
std::string _consumer_tag; // 消费者tag(唯一标识)
ConsumerCallback _callback; // 消费者回调函数
std::string _vhost_name; // 消费者队列所在虚拟机名称
std::string _qname; // 消费者订阅的队列
bool _auto_ack; // 自动确认标志
};
2.队列消费者管理模块
1.为何要有队列消费者管理模块?
因为我们的消费者描述是依附于队列而存在的,只有订阅了某个队列的客户端才是消费者
类似于我们之前说的负载均衡,只不过消费者这里的负载均衡是这样设计并实现的:
2.队列消费者管理模块的设计
我们队列消费者管理模块要实现负载均衡:
- RR轮转:将请求按顺序分配给各个消费者【实现简单,适用于消费者消费能力大致相同的场景】
- 最小连接数:将请求分配给负载最低的消费者【完全式的负载均衡】
还有很多其他算法,大家感兴趣的话可以自行去了解一下,这里就不赘述了
为了降低消费者管理模块和消费者之间的耦合度,我们采用RR轮转,而不是最少连接数
这样的话,我们的消费者管理模块只负责组织维护对应队列的消费者,负载均衡式选取适合推送消息的消费者
之后消费者如何处理,处理多长时间,当前的状态是什么,我们无需考虑
【因为要支持轮询序号的随机访问,所以用vector
尽管vector在中间位置/起始位置删除的代价较大,但是消费者的获取需求远高于增删
所以在权衡下,选择了vector以牺牲新增和删除效率来提升查询效率】
成员:
- 虚拟机名称
- 队列名
- vector<消费者句柄> consumer_vec
- size_t seq;//轮询序号
- 互斥锁【因为会存在多线程同时访问consumer_vec和int seq】
其实int seq可以通过搞成atomic<int>
来确保线程安全,因为只需要++即可
size_t index=seq.fetch_add(1);
index%=consumer_vec.size();
反正index溢出不报错,直接回归到0也没什么大问题
但是因为都已经需要加锁保护consumer_vec了,所以不用原子类,保护这个临界资源也只是顺手的事
用了原子类,反而多此一举,纯属浪费资源
接口:
- 新增消费者
- 删除消费者
- 获取消费者【RR轮转,负载均衡式获取】
- 判断指定消费者是否存在
- 销毁该队列所有消费者
- 判断是否有消费者
3.总体消费者管理模块
其实就是增,删,查
成员:
- unordered_map<pair<虚拟机名称,队列名>,队列消费者管理模块句柄>
- 互斥锁
接口:
3. 初始化队列消费者管理模块
4. 销毁队列消费者管理模块
5. 新增消费者
6. 删除消费者
7. 获取消费者(RR轮转式负载均衡)
8. 判断指定消费者是否存在
9. 销毁所有消费者
10. 判断某一队列是否有消费者
四、代码
其实消费者管理模块就是设计起来略显复杂,但是代码简洁
1.队列消费者管理模块
总体上没啥难的,就是注意一下:
只有想要订阅当前队列消息的消费者才会调用该队列消费者管理模块的createConsumer函数
因此该函数无需传入vhost_name、qname作为参数
class QueueConsumerManager
{
public