使用Go实现可用select监听的队列

1. 背景与选型

《基于Redis Cluster的分布式锁实现以互斥方式操作共享资源》一文一样,今天要说的Go队列方案也是有一定项目背景的。

5G消息方兴未艾[1]!前一段时间从事了一段时间5G消息网关的研发,但凡涉及类似消息业务的网关,我们一般都离不开队列这种数据结构的支持。这个5G消息网关项目采用的是Go技术栈开发,那么我们应该如何为它选择一个与业务模型匹配且性能不差的实现呢?

如今一提到消息队列,大家第一个想到的一定是kafka[2],kafka的确是一款优秀的分布式队列中间件,但对于我们这个系统来说,它有些“重”,部署和运维都有门槛,并且项目组里也没有能很好维护它的专家,毕竟“可控”是技术选择的一个重要因素。除此之外,我们更想在Go技术栈的生态中挑选,但kafka是Java实现的。

Go圈里在性能上能与kafka“掰掰手腕”的成熟选手不多,nats[3]以及其主持持久化的子项目nats-streaming[4]算是其中两个。不过nats的消息送达模型是:At-least-once-delivery,即至少送一次(而没有kafka的精确送一次的送达模型)。一旦消费者性能下降,给nats server返回的应答超时,nats就会做消息的重发处理:即将消息重新加入到队列中。这与我们的业务模型不符,即便nats提供了发送超时的设定,但我们还是无法给出适当的timeout时间。Go圈里的另一个高性能分布式消息队列nsq[5]采用的也是“至少送一次”的消息送达模型[6],因此也无法满足我们的业务需求。

我们的业务决定了我们需要的队列要支持“多生产者多消费者”模型,Go语言内置的channel也是一个不错的候选。经过多个Go版本的打磨和优化,channel的send和recv操作性能在一定数量goroutine的情况下已经可以满足很多业务场景的需求了。但channel还是不完全满足我们的业务需求。我们的系统要求尽可能将来自客户端的消息接收下来并缓存在队列中。即便下游发送性能变慢,也要将客户消息先收下来,而不是拒收或延迟响应。而channel本质上是一个具有“静态大小”的队列并且Go的channel操作语义会在channel buffer满的情况下阻塞对channel的继续send,这就与我们的场景要求有背离,即便我们使用buffered channel,我们也很难选择一个合适的len值,并且一旦buffer满,它与unbuffered channel行为无异。

这样一来,我们便选择自己实现一个简单的、高性能的满足业务要求的队列,并且最好能像channel那样可以被select监听到数据ready,而不是给消费者带去“心智负担” :消费者采用轮询的方式查看队列中是否有数据。

2. 设计与实现方案

要设计和实现这样一个队列结构,我们需要解决三个问题:

  • 实现队列这个数据结构;

  • 实现多goroutine并发访问队列时对消费者和生产者的协调;

  • 解决消费者使用select监听队列的问题。

我们逐一来看!

1) 基础队列结构实现来自一个未被Go项目采纳的技术提案

队列是最基础的数据结构,实现一个“先进先出(FIFO)”的练手queue十分容易,但实现一份能加入标准库、资源占用小且性能良好的queue并不容易。Christian Petrin[7]在2018年10月份曾发起一份关于Go标准库加入queue实现的技术提案[8],提案对基于array和链表的多种queue实现[9]进行详细的比对,并最终给出结论:impl7[10]是最为适宜和有竞争力的标准库queue的候选者。虽然该技术提案目前尚未得到accept,但impl7足可以作为我们的内存队列的基础实现。

2) 为impl7添加并发支持

在性能敏感的领域,我们可以直接使用sync包提供的诸多同步原语来实现goroutine并发安全访问,这里也不例外,一个最简单的让impl7队列实现支持并发的方法就是使用sync.Mutex实现对队列的互斥访问。由于impl7并未作为一个独立的repo存在,我们将其代码copy到我们的实现中(queueimpl7.go),并将其包名由queueimpl7改名为queue:

// github.com/bigwhite/experiments/blob/master/queue-with-select/safe-queue1/queueimpl7.go

// Package queueimpl7 implements an unbounded, dynamically growing FIFO queue.
// Internally, queue store the values in fixed sized slices that are linked using
// a singly linked list.
// This implementation tests the queue performance when performing lazy creation of
// the internal slice as well as starting with a 1 sized slice, allowing it to grow
// up to 16 by using the builtin append function. Subsequent slices are created with
// 128 fixed size.
package queue

