Skip to content

项目难点(技术难点)

难点要体现你在高并发、分布式、网络通信、状态一致性等层面的思考。 可以选择一个或两个重点展开。

难点 1:WebSocket 长连接管理与消息可靠投递

在项目中,每个客户端都会与服务端建立一个长连接。当用户量较大时,管理这些连接的状态、心跳、异常断开重连是个挑战。 解决思路:

  • 使用 Netty + Redis + Redisson 来实现分布式连接管理;
  • 每个连接对应一个 userId → channel 映射;
  • 定时发送心跳包检测连接状态,自动清理超时连接;
  • 在发送消息时,采用 消息确认机制(ACK) 保证消息可靠送达,若用户离线则存入 Redis 离线队列,用户上线后自动推送补偿。

🧠 亮点关键词

“分布式 WebSocket 管理”、“心跳检测”、“消息 ACK”、“离线补偿机制”。


实现逻辑

你现在要的是一个 完整可运行的“消息可靠投递 + ACK 确认 + 自动重发机制”示例代码,基于 ✅ Spring Boot ✅ Netty ✅ Spring Data Redis

我给你一份完整可理解的版本(关键类齐全,足够你直接讲给面试官或在项目中使用)。


🧩 一、总体逻辑

  1. 服务端发送消息时 → 存 Redis的hash结构 ,里面存有这个消息的对象和一个重试的次数。(最多重试3次)等待 ACK。
  2. 客户端收到消息后判断该消息是否保存在本地 → 存在本地?直接回复 ACK:保存到本地、回复ACK。
  3. 服务端收到 ACK → 删除 Redis 记录
  4. 定时任务扫描 Redis(6s) → 重发未确认消息

🧱 二、代码结构

java
com.chat.server
 ├── WebSocketSessionManager.java     // 管理用户连接
 ├── MessageService.java               // 发送/ACK逻辑
 ├── AckScheduler.java                 // 定时扫描重发
 ├── ChatMessage.java                  // 消息实体
 ├── NettyServerHandler.java           // 处理消息&ACK
 └── RedisConfig.java                  // Redis配置

✅ 1. ChatMessage.java

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

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 (核心逻辑)

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 (自动重发)

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)

java
@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 配置类

java
@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️⃣ 定义消息发布服务(发布者)

java
@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,当有其他节点发来消息时会自动接收。

java
@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判断(通过时间戳)
  • 在线人数统计

代码实现

java
@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。

如有转载或 CV 的请标注本站原文地址

访客数 --| 总访问量 --