分布式IoT数据采集与存储系统(项目复盘)

一、整体架构图

bash 复制代码
设备/客户端
   |
   | TCP JSON\n
   v
ChatServer :8888        <-- 网关/生产者,也负责广播
   |
   | TCP JSON\n
   v
Broker :7777            <-- 消息代理/转发器
   |
   | 轮询 TCP JSON\n
   v
WriteDB-1 / WriteDB-2 / WriteDB-3   <-- 消费者
   |
   | SQL INSERT
   v
MySQL IoT_monitor.IoTtable

同时:

bash 复制代码
ChatServer 收到客户端消息后,还会广播给所有在线客户端。

角色注册流程:

bash 复制代码
WriteDB   -> Broker: {"role":"consumer"}\n
ChatServer -> Broker: {"role":"producer"}\n

Broker根据角色,把连接分别放进_producers 和 _consumers

二、三个服务的职责

1、ChatServer:接入网关 + 生产者

它做两件事:

1、作为TCP Server,监听8888,接收设备/客户端发来的JSON

2、作为TCP Client,连接Broker127.0.0.1:7777,把数据转发给Broker

它相当于:

  • 对设备来说:它是服务器
  • 对Broker来说:它是生产者producer

关键逻辑:

bash 复制代码
_server.setThreadNum(4);

ChatServer 使用 4 个 IO 线程,所以 _connections 这个客户端集合需要加锁:

cpp 复制代码
std::lock_guard<std::mutex> lock(_conn_mutex);

连接 Broker 后,发送角色声明:

cpp 复制代码
conn->send("{\"role\":\"producer\"}\n");

收到客户端消息后:

cpp 复制代码
Json::Value root;
if (!unserialize(msg, root)) { ... }

if (!root.isMember("device_id") || ... ) { ... }

std::string device_id = root["device_id"].asString();
float temperature = root["temperature"].asFloat();
float humidity = root["humidity"].asFloat();

然后重新构造一个只包含必要字段的 JSON:

cpp 复制代码
Json::Value out;
out["device_id"] = device_id;
out["temperature"] = temperature;
out["humidity"] = humidity;

Json::StreamWriterBuilder builder;
builder["indentation"] = "";
std::string out_msg = Json::writeString(builder, out);

再转发给 Broker:

cpp 复制代码
_db_client->connection()->send(out_msg + "\n");

同时广播给所有在线客户端:

cpp 复制代码
for (auto it = _connections.begin(); it != _connections.end(); ++it)
{
    (*it)->send(msg + "\n");
}

为什么这样写?

  • 接入层只负责收数据、校验、转发、广播。

  • 不直接写 MySQL,避免网关和数据库强耦合。

  • 通过 Broker 解耦,后面可以挂多个写库服务。

  • 广播让所有在线客户端能看到最新传感器数据。

2、Broker:消息代理 + 轮询负载均衡

Broker是系统的中心,它监听7777,维护两个列表:

cpp 复制代码
std::vector<TcpConnectionPtr> _producers;   // ChatServer
std::vector<TcpConnectionPtr> _consumers;   // WriteDB
std::mutex _mtx;
size_t _next_index = 0;

它做的事情:

1、接收ChatServer和WriteDB的连接

2、根据角色消息,把连接等级为producer或consumer

3、收到业务消息,轮询转发给某个consumer

4、连接断开时,从对应列表移除

角色注册逻辑:

cpp 复制代码
if (msg.find("\"role\"") != std::string::npos)
{
    if (msg.find("producer") != std::string::npos)
    {
        _producers.push_back(conn);
    }
    else if (msg.find("consumer") != std::string::npos)
    {
        _consumers.push_back(conn);
    }
    return;
}

业务转发逻辑:

cpp 复制代码
if (_consumers.empty())
{
    LOG_WARN << "【Broker】没有消费者,消息丢弃";
    return;
}

_next_index = _next_index % _consumers.size();
_consumers[_next_index]->send(msg + "\n");

_next_index++;

为什么这样写?

  • Broker 是生产者-消费者模式里的中间人。

  • ChatServer 不需要知道 WriteDB 的 IP 和数量。

  • WriteDB 不需要知道 ChatServer 是谁。

  • 新增 WriteDB 实例时,只要连上 Broker 并声明 consumer,就能自动加入负载均衡。

  • 轮询算法简单、均匀,适合写库这种无状态消费者。

为什么加锁?

因为:

cpp 复制代码
_server.setThreadNum(2);