// Keeping below as var so it is possible to run the slice size bench tests with no coding changes.
var (
        // firstSliceSize holds the size of the first slice.
        firstSliceSize = 1

        // maxFirstSliceSize holds the maximum size of the first slice.
        maxFirstSliceSize = 16

        // maxInternalSliceSize holds the maximum size of each internal slice.
        maxInternalSliceSize = 128
)
... ...

下面我们就来为以queueimpl7为底层实现的queue增加并发访问支持:

// github.com/bigwhite/experiments/blob/master/queue-with-select/safe-queue1/safe-queue.go

package queue

import (
 "sync"
)

type SafeQueue struct {
 q *Queueimpl7
 sync.Mutex
}

func NewSafe() *SafeQueue {
 sq := &SafeQueue{
  q: New(),
 }

 return sq
}

func (s *SafeQueue) Len() int {
 s.Lock()
 n := s.q.Len()
 s.Unlock()
 return n
}

func (s *SafeQueue) Push(v interface{}) {
 s.Lock()
 defer s.Unlock()

 s.q.Push(v)
}

func (s *SafeQueue) Pop() (interface{}, bool) {
 s.Lock()
 defer s.Unlock()
 return s.q.Pop()
}

func (s *SafeQueue) Front() (interface{}, bool) {
 s.Lock()
 defer s.Unlock()
 return s.q.Front()
}

我们建立一个新结构体SafeQueue,用于表示支持并发访问的Queue,该结构只是在queueimpl7的Queue的基础上嵌入了sync.Mutex。

3) 支持select监听

到这里支持并发的queue虽然实现了,但在使用上还存在一些问题,尤其是对消费者而言,它只能通过轮询的方式来检查队列中是否有消息。而Go并发范式中,select扮演着重要角色,如果能让SafeQueue像普通channel那样能支持select监听,那么消费者在使用时的心智负担将大大降低。于是我们得到了下面第二版的SafeQueue实现:

// github.com/bigwhite/experiments/blob/master/queue-with-select/safe-queue2/safe-queue.go

package queue

import (
 "sync"
 "time"
)

const (
 signalInterval = 200
 signalChanSize = 10
)

type SafeQueue struct {
 q *Queueimpl7
 sync.Mutex
 C chan struct{}
}

func NewSafe() *SafeQueue {
 sq := &SafeQueue{
  q: New(),
  C: make(chan struct{}, signalChanSize),
 }

 go func() {
  ticker := time.NewTicker(time.Millisecond * signalInterval)
  defer ticker.Stop()
  for {
   select {
   case <-ticker.C:
    if sq.q.Len() > 0 {
     // send signal to indicate there are message waiting to be handled
     select {
     case sq.C <- struct{}{}:
      //signaled
     default:
      // not block this goroutine
     }
    }
   }
  }

 }()

 return sq
}

func (s *SafeQueue) Len() int {
 s.Lock()
 n := s.q.Len()
 s.Unlock()
 return n
}

func (s *SafeQueue) Push(v interface{}) {
 s.Lock()
 defer s.Unlock()

 s.q.Push(v)
}

func (s *SafeQueue) Pop() (interface{}, bool) {
 s.Lock()
 defer s.Unlock()
 return s.q.Pop()
}

func (s *SafeQueue) Front() (interface{}, bool) {
 s.Lock()
 defer s.Unlock()
 return s.q.Front()
}

从上面代码看到,每个SafeQueue的实例会伴随一个goroutine,该goroutine会定期(signalInterval)扫描其所绑定的队列实例中当前消息数,如果大于0,则会向SafeQueue结构中新增的channel发送一条数据,作为一个“事件”。SafeQueue的消费者则可以通过select来监听该channel,待收到“事件”后调用SafeQueue的Pop方法获取队列数据。下面是一个SafeQueue的简单使用示例:

// github.com/bigwhite/experiments/blob/master/queue-with-select/main.go
package main

import (
 "fmt"
 "sync"
 "time"

 queue "github.com/bigwhite/safe-queue/safe-queue2"
)

