文章目录
- 1、如果数据量太大了给消息队列塞满了怎么办?
- 2、群聊怎么实现?
- 3、 为什么要用二级缓存?二级缓存怎么实现?
- 4、写扩散、读扩散是什么,他们的区别是什么?
- 5、如果每个服务器都要维护一个 userId 和 channel 的列表,服务器上同时在线的人很多,列表很大如何解决?
- 6、你的项目怎么保证 Kafka 分区之间的顺序消费?
- 7、项目中哪些结构或模块用到了高并发,能体现出高并发的理念?
- 8、Netty 的内存参数是怎么设置?
- 9、 竞品也是用 WebSocket 和 Netty 吗,这方面有了解过吗?
- 10、 为什么 WebSocket + Netty 是主流?
- 11、在 WebSocket 即时通信项目中,采用了 kafka 消息队列。如果生产者向 broker 发送的信息,broker 向生产者返回 ack,此时 ack 丢失了,但 broker 已经接收到这个消息,生产者会重新发送,这个时候消息会重复被读取吗?
- 12、 在 WebSocket 即时通信项目中,采用了 kafka 消息队列。如果消费者消费消息,向 broker 返回的 ack 丢失了,broker 会重发消息吗?这个消息会被重复读取吗?
- 13、消息记录如何存储?
- 14、 聊天时,kafka 的 topic 如何设计,partition 的个数又如何呢?
- 15、 Kafka 4 个消费者消费 3 个分区,3 个消费者消费 4 个分区,分别是什么现象?
- 16、Kafka 跟 RocketMQ 的区别?
- 17、 用户发红包时需要扣减用户金额,是否要先加锁和锁后更新?
- 18、 未加锁的话,如果检查用户余额时余额够用,更新的时候不够用怎么办?
- 19、 如何保证红包表的修改和用户余额的修改是同步成功或失败的
- 20、 Redis中已经修改的话,如何恢复
- 21、 为什么考虑使用redis存储红包剩余个数?
- 22、 Redis缓存挂掉怎么办?用户还能抢红包吗
- 23、 用 WebSocket 和别人聊天,聊天信息是如何存储的,有没有加密或者其他的东西?
- 24、 用户量巨大,如何分库分表?
- 25、 雪花算法(全局唯一ID)
- 26、 用户密码丢了可以修改密码?
- 27、项目中为什么要用一致性哈希?
- 28、 数据库三范式,用到了哪个范式?有没有哪张表因为实际查询情况只满足第二范式的?
1、如果数据量太大了给消息队列塞满了怎么办?
- 原因:消息队列出现消息堆积,通常是因为消息的消费速度远小于生产速度。
- 消费者:
- 增加消费者实例:扩展消费者组的实例数量,确保消费者数量与分区数匹配
- 异步批处理:消费者采用批量拉取消息(
max.poll.records
)并异步处理,提升吞吐量。 - 多线程:将消息拉取与业务处理分离,使用线程池异步处理消息。例如,主线程负责从
Kafka
拉取消息,放入阻塞队列,子线程池并行处理消息。 - 流控机制:动态调整 Kafka 消费速率,流控机制的核心思想是根据队列的剩余容量动态调整消费速率,确保生产和消费的平衡
- 离线消息异步落库:将离线消息直接存入数据库,绕过 Kafka,减少队列压力
- Broker:
- 临时 Topic 应急:若积压严重,可新建临时 Topic,增加分区数,将积压消息转发至临时 Topic,同时启动更多消费者快速处理,处理完成后恢复原架构。
- 分区动态分配:监控分区负载情况,通过
Kafka
的kafka-reassign-partitions
工具均衡分区分布,避免部分分区过载。
- 生产者:
- 限流:在生产者端设置限流机制,避免消息生产速度过快(令牌桶算法)
- 批量发送:将多条消息批量发送,减少网络请求次数,可以通过设置
batch.size
参数来控制批量消息的大小。例如,设置batch.size = 1024 (1KB)
是,当消息累计到达1KB
时,生产者将这批消息一次性发送出去。 - 业务降级:当系统过载时,优先确保关键消息(如文字消息)发送,非关键消息(如图片,表情包,文件)延迟发送或暂存
- 容灾与监控:
- 消息持久化:引入本地缓存(如
Redis
)或外部存储,将积压消息暂存并异步处理,避免丢失 - 重试:设置死信队列(DLQ),捕获处理失败的消息,定时重试或人工介入
- 熔断:监控消费者延迟,超过阈值时触发告警,并暂停非核心业务的消息生产
- 消息持久化:引入本地缓存(如
思:什么是异步处理?
一、异步处理的定义与原理
- 定义:异步处理是指在执行一个任务时,不等待任务完成,而是继续执行后续代码,当任务完成后再通过回调、事件通知等方式处理结果。
- 原理:
- 非阻塞:与同步处理不同,异步处理不会阻塞当前线程,允许程序在等待任务完成时继续执行其他操作。
- 回调机制:任务完成后,通过回调函数、事件通知等方式返回结果。
二、异步处理的特点
- 提高效率:避免线程阻塞,充分利用系统资源,提升吞吐量。
- 解耦:生产者与消费者解耦,生产者无需等待消费者处理完成即可继续工作。
- 复杂性增加:需要处理回调、错误处理、状态管理等问题。
三、消息队列中的异步处理
1、消费者端的异步处理
- 批量拉取与异步处理:消费者从消息队列中批量拉取消息后,将消息放入一个阻塞队列,然后通过线程池异步处理这些消息。主线程负责从消息队列中拉取消息,子线程池负责处理消息。
2、生产者端的异步发送
- 批量发送:生产者可以将多条消息批量发送,减少网络请求次数。通过设置batch.size参数,当消息累计到达一定大小时,一次性发送出去。
3、异步处理的优势
- 提升系统吞吐量:通过非阻塞的方式,系统可以同时处理多个任务,提高资源利用率。
- 增强系统可扩展性:异步处理可以更好地应对高并发场景,通过线程池等技术动态调整资源分配。
- 降低系统耦合度:生产者和消费者之间解耦,便于独立扩展和优化。
思:什么是吞吐量?
一、吞吐量的定义
- 定义:吞吐量是指单位时间内系统能够处理的请求数量或数据量,是衡量系统性能的重要指标之一。
- 单位:通常用“请求数/秒”(如 QPS,Queries Per Second)或“字节数/秒”(如 MB/s 或 GB/s)来表示。
二、 吞吐量的重要性
- 衡量系统性能:吞吐量越高,说明系统在单位时间内能够处理更多的请求或数据,性能越好。
- 评估系统能力:通过吞吐量可以评估系统在高并发场景下的处理能力,帮助进行容量规划和资源分配。
三、影响吞吐量的因素
- 硬件资源:
- CPU:CPU 的性能直接影响系统的处理速度,多核 CPU 可以同时处理多个任务,提高吞吐量。
- 内存:足够的内存可以减少磁盘 I/O 操作,提高数据读写速度,从而提升吞吐量。
- 网络带宽:在网络应用中,网络带宽的大小直接影响数据传输的速度,带宽越高,吞吐量越大。
- 软件架构:
- 多线程/多进程:通过多线程或多进程可以充分利用多核 CPU 的资源,提高系统的并发处理能力。
- 异步处理:异步处理可以避免线程阻塞,提高系统的吞吐量。
- 负载均衡:通过负载均衡技术,可以将请求均匀分配到多个服务器上,避免单点过载,提高系统的整体吞吐量。
- 数据处理方式:
- 批量处理:将多个请求或数据批量处理,减少系统调用和网络传输的开销,提高吞吐量。
- 缓存机制:使用缓存可以减少对数据库或磁盘的频繁访问,提高数据读取速度,从而提升吞吐量。
四、吞吐量与响应时间的关系
- 吞吐量越高,响应时间不一定越短:吞吐量和响应时间是两个不同的性能指标。吞吐量高表示系统在单位时间内能够处理更多的请求,但并不一定意味着每个请求的响应时间很短。例如,一个系统通过批量处理提高了吞吐量,但每个请求的响应时间可能会因为等待批量处理而变长。
- 吞吐量和响应时间的平衡:在实际应用中,需要根据业务需求平衡吞吐量和响应时间。对于实时性要求高的系统,响应时间更为重要;而对于数据处理量大的系统,吞吐量更为关键。
五、吞吐量的优化方法
- 硬件优化:升级硬件设备,如增加 CPU 核心数、扩大内存容量、提升网络带宽等。
- 软件优化:
- 代码优化:优化算法和代码逻辑,减少不必要的计算和资源消耗。
- 架构优化:采用分布式架构、微服务架构等,提高系统的可扩展性和并发处理能力。
- 缓存策略:合理使用缓存,减少对后端数据库的访问压力。
- 异步处理:引入异步处理机制,避免线程阻塞,提高系统的吞吐量。
- 监控与调优:通过监控工具实时监控系统的吞吐量和性能指标,根据监控结果进行调优。
2、群聊怎么实现?
关于群聊的实现,我们确实是基于单聊模型之上进行扩展的。可以先快速地描述一下单聊的流程,再重点讲解群聊的实现方案、遇到的挑战以及优化。
1. 单聊实现(作为铺垫)
单聊的本质是点对点的消息投递。
这个流程的核心在于MessagingService
能够通过Redis精准地知道接收者B连接在哪一台RealTimeCommService
(称之为RTC服务)上,实现了消息的精准路由。
2. 群聊实现:从“暴力广播”到“精准扩散”
群聊是一对多的消息投递。我们之前的实现想法是比较简单的即,MessagingService 把消息推送给所有的RTC服务,每台RTC服务再判断自己管理的连接中有哪些属于这个群。但这个方案在RTC集群规模扩大时,会产生巨大的网络开销,是一场“广播风暴”。
原实现流程:
因此,可以采用了更优化的“写扩散”(Write Diffusion),也叫“读扩散前置”的方案。核心思想是:在消息发送时,就计算好所有需要接收消息的群成员,并按他们所在的RTC服务器进行分组推送。
具体流程如下:
技术实现细节:
- 群成员关系存储:在MySQL中,可以有group_info 表和group_member_relation 表。
- 路由信息查询优化:MessagingService 不是一个个去Redis查询成员路由,而是使用MGET 命令一次性批量获取所有成员的路由信息,减少网络往返。
- 消息推送优化:MessagingService 在计算出推送分组后,会使用CompletableFuture 或线程池并发地向不同的RTC服务发起HTTP请求,最大化推送效率。
3. 可能的追问与解决方案
追问:如果群成员非常多(例如上千人),你这个方案有什么问题?
回答:这是“写扩散”方案的主要挑战。当群成员数量巨大时:
- 拉取群成员列表慢:从DB一次性拉取几千个成员ID,有性能压力。我们会加缓存(Redis Set 结构)来存储群成员列表。
- 消息风暴:一次消息发送会扩散成百上千次的推送,对MessagingService 和RTC服务造成巨大瞬间压力。
解决方案:
- 限制大群人数:像微信一样,对普通群聊设置人数上限(例如500人),在产品层面规避技术挑战。
- 引入“读扩散”模型:对于超大群(类似Telegram频道或Discord社区),我们会切换到“读扩散”(Read Diffusion)模型。
- 写扩散:服务器主动推送给每个人,实时性好,适合小群。
- 读扩散:服务器只把消息存一份,客户端上线后,主动去拉取自己所在群的新消息。这种方式服务端压力小,但客户端实现复杂,实时性稍差(需要客户端轮询或通过轻量级通知唤醒)。
在我们的系统中,可以做一个融合方案:500人以下的群用写扩散,保证实时互动;超过500人的“超级群”,自动切换为读扩散模型。
3、 为什么要用二级缓存?二级缓存怎么实现?
a. 性能提升:Caffeine 作为本地缓存,访问速度是纳秒级(内存访问),远快于Redis(微秒级,网络+内存)。高频访问的WS地址可以直接从本地获取,大幅降低延迟。
b. 减轻 Redis 压力:通过本地缓存拦截大部分请求,避免所有请求都打到 Redis,尤其在高峰期能显著降低 Redis 的负载和网络带宽消耗。
c. 兜底机制:二级缓存(Redis)保证了即使本地缓存失效(如服务重启),系统仍然能正常工作。
4、写扩散、读扩散是什么,他们的区别是什么?
- 写扩散,消息发送时,主动推送给所有接收者(类似广播,或者说推)
- 读扩散,消息先集中存储,接收者主动拉取(类似收件箱,或者说拉)
- 说白了就是让谁去获取数据,写扩散就是需要服务器给所有的接收者发送消息,适合人数较少的群,读扩散是接收者主动去拉取消息,适合人数较多的群,一般我们可以采用推拉结合的混合写法:写扩散给在线成员,读扩散给离线成员
5、如果每个服务器都要维护一个 userId 和 channel 的列表,服务器上同时在线的人很多,列表很大如何解决?
项目中的方案用 **ConcurrentHashMap **来保存映射,下面是其他方案
a. 分布式存储方案
- 分片存储
- 将用户 ID 按哈希或范围分片到不同节点
- 每个节点只维护部分用户的映射关系
- 可通过一致性哈希减少节点变动的影响
- 外部存储
- 使用 Redis 集群存储映射关系
- 利用 Redis 的高性能和集群能力
- 可选用 Redis 的 Hash 结构存储,设置合理 TTL
** b. 内存优化方案**
- 高效数据结构
- 使用更紧凑的数据结构如 LongObjectHashMap (Netty提供)
- 对用户ID进行编码压缩(如将字符串ID转为数字)
- 分层存储
- 热数据在内存,冷数据持久化到磁盘
- 实现 LRU/LFU 缓存策略自动淘汰不活跃连接
c. 架构优化方案
- 服务拆分
- 将长连接服务按业务或地域拆分
- 每个服务实例维护自己连接的用户子集
- 服务发现机制
- 维护用户所在服务节点的路由表
- 通过 Zookeeper /Etcd / Nacos 等实现服务发现
6、你的项目怎么保证 Kafka 分区之间的顺序消费?
- **Kafka只能保证单个分区内的消息顺序,而无法保证跨分区的顺序。**这一点很重要,因为很多用户可能会误以为只要在一个主题下就能保证全局顺序,但实际上需要依赖分区的设计。
- 接下来需要详细解释如何利用分区来保证顺序。比如生产者通过指定消息键(Key)将相关消息发送到同一分区,消费者则按分区顺序消费。这时候可能会提到生产者发送消息时使用相同的Key,确保进入同一分区,消费者单线程处理每个分区的消息,或者使用多线程但确保同一分区的消息由同一线程处理。
- 同时,用户可能想知道如何应对消费者组内的多个消费者实例,这时候需要解释分区的分配机制,每个分区只能被一个消费者实例消费,从而保证顺序。如果有多个消费者实例,每个实例处理不同的分区,而每个分区内的消息是顺序的。
7、项目中哪些结构或模块用到了高并发,能体现出高并发的理念?
- 项目中消息模块用到了,具体体现在群发消息,主要通过线程池实现
-
接着可能追问到线程池的参数,拒绝策略,消息队列,以及线程数的配置
-
在我们的项目中,红包模块,特别是**用户领取红包(抢红包)**的环节,是典型的高并发场景,也最能体现我们对高并发处理的设计理念。
-
我们项目中红包模块的领取环节通过缓存预热(Redis)、原子操作(Lua)、异步处理(消息队列/Redis List + 后台任务)和流量控制(限流)等多种手段,有效地解决了高并发场景下的数据一致性、性能和可用性问题。特别是 Redis+Lua 原子操作保证了抢红包逻辑的正确性,而异步化数据库操作则实现了对数据库压力的削峰填谷,提升了系统的整体吞吐能力和用户体验,这些都是高并发系统设计的核心理念体现。
8、Netty 的内存参数是怎么设置?
- 项目中没有用到内存参数设置,但是有核心线程数的配置,在长链接模块的 NettyServer 里面
bossGroup = new NioEventLoopGroup(1)
- 这是“老板”线程组,负责接收客户端的连接请求
- 参数
1
表示这个线程组只包含1个线程 - 通常只需要一个线程来接收新连接就足够了,因为建立连接的操作相对轻量
- 这个线程组会将接受的连接交给workerGroup处理
workerGroup = new NioEventLoopGroup(NettyRuntime.availableProcessors())
- 这是“工人”线程组,负责处理已被接受的连接的数据读写和业务逻辑
NettyRuntime.availableProcessors()
会返回当前JVM可用的处理器核心数- 默认情况下,Netty会创建相当于CPU核心数的线程,这样可以充分利用多核性能
- 这些线程会处理实际的I/O操作和你的业务逻辑
- 参数调整:
- 如果连接数非常多,可以适当增加bossGroup的线程数
- 如果业务逻辑非常耗时,可以增加workerGroup的线程数
- 在大多数场景下,使用默认值(特别是workerGroup使用CPU核心数)已经能提供很好的性能
9、 竞品也是用 WebSocket 和 Netty 吗,这方面有了解过吗?
-
微信(官方实现)
- 协议层:
- 早期使用 TCP 长连接(自定义协议),后期逐步支持 WebSocket(尤其是网页版和小程序)。
- 移动端可能采用多协议混合(TCP + HTTP/2 + QUIC)以适应不同网络环境。
- 网络库:
- 自研高性能框架(类似 Netty 的优化思路),可能基于 C++ 实现。
- 弱网优化显著(如动态心跳、ACK 确认、消息重排等)。
- 特点:协议高度私有化,安全性强,支持亿级并发。
- 协议层:
-
钉钉/飞书(企业级IM)
- 协议层:
- WebSocket 为主(标准化协议,便于跨平台),HTTP/2 作为补充。
- 网络库:
- 直接使用 Netty 或 Node.js 的 ws 库(轻量级实现)。
- 特点:开源方案,协议公开,适合快速搭建。
- 协议层:
-
Slack/Discord(海外产品)
- 协议层:
- WebSocket + HTTP REST 混合(WebSocket 用于实时推送,HTTP 用于历史消息拉取)。
- 部分场景使用 MQTT(轻量级,适合 IoT 或移动端)。
- 网络库:
- Netty(Java 生态)或 Go/C++ 自研框架(如 Discord 的 Tonic)。
- 特点:分布式架构(如分片处理消息),边缘节点加速。
- 协议层:
-
小众或开源 IM(如 Rocket.Chat)
- 协议层:
- 纯 WebSocket + REST API。
- 网络库:
- 直接使用 Netty 或 Node.js 的 ws 库(轻量级实现)。
- 特点:开源方案,协议公开,适合快速搭建。
- 协议层:
10、 为什么 WebSocket + Netty 是主流?
-
WebSocket 优势:
- 全双工通信,适合高频双向交互(比 HTTP 轮询高效)。
- 标准协议,跨语言/平台支持好(浏览器、移动端、后端均可兼容)。
-
Netty 优势:
- 高并发、低延迟(Reactor 模型 + 零拷贝优化)。
- 成熟的编解码、心跳、重连等扩展能力。
11、在 WebSocket 即时通信项目中,采用了 kafka 消息队列。如果生产者向 broker 发送的信息,broker 向生产者返回 ack,此时 ack 丢失了,但 broker 已经接收到这个消息,生产者会重新发送,这个时候消息会重复被读取吗?
在 Kafka 中,当生产者未收到 broker 的 ACK 时确实会重发消息,但通过以下机制可避免重复问题:
- 采用雪花算法生成全局唯一消息 ID,消费端通过数据库唯一键约束实现天然去重
- 启用 Kafka 生产者幂等性(enable.idempotence=true),减少无效重试
12、 在 WebSocket 即时通信项目中,采用了 kafka 消息队列。如果消费者消费消息,向 broker 返回的 ack 丢失了,broker 会重发消息吗?这个消息会被重复读取吗?
在 Kafka 中,如果消费者成功处理了消息但提交偏移量(ACK)失败,Broker不会重发消息,但消费者会因偏移量未更新而重复拉取同一条数据,导致消息被重复处理。
- 消费者幂等性设计。使用redis记录已被处理的消息,设置过期时间。在处理消息前利用 redis 检查这个消息是否已经被处理。(高并发下消息去重)
- 消费者端开启事务。仅读取已提交事务的消息。(在严格要求一次性时启用)
- 在消息处理完成后同步提交偏移量,失败重试。
13、消息记录如何存储?
-
a. 项目中是直接存到 MySQL 数据库中
-
b. Redis(热 Zset) + MySQL(冷)分层存储
- 优点:
- Redis内存读写性能极高,适合高频访问的热数据
- 减轻MySQL压力,提高系统整体吞吐量
- 可利用Redis丰富的数据结构优化消息存取
- 自动分层,热数据快速响应,冷数据持久化存储
- Redis支持集群,横向扩展能力强
- 缺点:
- 数据一致性维护复杂,需处理缓存与DB同步
- Redis持久化可能丢失部分数据,可靠性低于MySQL
- 需要设计合理的热冷数据迁移策略
- 这里可以展开为什么要用Zset,然后面试官可能会问Zset的底层是什么原理,Zset的常用命令啊,Zset的使用场景是什么啊等等
- 优点:
-
c. MongoDB 存储方案
- 优点:
- 文档型存储,天然适合消息这种半结构化数据
- 水平扩展能力强,适合海量数据场景
- 写入性能高,适合IM的高并发写入需求
- 灵活的schema设计,方便扩展消息字段
- 支持丰富的查询方式,包括全文检索
- 内置分片和复制集,高可用性设计
- 缺点:
- 不支持事务,复杂业务场景一致性难保证
- 内存占用较高,默认全索引占用空间大
- 相比MySQL,复杂查询性能可能较低
- 优点:
14、 聊天时,kafka 的 topic 如何设计,partition 的个数又如何呢?
a. 可以按消息类型拆分 Topic,不同消息的 Topic 可以设置不一样
b. Partition 数量设计原则:
- 并行度:Partition 数量 ≥ 消费者线程数(避免资源闲置)。
- 顺序性:需保序的消息(如单聊)应通过 Key 设计保证同一会话的消息落到同一 Partition。
- 吞吐量:单个 Partition 的写入上限约 10MB/s,需预估峰值流量。
- 目标分区数 = max(峰值写入吞吐量 / 10MB/s, 消费者线程总数, 业务要求的保序维度数量)
15、 Kafka 4 个消费者消费 3 个分区,3 个消费者消费 4 个分区,分别是什么现象?
a. 4个消费者消费3个分区:
- 由于每个分区只能被一个消费者消费,因此会有1个消费者处于空闲状态,不会消费任何消息。
- 其他3个消费者会各自消费一个分区,实现消息的并行处理。
b. 3个消费者消费4个分区:
- 分区是消息的物理存储单元,而消费者是逻辑处理单元。
- 一个分区只能被一个消费者绑定,但一个消费者可以绑定多个分区(前提是这些分区未被其他消费者占用)。
- 分配逻辑:
- Kafka 会尽量保证分区分配的均衡性。例如:
- 4 分区 ÷ 3 消费者 = 每个消费者至少 1 分区,剩余 1 分区分配给其中一个消费者。
16、Kafka 跟 RocketMQ 的区别?
a. 设计理念:
- Kafka:最初由LinkedIn开发,设计目标是高吞吐量、低延迟和分布式。Kafka强调消息的持久化和分区特性,适用于大规模数据处理和实时数据管理。
- RocketMQ:由阿里巴巴开发,设计目标是高可用、高性能和可靠性。RocketMQ强调消息的顺序性和事务性,适用于金融、电商等对消息可靠性要求较高的场景。
b. 消息模型:
- Kafka:基于发布/订阅模型,消息被发布到主题(Topic),消费者订阅主题并消费消息。
- RocketMQ:支持发布/订阅模型和队列模型,消息可以被发送到主题或队列,消费者可以订阅主题或队列中消费消息。
c. 消息顺序性:
- Kafka:保证分区的消息顺序,但不保证跨分区的消息顺序。
- RocketMQ:保证主题和队列内的消息顺序。
d. 消息可靠性:
- Kafka:通过副本机制保证消息的高可用性,但不提供消息的事务性支持。
- RocketMQ:提供消息的事务性支持,确保消息的可靠传输。
e. 性能:
- Kafka:在高吞吐量场景下表现优异,适合大规模数据处理。
- RocketMQ:在高并发、低延迟场景下表现优异,适合金融、电商等对消息实时性要求较高的场景。
17、 用户发红包时需要扣减用户金额,是否要先加锁和锁后更新?
- 在高并发场景下不加锁去更新余额(如同一用户在不同设备同时发送多个红包),可能导致数据不一致:
- 多个不同请求可能同时读取到相同的余额数据
- 各自计算新余额并更新,导致只有最后一次更新被保留
- 最终用户实际扣减金额小于应扣减总额
- 因此在这种情况下应该先加锁后更新用户余额。
- 可以使用数据库悲观锁、Redis实现分布式锁等,在更新前先获取锁
18、 未加锁的话,如果检查用户余额时余额够用,更新的时候不够用怎么办?
- 可能的后果:
- 数据不一致:最终余额不正确,可能造成资金损失
- 余额透支:如果没有额外检查,可能导致负余额
- 解决方案:
- 条件更新:SQL中添加条件,确保更新时余额仍为预期值
- 代码块
int rows = userBalanceMapper.updateBalanceWithCondition(userId, newBalance, originalBalance); if (rows != 1) { throw new ServiceException("余额已变更,请重试"); }
- 悲观锁
19、 如何保证红包表的修改和用户余额的修改是同步成功或失败的
a. @transactional事务注解
20、 Redis中已经修改的话,如何恢复
a. 补偿事务(手动回滚 Redis):在捕获到导致数据库事务回滚的异常时,显式地去删除之前在 Redis 中设置的 key。
b. 将 Redis 操作移到事务成功提交后执行:利用 Spring 的事务同步机制,注册一个回调,只在数据库事务成功提交后才执行 Redis 操作。
c. 采用分布式事务方案(重量级):使用 Seata、Atomikos 或其他分布式事务框架来协调数据库和 Redis 的操作,让它们要么都成功,要么都回滚。
推荐方案:对于大多数场景,方案 b(将 Redis 操作移到事务成功提交后执行)是一个比较好的权衡
21、 为什么考虑使用redis存储红包剩余个数?
a. 使用 Redis 存储红包剩余个数,主要是为了利用其内存存储的高性能和原子操作来应对抢红包场景下的高并发需求,同时减轻核心数据库的压力,以及Redis的TTL机制非常适合红包的有效期管理。
22、 Redis缓存挂掉怎么办?用户还能抢红包吗
a. 不能,但是可以采取以下应对策略:
i. 首先要保证 Redis 自身的高可用性,比如使用 Redis Sentinel(哨兵模式)或 Redis Cluster(集群模式)来提供自动故障转移能力。
ii. 对 Redis 的状态进行实时监控,并在出现连接异常或性能问题时及时告警,以便运维快速介入。
iii. 如果 Redis 挂了,直接返回系统繁忙或活动暂时不可用,优先保证核心系统的稳定,待 Redis 恢复后再提供服务。
iv. 设计降级方案:当检测到 Redis 异常时,尝试将压力转移到数据库层面,例如使用数据库的悲观锁(SELECT … FOR UPDATE)来锁定红包记录并更新数量。但是对于抢红包这种高并发场景,直接依赖数据库锁通常不是最佳选择,除非并发量不高或者作为万不得已的临时措施。
v. 在调用 Redis 的客户端处可以加入熔断器(如 Hystrix、Resilience4j)。当 Redis 调用连续失败达到阈值时,熔断器打开,后续请求直接快速失败(fail-fast)或执行预设的降级逻辑(如返回“暂时无法抢红包”的特定响应),避免请求堆积和资源消耗。
23、 用 WebSocket 和别人聊天,聊天信息是如何存储的,有没有加密或者其他的东西?
这个问题在项目中是通过Kafka消费完直接存储到MySQL,直接并没有做其他操作。但是可以说进阶思路,比如数据加密,可以说在项目中加入端对端加密,用户登录之后给用户颁发一对公私钥,私钥用户自己保存,发送消息的时候通过私钥加密,接收者接收消息的时候去获取发送者的公钥进行解密。
24、 用户量巨大,如何分库分表?
这个问题我们项目的id都是用雪花id生成的,能够保证id的唯一性,可以根据id进行哈希,然后根据哈希值分表,可能会问雪花id是什么。
25、 雪花算法(全局唯一ID)
a. 命名由来:世界上没有两片相同的雪花
b. 全局唯一ID所需具备的条件:
- 全局唯一
- 单调递增
- 含时间戳
- 高可用,低延迟
c. 主流方案: - UUID
- 缺点:无序,降低索引性能
d. 雪花算法原理
- 时间范围:2^41 / (365 * 24 * 60 * 60 * 1000L) = 69.73 年
- 工作进程数量:2^10 = 1024
- 序列:2^12 * 1000L = 409.6万
- ID:时间范围左移22位,工作进程左移12位,序列号位不用位移,因为它本来就在最低位最后转成10进制就可以了
e. 实现
- 引入 hutool 依赖
<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.7.17</version> </dependency>
- 编写代码
// 设置 workerId, datacenterId Snowflake snowflake = IdUtil.getSnowflake(1, 1);
26、 用户密码丢了可以修改密码?
IM 项目里面确实没有修改密码的接口,但是我们用验证码登录,可以通过验证码来重置密码。
27、项目中为什么要用一致性哈希?
在我们的 IM 系统中,Real-TimeCommunicationService(即Netty长连接服务)是集群部署的,这就带来一个核心问题:当一个用户登录时,我们必须将他稳定地路由到集群中的某一个Netty实例上,并且在他后续的在线时间内,所有发给他的消息都要准确地找到这个实例。一致性哈希就是我们用来解决这个分布式路由问题的关键技术。
1. 传统哈希路由的痛点
如果使用简单的哈希算法,比如 userId.hashCode() % N(N是服务器数量),会有一个致命缺陷:服务器数量N发生变化时,会导致大规模的路由失效。
- 场景:假设有3台Netty服务器(N=3),用户A(hash=100)被路由到 100 % 3 = 1 号服务器。
- 扩容:为了应对高峰流量,我们增加了一台服务器,现在 N=4。此时,用户A的路由变成了 100 % 4 = 0 号服务器。
- 后果:这意味着几乎所有用户的路由映射都会改变,导致大规模的连接断开和重连,对系统造成巨大冲击,这在生产环境中是不可接受的。
2. 一致性哈希的优势与原理
一致性哈希通过一个巧妙的设计——哈希环(Hash Ring),完美地解决了这个问题。
- 构建哈希环:我们可以想象一个由 2^32 个点组成的闭环。首先,我们将每个Netty服务器的IP地址或标识进行哈希,将它们散列到这个环上。
- 对象路由:当一个用户登录时,我们对他的 userId 进行同样的哈希,也得到环上的一个点。然后,从这个点开始顺时针寻找,遇到的第一个服务器节点,就是该用户应该连接的服务器。
- 动态扩缩容:
- 增加节点:当在环上增加一个新节点D时,只有落在C和D之间(逆时针)的用户需要重新路由到D,其他所有用户的路由保持不变。
- 移除节点:当节点B宕机时,原来路由到B的用户会自动“顺延”到它的下一个节点C,同样只影响一小部分用户。
2.1 架构示意图
上图清晰地展示了,增加一个新节点D后,只有原本应路由到B、但现在离D更近的User 1需要变更路由,而User 2和User 3的路由完全不受影响。
3. 实现细节与流程
- 服务发现:AuthenticationService 启动时,会通过 Nacos 订阅 Real-TimeCommunicationService 的服务列表,并监听其变化。
- 构建哈希环:AuthenticationService 在内存中利用这个服务列表构建一个一致性哈希环。
- 用户登录路由:
- 用户登录请求到达 AuthenticationService。
- AuthenticationService 使用 userId 在一致性哈希环上计算出目标Netty服务器的地址。
- 将这个映射关系(userId -> netty_server_address)存入 Redis,并设置一个合理的过期时间(例如,比心跳周期稍长)。
- 返回Netty服务器地址给客户端,客户端据此地址建立WebSocket长连接。
- 消息投递:MessagingService 在需要投递消息时,会先从 Redis 中查找接收者 userId 对应的 netty_server_address,然后通过HTTP请求将消息推送到该特定实例。
4. 技术难点与优化:虚拟节点
标准的一致性哈希算法可能会导致数据倾斜问题,即某些服务器节点因为在环上的位置“不好”,承载了远超其他节点的负载。
- 解决方案:我们引入了虚拟节点(Virtual Nodes)机制。
- 原理:我们不直接将物理节点(如 192.168.1.100)哈希到环上,而是为每个物理节点创建多个虚拟节点(如 192.168.1.100#1,192.168.1.100#2,…)。然后将这些大量的虚拟节点散列到环上。
- 效果:这样一来,环上的节点分布变得非常均匀,每个物理节点实际负责的范围是它所有虚拟节点负责范围的总和,从而使得负载在物理节点之间也趋于均衡。虚拟节点的数量越多,负载就越均衡。
好的,没问题。作为一名经验丰富的Java后端架构师,我将基于你现在的答案框架,为其注入更丰富的技术细节、实现考量和架构洞察,帮助你在面试中展现出超越普通应届生的深度和广度。
28、 数据库三范式,用到了哪个范式?有没有哪张表因为实际查询情况只满足第二范式的?
首先,回顾一下数据库三范式:
- 第一范式(1NF):属性不可再分。要求数据库表中的每一列都是不可分割的基本数据项,同一列中不能有多个值,也不能存在重复的属性。如果实体中的某个属性有多个值时,必须拆分为不同的属性。
- 第二范式(2NF):在满足1NF的基础上,非主键列必须完全依赖于主键(针对联合主键)。也就是说,消除了非主属性对主属性的部分函数依赖。如果一个表的主键是单列,那么它只要满足1NF,就自动满足2NF。
- 第三范式(3NF):在满足2NF的基础上,任何非主属性不依赖于其它非主属性。也就是说,消除了非主属性对主属性的传递函数依赖。
项目数据库设计整体上遵循了第三范式,确保了数据的低冗余和一致性。例如,用户表、好友关系表、会话表等都严格满足3NF。但是,在message表的设计中,考虑到实际查询需求,我们做了一些权衡。message表中包含了session_type字段。session_type本身是由session_id决定的(session_id能在session表中查到其类型),因此在message表中存在message_id → session_id → session_type的传递依赖。这使得message表只满足第二范式,而不满足第三范式。
这样做的主要目的是为了优化消息拉取性能。当用户拉取消息列表时,往往需要根据消息是单聊还是群聊进行不同的处理或展示。如果在message表中不冗余session_type,就需要对每一条消息或每一批消息都去关联查询session表,在高并发的IM场景下,这会带来显著的性能开销。通过冗余session_type,我们可以直接从message表中获取所需信息,避免了大量JOIN操作,提升了查询效率。虽然这引入了数据冗余,但在会话类型基本不变的前提下,更新异常的风险是可控的,收益大于成本。