编辑
2026-02-19
java炒饭
00

目录

高仿QQ即时通讯系统 - 面试题全集
一、WebSocket 与实时通信
二、JWT 双 Token 认证
三、AOP 限流与分布式锁
四、Kafka 批量消费与消息可靠性
五、红包系统
六、分库分表
七、缓存策略
八、系统设计与架构
九、追问场景(高频难题)

让我先深入了解你的代码实现,再出针对性的面试题。

[1 tool called]

已深入分析你的代码实现,现在针对每个技术点出题,覆盖原理、设计决策、细节实现、故障处理等多个维度。


高仿QQ即时通讯系统 - 面试题全集


一、WebSocket 与实时通信

Q1:你的项目如何实现 WebSocket 集群间消息路由?请详细描述一条私聊消息从发送到接收的完整链路。

答: 完整链路分为以下几步:

  1. 发送方 → WebSocket Server A:发送方通过 WebSocket 连接将消息发给 WS 服务器A。
  2. WS Server A → Kafka:服务器A将消息投递到 Kafka Topic message.persist,保证消息异步落库。
  3. WS Server A → Redis 查路由:通过 Redis Key ws:user:session:{receiverId} 获取接收方所在服务器的 serverKey
  4. WS Server A → RabbitMQ:以 ws.chat.{serverKey} 为 routingKey,将消息投递到 Topic Exchange ws.exchange
  5. WS Server B 监听队列:每个 WS 实例都有自己独占的队列 ws.chat.queue.{serverKey},绑定对应 routingKey,收到消息后通过本地 ConcurrentHashMap<Long, WebSocketSession> 找到接收方 Session,直接推送。
  6. Kafka Consumer 批量落库:Java 服务批量消费 Kafka 消息,批量 INSERT IGNORE 写入分片表,保证幂等。

关键设计:RabbitMQ 负责实时性,Kafka 负责可靠存储,两者职责分离,互不阻塞。


Q2:你的心跳机制是如何设计的?为什么心跳要携带 Token?

答: 心跳设计:

  • 客户端定时(如30秒)发送 ping 消息,携带当前 accessToken
  • 服务端收到后:①验证 Token 有效性(解析 JWT + 检查黑名单 seq);②Token 无效则关闭连接踢下线;③Token 有效则刷新 Redis 中会话 TTL(ws:user:session:{userId}),返回 pong

携带 Token 的原因:

  1. 安全踢下线:用户被封号、强制下线时,服务端将该用户的 token seq 加入黑名单,下一次心跳到来时即被踢出,无需主动断开所有连接。
  2. 无状态验证:WebSocket 是长连接,建立连接时验证过一次 Token,但 Token 可能中途失效(被拉黑),心跳携带 Token 可做持续有效性检查。
  3. 刷新会话 TTL:避免 Redis 中的会话信息过期,保持会话活跃状态。

Q3:如果用户在心跳间隔内掉线,Redis 中的会话信息如何处理?会有什么问题?

答: 问题分析:

  • Redis 会话 Key ws:user:session:{userId} TTL=300秒,如果用户掉线后不再心跳刷新,300秒后自动过期,问题不大。
  • 实际隐患:在 300 秒内,Redis 中仍记录该用户在线,此时若有消息发给该用户,RabbitMQ 会路由到对应 WS 服务器,但本地 Session Map 中该用户已不存在,消息会静默丢失

解法:

  1. WebSocket afterConnectionClosed 回调:在连接关闭时主动从 Redis 删除会话 Key,同时从本地 Session Map 中移除,是最佳实践。
  2. 消息离线缓存:找不到 Session 时,将消息存入离线消息队列(Redis List 或数据库),用户重新上线后拉取。

Q4:你的集群设计中,每个 WS 实例都有独立的 RabbitMQ 队列,这带来了什么问题?如何优化?

答: 问题:

  1. 队列数量膨胀:每个 WS 实例创建3个队列(chat/group/system),100个实例就有300个队列,管理成本高。
  2. 实例重启时队列残留:如果队列设置为持久化,实例重启后旧队列中可能堆积大量消息。

优化方案:

  1. 使用临时队列(exclusive + auto-delete):队列生命周期与连接绑定,实例断开时自动删除,避免队列堆积。
  2. 直接使用 serverKey 作为 routingKey:已实现,清晰明确。
  3. 动态注册队列:WS 实例启动时动态创建并绑定队列,关闭时解绑,框架层面用 @Bean + ApplicationContext 动态注册。

