实时计算

429 阅读11分钟

1. PostgreSQL 事务日志WAL

【参考链接】:www.postgres.cn/v2/news/vie…

【参考链接】:www.postgres.cn/v2/news/vie…

【参考链接】:www.postgres.cn/news/viewon…

1.1 WAL segment file

WAL segment file内部划分为N个page(Block),每个page大小为8192 Bytes即8K,每个WAL segment file第1个page的header在PG源码中相应的数据结构是XLogLongPageHeaderData,后续其他page的header对应的数据结构是XLogPageHeaderData。在一个page中,page header之后是N个XLOG Record。

1.2 XLOG Record

XLOG Record由两部分组成,第一部分是XLOG Record的头部信息,大小固定(24 Bytes),对应的结构体是XLogRecord;第二部分是XLOG Record data。 XLOG Record的整体布局如下:

头部数据(固定大小的XLogRecord结构体)
XLogRecordBlockHeader 结构体
XLogRecordBlockHeader 结构体
...
XLogRecordDataHeader[Short|Long] 结构体
block data
block data
...
main data

XLOG Record按存储的数据内容来划分,大体可以分为三类:

  • Record for backup block:存储full-write-page的block,这种类型Record是为了解决page部分写的问题。在checkpoint完成后第一次修改数据page,在记录此变更写入事务日志文件时整页写入(需设置相应的初始化参数,默认为打开);
  • Record for tuple data block:存储page中的tuple变更,使用这种类型的Record记录;
  • Record for Checkpoint:在checkpoint发生时,在事务日志文件中记录checkpoint信息(其中包括Redo point)。

1.3 XLOG Record data

其中XLOG Record data是存储实际数据的地方,由以下几部分组成:

  • 0..N个XLogRecordBlockHeader,每一个XLogRecordBlockHeader对应一个block data; XLogRecordDataHeader[Short|Long],如数据大小<256 Bytes,则使用Short格式,否则使用Long格式;
  • block data:full-write-page data和tuple data。对于full-write-page data,如启用了压缩,则数据压缩存储,压缩后该page相关的元数据存储在XLogRecordBlockCompressHeader中;
  • main data: /checkpoint等日志数据. 以INSERT数据为例,在插入数据时的XLOG Record data内部结构如下图所示: 在内存中,WAL Record通过XLogRecData结构体进行组织,形成一个链表。 插入语句的XLOG Record,rdata由4部分组成:
  • 第一部分:XLogRecord + XLogRecordBlockHeader + XLogRecordDataHeaderShort,共46(24 + 20 + 2)个字节。
  • 第二部分:xl_heap_header,5个字节
  • 第三部分:tuple data,20个字节
  • 第四部分:xl_heap_insert,3个字节
第一部分:
(gdb) p *rdata 
$22 = {next = 0x244f2c0, data = 0x244f4c0 "J", len = 46} 
(gdb) p *(XLogRecord *)rdata->data --> XLogRecord,24个字节
$27 = {xl_tot_len = 74, xl_xid = 2268, xl_prev = 5514538616, xl_info = 0 '\000', xl_rmid = 10 '\n', xl_crc = 1158677949}
(gdb) p *(XLogRecordBlockHeader *)(0x244f4c0+24) --> XLogRecordBlockHeader,20个字节
$29 = {id = 0 '\000', fork_flags = 32 ' ', data_length = 25}
(gdb) x/2bx (0x244f4c0+44) --> XLogRecordDataHeaderShort,2个字节
0x244f4ec:  0xff    0x03
第二部分:
(gdb) p *rdata->next
$23 = {next = 0x244f2d8, data = 0x7ffebea9d830 "\004", len = 5}
(gdb) p *(xl_heap_header *)rdata->next->data
$32 = {t_infomask2 = 4, t_infomask = 2050, t_hoff = 24 '\030'}
第三部分:
(gdb) p *rdata->next->next
$24 = {next = 0x244f2a8, data = 0x24e6a2f "", len = 20}
(gdb) x/20bc  0x24e6a2f
0x24e6a2f:  0 '\000'    8 '\b'  0 '\000'    0 '\000'    0 '\000'    11 '\v' 67 'C'  50 '2'
0x24e6a37:  45 '-'  56 '8'  11 '\v' 67 'C'  51 '3'  45 '-'  56 '8'  11 '\v'
0x24e6a3f:  67 'C'  52 '4'  45 '-'  56 '8'
(gdb) 
第四部分:
(gdb) p *rdata->next->next->next
$25 = {next = 0x0, data = 0x7ffebea9d840 "\b", len = 3}
(gdb) 
(gdb) p *(xl_heap_insert *)rdata->next->next->next->data
$33 = {offnum = 8, flags = 0 '\000'}

2. decode方式

解码格式:

  • Protobuf:一种平台无关、语言无关、可扩展且轻便高效的序列化数据结构的协议,可以用于网络通信和数据存储
  • Avro:Avro是Hadoop的一个数据序列化系统
  • SQL
  • JSON

2.1 CDC(Changing Data Capture)

