历史背景
PostgreSQL 的实现始于 1986 年,由伯克利大学的 Michael Stonebraker 教授领导。经过几十年的发展,PostgreSQL 堪称目前最先进的开源关系型数据库。它有自由宽松的许可证,任何人都可以免费使用、修改和分发 PostgreSQL,不管是私用、商用还是学术研究目的。
PostgreSQL 全方位支持 OLTP 和 OLAP,具有强大的 SQL 查询能力和大量扩展,能满足几乎所有商业需求,所以近年来越来越被受到重视。事实上,PostgreSQL 强大的扩展性和高性能使得它能模拟任何其他不同类型数据库的功能。
图片来源(遵循 CC 3.0 BY-SA 版权协议): https://en.wikibooks.org/wiki/PostgreSQL/Architecture
而 etcd 又是如何诞生的呢?它解决了什么问题?
2013 年,有一个叫 CoreOS 的创业团队,他们构建了一个产品:Container Linux。它是一个开源、轻量级的操作系统,侧重自动化、快速部署应用服务,并要求应用程序都在容器中运行,同时提供集群化的管理方案,用户管理服务就像单机一样方便。
他们希望在重启任意一节点的时候,用户的服务不会因此而宕机,导致无法提供服务,因此需要运行多个副本。但是多个副本之间如何协调,如何避免变更的时候所有副本不可用呢?
为了解决这个问题,CoreOS 团队需要一个协调服务来存储服务配置信息、提供分布式锁等能力。怎么办呢?当然是分析业务场景、痛点、核心目标,然后是基于目标进行方案选型,评估是选择社区开源方案还是自己造轮子。这其实就是我们遇到棘手问题时的通用解决思路,CoreOS 团队同样如此。
一个协调服务,理想状态下大概需要满足以下五个目标:
- 高可用,数据多副本
- 数据一致性,数据副本之间的版本校对
- 低容量、仅存储关键元数据配置。协调服务保存的仅仅是服务、节点的配置信息(属于控制面配置),而不是与用户相关的数据,所以存储上不需要考虑数据分片,无需过度设计
- 功能:增删改查,监听数据变化的机制。协调服务保存了服务的状态信息,若服务有变更或异常,相比控制端定时去轮询检查一个个服务状态,若能快速推送变更事件给控制端,则可提升服务可用性、以及减少协调服务不必要的性能开销
- 运维复杂度
从 CAP 理论上来说,etcd 属于 CP 系统。
作为 kubernetes 集群的中枢组件 kube-apiserver 正是采用 etcd 作为底层存储。
一方面,k8s 集群中资源对象的创建都需要借助 etcd 来持久化;另一方面,正是 etcd 的数据 watch 机制,驱动着整个集群的 Informer 工作,从而达到源源不断的容器编排!因此,从技术角度说,Kubernetes 采用 etcd 的核心理由有:
- etcd 采用 go 语言编写,和 k8s 技术栈一致,资源占用率低,部署异常简单。
- etcd 的强一致性、watch、lease 等特性是 k8s 的核心依赖。
总而言之,etcd 是针对配置管理和分发这个特定需求而设计出来的分布式 KV 数据库,它是云原生软件, 开箱即用和高性能使得它在这个需求上优于传统数据库。
要比对 etcd 和 PostgreSQL 这两个不同类型的数据库,需要在同一个需求上去看才客观。
所以本文只针对配置管理这个需求来阐述两者的差异。
数据模型
不同数据库对用户所呈现的数据模型有所不同,它决定了数据库的适用场景。
key-value vs SQL
key-value 是 nosql 里面很流行的模型,也是 etcd 所采纳的设计,相比 SQL,它有什么好处呢?
我们先来看 SQL。
关系数据库维护表中的数据,提供了一种高效、直观和灵活的方式来存储和访问结构化信息。
表(也称为关系)由包含一个或多个数据类别的列和包含该类别定义的一组数据的行(也称为表记录)组成。应用程序通过指定查询来访问数据,这些查询使用诸如 project 之类的操作来标识属性、选择来标识元组以及连接来组合关系。数据库管理的关系模型是由 IBM 计算机科学家埃德加·科德在1970年开发的。
图片来源(遵循 CC 3.0 BY-SA 版权协议): https://en.wikipedia.org/wiki/Associative_entity
每个表里面的记录没有唯一标识符,因为表被设计为可容纳多个重复行。如果需要做到 KV 查询,需要为表里面用作 key 的字段加上唯一索引。PostgreSQL 的索引默认是 btree,跟 etcd 一样,可以做 key 的范围查询。
结构化查询语言 (SQL) 是一种编程语言,用于在关系数据库中存储和处理信息。关系数据库以表格形式存储信息,行和列分别表示不同的数据属性和数据值之间的各种关系。您可以使用 SQL 语句从数据库中存储、更新、删除、搜索和检索信息。您还可以使用 SQL 来维护和优化数据库性能。
PostgreSQL 对 SQL 做了很多扩展,使得它是图灵完备的语言,使用 SQL 可以做任何复杂的操作,使得数据处理逻辑完全在服务端进行。
而 etcd 的定位是配置管理,配置数据一般是哈希表,所以将数据模型定位为 key-value,相当于只有全局一张大表,你可以对这张表进行增删查改。这张表只有两个字段,一个 key,一个 value,key 必须是唯一的,而且带有版本信息,而 value 的类型不做任何假设,所以客户端需要获取全量的 value 做进一步处理。
总的来说,etcd 的 kv 是而对 SQL 的简化,对于配置管理这个特定需求,更加方便和直观。
MVCC(多版本并发控制)
对于配置管理,数据版本化是其中一个刚需:
- 查询历史数据
- 通过比较版本可以知道数据的新旧
- watch 数据需要以版本为根据,以便实现增量通知
etcd 和 PostgreSQL 都有 MVCC,但是它们的差异在哪里呢?
etcd 维护了一个全局递增的 64 位版本计数器(无需担心计数器溢出,因为即便一秒钟产生百万次更新,也需要几十万年才能用完),每个 key-value 在创建和更新的时候都会赋予版本。删除 key-value 时会创建一个墓碑,版本重置为0,也就是说,每次变更都产生新的版本,而不是原地更新。同时,etcd 保留了一个 key-value 的所有版本,并且对用户可见。此外,etcd 实现 MVCC 最大的好处是读写分离,读取数据无需加锁,满足了 etcd 以读为主的定位。
与 etcd 不同,PostgreSQL 的 MVCC 不是为了提供递增版本号,而是为了实现事务,也就是各类隔离级别,它对用户是透明的。MVCC 是一种乐观锁,它允许并发更新。表的每一行都有事务 ID 的记录,与 etcd 类似,xmin 对应了创建事务 ID,xmax 对应了更新事务 ID。
- 每个事务只能读取在它之前已经 commit 的事务
- 更新如果遇到版本冲突,会进行匹配重试,以决定是否更新
示例请查看: https://devcenter.heroku.com/articles/postgresql-concurrency
但是事务 ID 不能用于配置的版本控制,原因如下:
- 同一个事务内涉及到的所有行都被赋予同一个事务 ID,也就是说,它不是行级别的
- 只能读取最新版本的行,无法做历史查询
- 事务 ID 会变,因为事务 ID 是32位计数器,容易溢出,在 vacuum 的时候会被重置
- 无法根据事务 ID 实现 watch
所以 PostgreSQL 要做配置数据的版本控制,需要用其他形式来替代,没有开箱即用的支持。
客户端接口
接口设计决定了客户端的使用成本和资源消耗,通过分析接口差异,可以帮助我们如何选型。
etcd 提供了 kv/watch/lease API,实践证明它们特别满足配置管理的操作需求,那么在 PostgreSQL 上这些接口又如何实现呢?
PostgreSQL 对这些 API 没有开箱即用的功能,需要通过封装来实现,这里使用笔者开发的 pg_watch_demo 项目来做分析:
https://github.com/kingluo/pg_watch_demo
grpc/http vs tcp
PostgreSQL 是多进程架构,每个进程只能处理一个 tcp 连接,使用自定义协议通过 SQL 提供功能,采用一问一答的交互模型 (同一时间只能有一个查询执行中,类似 http1的同一时间只能处理一个请求,多个请求需要形成 pipeline),资源消耗大且比较低效,对于 QPS 大的场景需要前置连接池代理(例如 pgbouncer)来提高性能。
而 etcd 是 golang 的多协程架构,提供 grpc 和 restful 两种接口,使用方便,客户端容易集成; 资源消耗小,每条 grpc 连接可并发多个查询。
数据定义
etcd
1message KeyValue {
2 bytes key = 1;
3 // 创建 key 的 revision
4 int64 create_revision = 2;
5 // 更新 key 的 revision
6 int64 mod_revision = 3;
7 // 版本递增计数器,每次更新递增,但是 delete 时会被清零用作墓碑
8 int64 version = 4;
9 bytes value = 5;
10 // key 使用的 lease 对象,用作 ttl,如果是0则没有 ttl
11 int64 lease = 6;
12}
PostgreSQL
PostgreSQL 需要使用一个 table 来模拟 etcd 的全局数据空间:
1CREATE TABLE IF NOT EXISTS config (
2 key text,
3 value text,
4 -- 等价于 `create_revision` 和 `mod_revision`
5 -- 这里使用大整数的递增序列类型来模拟 revision
6 revision bigserial,
7 -- 墓碑
8 tombstone boolean NOT NULL DEFAULT false,
9 -- 组合索引,先搜索 key,然后是 revision
10 primary key(key, revision)
11);
get
etcd
etcd 的 get 参数比较丰富:
- 范围查询,例如
key
为/abc
,而range_end
为/abd
,那么就可以获取以/abc
为前缀的所有 key-value - 历史查询,指定
revision
,或者指定 mod_revision 范围 - 排序、限定返回数目
1message RangeRequest {
2 ...
3 bytes key = 1;
4 // 范围查询
5 bytes range_end = 2;
6 int64 limit = 3;
7 // 历史查询
8 int64 revision = 4;
9 // 排序
10 SortOrder sort_order = 5;
11 SortTarget sort_target = 6;
12 bool serializable = 7;
13 bool keys_only = 8;
14 bool count_only = 9;
15 // 历史查询
16 int64 min_mod_revision = 10;
17 int64 max_mod_revision = 11;
18 int64 min_create_revision = 12;
19 int64 max_create_revision = 13;
20}
PostgreSQL
postgres 可以通过 SQL 完成 etcd 的 get 功能,甚至提供更多复杂的功能,因为 SQL 本身是语言,不是固定参数的接口可比拟的,这里简单展示只获取最新 revision 的 key-value。由于主键是组合索引,本身可以按范围搜索,所以速度会很快。
1CREATE FUNCTION get1(kk text)
2RETURNS table(r bigint, k text, v text, c bigint) AS $$
3 SELECT revision, key, value, create_time
4 FROM config
5 where key = kk and tombstone = false
6 ORDER BY key, revision desc
7 limit 1
8$$ LANGUAGE sql;
put
etcd
1message PutRequest {
2 bytes key = 1;
3 bytes value = 2;
4 int64 lease = 3;
5 // 是否返回修改前的 kv
6 bool prev_kv = 4;
7 bool ignore_value = 5;
8 bool ignore_lease = 6;
9}
PostgreSQL
类似 etcd,更改不是原地执行,而是插入一个新行,赋予新的 revision。
1CREATE FUNCTION set(k text, v text) RETURNS bigint AS $$
2 insert into config(key, value) values(k, v) returning revision;
3$$ LANGUAGE SQL;
delete
etcd
1message DeleteRangeRequest {
2 bytes key = 1;
3 bytes range_end = 2;
4 bool prev_kv = 3;
5}
PostgreSQL
类似 etcd,删除不是原地修改,而是插入一个新行,将 tombstone 字段设置为 true 以表示是墓碑。
1CREATE FUNCTION del(k text) RETURNS bigint AS $$
2 insert into config(key, tombstone) values(k, true) returning revision;
3$$ LANGUAGE SQL;
watch
etcd
1message WatchCreateRequest {
2 bytes key = 1;
3 // 指定 key 的范围
4 bytes range_end = 2;
5 // watch 的起始 revision
6 int64 start_revision = 3;
7 ...
8}
9
10message WatchResponse {
11 ResponseHeader header = 1;
12 ...
13 // 为了提高效率,可返回多个事件
14 repeated mvccpb.Event events = 11;
15}
PostgreSQL
PostgreSQL 没有内置的 watch 功能,需要结合触发器和 channel 来实现。pg_notify
能将数据发送到侦听某个 channel 的所有应用程序。
1-- 触发器,用于 put/delete 事件的分发
2CREATE FUNCTION notify_config_change() RETURNS TRIGGER AS $$
3DECLARE
4 data json;
5 channel text;
6 is_channel_exist boolean;
7BEGIN
8 IF (TG_OP = 'INSERT') THEN
9 -- 将改动用 JSON 编码
10 data = row_to_json(NEW);
11 -- 从 key 提取分发的 channel name
12 channel = (select SUBSTRING(NEW.key, '/(.*)/'));
13 -- 如果当前有应用在 watch,则通过 channel 发送事件
14 is_channel_exist = not pg_try_advisory_lock(9080);
15 if is_channel_exist then
16 PERFORM pg_notify(channel, data::text);
17 else
18 perform pg_advisory_unlock(9080);
19 end if;
20 END IF;
21 RETURN NULL; -- result is ignored since this is an AFTER trigger
22END;
23$$ LANGUAGE plpgsql;
24
25CREATE TRIGGER notify_config_change
26AFTER INSERT ON config
27 FOR EACH ROW EXECUTE FUNCTION notify_config_change();
由于是封装出来的 watch,所以需要客户端应用也要实现配合逻辑,以 golang 为例:
- 发起 listen。
一旦 listen,所有 notify 数据会缓存起来(在 PostgreSQL 和 golang 的 channel 层面都可能存在缓存)
- get_all(key_prefix, revision)。
读取从指定 revision 开始的所有存量数据,每一个 key 只会返回最新 revision 数据,已删除的数据自动去除。也可以不指定 revision(这是最常见的情形),它会读取 key_prefix 前缀的所有 key 的最新数据。
- watch 新数据,包括在第一步和第二步之间可能缓存起来的 notification,使得不错过这个时间窗口可能产生的新数据。对于已经在第二步读取过的 revision,在这步忽略掉。
1func watch(l *pq.Listener) {
2 for {
3 select {
4 case n := <-l.Notify:
5 if n == nil {
6 log.Println("listener reconnected")
7 log.Printf("get all routes from rev %d including tombstones...\n", latestRev)
8 // 重连的时候根据断开前的 revision 断点续传
9 str := fmt.Sprintf(`select * from get_all_from_rev_with_stale('/routes/', %d)`, latestRev)
10 rows, err := db.Query(str)
11 ...
12 continue
13 }
14 ...
15 // 应用要维护一个状态,里面记录已经接受到的最新的 revision
16 updateRoute(cfg)
17 case <-time.After(15 * time.Second):
18 log.Println("Received no events for 15 seconds, checking connection")
19 go func() {
20 // 长时间没收到事件,则检查一下连接是否健康
21 if err := l.Ping(); err != nil {
22 log.Println("listener ping error: ", err)
23 }
24 }()
25 }
26 }
27}
28
29log.Println("get all routes...")
30// 应用在初始化的时候应该全量获取当前所有的 key-value,然后通过 watch 来增量监控更新
31rows, err := db.Query(`select * from get_all('/routes/')`)
32...
33go watch(listener)
transaction
etcd
etcd 的事务是带有判断条件的多个操作的集合,事务做出的修改是原子提交的。
1message TxnRequest {
2 // 指定事务执行条件
3 repeated Compare compare = 1;
4 // 条件满足要执行的多个操作
5 repeated RequestOp success = 2;
6 // 条件不满足要执行的多个操作
7 repeated RequestOp failure = 3;
8}
PostgreSQL
用 DO
命令可以执行任何命令,包括存储过程,它支持很多语言,例如自带的 plpgsql、python 等,用这些语言可以实现任何条件判断、循环等控制逻辑,比 etcd 更丰富。
1DO LANGUAGE plpgsql $$
2DECLARE
3 n_plugins int;
4BEGIN
5 SELECT COUNT(1) INTO n_plugins FROM get_all('/plugins/');
6 IF n_plugins = 0 THEN
7 perform set('/routes/1', 'foobar');
8 perform set('/upstream/1', 'foobar');
9 ...
10 ELSE
11 ...
12 END IF;
13END;
14$$;
lease
etcd
在 etcd 里面,可以创建 lease 对象,应用要定期去续约这个 lease 对象,使得它不过期。
每个 key-value 可以绑定一个 lease 对象,当 lease 对象过期时,所有绑定它的 key-value 都会过期,相当于自动被删除了。
1message LeaseGrantRequest {
2 // lease 的存活时间
3 int64 TTL = 1;
4 int64 ID = 2;
5}
6
7// lease 续约
8message LeaseKeepAliveRequest {
9 int64 ID = 1;
10}
11
12message PutRequest {
13 bytes key = 1;
14 bytes value = 2;
15 // lease ID,用于实现 ttl
16 int64 lease = 3;
17 ...
18}
PostgreSQL
- 在 PostgreSQL 里面可以通过外键来维护 lease,查询的时候,如果有关联的 lease 对象且过期,则视为墓碑。
- keepalive 请求更新 lease 表里面的 last_keepalive 时间戳。
1CREATE TABLE IF NOT EXISTS config (
2 key text,
3 value text,
4 ...
5 -- 通过外键来指定其绑定的 lease 对象
6 lease int64 references lease(id),
7);
8
9CREATE TABLE IF NOT EXISTS lease (
10 id text,
11 ttl int,
12 last_keepalive timestamp;
13);
性能对比
PostgreSQL 需要通过封装来模拟 etcd 的各类 API,那么性能如何呢?
这里有一个简单的测试结果:
https://github.com/kingluo/pg_watch_demo#benchmark
从结果可见,读写性能相差无几,而 PostgreSQL 甚至比 etcd 更快。
另外,一个更新从发生到应用接收到事件的延时决定了更新的分发效率,PostgreSQL 和 etcd 也是相差无几,客户端和服务端都在同一个机器测试时,watch 延时小于1毫秒。
但是 PostgreSQL 有如下缺陷值得说明:
- 每个更新对应的 WAL 日志更大,磁盘 IO 比 etcd 多一倍
- CPU 耗费比 etcd 多
- 基于 channel 的 notify 是事务级别的概念,对同一类资源进行更新,就会将更新发往同一个 channel,更新请求之间会抢夺互斥锁,导致请求串行化,也就是说,通过 channel 实现 watch,会影响 put 的并行化
从这里也可以看到,为了实现同样的需求,我们对 PostgreSQL 需要更多的学习成本和优化成本。
存储
底层存储决定了性能,数据如何落地决定了数据库对内存、磁盘等资源的需求。
etcd
etcd 存储架构图:
etcd 首先将更新写入日志(WAL,write-ahead log),并且刷到磁盘,以保证这笔更新不会丢失,一旦日志成功写入且经过大多数节点确认,就可以返回结果给客户端了。etcd 还会异步更新 treeIndex 和 boltdb。
为了避免日志的无限增长,etcd 定期对存储做快照,快照之前的日志可以被删掉。
etcd 对所有的 key 都在内存里面做索引(treeIndex),在其中记录每个 key 的版本信息,但是 value 只是保留对 boltdb 的指针(revision)。
而 key 对应的 value 则是保存在磁盘里面,使用 boltdb 来维护。
treeIndex 和 boltdb 都使用 btree 数据结构,众所周知,btree 对于查找和范围查找是高效的。
treeIndex 的结构图:
图片来源(遵循 CC 4.0 BY-SA 版权协议): https://blog.csdn.net/H_L_S/article/details/112691481
每个 key 被分为不同的 generation,每次删除结束一个 generation。
value 的指针由两个整数构成,第一个整数 main 是 etcd 的事务 ID,而第二个整数 sub 表示在该事务里对这个 key 的更新 ID。
boltdb 支持事务和快照,里面保存的是 revision 对应的值。
图片来源(遵循 CC 4.0 BY-SA 版权协议): https://blog.csdn.net/H_L_S/article/details/112691481
写入数据示例:
写入 key="key1", revision=(12,1),value="keyvalue5"
,注意 treeIndex 和 boltdb 的红色部分变化:
图片来源(遵循 CC 4.0 BY-SA 版权协议): https://blog.csdn.net/H_L_S/article/details/112691481
删除 key="key",revision=(13,1)
,treeIndex 产生新的空的 generation,在 boltdb 生成一个空值,key="13_1t",
这里的 t
表示 tombstone
(墓碑)。
这也暗含了你无法读取墓碑,因为 treeIndex 里面的指针是 (13,1)
,但 boltdb 里是 13_1t
,无法匹配。
图片来源(遵循 CC 4.0 BY-SA 版权协议): https://blog.csdn.net/H_L_S/article/details/112691481
值得注意的是,etcd 对 boltdb 的读写都由一个单独的 goroutine 来调度,以减少对磁盘的随机读写,提高 IO 性能。
PostgreSQL
PostgreSQL 的存储架构图:
与 etcd 类似,PostgreSQL 首先将更新追加到日志文件,日志刷盘成功才表示事务完成,同时将更新写入 shared_buffer 内存。
shared_buffer 由所有表共享,它映射了表和索引。
PostgreSQL 里面每张表都由多个表页(page)文件组成,每个表页 8 KB,一个表页包含多个行。
除了表,索引(例如 btree 索引)也是由同样格式的表页文件组成,只不过这些表页文件比较特殊,互相关联形成树结构。
PostgreSQL 有一个 checkpointer 进程,会定时将所有表和索引的被更改的表页文件刷进磁盘,每个 checkpoint 之前的日志文件可以被删掉回收,避免日志无限增长。
表页结构:
图片来源(遵循 CC 3.0 BY-SA 版权协议): https://en.wikibooks.org/wiki/PostgreSQL/Page_Layout
索引的表页结构:
图片来源(遵循 CC 3.0 BY-SA 版权协议): https://en.wikibooks.org/wiki/PostgreSQL/Index_Btree
由于表页文件的分散,为了提高读性能,某些 SQL 语句的查询计划会考虑通过位图使得表页读取被顺序化,提高 IO 性能:
1EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100;
2
3 QUERY PLAN
4------------------------------------------------------------------------------
5 Bitmap Heap Scan on tenk1 (cost=5.07..229.20 rows=101 width=244)
6 Recheck Cond: (unique1 < 100)
7 -> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=101 width=0)
8 Index Cond: (unique1 < 100)
结论
PostgreSQL 和 etcd 的存储充分考虑了 IO 性能,而 etcd 更是将所有 key 的索引置于内存,它们也都考虑了磁盘顺序读写的批量操作优化。 所以你可以看到在上面的性能对比里面,PostgreSQL 和 etcd 的读写性能相差无几。
但是相比 PostgreSQL,etcd 要求更大的内存容量和更快的磁盘。
分布式
去中心化和数据一致性是 etcd 的特色,也是云原生的需求,而传统数据库如何满足这点?
etcd
Raft是很流行的分布式协议,etcd 通过 raft 分发更新到多个节点,保证已提交数据被大多数节点确认过。
raft 有严谨正确的角色定义,角色切换图如下:
图片来源(遵循 CC 3.0 BY-SA 版权协议): https://raft.github.io
默认情况下,所有读写都在 master 节点执行。
写要一致性这个好理解,但这里值得说明的是一致性读,它确保了读来自已提交数据,并且是数据的最新版本,每次读到的版本都等于或大于上一次读的版本。
一致性读的实现简单来说,就是 slave 节点从 master 节点获取最新版本,如果 slave 节点版本比 master 节点旧,则等待同步。
可见,etcd 的读写负担都落在 master 节点上,分布式只是保证可用副本和数据一致性,但是没有负载均衡。
PostgreSQL
PostgreSQL 是传统数据库出身,没有自带 raft 等分布式协议的实现, 但是它具备了集群化所需的数据复制特性,并且结合第三方的 raft 组件, 可以实现和 etcd 一模一样的分布式系统。
原生的 PostgreSQL 已经包含了以下基础特性:
- synchronous commit
- quorum replication
- failover trigger
- hot-standby
在主节点上的事务提交可配置为需要多个节点都确认才算提交成功,并且确认节点数可配置为大多数节点(quorum)。
相关资料: https://www.2ndquadrant.com/en/blog/evolution-fault-tolerance-postgresql-synchronous-commit/
数据复制的角色可以被切换(failover trigger),也有 pg_rewind 等工具可截除未被大多数节点确认的数据,以便重新加入集群。
hot-standby 则提供类似 etcd 那样的 serializable read,也就是能在从节点上读取已提交数据,但不保证是最新版本。
相关配置示例:
1-- set quorum sync replication in postgresql.conf
2-- assume you have 5 nodes, then at least 2 standbys must be committed
3-- then you could tolerate 2 nodes failures
4synchronous_commit on
5synchronous_standby_names ="ANY 2 (*)"
6
7-- if master fails, check flushed lsn of each standby
8-- promote a standby with max lsn to master
9select flushed_lsn from pg_stat_wal_receiver;
PostgreSQL 在数据面上已经全面支持集群化,只需要在控制面提供 raft 组件即可实现去中心化的集群。笔者曾为多个商业客户提供 pg_raft 组件,该组件作为 PostgreSQL 的 worker process 运行,基于 raft 协议为 PostgreSQL 提供选主等集群管理功能。
维护
etcd 是为特定需求而设计的数据库,所以本身不需要怎么维护,这也是它的卖点之一。
另一方面,由于优秀的设计,PostgreSQL 相比其他关系数据库, 需要 DBA 维护的点比较少,而且类似 etcd,很多维护工作都是 PostgreSQL 内置和自动进行的。
数据库有很多维护的例行任务,这里只关注两点,compaction 和快照备份。
compaction
数据多版本化会使得数据库变得臃肿,读写效率变低,很多旧版本的数据当没有读取需要的时候应该要删除,并且要将删除后的空洞部分合并,这就是 compaction。
etcd 在 API 上提供了 compact 和 defrag 两个操作。
compact 用于删除某个 revision 之前的所有旧版本数据,注意如果覆盖了某些 key 的最新版本,则会保留最新版本,例如 compact 100
,但是前面有一个 key=foo, revision=87
的 key-value,那么会保留它,但是会删除 key=foo, revision=65
的 key-value,说白了,compact 不会丢每个 key 的当前数据版本。
etcd 提供了 Auto Compaction 功能,例如可以指定每隔多少小时 compact 一次。
compact 会在 boltdb 留下空洞,所以需要 defrag 来整合它们,但是 defrag 会涉及大量 IO 和阻塞读写,需要谨慎进行。
另一方面,PostgreSQL 的 compact 也很简单,例如使用以下 SQL 删除 revision 为100之前的旧数据:
1with alive as (
2 select r as revision from get_all('/routes/')
3)
4delete from config
5where revision < 100 and not exists (
6 select 1 from alive where alive.revision = config.revision limit 1
7);
如果需要定时执行,可使用 crontab 或者 pg_cron 来实现。
而数据库自身的 MVCC 清理,PostgreSQL 有自带的 vacuum 命令(vacuum full 对应 etcd 的 defrag),也有自动化的 autovacuum。
快照备份
快照备份可用于应急恢复,是数据库维护的刚需任务。
etcd 提供了 API 创建和恢复快照,例如:
1$ etcdctl snapshot save backup.db
2$ etcdctl --write-out=table snapshot status backup.db
3+----------+----------+------------+------------+
4| HASH | REVISION | TOTAL KEYS | TOTAL SIZE |
5+----------+----------+------------+------------+
6| fe01cf57 | 10 | 7 | 2.1 MB |
7+----------+----------+------------+------------+
8$ etcdctl snapshot restore backup.db
PostgreSQL 也有很完善的备份工具:
- pg_basebackup 为新建 pg 从节点做数据准备
- pgdump 在线克隆数据库实例,可选择备份哪些表
事实上,基于 WAL 和逻辑复制,PostgreSQL 还支持更高级的备份机制,请参见如下链接:
https://www.postgresql.org/docs/current/continuous-archiving.html
https://www.postgresql.org/docs/current/logical-replication.html
结论
PostgreSQL 是通用型的传统 SQL 数据库,etcd 是专用的分布式 KV 数据库。
相比 etcd 这类纯粹的数据存取系统,PostgreSQL 起码有如下额外好处:
- 丰富的鉴权机制,能实现完整的 RBAC 和细粒度的权限控制,支持多租户(多数据库实例),能过滤 IP,无需额外代理
- SQL 自带 schema,支持外键,无需提供额外的控制面逻辑去保证数据的完备性
- 支持 JSON 类型的字段,支持基于 JSON 的索引和各类 JSON 操作,例如对路由配置进行索引以便做路由匹配
- 支持数据加密,也支持通过 fdw 访问 hashicorp vault 获取 secret
- 逻辑复制可实现多套独立集群之间的数据同步
- 有存储过程加持,可实现额外的功能,例如实现上游的慢启动
从功能上看,PostgreSQL 是 etcd 的超集,所以 PostgreSQL 可以通过其自带的丰富的基础功能和第三方组件来重现 etcd 的功能,也可以云化。
用 PostgreSQL 来实现 etcd 的功能,相当于将航母改造为巡航舰,技术上完全没问题,但如果没有超出 etcd 能力范围的需求,那么这种做法的性价比很低,因为开发成本和维护成本是不可忽视的事实。
etcd 最大的好处是开箱即用,满足了云原生时代对配置分发的需求,etcd 还可以用作应用选主、分布式锁、任务调度等功能的核心组件。