go+rocketmq+websocket+nginx实现跨实例通信(windows利用docker模拟)

演示案例需求

在我参与开发的一个创业项目中有一个聊天的功能,为了快速成型我一开始按照单体应用的方式开发,给每一个登录的用户建立websocket连接进行通信。但是开发的过程中我就察觉到如下问题

  1. 当在线人数较多时,一台服务器就无法创建满足需求的数量的websocket
  2. 当在线用户较多的时候,可能会碰到某一时间段请求量过多的情况,服务器的压力会较大,使得其他服务无法正常运行
  3. 如果服务器宕机,可能会出现消息丢失、消息状态不正确等问题

我的想法就是将程序多实例部署,利用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也没办法出队。这就导致消息处理异常。
正确处理
官方示例图1
官方示例图2

Docker踩坑

  1. 由于NameSrv和Broker都是通过docker部署的,docker会自动给其分配虚拟ip,如果broker采用默认的配置,就会导致通过访问NameSrv获取的Broker地址是无法连接的。(注意:这里的后端实例并不是通过docker部署的,而是在windows下直接运行的)为了解决这个问题,就必须要改变Broker的brokerIP1为当前电脑的IP。这个可以在cmd中通过ipconfig指令获取
    在这里插入图片描述
  2. 为什么不把后端程序也一同通过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。如果你知道我哪里做的有问题,我恳求你能够在评论区指出我的问题给出解决方案
  3. 如果需要利用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

官网对RocketMQ各个模型的解释

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值