【参考链接】www.postgres.cn/news/viewon… 实施CDC,数据库至少需要提供以下功能:

  • 1.获取数据库的变更日志(WAL),并解码成逻辑上的事件(对表的增删改而不是数据库的内部表示)
  • 2.获取数据库的"一致性快照",从而订阅者可以从任意一个一致性状态开始订阅而不是数据库创建伊始。
  • 3.保存消费者偏移量,以便跟踪订阅者的消费进度,及时清理回收不用的变更日志以免撑爆磁盘。 我们会发现,PostgreSQL在实现逻辑复制的同时,已经提供了一切CDC所需要的基础设施:

•逻辑解码(Logical Decoding),用于从WAL日志中解析逻辑变更事件

•复制协议(Replication Protocol):提供了消费者实时订阅(甚至同步订阅)数据库变更的机制

•快照导出(export snapshot):允许导出数据库的一致性快照(pgexportsnapshot)

•复制槽(Replication Slot),用于保存消费者偏移量,跟踪订阅者进度

在PostgreSQL上实施CDC最为直观优雅的方式,就是按照PostgreSQL的复制协议编写一个"逻辑从库" ,从数据库中实时地,流式地接受逻辑解码后的变更事件,完成自己定义的处理逻辑,并及时向数据库汇报自己的消息消费进度。就像使用Kafka一样。在这里CDC客户端可以将自己伪装成一个PostgreSQL的从库,从而不断地实时从PostgreSQL主库中接收逻辑解码后的变更内容。同时CDC客户端还可以通过PostgreSQL提供的复制槽(Replication Slot)机制来保存自己的消费者偏移量,即消费进度,实现类似消息队列一至少次的保证,保证不错过变更数据。(客户端自己记录消费者偏移量跳过重复记录,即可实现"恰好一次 "的保证 )

想要解读原始的二进制WAL日志,不仅仅需要WAL结构相关的知识,还需要系统目录(System Catalog),即元数据。没有元数据就无从得知用户可能感兴趣的模式名,表名,列名,只能解析出来的一系列数据库自己才能看懂的oid。

只有成功提交的事务才会产生逻辑解码变更事件。也就是说用户不用担心收到并处理了很多行变更消息之后,最后发现事务回滚了,还需要担心怎么通知消费者去回滚变更。

PostgreSQL的逻辑解码是这样工作的,每当特定的事件发生(表的Truncate,行级别的增删改,事务开始与提交),PostgreSQL都会调用一系列的钩子函数。所谓的逻辑解码输出插件(Logical Decoding Output Plugin),就是这样一组回调函数的集合。它们接受二进制内部表示的变更事件作为输入,查阅一些系统目录,将二进制数据翻译成为用户感兴趣的结果。

除了PostgreSQL自带的"用于测试"的逻辑解码插件:test_decoding 之外,还有很多现成的输出插件,例如:

•JSON格式输出插件:wal2json

•SQL格式输出插件:decoder_raw

•Protobuf输出插件:decoderbufs

当然还有PostgreSQL自带逻辑复制所使用的解码插件:pgoutput。

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

2.2 局限性

2.2.1 完备性

就目前而言,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并不会记录在逻辑解码的输出中。好消息是未来会有越来越多的钩子与支持,因此这个问题是可解的。

2.2.2 同步提交

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

2.2.3 故障切换

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

2.2.4 恰好一次保保障

另外一个使用PostgreSQL CDC的问题是消息队列中经典的恰好一次问题。 PostgreSQL的逻辑复制实际上提供的是至少一次保证,因为消费者偏移量的值会在检查点的时候保存。如果PostgreSQL主库宕机,那么重新发送变更事件的起点,不一定恰好等于上次订阅者已经消费的位置。因此有可能会发送重复的消息。 解决方法是:逻辑复制的消费者也需要记录自己的消费者偏移量,以便跳过重复的消息,实现真正的恰好一次 消息传达保证。这并不是一个真正的问题,只是任何试图自行实现CDC客户端的人都应当注意这一点。

2.2.5 Failover Slot

对目前PostgreSQL的CDC来说,Failover Slot是最大的难点与痛点。逻辑复制依赖复制槽,因为复制槽持有着消费者的状态,记录着消费者的消费进度,因而数据库不会将消费者还没处理的消息清理掉。 但以目前的实现而言,复制槽只能用在主库上,且复制槽本身并不会被复制到从库上。因此当主库进行Failover时,消费者偏移量就会丢失。如果在新的主库承接任何写入之前没有重新建好逻辑复制槽,就有可能会丢失一些数据。对于非常严格的场景,使用这个功能时仍然需要谨慎。

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

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

2.3 Go语言编写一个简单的CDC客户端

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

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()                 // 主消息循环
}

2. 实时计算flink版(阿里云)

2.1 源表

  • 日志服务
  • 全量maxcompute
  • Postgres CDC即Postgres的流式源表

2.2 目标表

  • 日志服务
  • maxcompute
  • postgresql

3. flink

3.1 flink对rocketmq的支持

github已经有开源:github.com/apache/rock…

4. spark

4.1 spark对rocketmq的支持

github已经有开源:github.com/apache/rock…

5. spark和flink对比

Spark的技术理念是基于批来模拟流的计算,也就是更擅长有延迟的离线计算

Flink则完全相反,它采用的是基于流计算来模拟批计算。