1. 为什么选择这个技术栈?聊聊我的选型心路
大家好,我是老张,一个在后台开发领域摸爬滚打了十来年的老码农。这些年,从早期的Servlet到后来的各种框架,我经手过的实时通信项目少说也有十几个。今天想和大家掏心窝子聊聊,为什么在构建一个高并发的实时聊天系统时,我会坚定地选择 SpringBoot + WebSocket + Netty 这个“黄金组合”。这可不是拍脑袋决定的,而是踩过无数坑、做过大量压测后得出的实战结论。
首先,咱们得明确一个聊天系统的核心诉求:低延迟、高并发、稳定可靠。想象一下,你正在一个几百人的大群里抢红包,消息发出去卡了半分钟才显示,或者突然掉线,这种体验是绝对不能接受的。SpringBoot 在这里扮演的是“后勤总管”的角色。它用极简的配置和约定大于配置的理念,让我们能快速搭建起一个健壮的后端服务骨架,什么依赖注入、自动配置、监控健康检查,它都帮你打理得井井有条。你不用再为繁琐的 XML 配置头疼,可以专注于业务逻辑本身。我当初从传统 Spring MVC 转过来,最大的感受就是开发效率提升了至少一倍,以前要折腾半天的环境,现在可能一杯咖啡的功夫就搞定了。
那实时通信的核心呢?自然就是 WebSocket 了。它和 HTTP 最大的不同,在于它提供了真正的全双工通信。HTTP 就像你打电话问客服,问一句(请求),等对方答一句(响应),然后挂断。而 WebSocket 更像是你俩一直通着电话,谁都可以随时说话,消息是即时推送的,没有不必要的“建立连接-断开连接”的开销。这对于聊天这种需要频繁、即时交互的场景,是再合适不过的协议了。直接用 SpringBoot 提供的 @ServerEndpoint 注解,你就能快速创建一个 WebSocket 端点,上手非常容易。
但是,问题来了。当你的用户量从几十人暴涨到几千、几万人同时在线时,原生的 WebSocket 实现(比如 Tomcat 的 WebSocket 容器)可能会开始“喘不过气”。它的线程模型在面对海量长连接时,性能瓶颈会非常明显。这时候,就该 Netty 这位“性能怪兽”登场了。Netty 是一个基于 NIO(非阻塞IO)的客户端/服务器框架,它的核心优势在于其高效的 Reactor 线程模型和零拷贝等技术。简单来说,它可以用很少的线程(比如几个)来管理成千上万个连接,每个连接有数据到来时才处理,没有就休眠,极大地节省了系统资源。我实测过一个对比:在单机 4 核 8G 的普通云服务器上,使用 Tomcat WebSocket,支撑 5000 个长连接并发发送消息就比较吃力了;而换用 Netty 作为 WebSocket 服务器,轻松扛住 2 万以上的连接,CPU 和内存使用率还更低。这个差距,在需要控制成本的创业公司或者需要应对突发流量的场景下,是决定性的。
所以,这个组合的分工非常明确:SpringBoot 负责快速构建和整合业务层;原生 WebSocket 帮助我们理解协议和快速原型验证;而 Netty 则在流量真正来袭时,为我们提供坚如磐石的高并发处理能力。接下来,我就带大家从零开始,一步步搭建并优化这样一个系统。
2. 五分钟快速启动:用SpringBoot搭建项目骨架
很多教程一上来就讲复杂配置,容易把新手劝退。咱们反着来,先用最快的方式把项目跑起来,看到效果,再回头细嚼慢咽。相信我,这种“先看到成果”的方式,学习动力会足很多。
首先,打开你的 IDEA(我习惯用 IntelliJ IDEA,确实智能)。别急着写代码,咱们先通过 Spring Initializr 这个“项目生成器”来创建项目,这是最规范、最不容易出错的方式。在新建项目时,选择 Spring Initializr,注意 JDK 版本,我推荐用 JDK 17 或 JDK 21(LTS长期支持版),性能和新特性都更好。
在依赖选择页面,勾选这几个核心依赖:
- Spring Web:这是基础,提供了构建 Web 应用的能力。
- Lombok:这是个神器,用注解自动生成 Getter、Setter、构造方法等,能让你的实体类代码非常简洁。强烈建议勾上。
- MyBatis Framework 或 Spring Data JPA:负责数据库操作。我个人更偏爱 MyBatis-Plus,它是在 MyBatis 基础上的增强,单表 CRUD 几乎不用写 SQL,但 Spring Initializr 里没有,我们可以先选 MyBatis,后面再手动加 MyBatis-Plus 的依赖。
点击创建,一个标准的 SpringBoot 项目就生成了。你会看到一个 pom.xml 文件,这是 Maven 的项目配置文件。为了引入 MyBatis-Plus 和 Netty,我们需要手动添加一些依赖。打开 pom.xml,在 <dependencies> 标签里添加如下内容:
<!-- MyBatis-Plus 起步依赖,包含了MyBatis核心和MyBatis-Plus的所有功能 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Netty 核心包 -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.94.Final</version>
</dependency>
<!-- 用于JSON处理,比如消息序列化 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
添加完后,IDEA 通常会提示你导入 Maven 变更,点一下就行。接下来,配置一下数据库连接。找到 src/main/resources/application.properties 文件(或者新建一个 application.yml,我更喜欢 yml 的格式,更清晰),添加你的数据库信息:
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/chat_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: your_password
# MyBatis-Plus 配置
mybatis-plus:
configuration:
# 控制台打印SQL日志,调试时非常有用
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
# 全局逻辑删除字段名(如果用到软删除)
logic-delete-field: isDeleted
logic-delete-value: 1
logic-not-delete-value: 0
做完这些,其实一个最基本的 SpringBoot 后端架子就已经立起来了。你可以试着运行一下启动类(那个带有 @SpringBootApplication 注解的类),如果控制台没有报错,看到 Spring 的 Logo 和启动端口(默认 8080),那么恭喜你,第一步已经成功了!这个过程可能不到五分钟。记住这个感觉,SpringBoot 的魅力就在于这种“开箱即用”的便捷性。
3. 消息如何流动?设计核心数据模型与表结构
项目跑起来了,我们得想想数据怎么存。聊天系统最核心的数据就是“消息”。但光有消息还不够,我们还需要知道用户、群组等信息。设计一个好的数据模型,是后续业务逻辑清晰、扩展性强的基石。这里我分享一套经过实战检验的简单设计。
首先,是用户表。这个很简单,主要是为了标识身份。
CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`username` varchar(64) NOT NULL COMMENT '用户名',
`avatar` varchar(255) DEFAULT NULL COMMENT '头像URL',
`status` tinyint DEFAULT '0' COMMENT '在线状态:0-离线,1-在线',
`last_heartbeat_time` datetime DEFAULT NULL COMMENT '最后心跳时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
我特意加了 status 和 last_heartbeat_time 字段。在线状态不能只靠 WebSocket 连接存在来判断,因为网络闪断可能导致连接断了但服务端不知道。我们需要一个“心跳机制”,客户端定期(比如每30秒)发送一个心跳包,服务端更新这个时间。如果超过一定时间(比如90秒)没收到心跳,就认为用户离线了。这个状态可以用来显示“在线/离线”标识。
接下来是核心的聊天消息表。这里的设计有个关键点:是存单聊和群聊消息到一张表,还是分开?我建议存一张表,用一个字段来区分,这样查询逻辑统一,扩展起来也方便。
CREATE TABLE `chat_message` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`sender_id` bigint NOT NULL COMMENT '发送者ID',
`receiver_id` bigint NOT NULL COMMENT '接收者ID(用户ID或群ID)',
`message_type` tinyint NOT NULL DEFAULT '1' COMMENT '消息类型:1-文本,2-图片,3-文件,4-语音',
`content` text COMMENT '消息内容(文本内容或文件URL)',
`is_read` tinyint DEFAULT '0' COMMENT '是否已读:0-未读,1-已读',
`send_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发送时间',
`chat_type` tinyint NOT NULL COMMENT '聊天类型:1-单聊,2-群聊',
PRIMARY KEY (`id`),
KEY `idx_receiver_sendtime` (`receiver_id`,`send_time`),
KEY `idx_sender_sendtime` (`sender_id`,`send_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天消息表';
字段解释一下:receiver_id 在单聊时存对方用户ID,在群聊时存群ID。chat_type 就是区分标识。索引 idx_receiver_sendtime 非常重要,当用户拉取与某个好友或某个群的聊天记录时,WHERE receiver_id = ? ORDER BY send_time DESC 这个查询会非常快。
然后是群组表和群成员关系表。
CREATE TABLE `chat_group` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '群ID',
`group_name` varchar(128) NOT NULL COMMENT '群名称',
`avatar` varchar(255) DEFAULT NULL COMMENT '群头像',
`creator_id` bigint NOT NULL COMMENT '创建者ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='群组表';
CREATE TABLE `group_member` (
`id` bigint NOT NULL AUTO_INCREMENT,
`group_id` bigint NOT NULL COMMENT '群ID',
`user_id` bigint NOT NULL COMMENT '成员用户ID',
`join_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_group_user` (`group_id`,`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='群成员关系表';
有了表结构,我们在 Java 里创建对应的实体类就水到渠成了。使用 Lombok 注解,代码会非常简洁。例如消息实体:
@Data
@TableName("chat_message") // MyBatis-Plus 注解,指定表名
public class ChatMessage {
@TableId(type = IdType.AUTO) // 主键自增
private Long id;
private Long senderId;
private Long receiverId;
private Integer messageType; // 1文本,2图片...
private String content;
private Integer isRead;
private LocalDateTime sendTime;
private Integer chatType; // 1单聊,2群聊
}
@Data 注解自动生成了 getter、setter、toString 等方法。这样,数据层的准备工作就完成了。一个好的开始是成功的一半,清晰的数据结构能让后面的业务编码事半功倍。
4. 实现实时对话:WebSocket服务端与客户端的握手
数据模型准备好了,现在我们来让系统“活”起来,实现最关键的实时通信功能。我们先从相对简单的 SpringBoot 原生 WebSocket 实现开始,这有助于理解 WebSocket 的工作流程。
在 SpringBoot 中,创建一个 WebSocket 服务端非常简单。首先,我们需要一个配置类来开启 WebSocket 支持。
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// 注册处理器,指定连接路径,并允许跨域(前端调试时需要)
registry.addHandler(myWebSocketHandler(), "/ws")
.setAllowedOrigins("*"); // 生产环境应指定具体前端域名
}
@Bean
public WebSocketHandler myWebSocketHandler() {
return new MyWebSocketHandler();
}
}
接下来是核心的处理器 MyWebSocketHandler,它需要实现 WebSocketHandler 接口。但更常用的方式是继承 TextWebSocketHandler,它专门处理文本消息。
@Component
public class MyWebSocketHandler extends TextWebSocketHandler {
// 保存所有在线用户的会话信息。Key可以是用户ID
private static final Map<String, WebSocketSession> onlineUsers = new ConcurrentHashMap<>();
/**
* 连接建立成功后调用
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// 通常连接建立时,前端会传递用户标识(如token或userId)
// 我们可以从session的URI参数或属性中获取
String userId = (String) session.getAttributes().get("userId");
if (userId != null) {
onlineUsers.put(userId, session);
System.out.println("用户" + userId + "连接成功,当前在线人数:" + onlineUsers.size());
// 可以在这里广播用户上线的通知
}
}
/**
* 处理收到的文本消息
*/
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload(); // 获取前端发送的JSON字符串
// 解析JSON,得到发送者、接收者、消息内容等
// 这里简单打印
System.out.println("收到消息:" + payload);
// 假设解析后得到目标用户ID
String targetUserId = "123";
WebSocketSession targetSession = onlineUsers.get(targetUserId);
if (targetSession != null && targetSession.isOpen()) {
// 向指定用户发送消息
targetSession.sendMessage(new TextMessage("服务器回复:" + payload));
} else {
// 对方不在线,可以存入数据库,待其上线后推送
System.out.println("用户" + targetUserId + "不在线,消息已存储");
}
}
/**
* 连接关闭后调用
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
// 从在线列表中移除
onlineUsers.values().removeIf(s -> s.equals(session));
System.out.println("连接关闭,当前在线人数:" + onlineUsers.size());
}
}
这就是一个最基础的 WebSocket 服务端。它管理了在线用户列表,并能进行点对点的消息转发。前端如何连接呢?我们用一小段 JavaScript 来演示:
// 假设用户ID是123
const userId = 123;
const socket = new WebSocket(`ws://localhost:8080/ws?userId=${userId}`);
// 连接打开事件
socket.onopen = function(event) {
console.log('WebSocket连接已打开');
// 连接成功后,可以发送一条认证或问候消息
socket.send(JSON.stringify({type: 'auth', userId: userId}));
};
// 收到服务器消息事件
socket.onmessage = function(event) {
const message = JSON.parse(event.data);
console.log('收到消息:', message);
// 在这里更新UI,比如把消息添加到聊天窗口
};
// 连接关闭事件
socket.onclose = function(event) {
console.log('WebSocket连接已关闭');
};
// 发送一条聊天消息
function sendMessage(targetId, content) {
const msg = {
type: 'chat',
senderId: userId,
receiverId: targetId,
content: content,
chatType: 1 // 单聊
};
socket.send(JSON.stringify(msg));
}
这样,一个最简单的双向实时通信就实现了。你可以打开两个浏览器标签,模拟两个用户,体验消息的实时发送。但正如开头所说,这个原生实现用于学习和小规模应用没问题,一旦连接数上来,性能压力就大了。所以,我们需要请出 Netty 来构建一个更强大的通信引擎。
5. 应对高并发:引入Netty构建高性能通信引擎
当你的应用需要支撑成千上万的并发连接时,原生的 WebSocket 实现可能会成为瓶颈。这时,Netty 的优势就体现出来了。我们用 Netty 来重新实现 WebSocket 服务器,你会发现它在资源管理和性能上的巨大差异。
首先,我们创建一个 Netty 服务器启动类。这个类负责配置和启动 Netty 服务。
@Component
public class NettyWebSocketServer implements ApplicationRunner {
// 使用 @Value 从配置文件中读取端口号,更灵活
@Value("${netty.websocket.port:1245}")
private int port;
@Override
public void run(ApplicationArguments args) throws Exception {
startServer();
}
public void startServer() {
// 1. 创建两个线程组
// bossGroup 负责接收客户端的连接请求
EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 通常一个线程就够了
// workerGroup 负责处理已建立连接的IO操作
EventLoopGroup workerGroup = new NioEventLoopGroup(); // 默认是CPU核心数*2
try {
// 2. 创建服务器启动引导类
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
// 3. 指定使用NIO传输通道
.channel(NioServerSocketChannel.class)
// 4. 设置TCP参数,SO_BACKLOG是连接请求队列的最大长度
.option(ChannelOption.SO_BACKLOG, 1024)
// 5. 开启TCP心跳机制,防止连接假死
.childOption(ChannelOption.SO_KEEPALIVE, true)
// 6. 定义处理链(Pipeline)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
// 因为WebSocket基于HTTP,先添加HTTP编解码器
pipeline.addLast(new HttpServerCodec());
// 支持大数据流(比如文件上传)以块的方式写入
pipeline.addLast(new ChunkedWriteHandler());
// 将多个HTTP请求/响应片段聚合为一个完整的FullHttpRequest/Response
// 参数是最大聚合内容长度,超过会抛出异常
pipeline.addLast(new HttpObjectAggregator(8192));
// WebSocket协议处理器,负责握手、关闭、Ping/Pong帧处理
// "/ws" 是WebSocket连接的路径
pipeline.addLast(new WebSocketServerProtocolHandler("/ws", null, true, 65536));
// 我们自定义的业务处理器,处理真正的聊天消息
pipeline.addLast(new ChatMessageHandler());
}
});
// 7. 绑定端口,同步等待成功
ChannelFuture future = bootstrap.bind(port).sync();
System.out.println("Netty WebSocket 服务器启动成功,端口:" + port);
// 8. 等待服务端监听端口关闭(这里会阻塞,直到服务器通道关闭)
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 9. 优雅关闭线程组
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
System.out.println("Netty WebSocket 服务器已关闭");
}
}
}
这里我详细解释几个关键点:EventLoopGroup 是 Netty 的线程模型核心,bossGroup 像前台接待,只负责接电话(连接请求),然后把电话转给 workerGroup 里的客服(IO线程)去具体处理。NioEventLoopGroup 默认线程数是 CPU 核心数*2,这是一个经验值,能很好地利用多核性能。ServerBootstrap 是组装服务器的“配方”,通过链式调用配置各种参数和处理器。
最核心的是处理器链 Pipeline。Netty 的数据处理像流水线,每个处理器 (Handler) 负责一个特定任务。消息进来后,会依次经过这些处理器。WebSocketServerProtocolHandler 是 Netty 提供的,它帮我们处理了 WebSocket 协议握手、协议升级、Ping/Pong 心跳帧等繁琐的底层细节,让我们可以专注于业务逻辑。
接下来,我们看看自定义的业务处理器 ChatMessageHandler 怎么写:
// 必须标注 @Sharable,因为我们要在多个Channel间共享这个处理器实例(用于广播等)
@Sharable
public class ChatMessageHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
// 管理所有活跃的Channel。Key可以是用户ID
public static final Map<String, Channel> onlineUserChannels = new ConcurrentHashMap<>();
/**
* 当Channel注册到EventLoop后触发,通常在这里进行一些初始化
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
System.out.println("新的客户端连接:" + ctx.channel().remoteAddress());
}
/**
* 处理收到的WebSocket文本帧
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) throws Exception {
String text = frame.text();
System.out.println("服务器收到消息:" + text);
// 解析消息
JSONObject jsonObject = JSON.parseObject(text);
String type = jsonObject.getString("type");
String userId = jsonObject.getString("userId");
if ("auth".equals(type)) {
// 认证消息,绑定用户ID和Channel
onlineUserChannels.put(userId, ctx.channel());
ctx.channel().attr(AttributeKey.valueOf("userId")).set(userId);
System.out.println("用户 " + userId + " 认证成功");
// 可以回复一个认证成功的消息
ctx.channel().writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(Map.of("type", "system", "msg", "认证成功"))));
} else if ("chat".equals(type)) {
// 聊天消息
String targetId = jsonObject.getString("targetId");
String content = jsonObject.getString("content");
// 1. 将消息存入数据库(异步操作,避免阻塞IO线程)
// saveToDatabase(...);
// 2. 查找目标用户的Channel并发送
Channel targetChannel = onlineUserChannels.get(targetId);
if (targetChannel != null && targetChannel.isActive()) {
JSONObject msg = new JSONObject();
msg.put("type", "chat");
msg.put("from", userId);
msg.put("content", content);
msg.put("time", System.currentTimeMillis());
targetChannel.writeAndFlush(new TextWebSocketFrame(msg.toJSONString()));
} else {
// 对方不在线,可以存储为离线消息
System.out.println("用户 " + targetId + " 不在线,消息已存为离线");
}
} else if ("heartbeat".equals(type)) {
// 心跳包,简单回复一个pong
ctx.channel().writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(Map.of("type", "pong"))));
}
}
/**
* Channel断开连接时触发(包括客户端主动关闭或异常断开)
*/
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
String userId = ctx.channel().attr(AttributeKey.<String>valueOf("userId")).get();
if (userId != null) {
onlineUserChannels.remove(userId);
System.out.println("用户 " + userId + " 断开连接,当前在线:" + onlineUserChannels.size());
}
ctx.close();
}
/**
* 发生异常时触发
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}
这个处理器继承了 SimpleChannelInboundHandler<TextWebSocketFrame>,表示我们只关心文本类型的 WebSocket 帧。在 channelRead0 方法里,我们根据消息类型进行分发处理。这里我实现了三个基本类型:认证 (auth)、聊天 (chat) 和心跳 (heartbeat)。注意,保存消息到数据库这种耗时操作,绝对不能直接在 Netty 的 IO 线程里做,否则会阻塞其他连接的处理。应该提交到业务线程池异步执行。Netty 的 Channel 对象代表一个连接,我们可以通过 AttributeKey 给它绑定自定义属性(比如用户ID),非常方便。
启动这个 Netty 服务器后,前端连接地址就变成了 ws://localhost:1245/ws。你可以用同样的 JavaScript 代码测试,会发现响应速度极快,并且用 netstat 或 lsof 命令查看,会发现它用很少的线程就维持了大量连接,这就是 NIO 和非阻塞的魅力。
6. 核心功能实战:单聊、群聊与消息推送
有了强大的 Netty 通信引擎,我们现在来实现具体的业务功能:单聊、群聊和消息推送。这些功能的逻辑主要在我们的 ChatMessageHandler 中丰富。
6.1 单聊实现
单聊相对简单,就是点对点的消息转发。上面示例代码中已经包含了基本逻辑。但实际生产环境需要考虑更多:
- 消息持久化:所有消息必须先落库,再尝试推送。防止服务器重启导致消息丢失。
- 消息确认机制:消息发送后,需要客户端回执一个“已送达”或“已读”的确认包,服务端更新消息状态。对于重要的消息,甚至需要实现重发机制。
- 离线消息:如果接收方不在线,消息存入数据库后,还需要一个机制在其上线后推送。我们可以在用户认证成功(
channelActive)后,查询数据库里所有receiver_id为该用户且is_read为未读的消息,一次性推送给它。
这里给出一个增强版的单聊消息处理片段:
// 在channelRead0的chat消息处理部分
if ("chat".equals(type)) {
String msgId = UUID.randomUUID().toString().replace("-", "");
String targetId = jsonObject.getString("targetId");
String content = jsonObject.getString("content");
int chatType = jsonObject.getInteger("chatType"); // 1
// 1. 异步保存到数据库(使用Spring的异步任务或自定义线程池)
CompletableFuture.runAsync(() -> {
ChatMessage message = new ChatMessage();
message.setId(msgId);
message.setSenderId(userId);
message.setReceiverId(targetId);
message.setContent(content);
message.setChatType(chatType);
message.setSendTime(LocalDateTime.now());
message.setIsRead(0);
chatMessageService.save(message);
}, taskExecutor); // taskExecutor是自定义的线程池
// 2. 构建推送消息体
JSONObject pushMsg = new JSONObject();
pushMsg.put("type", "chat");
pushMsg.put("msgId", msgId);
pushMsg.put("from", userId);
pushMsg.put("content", content);
pushMsg.put("time", System.currentTimeMillis());
// 3. 查找并推送
Channel targetChannel = onlineUserChannels.get(targetId);
if (targetChannel != null && targetChannel.isActive()) {
targetChannel.writeAndFlush(new TextWebSocketFrame(pushMsg.toJSONString()));
// 可以在这里更新消息状态为“已送达”(非已读)
// updateMessageStatus(msgId, DELIVERED);
}
// 无论对方是否在线,都先给发送方一个发送成功的回执
ctx.channel().writeAndFlush(new TextWebSocketFrame(
JSON.toJSONString(Map.of("type", "ack", "msgId", msgId, "status", "sent"))
));
}
6.2 群聊与消息广播
群聊的本质是一对多的消息广播。当某个用户向群组发送消息时,服务端需要将此消息推送给该群组内在线的所有其他成员。
首先,我们需要在内存中维护一个群组与在线成员的映射关系。这比单纯维护用户连接映射要复杂一些。一个高效的结构是使用 Map<Long, Set<String>>,Key 是群组ID,Value 是该群组内当前在线用户的ID集合。
// 在ChatMessageHandler中定义
private static final Map<Long, Set<String>> groupOnlineMembers = new ConcurrentHashMap<>();
// 在用户认证成功,或加入群组时,更新这个映射
public void addUserToGroup(Long groupId, String userId) {
groupOnlineMembers.computeIfAbsent(groupId, k -> ConcurrentHashMap.newKeySet()).add(userId);
}
// 用户断开连接或退出群组时,从所有群组中移除该用户
public void removeUserFromAllGroups(String userId) {
for (Set<String> members : groupOnlineMembers.values()) {
members.remove(userId);
}
}
当处理群聊消息时(chatType 为 2):
} else if ("chat".equals(type) && chatType == 2) { // 群聊
Long groupId = jsonObject.getLong("groupId");
// 1. 异步保存群消息(这里可以只存一条,也可以为每个接收者存一条,看业务)
// 2. 获取该群所有在线成员(除了发送者自己)
Set<String> memberIds = groupOnlineMembers.getOrDefault(groupId, Collections.emptySet());
JSONObject pushMsg = buildGroupMessage(msgId, userId, groupId, content); // 构建消息
for (String memberId : memberIds) {
if (!memberId.equals(userId)) { // 不发送给自己
Channel memberChannel = onlineUserChannels.get(memberId);
if (memberChannel != null && memberChannel.isActive()) {
memberChannel.writeAndFlush(new TextWebSocketFrame(pushMsg.toJSONString()));
}
}
}
// 3. 同样给发送方回执
ctx.channel().writeAndFlush(...);
}
这里有一个优化点:如果群成员非常多(比如2000人),遍历 Set 并逐个查找 Channel 发送,在单线程下可能会慢。可以考虑将推送任务提交到线程池并行处理,或者对于超大群,采用不同的广播策略(如只推送给最近活跃的成员)。
6.3 用户在线状态管理
用户在线状态不能仅仅依靠 Channel 是否活跃来判断。网络抖动可能导致连接临时断开又重连。因此,我们需要一个心跳机制来维持和判断连接的有效性。
客户端需要定期(比如每30秒)向服务器发送一个心跳包:
// 前端心跳
setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({type: 'heartbeat'}));
}
}, 30000);
服务端在收到心跳包后,更新该用户对应的“最后活跃时间戳”。我们可以用一个 Map<String, Long> 来存储。
private static final Map<String, Long> userLastHeartbeat = new ConcurrentHashMap<>();
// 在channelRead0处理heartbeat
if ("heartbeat".equals(type)) {
userLastHeartbeat.put(userId, System.currentTimeMillis());
// 简单回复一个pong
ctx.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(Map.of("type", "pong"))));
}
然后,我们需要一个后台定时任务(比如每60秒运行一次),检查所有在线用户的心跳时间。如果某个用户超过90秒没有心跳,就认为他离线了,主动关闭其 Channel 并从在线列表中移除。
@Component
public class HeartbeatCheckTask {
@Scheduled(fixedRate = 60000) // 每60秒执行一次
public void check() {
long now = System.currentTimeMillis();
for (Map.Entry<String, Long> entry : userLastHeartbeat.entrySet()) {
if (now - entry.getValue() > 90000) { // 超过90秒
String userId = entry.getKey();
Channel channel = ChatMessageHandler.onlineUserChannels.get(userId);
if (channel != null) {
channel.close(); // 关闭连接,会触发channelInactive
System.out.println("因心跳超时,强制断开用户连接:" + userId);
}
// 清理心跳记录
userLastHeartbeat.remove(userId);
}
}
}
}
这样,我们就实现了一个相对健壮的在线状态管理系统。前端可以根据服务端推送的上下线通知(在 channelActive 和 channelInactive 中广播),实时更新好友列表的在线状态。
7. 性能调优与踩坑指南:让系统真正“高并发”
系统功能跑通只是第一步,要让它能承受真实的高并发压力,还需要进行一系列优化。这里分享几个我踩过坑后总结的关键点。
7.1 Netty 参数调优
Netty 本身提供了很多可调参数,对性能影响巨大。
- SO_BACKLOG:在
ServerBootstrap.option()中设置。它定义了操作系统用于挂起连接请求的队列长度。在连接建立非常频繁的系统中,可以适当调大(比如2048或4096),但也不能太大,会消耗内存。需要根据somaxconn系统参数调整。 - TCP_NODELAY / SO_KEEPALIVE:在
ServerBootstrap.childOption()中设置。TCP_NODELAY禁用 Nagle 算法,减少小数据包的延迟,对于实时聊天这种需要低延迟的场景,务必设置为 true。SO_KEEPALIVE开启 TCP 层的心跳,可以检测死连接,建议开启。 - ChannelOption.WRITE_BUFFER_WATER_MARK:写水位线控制。当网络拥塞或对端读取慢时,Netty 的发送缓冲区可能会积压。通过设置低水位线和高水位线,可以配合
Channel.isWritable()实现背压(Backpressure),避免内存爆掉。bootstrap.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(32 * 1024, 64 * 1024)); - EventLoopGroup 线程数:
NioEventLoopGroup默认线程数是Runtime.getRuntime().availableProcessors() * 2。对于计算密集型业务,可以保持默认或减少;对于纯 IO 密集型(如聊天转发),可以适当增加,但不要超过 CPU 核数太多,避免线程切换开销。我一般设置为 CPU 核数 + 1。
7.2 JVM 与操作系统优化
- JVM 堆内存:Netty 大量使用堆外内存(Direct Buffer),所以除了设置堆内存(
-Xms4g -Xmx4g),还需要关注直接内存大小,通过-XX:MaxDirectMemorySize设置,防止OutOfDirectMemoryError。 - Linux 文件描述符限制:一个 TCP 连接就是一个文件描述符。默认的
ulimit -n可能只有1024,对于高并发是远远不够的。需要修改/etc/security/limits.conf,提高nofile限制(比如65535或更高)。 - Linux TCP 参数:调整
/etc/sysctl.conf中的网络参数,例如:net.core.somaxconn = 2048:提高连接队列长度,与SO_BACKLOG配合。net.ipv4.tcp_tw_reuse = 1和net.ipv4.tcp_tw_recycle = 1(注意,在较新内核中tcp_tw_recycle可能有问题,需谨慎):快速回收 TIME_WAIT 状态的连接,对于短连接频繁的服务很有用。对于长连接的聊天服务,TIME_WAIT 问题不突出。
7.3 业务层优化
- 异步化与线程池隔离:这是最重要的原则。Netty 的 IO 线程(EventLoop)必须快进快出,绝不能执行任何阻塞操作(如数据库 IO、远程 HTTP 调用、复杂计算)。所有耗时操作都必须提交到独立的业务线程池。
// 在Handler中注入一个业务线程池 @Autowired private ExecutorService businessExecutor; // 在处理消息时提交任务 businessExecutor.submit(() -> { // 1. 保存消息到数据库 // 2. 记录日志 // 3. 其他业务逻辑 }); // Netty IO线程立即返回,继续处理其他请求 - 消息广播优化:对于超大群(如2000人)的广播,遍历发送可能会成为瓶颈。可以考虑:
- 使用 Netty 的
ChannelGroup:Netty 提供了ChannelGroup来管理一组 Channel,其writeAndFlush方法会批量发送,比循环单发效率高。 - 分级广播:对于非强制性实时消息(如群公告),可以推送到消息队列(如 Kafka、RocketMQ),由消费者异步推送,避免阻塞主流程。
- 限制广播频率:在客户端或服务端对高频广播进行限流。
- 使用 Netty 的
- 连接保活与重连:移动网络不稳定,断线重连是常态。前端需要实现自动重连逻辑,并在重连后重新认证、同步离线消息。服务端的心跳超时时间不宜设得太短,避免因网络波动误判。
7.4 监控与日志
高并发系统没有监控就是“盲人骑瞎马”。至少要做以下几点:
- 连接数监控:实时监控
onlineUserChannels.size(),可以暴露到 Spring Boot Actuator 的 Endpoint,或用 Metrics 库上报到 Prometheus。 - 消息吞吐量监控:统计每秒收发消息数。
- JVM 监控:GC 情况、堆内存、直接内存使用率。
- Netty 自带指标:Netty 4.1+ 提供了丰富的指标,可以通过
Micrometer集成。
我曾在一次线上活动中,因为没设好写水位线,导致内存飙升,最后 OOM。也遇到过因为 SO_BACKLOG 设置太小,在大规模用户同时重连时,大量连接被拒绝。这些都是宝贵的教训。调优是一个持续的过程,需要结合压测(如用 JMeter 模拟大量 WebSocket 连接和消息发送)和监控数据,不断调整。
8. 前端联调与部署上线:最后的临门一脚
后端服务写得再漂亮,最终还是要和前端配合,并部署到服务器上。这里聊聊联调和部署中的一些实用技巧。
8.1 前端连接与状态管理
前端使用 WebSocket API 连接我们的 Netty 服务器。一个健壮的前端连接管理器应该包含以下功能:
- 自动重连:连接断开后,尝试以指数退避策略(比如间隔1s, 2s, 4s, 8s...)重连。
- 心跳维持:定期发送心跳包,并检测服务器响应,如果超时则主动重连。
- 消息队列:在连接未建立或断开时,将待发送消息缓存在本地队列,连接恢复后自动发送。
- 状态同步:连接建立后,主动向服务器同步本地状态(如最后收到的消息ID),拉取离线消息。
一个简单的 Vue 3 组合式函数示例:
// useWebSocket.js
import { ref, onUnmounted } from 'vue';
export function useWebSocket(url, onMessage) {
const socket = ref(null);
const isConnected = ref(false);
let reconnectTimer = null;
let heartbeatTimer = null;
function connect() {
if (socket.value?.readyState === WebSocket.OPEN) return;
const ws = new WebSocket(url);
socket.value = ws;
ws.onopen = () => {
console.log('WebSocket连接成功');
isConnected.value = true;
// 连接成功后发送认证信息
ws.send(JSON.stringify({ type: 'auth', userId: '123' }));
// 开始心跳
startHeartbeat();
// 清除重连定时器
if (reconnectTimer) clearTimeout(reconnectTimer);
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'pong') {
// 收到服务器心跳回复,啥也不做
return;
}
// 处理业务消息
onMessage?.(msg);
};
ws.onclose = () => {
console.log('WebSocket连接关闭');
isConnected.value = false;
stopHeartbeat();
// 尝试重连
scheduleReconnect();
};
ws.onerror = (error) => {
console.error('WebSocket错误:', error);
ws.close(); // 触发onclose
};
}
function startHeartbeat() {
heartbeatTimer = setInterval(() => {
if (socket.value?.readyState === WebSocket.OPEN) {
socket.value.send(JSON.stringify({ type: 'heartbeat' }));
}
}, 30000); // 30秒一次
}
function stopHeartbeat() {
if (heartbeatTimer) clearInterval(heartbeatTimer);
}
function scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
console.log('尝试重连...');
connect();
}, 2000); // 2秒后重连
}
function send(data) {
if (socket.value?.readyState === WebSocket.OPEN) {
socket.value.send(JSON.stringify(data));
} else {
console.warn('连接未就绪,消息发送失败', data);
// 可以在这里将消息加入待发送队列
}
}
// 组件卸载时关闭连接
onUnmounted(() => {
if (socket.value) {
socket.value.close();
}
stopHeartbeat();
if (reconnectTimer) clearTimeout(reconnectTimer);
});
return { socket, isConnected, connect, send };
}
8.2 部署架构与负载均衡
单机能力总有上限。当用户量进一步增长,我们必须考虑水平扩展。部署多台聊天服务器,就引入了两个新问题:连接分布和服务器间通信。
- 连接分布(负载均衡):用户应该连接到哪台服务器?我们不能用传统的 HTTP 负载均衡器(如 Nginx 的默认轮询),因为 WebSocket 是长连接,需要保证一个用户会话始终与同一台后端服务器通信(粘性会话)。Nginx 从 1.7.11 版本开始支持
ip_hash或hash $connection_id来实现 WebSocket 的粘性负载均衡。upstream websocket_backend { # 使用ip_hash保证同一IP的客户端连接到固定服务器 ip_hash; server 192.168.1.10:1245; server 192.168.1.11:1245; server 192.168.1.12:1245; } server { listen 80; location /ws { proxy_pass http://websocket_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 重要:设置较长的超时时间 proxy_read_timeout 3600s; proxy_send_timeout 3600s; } } - 服务器间通信:用户A在服务器1上,用户B在服务器2上,他们俩要单聊,消息怎么从服务器1传到服务器2?这就需要引入一个中心化的消息路由组件。常见的方案有:
- Redis Pub/Sub:每台服务器订阅一个公共频道。当服务器1需要给用户B发消息时,它发现用户B不在本地,就将消息发布到 Redis 频道。服务器2订阅了这个频道,收到消息后,发现用户B在自己这里,就进行推送。这种方式实现简单,但 Redis 可能成为瓶颈,且消息不持久化。
- 消息队列(如 Kafka/RocketMQ):每台服务器消费一个指定的 Topic。发送跨服务器消息时,投递到消息队列,由消费了该队列的服务器处理。这种方式吞吐量高,可持久化,但复杂度也高。
- 专门的网关或路由服务:维护一个全局的“用户-服务器”映射表(可以放在 Redis 里)。所有消息先发到网关,网关查表后转发到对应的服务器。网关本身可能成为单点,需要做高可用。
对于中小型项目,我通常推荐 Redis Pub/Sub 方案,它足够简单,性能也基本够用。我们在每台 Netty 服务器启动时,都订阅一个叫 chat.cluster 的频道。发送消息时,先判断目标用户是否在线且在本机,如果是就直接发送;如果不是,就向 Redis 频道发布一条消息,消息体里包含目标用户ID和内容。其他服务器收到后,判断目标用户是否在自己这里,是则推送。这样,一个简单的分布式聊天集群就搭建起来了。
8.3 安全与生产就绪
最后,在上线前,别忘了这些生产环境的必备项:
- SSL/TLS:一定要用
wss://代替ws://。可以使用 Let‘s Encrypt 申请免费证书,在 Nginx 上配置 SSL 卸载,或者直接在 Netty 中配置 SSL 上下文。 - 鉴权与认证:连接建立时,不能只靠一个简单的 userId 参数。应该使用 Token(如 JWT)。前端连接时携带 Token,服务端在 WebSocket 握手阶段(HTTP 升级请求头中)验证 Token 的有效性,验证通过后才建立连接。
- 限流与防刷:防止恶意用户建立大量连接或发送海量消息。可以在 Nginx 层面做连接数限流,在 Netty 的 Handler 里做消息频率限制。
- 日志与排查:为每个连接分配一个唯一的链路 ID,贯穿整个消息处理流程,这样在排查问题时,可以通过这个 ID 快速定位所有相关日志。
从一行代码开始,到最终部署一个能抗住一定压力的实时聊天系统,这个过程充满了挑战,但也非常有成就感。这套基于 SpringBoot + WebSocket + Netty 的方案,经过多个项目的验证,在性能、可维护性和开发效率上取得了很好的平衡。希望我的这些经验和踩过的坑,能帮助你少走弯路,更快地构建出自己的实时通信应用。记住,架构是迭代出来的,先从能跑通的简单版本开始,随着业务增长,再逐步引入更复杂的优化和组件。

297

被折叠的 条评论
为什么被折叠?