func main() {
 var q = queue.NewSafe()
 var wg sync.WaitGroup

 wg.Add(2)
 // 生产者
 go func() {
  for i := 0; i < 1000; i++ {
   time.Sleep(time.Second)
   q.Push(i + 1)

  }
  wg.Done()
 }()

 // 消费者
 go func() {
 LOOP:
  for {
   select {
   case <-q.C:
    for {
     i, ok := q.Pop()
     if !ok {
      // no msg available
      continue LOOP
     }

     fmt.Printf("%d\n", i.(int))
    }
   }

  }

 }()

 wg.Wait()
}

从支持SafeQueue的原理可以看到,当有多个消费者时,只有一个消费者能得到“事件”并开始消费。如果队列消息较少,只有一个消费者可以启动消费,这个机制也不会导致“惊群”;当队列中有源源不断的消费产生时,与SafeQueue绑定的goroutine可能会连续发送“事件”,多个消费者都会收到事件并启动消费行为。在这样的实现下,建议消费者在收到“事件”后持续消费,直到Pop的第二个返回值返回false(代表队列为空),就像上面示例中的那样。

这个SafeQueue的性能“中规中矩”,比buffered channel略好(Go 1.16 darwin下跑的benchmark):

$go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/bigwhite/safe-queue/safe-queue2
cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz
BenchmarkParallelQueuePush-8              10687545        110.9 ns/op       32 B/op        1 allocs/op
BenchmarkParallelQueuePop-8               18185744         55.58 ns/op        0 B/op        0 allocs/op
BenchmarkParallelPushBufferredChan-8      10275184        127.1 ns/op       16 B/op        1 allocs/op
BenchmarkParallelPopBufferedChan-8        10168750        128.8 ns/op       16 B/op        1 allocs/op
BenchmarkParallelPushUnBufferredChan-8     3005150        414.9 ns/op       16 B/op        1 allocs/op
BenchmarkParallelPopUnBufferedChan-8       2987301        402.9 ns/op       16 B/op        1 allocs/op
PASS
ok   github.com/bigwhite/safe-queue/safe-queue2 11.209s

注:BenchmarkParallelQueuePop-8因为是读取空队列,所以没有分配内存,实际情况是会有内存分配的。另外并发goroutine的模拟差异可能导致有结果差异。

3. 扩展与问题

上面实现的SafeQueue是一个纯内存队列,一旦程序停止/重启,未处理的消息都将消失。一个传统的解决方法是采用wal(write ahead log)在推队列之前将消息持久化后写入文件,在消息出队列后将消息状态也写入wal文件中。这样重启程序时,从wal中恢复消息到各个队列即可。我们也可以将wal封装到SafeQueue的实现中,在SafeQueue的Push和Pop时自动操作wal,并对SafeQueue的使用者透明,不过这里有一个前提,那就是队列消息的可序列化(比如使用protobuf)。另外SafeQueue还需提供一个对外的wal消息恢复接口。大家可以考虑一下如何实现这些。

另外在上述的SafeQueue实现中,我们在给SafeQueue增加select监听时引入两个const:

const (
 signalInterval = 200
 signalChanSize = 10
)

对于SafeQueue的使用者而言,这两个默认值可能不满足需求,那么我们可以将SafeQueue的New方法做一些改造,采用“功能选项(functional option)”的模式[11]为用户提供设置这两个值的可选接口,这个“作业”也留给大家了^_^。

本文所有示例代码可以在这里[12]下载 - https://github.com/bigwhite/experiments/tree/master/queue-with-select。


“Gopher部落”知识星球正式转正(从试运营星球变成了正式星球)!“gopher部落”旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!部落目前虽小,但持续力很强。在2021年上半年,部落将策划两个专题系列分享,并且是部落独享哦:

  • Go技术书籍的书摘和读书体会系列

  • Go与eBPF系列

欢迎大家加入!

Go技术专栏“改善Go语⾔编程质量的50个有效实践[13]”正在慕课网火热热销中!本专栏主要满足广大gopher关于Go语言进阶的需求,围绕如何写出地道且高质量Go代码给出50条有效实践建议,上线后收到一致好评!欢迎大家订 阅!


我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用[14]”在慕课网热卖>中,欢迎小伙伴们订阅学习!

我爱发短信 [15] : 企业级短信平台定制开发专家 https://51smspush.com/。 smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。 2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址[16]:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 - https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx

  • 微信公众号:iamtonybai

  • 博客:tonybai.com

  • github: https://github.com/bigwhite

  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

参考资料

[1] 

5G消息方兴未艾: https://51smspush.com

[2] 

kafka: https://kafka.apache.org/

[3] 

nats: https://github.com/nats-io/nats-server

