SpringBoot + Netty + WebSocket + ConcurrentHashMap 高性能消息推送服務器

SpringBoot + Netty + WebSocket + 雙向ConcurrentHashMap 高性能消息推送服務器

項目地址

https://github.com/KeepSorted/PushServer

項目需求

最近老闆提出新的需求,大概就是手機發送要打印的東西到電腦,然後電腦接收到之後打印出來。因爲手機和電腦不能直接通信,所以只能通過服務器中轉,核心思想是通過ID標記電腦,然後手機向該ID發送消息。

  1. 能實現點對點的消息推送
  2. 同時在線人數預計超過3k
  3. 響應速度不超過1s

這種情況下有兩種通信方案

  1. POLL輪詢。
    • 思路: 服務器保存一個 Map<ID: Integer, Message: List<String>>格式的消息緩存。手機發送<ID, Message>到服務器,電腦通過HTTP輪詢,間隔小於1s, 通過ID獲取自己的消息。
    • 優點: 實現簡單,不需要維護連接。
    • 缺點: 響應速度慢,因爲輪詢有一定的間隔,不能做到實時響應;服務器開銷大,因爲大多數的請求都是無效的。
  2. PUSH推送。
    • 思路: 服務器維護一個連接池;電腦連接上之後服務器記錄下 <ID, Connection>的對應關係,手機發送<ID, Message>到服務器,服務器收到後主動向電腦推送消息。
    • 優點: 實時性好,因爲長連接沒有輪詢間隔,可以做到實時推送;運行開銷小,因爲平時只是維護連接,基本不需要消耗cpu和帶寬。
    • 缺點: 開發難度大

作爲有追求的程序員[狗頭],毫無疑問選擇了後者。

設計與技術選型

大致方向定下來了,接下來就是設計系統架構和技術選型。

SpringBoot

之前開發用過Golang-Gin\Python-Flask,但最近Get到了Java的強大之處,所以改用Java開發。SpringBoot作爲Java目前最流行的、輕量級的框架,開發這種小型應用當然再合適不過了。

WebSocket長連接 + HTTP混合方式

  1. 不難看出,只有電腦端需要維護長連接。因爲手機只需要實現消息的發送,直接用HTTP發送消息就好了,也可以減少一些不必要的連接。
  2. 電腦端用的Vue開發的,相較於TCP\MQTT\MQ等方案,WebSocket更加適用於Web端。

Netty異步框架

Netty基於Nio,比傳統的Bio方案效率更高,性能更好,作爲有追求的程序員[再次狗頭], 在沒有歷史包袱的情況下,當然會選用更高端的方案。

雙向ConcurrentHashMap

上節中提到,服務器需要維護<ID, Connection>的對應關係,這樣在手機發來消息時可以找到應該推送給誰。那麼我們應該用什麼樣的數據結構呢?

  1. List。可以實現,但是每次查找的時候需要遍歷列表,也就是O(n)複雜度。在連接較少的時候還可以接收,如果消息和連接數多了之後,無疑會增大系統開銷,降低效率
  2. HashMap。於是我們想到了HashMap,因爲HashMap的插入、查找可以O(1)的複雜度。但是HashMap不是線程安全的,put操作會引起死循環。
  3. HashTable。爲了實現線程安全,可以使用HashTable。但其內部採用synchronized進行同步,是一種悲觀鎖,所以在多線程環境下會造成激烈競爭。
  4. ConcurrentHashMap。所以最後選擇了併發HashMap。ConcurrentHashMap使用了鎖分段技術,將數據分段,讀寫時對每段加鎖,這樣就避免了多個線程競爭同一把鎖的情況,大大提高了性能。
  5. 使用單哈希表有一個缺陷,那就是通過Value查找Key的複雜度是 O(n),因此以空間換時間的方法,使用兩個ConcurrentHashMap構成雙向哈希表,實現 O(1)時間的Key->Value或Value->Key的 查找、增加、刪除

所以最後採用了雙向ConcurrentHashMap保存<ID, Connection>連接,實現O(1)的時間複雜度向連接推送消息

心跳保活、定時清理方案

WebSocket雖然有onMessage、onConnect、onClientLeft等回調,但還是會有異常的情況發生,即連接丟失,但服務器還沒在全局Map中清理掉<ID, Connection>,長此以往,會造成內存泄露。所以需要清理連接的機制,保證丟失的連接可以被清理掉。

