演示案例需求
在我参与开发的一个创业项目中有一个聊天的功能,为了快速成型我一开始按照单体应用的方式开发,给每一个登录的用户建立websocket连接进行通信。但是开发的过程中我就察觉到如下问题
- 当在线人数较多时,一台服务器就无法创建满足需求的数量的websocket
- 当在线用户较多的时候,可能会碰到某一时间段请求量过多的情况,服务器的压力会较大,使得其他服务无法正常运行
- 如果服务器宕机,可能会出现消息丢失、消息状态不正确等问题
我的想法就是将程序多实例部署,利用nginx或者网关服务进行负载均衡,利用rocketmq解决分布在不同实例间的websocket间的通信、消息限流削峰和消息可靠性的问题。
为了快速检验想法,我就做了一个简单的demo。用户A连接程序1,用户B连接程序2,实现用户A跟用户B跨程序聊天。
下面是实现思路
搭建RocketMQ
官网搭建示例
创建docker-compose.yml
version: '3.8'
services:
namesrv:
networks:
- rocketmq-net
image: apache/rocketmq:5.3.1
command: sh mqnamesrv
ports:
- "9876:9876"
volumes:
- ./data/namesrv/logs:/home/rocketmq/logs
broker:
networks:
- rocketmq-net
image: apache/rocketmq:5.3.1
command: sh mqbroker -c /etc/rocketmq/broker.conf -n namesrv:9876
ports:
- "10909:10909"
- "10911:10911"
- "10912:10912"
environment:
- JAVA_OPT_EXT=-Xms512m -Xmx512m
depends_on:
- namesrv
volumes:
- ./data/broker/logs:/home/rocketmq/logs
- ./data/broker/store:/home/rocketmq/store
- ./broker.conf:/etc/rocketmq/broker.conf
console:
networks:
- rocketmq-net
image: apacherocketmq/rocketmq-console:2.0.0
ports:
- "8080:8080"
environment:
- "JAVA_OPTS=-Drocketmq.namesrv.addr=namesrv:9876"
depends_on:
- namesrv
- broker
networks:
rocketmq-net:
driver: bridge
在docker-compose.yml文件同一个文件夹下创建broker.conf
brokerIP1=192.168.1.215 # 这里要改成你的ip 可以通过ipconfig查看,填写无线局域网适配器 WLAN:下的IPV4地址即可
运行下面指令cmd:
docker-compose up -d # 创建容器
进入localhost:8080.添加TestTopic
示例代码
程序1:
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"sync"
"syscall"
"github.com/apache/rocketmq-client-go/v2"
"github.com/apache/rocketmq-client-go/v2/consumer"
"github.com/apache/rocketmq-client-go/v2/primitive"
"github.com/apache/rocketmq-client-go/v2/producer"
"github.com/go-redis/redis/v8"
"github.com/gorilla/websocket"
)
var (
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
redisClient *redis.Client
)
type Server struct {
clients sync.Map // WebSocket connections
userConnMap sync.Map // 用户ID到连接的映射
producer rocketmq.Producer
consumer rocketmq.PushConsumer
serverID string
port int
}
type Message struct {
To string `json:"to"`
Content string `json:"content"`
}
func main() {
port := flag.Int("port", 7777, "server port")
flag.Parse()
// 初始化Redis客户端
redisClient = redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6380",
Password: "", // 无密码
DB: 1, // 默认DB
})
// 初始化RocketMQ Producer
p, err := rocketmq.NewProducer(
producer.WithNameServer([]string{"127.0.0.1:9876"}),
producer.WithRetry(2),
)
if err != nil {
log.Fatalf("创建Producer失败: %s", err)
}
go func() {
err = p.Start()
if err != nil {
log.Fatalf("启动Producer失败: %s", err.Error())
}
}()
// 创建服务器实例
server := &Server{
producer: p,
port: *port,
serverID: strconv.Itoa(int(*port)),
}
go func() {
// 初始化Consumer
server.initConsumer()
// server.initConsumerWithTag()
// server.initConsumerWithSQL()
if err := server.consumer.Start(); err != nil {
log.Fatalf("启动Consumer失败: %s", err)
}
}()
// 设置WebSocket路由
http.HandleFunc("/ws", server.handleWebSocket)
log.Printf("Server starting on :%d", *port)
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
}
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
// 从查询参数获取用户ID
userID := r.URL.Query().Get("userID")
if userID == "" {
http.Error(w, "未提供用户ID", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Print("Upgrade error:", err)
return
}
defer conn.Close()
// 记录用户连接
s.clients.Store(conn, true)
s.userConnMap.Store(userID, conn)
// 更新Redis中的用户位置
redisClient.Set(context.Background(), "user:"+userID, s.serverID, 0)
defer func() {
s.clients.Delete(conn)
s.userConnMap.Delete(userID)
redisClient.Del(context.Background(), "user:"+userID)
}()
fmt.Println("WebSocket连接已建立,用户ID:", userID)
for {
_, msgBytes, err := conn.ReadMessage()
if err != nil {
break
}
// 解析消息
var msg Message
if err := json.Unmarshal(msgBytes, &msg); err != nil {
log.Printf("消息解析失败: %v", err)
continue
}
log.Printf("websocket收到发送给用户{%s}消息:{%s}", msg.To, msg.Content)
// 查询目标用户所在服务器
targetServerID, err := redisClient.Get(context.Background(), "user:"+msg.To).Result()
if err != nil {
log.Printf("用户%s不在线", msg.To)
// 正常来说如果用户不在线这里就应该将消息存储起来,为了削峰限流,也可以将消息发送到一个未发送消息队列中,并用消费者将保存到数据库中。
continue
} else if targetServerID == s.serverID {
log.Printf("用户%s在本服务器上,无需发送mq", msg.To)
continue
}
// 发送到RocketMQ,并设置目标服务器属性
rocketMsg := &primitive.Message{
Topic: "TestTopic",
Body: msgBytes,
}
// rocketMsg.WithTag(targetServerID)
rocketMsg.WithProperty("targetServerID", targetServerID)
if _, err := s.producer.SendSync(context.Background(), rocketMsg); err != nil {
log.Printf("发送消息失败: %v", err)
} else {
// log.Printf("发送消息成功{%v}", rocketMsg)
}
}
}
func (s *Server) initConsumer() {
c, err := rocketmq.NewPushConsumer(
consumer.WithNsResolver(primitive.NewPassthroughResolver([]string{"127.0.0.1:9876"})),
consumer.WithGroupName("DemoGroup7777"),
consumer.WithVIPChannel(true),
)
if err != nil {
log.Fatalf("创建Consumer失败: %s", err)
}
log.Printf("Consumer.Tag:%s", s.serverID)
// 订阅消息
err = c.Subscribe("TestTopic", consumer.MessageSelector{Type: consumer.TAG, Expression: s.serverID}, func(ctx context.Context, msgs ...*primitive.MessageExt) (consumer.ConsumeResult, error) {
// 收到消息日志
log.Printf("✅consumer收到 msgs{%v}", msgs) // 关键日志
for _, msg := range msgs {
// 解析消息体
var message Message
if err := json.Unmarshal(msg.Body, &message); err != nil {
log.Printf("解析消息体失败: %v", err)
continue
}
// 查找目标用户连接
if conn, ok := s.userConnMap.Load(message.To); ok {
if err := conn.(*websocket.Conn).WriteMessage(websocket.TextMessage, msg.Body); err != nil {
log.Printf("发送消息到客户端失败: %v", err)
s.userConnMap.Delete(message.To)
}
} else {
log.Printf("用户%s不在线", message.To)
}
}
return consumer.ConsumeSuccess, nil
})
if err != nil {
log.Fatalf("订阅失败: %s", err)
}
s.consumer = c
}
程序2:
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"sync"
"syscall"
"github.com/apache/rocketmq-client-go/v2"
"github.com/apache/rocketmq-client-go/v2/consumer"
"github.com/apache/rocketmq-client-go/v2/primitive"
"github.com/apache/rocketmq-client-go/v2/producer"
"github.com/go-redis/redis/v8"
"github.com/gorilla/websocket"
)
var (
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
redisClient *redis.Client
)
type Server struct {
clients sync.Map // WebSocket connections
userConnMap sync.Map // 用户ID到连接的映射
producer rocketmq.Producer
consumer rocketmq.PushConsumer
serverID string
port int
}
type Message struct {
To string `json:"to"`
Content string `json:"content"`
}
func main() {
port := flag.Int("port", 6666, "server port")
flag.Parse()
// 初始化Redis客户端
redisClient = redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6380",
Password: "", // 无密码
DB: 1, // 默认DB
})
var p rocketmq.Producer
// 初始化RocketMQ Producer
p, err := rocketmq.NewProducer(
producer.WithNameServer([]string{"127.0.0.1:9876"}),
producer.WithRetry(2),
)
if err != nil {
log.Fatalf("创建Producer失败: %s", err)
}
err = p.Start()
if err != nil {
log.Fatalf("启动Producer失败: %s", err.Error())
}
// 创建服务器实例
server := &Server{
producer: p,
port: *port,
serverID: strconv.Itoa(int(*port)),
}
go func() {
// 初始化Consumer
server.initConsumer()
// server.initConsumerWithTag()
// server.initConsumerWithSQL()
if err := server.consumer.Start(); err != nil {
log.Fatalf("启动Consumer失败: %s", err)
}
}()
// 设置WebSocket路由
http.HandleFunc("/ws", server.handleWebSocket)
log.Printf("Server starting on :%d", *port)
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
}
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
// 从查询参数获取用户ID
userID := r.URL.Query().Get("userID")
if userID == "" {
http.Error(w, "未提供用户ID", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Print("Upgrade error:", err)
return
}
defer conn.Close()
// 记录用户连接
s.clients.Store(conn, true)
s.userConnMap.Store(userID, conn)
// 更新Redis中的用户位置
redisClient.Set(context.Background(), "user:"+userID, s.serverID, 0)
defer func() {
s.clients.Delete(conn)
s.userConnMap.Delete(userID)
redisClient.Del(context.Background(), "user:"+userID)
}()
fmt.Println("WebSocket连接已建立,用户ID:", userID)
for {
_, msgBytes, err := conn.ReadMessage()
if err != nil {
break
}
// 解析消息
var msg Message
if err := json.Unmarshal(msgBytes, &msg); err != nil {
log.Printf("消息解析失败: %v", err)
continue
}
log.Printf("websocket收到发送给用户{%s}消息:{%s}", msg.To, msg.Content)
// 查询目标用户所在服务器
targetServerID, err := redisClient.Get(context.Background(), "user:"+msg.To).Result()
if err != nil {
log.Printf("用户%s不在线", msg.To)
// 正常来说如果用户不在线这里就应该将消息存储起来,为了削峰限流,也可以将消息发送到一个未发送消息队列中,并用消费者将保存到数据库中。
continue
} else if targetServerID == s.serverID {
log.Printf("用户%s在本服务器上,无需发送mq", msg.To)
continue
}
// 发送到RocketMQ,并设置目标服务器属性
rocketMsg := &primitive.Message{
Topic: "TestTopic",
Body: msgBytes,
}
// rocketMsg.WithTag(targetServerID)
rocketMsg.WithProperty("targetServerID", targetServerID)
if _, err := s.producer.SendSync(context.Background(), rocketMsg); err != nil {
log.Printf("发送消息失败: %v", err)
} else {
// log.Printf("发送消息成功{%v}", rocketMsg)
}
}
}
func (s *Server) initConsumer() {
c, err := rocketmq.NewPushConsumer(
consumer.WithNsResolver(primitive.NewPassthroughResolver([]string{"127.0.0.1:9876"})),
consumer.WithGroupName("DemoGroup6666"),
consumer.WithVIPChannel(true),
)
if err != nil {
log.Fatalf("创建Consumer失败: %s", err)
}
log.Printf("Consumer.Tag:%s", s.serverID)
// 订阅消息
err = c.Subscribe("TestTopic", consumer.MessageSelector{Type: consumer.TAG, Expression: s.serverID}, func(ctx context.Context, msgs ...*primitive.MessageExt) (consumer.ConsumeResult, error) {
// 收到消息日志
log.Printf("✅consumer收到 msgs{%v}", msgs) // 关键日志
for _, msg := range msgs {
// 解析消息体
var message Message
if err := json.Unmarshal(msg.Body, &message); err != nil {
log.Printf("解析消息体失败: %v", err)
continue
}
// 查找目标用户连接
if conn, ok := s.userConnMap.Load(message.To); ok {
if err := conn.(*websocket.Conn).WriteMessage(websocket.TextMessage, msg.Body); err != nil {
log.Printf("发送消息到客户端失败: %v", err)
s.userConnMap.Delete(message.To)
}
} else {
log.Printf("用户%s不在线", message.To)
}
}
return consumer.ConsumeSuccess, nil
})
if err != nil {
log.Fatalf("订阅失败: %s", err)
}
s.consumer = c
}
postman测试效果:
消费者组踩坑
刚开始我没有具体了解RocketMQ的消费者组的原理,我以为程序1和程序2的业务都是相同的,应该放在同一个消费者组DemoGroup中,然后通过消息的Tag来将消息送给目标消费者处理,导致消息消费出现异常。
解释:RocketMQ中设定一个消费者组内的所有消费者配置都是一致的,同时为了避免竞争,会进行负载均衡。举一个例子:TestTopic收到一条消息1-Tag=7777,并放到了消息队列1中.由于后端实例1-消费者只处理Tag=6666的消息,导致这条消息一致存储在消息队列1中。这时TestTopic收到一条消息2-Tag=6666并放到了消息队列1中。由于消息队列先进先出,消息1卡还没出队,所以消息2也没办法出队。这就导致消息处理异常。
Docker踩坑
- 由于NameSrv和Broker都是通过docker部署的,docker会自动给其分配虚拟ip,如果broker采用默认的配置,就会导致通过访问NameSrv获取的Broker地址是无法连接的。(注意:这里的后端实例并不是通过docker部署的,而是在windows下直接运行的)为了解决这个问题,就必须要改变Broker的brokerIP1为当前电脑的IP。这个可以在cmd中通过ipconfig指令获取
- 为什么不把后端程序也一同通过docker-compose部署?原因有两个
i. 本来后端程序就不该跟MQ一块部署,在实际开发来说他们两个属于两个实体,应该分开部署
ii. 如果通过docker-compose一键部署会存在后端程序无法连接NameSrv的情况 .后端容器一直报错:new Namesrv failed.: IP addr error。我试过很多方法都没办法解决这个问题。我让后端程序开始创建生成者之前先解析NameSrv和Broker容器的ip,发现是能够正确解析的,而且通过telnet 工具也能正常连接,但是就是报错new Namesrv failed.: IP addr error或者说没办法连接上Namesrv,找不到topic=TestTopic。我自己是进入过Namesrv和Broker容器进行过确认的,Broker确实连接上了Namesrv,而且也存在TestTopic。如果你知道我哪里做的有问题,我恳求你能够在评论区指出我的问题给出解决方案 - 如果需要利用docker-compose部署后端程序,在修改程序后一定要删除之前残留的镜像。只是执行docker-compose down只会删除让这个容器组,下次你修了了后端代码继续执行docker-compose up -d 的时候,docker会复用你之前创建的后端镜像,导致你的修改没有得到更新。
利用nginx进行负载均衡
现在我们仍然是手工通过postman填写不同的路由来连接后端实现跨实例通信的,但是在项目中肯定不能这么干,前端哪里知道他该连那个ip呢?这时我们就可以利用nginx来实现负载均衡,前端只要访问nginx即可,完全不用管后端有什么实例,ip是多少之类的麻烦事。
消费者组命名设计
因为用户跟实例之间是强绑定的,所以每一个实例之间都应该有一个消费者组。那该如何命名消费者组呢?通过手工去修改肯定是不够优雅的,麻烦还容易出错。这里我就想了一种方案,就是利用Redis的HSet结构和Incr指令的原子性来给各个服务器分配一个id,然后服务器对应的消费者组命名为“ChatService-Prod-Node{$id}”。
HSet结构{
“cursor” : 2 # id自增器
“ip1” : 1 #以服务器ip为key,id为value
“ip2” : 2
}
在后端程序启动的时候,获取当前服务器的ip,接着访问Redis获取该ip的id,如果不存在则利用Incr指令让cursor自增,并将“ip”:cursor值存入HSet中。然后就可以利用这个id来命名消费者组了。每次有用户连接到该服务器,就将用户所在的服务器id存入redis中。这样就可以让生成责知道设置什么Tag让消息能够通过MQ转达到目标服务器,实现跨实例通信、
补充
默认情况下不支持SQL过滤,如果需要在broker.conf文件中开启
# 启用属性过滤
enablePropertyFilter = true