[4] 

nats-streaming: https://github.com/nats-io/nats-streaming-server

[5] 

nsq: https://github.com/nsqio/nsq

[6] 

“至少送一次”的消息送达模型: https://nsq.io/overview/features_and_guarantees.html

[7] 

Christian Petrin: https://github.com/christianrpetrin

[8] 

关于Go标准库加入queue实现的技术提案: https://github.com/golang/proposal/blob/master/design/27935-unbounded-queue-package.md

[9] 

多种queue实现: https://github.com/christianrpetrin/queue-tests

[10] 

impl7: https://github.com/christianrpetrin/queue-tests/tree/master/queueimpl7/queueimpl7.go

[11] 

“功能选项(functional option)”的模式: https://www.imooc.com/read/87/article/2424

[12] 

这里: https://github.com/bigwhite/experiments/tree/master/queue-with-select

[13] 

改善Go语⾔编程质量的50个有效实践: https://www.imooc.com/read/87

[14] 

Kubernetes实战:高可用集群搭建、配置、运维与应用: https://coding.imooc.com/class/284.html

[15] 

我爱发短信: https://51smspush.com/

[16] 

链接地址: https://m.do.co/c/bff6eed92687

<think>嗯,用户想了解Canal如何实现binlog的监听和近实时同步。首先,我需要回忆一下Canal的基本原理。Canal是阿里巴巴开源的MySQL数据库增量日志解析工具,对吧?它通过模拟MySQL Slave的交互协议,把自己伪装成一个Slave,然后向Master发送dump请求。这样Master就会把binlog推送给Canal,Canal解析这些binlog后,可以将变更事件转发给下游,比如消息队列或者直接写入其他存储系统,实现数据同步。 接下来,用户可能想知道具体的实现步骤。可能需要先配置MySQL,开启binlog,并且设置binlog格式为ROW模式,因为ROW模式会记录每一行数据的变更细节,这对于数据同步来说是最详细的。然后,Canal的服务器端需要配置连接MySQL的信息,比如地址、端口、用户名、密码,以及要监听的数据库和表。之后启动Canal服务器,它会连接到MySQL,开始拉取binlog事件。 然后客户端部分,用户需要编写一个客户端来订阅Canal服务器提供的变更数据。客户端接收到数据后,可以进行处理,比如写入到Elasticsearch或者另一个数据库,实现实时同步。这里需要注意数据的顺序性和一致性,可能需要处理网络中断或者消费失败的情况,比如使用消息队列来保证可靠传输。 用户可能还想知道Canal的应用场景,比如数据同步到缓存、ES,或者用于ETL、数据迁移。另外,Canal的高可用和扩展性,比如多个Canal实例监控同一个MySQL,通过Zookeeper协调,避免单点故障。 可能的疑问点包括:binlog的格式为什么必须是ROW?因为STATEMENT或MIXED格式可能无法正确记录所有变更,特别是涉及不确定函数的时候。另外,权限问题,Canal需要访问MySQL的binlog,所以需要MySQL用户有REPLICATION权限。此外,网络延迟和性能影响,Canal作为伪Slave,对MySQL的影响应该和正常的Slave一样,但需要监控资源使用情况。 还有,近实时同步的延迟可能由哪些因素造成?比如网络带宽、Canal处理速度、下游消费速度等。需要优化解析和传输的效率,可能采用并行处理,但要注意顺序问题。 最后,可能还需要比较Canal和其他工具,比如Debezium,不同点在于Canal主要针对MySQL,而Debezium支持多种数据库,但Canal在MySQL生态中有更好的集成。C</think>anal 是阿里巴巴开源的一款基于 MySQL 数据库 **增量日志(binlog)** 的增量订阅与消费工具,主要用于实现 MySQL 数据库的 **近实时数据同步**。其核心原理是通过监听并解析 MySQL 的 binlog,将数据变更事件(如 INSERT、UPDATE、DELETE)捕获并传递给下游系统,从而支持数据同步、缓存更新、实时分析等场景。 --- ### **核心实现原理** 1. **MySQL binlog 机制** MySQL 的 binlog 是二进制日志,记录了所有对数据库的修改操作(DDL 和 DML)。Canal 通过模拟 MySQL Slave 的交互协议,伪装成从库(Slave),向主库(Master)发送 dump 请求,实时获取 binlog 流。 2. **Canal 的工作流程** - **伪装为 Slave**:Canal 向 MySQL 注册为 Slave,指定从哪个 binlog 文件和位置开始同步。 - **拉取 binlog**:MySQL 主库将 binlog 事件推送给 Canal。 - **解析与转换**:Canal 解析 binlog 的二进制内容,将其转换为结构化数据(如 JSON 或 Protobuf 格式)。 - **传递到下游**:解析后的数据可通过消息队列(如 Kafka/RocketMQ)或直接推送到客户端,供其他系统消费。 3. **支持多种消费模式** - **直接客户端订阅**:通过 Canal 提供的 Java/Go 客户端 API 直接消费数据变更事件。 - **消息队列中转**:将数据变更发布到 Kafka 等消息队列,解耦生产与消费。 --- ### **核心组件** 1. **Canal Server** 负责连接 MySQL、拉取 binlog、解析和存储数据变更事件,支持 HA 部署(基于 Zookeeper 协调多实例)。 2. **Canal Client** 订阅 Canal Server 的事件,处理数据(如写入 Elasticsearch、HBase 或另一个数据库)。 3. **Meta Manager** 存储消费进度(binlog 的 position),确保故障恢复后从断点继续同步。 --- ### **使用步骤** 1. **MySQL 配置** - 开启 binlog:设置 `log_bin=ON`,格式为 `ROW` 模式(`binlog_format=ROW`)。 - 创建 Canal 专用账号并授权: ```sql CREATE USER 'canal'@'%' IDENTIFIED BY 'canal_password'; GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%'; ``` 2. **部署 Canal Server** - 配置 `canal.properties`(指定 Zookeeper 地址、存储模式等)。 - 配置 `instance.properties`(定义 MySQL 连接信息、监听的数据库和表)。 3. **开发客户端** - 通过 Canal 客户端 API 订阅数据变更事件: ```java CanalConnector connector = CanalConnectors.newClusterConnector( "zookeeper_host:2181", "example", "canal_user", "canal_password"); connector.connect(); connector.subscribe(".*\\..*"); // 订阅所有库表 while (true) { Message message = connector.getWithoutAck(100); // 批量获取 List<CanalEntry.Entry> entries = message.getEntries(); // 处理 entries(解析为 SQL 或结构化的 RowData) connector.ack(message.getId()); // 确认消费 } ``` 4. **下游消费** 将解析后的数据写入 Elasticsearch、Kafka 或目标数据库,实现实时同步。 --- ### **关键特性** - **近实时性**:延迟通常在秒级,取决于网络和系统负载。 - **高吞吐**:支持批量拉取和异步处理。 - **数据过滤**:可配置仅同步特定库表。 - **高可用**:通过 Zookeeper 实现多节点 HA。 - **多语言支持**:提供 JavaGo 等客户端 SDK。 --- ### **典型应用场景** 1. **缓存更新** 数据库变更后,自动更新 Redis 或 Memcached 中的缓存。 2. **搜索索引同步** 实时将数据同步到 Elasticsearch 或 Solr,保证搜索结果的及时性。 3. **数据仓库/湖** 将 OLTP 数据实时导入 OLAP 系统(如 Hive、ClickHouse)。 4. **多活架构** 跨数据中心的数据库双向同步。 5. **业务解耦** 通过消息队列分发数据变更事件,触发下游业务逻辑。 --- ### **注意事项** - **ROW 模式必须启用**:binlog 格式需为 `ROW`,否则无法捕获完整的数据变更。 - **主键约束**:表必须有主键,否则 DELETE/UPDATE 操作可能导致同步数据不准确。 - **网络与资源**:需监控 Canal 的吞吐和延迟,避免因同步积压影响 MySQL 主库性能。 - **数据一致性**:需处理重复消费和顺序性问题(如通过消息队列的幂等性保证)。 --- ### **对比其他工具** | 工具 | 特点 | |------------|----------------------------------------------------------------------| | **Canal** | 专注 MySQL,轻量级,与阿里生态集成好(如 RocketMQ、DataWorks)。 | | **Debezium** | 支持多数据库(MySQL、PostgreSQL 等),基于 Kafka Connect 生态。 | | **Maxwell** | 简单易用,直接输出 JSON 到 Kafka,适合轻量级场景。 | 如果需要深度集成 MySQL 或低延迟同步,Canal 是理想选择;若需多数据库支持或 Kafka 生态集成,可考虑 Debezium。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值