於是採用了 心跳保活+定時清理的方案,具體流程如下:

  1. 電腦定時發送心跳包(時間可以稍長,10s就可以),服務器收到後,給Connection打上最後一次收到消息的時間戳。
  2. 服務器開一個線程專門檢查連接是否正常,具體做法是: 比較當前時間戳和Connection的時間戳,如果間隔大於閾值(如1分鐘),則判斷連接異常斷開,則從Map中清理掉。

這個過程類似GC,在後臺自動清理掉垃圾。由於異常失效的連接非常少,所以這個開銷也基本可以忽略不計。追求極致性能還可以 使用LinkedHashMap,用類似LRU的方法在接近O(1)的複雜度下獲得超時連接(非常少且肯定隊尾),但這會設計到同步問題(ConcurrentLinkedHashMap解決),且有點炫技和過度設計的嫌疑,因此直接暴力遍歷就完事兒了。

實現步驟

創建maven項目,導入SpringBoot、Netty、WebSocket

  1. Intellij Idea用Spring Initializr創建項目,我用了Java12,因爲可以一路var,爽翻,再也不用寫又臭又長的類型聲明瞭[第三次狗頭]

  2. 創建Maven項目 Java version選11

  3. 一路next

  4. maven引入 netty、SprintBoot (pom.xml)

    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-all</artifactId>
        <version>4.1.36.Final</version>
    </dependency>
    <dependency>
     <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    

併發安全獲取自增ID (Utils.java)

 public class Utils {
    private static AtomicInteger counter = new AtomicInteger(0);
    /**
     * 生成ID, time: 13位 + random: 3位
     * @return id
     */
    public static long generateID () {
        return 1000000000 + counter.getAndIncrement();
    }

    /**
     * 打印
     * @param s
     */
    public static void log(String s) {
	//  System.out.println("[" + new Date().toString() + "]  " + s);
    }
}

這個generateID函數是爲了給客戶端分配唯一ID設計的。因爲在高併發情況下,普通int型數據無法保證併發安全

處理連接,轉發消息

首先是能通過鍵或值,在O(1) 的複雜度下進行 插入、查找、刪除的 “雙向HashMap” (BiDirectionHashMap.java)


import java.util.concurrent.ConcurrentHashMap;

/**
 * 雙向HashMap, 可以實現O(1) 按值/鍵 查找、添加、刪除元素對
 */
public class BiDirectionHashMap<K, V> {
    private ConcurrentHashMap<K, V> k2v; // key -> value
    private ConcurrentHashMap<V, K> v2k; // value -> key

    /**
     * 默認構造函數
     */
    BiDirectionHashMap() {
        this.k2v = new ConcurrentHashMap<>();
        this.v2k = new ConcurrentHashMap<>();
    }

    /**
     * 添加
     * @param k 鍵
     * @param v 值
     */
    public void put(K k, V v) {
        k2v.put(k, v);
        v2k.put(v, k);
    }

    /**
     * 查看大小
     * @return 大小
     */
    public int size () {
        return k2v.size();
    }

    /**
     * 是否有鍵
     * @param k 鍵
     * @return
     */
    public boolean containsKey(K k) {
        return k2v.containsKey(k);
    }

    /**
     * 是否有Value
     * @param v 值
     * @return
     */
    public boolean containsValue(V v) {
        return v2k.containsKey(v);
    }

    /**
     * 通過鍵刪除
     * @param k 鍵
     * @return
     */
    public boolean removeByKey(K k) {
        if (!k2v.containsKey(k)) {
            return false;
        }

        V value = k2v.get(k);
        k2v.remove(k);
        v2k.remove(value);
        return true;
    }

    /**
     * 通過值刪除
     * @param v 值
     * @return
     */
    public boolean removeByValue(V v) {
        if (!v2k.containsKey(v)) {
            return false;
        }

        K key = v2k.get(v);
        v2k.remove(v);
        k2v.remove(key);
        return true;
    }

    /**
     * 通過鍵獲取值
     * @param k
     * @return
     */
    public V getByKey(K k) {
        return k2v.getOrDefault(k, null);
    }