Q5:单设备登录是如何实现的?如果同一用户在两台设备上登录,第一台设备的体验是怎样的?

答: 实现机制:

  1. 新连接建立时,从 Redis 读取 ws:user:session:{userId},如果存在说明用户已在别处登录。
  2. 取出旧 serverKey,通过 RabbitMQ 向旧服务器发送 "被踢下线" 系统消息(routingKey = ws.system.{oldServerKey})。
  3. 旧服务器收到消息后关闭旧 WebSocket 连接。
  4. 新连接写入 Redis,覆盖旧会话信息。

用户体验:第一台设备收到 "您的账号在另一设备登录,已被强制下线" 的系统消息,然后 WebSocket 连接被关闭,客户端提示重新登录。


二、JWT 双 Token 认证

Q6:请详细描述你的双 Token 设计,为什么要用两个 Token?accessToken 过期后如何无感续期?

答: 设计原因:

  • 单 Token 的矛盾:有效期长则安全风险大(Token 泄露长期有效);有效期短则频繁需要重新登录,体验差。
  • 双 Token 解耦安全与体验
    • accessToken:有效期15分钟,用于业务鉴权,短期有效降低泄露风险。
    • refreshToken:有效期7天,仅用于刷新,不参与业务请求,泄露面更小。

无感续期流程:

  1. 客户端请求接口,Gateway 验证 accessToken,发现已过期,返回 401。
  2. 客户端自动携带 refreshToken 调用刷新接口。
  3. 服务端验证 refreshToken(解析+黑名单检查+数据库比对),生成新的 Token 对(tokenSeq+1),更新数据库中的 refreshToken将旧 refreshToken 的 seq 加入黑名单
  4. 客户端收到新 Token 对,静默替换本地存储,用户无感知。
  5. 用新 accessToken 重试原请求。

Q7:你的黑名单机制是按 seq 实现的,而不是存储具体的 Token 字符串,这样设计有什么好处?有什么局限?

答: 设计:Redis Key = blacklist:token_seq:{userId},Value = 被拉黑的最大 seq 值,检查时只需判断 token.seq <= blacklistSeq

好处

  1. O(1) 查询,存储极小:只需存一个数字,无论用户有多少历史 Token 都只占一条 Redis 记录,而非传统黑名单需要存每个 Token 字符串(可能几十KB)。
  2. 批量吊销:强制下线时,只需将当前最大 seq 写入黑名单,该用户所有历史 Token(seq ≤ 黑名单值)全部失效,一次操作解决。
  3. 自然过期:不需要处理黑名单条目的过期删除问题,逻辑简单。

局限

  1. 不支持精细化吊销:无法单独吊销某一个特定 Token,只能吊销某个 seq 及其之前的所有。在多设备登录场景(若支持的话)无法只踢出某一台设备。
  2. seq 单调递增假设:依赖每次刷新 seq 严格递增,如果存在并发刷新 Token 的情况需要加锁保护。

Q8:refreshToken 的安全性如何保障?如果 refreshToken 泄露了怎么办?

答: 保障措施:

  1. 数据库绑定:刷新时需要数据库中的 refreshToken 与请求中的一致,且每次刷新后数据库更新为新 Token,旧 Token 立即失效(tokenSeq 递增,旧 seq 被拉黑)。
  2. HTTPS 传输:实际部署中 refreshToken 只在 HTTPS 下传输,防止中间人攻击。
  3. HttpOnly Cookie:前端将 refreshToken 存 HttpOnly Cookie,防止 XSS 读取(当然这是前端责任)。

泄露处理:

  • 若检测到同一 refreshToken 被两个不同客户端使用(可通过 deviceId 或 IP 异常来判断),触发 Token 轮换检测(RFC 6749 Token Rotation),立即吊销该用户的所有 Token,强制重新登录。
  • 用户主动发现异常时,调用"登出所有设备"接口,将当前最大 seq 写入黑名单,所有 Token 失效。

Q9:Token 验证在 Gateway 层和 WebSocket 层都有,它们的职责如何分工?

答:

  • Gateway 层:拦截所有 HTTP REST 请求,验证 accessToken 合法性(解析签名+黑名单检查),合法则在请求头中注入 userIdemail 等信息透传给下游服务,下游服务无需再验证 Token,直接读取请求头。
  • WebSocket 层:WebSocket 握手时验证 Token(URL 参数或首次消息携带),建立连接后每次心跳再验证,目的是持续检测 Token 是否被吊销。

