
🔥草莓熊Lotso: 个人主页
❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!
🎬 博主简介:

文章目录
- 前言:
- [一. etcd 的安装与使用](#一. etcd 的安装与使用)
-
- [1.1 etcd 基础介绍](#1.1 etcd 基础介绍)
- [1.2 etcd 安装与配置](#1.2 etcd 安装与配置)
- [1.3 etcd-cpp-apiv3 客户端与接口介绍](#1.3 etcd-cpp-apiv3 客户端与接口介绍)
- [1.4 基础使用样例](#1.4 基础使用样例)
- [二、etcd 的二次封装:服务注册与发现组件](#二、etcd 的二次封装:服务注册与发现组件)
-
- [2.1 封装思路](#2.1 封装思路)
- [2.2 服务注册客户端 Registry](#2.2 服务注册客户端 Registry)
- [2.3 服务发现客户端 Discovery](#2.3 服务发现客户端 Discovery)
- [三、etcd 封装组件测试](#三、etcd 封装组件测试)
-
- [3.1 服务注册端测试](#3.1 服务注册端测试)
- [3.2 服务发现端测试](#3.2 服务发现端测试)
- [3.3 测试流程](#3.3 测试流程)
- [四、brpc 的安装与核心介绍](#四、brpc 的安装与核心介绍)
- [五、brpc 基础使用样例与测试](#五、brpc 基础使用样例与测试)
- [六、brpc 信道管理封装](#六、brpc 信道管理封装)
-
- [6.1 封装思路](#6.1 封装思路)
- [6.2 单服务信道管理:ServiceChannel](#6.2 单服务信道管理:ServiceChannel)
- [6.3 全局服务信道管理:ServiceManager](#6.3 全局服务信道管理:ServiceManager)
- [6.4 数据结构选型:为什么用 vector 而不是哈希表](#6.4 数据结构选型:为什么用 vector 而不是哈希表)
- [七、etcd + brpc 全链路联调](#七、etcd + brpc 全链路联调)
- 八、核心要点总结
- 结尾:
前言:
做 C++ 微服务架构,绕不开两个核心问题:服务之间怎么找到彼此?服务之间怎么高效通信?在我们这套即时通讯系统里,我们选择了 etcd 做服务注册发现中心 ,brpc 做 RPC 通信框架。这俩是国内 C++ 微服务项目的经典搭配,性能够强、生态成熟,踩坑也有大量前人经验可以参考。
本文就顺着项目落地的完整路径,从 etcd 安装、客户端使用、业务封装,到 brpc 编译、基础调用、信道管理,最后把两个组件完全打通,实现完整的「服务注册-服务发现-RPC 调用」全链路。全程附带源码和实战踩坑记录,跟着走一遍就能直接落地到自己的项目里。
一. etcd 的安装与使用
1.1 etcd 基础介绍
etcd 是 Go 语言编写的分布式、高可用一致性键值存储系统,底层基于 Raft 算法保证集群数据一致,常用来做配置共享和服务发现,相比 Zookeeper 更加轻量化。
它的核心能力很简单:存键值对 + 监控变化。
- 服务启动时,把自己的地址作为键值对写入 etcd,完成「服务注册」
- 调用方从 etcd 读取键值对,拿到服务地址,完成「服务发现」
- 配合租约 + 保活机制:服务绑定一个有有效期的租约,定期续租;如果服务挂了,续租停止,租约到期后 etcd 自动删除对应数据
- 配合 watch 机制:调用方可以监控指定目录,数据有新增、删除时,etcd 会实时推送事件通知客户端
这一套组合下来,就实现了微服务里最核心的「自动注册、自动发现、故障自动摘除」。
1.2 etcd 安装与配置
安装与启动
Ubuntu 下可以直接用 apt 安装,简单快捷:
# 安装etcd
sudo apt-get install etcd
# 启动服务
sudo systemctl start etcd
# 设置开机自启
sudo systemctl enable etcd
核心配置说明
配置文件路径为 /etc/default/etcd,几个核心参数解释:
# 节点名称,默认default
ETCD_NAME="etcd1"
# 数据存储目录
ETCD_DATA_DIR="/var/lib/etcd/default.etcd"
# 客户端监听地址,2379是客户端默认端口
ETCD_LISTEN_CLIENT_URLS="[http://0.0.0.0:2379](http://0.0.0.0:2379),[http://127.0.0.1:2379](http://127.0.0.1:2379)"
# 对外宣告的客户端地址,外网访问要填公网IP
ETCD_ADVERTISE_CLIENT_URLS="http://你的公网IP:2379"
端口说明:2379 是客户端访问端口 ,用于业务读写数据;2380 是集群节点间通信端口,单节点部署用不到。
验证与版本坑
安装完成后用 etcdctl 验证,这里有个新手必踩的坑: 默认 etcdctl 使用 v2 API,执行 put 命令会报错 No help topic for 'put'。
解决方法是配置环境变量,指定使用 v3 API:
sudo vi /etc/profile
# 末尾添加
export ETCDCTL_API=3
# 加载配置
source /etc/profile
测试读写:
etcdctl put mykey "this is awesome"
etcdctl get mykey
能正常写入和读取,说明 etcd 就安装好了。
1.3 etcd-cpp-apiv3 客户端与接口介绍
我们是 C++ 项目,需要对应的 C++ 客户端,这里用的是 etcd-cpp-apiv3。
依赖安装
先把依赖装全,避免编译报错:
sudo apt-get install libboost-all-dev libssl-dev
sudo apt-get install libprotobuf-dev protobuf-compiler-grpc
sudo apt-get install libgrpc-dev libgrpc++-dev libcpprest-dev
编译安装客户端
git clone [https://github.com/etcd-cpp-apiv3/etcd-cpp-apiv3.git](https://github.com/etcd-cpp-apiv3/etcd-cpp-apiv3.git)
cd etcd-cpp-apiv3
mkdir build && cd build
cmake ..
make -j$(nproc)
sudo make install
安装完成后,头文件在 /usr/include/etcd/,动态库为 libetcd-cpp-api.so。
核心类与接口
给大家梳理下最常用的几个核心类,不用全记,知道各自分工就行:
Client:客户端主类,提供键值增删改查、租约、保活、锁等所有接口Response:请求响应对象,is_ok()判断是否成功,error_message()获取错误信息,events()获取 watch 事件列表Value:键值对对象,key()获取键,as_string()获取值,is_dir()判断是否为目录KeepAlive:租约保活对象,Lease()获取租约 ID;对象析构则停止保活,租约会自动到期Watcher:监控对象,监听指定 key/目录的变化,触发回调函数,支持递归监控目录
核心操作接口:
// 写入键值对,可绑定租约
pplx::task<Response> put(const std::string& key, const std::string& value, int64_t leaseId = 0);
// 列出目录下所有键值
pplx::task<Response> ls(const std::string& key);
// 创建租约,ttl为有效期(秒)
pplx::task<Response> leasegrant(int ttl);
// 获取保活对象,自动持续续租
pplx::task<std::shared_ptr<KeepAlive>> leasekeepalive(int ttl);
1.4 基础使用样例
我们写两个最简单的例子:一个写数据注册服务,一个读数据并监控变化。
服务注册端:put.cc
#include <etcd/Client.hpp>
#include <etcd/KeepAlive.hpp>
#include <etcd/Response.hpp>
#include <iostream>
#include <thread>
int main(int argc, char* argv[]) {
std::string etcd_host = "[http://127.0.0.1:2379](http://127.0.0.1:2379)";
etcd::Client client(etcd_host);
// 获取3秒租约的保活对象,内部会自动持续续租
auto keep_alive = client.leasekeepalive(3).get();
auto lease_id = keep_alive->Lease();
// 注册用户服务,绑定租约,服务挂了自动删除
auto resp1 = client.put("/service/user", "127.0.0.1:8080", lease_id).get();
if (!resp1.is_ok()) {
std::cout << "注册失败:" << resp1.error_message() << std::endl;
return -1;
}
// 注册好友服务,不绑定租约,永久存在
auto resp2 = client.put("/service/friend", "127.0.0.1:9090").get();
if (!resp2.is_ok()) {
std::cout << "注册失败:" << resp2.error_message() << std::endl;
return -1;
}
std::cout << "服务注册完成,保持运行10秒" << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(10));
return 0;
}
服务发现端:get.cc
#include <etcd/Client.hpp>
#include <etcd/Watcher.hpp>
#include <etcd/Response.hpp>
#include <iostream>
void callback(const etcd::Response& resp) {
if (!resp.is_ok()) {
std::cout << "监控错误:" << resp.error_message() << std::endl;
return;
}
for (auto const& ev : resp.events()) {
if (ev.event_type() == etcd::Event::EventType::PUT) {
std::cout << "服务上线/更新: "
<< ev.kv().key() << " -> " << ev.kv().as_string() << std::endl;
} else if (ev.event_type() == etcd::Event::EventType::DELETE_) {
std::cout << "服务下线: "
<< ev.prev_kv().key() << " -> " << ev.prev_kv().as_string() << std::endl;
}
}
}
int main() {
std::string etcd_host = "[http://127.0.0.1:2379](http://127.0.0.1:2379)";
etcd::Client client(etcd_host);
// 先全量获取一次当前所有服务
auto resp = client.ls("/service").get();
if (resp.is_ok()) {
std::cout << "当前已有服务:" << std::endl;
for (size_t i = 0; i < resp.keys().size(); ++i) {
std::cout << resp.value(i).key() << " -> "
<< resp.value(i).as_string() << std::endl;
}
}
// 递归监控/service目录下所有变化
etcd::Watcher watcher(client, "/service", callback, true);
watcher.Wait(); // 阻塞等待事件
return 0;
}
编译与运行
# 编译
g++ -std=c++17 put.cc -o put -letcd-cpp-api -lcpprest
g++ -std=c++17 get.cc -o get -letcd-cpp-api -lcpprest
# 先运行发现端
./get
# 再运行注册端
./put
运行现象:
- 启动 get 程序,先打印当前已有的服务
- 启动 put 程序,get 端立刻收到两个服务的 PUT 事件
- 10 秒后 put 程序退出,大约 3 秒后,get 端收到 user 服务的 DELETE 事件(带租约的自动删除),friend 服务则保留
二、etcd 的二次封装:服务注册与发现组件
原生 etcd 客户端是通用的键值存储接口,直接写在业务里既零散又容易出错。我们把它封装成两个业务专用的类,完全贴合微服务场景。
2.1 封装思路
封装的核心原则:屏蔽底层细节,只暴露业务需要的能力。
- Registry 服务注册客户端:服务启动时调用,传入服务名和地址,自动完成租约、保活、注册;服务退出自动注销
- Discovery 服务发现客户端:调用方使用,指定关注的服务根目录,设置上线/下线回调,自动完成全量拉取和实时监控
这样业务层完全不用知道 etcd、租约、watch 这些概念,只管注册和接收通知就行。
2.2 服务注册客户端 Registry
#pragma once
#include <etcd/Client.hpp>
#include <etcd/KeepAlive.hpp>
#include <etcd/Response.hpp>
#include <memory>
#include <string>
#include "logger.hpp"
class Registry {
public:
using ptr = std::shared_ptr<Registry>;
Registry(const std::string& host)
: _client(std::make_shared<etcd::Client>(host))
, _keep_alive(_client->leasekeepalive(3).get())
, _lease_id(_keep_alive->Lease()) {}
~Registry() {
_keep_alive->Cancel(); // 析构取消保活,租约到期自动下线
}
// 注册服务,key为服务路径,val为访问地址
bool registry(const std::string& key, const std::string& val) {
auto resp = _client->put(key, val, _lease_id).get();
if (!resp.is_ok()) {
LOG_ERROR("注册服务失败: {}", resp.error_message());
return false;
}
return true;
}
private:
std::shared_ptr<etcd::Client> _client;
std::shared_ptr<etcd::KeepAlive> _keep_alive;
uint64_t _lease_id;
};
代码解读:
- 构造时直接创建 3 秒租约并开启保活,不用业务层关心租约细节
- 析构函数自动取消保活,服务正常退出时,最多 3 秒就会从注册中心摘除
- 所有注册的 key 都绑定同一个租约,一个服务多个接口也能同时下线
2.3 服务发现客户端 Discovery
#pragma once
#include <etcd/Client.hpp>
#include <etcd/Watcher.hpp>
#include <etcd/Response.hpp>
#include <functional>
#include <memory>
#include <string>
#include "logger.hpp"
class Discovery {
public:
using NotifyCallback = std::function<void(std::string, std::string)>;
Discovery(const std::string& host, const std::string& basedir,
const NotifyCallback& put_cb, const NotifyCallback& del_cb)
: _client(std::make_shared<etcd::Client>(host))
, _put_cb(put_cb)
, _del_cb(del_cb) {
// 1. 先全量拉取一次当前已有服务
auto resp = _client->ls(basedir).get();
if (!resp.is_ok()) {
LOG_ERROR("获取服务列表失败: {}", resp.error_message());
return;
}
for (size_t i = 0; i < resp.keys().size(); ++i) {
if (_put_cb) _put_cb(resp.key(i), resp.value(i).as_string());
}
// 2. 开启监控,后续变化实时回调
_watcher = std::make_shared<etcd::Watcher>(
*_client.get(), basedir,
std::bind(&Discovery::callback, this, std::placeholders::_1),
true);
}
private:
void callback(const etcd::Response& resp) {
if (!resp.is_ok()) {
LOG_ERROR("服务监控错误: {}", resp.error_message());
return;
}
for (auto const& ev : resp.events()) {
if (ev.event_type() == etcd::Event::EventType::PUT) {
LOG_DEBUG("新增服务: {} - {}", ev.kv().key(), ev.kv().as_string());
if (_put_cb) _put_cb(ev.kv().key(), ev.kv().as_string());
} else if (ev.event_type() == etcd::Event::EventType::DELETE_) {
LOG_DEBUG("下线服务: {} - {}", ev.prev_kv().key(), ev.prev_kv().as_string());
if (_del_cb) _del_cb(ev.prev_kv().key(), ev.prev_kv().as_string());
}
}
}
NotifyCallback _put_cb;
NotifyCallback _del_cb;
std::shared_ptr<etcd::Client> _client;
std::shared_ptr<etcd::Watcher> _watcher;
};
代码解读:
- 构造函数里做了两件事:先全量拉取已有服务,保证启动就有数据;再开启 watch,保证后续变化实时感知
- 回调类型用
std::function,灵活适配各种业务场景,和上层解耦 - 递归监控目录,子节点的增删都能收到通知
三、etcd 封装组件测试
封装完之后,我们写两个测试程序,验证注册和发现是否正常工作。
3.1 服务注册端测试
#include "etcd.hpp"
#include <gflags/gflags.h>
DEFINE_string(etcd_host, "[http://127.0.0.1:2379](http://127.0.0.1:2379)", "注册中心地址");
DEFINE_string(base_service, "/service", "服务根目录");
DEFINE_string(instance_name, "/echo/instance1", "当前实例名称");
DEFINE_string(access_host, "127.0.0.1:8080", "服务访问地址");
int main(int argc, char* argv[]) {
google::ParseCommandLineFlags(&argc, &argv, true);
Registry::ptr rclient = std::make_shared<Registry>(FLAGS_etcd_host);
rclient->registry(FLAGS_base_service + FLAGS_instance_name, FLAGS_access_host);
std::cout << "服务注册成功,运行中..." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(60));
return 0;
}
注意:实例名必须唯一 ,同一个服务的不同节点要用不同的实例名,比如
/service/echo/instance1、/service/echo/instance2,否则后注册的会覆盖先注册的。
3.2 服务发现端测试
#include "etcd.hpp"
#include <gflags/gflags.h>
DEFINE_string(etcd_host, "[http://127.0.0.1:2379](http://127.0.0.1:2379)", "注册中心地址");
DEFINE_string(base_service, "/service", "服务根目录");
void online(const std::string& service_name, const std::string& service_host) {
std::cout << "服务上线: " << service_name << " -> " << service_host << std::endl;
}
void offline(const std::string& service_name, const std::string& service_host) {
std::cout << "服务下线: " << service_name << " -> " << service_host << std::endl;
}
int main(int argc, char* argv[]) {
google::ParseCommandLineFlags(&argc, &argv, true);
Discovery::ptr dclient = std::make_shared<Discovery>(
FLAGS_etcd_host, FLAGS_base_service, online, offline);
std::cout << "服务发现启动,等待事件..." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(600));
return 0;
}
3.3 测试流程
- 先启动发现端,此时没有服务注册,控制台没有上线日志
- 再启动注册端,发现端立刻打印服务上线日志
- 关闭注册端,大约 3 秒后,发现端打印服务下线日志
能完整复现这个现象,说明封装的组件就没问题了。
四、brpc 的安装与核心介绍
4.1 brpc 框架简介
brpc 是百度开源的工业级 C++ RPC 框架,也是国内 C++ 后端项目的主流选择。它支持多协议(HTTP、gRPC、Redis、Thrift 等),性能优异,同步异步调用都支持,还有完善的服务治理能力。
和大家常听的 gRPC 简单对比:
| 维度 | brpc | gRPC |
|---|---|---|
| 出品方 | 百度 | |
| 语言 | C++ 为主 | 多语言 |
| 中文文档 | 完善 | 较少 |
| 编译难度 | 低,依赖少 | 高,子模块多 |
| 协议支持 | 多协议兼容 | 仅 gRPC |
我们项目选 brpc,核心原因就是 C++ 生态友好,中文资料多,编译坑少,和 etcd 搭配做微服务非常成熟。
4.2 brpc 编译安装
安装依赖
sudo apt-get install -y git g++ make libssl-dev libprotobuf-dev protobuf-compiler libleveldb-dev
源码编译安装
git clone [https://github.com/apache/brpc.git](https://github.com/apache/brpc.git)
cd brpc
mkdir build && cd build
cmake -DCMAKE_INSTALL_PREFIX=/usr ..
make -j$(nproc)
sudo make install
安装完成后,头文件和库文件都会安装到系统目录,直接链接即可使用。
4.3 核心类与接口梳理
brpc 类很多,核心的就几组,给大家梳理清楚:
1. Protobuf 相关
RPC 的接口和数据结构用 protobuf 定义,protoc 会自动生成代码:
- Service 基类:服务端继承它,重写业务方法
- Stub 类:客户端用它发起 RPC 调用
RpcController:RPC 上下文,Failed()判断请求是否失败,ErrorText()获取错误信息Closure:回调对象,异步调用时使用;服务端处理完调用Run()才算真正返回响应ClosureGuard:RAII 管理 Closure,析构自动调用Run(),防止忘记写返回语句
2. 服务端相关
Server:服务器核心类,监听端口,分发请求ServerOptions:服务器配置,比如工作线程数、空闲连接超时AddService:注册服务到服务器,第二个参数指定对象所有权,SERVER_DOESNT_OWN_SERVICE表示我们自己管理对象生命周期RunUntilAskedToQuit():阻塞运行,直到收到 Ctrl+C 退出信号
3. 客户端相关
Channel:通信信道,一个 Channel 对应一个服务节点,底层复用 TCP 连接ChannelOptions:信道配置,连接超时、请求超时、重试次数、协议类型Init():初始化信道,指定服务地址和配置
五、brpc 基础使用样例与测试
我们用一个最简单的 Echo 服务,走一遍完整的服务端、客户端开发流程。
5.1 proto 接口定义
先写 main.proto,定义请求、响应和服务接口:
syntax = "proto3";
package example;
option cc_generic_services = true;
message EchoRequest {
string message = 1;
}
message EchoResponse {
string message = 1;
}
service EchoService {
rpc Echo(EchoRequest) returns (EchoResponse);
}
生成 C++ 代码:
protoc --cpp_out=./ main.proto
会生成 main.pb.h 和 main.pb.cc 两个文件。
5.2 服务端实现
#include <brpc/server.h>
#include <butil/logging.h>
#include "main.pb.h"
// 1. 实现服务接口
class EchoServiceImpl : public example::EchoService {
public:
void Echo(google::protobuf::RpcController* controller,
const ::example::EchoRequest* request,
::example::EchoResponse* response,
::google::protobuf::Closure* done) override {
// RAII管理done,函数退出自动调用Run()
brpc::ClosureGuard rpc_guard(done);
std::cout << "收到消息: " << request->message() << std::endl;
std::string str = request->message() + " --这是响应!!";
response->set_message(str);
}
};
int main() {
// 关闭brpc默认日志,避免和我们自己的日志冲突
logging::LoggingSettings settings;
settings.logging_dest = logging::LOG_TO_NONE;
logging::InitLogging(settings);
// 2. 创建服务器并注册服务
brpc::Server server;
EchoServiceImpl echo_service;
int ret = server.AddService(&echo_service, brpc::ServiceOwnership::SERVER_DOESNT_OWN_SERVICE);
if (ret == -1) {
std::cout << "添加服务失败!" << std::endl;
return -1;
}
// 3. 配置并启动服务器
brpc::ServerOptions options;
options.idle_timeout_sec = -1; // 连接不超时
options.num_threads = 1; // 工作线程数
ret = server.Start(8080, &options);
if (ret == -1) {
std::cout << "启动服务器失败!" << std::endl;
return -1;
}
std::cout << "服务器启动成功,端口8080" << std::endl;
server.RunUntilAskedToQuit();
return 0;
}
5.3 客户端实现
同步调用
#include <brpc/channel.h>
#include "main.pb.h"
int main() {
// 1. 初始化信道
brpc::ChannelOptions options;
options.connect_timeout_ms = -1;
options.timeout_ms = -1;
options.max_retry = 3;
options.protocol = "baidu_std";
brpc::Channel channel;
int ret = channel.Init("127.0.0.1:8080", &options);
if (ret == -1) {
std::cout << "初始化信道失败!" << std::endl;
return -1;
}
// 2. 创建stub对象
example::EchoService_Stub stub(&channel);
// 3. 发起RPC调用
example::EchoRequest req;
req.set_message("你好~比特");
brpc::Controller cntl;
example::EchoResponse rsp;
stub.Echo(&cntl, &req, &rsp, nullptr); // nullptr表示同步调用
if (cntl.Failed()) {
std::cout << "RPC调用失败: " << cntl.ErrorText() << std::endl;
return -1;
}
std::cout << "收到响应: " << rsp.message() << std::endl;
return 0;
}
异步调用
异步调用不会阻塞,响应通过回调返回,适合高并发场景:
void echo_callback(brpc::Controller* cntl, example::EchoResponse* rsp) {
std::unique_ptr<brpc::Controller> cntl_guard(cntl);
std::unique_ptr<example::EchoResponse> rsp_guard(rsp);
if (cntl->Failed()) {
std::cout << "RPC调用失败: " << cntl->ErrorText() << std::endl;
return;
}
std::cout << "收到响应: " << rsp->message() << std::endl;
}
int main() {
// ... 信道初始化和同步一样 ...
example::EchoRequest req;
req.set_message("你好~比特");
// 异步调用:controller和response必须堆上分配
brpc::Controller* cntl = new brpc::Controller();
example::EchoResponse* rsp = new example::EchoResponse();
auto closure = google::protobuf::NewCallback(echo_callback, cntl, rsp);
stub.Echo(cntl, &req, rsp, closure);
std::cout << "异步调用已发出" << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(3)); // 等待回调
return 0;
}
重点提醒:异步调用的
Controller和Response必须用 new 创建,不能用栈变量!因为函数返回后栈对象就销毁了,回调里访问会直接崩溃。
5.4 编译与测试
all: server client
server: server.cc main.pb.cc
g++ -std=c++17 $^ -o $@ -lbrpc -lgflags -lssl -lcrypto -lprotobuf -lleveldb
client: client.cc main.pb.cc
g++ -std=c++17 $^ -o $@ -lbrpc -lgflags -lssl -lcrypto -lprotobuf -lleveldb
测试流程:
- 运行
./server启动服务 - 运行
./client发起调用,控制台打印响应说明调用成功 - 异步版本会先打印「异步调用已发出」,再打印响应,验证非阻塞
六、brpc 信道管理封装
基础用法里 Channel 是写死地址的,但微服务里节点是动态的,还需要负载均衡,所以我们要封装一层信道管理。
6.1 封装思路
封装目标:按服务名管理所有节点信道,支持动态增删,自带 RR 轮询负载均衡。
分两层设计:
ServiceChannel:管理单个服务的所有节点信道,负责节点增删、轮询选节点ServiceManager:管理所有服务的 ServiceChannel,对外只需要「按服务名取一个信道」
上层业务完全不用关心节点地址、有多少个节点,只需要传服务名就能拿到可用信道。
6.2 单服务信道管理:ServiceChannel
#pragma once
#include <brpc/channel.h>
#include <string>
#include <vector>
#include <unordered_map>
#include <mutex>
#include <memory>
#include "logger.hpp"
class ServiceChannel {
public:
using ChannelPtr = std::shared_ptr<brpc::Channel>;
using ptr = std::shared_ptr<ServiceChannel>;
ServiceChannel(const std::string& name)
: _service_name(name), _index(0) {}
// 节点上线:新增信道
void append(const std::string& host) {
auto channel = std::make_shared<brpc::Channel>();
brpc::ChannelOptions options;
options.connect_timeout_ms = -1;
options.timeout_ms = -1;
options.max_retry = 3;
options.protocol = "baidu_std";
int ret = channel->Init(host.c_str(), &options);
if (ret == -1) {
LOG_ERROR("初始化信道失败: {} - {}", _service_name, host);
return;
}
std::unique_lock<std::mutex> lock(_mutex);
_hosts.insert({host, channel});
_channels.push_back(channel);
}
// 节点下线:删除信道
void remove(const std::string& host) {
std::unique_lock<std::mutex> lock(_mutex);
auto it = _hosts.find(host);
if (it == _hosts.end()) {
LOG_WARN("删除节点未找到: {} - {}", _service_name, host);
return;
}
for (auto vit = _channels.begin(); vit != _channels.end(); ++vit) {
if (*vit == it->second) {
_channels.erase(vit);
break;
}
}
_hosts.erase(it);
}
// RR轮询获取一个信道
ChannelPtr choose() {
std::unique_lock<std::mutex> lock(_mutex);
if (_channels.empty()) {
return nullptr;
}
int32_t idx = _index++ % _channels.size();
return _channels[idx];
}
private:
std::mutex _mutex;
int32_t _index;
std::string _service_name;
std::vector<ChannelPtr> _channels; // 用于轮询,顺序访问快
std::unordered_map<std::string, ChannelPtr> _hosts; // 用于按主机名查找删除
};
6.3 全局服务信道管理:ServiceManager
class ServiceManager {
public:
using ptr = std::shared_ptr<ServiceManager>;
// 声明关注的服务,只有关注的才会管理
void declared(const std::string& service_name) {
std::unique_lock<std::mutex> lock(_mutex);
_follow_services.insert(service_name);
}
// 按服务名获取一个可用信道
ServiceChannel::ChannelPtr choose(const std::string& service_name) {
std::unique_lock<std::mutex> lock(_mutex);
auto sit = _services.find(service_name);
if (sit == _services.end()) {
LOG_ERROR("没有可用的服务节点: {}", service_name);
return nullptr;
}
return sit->second->choose();
}
// 服务上线回调
void onServiceOnline(const std::string& service_instance, const std::string& host) {
std::string service_name = getServiceName(service_instance);
std::unique_lock<std::mutex> lock(_mutex);
auto fit = _follow_services.find(service_name);
if (fit == _follow_services.end()) {
LOG_DEBUG("未关注的服务上线,忽略: {}", service_name);
return;
}
ServiceChannel::ptr service;
auto sit = _services.find(service_name);
if (sit == _services.end()) {
service = std::make_shared<ServiceChannel>(service_name);
_services.insert({service_name, service});
} else {
service = sit->second;
}
service->append(host);
LOG_DEBUG("服务新增节点: {} - {}", service_name, host);
}
// 服务下线回调
void onServiceOffline(const std::string& service_instance, const std::string& host) {
std::string service_name = getServiceName(service_instance);
std::unique_lock<std::mutex> lock(_mutex);
auto fit = _follow_services.find(service_name);
if (fit == _follow_services.end()) {
LOG_DEBUG("未关注的服务下线,忽略: {}", service_name);
return;
}
auto sit = _services.find(service_name);
if (sit == _services.end()) {
LOG_WARN("删除节点时未找到服务: {}", service_name);
return;
}
sit->second->remove(host);
LOG_DEBUG("服务下线节点: {} - {}", service_name, host);
}
private:
// 从实例路径中提取服务名:/service/echo/instance1 -> /service/echo
std::string getServiceName(const std::string& service_instance) {
auto pos = service_instance.find_last_of('/');
if (pos == std::string::npos) return service_instance;
return service_instance.substr(0, pos);
}
std::mutex _mutex;
std::unordered_set<std::string> _follow_services;
std::unordered_map<std::string, ServiceChannel::ptr> _services;
};
6.4 数据结构选型:为什么用 vector 而不是哈希表
很多人第一反应会用 unordered_map 存节点,但在这个场景下,vector 其实更合适。核心原因就是访问模式是读远多于写:
- 高频读:每次 RPC 调用都要选一次节点,是 QPS 级别的操作
- 低频写:服务上下线是秒/分钟级别的操作
具体对比:
| 维度 | vector | unordered_map |
|---|---|---|
| 轮询访问 | O(1),下标取模直接定位,缓存友好 | 无序,无法直接下标访问,需额外维护迭代器 |
| 轮询公平性 | 顺序稳定,节点调度均匀 | 顺序不确定,无法保证公平 |
| 全量更新 | 直接 swap 新 vector,效率极高 | 需逐个增删,开销大 |
| 单个删除 | O(n),但节点数少,可忽略 | O(1),但优势发挥不出来 |
一句话总结:ServiceChannel 内部用 vector 做轮询,上层服务名映射用 unordered_map,各司其职,性能最优。
七、etcd + brpc 全链路联调
现在把两个组件打通,实现完整的微服务调用链路。
7.1 联调整体思路
完整的调用流程:
- 服务端:启动 RPC 服务 -> 向 etcd 注册自己的地址
- 客户端:通过 Discovery 监听 etcd -> 服务上下线时同步更新 ServiceManager -> 业务调用时按服务名选信道发起 RPC
这样客户端完全不用硬编码服务地址,节点动态增减完全自动感知。
7.2 服务端改造
在 brpc 服务端的基础上,加上 etcd 注册逻辑:
// ... EchoServiceImpl 实现和之前一样 ...
DEFINE_int32(listen_port, 7070, "RPC服务监听端口");
DEFINE_string(etcd_host, "[http://127.0.0.1:2379](http://127.0.0.1:2379)", "注册中心地址");
DEFINE_string(base_service, "/service", "服务根目录");
DEFINE_string(instance_name, "/echo/instance1", "实例名称");
DEFINE_string(access_host, "127.0.0.1:7070", "服务访问地址");
int main(int argc, char* argv[]) {
google::ParseCommandLineFlags(&argc, &argv, true);
// 关闭brpc日志
logging::LoggingSettings settings;
settings.logging_dest = logging::LOG_TO_NONE;
logging::InitLogging(settings);
// 启动RPC服务
brpc::Server server;
EchoServiceImpl echo_service;
server.AddService(&echo_service, brpc::ServiceOwnership::SERVER_DOESNT_OWN_SERVICE);
brpc::ServerOptions options;
options.idle_timeout_sec = -1;
options.num_threads = 1;
server.Start(FLAGS_listen_port, &options);
// 注册服务到etcd
Registry::ptr rclient = std::make_shared<Registry>(FLAGS_etcd_host);
rclient->registry(FLAGS_base_service + FLAGS_instance_name, FLAGS_access_host);
std::cout << "服务启动并注册完成" << std::endl;
server.RunUntilAskedToQuit();
return 0;
}
7.3 客户端改造
在 brpc 客户端的基础上,加上服务发现和信道管理:
DEFINE_string(etcd_host, "[http://127.0.0.1:2379](http://127.0.0.1:2379)", "注册中心地址");
DEFINE_string(base_service, "/service", "服务根目录");
DEFINE_string(call_service, "/service/echo", "要调用的服务名");
int main(int argc, char* argv[]) {
google::ParseCommandLineFlags(&argc, &argv, true);
// 1. 创建信道管理器,声明关注的服务
auto sm = std::make_shared<ServiceManager>();
sm->declared(FLAGS_call_service);
// 2. 创建服务发现,绑定上下线回调
auto put_cb = std::bind(&ServiceManager::onServiceOnline, sm.get(),
std::placeholders::_1, std::placeholders::_2);
auto del_cb = std::bind(&ServiceManager::onServiceOffline, sm.get(),
std::placeholders::_1, std::placeholders::_2);
Discovery::ptr dclient = std::make_shared<Discovery>(
FLAGS_etcd_host, FLAGS_base_service, put_cb, del_cb);
// 3. 循环发起RPC调用测试
while (true) {
auto channel = sm->choose(FLAGS_call_service);
if (!channel) {
std::this_thread::sleep_for(std::chrono::seconds(1));
continue;
}
example::EchoService_Stub stub(channel.get());
example::EchoRequest req;
req.set_message("你好~比特~!");
brpc::Controller* cntl = new brpc::Controller();
example::EchoResponse* rsp = new example::EchoResponse();
stub.Echo(cntl, &req, rsp, nullptr);
if (cntl->Failed()) {
std::cout << "调用失败: " << cntl->ErrorText() << std::endl;
delete cntl;
delete rsp;
std::this_thread::sleep_for(std::chrono::seconds(1));
continue;
}
std::cout << "收到响应: " << rsp->message() << std::endl;
delete cntl;
delete rsp;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
return 0;
}
7.4 联调测试与踩坑记录
编译
all: registry discovery
registry: registry.cc main.pb.cc
g++ -std=c++17 $^ -o $@ -lspdlog -lfmt -lgflags -letcd-cpp-api -lcpprest -lbrpc -lssl -lcrypto -lprotobuf -lleveldb
discovery: discovery.cc main.pb.cc
g++ -std=c++17 $^ -o $@ -lspdlog -lfmt -lgflags -letcd-cpp-api -lcpprest -lbrpc -lssl -lcrypto -lprotobuf -lleveldb
测试流程
- 确保 etcd 正常运行
- 启动 discovery 客户端,一开始会打印「没有可用的服务节点」
- 启动 registry 服务端,客户端很快收到服务上线日志,然后开始正常打印 RPC 响应
- 关闭服务端,大约 3 秒后,客户端收到服务下线日志,重新进入等待状态
常见踩坑
- 头文件重复包含导致重定义 :所有头文件都要加
#pragma once,尤其是 logger 这种公共组件 - 服务注册 key 不唯一:多实例一定要加不同的实例名,否则会互相覆盖
- 服务名和实例名不匹配:一定要从完整 key 里提取服务名,不然发现了服务但信道管理器不认
- 异步调用对象生命周期:Controller 和 Response 必须堆分配,回调里记得释放
- brpc 日志冲突:一定要关闭 brpc 默认日志,不然会和自己的日志系统混在一起
八、核心要点总结
- etcd 的灵魂是租约 + watch:租约实现故障自动摘除,watch 实现实时感知,二者配合才是完整的服务注册发现能力。
- 二次封装是工程化必备:把通用组件封装成业务接口,屏蔽底层细节,既能减少重复代码,也能降低出错概率。
- brpc 的 RAII 设计很重要:ClosureGuard、Channel 这些都遵循 RAII 思想,用好能少写很多样板代码,也更安全。
- 数据结构要匹配访问模式:没有绝对好的数据结构,读多写少选 vector、按键查找选哈希表,匹配场景才是最优。
- 联调核心是边界对齐:注册的 key 格式、发现的解析逻辑、信道管理的服务名,三者必须对应上,不然就会出现「注册了但发现不了」的玄学问题。
结尾:
html
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!
结语:到这里,etcd + brpc 的完整微服务通信底座就跑通了。这也是我们整个即时通讯系统的核心基础设施,后面的用户管理、好友管理、消息转发等所有子服务,都可以基于这套模式开发,服务之间完全解耦,动态扩缩容也非常方便。接下来我们会基于这套底座,继续拆分各个业务子服务,一步步把整个聊天系统落地。后续也会分享更多 C++ 微服务、高性能后端的实战内容,欢迎关注交流。
✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど
