Go:基于Redis Stream构建可扩展事件流


事件流架构是独立扩展某些组件的好方法。当提到事件流工具时,这方面的主流似乎是Kafka,但也有一些其他实用的流工具,比如NATS流/Jetstream, NSQ或Redis流。

今天我将写一些关于Redis Stream基本用法的笔记,我们将构建一个发布者和一个消费者的例子,并使用运行在docker上的Redis服务器做本地测试。发布者将使用XADD命令发送一些消息到Redis的持久化消息流中,消费者将使用XREADGROUP读取流。

当事件在给定流中发布时,通过使用XREADGROUP,可以让多个消费者执行相同的操作。这种机制使水平扩展消费者成为可能。当我们添加一个消费者时,它将自己注册到一个消费者组中,并且流中的的消息将均匀地发送到组中的不同消费者。

构建示例

假设我们构建了一个分布式系统,在这个系统中,将从外部接收一些”tickets“消息,当消息到达时,我们希望执行一些操作,例如解析消息的内容,调用API写信息到数据库。

本地Redis

开始之前,我们需要一个Redis服务,我们在本地使用docker镜像启动redis服务:

docker run --name localredis -d redis redis-server --appendonly yes

这里启动本地redis服务,appendonly参数的作用是设置将redis数据持久化。当有数据变化,就会写副本到一个文件。如果redis服务重启,消息数据会恢复。

Go发布者

让我们开始编写代码,为发布者创建一个新的go module。这个发布者将简单地使用XADD命令向Redis流发送一些消息。
初始化一个Go module,创建一个main.go文件,并添加redis调用库:

go mod init publisher
touch main.go
go get github.com/go-redis/redis

在main.go文件中,我们将连接redis并使用ping来检查是否连接成功:

package main
import (
 "fmt"
 "log"
"github.com/go-redis/redis"
)
func main() {
  log.Println("Publisher started")
  redisClient := redis.NewClient(&redis.Options{
    Addr: fmt.Sprintf("%s:%s", "127.0.0.1", "6379"),
  })
  _, err := redisClient.Ping().Result()
  if err != nil {
    log.Fatal("Unable to connect to Redis", err)
  }
  log.Println("Connected to Redis server")
}

当执行go run main.go,如果一切正常的话将看到如下日志:

2021/08/25 20:54:21 Publisher started
2021/08/25 20:54:21 Connected to Redis server

现在我们创建一个“tickets”流然后添加一个消息:

func publishTicketReceivedEvent(client *redis.Client) error {
  log.Println("Publishing event to Redis")
  err := client.XAdd(&redis.XAddArgs{
    Stream:       "tickets",
    MaxLen:       0,
    MaxLenApprox: 0,
    ID:           "",
    Values: map[string]interface{}{
      "whatHappened": string("ticket received"),
      "ticketID":     int(rand.Intn(100000000)),
      "ticketData":   string("some ticket data"),
    },
  }).Err()
  return err
}

这里函数将发送一条“ticket received”消息并附带一个随机id和一些数据。在主函数中,我们将以上函数在循环中多次使用看看会发生什么?

for i := 0; i < 3000; i++ {
  err = publishTicketReceivedEvent(redisClient)
  if err != nil {
    log.Fatal(err)
  }
}

如果你执行main.go函数,将会打印3000多行日志:

...
2021/08/25 21:08:38 Publishing event to Redis
2021/08/25 21:08:38 Publishing event to Redis
2021/08/25 21:08:38 Publishing event to Redis

我们进入redis容器内看看,在redis服务中,打开redis-cli客户端,查看当前状态:

docker exec -it localredis /bin/bash
redis-cli
127.0.0.1:6379> XINFO STREAM tickets

XINFO是redis命令用于监控消息流或消费者组状态。这里我们将看到有3000条流数据:以下是第一条和最后一条数据:

1) "length"
 2) (integer) 3000
 3) "radix-tree-keys"
 4) (integer) 45
 5) "radix-tree-nodes"
 6) (integer) 111
 7) "last-generated-id"
 8) "1615061318123-0"
 9) "groups"
10) (integer) 0
11) "first-entry"
12) 1) "1615061313111-0"
    2) 1) "whatHappened"
       2) "ticket received"
       3) "ticketID"
       4) "98498081"
       5) "ticketData"
       6) "some ticket data"
13) "last-entry"
14) 1) "1615061318123-0"
    2) 1) "whatHappened"
       2) "ticket received"
       3) "ticketID"
       4) "39114354"
       5) "ticketData"
       6) "some ticket data"

Ok,我们已经在名为“tickets”流中发布了一些消息,现在我们来构建一个消费者去消费流数据:

Go消费者

