项目难点(技术难点)
难点要体现你在高并发、分布式、网络通信、状态一致性等层面的思考。 可以选择一个或两个重点展开。
难点 1:WebSocket 长连接管理与消息可靠投递
在项目中,每个客户端都会与服务端建立一个长连接。当用户量较大时,管理这些连接的状态、心跳、异常断开重连是个挑战。 解决思路:
- 使用 Netty + Redis + Redisson 来实现分布式连接管理;
- 每个连接对应一个
userId → channel映射; - 定时发送心跳包检测连接状态,自动清理超时连接;
- 在发送消息时,采用 消息确认机制(ACK) 保证消息可靠送达,若用户离线则存入 Redis 离线队列,用户上线后自动推送补偿。
🧠 亮点关键词:
“分布式 WebSocket 管理”、“心跳检测”、“消息 ACK”、“离线补偿机制”。
实现逻辑
你现在要的是一个 完整可运行的“消息可靠投递 + ACK 确认 + 自动重发机制”示例代码,基于 ✅ Spring Boot ✅ Netty ✅ Spring Data Redis
我给你一份完整可理解的版本(关键类齐全,足够你直接讲给面试官或在项目中使用)。
🧩 一、总体逻辑
- 服务端发送消息时 → 存 Redis的hash结构 ,里面存有这个消息的对象和一个重试的次数。(最多重试3次)等待 ACK。
- 客户端收到消息后判断该消息是否保存在本地 → 存在本地?直接回复 ACK:保存到本地、回复ACK。
- 服务端收到 ACK → 删除 Redis 记录
- 定时任务扫描 Redis(6s) → 重发未确认消息
🧱 二、代码结构
com.chat.server
├── WebSocketSessionManager.java // 管理用户连接
├── MessageService.java // 发送/ACK逻辑
├── AckScheduler.java // 定时扫描重发
├── ChatMessage.java // 消息实体
├── NettyServerHandler.java // 处理消息&ACK
└── RedisConfig.java // Redis配置✅ 1. ChatMessage.java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
private String msgId; // 消息唯一ID(UUID)
private Long fromUserId;
private Long toUserId;
private String type; // text/image/file/ack
private String content;
private Long timestamp;
}✅ 2. WebSocketSessionManager.java
@Component
public class WebSocketSessionManager {
private static final Map<Long, Channel> USER_CHANNEL_MAP = new ConcurrentHashMap<>();
public void bind(Long userId, Channel channel) {
USER_CHANNEL_MAP.put(userId, channel);
}
public void remove(Long userId) {
USER_CHANNEL_MAP.remove(userId);
}
public Channel getChannel(Long userId) {
return USER_CHANNEL_MAP.get(userId);
}
}✅ 3. MessageService.java (核心逻辑)
@Service
public class MessageService {
@Autowired
private WebSocketSessionManager sessionManager;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String WAIT_ACK_KEY = "wait_ack:";
private static final String OFFLINE_KEY = "offline:";
/**
* 发送消息(带ACK机制)
*/
public void sendMessage(ChatMessage msg) {
Long toUserId = msg.getToUserId();
Channel channel = sessionManager.getChannel(toUserId);
if (channel != null && channel.isActive()) {
// 在线发送
channel.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(msg)));
// 存入待确认列表
redisTemplate.opsForValue().set(
WAIT_ACK_KEY + msg.getMsgId(),
JSON.toJSONString(msg),
60, TimeUnit.SECONDS
);
} else {
// 不在线则离线存储
redisTemplate.opsForList().rightPush(OFFLINE_KEY + toUserId, JSON.toJSONString(msg));
}
}
/**
* 收到ACK
*/
public void handleAck(String msgId) {
redisTemplate.delete(WAIT_ACK_KEY + msgId);
}
/**
* 推送离线消息
*/
public void sendOfflineMessages(Long userId) {
String key = OFFLINE_KEY + userId;
List<String> list = redisTemplate.opsForList().range(key, 0, -1);
if (list != null) {
for (String json : list) {
ChatMessage msg = JSON.parseObject(json, ChatMessage.class);
sendMessage(msg);
}
redisTemplate.delete(key);
}
}
}✅ 4. AckScheduler.java (自动重发)
@Component
@EnableScheduling
public class AckScheduler {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private MessageService messageService;
private static final String WAIT_ACK_KEY = "wait_ack:";
@Scheduled(fixedDelay = 10000) // 每10秒扫描
public void checkUnAckMessages() {
Set<String> keys = redisTemplate.keys(WAIT_ACK_KEY + "*");
if (keys == null || keys.isEmpty()) return;
for (String key : keys) {
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
ChatMessage msg = JSON.parseObject(json, ChatMessage.class);
System.out.println("【重发消息】msgId=" + msg.getMsgId());
messageService.sendMessage(msg);
}
}
}
}✅ 5. NettyServerHandler.java (处理收发 + ACK)
@ChannelHandler.Sharable
@Component
public class NettyServerHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
@Autowired
private WebSocketSessionManager sessionManager;
@Autowired
private MessageService messageService;
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
String text = frame.text();
ChatMessage msg = JSON.parseObject(text, ChatMessage.class);
switch (msg.getType()) {
case "connect" -> {
// 客户端连接,绑定userId
sessionManager.bind(msg.getFromUserId(), ctx.channel());
messageService.sendOfflineMessages(msg.getFromUserId());
}
case "ack" -> {
// 收到ACK
messageService.handleAck(msg.getMsgId());
}
default -> {
// 普通消息
msg.setMsgId(UUID.randomUUID().toString());
msg.setTimestamp(System.currentTimeMillis());
messageService.sendMessage(msg);
}
}
}
@Override
public void handlerRemoved(ChannelHandlerContext ctx) {
// 断线清理
sessionManager.getChannelMap().entrySet().removeIf(e -> e.getValue().equals(ctx.channel()));
}
}面试总结话术(推荐背)
每条消息生成唯一msgId,发送时存入 Redis 的wait_ack:{msgId}(Hash 类型),包含发送时间戳、重发次数、消息体快照三个字段。定时任务会扫描这些键,若超时(如 5 秒)未收到 ACK 且重发次数未满 3 次,就触发重发并更新记录;收到 ACK 后则立即删除该键,终止追踪。
针对用户离线场景,消息会暂存到用户专属离线队列,待重连后自动推送。为避免重发导致的重复接收,消息体携带msgId,客户端通过本地 SQLite 数据库维护processed_msg表,专门记录已处理的msgId。收到消息时先查询该表:若msgId已存在,仅返回 ACK 不执行业务;若不存在,完成业务逻辑后将msgId写入表中。SQLite 的本地持久化特性,即使客户端重启也能保留去重记录,比内存缓存更可靠。
难点 2:多节点消息同步与集群可扩展性
单节点 WebSocket 服务很容易实现,但在部署多节点时,用户 A 连在节点 1,用户 B 连在节点 2,这时消息如何转发是个难点。 解决方案:
- 使用 Redisson 的 Topic 发布订阅机制 实现节点间消息广播;
- 当一个节点收到消息时,通过 Redis Topic 通知其他节点;
- 目标节点判断自己是否持有目标用户的连接,若有则直接推送。 这样就实现了跨节点实时通信,系统可以水平扩展。
🧠 亮点关键词:
“Redisson 发布订阅”、“分布式消息路由”、“水平扩展”、“节点间状态同步”。
代码实现
1️⃣ Redisson 配置类
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
// 单节点模式,可改为 cluster
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setDatabase(0);
return Redisson.create(config);
}
}2️⃣ 定义消息发布服务(发布者)
@Service
public class MessagePublisher {
private final RedissonClient redissonClient;
public static final String TOPIC_NAME = "chat_message_topic";
@Autowired
public MessagePublisher(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
/** 发布消息到 Redis 频道 */
public void publish(MessageSendDto msg) {
RTopic topic = redissonClient.getTopic(TOPIC_NAME);
topic.publish(msg);
}
}3️⃣ 定义订阅监听器(订阅者)
所有节点启动时都会自动订阅 Redis Topic,当有其他节点发来消息时会自动接收。
@Service
public class MessageSubscriber {
private final RedissonClient redissonClient;
@Autowired
private WebSocketService webSocketService; // 管理本节点的channel连接
@PostConstruct
public void init() {
RTopic topic = redissonClient.getTopic(MessagePublisher.TOPIC_NAME);
topic.addListener(MessageSendDto.class, (channel, msg) -> {
// 收到其他节点发布的消息
handleRemoteMessage(msg);
});
}
public MessageSubscriber(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
private void handleRemoteMessage(MessageSendDto msg) {
// 判断目标用户是否连接在本节点
Channel channel = WebSocketService.USER_CHANNEL_MAP.get(msg.getRecipientId());
if (channel != null && channel.isActive()) {
channel.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(msg)));
}
}
}面试可说的总结话术
在多节点部署时,我使用了 Redisson 的发布订阅机制。 当一个节点收到消息后,会将消息发布到 Redis Topic, 其他节点监听该 Topic,如果目标用户连接在当前节点,就直接推送。 这样就实现了分布式的 WebSocket 消息同步与系统水平扩展
难点 3:客户端离线消息与本地缓存
桌面客户端使用 Electron + SQLite,实现聊天记录离线可查、断网重连自动同步。 技术要点:
- 当用户掉线时,消息暂存在本地 SQLite;
- 重连后会发起一个“消息同步请求”,根据时间戳从服务端拉取未读消息;
- 离线和在线消息统一存储结构,前端渲染逻辑保持一致。
🧠 亮点关键词:
“本地离线缓存”、“增量同步”、“SQLite 本地化存储”、“断网可用”。
难点 4:统计在线用户
ZSet 本身是一个 (member, score) 对的集合, 我们可以用它来同时存储:
- 用户ID(
member) - 最近一次心跳时间戳(
score)
这意味着:
- 用户是否在线?👉 判断
score是否在「有效时间窗口」内。 - 在线人数?👉 查询时间窗口内的成员数。
- 在线列表?👉 直接取出有效用户的 ID。
💡 所以,只用 一个 Key(一个 ZSet) 就能实现:
- 在线状态记录
- TTL判断(通过时间戳)
- 在线人数统计
代码实现
@Service
public class OnlineUserService {
private static final String ONLINE_ZSET_KEY = "chat:online_users";
private static final long EXPIRE_TIME_MS = 60 * 1000; // 心跳60秒有效
@Autowired
private StringRedisTemplate redisTemplate;
/** 用户上线或心跳刷新 */
public void refreshOnlineUser(String userId) {
double now = System.currentTimeMillis();
redisTemplate.opsForZSet().add(ONLINE_ZSET_KEY, userId, now);
}
/** 用户下线 */
public void removeUser(String userId) {
redisTemplate.opsForZSet().remove(ONLINE_ZSET_KEY, userId);
}
/** 获取在线人数 */
public long getOnlineCount() {
double now = System.currentTimeMillis();
Double start = now - EXPIRE_TIME_MS;
Long count = redisTemplate.opsForZSet()
.count(ONLINE_ZSET_KEY, start, now);
return count == null ? 0 : count;
}
/** 获取在线用户列表 */
public Set<String> getOnlineUsers() {
double now = System.currentTimeMillis();
Double start = now - EXPIRE_TIME_MS;
return redisTemplate.opsForZSet()
.rangeByScore(ONLINE_ZSET_KEY, start, now);
}
/** 定时清理过期用户 */
@Scheduled(fixedDelay = 60000)
public void cleanOfflineUsers() {
double expireBefore = System.currentTimeMillis() - EXPIRE_TIME_MS;
redisTemplate.opsForZSet().removeRangeByScore(ONLINE_ZSET_KEY, 0, expireBefore);
}
}亮点总结(面试回答模板)
“在线用户我用 Redis 的 ZSet 做了一体化设计, 每个用户的 ID 作为 member,最后心跳时间作为 score。 这样既能统计在线人数,又能通过时间判断是否过期。 无需两个结构就能实现分布式共享、自动过期清理, 性能比单独维护 Set + TTL 更高,也更易扩展。”
简化版口述模板(面试时说法)
这个项目我在实现过程中主要遇到的难点有两个: 一个是 WebSocket 长连接的分布式管理与消息可靠投递,我通过 Netty + Redisson 实现了多节点消息同步,并结合心跳检测和 ACK 确认机制,保证消息在断线、离线情况下也能可靠送达; 第二个是客户端的 离线消息缓存与重连同步机制,我用 Electron + SQLite 实现了消息本地持久化和增量同步,用户断网也能查看聊天记录。
在创新点方面,我做了 WebRTC 点对点通话,让音视频流不经过服务端中转,延迟和带宽占用都显著降低。 同时整个客户端是 跨平台的 Electron 应用,支持截图、文件发送和本地预览,体验更接近原生 IM。
