个人面经分享
个人面经分享
我将在这里分享我的面试经历并附加Gemini AI的相关回答,这是我的简历
欧斯特智能(上海)
2025.09.01
学校那边可以搞定吗?
有使用过三级缓存吗?
缓存一致性怎么保证?
如果数据库不支持Binlog怎么保证一致性?
你提到了延时双删,它有什么问题?
JDK21有什么新特性?
虚拟线程有了解过吗?
IO密集型的线程池怎么设计(虚拟线程)?
MQ消息怎么避免重复消费和未消费?
在使用Redis和MQ之后怎么保证消息不丢失(分级分用户入库+WAL)?
Springboot3相较2有哪些新的注解?
了解过grpc吗?
了解过DDD吗?
了解过向量数据库吗?
1. 有使用过三级缓存吗?
在实际的应用开发中,"三级缓存" 通常指的是一个多层次的缓存架构,旨在最大化缓存命中率,降低对后端数据源的压力。虽然没有一个严格的、放之四海而皆准的“三级缓存”定义,但一个经典的实现通常包含以下几个层次:
- 一级缓存(本地缓存/堆内存缓存):
- 实现: 通常使用
ConcurrentHashMap
、Guava Cache
或Caffeine
等库在应用内部实现。- 优点: 速度最快,没有网络开销,直接从内存读取。
- 缺点: 容量有限,受限于单机内存;多实例部署时,缓存数据不共享,存在一致性问题。
- 应用场景: 缓存更新不频繁但读取极其频繁的热点数据。
- 二级缓存(分布式缓存):
- 实现: 使用Redis、Memcached等独立的分布式缓存系统。
- 优点: 独立部署,容量大,所有应用实例共享,解决了本地缓存的数据共享问题。
- 缺点: 存在网络I/O开销,速度慢于本地缓存。
- 应用场景: 系统中的核心热点数据,读多写少的场景。
- 三级缓存(兜底缓存/近线缓存):
- 实现: 这一层的实现比较多样化,可以是:
- 分布式文件系统/对象存储: 如NFS、HDFS、S3等,用于存储非结构化或半结构化的数据。
- NoSQL数据库: 如Elasticsearch、HBase等,用于特定场景的查询加速。
- 更持久化的二级缓存: 例如Redis的AOF或RDB持久化文件,在缓存服务重启时可以快速恢复一部分热数据。
- 优点: 持久化,容量更大,作为数据库前的最后一道防线,防止大量请求直接穿透到数据库。
- 缺点: 速度最慢,通常用于对时效性要求不那么高的数据。
总结: 是的,在构建复杂的高性能系统时,这种分层缓存的思想被广泛应用。例如,一个典型的电商系统在查询商品详情时,会先查Caffeine,未命中则查Redis,再未命中则查询Elasticsearch或最终的数据库,这便构成了一个事实上的多级缓存体系
2. 缓存一致性怎么保证?
保证缓存与数据库之间的数据一致性是缓存应用中的核心难题。常见的保证方案有以下几种,需要根据业务场景对一致性要求的级别来选择:
- Cache-Aside Pattern (旁路缓存模式): 这是最常用的一种模式。
- 读: 先读缓存,缓存命中则直接返回;缓存未命中,则读数据库,然后将数据写入缓存,再返回。
- 写: 先更新数据库,再删除缓存。
- 为什么是删除缓存而不是更新缓存?
- 懒加载: 只有在下次读取时才加载,避免了无效的写操作。如果更新了缓存但该数据后续一直没被访问,就浪费了资源。
- 并发问题: "先更新数据库,再更新缓存" 在并发场景下,可能会导致缓存和数据库数据不一致(脏数据)。例如,线程A更新数据库,线程B更新数据库,线程B先更新了缓存,线程A后更新了缓存,导致缓存中是A的数据(旧数据)。而删除操作是幂等的,多次删除结果一致。
- Read-Through/Write-Through/Write-Behind (读穿透/写穿透/回写模式): 这类模式通常由一些缓存框架或组件提供支持,将缓存和数据库的操作封装起来,对应用层透明。
- Read-Through: 应用读缓存,如果缓存未命中,由缓存服务自己负责从数据库加载数据并返回。
- Write-Through: 应用写缓存,由缓存服务负责将数据写入数据库,两者都成功后才返回。保证了强一致性,但牺牲了性能。
- Write-Behind (Write-Back): 应用只管写缓存,由缓存服务异步地、批量地将数据写入数据库。性能最好,但存在数据丢失的风险(例如缓存服务宕机)。
- 订阅Binlog,异步更新缓存:
- 思路: 通过Canal、Debezium等工具订阅MySQL的Binlog日志,获取数据库的实时变更数据。
- 流程: 当监听到数据库发生变更(INSERT, UPDATE, DELETE)时,由一个专门的服务去解析Binlog,然后去更新或删除对应的缓存。
- 优点: 业务代码与缓存维护逻辑解耦,对业务代码无侵入;可以实现最终一致性,延迟较低。
- 缺点: 架构复杂度高,需要引入额外的中间件和维护成本。
3. 如果数据库不支持Binlog怎么保证一致性?
如果底层数据库(例如一些NoSQL数据库或老旧的关系型数据库)不提供类似MySQL Binlog的事务日志订阅功能,一致性的保证会变得更加困难,通常可以采用以下策略:
- 消息队列 (Message Queue):
- 思路: 在更新数据库的业务逻辑中,同时发送一条消息到MQ。
- 流程: 一个独立的消费者服务订阅该消息,当收到消息后,去执行缓存的删除或更新操作。
- 挑战: 这引入了分布式事务的问题。如何保证“更新数据库”和“发送MQ消息”这两个操作的原子性?
- 本地消息表/发件箱模式 (Outbox Pattern): 将业务操作和要发送的消息放在同一个本地事务中完成。一个后台任务定时轮询这张消息表,将消息发送到MQ,并标记为已发送。这是保证最终一致性的可靠方案。
- 事务消息: 依赖MQ提供的事务消息功能(如RocketMQ),但会增加业务代码的复杂度。
- 定时任务轮询/ETL:
- 思路: 通过一个定时任务,周期性地去扫描数据库中的数据变更(例如通过一个
last_updated_time
时间戳字段)。- 流程: 定时任务拉取变更的数据,然后更新缓存。
- 缺点: 一致性的延迟较高,取决于轮询的频率;对数据库有一定的查询压力。适用于对一致性要求不高的场景。
- 应用层双写:
- 在应用代码中,同时写入数据库和缓存。这种方式问题最多,并发控制复杂,很容易出现不一致,通常不推荐。
4. 你提到了延时双删,它有什么问题?
"延时双删" 是对Cache-Aside模式中“先更新数据库,再删除缓存”方案的一种优化,旨在解决一个特定的并发问题。
问题场景:
- 线程A请求更新数据。
- 线程A执行
UPDATE database
。- 线程A执行
DELETE cache
。在删除前,线程B发起了一次读请求。- 线程B
SELECT
缓存,发现缓存不存在(或已被一个更早的写操作删除)。- 线程B
SELECT database
,读取到了旧数据(因为此时线程A的事务可能还未提交)。- 线程B将旧数据写入了缓存。
- 线程A完成了缓存删除操作。
此时,缓存中的数据是旧的,而数据库中的是新的,导致了不一致,且这个脏数据会一直存在,直到缓存过期或下次被更新。
延时双删策略:
- 先淘汰缓存。
- 再写数据库。
- 休眠一段时间(例如几百毫秒)。
- 再次淘汰缓存。
延时双删的问题:
- 休眠时间的确定是难题: 这个“延时”时间到底设置多久?
- 太短,可能无法覆盖掉并发读操作将旧数据写入缓存的时间窗口,问题依旧存在。
- 太长,会影响系统吞吐量,并且在这段时间内,数据的不一致性是确定存在的。这个时间需要根据业务的读写耗时来估算,但很难精确。
- 吞吐量下降: 引入了
sleep
操作,使得写操作的耗时显著增加,降低了系统的整体吞吐能力。- 无法完全避免问题: 在极端情况下,例如主从数据库延迟很高时,延时双删依然可能失败。如果休眠时间小于主从同步的延迟,那么在第二次删除缓存后,读请求依然可能从“从库”读取到旧数据并写回缓存。
结论: 延时双删是一种“妥协”的方案,实现复杂且效果不完美。在绝大多数场景下,“先更新数据库,再删除缓存”,并为缓存设置合理的过期时间,已经能够满足需求。如果对一致性要求非常高,那么应该优先考虑订阅Binlog的异步更新方案。
5. JDK21有什么新特性?
JDK 21是一个长期支持(LTS)版本,带来了许多重要的新特性,其中最引人注目的是:
- 虚拟线程 (Virtual Threads) - 正式版: 这是Project Loom的核心成果。虚拟线程是由JDK而非操作系统内核管理的轻量级线程。它们极大地简化了编写、维护和观察高吞吐量并发应用的过程。
- 结构化并发 (Structured Concurrency) - 预览阶段: 简化并发编程,将不同线程中运行的相关任务组视为一个工作单元,简化错误处理和取消操作,提高代码的可靠性和可观察性。
- 作用域值 (Scoped Values) - 预览阶段: 提供了一种在线程内以及线程间共享不可变数据的新方式,是
ThreadLocal
的更优替代方案,尤其是在使用大量虚拟线程时。- 字符串模板 (String Templates) - 预览阶段: 简化字符串拼接和格式化,通过模板和嵌入式表达式生成字符串,比传统的
+
拼接或String.format()
更易读、更安全。- 未命名类和实例主方法 (Unnamed Classes and Instance Main Methods) - 预览阶段: 旨在降低Java学习的门槛,让初学者可以编写更简洁的“Hello, World!”程序。
- 序列化集合 (Sequenced Collections): 引入了新的接口
SequencedCollection
,SequencedSet
, 和SequencedMap
,为集合提供统一的、定义明确的遍历顺序,并提供了获取第一个/最后一个元素、反向遍历等标准API。- switch模式匹配 (Pattern Matching for switch) - 正式版: 扩展了
switch
语句和表达式,使其可以对对象的类型和结构进行匹配,代码更简洁、更安全。- 记录模式 (Record Patterns) - 正式版: 用于解构记录(Record)实例,可以与模式匹配(如
instanceof
和switch
)结合使用,方便地提取组件数据。6. 虚拟线程有了解过吗?
是的,有深入了解。
核心概念:
虚拟线程(Virtual Thread)是JDK实现的用户态线程,它并不直接映射到操作系统的内核线程(Platform Thread)。相反,大量的虚拟线程可以运行在少数几个内核线程上。
工作原理:
- 当一个虚拟线程执行I/O操作或其他阻塞操作时,它不会阻塞底层的内核线程。
- JVM会挂起这个虚拟线程,并将其从内核线程上卸下(unmount)。
- 内核线程(Carrier Thread)可以立即去执行另一个准备就绪的虚拟线程。
- 当之前的I/O操作完成后,被挂起的虚拟线程会重新变为可运行状态,并被调度到任意一个可用的内核线程上继续执行(mount)。
优势:
- 极高的吞吐量: 可以轻松创建数百万个虚拟线程而不会耗尽系统资源。因为虚拟线程本质上只是一个轻量级的Java对象,占用的内存很小。
- 简化并发编程: 可以用传统的回车符换行,同步阻塞式的编程模型(per-request-per-thread)来编写代码,而无需使用复杂、易错的异步/响应式编程(如
CompletableFuture
或Reactive Streams
),就能达到异步编程的性能和扩展性。代码更易读、易调试、易维护。- 兼容现有API: 大部分现有的Java同步API(如
InputStream
,OutputStream
,Socket
等)在虚拟线程中运行时,会自动触发挂起/恢复的调度,无需修改代码。适用场景:
非常适合I/O密集型或网络密集型的应用,例如:
- 微服务网关
- Web服务器
- 数据库连接代理
- 任何需要处理大量并发连接和请求,且大部分时间花在等待网络或磁盘响应的场景。
不适用场景:
不适合CPU密集型的任务。因为虚拟线程的目标是提高并发吞吐量,而不是并行计算能力。CPU密集型任务需要的是与CPU核心数相当的内核线程来并行执行,使用虚拟线程不会带来性能提升,反而可能因为额外的调度开销而略有下降。
7. IO密集型的线程池怎么设计(虚拟线程)?
使用虚拟线程来处理IO密集型任务,线程池的设计思想发生了根本性的转变。
传统线程池设计 (使用平台线程):
- 需要精心计算线程池大小,通常根据
核心数 * (1 + 等待时间/计算时间)
的公式来估算。- 线程数是一个宝贵的、有限的资源,需要池化来复用。
- 任务队列是必须的,用于缓冲无法立即处理的请求。
使用虚拟线程的设计:
不需要传统的线程池了!
正确的做法是:为每一个任务(例如,每一个进来的HTTP请求)创建一个新的虚拟线程。
// 在JDK 21中,可以这样简单地处理请求 try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 1000000; i++) { Runnable task = () -> { // 执行IO密集型任务,例如调用一个远程API // 或者查询数据库 // 这里的代码是同步阻塞式的 handleRequest(); }; executor.submit(task); } } // try-with-resources 会自动关闭 executor
为什么可以这样做?
- 创建成本极低: 创建一个虚拟线程几乎没有开销,和创建一个普通对象类似。
- 无限的并发能力: 理论上可以创建任意数量的虚拟线程,受限于内存而非操作系统线程数限制。
- 自动调度: JVM的ForkJoinPool会负责将这些虚拟线程调度到后端的少量平台线程(Carrier Threads)上去执行。当虚拟线程遇到IO阻塞时,会自动让出平台线程。
结论: 在虚拟线程的世界里,对于IO密集型任务,我们不再需要管理和池化线程。设计模式回归到了最简单的“一个请求一个线程”模型,但却获得了异步编程的性能。你只需要使用
Executors.newVirtualThreadPerTaskExecutor()
,然后放心地为每个任务提交一个新的虚拟线程即可。8. MQ消息怎么避免重复消费和未消费?
这是保证MQ消息可靠性的两个核心问题。
1. 避免未消费 (保证消息至少被消费一次 At-Least-Once)
- ACK机制: 这是最核心的机制。消费者在成功处理完消息后,向MQ发送一个确认(ACK)信号。
- 自动ACK: 消费者拉取到消息后,客户端库立即自动发送ACK。这种方式如果消费者在处理消息过程中宕机,消息会丢失。
- 手动ACK: 消费者在业务逻辑完全成功执行完毕后,手动调用API发送ACK。如果在处理过程中出现异常,则不发送ACK(或发送NACK,表示消费失败)。
- 重试机制: 当MQ没有在规定时间内收到消费者的ACK时(或收到NACK),它会认为消息没有被成功消费,并在一段时间后将该消息重新投递给其他(或同一个)消费者。
2. 避免重复消费 (保证消息最多被消费一次 At-Most-Once 或 精确一次 Exactly-Once)
由于重试机制的存在(例如,消费者成功处理了消息,但在发送ACK时网络中断或宕机),重复消费是可能发生的。解决方案的核心是:保证消费逻辑的幂等性 (Idempotence)。
幂等性意味着对同一个操作执行一次和执行多次,结果是完全相同的。实现幂等性的常见方法有:
- 数据库唯一键: 利用数据库的唯一索引或主键约束。例如,订单创建消息,可以用订单ID作为主键,重复的消息在
INSERT
时会因为主键冲突而失败。- 状态机控制: 在数据库中为业务数据设计一个状态字段。例如,订单状态有“待支付”、“已支付”、“已取消”。如果一个“支付成功”的消息来了,只有当订单状态是“待支付”时才执行更新操作,如果已经是“已支付”,则直接忽略。
UPDATE orders SET status = 'PAID' WHERE order_id = ? AND status = 'PENDING';
- 通过返回值判断影响的行数,如果为0,说明是重复消费。
- 全局唯一ID + 记录表:
- 为每条消息生成一个全局唯一的ID(可以使用业务ID,如订单号;或消息自身的Message ID)。
- 消费者端准备一个“消费记录表”。
- 在处理消息前,先查询这个ID是否在记录表中存在。
- 如果存在,说明是重复消息,直接忽略并ACK。
- 如果不存在,则将该ID插入记录表,然后处理业务逻辑。这两个操作需要放在同一个本地事务中,保证原子性。
总结: 通过 手动ACK + 重试机制 保证了消息“至少被消费一次”,再通过 消费端实现幂等性 来解决重试带来的重复消费问题,最终可以实现“精确一次消费”的业务效果。
9. 在使用Redis和MQ之后怎么保证消息不丢失(分级分用户入库+WAL)?
这个问题场景比较复杂,结合了缓存、消息队列和数据库持久化,旨在构建一个高可靠的数据处理链路。这里的“分级分用户入库+WAL”是一种具体的、高级的可靠性保证方案。我们来分解一下。
消息不丢失需要保证三个环节的可靠性:
- 生产者 -> MQ: 消息不能在发送过程中丢失。
- MQ自身: 消息在MQ中存储时不能丢失。
- MQ -> 消费者: 消息在消费过程中不能丢失。
综合解决方案:
- 生产者端可靠性:
- 使用MQ提供的事务消息或生产者确认机制 (Publisher Confirms)。
- Publisher Confirms: 发送消息后,生产者阻塞或异步等待MQ的确认回执。如果未收到确认(或收到失败确认),则进行重试。
- 结合本地消息表 (Outbox Pattern): 这是最可靠的方式。将业务操作和写“本地消息表”放在一个数据库事务里。一个独立的任务轮询这张表,将消息发送到MQ,成功后再更新表状态。
- MQ自身可靠性:
- 持久化: 将消息配置为持久化模式。队列和消息都设置为durable。这样即使MQ服务重启,消息也不会丢失。
- 集群/镜像: 部署MQ集群,使用镜像队列等机制,将消息副本存储在多个节点上,防止单点故障。
- 消费者端可靠性:
- 手动ACK: 如上一问所述,业务处理成功后才发送确认。
- 幂等性处理: 防止重试导致数据错乱。
融入 Redis 和 “分级分用户入库+WAL” 策略:
这是一个典型的高性能异步入库场景。例如,处理海量的用户行为日志或计费请求。直接写入数据库会很慢,所以引入了Redis和MQ来削峰填谷和异步处理。
- 分级: 可能指对不同优先级的消息进行分级处理。例如,VIP用户的请求走高优先级队列,普通用户走普通队列。
- 分用户: 为了避免单个用户的异常数据影响整体,以及为了后续追踪和数据隔离,可能会按用户ID进行分片或路由到不同的队列/消费者。
- 入库: 最终的目标是将数据持久化到数据库。
WAL (Write-Ahead Logging, 预写日志) 在这里的应用,是一种提升消费者端性能和可靠性的关键技术。
流程可以是这样的:
- 接收与缓冲: 消费者从MQ获取批量消息。
- 写入Redis: 将消息数据快速写入Redis,可以做一些初步的聚合或格式转换。Redis在这里起到了一个高速缓冲层的作用。
- 写入WAL: 在将数据写入数据库之前,消费者先把这个“写数据库”的操作(例如,SQL语句或数据变更记录)以日志的形式,顺序写入一个本地的持久化文件(WAL文件)。这个写文件操作非常快。
- ACK消息: 一旦WAL写入成功,消费者就可以立即向MQ发送ACK,表示这批消息已经“安全”了。即使此时消费者进程崩溃,由于操作记录在WAL中,重启后可以读取WAL来恢复,不会丢失数据。
- 异步批量入库: 一个独立的后台线程,负责读取WAL文件中的操作记录,然后以较高的批处理效率将数据批量写入最终的数据库(如MySQL, HBase等)。
- Checkpoint: 当WAL中的数据成功写入数据库后,会定期或定量地在WAL文件中做一个检查点(Checkpoint),表示这个点之前的数据已经持久化,可以安全删除了。
这种架构的优势:
- 高吞吐量: 消费者的核心逻辑只是飞快地写Redis和本地文件(WAL),然后就可以ACK了,消费速度极快。
- 高可靠性: WAL保证了即使在异步入库的过程中发生故障,数据也不会丢失。这是借鉴了数据库本身的设计思想。
- 数据库压力小: 最终对数据库的写入是异步的、批量的,大大降低了数据库的写压力。
10. Springboot3相较2有哪些新的注解?
Spring Boot 3.0 是一个重要的主版本升级,它基于 Spring Framework 6,要求 Java 17 作为最低版本,并迁移到了 Jakarta EE 9/10。注解方面的变化虽然不是铺天盖地,但有一些值得关注的新增和变化:
@GraphQlController
和相关注解:
- 随着Spring for GraphQL的正式集成,引入了一系列注解来简化GraphQL应用的开发,如
@QueryMapping
,@MutationMapping
,@SubscriptionMapping
,@SchemaMapping
等。这取代了之前需要手动配置RuntimeWiring.Builder
的方式。@HttpExchange
:
- 用于创建声明式的HTTP客户端接口,类似于Feign。你可以定义一个Java接口,并使用
@HttpExchange
(或更具体的@GetExchange
,@PostExchange
等)来注解方法,Spring会自动为你生成实现类来发起HTTP请求。- Observability (可观测性) 相关的注解和改进:
- Spring Boot 3全面拥抱了Micrometer Tracing,整合了Brave和OpenTelemetry等分布式追踪方案。虽然不全是“新注解”,但像
@Observed
(来自Micrometer)这样的注解变得更加核心和易用,可以方便地为方法添加metrics、logging和tracing。- AOT (Ahead-Of-Time) 相关的提示注解:
- 为了支持GraalVM原生镜像(Native Image)编译,引入了
@RegisterReflectionForBinding
,@Reflective
等RuntimeHints
相关的注解。开发者可以通过这些注解,向AOT编译器提供关于反射、序列化、动态代理等运行时行为的提示,确保原生镜像能够正确运行。更重要的是一些底层和依赖的变化,而非仅仅是注解:
- Jakarta EE迁移: 所有之前使用
javax.*
包名的API都变成了jakarta.*
。例如javax.servlet.http.HttpServletRequest
变为jakarta.servlet.http.HttpServletRequest
。这导致了大量依赖库需要升级。- 配置文件处理变化:
spring.factories
的服务加载机制被新的META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
文件所取代,虽然对普通用户影响不大,但对库开发者影响很大。11. 了解过grpc吗?
是的,非常了解。
gRPC (gRPC Remote Procedure Calls) 是一个由Google开发的高性能、开源、通用的RPC(远程过程调用)框架。
核心特点:
- 基于HTTP/2:
- HTTP/2提供了多路复用、头部压缩、服务端推送、二进制分帧等特性,使得gRPC在单个TCP连接上可以处理多个并发请求,大大降低了延迟,提升了网络利用率。
- 使用Protocol Buffers (Protobuf):
- gRPC默认使用Protobuf作为其接口定义语言(IDL)和消息交换格式。
- IDL: 你在一个
.proto
文件中定义服务(Service)、方法(RPC)以及消息(Message)的结构。- 序列化: Protobuf是一种高效的二进制序列化协议,序列化后的数据体积小、解析速度快,相比JSON/XML有明显的性能优势。
- 强类型与代码生成: 通过
.proto
文件,可以使用protoc
编译器生成指定语言(Java, Go, Python, C++等)的客户端存根(stub)和服务端骨架代码,实现了强类型的接口约束,避免了联调时的很多低级错误。- 支持多种通信模式:
- Unary RPC: 传统的请求-响应模式,客户端发送一个请求,服务端返回一个响应。
- Server Streaming RPC: 客户端发送一个请求,服务端返回一个数据流,可以持续发送多个消息。
- Client Streaming RPC: 客户端发送一个数据流,服务端在接收完所有消息后返回一个响应。
- Bidirectional Streaming RPC: 客户端和服务端都可以独立地、异步地向对方发送数据流,可以实现全双工通信。
优势:
- 高性能: HTTP/2 + Protobuf的组合带来了极低的延迟和高吞吐量。
- 跨语言: 支持主流的编程语言,非常适合构建多语言环境下的微服务系统。
- 服务定义清晰:
.proto
文件即是服务契约,是唯一的、权威的接口文档。- 功能强大: 支持流式通信、认证、负载均衡、超时、取消等高级功能。
适用场景:
- 微服务间的内部通信: 这是gRPC最主要的应用场景,尤其是在对性能要求高的系统中。
- 移动客户端与后端通信: Protobuf的轻量和HTTP/2的高效非常适合移动网络环境。
- 需要流式数据传输的场景: 如物联网数据上报、实时消息推送等。
与RESTful API的对比:
特性 gRPC RESTful API 协议 HTTP/2 通常是 HTTP/1.1 (也可用 HTTP/2) 数据格式 Protobuf (二进制) JSON (文本) 契约定义 .proto 文件 (强类型) OpenAPI/Swagger (可选, 弱约束) 性能 高 相对较低 浏览器支持 需要代理 (gRPC-Web) 原生支持 易用性 学习曲线稍陡 简单直观 12. 了解过DDD吗?
是的,DDD(Domain-Driven Design,领域驱动设计)是一种软件开发方法论,旨在通过将实现连接到不断发展的模型来应对复杂的需求。
核心思想:
DDD的核心思想是,在软件开发过程中,我们应该将主要的焦点放在业务领域(Domain)本身,而不是技术实现。我们应该与领域专家(Domain Expert)紧密合作,建立一个能够准确反映业务领域知识和流程的通用语言(Ubiquitous Language)和领域模型(Domain Model),并用这个模型来指导整个软件的设计和实现。
关键概念:
战略设计 (Strategic Design): 关注于宏观层面,如何划分和组织复杂的业务领域。
- 领域 (Domain): 业务问题发生的范围。
- 子域 (Subdomain): 将复杂的领域划分为更小的、可管理的部分。
- 核心域 (Core Domain): 业务最核心、最具竞争力的部分,需要投入最好的资源。
- 支撑子域 (Supporting Subdomain): 非核心,但为核心域提供支撑的业务部分。
- 通用子域 (Generic Subdomain): 非核心,且业界有成熟解决方案的部分(如权限、认证),通常可以直接使用或购买。
- 通用语言 (Ubiquitous Language): 在项目团队(开发、产品、测试、领域专家)中形成的一套共享的、无歧义的术语、概念和词汇表,用于描述领域模型。代码中的类名、方法名、变量名都应该直接使用通用语言。
- 限界上下文 (Bounded Context): 一个逻辑边界,通用语言和领域模型在这个边界内具有明确的、唯一的含义。每个限界上下文都对应一个微服务或一个独立的模块。
- 上下文映射图 (Context Map): 描述不同限界上下文之间的关系,如合作(Partnership)、共享内核(Shared Kernel)、客户-供应商(Customer-Supplier)、防腐层(Anti-Corruption Layer)等。
战术设计 (Tactical Design): 关注于微观层面,如何在限界上下文内部设计出高质量的领域模型。
- 实体 (Entity): 具有唯一标识(ID)并且其生命周期和状态是连续变化的领域对象。例如,一个“用户”或一张“订单”。
- 值对象 (Value Object): 没有唯一标识,通过其属性值来描述事物,通常是不可变的(Immutable)。例如,“地址”(由省、市、区组成)或“金额”(由数值和币种组成)。
- 聚合 (Aggregate): 一组相关领域对象的集群,作为数据修改和持久化的基本单元。
- 聚合根 (Aggregate Root): 是聚合的入口,是聚合中唯一一个可以被外部直接引用的实体。所有对聚合内部的修改都必须通过聚合根来完成,以保证业务规则的一致性。
- 工厂 (Factory): 负责封装创建复杂对象(特别是聚合)的逻辑。
- 仓库 (Repository): 负责封装对象的持久化和查询逻辑,提供一个类似集合的接口来访问聚合根,隔离领域层和基础设施层。
- 领域服务 (Domain Service): 当某个操作不适合放在任何一个实体或值对象中时(例如,它需要协调多个聚合),可以将其封装在领域服务中。
- 领域事件 (Domain Event): 用于表示在领域中发生过的重要事情。例如,“订单已支付”、“用户已注册”。领域事件是实现最终一致性和系统解耦的关键。
DDD的价值:
- 帮助团队应对复杂的业务场景。
- 建立清晰、一致的业务模型和代码实现。
- 促进开发人员与业务专家的有效沟通。
- 构建出灵活、可维护、易于演进的软件系统,特别适合微服务架构。
13. 了解过向量数据库吗?
是的,向量数据库(Vector Database)是近年来随着AI和机器学习的兴起而变得非常流行的一种新型数据库。
核心功能:
专门用于存储、管理和查询高维向量数据的数据库。
背景:
在AI领域,文本、图片、音频、视频等非结构化数据,通常可以通过嵌入(Embedding)模型(如Word2Vec, BERT, CLIP等)转换成高维的数学向量(Vector)。这些向量能够捕捉到原始数据的语义信息。
- 语义相似性: 在向量空间中,两个向量的距离(如欧氏距离、余弦相似度)越近,代表它们对应的原始数据在语义上就越相似。
传统数据库的困境:
传统的关系型数据库或NoSQL数据库不擅长处理这种高维向量的相似性搜索。如果要在上百万甚至上亿的向量中找到与给定向量最相似的Top-K个向量,使用传统数据库进行暴力计算,其复杂度和耗时是无法接受的。
向量数据库的解决方案:
向量数据库的核心技术是 近似最近邻搜索(Approximate Nearest Neighbor, ANN) 算法。
- ANN算法: 它不追求100%精确地找到最近邻,而是在可接受的精度损失下,以极高的效率和速度找到与查询向量高度相似的结果。
- 索引结构: ANN算法通过构建特殊的索引结构(如HNSW, IVF, LSH, Faiss等)来实现快速检索。这些索引将高维向量组织在特定的数据结构中,使得搜索时可以快速剪枝,避免全局扫描。
主要功能和特点:
- 高效的相似性搜索 (ANN Search): 这是最核心的功能。
- 海量向量存储: 能够管理数十亿甚至更多的向量数据。
- 支持元数据 (Metadata) 存储和过滤: 可以在存储向量的同时,为其附加一些标量字段(如ID、类别、时间戳等)。查询时可以先通过元数据进行过滤(例如,只在“类别A”的商品中搜索),然后再进行向量相似性搜索。
- 易用的API: 提供友好的SDK和API,方便开发者进行数据插入和查询。
- 高可用和可扩展: 通常被设计为分布式系统,支持水平扩展和高可用。
应用场景:
- 以图搜图/视频搜索: 输入一张图片,找到相似的图片。
- 语义文本搜索/智能问答: 输入一个问题,在海量文档中找到语义最相关的答案。这是大型语言模型(LLM)应用中RAG(Retrieval-Augmented Generation)架构的核心组件。
- 推荐系统: 根据用户的行为向量,推荐相似的商品或内容。
- 异常检测: 在向量空间中,离群的向量点可能代表异常数据。
- 药物发现/分子匹配: 在化学领域,分子结构可以被表示为向量,用于寻找相似的分子。
主流的向量数据库产品:
- 开源: Milvus, Weaviate, Qdrant, Chroma
- 云服务/商业版: Pinecone, Zilliz Cloud (Milvus的商业版), Redis (通过RediSearch模块), Elasticsearch (通过k-NN插件) 等也提供了向量搜索能力。
欧斯特智能(二面)
2025.09.03
- 介绍一下你的网关项目
- 你的网关项目相比SpringCloud Gateway有什么优势?
- 介绍一下grpc
- 介绍一下你定义的后置处理器是怎么工作的
- 写一个批量插入的代码示例
- Stream和for循环有什么区别?
- Springboot2和3分别怎么实现自动装配的?
- 说一下ConditionOn注解
- 怎么实现一个bean在另一个bean之后加载呢?
- yaml配置文件的两种读取方法
- 循环依赖怎么解决?
- 说一下AOP和它的几个概念
- 说一下Rocketmq的四种通知方式(广播路由什么的)
- 一些前端问题
1. 介绍一下你的网关项目
我的网关项目是一个基于 Netty 和 Spring Boot 构建的微服务网关,其核心设计思想是 责任链模式 和 分布式服务治理 。
核心功能模块包括:
- 网关核心模块 (
api-gateway-core
): 作为请求处理的核心入口,基于 Netty 实现,通过责任链模式 对请求进行处理。处理链包括:
- 鉴权处理器 (
AuthorizationHandler
): 基于 JWT 对请求进行鉴权。- 自定义处理器 (
PreExecutorHandler
,PostExecutorHandler
): 支持灵活的自定义熔断、限流、日志等操作。- 请求调用处理器 (
ExecutorHandler
): 解析请求类型并发送请求(支持 HTTP 和 Dubbo RPC)。- 结果包装处理器 (
ResultHandler
): 对返回结果进行统一的包装。- 网关中心模块 (
api-gateway-center
):
- 配置管理: 动态加载并刷新 Redis 和 Nginx 配置,支持负载均衡策略更新。
- 服务注册与发现: 服务提供者通过 SDK 注册服务信息,Redis 存储服务元数据,并定时同步数据库信息到 Redis。
- 心跳与续约: 服务启动时主动注册,后续定期发送心跳续约。
- SDK 模块:
- 服务注册 SDK (
server-find-sdk
): 自动加载配置,扫描自定义注解并上传接口信息。- 请求调用 SDK (
server-send-sdk
): 提供便捷的请求调用方式,支持失败重试。2. 你的网关项目相比 SpringCloud Gateway 有什么优势?
我的网关项目在设计上借鉴了 Spring Cloud Gateway 的思想,但在某些方面提供了更轻量级和更具定制化的解决方案:
- 高性能网络核心:直接使用 Netty 作为网络框架,提供了更底层的控制能力和更高的性能潜力。
- 高度可扩展的责任链模式:通过自定义的前置和后置处理器,可以非常灵活地实现鉴权、限流、熔断、日志等功能,并且可以自由排序。
- 双协议支持:原生支持 HTTP 和 Dubbo 两种协议的泛化调用,可以更好地适应异构系统。
- 动态配置和服务发现:通过网关中心和 Redis 实现动态配置和服务发现,可以实时更新路由规则和负载均衡策略。
- 轻量级:相比于 Spring Cloud Gateway,我的项目更加轻量级,核心模块的依赖更少,更易于理解和维护。
3. 介绍一下 gRPC
gRPC (Google Remote Procedure Call) 是一个由 Google 开发的高性能、开源的通用 RPC (Remote Procedure Call) 框架。它可以在任何环境下运行,并且可以用任何语言编写。
主要特点:
- 基于 HTTP/2: gRPC 使用 HTTP/2 作为其传输协议,这带来了多路复用、头部压缩、服务器推送等特性,从而实现了更低的延迟和更高的吞吐量。
- 协议缓冲区 (Protocol Buffers): gRPC 默认使用 Protocol Buffers 作为其接口定义语言 (IDL) 和消息交换格式。Protocol Buffers 是一种轻量级、高效的二进制序列化格式,比 JSON 或 XML 更快、更小。
- 多语言支持: gRPC 支持多种编程语言,包括 Java, C++, Python, Go, Ruby, C#, Node.js, Android Java, Objective-C 和 PHP。
- 流式处理: gRPC 支持四种类型的流式处理:
- 一元 RPC: 客户端发送一个请求,服务器返回一个响应。
- 服务器流式 RPC: 客户端发送一个请求,服务器返回一个流式的响应。
- 客户端流式 RPC: 客户端发送一个流式的请求,服务器返回一个响应。
- 双向流式 RPC: 客户端和服务器都可以发送一个流式的请求和响应。
4. 介绍一下你定义的后置处理器是怎么工作的
我定义的后置处理器是通过
CustomPostHandler
接口和PostExecutorHandler
类实现的。
CustomPostHandler
接口: 这是一个自定义的后置处理器接口,所有自定义的后置处理器都需要实现这个接口,并实现handle
方法和getOrder
方法。PostExecutorHandler
类: 这个类是后置处理器的执行器,它会收集所有实现了CustomPostHandler
接口的 Bean,并根据getOrder
方法返回的顺序进行排序。然后,它会依次执行这些后置处理器的handle
方法。如果某个处理器返回了一个非null
的Result
对象,那么处理链就会被终止,并且该Result
对象会被直接返回给客户端。5. 写一个批量插入的代码示例
这是一个使用 MyBatis-Plus 进行批量插入的示例,在你的项目中已经引入了 MyBatis-Plus 依赖。
@Service public class BatchInsertService { @Resource private YourMapper yourMapper; @Transactional public void batchInsert(List<YourEntity> entityList) { // 每次批量插入的数量 int batchSize = 1000; for (int i = 0; i < entityList.size(); i += batchSize) { int toIndex = Math.min(i + batchSize, entityList.size()); yourMapper.insertBatchSomeColumn(entityList.subList(i, toIndex)); } } } // 在 YourMapper.java (或者相应的 XML 文件) 中定义 insertBatchSomeColumn 方法 public interface YourMapper extends BaseMapper<YourEntity> { Integer insertBatchSomeColumn(List<YourEntity> entityList); } // 在 YourMapper.xml 文件中 <insert id="insertBatchSomeColumn"> INSERT INTO your_table (column1, column2) VALUES <foreach collection="list" item="item" separator=","> (#{item.field1}, #{item.field2}) </foreach> </insert>
6. Stream 和 for 循环有什么区别?
Stream 和 for 循环都是用来遍历集合的,但它们之间存在一些重要的区别:
- 声明性 vs. 命令性: Stream 是声明性的,你只需要描述你想要做什么,而不需要关心如何做。而 for 循环是命令性的,你需要明确地指定每一步的操作。
- 内部迭代 vs. 外部迭代: Stream 使用内部迭代,它会自动地为你处理迭代的过程。而 for 循环使用外部迭代,你需要手动地控制迭代的过程。
- 链式操作: Stream 支持链式操作,你可以将多个操作连接在一起,形成一个流水线。这使得代码更加简洁、易读。
- 并行处理: Stream 可以很容易地进行并行处理,只需要调用
parallel()
方法即可。这可以充分利用多核处理器的优势,提高处理效率。- 无状态: Stream 的操作通常是无状态的,它们不会修改原始的集合。这使得代码更加安全、易于调试。
7. Springboot 2 和 3 分别怎么实现自动装配的?
- Spring Boot 2:
- 主要通过
META-INF/spring.factories
文件来实现自动装配。- 在这个文件中,通过
org.springframework.boot.autoconfigure.EnableAutoConfiguration
key 来指定需要自动装配的配置类。- Spring Boot 在启动时会扫描所有
spring.factories
文件,并加载其中指定的配置类。- Spring Boot 3:
- 从 Spring Boot 2.7 开始,推荐使用新的自动装配方式,并在 Spring Boot 3.0 中成为默认方式。
- 新的方式是,在
META-INF/spring/
目录下创建一个名为org.springframework.boot.autoconfigure.AutoConfiguration.imports
的文件。- 在这个文件中,每一行列出一个自动配置类的全限定名。
- 这种方式比
spring.factories
更高效,因为 Spring Boot 可以直接读取这个文件,而不需要解析spring.factories
的格式。8. 说一下 ConditionOn 注解
@ConditionalOn
是 Spring Boot 提供的一系列条件注解,它们可以根据不同的条件来决定是否创建一个 Bean。这使得我们可以更加灵活地配置我们的应用程序。一些常用的
@ConditionalOn
注解包括:
@ConditionalOnBean
: 当容器中存在指定的 Bean 时,才会创建当前的 Bean。@ConditionalOnClass
: 当类路径下存在指定的类时,才会创建当前的 Bean。@ConditionalOnExpression
: 当指定的 SpEL 表达式返回true
时,才会创建当前的 Bean。@ConditionalOnMissingBean
: 当容器中不存在指定的 Bean 时,才会创建当前的 Bean。@ConditionalOnProperty
: 当指定的属性存在并且具有指定的值时,才会创建当前的 Bean。@ConditionalOnResource
: 当类路径下存在指定的资源时,才会创建当前的 Bean。9. 怎么实现一个 bean 在另一个 bean 之后加载呢?
在 Spring 中,我们可以使用
@DependsOn
注解来指定一个 Bean 的加载顺序。Java
@Component public class BeanA { // ... } @Component @DependsOn("beanA") public class BeanB { // ... }
在这个例子中,
BeanB
会在BeanA
加载之后再加载。10. yaml 配置文件的两种读取方法
在 Spring Boot 中,我们可以使用
@ConfigurationProperties
和@Value
注解来读取application.yml
配置文件中的属性。
@ConfigurationProperties
:
- 这个注解可以将配置文件中的一组属性映射到一个 Java 对象中。
- 它支持类型安全的配置,并且可以很好地与 JSR-303 验证注解配合使用。
- 在你的项目中,
GlobalConfiguration.java
就是一个很好的例子。@Value
:
- 这个注解可以用来读取单个的配置属性。
- 它支持 SpEL 表达式,可以进行更加灵活的配置。
11. 循环依赖怎么解决?
在 Spring 中,循环依赖是指两个或多个 Bean 相互依赖,形成一个闭环。Spring 默认可以解决 singleton-scoped bean 之间的循环依赖,但是对于 prototype-scoped bean 和构造函数注入的循环依赖,Spring 是无法解决的。
解决方法:
- 使用
@Lazy
注解:@Lazy
注解可以延迟一个 Bean 的初始化,直到它第一次被使用。这可以打破循环依赖。- 使用
setter
注入: 将构造函数注入改为setter
注入。- 使用
@PostConstruct
注解: 可以在一个 Bean 的构造函数执行完毕之后,再进行一些初始化操作。- 重构代码: 重新设计你的代码,避免循环依赖的出现。
12. 说一下 AOP 和它的几个概念
AOP (Aspect-Oriented Programming) 即面向切面编程,是一种编程范式,它允许我们通过分离横切关注点来增加程序的模块化。
核心概念:
- 切面 (Aspect): 一个关注点的模块化,这个关注点可能会横切多个对象。
- 连接点 (Join Point): 程序执行过程中的一个点,例如一个方法的执行或者一个异常的处理。
- 通知 (Advice): 在切面的某个特定的连接点上执行的动作。
- 切点 (Pointcut): 匹配连接点的断言。
- 引入 (Introduction): 在不修改类代码的前提下,为类添加新的方法和属性。
- 目标对象 (Target Object): 被一个或者多个切面所通知的对象。
- AOP 代理 (AOP Proxy): AOP 框架创建的对象,用来实现切面契约。
- 织入 (Weaving): 把切面连接到其他的应用程序类型或者对象上,来创建一个被通知的对象。
13. 说一下 RocketMQ 的四种通知方式
RocketMQ 提供了四种发送消息的方式:
- 同步发送 (Sync): 客户端发送一条消息,然后等待服务器的响应。
- 异步发送 (Async): 客户端发送一条消息,然后立即返回,不等待服务器的响应。当服务器处理完消息之后,会通过回调函数来通知客户端。
- 单向发送 (Oneway): 客户端发送一条消息,然后立即返回,不等待服务器的响应,也没有回调函数。
- 顺序消息 (Ordered Message): RocketMQ 可以保证消息按照发送的顺序被消费。
智农天地(北京)
2025.09.04
- 做一个自我介绍
- 线程和进程有什么区别
- 说一下你编写代码中异步的体现
- 前端你有什么了解?
- 介绍一下你简历中这个票务平台的ai这一块内容
- 你对大模型还有什么其他应用吗?
- 说一下git的常用命令
- linux的常用命令有什么
- 介绍一下向量数据库
- 如果实习的话学校这边OK吗?
智农天地(二面)
2025.09.04
- 做一个自我介绍
- 说一下你项目中ai是怎么用的
- 可以实习多久?
- 有没有什么实习要求?(你问我干嘛?)
- 工资给你开多少吧?(黑人问号)
- 多久可以有明确的答复?
美团(北京)
2025.09.05
- 介绍一下你的网关项目和核心组件
- 你说这个项目是根据美团的sherperd网关改写的,说一下你做了哪些改动
- 你在做这个项目过程中遇到了哪些困难?
- 你第二个购票系统有哪些项目亮点?
- 介绍一下AOP是什么
- 你是怎么保证热点数据的一致性的?(缓存一致性问题)
- 你是怎么做到分库分表?为什么要分库分表?
- 介绍一下你的AI项目和它的核心功能
- 说一说什么是MCP,你是怎么用的?
- 对于AI你还了解了其他的一些概念吗?(我说的向量数据库)
- 你是哪个高中的?(当时问的我一脸懵,后来才知道面试官也是长治人,太巧了哈哈哈哈)
手撕: 反转链表(写代码+说思路+另一种解法)
反问: 我们平常的工作是大概是什么?
我们平常的工作中可以接触到核心业务代码吗?
您介意把这个面试过程放到我的博客上吗?
腾讯(深圳)
2025.09.05
- 先做个自我介绍吧
- 选一个你认为最能体现你的技术的一个项目
- 介绍一下你的这个网关项目(打开屏幕共享进行讲解)
- 说一下这些组件是怎么互相作用的,它们的职责是怎么样的?
- 深挖项目.....
- 你认为你的项目有哪些不足?
- JVM虚拟机和服务是什么样的关系(进程和线程 包含和不包含)
手撕: 在排序数组中查找元素的第一个和最后一个位置
反问: 如果我加入贵公司,我需要提前学习一下什么呢?
刚刚你问我的JVM虚拟机那个问题您认为是怎么样的?
我可以把面试过程放到我的博客上吗?
美团(二面)
2025.09.09
- 因为面试官要求对问题保密鼠鼠我就不放问题了
- 今天的心情也是很不错 本来以为腾讯的面试挂了 但没想到挺到二面了 鼠鼠的腾讯梦还可以继续下去 这个面试也比较准备的不错
- 说些题外话 我也为面试付出了相当多的精力 可能没有大家想的那么轻松 一百多万字的八股反反复复背了好几遍 算法题前前后后也刷了好多遍了 项目实习也准备的比较充分了 打铁还是得自身硬啊 🐉🐉一百面到现在成功率还是百分百 希望可以撑到拿腾讯offer的那一天
- 最后说一句🐉人🐉言:人有些时候还是得自己成全自己啊~
腾讯(二面)
2025.09.11
做个自我介绍吧。
请详细介绍一下你简历上的项目,可以先从第一个网关项目开始。
你为什么会想要做这样一个网关项目?
介绍一下这个网关的整体架构和核心组件吗?
你在设计中提到了“前置处理器”和“后置处理器”,能具体讲讲它的作用和好处吗?
服务实例是如何将自己的信息注册到网关中心的?需要接入的服务做很多代码改造吗?
你提到了“接口上报”和“心跳续约”,这两者怎么做的?
当一个服务有多个实例时,网关是如何进行负载均衡的?你实现了哪些策略?
了解过一致性哈希吗?
如果某个服务实例或网关核心节点宕机了,系统会如何处理以保证高可用性?
你提到服务组和网关核心组是一对一绑定的,这种设计出于什么考虑?如何实现服务间的资源隔离?
网关的限流功能是如何实现的?
在网关集群部署的情况下,如何实现一个全局统一的分布式限流,而不是各个节点单机限流?
每次请求都访问Redis判断限流会有效率问题,有什么优化思路吗?
限流的阈值是固定的还是可以动态调整的?如果要动态调整,你会如何设计实现?
了解令牌桶、漏桶等常见的限流算法吗?它们是如何工作的?
你对这个网关项目做过性能测试吗?结果如何,比如QPS和延迟?
你提到网关带来了额外的3毫秒延迟,你分析过这部分延迟主要耗时在哪里吗?这个结果符合你的预期吗?
你的网关和服务之间有远程调用,能谈谈你对RPC的理解吗?它是如何工作的?
你项目中用到了Redis,能讲讲为什么Redis的性能这么高吗?
你提到了I/O多路复用,可以具体解释一下它的原理吗?你知道select、poll和epoll的区别吗?
HTTP和TCP协议有什么区别和联系?
能否详细描述一下TCP的三次握手和四次挥手过程?
为什么TCP挥手需要四次,而不是三次?
了解TCP的滑动窗口机制吗?
接下来聊聊你的第二个项目,那个结合了Spring AI的购票系统,介绍一下你主要做了什么?
在这个项目中,AI主要应用在哪些方面?
你提到了Function Calling,能解释一下它是什么,以及它是如何工作的吗?
手撕忘了
反问: 要提前学什么?
组内业务
字节(北京)
2025.09.12
- 请介绍一下你的网关项目是做什么的。
- 这个网关项目主要解决了什么问题?
- 服务之间使用的是什么通信协议 (HTTP, RPC)?
- HTTP 和 Dubbo 这两种协议有什么区别?
- 你提到 Dubbo 的二进制传输效率高,但 HTTP 底层也是 TCP,也是二进制,为什么效率有差异?
- Dubbo 默认使用什么序列化方式?
如果下游服务有多个实例,请求如何路由?
你知道哪些常见的负载均衡算法?
如何在代码中实现轮询(Round Robin)算法?
如何实现加权轮询(Weighted Round Robin)算法?
你是如何实现请求限流的?
限流逻辑是放在哪里实现的 (例如 Nginx, 服务实例)?
如果实例是动态扩缩容的,如何实现一个全局的总限流 (例如,所有实例加起来总共 100 QPS)?
可以用什么中间件来实现全局限流?
如果用 Redis,应该如何实现?具体的数据结构、Key 和 Value 是什么?
多实例并发访问 Redis 进行限流,如何处理并发问题?
网关的元数据信息(如服务地址)是存储在哪里的?
了解 Redis 的高可用实现方案吗?
在主从模式下,如果 Master 节点挂了,会发生什么?
Sentinel (哨兵) 是如何从多个 Slave 节点中选举出新的 Master 的?选举的逻辑是怎样的?
了解 Raft 协议吗?
进程和线程有什么区别?
进程分配的“资源”具体指什么?
在单核 CPU 的情况下,使用多线程能提高效率吗?为什么?
为什么 Tomcat 默认会开 200 个线程,它不怕线程切换的开销吗?
为什么 Redis 是单线程的,但性能却非常高?
为什么处理 I/O 任务时,使用多线程更合适?
你认为一个服务的线程数应该设置成多少比较合适?
为什么要使用线程池?
当一个新任务提交给线程池后,它的内部执行流程是怎样的 (核心线程、队列、最大线程、拒绝策略)?
如果服务器只有 2G 物理内存,一个程序需要申请超过 2G 的内存,它还能运行吗?为什么我们平时在电脑上开很多软件,把内存占满了,软件也不会立刻全部崩溃?
TCP 协议的“可靠”体现在哪些方面?
发送方 A 如何知道接收方 B 没有收到某个数据包?
A 和 B 通信时,B 所在的服务器整个宕机了,A 这边会有什么现象?
B 服务器没宕机,只是 B 的应用程序进程崩溃了,这时 A 持续发数据给 B,会发生什么?
InnoDB 存储引擎使用什么数据结构做索引?
请描述一下 B+ 树的结构。
B+ 树和 B 树有什么区别?
为什么 MySQL 选择 B+ 树而不是 B 树?
为什么 B+ 树的叶子节点用链表串联起来,就能提升 I/O 性能?
为什么不使用平衡二叉树(如 AVL 树)这类结构做索引?
O(log n)
的复杂度不是已经很快了吗?一张有 200 万条数据的表,它的 B+ 树索引大概有多高?这个层数是怎么计算出来的?
手撕: 给定一个整数数组
nums
和一个整数k
,找到该数组中和为k
的连续子数组的个数。反问:
团队主要的技术栈是什么?
工作地点在哪里?
团队负责的业务是什么?
一、项目经验 (网关)
- 核心问题: 网关主要解决服务治理的问题,它作为所有后端服务的统一入口,提供以下核心能力:
- 统一鉴权: 对所有请求进行统一的身份认证和权限校验。
- 动态路由: 根据请求的路径、参数等信息,动态地转发到下游正确的服务实例。
- 流量控制: 实现限流、熔断、降级,保护后端服务。
- 协议转换: 可以将外部的 HTTP 协议转换成内部的 RPC 协议。
- 日志监控: 集中记录所有请求的日志,便于监控和问题排查。
- 协议对比:
- HTTP: 应用层协议,基于“请求-响应”模式,通常传输文本(JSON/XML),通用性好,常用于对外暴露的 API。
- Dubbo (RPC): 远程过程调用框架,通常工作在传输层之上。它允许像调用本地方法一样调用远程服务。默认使用二进制协议(Hessian2)进行序列化,传输效率更高,更适合内部服务之间的高性能通信。
- 负载均衡:
- 常见算法: 随机、轮询、加权轮询、最少活跃连接数、一致性哈希等。
- 轮询实现: 可以使用一个原子类
AtomicInteger
作为计数器,每次请求对总服务数取模counter.getAndIncrement() % serverCount
来获取下一个服务的索引。- 加权轮询实现: 可以创建一个包含所有服务实例的列表,其中权重越高的服务在列表中出现的次数越多,然后对这个列表进行简单的轮询。或者,可以计算权重的最大公约数,按权重比例进行调度。
- 服务限流:
- 全局限流: 必须依赖一个中心化的存储来实现,比如 Redis 或专门的限流中间件。
- Redis 实现:
- 计数器: 使用
INCR
命令。当一个请求到来时,对一个全局的 Key (如global_rate_limit
) 执行INCR
。如果是第一次INCR
,则使用EXPIRE
设置一个 1 秒的过期时间。每次请求都检查这个 Key 的值是否超过阈值。这种方式在时间窗口的边界处有精度问题。- 滑动窗口: 使用 Redis 的
ZSET
(有序集合)。将每次请求的时间戳作为 score,请求的唯一标识 (如 UUID) 作为 member 存入ZSET
。每次请求时,先移除窗口之前(currentTime - windowSize
)的所有记录,然后统计ZSET
中的元素数量,判断是否超过阈值。- 并发问题: Redis 的命令是原子性的,例如
INCR
、ZADD
都是原子操作,天然解决了并发问题。对于组合逻辑(如先INCR
再EXPIRE
),可以使用 Lua 脚本来保证多个命令的原子性执行。二、技术基础
1. Redis
- 高可用: 主要有三种方案:
- 主从复制 (Master-Slave): 数据备份和读写分离。
- 哨兵模式 (Sentinel): 在主从基础上增加了自动故障转移能力。Sentinel 负责监控主节点,当主节点宕机时,会自动从从节点中选举一个新主节点。
- 集群模式 (Cluster): 通过数据分片 (Sharding) 实现横向扩展,每个分片内部可以再做主从,实现高可用。
- 哨兵选举: 当一个 Sentinel 认为主节点下线(主观下线),它会询问其他 Sentinel。当足够多的 Sentinel (超过半数) 都认为主节点下线时,就形成了客观下线。然后,活着的 Sentinel 之间会进行一次选举,选出一个领导者。领导者会从从节点中,按照优先级 > 复制偏移量 > 运行 ID 的顺序,选出一个最合适的节点作为新主节点。
2. 操作系统与并发
- 进程与线程:
- 进程: 操作系统资源分配的最小单位。它有自己独立的内存空间。
- 线程: CPU 调度的最小单位。它共享所属进程的资源,是实际执行代码的单位。
- 单核多线程: 对于 CPU 密集型任务,不能提高效率,反而会因为线程上下文切换带来额外开销。对于 I/O 密集型任务,可以显著提高效率,因为当一个线程在等待 I/O (如读写数据库、文件) 时,CPU 可以切换去执行其他线程,避免了 CPU 空闲。
- Tomcat 线程模型: Web 应用绝大多数都是 I/O 密集型的。一个请求的处理过程中,大部分时间可能都在等待数据库返回结果或调用其他服务。Tomcat 采用多线程模型,是为了在一个线程等待 I/O 时,其他线程能继续处理别的请求,从而提高服务器的吞吐量和并发处理能力。
- 虚拟内存: 操作系统通过虚拟内存技术,将一部分硬盘空间映射为内存来使用(称为交换空间或页面文件)。当物理内存不足时,操作系统会将内存中不常用的数据(页)换出到硬盘上,为新任务腾出空间。这使得程序感觉自己拥有比物理内存更大的连续内存空间。虽然这会导致性能下降(硬盘读写远慢于内存),但能保证系统在内存紧张时依然可以运行,而不是直接崩溃。
3. 计算机网络 (TCP)
- 可靠性: 通过以下机制保证:
- 序列号和确认应答 (ACK): 对数据包进行编号,接收方收到后发送 ACK 确认,确保数据包有序、不丢失。
- 超时重传: 发送方如果在一定时间内没收到 ACK,会重新发送数据包。
- 流量控制: 使用滑动窗口,控制发送方的发送速率,防止撑爆接收方。
- 拥塞控制: 根据网络状况动态调整发送速率,防止网络拥堵。
- 网络异常场景:
- 场景一 (服务器宕机): A 发送数据后,收不到 B 的 ACK。A 会触发超时重传机制,在多次重试失败后,A 会放弃连接,并通知上层应用“连接超时”。
- 场景二 (进程崩溃): A 发送数据给 B 的端口。B 的操作系统收到数据包后,发现没有进程在该端口监听。操作系统会立即回复一个 RST (Reset) 包。A 收到 RST 包后,会立即中断连接,并通知上层应用“连接被重置”。
4. 数据库 (MySQL)
- 索引结构: InnoDB 使用 B+ 树作为索引结构。
- B+ 树 vs B 树:
- 数据存储: B+ 树只有叶子节点才存储数据(或数据地址),非叶子节点只存储键值和指针。B 树的所有节点都会存储数据。
- 查询效率: B+ 树的非叶子节点不存数据,使得单个节点可以容纳更多的键值,树的高度更低,I/O 次数更少。
- 范围查询: B+ 树的叶子节点之间通过双向链表连接,非常适合进行范围查询,而 B 树需要进行复杂的中序遍历。
- 为什么不用二叉树: 数据库索引存储在磁盘上。磁盘 I/O 是非常耗时的操作。索引查询的性能瓶颈在于 I/O 次数。B+ 树的高度非常低(通常 3-4 层就能支撑千万级数据),一次查询只需要 3-4 次 I/O。而二叉树的高度会深得多,导致 I/O 次数急剧增加,性能会非常差。
三、算法题
- 题目: 和为 k 的子数组。
- 思路: 使用前缀和 + 哈希表。
- 定义一个哈希表
map
,key
存储前缀和,value
存储该前缀和出现的次数。- 初始化
map.put(0, 1)
,表示和为 0 的前缀和出现过 1 次。- 遍历数组,计算当前位置的前缀和
preSum
。- 在
map
中查找是否存在preSum - k
这个键。如果存在,说明找到了一个或多个和为k
的子数组,将map.get(preSum - k)
的值累加到结果count
中。- 将当前的
preSum
存入map
中,更新其出现次数。- 复杂度:
- 时间复杂度: O(n),因为我们只遍历数组一次。
- 空间复杂度: O(n),在最坏情况下,哈希表需要存储 n 个不同的前缀和。