    /**
     * 通過值獲取鍵
     * @param v
     * @return
     */
    public K getByValue(V v) {
        return v2k.getOrDefault(v, null);
    }
}

其中用到了兩個ConcurrentHashMap,用到了分段鎖保證併發安全。

接下來是用來記錄連接的類(MyChannelHandlerMap.java)


/**
 * 用於共享
 */
public class MyChannelHandlerMap {
    /**
     * 保存映射關係的雙向Hash表
     */
    public static BiDirectionHashMap<Long, Channel> biDirectionHashMap = new BiDirectionHashMap<>();

    /**
     * TODO: 不活躍連接/異常連接清除
     * 記錄最後一次通信時間, 用於確定不活躍連接,然後清理掉
     */
    public static ConcurrentHashMap<Long, Date> lastUpdate = new ConcurrentHashMap<>();

    /**
     * 是否存在連接
     * @param id
     * @return
     */
    public boolean existConnectionByID (Long id) {
        return biDirectionHashMap.containsKey(id);
    }
}

然後開始啓動Netty服務器 (NettyServer.java),對Netty服務器進行配置,接收WebSocket請求,並交由Handler處理


public class NettyServer {
    private final int port;

    NettyServer(int port) {
        this.port = port;
    }

    public void start() throws Exception {
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            ServerBootstrap sb = new ServerBootstrap();
            sb.option(ChannelOption.SO_BACKLOG, 4096);
            sb.group(group, bossGroup) // 綁定線程池
                    .channel(NioServerSocketChannel.class) // 指定使用的channel
                    .localAddress(this.port)// 綁定監聽端口
                    .childHandler(new ChannelInitializer<SocketChannel>() { // 綁定客戶端連接時候觸發操作
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                            Utils.log("收到新連接");
                            ch.pipeline().addLast(new HttpServerCodec());
                            ch.pipeline().addLast(new ChunkedWriteHandler());
                            ch.pipeline().addLast(new HttpObjectAggregator(8192));
                            ch.pipeline().addLast(new WebSocketServerProtocolHandler("/push", null, true, 65536 * 10));
                            ch.pipeline().addLast(new MyWebSocketHandler());
                        }
                    });
            ChannelFuture cf = sb.bind().sync(); // 服務器異步創建綁定
            Utils.log(NettyServer.class + " 啓動正在監聽: " + cf.channel().localAddress());
            cf.channel().closeFuture().sync(); // 關閉服務器通道
        } finally {
            group.shutdownGracefully().sync(); // 釋放線程池資源
            bossGroup.shutdownGracefully().sync();
        }
    }
}

接下來是處理消息的Handler (MyWebSocketHandler.java)


public class MyWebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        Utils.log("與客戶端建立連接,通道開啓!");
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        Channel channel = ctx.channel();
        if (!MyChannelHandlerMap.biDirectionHashMap.containsValue(channel)) {
            Utils.log("該客戶端未註冊");
            return;
        }
        MyChannelHandlerMap.biDirectionHashMap.removeByValue(channel);
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        super.channelRead(ctx, msg);
    }

    /**
     * 刷新最後一次通信時間
     * @param channel 通道
     */
    private void freshTime (Channel channel) {
        if (MyChannelHandlerMap.biDirectionHashMap.containsValue(channel)) {
            Utils.log("update time");
            long id = MyChannelHandlerMap.biDirectionHashMap.getByValue(channel);
            MyChannelHandlerMap.lastUpdate.put(id, new Date());
        }
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame textWebSocketFrame) throws Exception {
        Channel channel = ctx.channel();
        freshTime(channel);

        Utils.log("read0: " + textWebSocketFrame.text());
        String text = textWebSocketFrame.text();

        // 收到生成ID的指令, 返回 id:xxxxxxxx
        if (text.equals("getID")) {
            // 已建立連接, 則返回已有ID
            if (MyChannelHandlerMap.biDirectionHashMap.containsValue(channel)) {
                Long id = MyChannelHandlerMap.biDirectionHashMap.getByValue(channel);
                channel.writeAndFlush(new TextWebSocketFrame("id:" + id));
                return;
            }
            Long id = Utils.generateID();  // 創建ID
            Utils.log("id ->  " + id);
            channel.writeAndFlush(new TextWebSocketFrame("id:" + id));
            MyChannelHandlerMap.biDirectionHashMap.put(id, ctx.channel());
            MyChannelHandlerMap.lastUpdate.put(id, new Date());
        }
    }
}