Broker 有多个 IO 线程,onConnection 和 onMessage 可能在不同线程并发执行,_producers、_consumers、_next_index 都是共享状态,必须加锁。

3、writeDB:消费者 + 写入MySQL

它作为 TCP Client 连接 Broker:

cpp 复制代码
_client(loop, InetAddress("127.0.0.1", 7777), "WriteDBCli")

连接成功后声明自己是消费者:

cpp 复制代码
conn->send("{\"role\":\"consumer\"}\n");

收到 Broker 转发的消息后:

cpp 复制代码
Json::Value root;
if (!unserialize(msg, root)) { ... }

if (!root.isMember("device_id") || ... ) { ... }

std::string device_id = root["device_id"].asString();
float temperature = root["temperature"].asFloat();
float humidity = root["humidity"].asFloat();

拼 SQL:

cpp 复制代码
std::string sql =
    "INSERT INTO IoTtable(device_id, temperature, humidity) VALUES ('"
    + _mysql->escape(device_id) + "', "
    + std::to_string(temperature) + ", "
    + std::to_string(humidity) + ")";

加锁执行:

cpp 复制代码
std::lock_guard<std::mutex> lock(_db_mutex);
if (!_mysql->execute(sql)) { ... }

为什么这样写?

  • 消费者只关心"收到 JSON,写进 MySQL"。

  • 多个 WriteDB 实例可以同时消费,提高写库吞吐。

  • 用 escape 防止 device_id 里的引号破坏 SQL,降低注入风险。

  • 加锁是因为 MySQL 连接对象通常不是线程安全的。虽然当前 WriteDB 是单 EventLoop,但加锁是为将来扩展或线程安全兜底。

三、通信协议:TCP + JSON + \n

三个程序都用了同一套拆包逻辑:

cpp 复制代码
while (true)
{
    const char* data = buf->peek();
    size_t len = buf->readableBytes();
    if (len == 0) break;

    const char* newline = static_cast<const char*>(memchr(data, '\n', len));
    if (newline == nullptr) break;

    std::string msg(data, newline - data);
    buf->retrieveUntil(newline + 1);

    if (msg.empty()) continue;

    handleOneMessage(msg);
}

这是处理 TCP 粘包/半包的标准写法:

  • TCP 是字节流,没有消息边界。

  • 用 \n 作为一条消息的结束符。

  • 如果没找到 \n,说明消息不完整,等下一次数据。

  • 如果一次读到多条消息,循环拆开。

  • retrieveUntil 消费掉已经处理的数据。

为什么用 JSON?

  • 可读性好,方便调试。

  • 字段扩展方便。

  • 跨语言。

为什么用 \n 分帧?

  • 简单。

  • 文本协议直观。

  • 但 JSON 内部不能有换行,所以 ChatServer 序列化时设置了:

cpp 复制代码
builder["indentation"] = "";

否则 JSON 美化输出会带换行,破坏协议。

四、数据流完整时序

假设有一个设备发数据:

cpp 复制代码
{"device_id":"sensor01","temperature":25.6,"humidity":60.2}

流程:

  1. 设备连接 ChatServer 8888。

  2. 设备发送 JSON + \n。

  3. ChatServer 收到,拆包,解析 JSON。

  4. ChatServer 校验 device_id、temperature、humidity。

  5. ChatServer 重新构造精简 JSON,发给 Broker 7777。

  6. ChatServer 同时把原始消息广播给所有在线客户端。

  7. Broker 收到业务消息,检查消费者列表。

  8. Broker 用轮询选一个 WriteDB:

cpp 复制代码
_next_index = _next_index % _consumers.size();
_consumers[_next_index]->send(msg + "\n");
_next_index++;
  1. WriteDB 收到消息,拆包,解析 JSON。

  2. WriteDB 拼 SQL,写入 MySQL:

cpp 复制代码
INSERT INTO IoTtable(device_id, temperature, humidity)
VALUES ('sensor01', 25.600000, 60.200000);
  1. 如果某个 WriteDB 挂掉,TCP 断开,Broker 在 onConnection 中把它从 _consumers 移除,其他 WriteDB 继续工作。

五、为什么这样设计?

1、解耦

如果 ChatServer 直接写 MySQL:

  • 网关要处理数据库连接。

  • 写库失败会影响网关。

  • 无法方便地增加多个写库实例。

  • 数据库压力直接打到网关。

引入 Broker 后:

  • ChatServer 只管接入和转发。

  • WriteDB 只管消费和写库。

  • Broker 只管路由和负载均衡。

  • 每个角色职责单一。

