SpringBoot + Netty + WebSocket + 雙向ConcurrentHashMap 高性能消息推送服務器
項目地址
https://github.com/KeepSorted/PushServer
項目需求
最近老闆提出新的需求,大概就是手機發送要打印的東西到電腦,然後電腦接收到之後打印出來。因爲手機和電腦不能直接通信,所以只能通過服務器中轉,核心思想是通過ID標記電腦,然後手機向該ID發送消息。
- 能實現點對點的消息推送
- 同時在線人數預計超過3k
- 響應速度不超過1s
這種情況下有兩種通信方案
- POLL輪詢。
- 思路: 服務器保存一個
Map<ID: Integer, Message: List<String>>
格式的消息緩存。手機發送<ID, Message>到服務器,電腦通過HTTP輪詢,間隔小於1s, 通過ID獲取自己的消息。 - 優點: 實現簡單,不需要維護連接。
- 缺點: 響應速度慢,因爲輪詢有一定的間隔,不能做到實時響應;服務器開銷大,因爲大多數的請求都是無效的。
- 思路: 服務器保存一個
- PUSH推送。
- 思路: 服務器維護一個連接池;電腦連接上之後服務器記錄下
<ID, Connection>
的對應關係,手機發送<ID, Message>到服務器,服務器收到後主動向電腦推送消息。 - 優點: 實時性好,因爲長連接沒有輪詢間隔,可以做到實時推送;運行開銷小,因爲平時只是維護連接,基本不需要消耗cpu和帶寬。
- 缺點: 開發難度大
- 思路: 服務器維護一個連接池;電腦連接上之後服務器記錄下
作爲有追求的程序員[狗頭],毫無疑問選擇了後者。
設計與技術選型
大致方向定下來了,接下來就是設計系統架構和技術選型。
SpringBoot
之前開發用過Golang-Gin\Python-Flask,但最近Get到了Java的強大之處,所以改用Java開發。SpringBoot作爲Java目前最流行的、輕量級的框架,開發這種小型應用當然再合適不過了。
WebSocket長連接 + HTTP混合方式
- 不難看出,只有電腦端需要維護長連接。因爲手機只需要實現消息的發送,直接用HTTP發送消息就好了,也可以減少一些不必要的連接。
- 電腦端用的Vue開發的,相較於TCP\MQTT\MQ等方案,WebSocket更加適用於Web端。
Netty異步框架
Netty基於Nio,比傳統的Bio方案效率更高,性能更好,作爲有追求的程序員[再次狗頭], 在沒有歷史包袱的情況下,當然會選用更高端的方案。
雙向ConcurrentHashMap
上節中提到,服務器需要維護<ID, Connection>的對應關係,這樣在手機發來消息時可以找到應該推送給誰。那麼我們應該用什麼樣的數據結構呢?
- List。可以實現,但是每次查找的時候需要遍歷列表,也就是O(n)複雜度。在連接較少的時候還可以接收,如果消息和連接數多了之後,無疑會增大系統開銷,降低效率
- HashMap。於是我們想到了HashMap,因爲HashMap的插入、查找可以O(1)的複雜度。但是HashMap不是線程安全的,put操作會引起死循環。
- HashTable。爲了實現線程安全,可以使用HashTable。但其內部採用
synchronized
進行同步,是一種悲觀鎖,所以在多線程環境下會造成激烈競爭。 - ConcurrentHashMap。所以最後選擇了併發HashMap。ConcurrentHashMap使用了鎖分段技術,將數據分段,讀寫時對每段加鎖,這樣就避免了多個線程競爭同一把鎖的情況,大大提高了性能。
- 使用單哈希表有一個缺陷,那就是通過Value查找Key的複雜度是 O(n),因此以空間換時間的方法,使用兩個ConcurrentHashMap構成雙向哈希表,實現 O(1)時間的Key->Value或Value->Key的 查找、增加、刪除
所以最後採用了雙向ConcurrentHashMap保存<ID, Connection>連接,實現O(1)的時間複雜度向連接推送消息。
心跳保活、定時清理方案
WebSocket雖然有onMessage、onConnect、onClientLeft等回調,但還是會有異常的情況發生,即連接丟失,但服務器還沒在全局Map中清理掉<ID, Connection>,長此以往,會造成內存泄露。所以需要清理連接的機制,保證丟失的連接可以被清理掉。
於是採用了 心跳保活+定時清理的方案,具體流程如下:
- 電腦定時發送心跳包(時間可以稍長,10s就可以),服務器收到後,給Connection打上最後一次收到消息的時間戳。
- 服務器開一個線程專門檢查連接是否正常,具體做法是: 比較當前時間戳和Connection的時間戳,如果間隔大於閾值(如1分鐘),則判斷連接異常斷開,則從Map中清理掉。
這個過程類似GC,在後臺自動清理掉垃圾。由於異常失效的連接非常少,所以這個開銷也基本可以忽略不計。追求極致性能還可以 使用LinkedHashMap,用類似LRU的方法在接近O(1)的複雜度下獲得超時連接(非常少且肯定隊尾),但這會設計到同步問題(ConcurrentLinkedHashMap解決),且有點炫技和過度設計的嫌疑,因此直接暴力遍歷就完事兒了。
實現步驟
創建maven項目,導入SpringBoot、Netty、WebSocket
-
Intellij Idea用Spring Initializr創建項目,我用了Java12,因爲可以一路var,爽翻,再也不用寫又臭又長的類型聲明瞭[第三次狗頭]
-
創建Maven項目 Java version選11
-
一路next
-
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());
}
}
流程大概是這樣的:
- Client與Server建立連接,此時不做任何處理
- Client連接成功後,發送 getID指令
- Server收到getID指令後,爲該客戶端生成一個唯一ID,並將<ID, Channel>的映射關係存起來
- 其他客戶端(推送方)發送消息<ID, Message>的時候,Server查Hash表找到Channel,並向其發送Message
- 當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
,我麼可以將當前時間now
與lastUpdated
進行比較,超過閾值則清理連接。
性能測試
使用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