和发布者类似,首先需要创建一个新的module:

go mod init consumer
touch main.go
go get github.com/go-redis/redis

在main.go要先连接redis服务:

package main
import (
  "fmt"
  "log
  "github.com/go-redis/redis"
)
func main() {
  log.Println("Consumer started")
  redisClient := redis.NewClient(&redis.Options{
    Addr: fmt.Sprintf("%s:%s", "127.0.0.1", "6379"),
  })
  _, err := redisClient.Ping().Result()
  if err != nil {
    log.Fatal("Unbale to connect to Redis", err)
  }
  log.Println("Connected to Redis server")
}

下面使用XGROUPCREATE来创建消费者组:

subject := "tickets"
consumersGroup := "tickets-consumer-group"
err = redisClient.XGroupCreate(subject, consumersGroup, "0").Err()
if err != nil {
log.Println(err)
}

现在可以使用XREADGROUP来监听流中消息,并使用一个唯一id将消费者注册到消费者组里:
为了生成唯一id,将使用xid库:

go get github.com/rs/xid

当接收到ticket消息时,将调用以下函数:

func handleNewTicket(ticketID string, ticketData string) error {
  log.Printf("Handling new ticket id : %s data %s\n", ticketID, ticketData)
  return nil
}

然后在main.go中创建一个无限循环,我们调用XREADGROUP并在>位置,表示从该组的第一个待处理消息开始,然后为每个ticket调用handNewTicket函数,并发送XACK命令到redis服务通知消息已经被消费。

uniqueID := xid.New().String()
for {
  entries, err := redisClient.XReadGroup(&redis.XReadGroupArgs{
    Group:    consumersGroup,
    Consumer: uniqueID,
    Streams:  []string{subject, ">"},
    Count:    2,
    Block:    0,
    NoAck:    false,
  }).Result()
  if err != nil {
    log.Fatal(err)
  }
for i := 0; i < len(entries[0].Messages); i++ {
  messageID := entries[0].Messages[i].ID
  values := entries[0].Messages[i].Values
  eventDescription := fmt.Sprintf("%v", values["whatHappened"])
  ticketID := fmt.Sprintf("%v", values["ticketID"])
  ticketData := fmt.Sprintf("%v", values["ticketData"])
  if eventDescription == "ticket received" {
    err := handleNewTicket(ticketID, ticketData)
    if err != nil {
      log.Fatal(err)
    }
    redisClient.XAck(subject, consumersGroup, messageID)
  }
}

如果程序执行正常,将看到3000行的日志:

...
2021/08/25 21:51:44 Handling new ticket id : 28377708 data some ticket data
2021/08/25 21:51:44 Handling new ticket id : 56451806 data some ticket data
2021/08/25 21:51:44 Handling new ticket id : 94132471 data some ticket data

您还应该注意到,该程序没有退出,仍然在侦听消息。这是因为我们使用BLOCK = 0参数调用XREADGROUP,这意味着程序执行将被阻塞无限长的时间,直到新消息到来。如果您打开第二个终端并再次运行发布者,会看到消息同时被发布和消费,这是一件好事。

在现实生活中,消费消息时,我们会对消息做比log.Println()更复杂的事情。我们可能调用外部API,写入数据库或将数据导出到S3桶中等……当我们给消费者增加一些延迟时,会发生什么?

假设我们仍然以同样的速度发布这3000条消息,但是我们在消费者中添加了一个time.Sleep(100 * time.Millisecond),这将耗时超过5分钟来消费这3000条消息…除非我们同时运行多个消费者。好消息是,我们已经为这种方法编写了所有代码。

如何扩展?

我们做一个快速实验。如果我们将time.Sleep(100 * time.Millisecond)添加到消费者的handNewTicket函数,将会如何?

func handleNewTicket(ticketID string, ticketData string) error {
  log.Printf("Handling new ticket id : %s data %s\n", ticketID, ticketData)
   time.Sleep(100 * time.Millisecond)
   return nil
}

在消费者中打开5个终端运行go run main.go:



然后在发布者运行go run main.go,你将看到tickets消息平均的发布到各个消费者,所以消费者以固定的速度同时运行。几秒钟后,所有消费者都应该同时停止。


是不是很厉害?

只需几行代码,我们就拥有了一个带有事件发布者、事件流和任意数量消费者的分布式系统。这种解决方案可以很容易地在kubernetes集群上扩展,并允许我们处理潜在的巨大数据负载。
这个示例项目的源代码可以在这里找到:https://github.com/gmrdn/redis-streams-go
想了解更多关于Redis Streams的信息,查看 https://redis.io/topics/streams-intro

备注:文章部分结果在译者本地运行测试。原文

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容