分工原则:Gateway 是入口防线,WebSocket Handler 是长连接安全守卫。两处验证逻辑复用了公共模块 TokenService.validateAccessToken(),保持一致性。


三、AOP 限流与分布式锁

Q10:你的限流是用 Redis SETNX 实现的,说说 SETNX 在这里的原理,与令牌桶、滑动窗口相比有何区别?

答: SETNX 原理

  • SET key value NX EX ttl:若 Key 不存在则设置成功(返回1),已存在则失败(返回0)。
  • 相当于:在 ttl 时间窗口内,第一次请求设置 Key 成功,后续请求发现 Key 存在,拒绝服务。
  • 本质是固定窗口限流,每个 IP/邮箱在 ttl 时间内最多执行1次。

对比

策略原理优点缺点
SETNX(固定窗口)ttl内只允许1次实现简单,无额外存储窗口边界有突刺问题
令牌桶以固定速率生产令牌,有令牌才能通过允许一定突发流量实现复杂
滑动窗口维护时间窗口内请求计数平滑,无边界突刺需要 Sorted Set,存储多
漏桶请求匀速流出最平滑不允许任何突发

对于防刷场景(如邮箱验证码10秒内只发一次),SETNX 简洁高效,完全够用。对于高并发接口限流(如每秒100次),建议用令牌桶或滑动窗口。


Q11:AOP 切面如何获取客户端 IP?在反向代理(Nginx)场景下有什么坑?

答: 获取 IP 方式:

java
// 依次检查代理头 String ip = request.getHeader("X-Forwarded-For"); if (ip == null) ip = request.getHeader("X-Real-IP"); if (ip == null) ip = request.getRemoteAddr(); // X-Forwarded-For 可能是逗号分隔的链路,取第一个 if (ip.contains(",")) ip = ip.split(",")[0].trim();

坑点

  1. 伪造 IPX-Forwarded-For 是可以被客户端伪造的,恶意用户可以加上 X-Forwarded-For: 1.2.3.4 绕过 IP 限流。解决:只信任可信代理(Nginx)设置的 Header,在 Nginx 层用 $realip_remote_addr 覆盖客户端伪造的值。
  2. 内网 IP:Nginx 转发时 RemoteAddr 变成 Nginx IP,必须用 Header 获取真实 IP。
  3. IPv6 格式::1 是 IPv6 的本地回环地址,需要标准化处理。

Q12:分布式锁在红包场景是用 Redisson 实现的,而限流是用 SETNX,为什么两者不统一?

答: 这是两种不同语义的场景:

限流(SETNX)

  • 语义:在时间窗口 T 内,拒绝重复请求
  • 不需要"锁释放",Key 自然过期即可
  • 不需要重试,直接拒绝即返回错误给客户端
  • SETNX 一行代码足够,引入 Redisson 反而是杀鸡用牛刀

