我们的客户有一个共同的问题,就是将尖锐的流量流向他们的客户端的事件。例如,设想一个需要流式传输加密货币交易细节的服务器。每一秒钟发生的交易数量可能是波动的,有时可能什么都不发生,有时可能是成千上万。
为了确保可靠的通信,重要的是要对向订阅的客户发送多少消息进行速率限制。如果在一段时间内产生的消息数量激增,超过可接受的水平,我们如何确保能够处理,并及时向客户提供这些消息?
事件流本质上是一种Pub/Sub的实现,它有一个单向的数据流。也就是说,数据从发布者流向消费者,而消费者自己不需要发布任何东西。这意味着当涉及到限制所有潜在的发布者时,我们有更多的控制权,因为在大多数事件流场景中,你会让发布者直接在你的控制下成为服务器。
通过Redis和Ably的组合,用Go编写,我们将寻求创建以下可靠的事件流结构:
- 我们将有一个交易服务器,它将有突发的消息发送至我们的客户。
- 我们将有一个Redis容器,它将保存来自我们交易服务器的消息。
- 我们将有一个**发布服务器,它跟踪当前的消息发送率,并在可以发布到Ably的时候从Redis中消费。
- 然后客户被订阅到发布者发布的Ably频道,并显示信息。
我们的核心功能(服务器、Redis、发布者)将全部包含在一个Kubernetes集群中。
堆栈
在这之前,我们先来看看我们要使用的工具是什么,以及为什么要使用它们。
高朗
Go是一种高效、可靠的编程语言,其核心是基于可扩展性和并发性的前提。Go有效地实现了Pub/Sub通信,用自己的通道在goroutine之间进行通信,多个goroutine可以同时发布和订阅这些通道。Go的这一核心设计使其成为最适合将这种Pub/Sub功能扩展到网络Pub/Sub通信的语言之一:
*想了解更多关于Golang和实时性的信息?我们已经为你准备好了。
-Golang中的Pub/Sub指南--
用Golang和WebSockets构建实时应用程序:客户端注意事项
*
-用Golang和AblyD对你的服务器进行细粒度的访问控制
Redis
Redis是目前最流行、功能最全面的内存数据结构存储之一。由于它是内存的,所以检索信息的速度非常快,而且它可以在大多数编程语言中使用。
在本教程中,我们将使用它作为一个消息队列,保存来自我们交易服务器的消息,直到我们的发布者服务器准备好处理这些消息。
Ably
Ably是一个pub/sub消息平台,它处理消息的整个分发过程,可扩展到数十亿用户。Ably的客户端库使用的主要协议是WebSockets,然而Ably本质上是协议不可知的,允许任何实时协议,如SSE或MQTT也能无缝工作。
我们将使用Ably将Redis实例持有的消息分发到我们想要的许多客户端。
Docker和Kubernetes
Docker是一个操作系统级的虚拟化工具,它允许人们创建图像,这是虚拟机在特定状态下的代表,是用Docker文件设置的。这是非常强大的,因为它允许将一个特定的虚拟机做成Docker Image,现在使用这个Docker Image的人将从完全相同的状态开始。一个Docker镜像的实例被称为Docker容器。
Kubernetes通过允许Docker容器被分组、动态扩展,使其变得更加强大,并在总体上使管理Docker容器的集合变得更加容易。
在本教程中,我们将有一个Docker容器用于交易服务器,用于Redis实例,还有三个容器用于Ably Publisher。我们将用Kubernetes管理这些容器,并演示如何根据需要轻松地扩大和缩小Ably发布器:

