cdc工具 postgresql_PostgreSQL变更事件捕获 (CDC)__数据库在本质上是一个状态集合,任何对数据库的变更(增删改)本质上都是对状态的修改。 在实际生产中,我们经常需要把数据...

本文介绍了PostgreSQL的变更数据捕获(CDC)工具,特别是逻辑解码和pg_recvlogical客户端。通过逻辑解码,可以获取数据库的增删改事件,并通过自定义输出插件转换为不同格式。文章还讨论了如何编写自定义的CDC客户端,包括连接、汇报进度和处理变更事件。此外,提到了CDC的局限性,如不完整事件钩子和同步提交问题,以及故障切换时的复制槽问题。最后,建议在生产环境中使用CDC时要谨慎考虑这些问题。
摘要由CSDN通过智能技术生成

--slot=test_slot --plugin=decoder_raw --start -f - | psql ```

#### 另一个有趣的场景是UNDO LOG。PostgreSQL的故障恢复是基于REDO LOG的,通过重放WAL会到历史上的任意时间点。在数据库模式不发生变化的情况下,如果只是单纯的表内容增删改出现了失误,完全可以利用类似decoder_raw的方式反向生成UNDO日志。提高此类故障恢复的速度。

#### 最后,输出插件可以将变更事件格式化为各种各样的形式。解码输出为Redis的kv操作,或者仅仅抽取一些关键字段用于更新统计数据或者构建外部索引,有着很大的想象空间。

#### 编写自定义的逻辑解码输出插件并不复杂,可以参阅这篇官方文档。毕竟逻辑解码输出插件本质上只是一个拼字符串的回调函数集合。在官方样例的基础上稍作修改,即可轻松实现一个你自己的逻辑解码输出插件。

### CDC客户端

#### PostgreSQL自带了一个名为pg_recvlogical的客户端应用,可以将逻辑变更的事件流写至标准输出。但并不是所有的消费者都可以或者愿意使用Unix Pipe来完成所有工作的。此外,根据端到端原则,使用pg_recvlogical将变更数据流落盘并不意味着消费者已经拿到并确认了该消息,只有消费者自己亲自向数据库确认才可以做到这一点。

#### 编写PostgreSQL的CDC客户端程序,本质上是实现了一个"猴版”数据库从库。客户端向数据库建立一条复制连接(Replication Connection) ,将自己伪装成一个从库:从主库获取解码后的变更消息流,并周期性地向主库汇报自己的消费进度(落盘进度,刷盘进度,应用进度)。

### 复制连接

#### 复制连接,顾名思义就是用于复制(Replication) 的特殊连接。当与PostgreSQL服务器建立连接时,如果连接参数中提供了replication=database|on|yes|1,就会建立一条复制连接,而不是普通连接。复制连接可以执行一些特殊的命令,例如IDENTIFY_SYSTEM, TIMELINE_HISTORY, CREATE_REPLICATION_SLOT, START_REPLICATION, BASE_BACKUP, 在逻辑复制的情况下,还可以执行一些简单的SQL查询。具体细节可以参考PostgreSQL官方文档中前后端协议一章:

#### https://www.postgresql.org/docs/current/protocol-replication.html

#### 譬如,下面这条命令就会建立一条复制连接:

#### $ psql 'postgres://localhost:5432/postgres?replication=on&application_name=mocker'

#### 从系统视图pg_stat_replication可以看到主库识别到了一个新的"从库"

```

vonng=# table pg_stat_replication ;

-[ RECORD 1 ]----+-----------------------------

pid | 7218

usesysid | 10

usename | vonng

application_name | mocker

client_addr | ::1

client_hostname |

client_port | 53420

```

#### 编写自定义逻辑

#### 无论是JDBC还是Go语言的PostgreSQL驱动,都提供了相应的基础设施,用于处理复制连接。

这里让我们用Go语言编写一个简单的CDC客户端,样例使用了jackc/pgx,一个很不错的Go语言编写的PostgreSQL驱动。这里的代码只是作为概念演示,因此忽略掉了错误处理,非常Naive。将下面的代码保存为main.go,执行go run main.go即可执行。

#### 默认的三个参数分别为数据库连接串,逻辑解码输出插件的名称,以及复制槽的名称。默认值为:

```