互斥锁(Redisson RLock)

  • 语义:同一时刻只有一个请求能执行临界区代码
  • 需要锁的获取和主动释放(finally 块)
  • 支持锁等待(tryLock(10, 30, TimeUnit.SECONDS)
  • 需要可重入、自动续期(Watchdog)等能力,Redisson 封装了这些

Redisson Watchdog 机制:持有锁的线程若执行时间超过锁超时(默认30秒),Watchdog 每10秒自动续期一次,防止业务未执行完锁就自动释放导致超发。


四、Kafka 批量消费与消息可靠性

Q13:你的 Kafka 消费者使用手动 ACK,如果批量消费100条消息时第50条落库失败,如何处理?

答: 当前代码逻辑分析(INSERT IGNORE + 批量插入):

  • 使用 INSERT IGNORE,即使第50条主键冲突也不会报错,整批可以成功。
  • 如果是其他类型的异常(如数据库连接断开),整批 batchInsert 会抛异常。

处理策略:

  1. 当前做法:整批失败时不手动 ACK,消息会被 Kafka 重新投递(enable.auto.commit=false),重试时再次批量插入,INSERT IGNORE 保证幂等不会重复。
  2. 风险:如果某条消息格式始终有问题导致落库永远失败,会造成消费死循环(毒药消息)。
  3. 优化:配置死信 Topic(DLT),超过最大重试次数(如3次)后,将失败消息路由到死信 Topic,记录告警日志,人工干预处理。
java
// 优化:try-catch 单条,记录失败消息 for (MessageMQ msg : messages) { try { messageMapper.insertIgnore(msg); } catch (Exception e) { log.error("消息落库失败,消息ID:{}", msg.getId(), e); // 发送到死信队列或告警 } } ack.acknowledge(); // 整批 ACK,不因个别失败阻塞

Q14:Kafka 分区数设置为64,这个数字是怎么确定的?分区数与消费者实例数有什么关系?

答: 代码中注释:// 分区数 >= 实例数,规则说明:

分区数 = 消费者并行度上限

  • Kafka 中,同一 Consumer Group 内,每个分区只能被一个消费者实例消费。
  • 分区数 = 64 意味着最多支持64个 Java 服务实例并行消费。
  • 若实例数 > 分区数,多余的实例空闲(资源浪费);若实例数 < 分区数,每个实例消费多个分区。

64的由来

  • 预估系统峰值需要的并发消费实例数,留有余量。
  • 分区数一旦设定很难修改(修改分区数会打乱按 key 路由的顺序性),所以要预估充足。

消息顺序性考量:私聊消息按 sessionId 作为 Kafka key,同一会话的消息路由到同一分区,保证单会话内消息顺序落库。


Q15:RabbitMQ 消息持久化是如何配置的?消息从生产到消费的全链路可靠性如何保障?

答: 全链路可靠性保障:

  1. 生产者确认(Publisher Confirm)

    • spring.rabbitmq.publisher-confirm-type: correlated
    • Broker 收到消息后回调 confirm,若失败触发重发逻辑。
  2. 消息持久化

    • Exchange 持久化:durable=true
    • Queue 持久化:durable=true
    • 消息持久化:deliveryMode=2MessageDeliveryMode.PERSISTENT
    • 三者缺一不可,否则 Broker 重启后消息丢失。
  3. 消费者手动 ACK

    • acknowledge-mode: manual
    • 消费成功后 channel.basicAck(),业务失败或异常 channel.basicNack(requeue=false)
    • requeue=false:失败消息不重新入队(防止无限循环),而是路由到死信队列(DLX)。
  4. 幂等性兜底

    • Redis SETNX key uuid 防重复消费
    • 数据库唯一索引兜底

Q16:你说 RabbitMQ 负责实时推送,Kafka 负责异步落库,如果 Kafka 消费延迟,用户看到的消息和数据库里的消息会不一致,怎么处理?

答: 这是一个最终一致性场景,核心设计思路:

发生情况:消息已通过 RabbitMQ 推送给接收方(对方已看到),但 Kafka 还未消费完,数据库中暂无此消息。此时如果接收方刷新历史消息,会发现消息"消失"。

解决方案

  1. 客户端缓存:客户端在本地缓存已接收的消息,数据库查询结果与本地缓存合并展示,数据库延迟落库不影响用户体验。
  2. 消息 ID 游标:客户端记录最新消息ID,拉取历史消息时用游标翻页,短暂的落库延迟通常在秒级,用户翻到未落库的消息之前已经落库完成。
  3. 读 Kafka 缓冲区:对于实时性要求极高的场景,可以先查 Redis 或 Kafka 的未消费消息,再合并数据库结果(复杂度高,通常不用)。
  4. 监控告警:监控 Kafka Consumer Lag,延迟超过阈值(如5秒)立即告警并扩容消费实例。

五、红包系统

Q17:请详细描述二倍均值算法,为什么用它而不是纯随机?

答: 二倍均值算法

每次随机金额 = Random(0.01, 剩余金额 / 剩余人数 * 2)

示例:10元,5人

  • 第1人:Random(0.01, 4.00),假设得3元,剩余7元4人
  • 第2人:Random(0.01, 3.50),假设得2元,剩余5元3人
  • 以此类推,最后一人得剩余全部

为什么不用纯随机

  • 纯随机(Random(0.01, 剩余金额))会导致前面的人可能拿走绝大部分,最后几人只剩几分钱,极度不公平。
  • 二倍均值的期望值:每人期望获得 剩余金额 / 剩余人数,即均值。金额范围被限制在 [0.01, 均值*2) 内,保证了每个人的金额相对均匀,最大金额不超过均值的2倍。
  • 数学保证:任意一次随机金额的期望值恰好等于人均金额,整体公平。

边界处理

  • 最后一个人直接获得剩余全部金额(不再随机)。
  • 最小金额保底0.01元,防止出现0分红包。
  • 防止随机值过大导致后续人无钱可分(每人至少保留0.01元)。

Q18:红包系统的 Lua 脚本原子操作具体做了什么?为什么必须用 Lua 脚本而不是多个 Redis 命令?

答: Lua 脚本原子操作流程(hongbao.lua):

lua
-- 1. 检查红包是否存在 local amounts = redis.call('LLEN', KEYS[1]) -- hongbao:{id}:amounts if amounts == 0 then return -3 end -- 红包已领完 -- 2. 检查用户是否已领 local received = redis.call('SISMEMBER', KEYS[2], ARGV[1]) -- hongbao:{id}:receivers, userId if received == 1 then return -2 end -- 已领过 -- 3. 弹出金额 + 记录用户 local amount = redis.call('LPOP', KEYS[1]) redis.call('SADD', KEYS[2], ARGV[1]) return amount -- 返回领取金额

必须用 Lua 的原因

  • Redis 本身是单线程的,但多个命令之间并不是原子的。若不用 Lua:

    1. A、B 同时检查红包有 1 个金额,都通过了检查
    2. A 执行 LPOP 拿到金额
    3. B 执行 LPOP 拿到 nil(红包已空),但 B 已通过了检查步骤
    4. 超发:B 可能被错误地认为领取成功
  • Lua 脚本在 Redis 中是原子执行的,执行期间不会插入其他命令,彻底解决 TOCTOU(检查时间与使用时间)竞态条件。


Q19:红包系统中,Redisson 分布式锁和 Lua 脚本同时存在,它们的职责如何区分?代码里两者是否都用了?

答: 根据代码分析:

  • receiveHongbao 方法中使用了 Redisson RLockhongbao:receive:{hongbaoId}),是主流程。
  • Lua 脚本存在于 resources/lua/hongbao.lua,作为备用方案。

