让我先深入了解你的代码实现,再出针对性的面试题。
[1 tool called]
已深入分析你的代码实现,现在针对每个技术点出题,覆盖原理、设计决策、细节实现、故障处理等多个维度。
Q1:你的项目如何实现 WebSocket 集群间消息路由?请详细描述一条私聊消息从发送到接收的完整链路。
答: 完整链路分为以下几步:
message.persist,保证消息异步落库。ws:user:session:{receiverId} 获取接收方所在服务器的 serverKey。ws.chat.{serverKey} 为 routingKey,将消息投递到 Topic Exchange ws.exchange。ws.chat.queue.{serverKey},绑定对应 routingKey,收到消息后通过本地 ConcurrentHashMap<Long, WebSocketSession> 找到接收方 Session,直接推送。INSERT IGNORE 写入分片表,保证幂等。关键设计:RabbitMQ 负责实时性,Kafka 负责可靠存储,两者职责分离,互不阻塞。
Q2:你的心跳机制是如何设计的?为什么心跳要携带 Token?
答: 心跳设计:
ping 消息,携带当前 accessToken。ws:user:session:{userId}),返回 pong。携带 Token 的原因:
Q3:如果用户在心跳间隔内掉线,Redis 中的会话信息如何处理?会有什么问题?
答: 问题分析:
ws:user:session:{userId} TTL=300秒,如果用户掉线后不再心跳刷新,300秒后自动过期,问题不大。解法:
afterConnectionClosed 回调:在连接关闭时主动从 Redis 删除会话 Key,同时从本地 Session Map 中移除,是最佳实践。Q4:你的集群设计中,每个 WS 实例都有独立的 RabbitMQ 队列,这带来了什么问题?如何优化?
答: 问题:
优化方案:
@Bean + ApplicationContext 动态注册。Q5:单设备登录是如何实现的?如果同一用户在两台设备上登录,第一台设备的体验是怎样的?
答: 实现机制:
ws:user:session:{userId},如果存在说明用户已在别处登录。serverKey,通过 RabbitMQ 向旧服务器发送 "被踢下线" 系统消息(routingKey = ws.system.{oldServerKey})。用户体验:第一台设备收到 "您的账号在另一设备登录,已被强制下线" 的系统消息,然后 WebSocket 连接被关闭,客户端提示重新登录。
Q6:请详细描述你的双 Token 设计,为什么要用两个 Token?accessToken 过期后如何无感续期?
答: 设计原因:
accessToken:有效期15分钟,用于业务鉴权,短期有效降低泄露风险。refreshToken:有效期7天,仅用于刷新,不参与业务请求,泄露面更小。无感续期流程:
accessToken,发现已过期,返回 401。refreshToken 调用刷新接口。refreshToken(解析+黑名单检查+数据库比对),生成新的 Token 对(tokenSeq+1),更新数据库中的 refreshToken,将旧 refreshToken 的 seq 加入黑名单。accessToken 重试原请求。Q7:你的黑名单机制是按 seq 实现的,而不是存储具体的 Token 字符串,这样设计有什么好处?有什么局限?
答:
设计:Redis Key = blacklist:token_seq:{userId},Value = 被拉黑的最大 seq 值,检查时只需判断 token.seq <= blacklistSeq。
好处:
局限:
Q8:refreshToken 的安全性如何保障?如果 refreshToken 泄露了怎么办?
答: 保障措施:
tokenSeq 递增,旧 seq 被拉黑)。泄露处理:
Q9:Token 验证在 Gateway 层和 WebSocket 层都有,它们的职责如何分工?
答:
accessToken 合法性(解析签名+黑名单检查),合法则在请求头中注入 userId、email 等信息透传给下游服务,下游服务无需再验证 Token,直接读取请求头。分工原则:Gateway 是入口防线,WebSocket Handler 是长连接安全守卫。两处验证逻辑复用了公共模块 TokenService.validateAccessToken(),保持一致性。
Q10:你的限流是用 Redis SETNX 实现的,说说 SETNX 在这里的原理,与令牌桶、滑动窗口相比有何区别?
答: SETNX 原理:
SET key value NX EX ttl:若 Key 不存在则设置成功(返回1),已存在则失败(返回0)。对比:
| 策略 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 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();
坑点:
X-Forwarded-For 是可以被客户端伪造的,恶意用户可以加上 X-Forwarded-For: 1.2.3.4 绕过 IP 限流。解决:只信任可信代理(Nginx)设置的 Header,在 Nginx 层用 $realip_remote_addr 覆盖客户端伪造的值。RemoteAddr 变成 Nginx IP,必须用 Header 获取真实 IP。::1 是 IPv6 的本地回环地址,需要标准化处理。Q12:分布式锁在红包场景是用 Redisson 实现的,而限流是用 SETNX,为什么两者不统一?
答: 这是两种不同语义的场景:
限流(SETNX):
互斥锁(Redisson RLock):
tryLock(10, 30, TimeUnit.SECONDS))Redisson Watchdog 机制:持有锁的线程若执行时间超过锁超时(默认30秒),Watchdog 每10秒自动续期一次,防止业务未执行完锁就自动释放导致超发。
Q13:你的 Kafka 消费者使用手动 ACK,如果批量消费100条消息时第50条落库失败,如何处理?
答:
当前代码逻辑分析(INSERT IGNORE + 批量插入):
INSERT IGNORE,即使第50条主键冲突也不会报错,整批可以成功。batchInsert 会抛异常。处理策略:
enable.auto.commit=false),重试时再次批量插入,INSERT IGNORE 保证幂等不会重复。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,这个数字是怎么确定的?分区数与消费者实例数有什么关系?
答:
代码中注释:// 分区数 >= 实例数,规则说明:
分区数 = 消费者并行度上限:
64的由来:
消息顺序性考量:私聊消息按 sessionId 作为 Kafka key,同一会话的消息路由到同一分区,保证单会话内消息顺序落库。
Q15:RabbitMQ 消息持久化是如何配置的?消息从生产到消费的全链路可靠性如何保障?
答: 全链路可靠性保障:
生产者确认(Publisher Confirm):
spring.rabbitmq.publisher-confirm-type: correlatedconfirm,若失败触发重发逻辑。消息持久化:
durable=truedurable=truedeliveryMode=2(MessageDeliveryMode.PERSISTENT)消费者手动 ACK:
acknowledge-mode: manualchannel.basicAck(),业务失败或异常 channel.basicNack(requeue=false)requeue=false:失败消息不重新入队(防止无限循环),而是路由到死信队列(DLX)。幂等性兜底:
SETNX key uuid 防重复消费Q16:你说 RabbitMQ 负责实时推送,Kafka 负责异步落库,如果 Kafka 消费延迟,用户看到的消息和数据库里的消息会不一致,怎么处理?
答: 这是一个最终一致性场景,核心设计思路:
发生情况:消息已通过 RabbitMQ 推送给接收方(对方已看到),但 Kafka 还未消费完,数据库中暂无此消息。此时如果接收方刷新历史消息,会发现消息"消失"。
解决方案:
Q17:请详细描述二倍均值算法,为什么用它而不是纯随机?
答: 二倍均值算法:
每次随机金额 = Random(0.01, 剩余金额 / 剩余人数 * 2)
示例:10元,5人
为什么不用纯随机:
Random(0.01, 剩余金额))会导致前面的人可能拿走绝大部分,最后几人只剩几分钱,极度不公平。剩余金额 / 剩余人数,即均值。金额范围被限制在 [0.01, 均值*2) 内,保证了每个人的金额相对均匀,最大金额不超过均值的2倍。边界处理:
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:
LPOP 拿到金额LPOP 拿到 nil(红包已空),但 B 已通过了检查步骤Lua 脚本在 Redis 中是原子执行的,执行期间不会插入其他命令,彻底解决 TOCTOU(检查时间与使用时间)竞态条件。
Q19:红包系统中,Redisson 分布式锁和 Lua 脚本同时存在,它们的职责如何区分?代码里两者是否都用了?
答: 根据代码分析:
receiveHongbao 方法中使用了 Redisson RLock(hongbao:receive:{hongbaoId}),是主流程。resources/lua/hongbao.lua,作为备用方案。职责对比:
| Redisson RLock | Lua 脚本 | |
|---|---|---|
| 并发控制 | 串行化所有请求(同时只有1个执行) | 原子化多步Redis操作 |
| 性能 | 较低(锁等待,最多等10秒) | 较高(单次原子操作) |
| 适用场景 | 低并发、逻辑复杂 | 高并发、逻辑简单 |
| 超发防护 | 依赖锁的互斥性 | 依赖原子性 |
实际生产中:对于高并发抢红包,Lua 脚本方案性能更优。Redisson 方案每次只有1个线程执行,其余都在等待,等待超时(10秒)会失败,抢红包体验差。Lua 脚本可以让所有请求并发执行,Redis 单线程保证原子性,无需等待。
Q20:红包金额预分配存在 Redis List 中,如果 Redis 宕机了怎么办?如何保证数据不丢失?
答: 风险分析:
hongbao:{id}:amounts List 丢失,但红包总金额已从用户账户扣减(数据库已落库),造成用户余额减少但红包无法领取的问题。解决方案:
appendfsync always 或 everysec),宕机重启后从 AOF 恢复数据。min-slaves-to-write=1 保证至少同步到一个从节点)。status=0 且 expire_time < now),将未领取金额退回到发送者账户。Q21:你的分片键是 session_id,分库算法是 ds$->{session_id % 2},分表算法是 message_$->{session_id.intdiv(2) % 2},为什么这样设计而不是直接 session_id % 4?
答: 当前算法:
session_id=0 → ds0, message_0session_id=1 → ds1, message_0session_id=2 → ds0, message_1session_id=3 → ds1, message_1若改为 session_id % 4 直接分4个逻辑表(所有在同一个库):
session_id % 4 只能分表,无法跨库分布当前设计优势:
缺陷:
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,无此烦恼 |
| 单调递增 | 同一毫秒内顺序不保证 | 同一秒内严格递增 |
选择理由:
workerId 分配是个麻烦事(需要服务注册或配置中心分配),你的方案通过 Redis 统一序列,无需管理机器ID。潜在问题:依赖 Redis,Redis 宕机时 ID 生成会失败,需要降级方案(如本地序列 fallback)。
Q23:按 session_id 分片后,如何查询某个用户的所有私聊会话列表?这是一个跨分片查询吗?
答: 问题分析:
sessionId = (min(userId, friendId) << 32) | max(userId, friendId)处理方式:
userId、friendId/groupId、lastMessage、unreadCount,直接按 userId 查询。Q24:如果未来数据量增长,需要从2库4表扩容到4库8表,如何迁移?对业务有没有影响?
答: 挑战:
session_id % 4 代替 session_id % 2),原有数据路由结果变化,历史数据在新算法下找不到。迁移步骤(双写方案):
更优方案(停服时间最小化):
1/n 的数据需要移动)。业务影响:停服迁移必然影响业务,需要选择低峰期,配合前端展示维护提示。
Q25:群聊在线成员缓存用了 Redis Set,TTL 为60秒,如果成员下线但 TTL 未过期,消息会被推送到该成员吗?
答: 问题复现:
SADD group:online:members:{groupId} userId。SREM,该记录会在60秒内继续存在。SMEMBERS 仍返回用户A,服务端尝试推送消息。ws:user:session:{userId},若 WebSocket 连接关闭时未清理该 Key,则仍能查到 serverKey。解决方案:
WebSocketHandler.afterConnectionClosed() 回调中,调用 SREM group:online:members:{groupId} userId 并删除 ws:user:session:{userId}。Q26:你设计了写扩散、推拉结合、读扩散三种群消息扩散模式,分别适用什么场景?各有什么优缺点?
答:
| 模式 | 触发条件 | 原理 | 优点 | 缺点 |
|---|---|---|---|---|
| 写扩散 | ≤100人 | 发消息时,主动推送给所有在线成员 | 读取时延迟极低,直接展示 | 群越大,写放大越严重(1000人群=1000次推送) |
| 推拉结合 | 100-1000人 | 推送通知(不含内容)+客户端主动拉取 | 平衡读写压力 | 增加客户端主动拉取逻辑复杂度 |
| 读扩散 | >1000人 | 消息只存一份,用户读取时拉取 | 写操作轻量,适合超大群 | 读取时延迟高,需要分页加载 |
你的实现亮点:
serverKey 分组,组内批量多线程异步推送(CompletableFuture),既减少 Redis 查询次数,又充分利用并发。ConcurrentHashMap)存储群的扩散模式,避免每条消息都查数据库判断群大小。Q27:为什么选 Nacos 做服务发现,而不是 Eureka 或 Consul?
答: 对比选型:
| Nacos | Eureka | Consul | |
|---|---|---|---|
| 服务发现 | ✅ | ✅ | ✅ |
| 配置中心 | ✅(内置) | ❌(需 Spring Cloud Config) | ✅(KV Store) |
| 健康检查 | 心跳 + 主动探测 | 心跳 | TCP/HTTP/gRPC |
| 临时/持久实例 | 支持区分 | 只有临时 | 持久 |
| AP/CP 切换 | 支持(临时=AP,持久=CP) | 只有 AP | CP |
| 生态 | 阿里巴巴,国内社区强 | Netflix,已停更 | HashiCorp |
选择 Nacos 的原因:
Q28:Gateway 层做了哪些事情?如果 Gateway 成为单点,如何保证高可用?
答: Gateway 职责:
/api/user/** → 用户服务)。高可用方案:
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:如果让你重新设计这个系统,有哪些地方你会改进?
答: 诚实地指出可以改进的点(展示技术深度):
离线消息推送:当前掉线后消息可能丢失,需要完善离线消息存储(Redis List 存最近N条)和重连后拉取机制。
消息已读状态:目前未看到已读/未读的实现,实际 QQ 有双 tick(已发送/已读),需要设计已读回执机制。
红包改用 Lua 脚本:当前用 Redisson 分布式锁串行处理,高并发下性能差,应全面切换为 Lua 脚本方案。
消息ACK确认:WebSocket 消息推送是否到达客户端没有确认机制,客户端收到消息后应发送 ACK,超时未收到 ACK 则重推(可靠消息协议)。
分库分表扩容难题:当前取模分片扩容困难,改用一致性哈希分片或引入 NewSQL(如 TiDB)会更好。
监控与可观测性:可以增加 Prometheus + Grafana 监控 Kafka Consumer Lag、RabbitMQ 队列深度、Redis 内存使用等核心指标。
消息内容加密:用户隐私保护,端对端加密(E2EE),当前消息明文存储在数据库中。
Q31:一致性哈希和普通取模哈希的区别?在分布式缓存/分库分表中哪个更适合?
答:
取模哈希:node = key % N
一致性哈希:
1/N 的数据量。虚拟节点:
适用场景:
Q32:你的项目中哪些地方可能存在内存泄漏?如何排查?
答: 潜在内存泄漏点:
ConcurrentHashMap<Long, WebSocketSession> 不清理:用户断连时未及时从 Map 中移除 Session,Map 会无限增长。
本地群扩散模式缓存 ConcurrentHashMap 无上限:群数量增长时,这个 Map 持续膨胀,无过期机制。
RabbitMQ 动态注册的 Bean:若 WS 服务频繁重启,动态注册的 Queue Bean 可能在 ApplicationContext 中堆积。
Kafka Consumer 线程池:批量消费时若使用线程池推送群消息,需确保线程池有合理的队列上限,否则任务堆积导致 OOM。
排查工具:
jmap -heap <pid>:查看堆内存使用情况。jmap -dump:format=b,file=heap.hprof <pid>:生成堆转储。memory 命令:在线诊断 JVM 内存,无需重启。Q33:WebSocket 的 ping/pong 心跳超时后如何检测用户掉线?afterConnectionClosed 和心跳超时是两套机制吗?
答: 两套机制:
机制1:TCP 层断开 → afterConnectionClosed
afterConnectionClosed 回调。机制2:心跳超时 → 主动断开
session.close() 关闭连接,触发 afterConnectionClosed。lastHeartbeat Map),定时任务扫描超时的连接。Spring WebSocket 内置 ping/pong:也可以用 WebSocketSession 本身的 setPingInterval 配合 setMaxSessionIdleTimeout 实现自动超时检测,比自己实现更简洁。
这份面试题集覆盖了你项目的全部核心技术点,从原理到实现细节,从设计决策到故障处理。建议重点背熟 Q1(消息完整链路)、Q6(双Token)、Q17-Q19(红包)、Q21-Q23(分库分表)、Q29(游标翻页)这几题,这些在大厂面试中被问到的概率最高。祝你拿下 offer!