dsn := "postgres://localhost:5432/postgres?application_name=cdc"

plugin := "test_decoding"

slot := "test_slot"

```

#### 也可以通过命令行提供自定义参数:

```

go run main.go postgres:///postgres?application_name=cdc test_decoding test_slot

package main

import (

"log"

"os"

"time"

"context"

"github.com/jackc/pgx"

)

type Subscriber struct {

URL string

Slot string

Plugin string

Conn *pgx.ReplicationConn

LSN uint64

}

// Connect 会建立到服务器的复制连接,区别在于自动添加了replication=on|1|yes|dbname参数

func (s *Subscriber) Connect() {

connConfig, _ := pgx.ParseURI(s.URL)

s.Conn, _ = pgx.ReplicationConnect(connConfig)

}

// ReportProgress 会向主库汇报写盘,刷盘,应用的进度坐标(消费者偏移量)

func (s *Subscriber) ReportProgress() {

status, _ := pgx.NewStandbyStatus(s.LSN)

s.Conn.SendStandbyStatus(status)

}

// CreateReplicationSlot 会创建逻辑复制槽,并使用给定的解码插件

func (s *Subscriber) CreateReplicationSlot() {

if consistPoint, snapshotName, err := s.Conn.CreateReplicationSlotEx(s.Slot, s.Plugin); err != nil {

log.Fatalf("fail to create replication slot: %s", err.Error())

} else {

log.Printf("create replication slot %s with plugin %s : consist snapshot: %s, snapshot name: %s",

s.Slot, s.Plugin, consistPoint, snapshotName)

s.LSN, _ = pgx.ParseLSN(consistPoint)

}

}

// StartReplication 会启动逻辑复制(服务器会开始发送事件消息)

func (s *Subscriber) StartReplication() {

if err := s.Conn.StartReplication(s.Slot, 0, -1); err != nil {

log.Fatalf("fail to start replication on slot %s : %s", s.Slot, err.Error())

}

}

// DropReplicationSlot 会使用临时普通连接删除复制槽(如果存在),注意如果复制连接正在使用这个槽是没法删的。

func (s *Subscriber) DropReplicationSlot() {

connConfig, _ := pgx.ParseURI(s.URL)

conn, _ := pgx.Connect(connConfig)

var slotExists bool

conn.QueryRow(`SELECT EXISTS(SELECT 1 FROM pg_replication_slots WHERE slot_name = $1)`, s.Slot).Scan(&slotExists)

if slotExists {

if s.Conn != nil {

s.Conn.Close()

}

conn.Exec("SELECT pg_drop_replication_slot($1)", s.Slot)

log.Printf("drop replication slot %s", s.Slot)

}

}

// Subscribe 开始订阅变更事件,主消息循环

func (s *Subscriber) Subscribe() {

var message *pgx.ReplicationMessage

for {

// 等待一条消息, 消息有可能是真的消息,也可能只是心跳包

message, _ = s.Conn.WaitForReplicationMessage(context.Background())

if message.WalMessage != nil {

DoSomething(message.WalMessage) // 如果是真的消息就消费它

if message.WalMessage.WalStart > s.LSN { // 消费完后更新消费进度,并向主库汇报

s.LSN = message.WalMessage.WalStart + uint64(len(message.WalMessage.WalData))

s.ReportProgress()

}

}

// 如果是心跳包消息,按照协议,需要检查服务器是否要求回送进度。

if message.ServerHeartbeat != nil && message.ServerHeartbeat.ReplyRequested == 1 {

s.ReportProgress() // 如果服务器心跳包要求回送进度,则汇报进度

}

}

}

// 实际消费消息的函数,这里只是把消息打印出来,也可以写入Redis,写入Kafka,更新统计信息,发送邮件等

func DoSomething(message *pgx.WalMessage) {

log.Printf("[LSN] %s [Payload] %s",

pgx.FormatLSN(message.WalStart), string(message.WalData))

}

// 如果使用JSON解码插件,这里是用于Decode的Schema