职责对比

Redisson RLockLua 脚本
并发控制串行化所有请求(同时只有1个执行)原子化多步Redis操作
性能较低(锁等待,最多等10秒)较高(单次原子操作)
适用场景低并发、逻辑复杂高并发、逻辑简单
超发防护依赖锁的互斥性依赖原子性

实际生产中:对于高并发抢红包,Lua 脚本方案性能更优。Redisson 方案每次只有1个线程执行,其余都在等待,等待超时(10秒)会失败,抢红包体验差。Lua 脚本可以让所有请求并发执行,Redis 单线程保证原子性,无需等待。


Q20:红包金额预分配存在 Redis List 中,如果 Redis 宕机了怎么办?如何保证数据不丢失?

答: 风险分析:

  • Redis 宕机后,hongbao:{id}:amounts List 丢失,但红包总金额已从用户账户扣减(数据库已落库),造成用户余额减少但红包无法领取的问题。

解决方案:

  1. Redis 持久化(AOF):开启 AOF 持久化(appendfsync alwayseverysec),宕机重启后从 AOF 恢复数据。
  2. Redis Sentinel/Cluster:主从复制+自动故障转移,主节点宕机时从节点接管,数据不丢失(min-slaves-to-write=1 保证至少同步到一个从节点)。
  3. 补偿机制:定时任务检查已扣款但未完全分发的红包(status=0expire_time < now),将未领取金额退回到发送者账户。
  4. 数据库作为 Source of Truth:发红包时先落库(记录总金额、份数),Redis 宕机后可从数据库重建 Redis 数据,重新预分配剩余金额。

六、分库分表

Q21:你的分片键是 session_id,分库算法是 ds$->{session_id % 2},分表算法是 message_$->{session_id.intdiv(2) % 2},为什么这样设计而不是直接 session_id % 4

答: 当前算法:

  • session_id=0 → ds0, message_0
  • session_id=1 → ds1, message_0
  • session_id=2 → ds0, message_1
  • session_id=3 → ds1, message_1

若改为 session_id % 4 直接分4个逻辑表(所有在同一个库):

  • session_id % 4 只能分表,无法跨库分布

当前设计优势

  1. 物理分离:两个库可以部署在不同机器,分散 I/O 和 CPU 负载,这是真正的分库而非只分表。
  2. 扩展性:增加数据库节点时,可按偶数扩展(2→4→8),迁移规律清晰。
  3. 数据分布均匀:相邻的 session_id 分布到不同的库,避免热点集中在一个库。

缺陷

  • intdiv 是 Groovy 语法,依赖 ShardingSphere 的 Groovy 表达式,不够直观,建议用自定义分片算法类替代。

Q22:你的消息 ID 生成用了"时间戳偏移(41bit) + Redis自增序列(22bit)",为什么不用雪花算法(Snowflake)?

答: 标准雪花算法结构41bit时间戳 + 10bit机器ID + 12bit序列号