和處理http消息的Controler.java


@RestController
@RequestMapping("send")
public class Controller {
    @PostMapping("/{id}")
    public ResponseEntity send(
            @PathVariable(value = "id", required = true) Long id,
            @RequestParam(value = "data", required = true) String data
    ) {
        if (!MyChannelHandlerMap.biDirectionHashMap.containsKey(id)) {
            Utils.log("該ID未註冊");
            return Response.notFound();
        }
        Channel channel = MyChannelHandlerMap.biDirectionHashMap.getByKey(id);
        channel.writeAndFlush(new TextWebSocketFrame(data));
        Utils.log("向該ID發送消息:" + data);
        return Response.success();
    }
}

Response.java


public class Response {
    public static ResponseEntity success() {
        return new ResponseEntity<>((Map<String, Object>) null, HttpStatus.OK);
    }

    public static ResponseEntity notFound() {
        return new ResponseEntity<>((Map<String, Object>) null, HttpStatus.NOT_FOUND);
    }

    public static ResponseEntity error() {
        return new ResponseEntity<>((Map<String, Object>) null, HttpStatus.BAD_REQUEST);
    }
}

PushServiceApplication.java 入口


@SpringBootApplication
public class PushServerApplication extends SpringBootServletInitializer {

    public static void main(String[] args) {
        SpringApplication.run(PushServerApplication.class, args);

        new Thread(new ClientsCheck()).start();  // 客戶端檢查

        try {
            new NettyServer(12345).start();
        }catch(Exception e) {
            Utils.log("NettyServerError:"+e.getMessage());
        }

    }

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder springApplicationBuilder) {
        return springApplicationBuilder.sources(this.getClass());
    }

}

流程大概是這樣的:

  1. Client與Server建立連接,此時不做任何處理
  2. Client連接成功後,發送 getID指令
  3. Server收到getID指令後,爲該客戶端生成一個唯一ID,並將<ID, Channel>的映射關係存起來
  4. 其他客戶端(推送方)發送消息<ID, Message>的時候,Server查Hash表找到Channel,並向其發送Message
  5. 當Client斷開之後,Server通過Channel快速找到ID,並在雙向Hash表中刪除兩者

定時清理

ClientsCheck.java (待完善)


public class ClientsCheck implements Runnable{
    @Override
    public void run() {
        try {
            while (true) {
                int size = MyChannelHandlerMap.biDirectionHashMap.size();
                Utils.log("client quantity -> " + size);
                Thread.sleep(10000);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

這個類實現了Runnable接口,可以作爲一個後臺任務清理不活動的連接,前面我們有記錄每個連接的最後通信時間lastUpdated,我麼可以將當前時間nowlastUpdated進行比較,超過閾值則清理連接。

性能測試

使用Python的gevent進行大併發測試。

10k併發測試

from gevent import monkey; monkey.patch_all()
import gevent
import websocket
from gevent import pool

PUSH_URL = 'ws://xxx.xxx.xxx/push'  # ws的url

def create_ws():
    ws = websocket.WebSocketApp(PUSH_URL, 
                                on_open=lambda ws: ws.send('getID'),  # 連接後發送getID指令
                                on_message=lambda ws, msg: print(msg),
                                on_error=lambda ws, err: print(err))
    ws.run_forever()

threads = []
for i in range(10000):  # 併發10000
    threads.append(gevent.spawn(create_ws))

print('finished -> ', len(threads))
gevent.joinall(threads)

在測試程序裏面,通過gevent併發了10000個連接 (基於協程的併發框架,如果線程的話做不到這麼高)

在這裏插入圖片描述

在併發數爲10k的時候,後臺佔用400多MB內存,在可接受的範圍內,並且在維持連接的時候,幾乎不佔用CPU資源

單次延遲 (非嚴謹)

使用curl工具調用發送接口,並記錄當前時間。測試環境爲

  • CPU: AMD Ryzen 3500u
  • 內存: 12g

用本地環回地址 127.0.0.1進行單機測試

在這裏插入圖片描述

單次消息響應時間應當小於200ms

平均延遲 (待續)

待續…

項目地址

https://github.com/KeepSorted/PushServer

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章