代表我们将要建立的东西的图示
开始
首先,让我们创建一个逻辑文件结构来容纳各种服务器、容器配置文件等。
在你的机器上创建一个文件夹,其中将包含这个项目。在这个文件夹中,创建两个文件夹。ably-publishing-server*,*和trading-server 。我们将把两种类型的服务器的代码放在各自的文件夹中:
my-redis-go-app
- ably-publishing-server
- trading-server
我们将从制作我们的交易服务器开始。在trading-server 文件夹中创建一个go.mod 文件,内容如下:
module go-redis-trading-server
go 1.16
require (
github.com/go-redis/redis v6.15.9+incompatible // indirect
)
这里我们指定要运行我们的交易服务器,我们将需要导入redis模块。
接下来,创建一个名为trading-server.go 的文件。这将包含我们的交易服务器的核心代码。我们想从我们的服务器中得到的东西是:
- 生成我们想要的交易数据,并将其发送给我们所有的客户。在本教程中,我们将模拟这些数据,但在真实的数据源中,它应该很容易替代。
- 将这些数据发送到Redis,Redis将保存这些数据,直到我们的发布服务器准备好将它们发送到Ably并流向我们的客户。
在trading-server.go 文件中,让我们添加我们的基本结构:
package main
import (
"log"
"os"
"fmt"
"sync"
"time"
"math/rand"
"strconv"
"github.com/go-redis/redis"
)
var wg = &sync.WaitGroup{}
func main() {
}
除了通常的导入和主函数之外,我们还指定了一个 等待组.我们将用它来阻止脚本的停止,直到我们明确要求它停止。
我们将使用环境变量来方便我们在部署服务器时进行配置,所以为了使设置更简单,让我们也创建一个函数,为我们获取一个环境变量,或者在没有指定的情况下使用一个默认值:
func getEnv(envName, valueDefault string) string {
value := os.Getenv(envName)
if value == "" {
return valueDefault
}
return value
}
在main 函数中,让我们使用go-redis 库连接到 Redis。创建一个函数,getRedis ,它将为我们处理这个问题:
func getRedis() *redis.Client {
// Create Redis Client
var (
host = getEnv("REDIS_HOST", "localhost")
port = string(getEnv("REDIS_PORT", "6379"))
password = getEnv("REDIS_PASSWORD", "")
)
client := redis.NewClient(&redis.Options{
Addr: host + ":" + port,
Password: password,
DB: 0,
})
// Check we have correctly connected to our Redis server
_, err := client.Ping().Result()
if err != nil {
log.Fatal(err)
}
return client
}
一旦我们建立了我们的Redis客户端,我们就需要一个函数来发送我们的假数据到Redis,添加以下内容:
func publishingLoop(redisClient *redis.Client) {
queueKey := getEnv("QUEUE_KEY", "myJobQueue")
publishRate, _ := strconv.Atoi(getEnv("PUBLISH_RATE", "200"))
baseTradeValue := float64(200)
// Send a burst of messages to Redis every 5 seconds
ticker := time.NewTicker(5 * time.Second)
quit := make(chan struct{})
for {
select {
case <- ticker.C:
for i := 0; i < publishRate; i++ {
// Random test value varying +- 5 around the baseTradeValue
tradeValue := baseTradeValue + (rand.Float64() * 10 - 5)
redisClient.RPush(queueKey, fmt.Sprintf("%f", tradeValue))
}
case <- quit:
// If you want a way to cleanly stop the server, call quit <- true
// So this runs
ticker.Stop()
defer wg.Done()
return
}
}
}
我们在这里得到的是一个ticker,它将导致我们每5秒向Redis发送大量的消息。我们在这里使用突发的消息,所以我们可以模拟一个场景,即我们有太多的消息要处理,因为它们的产生不会导致队列持续增长。
现在让我们把Redis客户端的初始化和发布函数添加到我们的主函数中,还有我们的发布函数:
func main() {
client := getRedis()
go publishingLoop(client)
// Add an item to the wait group so the server keeps running
wg.Add(1)
wg.Wait()
}
这样我们就有了我们的交易服务器,准备好开始向Redis输送数据了。
设置Ably Publisher
接下来,让我们设置一个服务器,它将负责从Redis队列中获取消息,并通过Ably将它们流向我们的客户端。为了确保为订阅的客户提供可靠的服务,Ably对发布的消息在每个通道和每个连接上都有一个速率限制,对于免费账户来说是50。作为我们发布者的一部分,我们将需要确保我们保持每秒最多发布50条消息的速度。
为了使我们能够在未来扩大Aply发布器的数量,在这里执行速率限制的最简单的方法是滑动日志。实际上,每次Ably Publishers试图从Redis获取消息发布时,它都会检查过去1秒内发送的消息的日志。如果返回的消息数量大于我们的限制,我们会等待一会儿,然后再试一次。
为了开始使用Ably Publisher,在ably-publisher 文件夹中,让我们创建一个go.mod 文件,内容如下:
module ably-go-publisher
go 1.16
require (
github.com/ably/ably-go v1.2.0
github.com/go-redis/redis v6.15.9+incompatible // indirect
)
这里我们只包括go-redis 模块,这样我们就可以与Redis实例进行交互,以及ably-go 库,与Ably进行交互。
接下来,在ably-publisher 中再次创建一个文件,名为publisher-server.go 。在里面,让我们添加我们的导入和基本的主函数。trading-server 此外,我们将包括我们用于getEnv 和getRedis 的函数,因为我们将需要它们:
import (
"log"
"os"
"time"
"fmt"
"context"
"sync"
"strconv"
"github.com/go-redis/redis"
"github.com/ably/ably-go/ably"
)
var ctx = context.Background()
var wg = &sync.WaitGroup{}
func main() {
}
func getRedis() *redis.Client {
// Create Redis Client
var (
host = getEnv("REDIS_HOST", "localhost")
port = string(getEnv("REDIS_PORT", "6379"))
password = getEnv("REDIS_PASSWORD", "")
)
client := redis.NewClient(&redis.Options{
Addr: host + ":" + port,
Password: password,
DB: 0,
})
_, err := client.Ping().Result()
if err != nil {
log.Fatal(err)
}
return client
}
func getEnv(envName, valueDefault string) string {
value := os.Getenv(envName)
if value == "" {
return valueDefault
}
return value
}
接下来,为了与Aply互动以向客户发送消息,我们需要实例化Aply库。作为连接和使用Ably的一部分,你将需要注册一个免费的Ably账户。一旦你有一个账户,你将需要从你账户的一个应用程序中获得你的API密钥。
一旦你有了你的API密钥,在你的代码中添加以下函数:
func getAblyChannel() *ably.RealtimeChannel {
// Connect to Ably using the API key and ClientID specified above
ablyClient, err := ably.NewRealtime(
// If you have an Ably account, you can find
// your API key at https://www.ably.io/accounts/any/apps/any/app_keys
ably.WithKey(getEnv("ABLY_KEY", "No key specified")),
// ably.WithEchoMessages(false) // // Uncomment to stop messages you send from being sent back
)
if err != nil {
panic(err)
}
// Connect to the Ably Channel with name 'trades'
return ablyClient.Channels.Get(getEnv("CHANNEL_NAME", "trades"))
}
我们在这里做的是实例化一个连接到我们的Ably应用程序,然后从该应用程序返回一个名为 "交易"的通道实例。我们将使用这个通道来发布我们的交易更新。
现在,我们有函数来设置我们与Redis和Ably的连接,我们需要写一些代码来将两者连接在一起,并考虑速率限制。
对于费率限制的执行,我们需要以下的Redis命令,这些命令都将作为Redis事务的一部分来执行(确保这些命令按顺序发生,并且是原子性的):
- ZRemRangeByScore(redisLogName, startScore, endScore) - 这将从我们的日志中删除属于startScore和endScore值的任何值。在这种情况下,我们的 "分数 "将是过去发布的时间戳,所以如果我们将startScore设置为 "0","endScore "设置为1秒前,我们将删除1秒前的所有日志。
- ZCard(redisLogName) - ZCard将获得一个集合中的信息数量,在这里是指日志的数量。如果我们已经执行了 ZRemRangeByScore,这将是上一秒发布的信息数量。
- ZAdd(redisLogName, {score, member})-ZAdd向指定的Redis键添加一个新的分数和成员。在我们的案例中,当我们要向Ably发布一条新消息时,我们将把当前的时间戳添加到我们的日志中。
为了从我们创建的消息队列中实际消费trading-server ,我们需要利用BLPop(timeout redisQueueName)的作用。这将删除Redis队列中的第一个项目并将其返回给我们。值得注意的是,它是阻塞的,这意味着如果队列上没有消息,它将等待,直到有一个消息被消耗,才继续。
从队列中弹出一个消息后,我们将用一个简单的channel.Publish(‘trade’, messageData) ,把它发送到我们的Ably Channel。
将这些都结合在一个事务中,我们最终得到以下函数:
func transactionWithRedis(client *redis.Client, channel *ably.RealtimeChannel) error {
// Redis key where messages from the trading server are stored
redisQueueName := getEnv("QUEUE_KEY", "myJobQueue")
// Values to be used for checking our Redis log key for the rate limit
redisLogName := redisQueueName + ":log"
now := time.Now().UnixNano()
windowSize := int64(time.Second)
clearBefore := now - windowSize
rateLimit, _ := strconv.ParseInt(getEnv("RATE_LIMIT", "50"), 10, 64)
err := client.Watch(func(tx *redis.Tx) error {
tx.ZRemRangeByScore(redisLogName, "0", strconv.FormatInt(clearBefore, 10))
// Get the number of messages sent this second
messagesThisSecond, err := tx.ZCard(redisLogName).Result()
if err != nil && err != redis.Nil {
return err
}
// If under rate limit, indicate that we'll be publishing another message
// And publish it to Ably
if messagesThisSecond < rateLimit {
err = tx.ZAdd(redisLogName, redis.Z{
Score: float64(now),
Member: now,
}).Err()
if err != nil && err != redis.Nil {
return err
}
messageToPublish, err := tx.BLPop(0*time.Second, redisQueueName).Result()
if err != nil && err != redis.Nil {
return err
}
err = channel.Publish(ctx, "trade", messageToPublish[1])
if err != nil {
fmt.Println(err)
}
}
return err
}, redisLogName)
return err
}
这样一来,我们就拥有了连接Redis和Ably所需的所有函数,并通过Ably将Redis队列中的消息发送给客户端在主函数中添加以下内容以利用它们:
func main() {
client := getRedis()
channel := getAblyChannel()
go func() {
for {
transactionWithRedis(client, channel)
}
}()
wg.Add(1)
wg.Wait()
}
使用Kubernetes进行Dockerizing和扩展
我们现在有了系统两部分所需的代码,所以现在我们需要找出一种合理的方式,将它们和Redis实例一起部署。为了做到这一点,我们将使用Docker来包装我们的每个服务和Redis实例,然后使用Kubernetes来将这些逻辑分组,并允许它们轻松扩展。
Docker
开始工作非常简单,我们只需要为交易服务器和Ably发布服务器创建一个Docker文件,定义每个容器应该如何构建。在ably-publishing-server 文件夹中,创建一个名为Dockerfile 的文件,内容如下:
FROM golang:latest as builder
WORKDIR /app
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy from the current directory to the WORKDIR in the image
COPY . .
# Build the server
RUN go build -o publisher publisher-server.go
# Command to run the executable
CMD ["./publisher"]
实际上,这一切都在构建我们编写的发布器代码,然后设置Docker Image的 "运行 "命令来运行生成的可执行文件。
我们将在trading-server 文件夹中做同样的脚本,作为Dockerfile :
FROM golang:latest as builder
WORKDIR /app
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy from the current directory to the WORKDIR in the image
COPY . .
# Build the server
RUN go build -o trading trading-server.go
# Command to run the executable
CMD ["./trading"]
这就是我们从代码中构建Docker映像所需要的全部内容![把Docker下载])到你的机器上,然后从trading-server 文件夹里面,运行下面的程序来构建。
> docker build -t go-redis-trading-server:latest .
现在你应该有一个包含你的交易服务器的Docker镜像了你可以通过运行以下程序来检查镜像的创建情况 docker images来查看你的机器上存在哪些镜像。
让我们为Ably发布服务器做同样的事情。在ably-publishing-server 文件夹中,运行以下内容:
> docker build -t go-redis-publishing-server:latest .
Kubernetes
现在我们已经为我们的服务制作了Docker容器,我们现在可以使用Kubernetes来协调我们的系统了。
我们需要做的是为Redis容器、Trades容器和Ably Publishing容器创建一个部署。在每个部署中,我们可以选择扩展Pod的数量,从而扩展每种类型的容器的运行数量。
此外,我们需要创建一个服务,它将定义从其他Pod到Redis Pod的访问。
让我们首先创建一个文件,包含我们的Redis容器和服务。在我们项目的基础文件夹中创建一个新的文件夹,名为deployments ,并在其中添加redis.yaml 。在其中添加以下内容:
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis-deployment # Unique name for the deployment
labels:
app: redis # Label used to select this deployment
spec:
selector:
matchLabels:
app: redis
replicas: 1 # Number of pods to run in this deployment
template:
metadata:
labels:
app: redis
spec: # Spec for the Docker Container inside of the Pod
containers:
- name: redis-container
image: redis # Use the generic Redis Docker Image
---
apiVersion: v1
kind: Service
metadata:
name: redis-service
labels:
app: redis
spec:
ports:
- port: 6379
targetPort: 6379
selector: # Pods with the 'app: redis' label will use this service
app: redis
这里我们定义了两个部分:一个Redis部署和一个Redis服务。部署有效地定义了我们将有一个官方Docker Redis容器的单一实例,从Docker Hub中提取。我们用 "app: redis "来标记这个容器和它的Pod,这样服务就能识别它们并正确应用。
部署规范下面定义的服务只是说明Redis Pod应该在6379端口上可用。
接下来,让我们为我们的交易和ably publishing容器创建部署规范。为了使这些规范能够正确使用你生成的Docker镜像,你需要将镜像托管在某个地方,以便部署最终从那里提取。要做到这一点,最简单的地方就是Docker Hub。如果你想跳过将你的图像推送到仓库,你也可以使用这个代码的图像,这些图像已经被制作出来,并在Docker Hub上以'tomably/trading-server:最新'和'tomably/go-redis-ably-publisher:最新'的方式提供。
让我们先创建交易服务器的部署。在deployment`文件夹中创建一个名为trading-deployment.yml 的新文件,内容如下:
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: trading-server
spec:
replicas: 1 # Number of pods to have within this deployment
selector:
matchLabels:
app: trading-server
template: # Template for Pods within this deployment
metadata:
labels: # The label used for pods within this deployment
app: trading-server
spec: # Details on the Docker Container to use in each Pod
containers:
- name: trading-server
image: tomably/trading-server:latest
imagePullPolicy: Always
env: # Environment variables passed to the container
- name: REDIS_HOST
value: redis-service
- name: REDIS_PORT
value: "6379"
在这里,我们再次指定我们要有一个单一的实例(副本),我们要拉动的形象。我们还指定了一些环境变量,这些变量将被容器所使用。
在同一文件夹中创建另一个文件,名为ably-publisher-deployment.yml 。我们将使用完全相同的结构,只是我们将副本设置为3。这应该意味着,当我们最终使用这个配置文件时,我们将有三个出版商的实例在运行。如果我们的速率限制器工作正常,它们在发布时应该尊重它们之间的共享速率限制。
作为环境变量的一部分,我们也要加入我们的Ably API密钥。确保将占位符INSERT_API_KEY_HERE文本替换为你在本教程早期获得的API密钥:
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ably-publisher
spec:
replicas: 3 # Number of pods to have within this deployment
selector:
matchLabels:
app: ably-publisher
template: # Template for Pods within this deployment
metadata:
labels: # The label used for pods within this deployment
app: ably-publisher
spec:
containers:
- name: ably-publisher
image: tomably/go-redis-ably-publisher:latest
imagePullPolicy: Always
env: # Environment variables passed to the container
- name: REDIS_HOST
value: redis-service
- name: REDIS_PORT
value: "6379"
- name: ABLY_KEY
value: "INSERT_API_KEY_HERE"
- name: RATE_LIMIT
value: "50"
完成这些后,我们应该有了运行项目所需的一切!为了运行我们的Kubernetes集群,我们将需要两样东西。Kubectl来运行Kubernetes命令,以及Minikube来运行集群。
你需要按照Kubernetes网站上的描述下载kubectl,然后通过运行以下程序确保你已经正确地完成了任务 kubectl version --client.
接下来,下载Minikube,然后在你的终端运行 *minikube start*来启动它的一个实例。
一旦你有了这些,我们将使用kubectl来部署我们的部署和服务,正如我们的配置文件中所描述的那样!运行以下命令,让kubectl读取并使用我们的配置文件来构建我们的集群:
> kubectl apply -f redis.yml
> kubectl apply -f ably-publisher-deployment.yml
> kubectl apply -f trading-deployment.yml
如果一切都按计划进行,如果你运行kubectl get pods ,你应该看到类似以下的返回:
NAME READY STATUS RESTARTS AGE
ably-publisher-6b6cb8c796-8px6w 1/1 Running 0 10s
ably-publisher-6b6cb8c796-mmh7p 1/1 Running 0 10s
ably-publisher-6b6cb8c796-nrxqp 1/1 Running 0 10s
redis-deployment-6f6964f4c-8gwsm 1/1 Running 0 10s
trading-server-7c5d445747-mrn54 1/1 Running 0 10s
如果任何返回的pods没有运行,而是处于Error或CrashLoopBackOff的状态,你可以尝试通过运行kubectl logs POD_NAME ,将POD_NAME替换为有问题的pod的名称来检查问题所在。
如果你进入Ably App的开发控制台,连接到 "交易"频道(或你重新命名的任何频道),你应该看到交易正在进行。
如果你想删除任何一个部署,你可以运行以下命令,确保将DEPLOYMENT_NAME改为相关的部署名称(本教程中的trading-server、ably-publisher或redis-deployment)。
> kubectl delete deployment DEPLOYMENT_NAME
订阅数据
现在我们的数据已经进入了Ably,让我们创建一个非常基本的网页来消费它。创建一个名为client.html 的新文件,并在其中添加以下代码,确保将INSERT_API_KEY_HERE文本替换为你的Ably API密钥。
<html>
<body>
<h1>Trades</h1>
<p>This page is subscribed to the channel 'trades' in Ably, and should add these updates under this text.</p>
<ul class="row" id="messages"></ul>
</body>
<!-- Include the latest Ably Library -->
<script src="https://cdn.ably.io/lib/ably.min-1.js"></script>
<!-- Instance the Ably library -->
<script type="text/javascript">
let realtime = new Ably.Realtime("INSERT_API_KEY_HERE"); /* ADD YOUR API KEY HERE */
/* Subscribe to messages on the trades channel */
let channel = realtime.channels.get("trades");
channel.subscribe(function(msg) {
show(msg.data);
});
function show(msg) {
let list = document.createElement("LI");
let text = document.createTextNode(msg);
list.appendChild(text);
document.getElementById("messages").appendChild(list);
}
</script>
</html>
如果你打开这个文件,它将是一个非常光秃秃的网页,它被订阅到Ably频道'trans'。假设你从我们的集群发布数据到该频道,你应该看到交易开始进入。
总结
就这样,我们完成了!我们现在有了一个容易扩展的基础设施,可以轻松地将大量的流量流向客户。这实际上只是刮了一下可能性的表面,但应该作为建立良好的工具的一个良好的起点。
事件流可以从许多其他功能中受益,例如在为客户发布或处理数据之前检查客户在渠道上的存在,在渠道上启用历史记录,以便客户可以检查以前的事件,或者甚至将事件转发给Webhooks和云功能进行进一步处理。
如果你想用现有的真实数据试试,你可以在Ably Hub中免费找到真实的加密货币价格。
这个项目的所有代码都可以在GitHub上找到。