你的算法结构41bit秒级时间偏移 + 22bit Redis自增序列

对比

雪花算法你的方案
时间精度毫秒级秒级(需要 Redis 支持更高序列)
序列上限4096/毫秒/节点4,194,303/秒(22bit)
部署依赖无外部依赖依赖 Redis
机器ID问题需要分配、管理机器ID(容器化麻烦)无机器ID,无此烦恼
单调递增同一毫秒内顺序不保证同一秒内严格递增

选择理由

  1. 容器化部署环境中,雪花算法的 workerId 分配是个麻烦事(需要服务注册或配置中心分配),你的方案通过 Redis 统一序列,无需管理机器ID。
  2. 每秒422万的序列上限,对于即时通讯场景足够(全球 QQ 消息峰值也就几十万/秒)。

潜在问题:依赖 Redis,Redis 宕机时 ID 生成会失败,需要降级方案(如本地序列 fallback)。


Q23:按 session_id 分片后,如何查询某个用户的所有私聊会话列表?这是一个跨分片查询吗?

答: 问题分析

  • 私聊 sessionId = (min(userId, friendId) << 32) | max(userId, friendId)
  • 用户A与不同好友的会话,每对好友的 sessionId 不同,分布在不同分片上。
  • 查询"用户A的所有会话列表"确实是跨分片查询

处理方式

  1. 会话表独立:消息表按 sessionId 分片,但会话列表(conversations)单独维护一张表,不分片(数据量远小于消息表,每用户最多几百个会话)。会话表存 userIdfriendId/groupIdlastMessageunreadCount,直接按 userId 查询。
  2. 消息表只按 sessionId 查:所有消息查询都必须带上 sessionId(如"查某个对话的历史消息"),这是强制规范,避免跨分片扫描。
  3. ShardingSphere 全路由:如果真的需要按 userId 查消息表,ShardingSphere 会全路由所有分片汇总结果,性能差,应极力避免。

Q24:如果未来数据量增长,需要从2库4表扩容到4库8表,如何迁移?对业务有没有影响?

答: 挑战

  • 扩容后分片算法变化(session_id % 4 代替 session_id % 2),原有数据路由结果变化,历史数据在新算法下找不到。

迁移步骤(双写方案):

  1. 停写(暂停消息写入):维护窗口内停止 Kafka Consumer,暂停消息落库。
  2. 数据迁移:按新算法计算每条历史消息的目标分片,将数据从旧分片迁移到新分片(可能跨库)。
  3. 更新配置:修改 ShardingSphere 分片算法配置,切换到新规则。
  4. 恢复写入:重新启动消费者,新消息按新规则写入。

更优方案(停服时间最小化)

  1. 双写过渡:同时向旧分片和新分片写入,新查询走新分片,旧分片作为读回退。
  2. 渐进迁移:先迁历史数据,再切换写入路由,最后下线旧分片。
  3. 使用一致性哈希:替代取模分片,扩容时只有部分数据需要迁移(理论上 1/n 的数据需要移动)。

业务影响:停服迁移必然影响业务,需要选择低峰期,配合前端展示维护提示。


七、缓存策略

Q25:群聊在线成员缓存用了 Redis Set,TTL 为60秒,如果成员下线但 TTL 未过期,消息会被推送到该成员吗?

答: 问题复现

  1. 用户A在线,SADD group:online:members:{groupId} userId
  2. 用户A断开连接,但若没有主动调用 SREM,该记录会在60秒内继续存在。
  3. 60秒内有群消息,SMEMBERS 仍返回用户A,服务端尝试推送消息。
  4. 推送时查询 ws:user:session:{userId},若 WebSocket 连接关闭时未清理该 Key,则仍能查到 serverKey。
  5. RabbitMQ 路由到对应 WS 实例,查找本地 Session Map,找不到用户A的 Session,消息静默丢失

解决方案

  1. 主动清理WebSocketHandler.afterConnectionClosed() 回调中,调用 SREM group:online:members:{groupId} userId 并删除 ws:user:session:{userId}
  2. 推送前验证:推送时再次检查用户 Session 是否在 WS 实例本地存在,不存在则标记为离线消息。
  3. 离线消息存储:找不到在线 Session 时,存入离线消息队列,用户重新上线后拉取未读消息。

Q26:你设计了写扩散、推拉结合、读扩散三种群消息扩散模式,分别适用什么场景?各有什么优缺点?

答:

