在使用etcd之前肯定要了解它是什么,为什么要选择使用它?
什么是 etcd?
Etcd 是一个 golang 编写的分布式、高可用的一致性键值存储系统,用于配置共享和服务发现等。它使用 Raft 一致性算法来保持集群数据的一致性,且客户端通过长连接watch 功能,能够及时收到数据变化通知,相较于 Zookeeper 框架更加轻量化。
它主要用于:
- 存储关键配置信息
- 服务发现
- 分布式锁
- 集群协调
etcd 就像是分布式系统的 “共享内存”,让多个节点能够安全可靠地共享状态信息。
通俗理解 ——etcd 就像一个可以跨进程、跨机器、跨网络共享的 “超级 map 容器”
什么场景使用它?
适合用 etcd 的场景 | 概括 | 为什么用 etcd |
---|---|---|
Kubernetes 集群 | 容器集群的 “大脑” | 存集群状态,保证所有组件数据一致 |
配置中心 | 多服务共享的配置 | 改一次全同步,不用重启服务 |
服务发现 | 找可用的服务地址 | 服务上下线自动更新,不用手动改配置 |
分布式锁 | 多节点抢资源时排队 | 保证同一时间只有一个节点操作 |
全局计数器 | 跨机器统计数字(如总订单数) | 多人同时操作也不会算错 |
安装 etcd
首先,需要在你的系统中安装 Etcd。Etcd 是一个分布式键值存储,通常用于服务发现
和配置管理。以下是在 Linux 系统上安装 Etcd 的基本步骤:
安装 Etcd:
sudo apt-get install etcd
启动 Etcd 服务:
sudo systemctl start etcd
设置 Etcd 开机自启:
sudo systemctl enable etcd
运行验证
etcdctl put mykey "this is awesome"
如果出现报错:
No help topic for 'put'
则 sudo vi /etc/profile 在末尾声明环境变量 ETCDCTL_API=3 以确定 etcd 版本。
export ETCDCTL_API=3
完毕后,加载配置文件,并重新执行测试指令
ubuntu@VM-16-12-ubuntu:~/code$ source /etc/profile
ubuntu@VM-16-12-ubuntu:~/code$ etcdctl put mykey "this is awesome"
OK
ubuntu@VM-16-12-ubuntu:~/code$ etcdctl get mykey
mykey
this is awesome
ubuntu@VM-16-12-ubuntu:~/code$ etcdctl del mykey
搭建服务注册发现中心
使用 Etcd 作为服务注册发现中心,你需要定义服务的注册和发现逻辑。这通常涉及到
以下几个操作:
- 服务注册:服务启动时,向 Etcd 注册自己的地址和端口。
- 服务发现:客户端通过 Etcd 获取服务的地址和端口,用于远程调用。
- 健康检查:服务定期向 Etcd 发送心跳,以维持其注册信息的有效性。
etcd 采用 golang 编写,v3 版本通信采用 grpc API,即(HTTP2+protobuf);官方只维护了 go 语言版本的
client 库, 因此需要找到 C/C++ 非官方的 client 开发库:etcd-cpp-apiv3
etcd-cpp-apiv3 是一个 etcd 的 C++版本客户端 API。它依赖于 mipsasm, boost, protobuf, gRPC, cpprestsdk 等库。
etcd-cpp-apiv3 的 GitHub 地址是:https://github.com/etcd-cpp-apiv3/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
sudo apt-get install libcpprest-dev
api 框架安装
git clone https://github.com/etcd-cpp-apiv3/etcd-cpp-apiv3.git
cd etcd-cpp-apiv3
mkdir build &&
cd build
cmake .. -DCMAKE_INSTALL_PREFIX=/usr
make -j$(nproc) &&
sudo make install
etcd操作命令
基本键值操作
# 写入键值对
etcdctl put /message "Hello etcd"
# 读取键值
etcdctl get /message
# 读取键的详细信息(包括版本等元数据)
etcdctl get /message -w json
# 读取前缀匹配的所有键
etcdctl get / --prefix
# 删除键
etcdctl del /message
# 删除前缀匹配的所有键
etcdctl del / --prefix
监听键变化
etcd 的 Watch 机制可以实时监听键的变化:
# 监听单个键
etcdctl watch /config/timeout
# 在另一个终端执行,触发上面的监听
etcdctl put /config/timeout 30s
# 监听前缀
etcdctl watch /config --prefix
租约与临时键
租约 (Lease) 机制可以创建自动过期的临时键,非常适合服务注册等场景:
# 创建一个30秒的租约
LEASE_ID=$(etcdctl lease grant 30 | awk '{print $2}')
echo "Lease ID: $LEASE_ID"
# 创建绑定到租约的键
etcdctl put /services/api-server "192.168.1.100:8080" --lease=$LEASE_ID
# 续租(延长租约有效期)
etcdctl lease keep-alive $LEASE_ID
# 撤销租约(立即删除所有绑定的键)
etcdctl lease revoke $LEASE_ID
事务操作
etcd 支持条件事务,实现 Compare-And-Swap 等原子操作:
# 事务:如果/message的值是"Hello etcd",则更新为"Hello world",否则不做操作
etcdctl txn --interactive
compares:
value("/message") = "Hello etcd"
success requests (get, put, del):
put("/message", "Hello world")
failure requests:
get("/message")
etcd 简单使用流程
客户端类与接口预览
C++
//pplx::task 并行库异步结果对象
//阻塞方式 get(): 阻塞直到任务执行完成,并获取任务结果
//非阻塞方式 wait(): 等待任务到达终止状态,然后返回任务状态
namespace etcd {
class Value
{
bool is_dir();//判断是否是一个目录
std::string const&
key() //键值对的 key 值
std::string const&
as_string()//键值对的 val 值
int64_t lease() //用于创建租约的响应中,返回租约 ID
}
//etcd 会监控所管理的数据的变化,一旦数据产生变化会通知客户端
//在通知客户端的时候,会返回改变前的数据和改变后的数据
class Event
{
enum class EventType
{
PUT, //键值对新增或数据发生改变
DELETE_,//键值对被删除
INVALID,
};
enum EventType event_type()
const Value&
kv()
const Value&
prev_kv()
}
class Response
{
bool is_ok()
std::string const&
error_message()
Value const&
value()//当前的数值 或者 一个请求的处理结果
Value const&
prev_value()//之前的数值
Value const&
value(int index)//
std::vector<Event>const&events();//触发的事件}class KeepAlive{KeepAlive(Client const& client, int ttl, int64_t lease_id =0);//返回租约 IDint64_t Lease();//停止保活动作void Cancel();}class Client{// etcd_url: "http://127.0.0.1:2379"Client(std::string const& etcd_url,std::string const& load_balancer = "round_robin");//Put a new key-value pair 新增一个键值对pplx::task<Response>put(std::string const& key,std::string const& value);//新增带有租约的键值对 (一定时间后,如果没有续租,数据自动删除)pplx::task<Response>put(std::string const& key,std::string const& value,const int64_t leaseId);//获取一个指定 key 目录下的数据列表pplx::task<Response>ls(std::string const& key);//创建并获取一个存活 ttl 时间的租约pplx::task<Response>leasegrant(int ttl);//获取一个租约保活对象,其参数 ttl 表示租约有效时间pplx::task<std::shared_ptr<KeepAlive>>leasekeepalive(intttl);//撤销一个指定的租约pplx::task<Response>leaserevoke(int64_t lease_id);//数据锁pplx::task<Response>lock(std::string const& key);}class Watcher{Watcher(Client const& client,std::string const& key, //要监控的键值对 keystd::function<void(Response)> callback, //发生改变后的回调bool recursive = false);//是否递归监控目录下的所有数据改变Watcher(std::string const& address,std::string const& key,std::function<void(Response)> callback,bool recursive = false);//阻塞等待,直到监控任务被停止bool Wait();bool Cancel();}
etcd 作为分布式键值存储,核心常用场景是 “数据写入(注册)” 与 “数据读取 + 变化监听(发现)”。下面结合 put.cc(写入 / 注册)和 get.cc(读取 / 监听)两份代码,一步步拆解 etcd 的简单使用流程,从环境准备到功能验证全覆盖。
put.cc :
- 定义 etcd 地址 (http://127.0.0.1:2379)
- 创建客户端对象 Client
- 创建租约并保活 client.leasekeepalive(3).get();
- 写入键值对(两种方式,有租约/没有租约) client.put
#include <etcd/Client.hpp>#include <etcd/KeepAlive.hpp>#include <etcd/Response.hpp>#include <thread>int main(int argc, char *argv[]){std::string etcd_host = "http://127.0.0.1:2379";//实例化客户端对象etcd::Client client(etcd_host);//获取租约保活对象--伴随着创建一个指定有效时长的租约auto keep_alive = client.leasekeepalive(3).get();//获取租约IDauto lease_id = keep_alive->Lease();//向etcd新增数据//1.有租约的数据auto resp1 = client.put("/service/user", "127.0.0.1:8080", lease_id).get();if (resp1.is_ok() == false) {std::cout <<"新增数据失败:" << resp1.error_message() << std::endl;return -1;}//2.没有租约的数据auto resp2 = client.put("/service/friend", "127.0.0.1:9090").get();if (resp2.is_ok() == false) {std::cout <<"新增数据失败:" << resp2.error_message() << std::endl;return -1;}std::this_thread::sleep_for(std::chrono::seconds(10));return 0;}
get.cc :
- 定义回调函数 callback (处理监控到的键值对事件)
- 连接 etcd + 读数据 ( /service 路径下的所有键值对信息)
- 启动监听 (watcher)
- 调用 watcher.Wait() 使程序保持运行以持续监控。
#include <etcd/Client.hpp>#include <etcd/KeepAlive.hpp>#include <etcd/Response.hpp>#include <etcd/Watcher.hpp>#include <etcd/Value.hpp>#include <thread>void callback(const etcd::Response &resp) {if (resp.is_ok() == false) {std::cout <<"收到一个错误的事件通知:" << resp.error_message() << std::endl;return;}for (auto const& ev : resp.events()) {if (ev.event_type() == etcd::Event::EventType::PUT) {std::cout <<"服务信息发生了改变:\n" ;std::cout <<"当前的值:" << ev.kv().key() <<"-" << ev.kv().as_string() << std::endl;std::cout <<"原来的值:" << ev.prev_kv().key() <<"-" << ev.prev_kv().as_string() << std::endl;}else if (ev.event_type() == etcd::Event::EventType::DELETE_) {std::cout <<"服务信息下线被删除:\n";std::cout <<"当前的值:" << ev.kv().key() <<"-" << ev.kv().as_string() << std::endl;std::cout <<"原来的值:" << ev.prev_kv().key() <<"-" << ev.prev_kv().as_string() << std::endl;}}}int main(int argc, char *argv[]){std::string etcd_host = "http://127.0.0.1:2379";//实例化客户端对象etcd::Client client(etcd_host);//获取指定的键值对信息auto resp = client.ls("/service").get();if (resp.is_ok() == false) {std::cout <<"获取键值对数据失败: " << resp.error_message() << std::endl;return -1;}int sz = resp.keys().size();for (int i = 0; i < sz;++i) {std::cout << resp.value(i).as_string() <<"可以提供" << resp.key(i) <<"服务\n";}//实例化一个键值对事件监控对象auto watcher = etcd::Watcher(client, "/service", callback, true);watcher.Wait();return 0;}
etcd的头文件
头文件 | 核心作用 | 包含的主要类 / 接口 |
---|---|---|
<etcd/Client.hpp> | etcd 客户端核心类,所有与 etcd 的交互入口 | etcd::Client:客户端主类,提供键值操作(put/get/del)、租约管理(leasegrant)、事务(txn)等所有核心接口,通过客户端对象连接 etcd 服务,发起各种请求 |
<etcd/KeepAlive.hpp> | 租约保活管理,处理租约的自动续期 | etcd::KeepAlive:租约保活类,通过 leasekeepalive() 创建,负责定期向 etcd 发送续期请求,维持租约有效,提供 Lease() 获取租约 ID、Cancel() 停止保活等接口 |
<etcd/Response.hpp> | 请求响应处理,封装 etcd 服务器的返回结果 | etcd::Response:响应类,包含请求的执行结果(成功 / 失败)、返回数据(键值对、事件、租约信息等),提供 is_ok()(判断成功)、error_message()(错误信息)、kv()(键值数据)等方法解析响应 |
<etcd/Watcher.hpp> | 数据变化监听,实时捕获键的新增 / 删除 / 修改 | etcd::Watcher:监听器类,通过指定键或前缀创建监听,当数据变化时触发回调函数 |
<etcd/Value.hpp> | 键值数据封装,存储单个键值对的详细信息 | etcd::Value(或 etcd::KeyValue):键值对类,包含键(key())、值(as_string())、版本(version())、租约 ID(lease())等元数据,用于从 Response 中提取具体的键值信息 |
etcd地址
http://127.0.0.1:2379 是 etcd 服务的默认本地地址
实际场景中地址会如何变化?
- 远程服务器部署:如果 etcd 部署在另一台服务器(如 IP 192.168.1.100),地址会变为 http://192.168.1.100:2379。
- 集群模式:etcd 集群由多个节点组成时,客户端地址通常是多个节点的地址列表(用逗号分隔),例如:
std::string etcd_host = "http://node1:2379,http://node2:2379,http://node3:2379";
这样即使某个节点故障,客户端仍能连接到其他节点。
- 自定义端口:如果启动时通过 --listen-client-urls 参数修改了端口(如改为 2380),地址会变为 http://xxx.xxx.xxx.xxx:2380。
注意:这里的地etcd地址属于虚地址,实际存储在 etcd 集群的磁盘上
租约
etcd 租约(Lease)是一种带有效期的资源管理机制。
核心作用是为 etcd 中的键值对(Key-Value)绑定 “生命周期”—— 租约到期或失效时,所有绑定它的键值对会被自动删除,无需手动清理。
它就像给数据加了 “定时自动删除” 功能,尤其适合解决分布式场景下的 “僵尸数据” 问题(如服务下线后残留的注册信息)。
租约的核心概念
- 租约 ID(Lease ID):每个租约的唯一标识,由 etcd 自动生成(或手动指定),用于关联键值对。
- TTL(Time-To-Live):租约的有效期(单位:秒),如 3 秒、30 秒,到期后租约自动失效。
- 保活(KeepAlive):若想延长租约有效期,客户端需定期向 etcd 发送 “保活请求”,etcd 收到后会重置租约的 TTL,相当于 “续期”。
- 键值绑定:一个租约可绑定多个键值对,所有绑定的键会随租约失效而同步删除。
makefile:
all : put get
put : put.cc
g++ -std=c++17 $^ -o $@ -letcd-cpp-api -lcpprest
get : get.cc
g++ -std=c++17 $^ -o $@ -letcd-cpp-api -lcpprest
.PHONY:clean
clean:
rm -f all
基于 etcd 的服务注册与发现模块
通过封装 etcd 的 C++ 客户端接口,实现了分布式系统中常见的服务注册与发现功能:
服务注册:将服务信息(键值对)注册到 etcd,并通过租约(lease)机制维持服务活性。
服务发现:从 etcd 中获取指定目录下的服务信息,并监控该目录的变化(新增 / 删除服务),通过回调函数通知上层处理。
#pragma once
#include <etcd/Client.hpp>#include <etcd/KeepAlive.hpp>#include <etcd/Response.hpp>#include <etcd/Watcher.hpp>#include <etcd/Value.hpp>#include <functional>#include "logger.hpp" // 自定义的log日志类namespace bite_im{//服务注册客户端类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();}bool registry(const std::string &key, const std::string &val) {auto resp = _client->put(key, val, _lease_id).get();if (resp.is_ok() == false) {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;};//服务发现客户端类class Discovery{public:using ptr = std::shared_ptr<Discovery>;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){//先进行服务发现,先获取到当前已有的数据auto resp = _client->ls(basedir).get();if (resp.is_ok() == false) {LOG_ERROR("获取服务信息数据失败:{}", resp.error_message());}int sz = resp.keys().size();for (int i = 0; i < sz;++i) {if (_put_cb) _put_cb(resp.key(i), resp.value(i).as_string());}//然后进行事件监控,监控数据发生的改变并调用回调进行处理_watcher = std::make_shared<etcd::Watcher>(*_client.get(), basedir,std::bind(&Discovery::callback, this, std::placeholders::_1), true);}~Discovery() {_watcher->Cancel();}private:void callback(const etcd::Response &resp) {if (resp.is_ok() == false) {LOG_ERROR("收到一个错误的事件通知: {}", resp.error_message());return;}for (auto const& ev : resp.events()) {if (ev.event_type() == etcd::Event::EventType::PUT) {if (_put_cb) _put_cb(ev.kv().key(), ev.kv().as_string());LOG_DEBUG("新增服务:{}-{}", ev.kv().key(), ev.kv().as_string());}else if (ev.event_type() == etcd::Event::EventType::DELETE_) {if (_del_cb) _del_cb(ev.prev_kv().key(), ev.prev_kv().as_string());LOG_DEBUG("下线服务:{}-{}", ev.prev_kv().key(), ev.prev_kv().as_string());}}}private:NotifyCallback _put_cb;NotifyCallback _del_cb;std::shared_ptr<etcd::Client> _client;std::shared_ptr<etcd::Watcher> _watcher;};}
关键类与成员解析
1. Registry 类(服务注册)
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();}bool registry(const std::string &key, const std::string &val) {auto resp = _client->put(key, val, _lease_id).get();if (resp.is_ok() == false) {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;};
功能:向 etcd 注册服务,并通过租约机制自动维持服务心跳,确保服务下线时自动注销。
核心成员:
- _client:etcd 客户端实例,用于与 etcd 服务器通信。
- _keep_alive:租约保活器,维持租约的有效性(定时向 etcd 发送心跳)。
- _lease_id:租约 ID,绑定服务注册的键值对,租约过期后键值对自动删除。
主要方法:
- 构造函数:初始化 etcd 客户端,创建一个 3 秒过期的租约,并启动保活机制。
- registry 方法:将键值对(服务信息)注册到 etcd,关联当前租约 ID。
- 析构函数:取消租约保活,使服务信息在租约过期后自动删除。
2. Discovery 类(服务发现)
//服务发现客户端类
class Discovery
{
public:
using ptr = std::shared_ptr<Discovery>;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){//先进行服务发现,先获取到当前已有的数据auto resp = _client->ls(basedir).get();if (resp.is_ok() == false) {LOG_ERROR("获取服务信息数据失败:{}", resp.error_message());}int sz = resp.keys().size();for (int i = 0; i < sz;++i) {if (_put_cb) _put_cb(resp.key(i), resp.value(i).as_string());}//然后进行事件监控,监控数据发生的改变并调用回调进行处理_watcher = std::make_shared<etcd::Watcher>(*_client.get(), basedir,std::bind(&Discovery::callback, this, std::placeholders::_1), true);}~Discovery() {_watcher->Cancel();}private:void callback(const etcd::Response &resp) {if (resp.is_ok() == false) {LOG_ERROR("收到一个错误的事件通知: {}", resp.error_message());return;}for (auto const& ev : resp.events()) {if (ev.event_type() == etcd::Event::EventType::PUT) {if (_put_cb) _put_cb(ev.kv().key(), ev.kv().as_string());LOG_DEBUG("新增服务:{}-{}", ev.kv().key(), ev.kv().as_string());}else if (ev.event_type() == etcd::Event::EventType::DELETE_) {if (_del_cb) _del_cb(ev.prev_kv().key(), ev.prev_kv().as_string());LOG_DEBUG("下线服务:{}-{}", ev.prev_kv().key(), ev.prev_kv().as_string());}}}private:NotifyCallback _put_cb;NotifyCallback _del_cb;std::shared_ptr<etcd::Client> _client;std::shared_ptr<etcd::Watcher> _watcher;};
功能:从 etcd 中发现指定目录下的服务信息,并实时监控该目录的变化(新增 / 删除服务)。
核心成员:
- _client:etcd 客户端实例。
- _watcher:监听器,监控指定目录的键值对变化。
- _put_cb/_del_cb:回调函数,分别在服务新增(PUT 事件)和服务下线(DELETE 事件)时触发。
主要方法:
- 构造函数:初始化客户端,先获取指定目录下已有的所有服务信息并通过 - _put_cb 回调通知;然后启动监听器,监控后续变化。
- callback 方法:内部事件处理函数,解析 etcd 推送的事件(PUT/DELETE),并调用对应的回调函数。
- 析构函数:取消监听器,停止监控。
3. 测试
registry.cc
#include "etcd.hpp"
#include <gflags/gflags.h>int main(int argc, char *argv[]){google::ParseCommandLineFlags(&argc, &argv, true);init_logger(false, " ", 0);Registry::ptr rclient = std::make_shared<Registry>("http://127.0.0.1:2379");rclient->registry("/service/user/instance", "127.0.0.1:8080");std::this_thread::sleep_for(std::chrono::seconds(600));return 0;}
discovery.cc
#include "etcd.hpp"
#include <gflags/gflags.h>void online(const std::string &service_name, const std::string &service_host) {LOG_DEBUG("上线服务: {}-{}", service_name, service_host);}void offline(const std::string &service_name, const std::string &service_host) {LOG_DEBUG("上线服务: {}-{}", service_name, service_host);}int main(int argc, char *argv[]){google::ParseCommandLineFlags(&argc, &argv, true);init_logger(false, " ", 0);Discovery::ptr rclient = std::make_shared<Discovery>("http://127.0.0.1:2379", "/service", online, offline);std::this_thread::sleep_for(std::chrono::seconds(600));return 0;}
makefile:
all : put get
put : registry.cc
g++ -std=c++17 $^ -o $@ -lspdlog -lfmt -lgflags -letcd-cpp-api -lcpprest
get : discovery.cc
g++ -std=c++17 $^ -o $@ -lspdlog -lfmt -lgflags -letcd-cpp-api -lcpprest
.PHONY:clean
clean:
rm -f put get
9090是上次测试遗留的