2、生产者 - 消费者

  • ChatServer 是生产者。

  • WriteDB 是消费者。

  • Broker 是中间队列/代理。

这是消息队列的基本思想,类似简化版 Kafka、RabbitMQ、Redis Pub/Sub

3、轮询负载均衡

cpp 复制代码
_next_index = _next_index % _consumers.size();
_consumers[_next_index]->send(msg + "\n");
_next_index++;

好处:

  • 简单。

  • 不需要记录每个消费者的负载。

  • 多个 WriteDB 可以均摊写库压力

4、故障隔离

WriteDB 挂掉:

  • TCP 断开。

  • Broker 的 onConnection 检测到。

  • removeFromList(_consumers, conn) 移除。

  • 其他 WriteDB 继续消费。

这体现了一定故障隔离能力

5、多线程与锁

  • ChatServer setThreadNum(4),多 IO 线程,所以 _connections 加锁。

  • Broker setThreadNum(2),多 IO 线程,所以 _producers/_consumers/_next_index 加锁。

  • WriteDB 单 EventLoop,但 MySQL 连接非线程安全,所以 _db_mutex 加锁。

这说明作者意识到 muduo 多线程回调会并发访问共享数据

六、为什么这是小型分布式?

严格说,如果都跑在 127.0.0.1,它是单机多进程。但从架构风格看,它已经是小型分布式系统的雏形。

它具备分布式系统的这些特征:

  1. 多节点/多进程

    • ChatServer、Broker、WriteDB 是独立进程。

    • 如果把 127.0.0.1 改成不同机器 IP,就可以多机部署。

  2. 网络通信

    • 不是函数调用,而是 TCP 长连接。

    • 有明确的网络协议:JSON + \n。

  3. 角色分离

    • 接入、转发、存储分离。

    • 类似微服务。

  4. 服务注册

    • WriteDB 连上 Broker 后发 {"role":"consumer"}。

    • ChatServer 连上 Broker 后发 {"role":"producer"}。

    • Broker 动态维护生产者/消费者列表。

  5. 负载均衡

    • Broker 轮询转发给多个 WriteDB。
  6. 故障隔离

    • 单个 WriteDB 挂掉,其他 WriteDB 继续工作。

    • Broker 会从消费者列表移除断开的连接。

  7. 水平扩展

    • 多启动几个 WriteDB,都连 Broker,就能增加写库能力。

    • 不需要改 ChatServer。

但它还不是生产级分布式,因为缺少:

  • Broker 集群和副本,Broker 是单点。

  • 消息持久化,Broker 内存转发,崩溃丢消息。

  • ACK 确认、重试、幂等。

  • 消费者健康检查、心跳。

  • 服务发现,IP/端口硬编码。

  • 分布式协调,如 etcd/ZooKeeper。

  • 数据库主从、分库分表。

  • 监控、限流、熔断、链路追踪。

  • 安全认证、TLS、鉴权。

  • 消息顺序保证。

  • 背压机制

七、总结

用 ChatServer 做接入网关,用 Broker 做简化消息队列和轮询负载均衡,用多个 WriteDB 做消费者写 MySQL。通过 TCP + JSON + 换行分帧,把接入、转发、存储解耦,形成一个可扩展、可故障隔离的小型分布式数据管道

相关推荐
java_logo1 小时前
Docker 部署 Portainer CE:轻松搭建容器管理平台
运维·docker·容器·portainer·容器管理·轩辕镜像·portainer-ce
雪落漂泊1 小时前
Linux基础开发工具(一)软件包管理器与Vim编辑器篇
linux·运维·服务器
豆豆1 小时前
2026 年证书寿命 398→200 天:一张到期项台账的字段设计与自动化边界
运维·服务器·cms·网站建设·网站制作·域名·ssl证书
悟道书生1 小时前
第三方服务器访问视图
运维·服务器
zxanz11 小时前
SSL 证书申请中,服务器验证跟 DNS 解析验证有什么区别?
服务器·网络协议·ssl
额额额对了2 小时前
Linux 内核定时器与 DHT11
linux·运维·服务器
KIDULT°10 小时前
MySQL系列 (1):数据库原理
运维·数据库·mysql·云计算
wewowilling13 小时前
认识微服务
java·运维·微服务·springcloud
玖石书13 小时前
Ubuntu 24.04 UFW 全命令攻略:从入门到端口开放实战
linux·运维·ubuntu