模式触发条件原理优点缺点
写扩散≤100人发消息时,主动推送给所有在线成员读取时延迟极低,直接展示群越大,写放大越严重(1000人群=1000次推送)
推拉结合100-1000人推送通知(不含内容)+客户端主动拉取平衡读写压力增加客户端主动拉取逻辑复杂度
读扩散>1000人消息只存一份,用户读取时拉取写操作轻量,适合超大群读取时延迟高,需要分页加载

你的实现亮点

  • 写扩散时缓存在线成员列表,按 serverKey 分组,组内批量多线程异步推送CompletableFuture),既减少 Redis 查询次数,又充分利用并发。
  • 本地缓存(ConcurrentHashMap)存储群的扩散模式,避免每条消息都查数据库判断群大小。

八、系统设计与架构

Q27:为什么选 Nacos 做服务发现,而不是 Eureka 或 Consul?

答: 对比选型:

NacosEurekaConsul
服务发现
配置中心✅(内置)❌(需 Spring Cloud Config)✅(KV Store)
健康检查心跳 + 主动探测心跳TCP/HTTP/gRPC
临时/持久实例支持区分只有临时持久
AP/CP 切换支持(临时=AP,持久=CP)只有 APCP
生态阿里巴巴,国内社区强Netflix,已停更HashiCorp

选择 Nacos 的原因

  1. 一体化:同时作为服务注册中心和配置中心,减少中间件数量。
  2. Eureka 已停更:Spring Cloud 官方推荐迁移到其他实现。
  3. 国内生态:与 Spring Cloud Alibaba 深度集成,文档丰富,出了问题好排查。

Q28:Gateway 层做了哪些事情?如果 Gateway 成为单点,如何保证高可用?