type Payload struct {

Change []struct {

Kind string `json:"kind"`

Schema string `json:"schema"`

Table string `json:"table"`

ColumnNames []string `json:"columnnames"`

ColumnTypes []string `json:"columntypes"`

ColumnValues []interface{} `json:"columnvalues"`

OldKeys struct {

KeyNames []string `json:"keynames"`

KeyTypes []string `json:"keytypes"`

KeyValues []interface{} `json:"keyvalues"`

} `json:"oldkeys"`

} `json:"change"`

}

func main() {

dsn := "postgres://localhost:5432/postgres?application_name=cdc"

plugin := "test_decoding"

slot := "test_slot"

if len(os.Args) > 1 {

dsn = os.Args[1]

}

if len(os.Args) > 2 {

plugin = os.Args[2]

}

if len(os.Args) > 3 {

slot = os.Args[3]

}

subscriber := &Subscriber{

URL: dsn,

Slot: slot,

Plugin: plugin,

} // 创建新的CDC客户端

subscriber.DropReplicationSlot() // 如果存在,清理掉遗留的Slot

subscriber.Connect() // 建立复制连接

defer subscriber.DropReplicationSlot() // 程序中止前清理掉复制槽

subscriber.CreateReplicationSlot() // 创建复制槽

subscriber.StartReplication() // 开始接收变更流

go func() {

for {

time.Sleep(5 * time.Second)

subscriber.ReportProgress()

}

}() // 协程2每5秒地向主库汇报进度

subscriber.Subscribe() // 主消息循环

}

```

#### 在另一个数据库会话中再次执行上面的变更,可以看到客户端及时地接收到了变更的内容。这里客户端只是简单地将其打印了出来,实际生产中,客户端可以完成任何工作,比如写入Kafka,写入Redis,写入磁盘日志,或者只是更新内存中的统计数据并暴露给监控系统。甚至,还可以通过配置同步提交,确保所有系统中的变更能够时刻保证严格同步(当然相比默认的异步模式比较影响性能就是了)。

#### 对于PostgreSQL主库而言,这看起来就像是另一个从库。

```

postgres=# table pg_stat_replication; -- 查看当前从库

-[ RECORD 1 ]----+------------------------------

pid | 14082

usesysid | 10

usename | vonng

application_name | cdc

client_addr | 10.1.1.95

client_hostname |

client_port | 56609

backend_start | 2019-05-19 13:14:34.606014+08

backend_xmin |

state | streaming

sent_lsn | 2D/AB269AB8 -- 服务端已经发送的消息坐标

write_lsn | 2D/AB269AB8 -- 客户端已经执行完写入的消息坐标

flush_lsn | 2D/AB269AB8 -- 客户端已经刷盘的消息坐标(不会丢失)

replay_lsn | 2D/AB269AB8 -- 客户端已经应用的消息坐标(已经生效)

write_lag |

flush_lag |

replay_lag |

sync_priority | 0

sync_state | async

postgres=# table pg_replication_slots; -- 查看当前复制槽

-[ RECORD 1 ]-------+------------

slot_name | test

plugin | decoder_raw

slot_type | logical

datoid | 13382

database | postgres

temporary | f

active | t

active_pid | 14082

xmin |

catalog_xmin | 1371

restart_lsn | 2D/AB269A80 -- 下次客户端重连时将从这里开始重放

confirmed_flush_lsn | 2D/AB269AB8 -- 客户端确认完成的消息进度

```

### 局限性

#### 想要在生产环境中使用CDC,还需要考虑一些其他的问题。略有遗憾的是,在PostgreSQL CDC的天空上,还飘着两朵小乌云。

### 完备性

#### 就目前而言,PostgreSQL的逻辑解码只提供了以下几个钩子:

```

LogicalDecodeStartupCB startup_cb;

LogicalDecodeBeginCB begin_cb;

LogicalDecodeChangeCB change_cb;

LogicalDecodeTruncateCB truncate_cb;

LogicalDecodeCommitCB commit_cb;

LogicalDecodeMessageCB message_cb;

LogicalDecodeFilterByOriginCB filter_by_origin_cb;

LogicalDecodeShutdownCB shutdown_cb;

