--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)