答: Gateway 职责:

  1. 路由转发:根据路径规则将请求路由到对应微服务(/api/user/** → 用户服务)。
  2. Token 验证:全局 Filter 验证 accessToken,合法后向下游请求头注入用户信息。
  3. 负载均衡:集成 Ribbon/LoadBalancer,对多实例服务做负载均衡。
  4. 限流熔断:可集成 Sentinel 或 Resilience4j 做接口级限流。

高可用方案:

  1. 多实例部署:Gateway 本身无状态(Token 验证依赖 Redis,不在本地保存状态),可直接水平扩展部署多实例。
  2. 前置负载均衡:在 Gateway 前部署 Nginx 或云厂商 LB(如 AWS ALB),将流量分发到多个 Gateway 实例。
  3. Nacos 服务发现:Gateway 实例注册到 Nacos,前置 Nginx 通过 DNS 或 IP 直连,或配合 Keepalived 做 VIP 漂移。

Q29:系统中消息的全局唯一 ID 是 时间戳<<22 | seq,这种 ID 为什么支持"游标翻页"?相比 offset 翻页有什么优势?

答: ID 结构:高位是时间戳,低位是序列号,ID 天然是时间有序的(越新的消息ID越大)。

游标翻页

sql
-- 查询 cursor 之前的20条消息(往上翻页) SELECT * FROM message WHERE session_id = ? AND id < #{cursor} ORDER BY id DESC LIMIT 20
  • 第一次请求:cursor = Long.MAX_VALUE,拿到最新20条
  • 下一次请求:cursor = 上次最后一条消息的ID,继续往前拿

对比 OFFSET 翻页

游标翻页(Cursor)OFFSET 翻页
性能O(log n)(索引范围扫描)O(n)(需要扫描跳过n条)
数据一致性稳定(新消息不影响已翻页)不稳定(新消息插入可能导致跳页/重复)
跳页不支持(只能顺序翻)支持(可直接跳到第100页)
适用场景消息记录、Feed流、无限滚动传统分页列表

聊天记录场景天然是顺序翻页,不需要跳页,游标翻页完全契合,且性能远优于 OFFSET。


Q30:如果让你重新设计这个系统,有哪些地方你会改进?

答: 诚实地指出可以改进的点(展示技术深度):

  1. 离线消息推送:当前掉线后消息可能丢失,需要完善离线消息存储(Redis List 存最近N条)和重连后拉取机制。

  2. 消息已读状态:目前未看到已读/未读的实现,实际 QQ 有双 tick(已发送/已读),需要设计已读回执机制。

  3. 红包改用 Lua 脚本:当前用 Redisson 分布式锁串行处理,高并发下性能差,应全面切换为 Lua 脚本方案。

  4. 消息ACK确认:WebSocket 消息推送是否到达客户端没有确认机制,客户端收到消息后应发送 ACK,超时未收到 ACK 则重推(可靠消息协议)。

  5. 分库分表扩容难题:当前取模分片扩容困难,改用一致性哈希分片或引入 NewSQL(如 TiDB)会更好。

  6. 监控与可观测性:可以增加 Prometheus + Grafana 监控 Kafka Consumer Lag、RabbitMQ 队列深度、Redis 内存使用等核心指标。

  7. 消息内容加密:用户隐私保护,端对端加密(E2EE),当前消息明文存储在数据库中。


九、追问场景(高频难题)

Q31:一致性哈希和普通取模哈希的区别?在分布式缓存/分库分表中哪个更适合?

答: 取模哈希node = key % N

  • 扩容时(N→N+1),几乎所有 key 的路由结果改变,需要迁移几乎全部数据。

一致性哈希

  • 将 0~2^32 的哈希空间组成一个环(Hash Ring)。
  • 每个节点映射到环上的一个点,key 顺时针找到第一个节点。
  • 扩容(添加新节点)时,只有新节点"顺时针方向的前驱节点"到新节点之间的 key 需要迁移,约 1/N 的数据量。

虚拟节点

  • 一致性哈希的问题是节点分布不均(数据倾斜),通过为每个物理节点创建多个虚拟节点(如每个节点150个虚拟点)解决。

适用场景

  • 分布式缓存(Redis Cluster、Memcached):节点变化频繁,用一致性哈希减少缓存失效。
  • 分库分表:数据量较稳定时用取模简单够用;预期频繁扩容时用一致性哈希。

Q32:你的项目中哪些地方可能存在内存泄漏?如何排查?

答: 潜在内存泄漏点

  1. ConcurrentHashMap<Long, WebSocketSession> 不清理:用户断连时未及时从 Map 中移除 Session,Map 会无限增长。

    • 排查:JVM heap dump,分析 Map 中是否有大量 CLOSED 状态的 Session。
  2. 本地群扩散模式缓存 ConcurrentHashMap 无上限:群数量增长时,这个 Map 持续膨胀,无过期机制。

    • 优化:改用 Caffeine/Guava Cache,设置最大容量(LRU 淘汰)和过期时间。
  3. RabbitMQ 动态注册的 Bean:若 WS 服务频繁重启,动态注册的 Queue Bean 可能在 ApplicationContext 中堆积。

  4. Kafka Consumer 线程池:批量消费时若使用线程池推送群消息,需确保线程池有合理的队列上限,否则任务堆积导致 OOM。

排查工具

  • jmap -heap <pid>:查看堆内存使用情况。
  • jmap -dump:format=b,file=heap.hprof <pid>:生成堆转储。
  • MAT(Memory Analyzer Tool):分析 heap dump,找到内存最大的对象。
  • Arthas memory 命令:在线诊断 JVM 内存,无需重启。

Q33:WebSocket 的 ping/pong 心跳超时后如何检测用户掉线?afterConnectionClosed 和心跳超时是两套机制吗?

答: 两套机制:

机制1:TCP 层断开 → afterConnectionClosed

  • 网络正常断开(客户端主动 close、进程退出)时,TCP 四次挥手,Spring WebSocket 框架感知到连接关闭,触发 afterConnectionClosed 回调。
  • 在此回调中清理 Session Map 和 Redis 会话,是最可靠的清理时机。

机制2:心跳超时 → 主动断开

  • 当网络异常(如断网、NAT 超时)时,TCP 连接实际已失效,但框架层可能感知不到(半开连接 Half-Open Connection)。
  • 此时依赖心跳超时:服务端若超过 N 秒未收到客户端 ping,主动调用 session.close() 关闭连接,触发 afterConnectionClosed
  • 实现:服务端记录每个 Session 的最后心跳时间(可用 lastHeartbeat Map),定时任务扫描超时的连接。

Spring WebSocket 内置 ping/pong:也可以用 WebSocketSession 本身的 setPingInterval 配合 setMaxSessionIdleTimeout 实现自动超时检测,比自己实现更简洁。


这份面试题集覆盖了你项目的全部核心技术点,从原理到实现细节,从设计决策到故障处理。建议重点背熟 Q1(消息完整链路)、Q6(双Token)、Q17-Q19(红包)、Q21-Q23(分库分表)、Q29(游标翻页)这几题,这些在大厂面试中被问到的概率最高。祝你拿下 offer!

如果对你有用的话,可以打赏哦
打赏
ali pay
wechat pay