```

#### 其中比较重要,也是必须提供的是三个回调函数:begin:事务开始,change:行级别增删改事件,commit:事务提交 。遗憾的是,并不是所有的事件都有相应的钩子,例如数据库的模式变更,Sequence的取值变化,以及特殊的大对象操作。

#### 通常来说,这并不是一个大问题,因为用户感兴趣的往往只是表记录而不是表结构的增删改。而且,如果使用诸如JSON,Avro等灵活格式作为解码目标格式,即使表结构发生变化,也不会有什么大问题。

#### 但是尝试从目前的变更事件流生成完备的UNDO Log是不可能的,因为目前模式的变更DDL并不会记录在逻辑解码的输出中。好消息是未来会有越来越多的钩子与支持,因此这个问题是可解的。

### 同步提交

#### 需要注意的一点是,有一些输出插件会无视Begin与Commit消息。这两条消息本身也是数据库变更日志的一部分,如果输出插件忽略了这些消息,那么CDC客户端在汇报消费进度时就可能会出现偏差(落后一条消息的偏移量)。在一些边界条件下可能会触发一些问题:例如写入极少的数据库启用同步提交时,主库迟迟等不到从库确认最后的Commit消息而卡住)

### 故障切换

#### 理想很美好,现实很骨感。当一切正常时,CDC工作流工作的很好。但当数据库出现故障,或者出现故障转移时,事情就变得比较棘手了。

#### 恰好一次保证

#### 另外一个使用PostgreSQL CDC的问题是消息队列中经典的恰好一次问题。

#### PostgreSQL的逻辑复制实际上提供的是至少一次保证,因为消费者偏移量的值会在检查点的时候保存。如果PostgreSQL主库宕机,那么重新发送变更事件的起点,不一定恰好等于上次订阅者已经消费的位置。因此有可能会发送重复的消息。

#### 解决方法是:逻辑复制的消费者也需要记录自己的消费者偏移量,以便跳过重复的消息,实现真正的恰好一次 消息传达保证。这并不是一个真正的问题,只是任何试图自行实现CDC客户端的人都应当注意这一点。

### Failover Slot

#### 对目前PostgreSQL的CDC来说,Failover Slot是最大的难点与痛点。逻辑复制依赖复制槽,因为复制槽持有着消费者的状态,记录着消费者的消费进度,因而数据库不会将消费者还没处理的消息清理掉。

#### 但以目前的实现而言,复制槽只能用在主库上,且复制槽本身并不会被复制到从库上。因此当主库进行Failover时,消费者偏移量就会丢失。如果在新的主库承接任何写入之前没有重新建好逻辑复制槽,就有可能会丢失一些数据。对于非常严格的场景,使用这个功能时仍然需要谨慎。

#### 这个问题计划将于下一个大版本(13)解决,Failover Slot的Patch计划于版本13(2020)年合入主线版本。

#### 在那之前,如果希望在生产中使用CDC,那么务必要针对故障切换进行充分地测试。例如使用CDC的情况下,Failover的操作就需要有所变更:核心思想是运维与DBA必须手工完成复制槽的复制工作。在Failover前可以在原主库上启用同步提交,暂停写入流量并在新主库上使用脚本复制复制原主库的槽,并在新主库上创建同样的复制槽,从而手工完成复制槽的Failover。对于紧急故障切换,即原主库无法访问,需要立即切换的情况,也可以在事后使用PITR重新将缺失的变更恢复出来。

#### 小结一下:CDC的功能机制已经达到了生产应用的要求,但可靠性的机制还略有欠缺,这个问题可以等待下一个主线版本,或通过审慎地手工操作解决,当然激进的用户也可以自行拉取该补丁提前尝鲜。

### 新书推荐

#### 《PostgreSQL指南:内幕探索》出版啦,该书为《The Internals of PostgreSQL for database administrators and system developers》的中文版,作者为日本 PostgreSQL 数据库专家SUZUKI。该书对数据库的集群、架构、查询处理、外部表、并发控制、垃圾回收、 堆表与索引存储结构、缓冲区管理、预写式日志、时间点恢复、流复制等功能等原理进行了深 入浅出的剖析。京东购买链接:请点击文章底部“阅读原文”:https://item.jd.com/12527505.html

![CENTER_PostgreSQL_Community]( /images/news/2019/20190617_2006_图片2.png)

![CENTER_PostgreSQL_Community](/images/news/2016/pg_bot_banner.jpg)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值