V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
dylangl
V2EX  ›  Go 编程语言

部署超简单的 Golong 分布式 WebSocket 微服务

  •  
  •   dylangl · 2020-03-14 14:29:32 +08:00 · 3963 次点击
    这是一个创建于 1714 天前的主题,其中的信息可能已经有所发展或是发生改变。

    使用场景

    在实现业务的时候,我们常常有些需求需要系统主动发送消息给客户端,方案有轮询和长连接,但轮询需要不断的创建销毁 http 连接,对客户端、对服务器来说都挺消耗资源的,消息推送也不够实时。这里我们选择了 WebSocket 长连接的方案。

    有大量的项目需要服务端主动向客户端推送消息,为了减少重复开发,我们做成了微服务。

    使用于服务器需要主动向客户端推送消息、客户端需要实时获取消息的请求。例如聊天、广播消息、多人游戏消息推送、任务执行结果推送等方面。

    使用流程

    用 Websocket 客户端连接本服务,服务端会返回客户端一个唯一的 client id,通过这个 client id 可以知道是哪个连接,客户端拿到这个 id 之后上报到服务端,服务端根据业务需求可以给这个长连接发送指定信息,或者绑定到分组。

    分布式方案

    维持大量的长连接对单台服务器的压力也挺大的,这里也就要求该服务需要可以扩容,也就是分布式地扩展。分布式对于可存储的公共资源有一套完整的解决方案,但对于 WebSocket 来说,操作对象就是每一个连接,它是维持在每一个程序中的。每一个连接不能存储起来共享、不能在不同的程序之间共享。所以我能想到的方案是不同程序之间进行通讯。

    那么,怎样知道某个连接在哪个应用呢?答案是通过 client id 去判断。那么通过 client id 又是如何知道的呢?有以下几种方案:

    1. 一致性 hash 算法

      一致性 hash 算法是将整个哈希值空间组织成一个虚拟的圆环,在 redis 集群中哈希函数的值空间为 0-2^32-1 ( 32 位无符号整型)。把服务器的 IP 或主机名作为关键字,通过哈希函数计算出相应的值,对应到这个虚拟的圆环空间。我们再通过哈希函数计算 key 的值,得到一个在圆环空间的位置,按顺时针方向找到的第一个节点就是存放该 key 数据的服务器节点。

      在没有节点的增减的时候,可以满足我们的需求,但如果此时一个节点挂掉了或者新增一个机器怎么办?节点挂点之后,会在圆环上删除节点,增加节点则反之。这时候按顺时针方向找的数据就不准确,在某些业务上来说可以接受,但在 WebSocket 微服务上来说,影响范围内的连接会断掉,如果要求没那么高,客户端再进行重连也可以。

    2. hash slot (哈希槽)

      服务器的 IP 或者主机名作为 key,对每个 key 进行计算 CRC16 值,然后对 16384 进行取模,得出一个对应 key 的 hash slot。

      HASH_SLOT = CRC16(key) mod 16384
      

      我们根据节点的数量,给每个节点划分范围,这个范围是 0-16384。hash slot 的重点就在这个虚拟表,key 对应的 hash slot 是永不变的,增减节点就是维护这张虚拟表。

    以上两种方案都可以实现需求,但一致性 hash 算法的方案会使部分 key 找到的节点不准确; hash slot 的方案需要维护一张虚拟表,在实现起来需要有一个功能去判断服务器是否挂了。修改这张虚拟表,新增节点也一样,在实现起来会遇到很多问题。

    然后我采取的方案是,每个连接都保存在本应用,然后用对称加密加密服务器 IP 和端口,得到的值作为 client id。对指定 client id 进行操作时,只需要解密这个 key,就能得到相应的 IP 和端口。判断是否为本机,不是本机的话进行 RPC 通讯告诉相应的程序。长连接的连接数据不可迁移,程序挂掉了相应的连接也就挂了,在该程序上的连接也就断开了,这时重连的话会找到另一个可用的程序。

    Golang 实现的分布式 WebSocket 微服务

    简介

    本系统基于 Golang、Redis、RPC 实现分布式 WebSocket 微服务,也可以单机部署,单机部署不需要 Redis、RPC。分布式部署可以支持 nginx 负责均衡、水平扩容部署,程序之间使用 RPC 通信。

    目前实现的功能有,给指定客户端发送消息、绑定客户端到分组、给分组里的客户端批量发送消息、获取在线的客户端、上下线自动通知。适用于长连接的大部分场景,分组可以理解为聊天室,绑定客户端到分组相当于把客户端添加到聊天室,给分组发送信息相当于给聊天室的每个人发送消息。

    架构图

    单机服务 WebSocket 单机服务架构图

    分布式

    WebSocket 分布式服务架构图

    时序图

    单发消息

    1. 客户端发送连接请求,连接请求通过 nginx 负载均衡找到一台 ws 服务器;
    2. ws 服务器响应连接请求,通过对称加密服务器 IP 和端口号,得到的值作为 client id,并返回。
    3. 客户端拿到 client id 之后,交给业务系统;
    4. 业务系统拿到 client id 之后,通过 http 发送相关消息,经过 nginx 负载分配到一台 ws 服务器;
    5. 这台 ws 服务器拿到 clinet id 和消息,解密出对应的服务器 IP 和端口;
    6. 拿到 IP 地址和端口,通过 PRC 协议给指定 ws 程序发送信息;
    7. 该 ws 程序接收到 client id 和信息,给指定的连接发送信息;
    8. 客户端收到信息。

    WebSocket 微服务单发时序图

    群发消息

    1. 前 3 个步骤跟单发的一样;
    2. 业务系统拿到 client id 之后,通过 http 给指定分组发送消息,经过 nginx 负载分配到一台 ws 服务器;
    3. 这台 ws 服务器拿到分组 ID 和消息,去 Redis 查询服务器列表,然后发送 RPC 广播;
    4. 所有收到广播的服务,找到本机所有该分组的连接;
    5. 给所有这些连接发送消息;
    6. 客户端收到信息。

    WebSocket 微服务群发消息时序图

    使用

    下载本项目:

    这里已经打包好了,下载相应的环境,支持 Linux、Windows、MacOS 环境。

    https://github.com/woodylan/go-websocket/releases

    你也可以选择自己编译:

    git clone https://github.com/woodylan/go-websocket.git
    

    编译:

    // 编译适用于本机的版本
    go build
    
    // 编译 Linux 版本
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build
    
    // 编译 Windows 64 位版本
    CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build
    
    // 编译 MacOS 版本
    CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build
    

    执行:

    编译成功之后会得到一个二进制文件go-websocket,执行该二进制文件,文件名后面跟着的是端口号,下面的命令666则表示端口号,你可以可以改成其他的。

    ./go-websocket 666
    

    连接测试:

    打开支持 Websocket 的客户端,输入 ws://127.0.0.1:666/ws 进行连接,连接成功会返回clientId

    单机部署

    单机部署很简单,不需要配置 Redis、RabbitMQ,只需要编译然后运行该二进制文件就可以了,步骤如上。

    分布式部署

    安装 Redis: 参考网上教程

    配置文件:

    配置文件位于项目根目录的configs/config.inicluster为 true 表示分布式部署。

    [common]
    # 是否分布式部署
    cluster = true
    # 对称加密 key 16 位
    crypto_key = xxxxxxxxxxxxxxxx
    
    [redis]
    host = 127.0.0.1
    port = 6379
    password =
    

    运行项目:

    在不同的机器运行本项目,注意配置号端口号,项目如果在同一机器,则必须用不同的端口。你可以用supervisor做进程管理。

    配置 Nginx 负载均衡:

    upstream ws_cluster {
        server 127.0.0.1:666;
        server 127.0.0.1:667;
    }
    
    server {
        listen  660;
        server_name ws.example.com;
    
        access_log /logs/access.log;
        error_log /logs/error.log;
        
        location /ws {
            proxy_pass http://ws_cluster; # 代理转发地址
            proxy_http_version 1.1;
    
            proxy_read_timeout 60s; # 超时设置
    
            # 启用支持 websocket 连接
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }
    
        location /api {
            proxy_set_header X-Forwarded-For $remote_addr;
            proxy_set_header Host            $http_host;
    
            proxy_pass http://ws_cluster; # 代理转发地址
        }
    }
    

    至此,项目部署完成。

    源码

    github:https://github.com/woodylan/go-websocket

    交流

    QQ 群:1028314856

    18 条回复    2020-08-08 16:08:34 +08:00
    Yoock
        1
    Yoock  
       2020-03-14 14:47:41 +08:00
    已 star
    LittleYangYang
        2
    LittleYangYang  
       2020-03-14 14:48:41 +08:00
    star 已交
    dylangl
        3
    dylangl  
    OP
       2020-03-14 14:50:01 +08:00
    whitehack
        4
    whitehack  
       2020-03-14 15:36:35 +08:00
    架构看起来不错。

    但是有一点比较奇怪,为什么客户端已经有 ws 连接了,还要通过 http 来与业务系统通信?
    然后业务系统再通过 ws 来推送消息。而不是直接通过 ws 解决问题? http 只是用来做辅助。
    我感觉 ws 为主,http 为辅,是比较合理的方案。
    而你这里是 http 为主,ws 为辅。

    只是比较好奇。望解惑。
    onesec
        5
    onesec  
       2020-03-14 16:13:35 +08:00
    开源不易,已经 star
    circleee
        6
    circleee  
       2020-03-14 16:23:06 +08:00
    star
    linxl
        7
    linxl  
       2020-03-14 16:43:35 +08:00
    居然 star 过
    dylangl
        8
    dylangl  
    OP
       2020-03-14 17:16:40 +08:00   ❤️ 1
    @whitehack 我做的定位是微服务,可以给不同的业务系统使用。所以 ws 的定位是用来接收消息,http 用来发送消息。为什么要这么做的? ws 与业务系统是解耦的,ws 收到的消息不会转发到业务系统。要实现解耦,那么就只能先把消息发送到业务系统,业务系统处理好业务逻辑之后再通过 http 发送给 ws server。
    dylangl
        9
    dylangl  
    OP
       2020-03-14 17:16:54 +08:00
    @linxl 感谢支持
    Leigg
        10
    Leigg  
       2020-03-14 17:23:04 +08:00
    群聊的逻辑描述的是否不大清楚?群组是没有连接的吧?有的是每个用户的 ws 连接。
    说说我的想法:按理说 ws-server 应该是无状态的,所以根据群组 id 找到群成员连接的任务应该要落到 [业务系统] 上来执行,然后 [业务系统] 应该维护好所有用户连接态,能够根据每个群成员 id 找到对应 ws-server,通过 rpc 让对应 ws-server 给成员发消息。
    JRyan
        11
    JRyan  
       2020-03-14 17:43:28 +08:00 via Android
    这种适合做直播弹幕吧,B 站的 goim 是这个思路
    dylangl
        12
    dylangl  
    OP
       2020-03-14 17:58:58 +08:00
    @Leigg 可能我描述得不是很清楚,但我实际的做法跟你想的差不多。用户的连接 id ( client id )是由业务系统去维护的,当需要发送到某个用户,由业务系统去找到相应的连接 id,然后发送消息。

    业务系统可以通过 ws server 绑定 client id 到指定的分组(比如房间),有提供相应的接口。业务系统也可以发送消息到指定的分组。
    hantsy
        13
    hantsy  
       2020-03-14 17:59:21 +08:00
    过去我的程序一般都是 HTTP 为主,WS,SSE 为辅助(解决实时要求)。
    dylangl
        14
    dylangl  
    OP
       2020-03-14 17:59:42 +08:00
    @JRyan 弹幕、聊天、消息通知等都可以用这个。
    dylangl
        15
    dylangl  
    OP
       2020-03-14 18:01:30 +08:00
    @hantsy 这个程序主要解决实时消息推送的问题,可能跟你过去的程序类型不太一样。
    cabing
        16
    cabing  
       2020-03-14 18:38:48 +08:00
    开源不容易。

    已 star,增加更多的单元测试就更好了。
    dylangl
        17
    dylangl  
    OP
       2020-03-15 16:10:18 +08:00
    @cabing 谢谢,后续会增加
    1044523901
        18
    1044523901  
       2020-08-08 16:08:34 +08:00
    不错
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1645 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 16:55 · PVG 00:55 · LAX 08:55 · JFK 11:55
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.