基于 SpringBoot 搭建社区问答论坛

1.使用 SpringBoot 搭建后端服务的基础架构,实现了用户提问、回复等核心业务逻辑

SpringBoot 程序的核心注解是什么?有哪些子注解?

SpringBoot 程序的核心注解是 @SpringBootApplication,它是一个组合注解,整合了多个常用注解的功能,能够让开发者以最少的配置来启动 SpringBoot 应用程序。它包含以下三个重要的子注解:

  • @Configuration:将类标记为配置类,允许在类中使用 @Bean 注解定义 Bean,就像传统 Spring 里的 XML 配置文件一样。
  • @EnableAutoConfiguration:依据项目的依赖自动为应用程序进行合理的默认配置,减少手动配置的工作量。
  • @ComponentScan:让 Spring 去指定包及其子包中扫描带有 @Component、@Service、@Repository、@Controller 等注解的类,并将它们注册为 Bean。

@Service、@Controller 等都是 @Component 的衍生注解。

在高并发场景下,创建或更新问题的操作可能会出现什么问题?如何优化?

在高并发场景下,多个并发请求同时修改同一问题数据,可能导致部分更新丢失或数据不一致;此外还可能产生性能瓶颈,即数据库写入操作频繁,可能导致数据库负载过高,响应变慢。优化方案大概有四个方向:

  • 乐观锁机制:在更新问题时,使用版本号或时间戳。例如,在问题表中增加 version 字段,每次更新前先查询当前版本号,更新时比对版本号,若一致则更新并递增版本号,否则返回错误让用户重试。
  • 分布式锁:在创建或更新关键操作前,获取分布式锁(如使用 Redis 实现分布式锁)。只有获取到锁的请求才能进行操作,确保同一时刻只有一个请求能修改问题数据,避免并发冲突。
  • 批量操作与异步处理:对于一些非关键的更新操作(如增加浏览量),可以采用批量操作和异步处理。将多个更新请求暂时缓存,达到一定数量或时间间隔后,批量写入数据库;或者使用消息队列(如 Kafka、RabbitMQ)将更新请求异步发送给专门的处理服务,减少对主业务流程的影响,提高系统响应速度。
  • 数据库读写分离:将读操作和写操作分离到不同的数据库服务器。发布问题主要是写操作,通过将读操作分担到从库,减轻主库压力,提高写操作的性能。同时,对热点数据(如热门问题)进行缓存(如使用 Redis 缓存),减少数据库读压力,间接提升写操作的并发性能。

高并发下的性能问题,考虑 SQL 优化,数据库读写分离冷热分离分库分表减少单库或者单表查询量过大,并且引入缓存层对频繁查询的数据进行缓存。而高并发下的可用问题,考虑消息队列做异步削峰,并且加入限流以及熔断降级机制。高并发下的一致性问题,考虑分布式锁以及互斥锁机制,并且引入消息队列和定时任务做补偿机制。

用户评论/回复功能在高并发场景下可能存在数据库竞争和一致性问题?是如何解决的?

针对高并发场景下的数据库竞争问题,可以使用数据库行级锁或者分布式锁来解决。
针对高并发场景下的一致性问题,可以通过声明式事务,即在方法上添加 @Transactional 注解,并指定事务的传播行为和回滚规则保证一致性。
此外,对于创建通知这种非关键且耗时的操作,可以将其异步化。使用 Spring 的异步任务功能,在创建通知方法上添加 @Async 注解,将通知创建操作放入单独的线程池中执行,减少对主业务流程的阻塞。

@Transactional 声明式事务的传播规则有哪些?

  • PROPAGATION_REQUIRED:当前有事务则加入,无事务则创建新事务,是默认规则。
  • PROPAGATION_SUPPORTS:当前有事务就加入,无事务则以非事务方式执行。
  • PROPAGATION_MANDATORY:当前有事务就加入,无事务则抛出异常。
  • PROPAGATION_REQUIRES_NEW:无论有无事务,都创建新事务,若有当前事务则挂起。
  • PROPAGATION_NOT_SUPPORTED:以非事务方式执行,若有当前事务则挂起。
  • PROPAGATION_NEVER:以非事务方式执行,若有当前事务则抛出异常。
  • PROPAGATION_NESTED:当前有事务则创建嵌套事务,无事务则创建新事务,嵌套事务有独立回滚点。

@Transactional 声明式事务失效的情况?

(1)注解所在类未被 Spring 管理
@Transactional 注解要起作用,其所在类必须是由 Spring 管理的 Bean。要是类没有被 Spring 容器扫描到,就无法创建代理对象,进而导致事务失效。
(2)方法非 public 修饰
@Transactional 注解仅能用于 public 方法,若用于非 public 方法,事务会失效。这是因为 Spring AOP 基于代理模式,代理类只能拦截 public 方法。
(3)自调用问题
在同一个类里,一个非事务方法调用另一个有 @Transactional 注解的方法,事务会失效。这是因为自调用时,没有经过 Spring 的代理对象,事务注解无法生效。
(4)异常类型不匹配
@Transactional 注解默认只对 RuntimeException 和 Error 类型的异常回滚事务,若抛出的是其他类型异常,事务不会回滚。

此外,无论是使用 JDK 动态代理还是 CGLIB 动态代理,final 方法由于其不可重写的特性,static 方法由于其不依赖对象实例的特性,都会导致 @Transactional 注解和 AOP 增强失效。

若用户量激增,如何设计高并发下的提问接口?涉及哪些技术点?

(1)限流与降级
引入 RateLimiter(如 Guava 的 RateLimiter)或分布式限流工具(如 Redis + Lua、Sentinel),限制单个用户提问频率(如每分钟最多提问 10 次)。使用 Hystrix 或 Resilience4j 实现降级逻辑,当系统负载过高时返回友好提示,避免雪崩。
(2)异步处理
将非核心逻辑(如提问后的通知、统计)通过 Spring Kafka/RabbitMQ 异步处理,减少接口响应时间。即提问接口直接返回提问成功,通过消息队列通知搜索服务更新索引、统计服务更新提问计数。
(3)数据库优化
对 Question 表进行分库分表(如按 user_id 哈希分表),避免单表数据量过大。使用读写分离(主从数据库),将查询操作路由到从库,减轻主库压力。

所以应对高并发的请求,就可以考虑限流熔断降级,考虑异步削峰,考虑读写分离冷热分离分库分表,考虑加入缓存层。

降级和熔断的区别?

熔断用于保护服务免受下游依赖故障的影响(如第三方接口超时),通过快速失败防止雪崩;降级用于应对自身资源不足(如 CPU 过载),通过牺牲非核心功能保障核心流程。例如,当支付服务依赖的风控接口故障时,应触发熔断;而当电商系统在大促期间整体流量超标时,应降级用户积分查询功能。

熔断简单来说就是快速失败;降级简单来说就是牺牲非核心功能保障核心功能。

2.使用 Flyway 管理数据库脚本,并使用 Lombok 注解简化实体类的编写,减少样板代码

Flyway 是什么?为什么要使用它来管理数据库脚本?

Flyway 是一款开源的数据库版本控制工具,主要用于管理数据库脚本的版本和变更。它支持多种数据库,能够帮助开发人员更轻松地处理数据库架构的演进。使用 Flyway 管理数据库脚本有以下好处:

  • 版本控制:Flyway 可以跟踪数据库脚本的版本,记录每个版本的变更内容。这使得开发团队能够清晰地了解数据库架构的演进历史,方便进行回溯和问题排查。例如,当出现问题时,可以准确知道在哪个版本引入了错误的变更,从而快速定位和解决问题。
  • 数据库迁移自动化:它能够自动执行数据库脚本,将数据库从一个版本迁移到另一个版本。在开发、测试和生产环境中,只需简单地运行 Flyway 命令,就可以确保数据库架构与应用程序的版本保持一致,减少了手动执行脚本时可能出现的错误。
  • 团队协作:在团队开发中,不同开发人员可能会对数据库进行不同的变更。Flyway 可以确保所有的变更都按照正确的顺序应用到数据库中,避免了因变更顺序不一致而导致的问题。同时,它也方便了团队成员之间的沟通和协作,大家可以通过查看版本控制信息了解数据库的变更情况。
  • 数据一致性:Flyway 在执行脚本时会保证事务的一致性。如果某个脚本执行失败,它会自动回滚整个事务,确保数据库处于一致的状态,防止数据损坏或不一致的情况发生。

总结来说,它可以保障数据库执行脚本的一致性,若出错则自动回滚;它还可以跟踪数据库脚本的版本并记录每个版本的变更内容,方便问题排查和回溯。

Lombok 有哪些常用注解?

Lombok 是一个 Java 库,能通过注解的方式自动生成 Java 类中的常用代码,例如构造函数、getter 和 setter 方法等,从而减少样板代码,提高开发效率。以下是 Lombok 中一些常用注解:

  • @Getter 和 @Setter:自动为类的属性生成 getter 和 setter 方法。
  • @ToString:自动为类生成 toString() 方法,方便打印对象信息。
  • @EqualsAndHashCode:自动为类生成 equals() 和 hashCode() 方法,用于比较对象是否相等。
  • @Data:是一个组合注解,包含了 @Getter、@Setter、@ToString、@EqualsAndHashCode 和 @RequiredArgsConstructor 的功能。
  • @Slf4j:为类自动生成一个 SLF4J 日志记录器。

Lombok 的原理是什么?

Lombok 的原理是利用 Java 编译期的注解处理机制,通过定义 @Getter、@Setter 等注解标记类或字段,其内置的注解处理器(实现 Processor 接口)在编译时扫描这些注解,基于抽象语法树(AST)动态生成 getter、setter、toString 等方法的字节码,最终将生成的代码与原代码合并输出,避免手动编写样板代码,本质是编译期代码生成技术

即在编译期通过扫描注解,基于抽象语法树新增对应方法的字节码并与原字节码合并。

3.结合 Guava 缓存库对社区的置顶问题数据进行缓存管理,以提高系统性能

项目中是如何对置顶问题数据进行缓存的?

private static Cache<String, List<QuestionDTO>> cacheQuestions = CacheBuilder.newBuilder()
.maximumSize(100)
.expireAfterWrite(10, TimeUnit.MINUTES)
.removalListener(entity -> log.info("QUESTIONS_CACHE_REMOVE:{}", entity.getKey()))
.build();

创建了一个 Guava 缓存实例,设置了缓存的最大容量为 100 个元素,缓存项写入 10 分钟后过期,并添加了缓存项移除监听器,当缓存项被移除时会记录日志。
在使用时,尝试从缓存中获取键为 sticky 的缓存值。若缓存命中,直接返回缓存值;若缓存未命中,则从数据库中查询置顶问题列表,然后遍历问题列表,将问题信息和用户信息包装为一个对象,最后将该对象组成的列表存入缓存并返回。若在获取缓存值或加载数据的过程中出现异常,则记录错误日志并返回空列表。

在缓存过期时间内,管理员对置顶问题进行的更改无法及时同步到用户端,影响用户体验。如何解决?

当管理员进行置顶问题的操作(如设置或取消置顶)时,在业务逻辑中添加手动刷新缓存的代码。一旦操作完成,立即清除缓存中关于置顶问题的记录,这样下次用户请求时就会重新从数据库中加载最新数据。

也可以借助消息队列(如 Kafka、RabbitMQ 等)实现异步缓存更新。当管理员进行置顶问题的操作后,发送一条消息到消息队列。有一个专门的消费者监听这个消息队列,一旦接收到消息,就立即刷新缓存。

为什么项目中选择使用 Guava 缓存而不是 Redis 缓存?

Guava 是 Java 项目的本地缓存,使用时无需额外的服务器部署和维护,仅需引入依赖即可使用,对于一些小型项目或者对缓存需求不复杂的场景,能显著降低系统的复杂度和维护成本。并且由于是本地缓存,数据存储在应用程序的内存中,读写操作无需网络传输,因此具有非常低的延迟,能快速响应请求,提升系统的性能。此外,在单节点应用中,Guava 缓存能保证数据的强一致性,因为所有操作都在同一个 JVM 内完成,不存在分布式环境下的数据同步问题。
总之,项目中缓存的数据量较小,且主要是为了减少对数据库的频繁访问,提高局部数据的访问速度,Guava 缓存是一个很好的选择,简单轻量又低延迟。而 Redis 更适合分布式系统,用于多节点之间的数据共享和缓存。

如何保证 Guava 缓存中的数据与数据库中的数据一致性?

项目中的方案:在对数据库进行增删改操作时,手动清除对应的缓存。即,当管理员设置置顶问题时,在操作完成后立即清除缓存中关于置顶问题的记录,下次请求时会重新从数据库加载最新数据。
其他的方案:设置合理的缓存过期时间,当缓存过期后,再次访问该数据时会从数据库中重新加载。这样可以在一定程度上保证数据的一致性,即使在更新数据库时没有及时更新缓存,随着时间的推移,缓存中的数据也会被更新。或者可以定期刷新缓存中的数据,确保缓存中的数据与数据库中的数据保持一致。可以使用定时任务来实现定期刷新。

也即,项目中的缓存过期策略首先是设置合理的缓存过期时间,其次是在管理员更新置顶问题数据后清除缓存。

在多线程环境下,Guava 缓存的加载和更新操作是如何保证线程安全的?

Guava 本身是线程安全的。Guava 缓存内部通过分段锁实现线程安全,即将缓存划分为多个段,每个段独立加锁,不同段的访问可并行处理,减少锁竞争。

当多个线程同时请求一个不存在于缓存中的键时,Guava 本身的 Cache.get(key, Callable) 方法会保证只有一个线程去执行 Callable 中的加载逻辑,其他线程会等待这个线程加载完成后直接从缓存中获取数据,避免了多个线程重复加载相同数据的问题。

4.针对用户提问/回复实现限流机制,对短时间大量发布问题的违规用户实现禁用

项目中是如何实现限流器机制的?

通过 Guava 缓存记录用户的操作次数,并在每次用户操作时检查其操作次数是否超过设定的阈值(即 2 次),同时利用缓存的过期机制实现了在一定时间内(1 分钟)对用户操作频率的限制。
并且当用户操作次数超过阈值,会通过 applicationContext 发布一个事件,该事件包含了触发限流的用户 ID。并且有一个监听器 Listener 监听该事件并对用户的违规次数进行统计,当用户的违规次数达到一定阈值时,对用户进行禁用处理并删除其发布的问题,从而实现了限流计数和限流处理的联动。

在一分钟内用户操作次数超过两次,则发布事件,监听器监听到该时间就会把用户的违规次数加一,若违规次数达到一定阈值,则禁用用户。

还有哪些限流器实现方式?

令牌桶算法是一种比较常用的限流算法。它维护一个令牌桶,以固定的速率向桶中放入令牌。当请求到来时,需要从桶中获取一个令牌才能处理请求。如果桶中没有令牌,则请求被拒绝。令牌桶的容量是有限的,当桶满时,新生成的令牌会被丢弃。
漏桶算法就像一个底部有漏洞的桶,请求就像水一样流入桶中,然后以固定的速率从桶中流出(处理请求)。无论请求的速率如何变化,漏桶都会以恒定的速率处理请求。如果桶满了,新的请求就会被丢弃。

为什么项目中使用缓存来做限流,而不用令牌桶等算法?

Guava 缓存实现的限流方式在项目中主要是针对同一用户的操作次数进行限流,以保护系统免受单个用户的突发大量请求的影响。而通常所说的限流器则更侧重于对不同用户的整体流量和并发数量等方面进行控制,以确保系统的稳定性和可靠性。两者的关注点和作用范围有所不同,但在实际应用中可以结合使用,以实现更全面的系统保护和流量控制。

所以项目的改进方向就是增加令牌桶算法限流机制对整体流量进行限制。

项目中是本地限流,那分布式怎么做限流?

(1)Sentinel 限流
Sentinel 是阿里巴巴开源的分布式系统的流量防卫兵,它以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度来保障服务的稳定性。Sentinel 提供了丰富的限流规则配置,支持基于 QPS、线程数等多种限流维度,并且可以动态调整限流规则。

import com.alibaba.csp.sentinel.Entry;
import com.alibaba.csp.sentinel.SphU;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;

import java.util.ArrayList;
import java.util.List;

public class SentinelRateLimiter {
private static final String RESOURCE_NAME = "my_resource";

static {
// 初始化限流规则
List<FlowRule> rules = new ArrayList<>();
FlowRule rule = new FlowRule();
rule.setResource(RESOURCE_NAME);
rule.setCount(100); // 限流阈值为 100 QPS
rule.setGrade(1); // 限流模式为 QPS 模式
rules.add(rule);
FlowRuleManager.loadRules(rules);
}

public static boolean isAllowed() {
Entry entry = null;
try {
entry = SphU.entry(RESOURCE_NAME);
return true;
} catch (BlockException e) {
return false;
} finally {
if (entry != null) {
entry.exit();
}
}
}
}

(2)Nginx 限流
Nginx 是一个高性能的 HTTP 服务器和反向代理服务器,它提供了 ngx_http_limit_req_module 和 ngx_http_limit_conn_module 模块来实现限流。ngx_http_limit_req_module 可以基于请求的速率进行限流,ngx_http_limit_conn_module 可以基于连接数进行限流。

http {
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;

server {
location / {
limit_req zone=mylimit;
proxy_pass http://backend;
}
}
}

分布式场景下使用令牌桶算法可也行,核心是通过中心化存储(如 Redis)以及原子操作解决状态共享问题。其优势在于支持动态调整和突发流量处理,但需权衡实现复杂度与性能损耗。实际应用中,可结合本地缓存和集群化存储进一步优化,以满足高并发场景的需求。

某个时间大量用户违规次数过多怎么办?会不会压垮服务?

需要引入消息队列,异步处理禁用用户的逻辑,避免阻塞。

这也是一个高并发的问题,核心在于系统应对突发异常流量时的稳定性如何保障。考虑消息队列做限流削峰,限流熔断降级在该场景下并不适用。

如何监控限流和禁用机制的有效性?需要哪些指标?

  • 限流命中率:被限流的请求数 / 总请求数,反映限流规则的触发频率。
  • 禁用用户增长趋势:每分钟新增禁用用户数,判断是否存在大规模恶意攻击。
  • 误判率:申诉成功的禁用用户数 / 总禁用用户数,衡量规则的准确性。

5.结合 SpringTask 定期查询问题数据,并计算标签的优先级,实现定时更新热门标签

项目中是如何通过定时任务更新热门标签数据的?换一种问法,如何保证热门标签数据一致性?

每隔 3 小时从数据库中查询所有问题,计算每个标签的优先级,并将优先级信息更新到热门标签缓存中。即定时刷新本地缓存保证数据一致性。

@Scheduled(fixedRate = 1000 * 60 * 60 * 3):这是 Spring 提供的定时任务注解,fixedRate 表示任务执行的固定时间间隔,这里设置为 3 小时(1000 * 60 * 60 * 3 毫秒)。

标签优先级是如何确定的?

标签优先级直接决定于标签关联的问题的评论数量。

热门标签数据存储位置在哪里?

本项目对热门标签数据的缓存需求相对简单,数据量较小,不需要复杂的缓存管理和高级功能,使用本地 List 存储热门标签数据可以避免引入额外的缓存组件(如 Redis 或 Guava 缓存)。并且本地 List 的实现简单直观,易于理解和维护,对于小型项目或者对性能要求不是特别高的场景,这种方式可以快速满足基本的缓存需求。

在多线程环境下,使用本地 List 存储热门标签数据会遇到什么问题,如何解决?

多个线程同时对 List 进行读取和更新操作时,可能会出现数据不一致的情况。例如,一个线程正在更新热门标签列表,而另一个线程同时读取,可能会读到不完整或错误的数据。
可以使用 CopyOnWriteArrayList 替代普通的 ArrayList,或者使用 synchronized 关键字对涉及 List 操作的方法或代码块进行同步。

CopyOnWriteArrayList 是 Java 中一种适用于读多写少场景的线程安全容器,其底层原理基于写时复制机制。即读操作无锁,直接访问底层数组(由 volatile 保证可见性),无需加锁,性能高效;而写操作加锁复制,即执行写操作时,先通过 ReentrantLock 加锁,避免多线程并发修改,然后复制当前数组生成一个新副本,在新副本上完成修改操作,最后将底层数组引用指向新副本(由 volatile 确保引用更新的原子性和可见性)。读操作始终读取旧数组的快照,写操作完成后新数组才会对其他线程可见,因此读操作不会阻塞写操作,但可能读取到旧数据(适用于允许延迟加载或最终一致性的场景)。

与使用 Redis 存储热门标签数据相比,当前使用本地 List 存储有哪些优缺点?

(1)优点

  • 简单易用:本地 List 的使用非常简单,不需要额外的服务器部署和配置,也不需要学习复杂的缓存操作命令,降低了开发和维护成本。
  • 低延迟:由于数据存储在本地内存中,读取操作不需要网络传输,因此具有非常低的延迟,能够快速响应请求。
  • 数据一致性相对容易保证:在单节点应用中,对本地 List 的操作都在同一个进程内,相对分布式缓存而言,数据一致性更容易维护,不需要考虑分布式环境下的数据同步问题。

(2)缺点

  • 扩展性差:本地 List 只能在单个应用服务器上使用,当项目并发量增大,需要扩展服务器节点时,无法在多个节点之间共享热门标签数据,不适合分布式系统架构。
  • 内存限制:本地 List 的大小受限于应用服务器的内存,随着数据量的增加,可能会导致内存溢出,而 Redis 可以通过集群等方式扩展内存容量。
  • 数据持久化困难:本地 List 中的数据在应用程序重启后会丢失,而 Redis 支持数据持久化,可以将数据保存到磁盘,保证数据的安全性和持续性。

所以为什么不用 Redis,就是它需要额外的配置和部署,引入了复杂性和维护成本;并且数据一致性维护复杂。对于单机情况下存储少量数据,本地缓存简单易用,低延迟,何乐而不为?

SpringTask 实现原理?(任务调度器和任务执行器两个核心组件)

(1)配置解析
在 Spring 应用启动时,Spring 会扫描配置类或 XML 配置文件,查找带有 @EnableScheduling 注解的类,该注解会启用 Spring 的任务调度功能。同时,Spring 会扫描带有 @Scheduled 注解的方法,将这些方法封装成任务。
(2)任务注册
Spring 会将带有 @Scheduled 注解的方法封装成 Runnable 任务,并根据注解中的配置信息创建相应的 Trigger 对象。然后,将任务和触发器注册到 TaskScheduler 中。
(3)任务调度
TaskScheduler 根据 Trigger 定义的触发规则,安排任务的执行时间。当任务的执行时间到达时,TaskScheduler 会从线程池中获取一个线程,并将任务交给该线程执行。
(4)任务执行
TaskExecutor 负责实际执行任务。当 TaskScheduler 将任务交给 TaskExecutor 时,TaskExecutor 会从线程池中获取一个空闲线程,并将任务分配给该线程执行。

简单来说,Spring 将任务和触发器注册到任务调度器中,由任务调度器动态计算下次执行时间,并插入内部队列以安排任务的执行时间。真正执行任务的是任务执行器,它一般会使用线程池来执行任务。

注意:任务调度器内部维护了一个任务队列,用于存储待执行的任务。它可以根据任务的触发规则对多个任务进行管理和调度。当任务调度器启动后,会不断检查任务队列中各个任务的执行时间,当某个任务的执行时间到达时,就会从线程池中获取线程来执行该任务。不同的任务可以有不同的触发规则,任务调度器会根据这些规则合理地安排每个任务的执行顺序和时间。

简单介绍下 cron 表达式?

Cron 表达式是一种用于指定任务执行时间的字符串表达式,在任务调度系统中广泛使用。Cron 表达式由 6 或 7 个字段构成,这些字段之间用空格分隔,每个字段分别代表不同的时间单位。包括秒、分、时、日、月、周、年(可选)。

6.通过 WebMvcConfigurer 接口配置拦截器,实现登录校验,权限验证,日志记录等

WebMvcConfigurer 的主要作用是什么?

借助 addInterceptors 方法,能把自定义的拦截器添加到 Spring MVC 的拦截器链里。拦截器可在请求处理的前后执行特定操作,像身份验证、日志记录、性能监控等。

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class MyWebConfig implements WebMvcConfigurer {

@Override
public void addInterceptors(InterceptorRegistry registry) {
// 添加自定义拦截器
registry.addInterceptor(new MyInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/login", "/register");
}
}

上述代码中,MyInterceptor 是自定义的拦截器,addPathPatterns 用来指定要拦截的路径,excludePathPatterns 则用于指定排除的路径。

拦截器仅拦截 Spring 处理的请求;过滤器基于 Servlet 规范,可拦截所有进入 Servlet 容器的请求。

具体配置了一个什么功能的拦截器?

定义了一个名为 SessionInterceptor 的拦截器,它实现了 HandlerInterceptor 接口,并重写 preHandle 方法,在请求处理之前被调用。

postHandle 方法在请求处理之后、视图渲染之前被调用;afterCompletion 方法在整个请求处理完成后(包括视图渲染)被调用。

该拦截器的主要功能是在请求处理之前,设置一些全局的上下文属性和广告信息,并根据 Cookie 中的 token 验证用户是否登录,若登录则将用户信息和未读通知数量设置到会话中。

为什么选择 WebMvcConfigurer 来配置拦截器?

WebMvcConfigurer 是 Spring MVC 提供的配置接口,用于自定义 MVC 组件(如拦截器、转换器、静态资源处理器等),通过实现该接口并声明为 @Configuration 类,可优雅地扩展 MVC 功能而不影响自动配置。

  • 低侵入性:无需继承任何类,通过重写 addInterceptors 方法即可注册拦截器,保持代码整洁。
  • 灵活性:可同时配置多个拦截器,并通过 order() 控制执行顺序,支持细粒度的拦截规则(如 addPathPatterns 匹配路径、excludePathPatterns 排除路径)。

拦截器的执行顺序说一说?

  • 预处理(preHandle):多个拦截器的 preHandle 按注册顺序(order() 从小到大)依次执行,若某拦截器返回 false,后续拦截器的 preHandle 和控制器均不执行。
  • 控制器执行:所有拦截器 preHandle 返回 true 时,执行控制器方法。
  • 后处理(postHandle):按注册顺序的逆序执行(最后注册的先执行),可访问控制器返回的模型数据。
  • 完成处理(afterCompletion):按注册逆序执行,在响应完成后调用,用于资源清理。

拦截器和过滤器的执行顺序?

Filter 在 Servlet 容器启动时加载,早于 Spring 上下文,执行顺序在拦截器之前。
以下是典型流程:
Filter → DispatcherServlet → 拦截器 preHandle → 控制器 → 拦截器 postHandle → Filter afterFilter。

servlet 工作原理?(以 Tomcat 为例)

(1)接收请求:客户端请求先到达 Web 服务器(如 Nginx),静态资源直接返回,动态请求(如 .do、.jsp)转发给 Tomcat。
(2)容器处理:Tomcat 解析请求 URL,匹配对应的 Servlet(通过 web.xml 或注解配置)。创建 HttpServletRequest 和 HttpServletResponse 对象,调用 Servlet 的 service() 方法。
(3)响应返回:Servlet 处理完成后,将结果写入 HttpServletResponse,Tomcat 将响应转换为 HTTP 格式,通过 Web 服务器返回给客户端。

RBAC 权限管理系统

1.基于 SpringBoot 搭建后端服务架构,实现用户登录,角色创建并分配权限的基本逻辑

角色和权限的区别是什么?

角色:主要用于对用户进行分类管理,方便将具有相同权限需求的用户归为一类,然后统一为该角色赋予相应的权限。这样可以简化权限管理的复杂度,当有新用户加入系统且其职责与某个已定义的角色相同时,只需将该角色赋予用户,用户就自动获得了该角色所拥有的所有权限,而无需逐个为其分配权限。
权限:用于精确控制用户对系统资源的访问和操作。通过对不同资源设置不同的权限,可以确保只有具有相应权限的用户才能进行合法的操作,从而保护系统数据的安全性和完整性。

2.使用 MyBatis 完成数据库交互,编写 SQL 脚本进行数据库的初始化与数据插入

MyBatis 的 #{} 和 ${} 的区别?

第一个占位符用于预编译 SQL 语句,它会把传入的参数当作一个字符串,并且会自动处理特殊字符,避免 SQL 注入攻击。在 SQL 语句里,使用 #{} 来表示占位符,MyBatis 会在执行 SQL 时把 #{} 替换成具体的参数值。
第二个占位符用于直接替换 SQL 语句中的变量,它会把传入的参数直接拼接到 SQL 语句中,不会进行预编译和转义处理。在 SQL 语句里,使用 ${} 来表示占位符。

MyBatis 的具体使用方式?

(1)添加依赖:在 pom.xml 中添加 MyBatis 和 MyBatis-Spring-Boot-Starter 的依赖

<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.1</version>
</dependency>

(2)配置数据源:在 application.properties 或 application.yml 中配置数据库连接信息

spring.datasource.url=jdbc:mysql://localhost:3306/test
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

(3)定义实体类:创建与数据库表对应的实体类

public class User {
private Long id;
private String name;
private Integer age;
}

(4)定义 Mapper 接口:创建 Mapper 接口,用于定义数据库操作方法

import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserMapper {
User selectUserById(Long id);
}

(5)编写 SQL 映射文件:创建与 Mapper 接口对应的 XML 文件,编写 SQL 语句

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.UserMapper">
<select id="selectUserById" resultType="com.example.entity.User">
SELECT * FROM user WHERE id = #{id}
</select>
</mapper>

(6)使用 Mapper 接口:在 Service 层注入 Mapper 接口,调用其方法进行数据库操作

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserService {
@Autowired
private UserMapper userMapper;

public User getUserById(Long id) {
return userMapper.selectUserById(id);
}
}

XML 和 Mapper 的映射规则有哪些?

(1)命名空间(namespace):在 XML 文件中,使用 namespace 属性指定对应的 Mapper 接口的全限定名。例如:

<mapper namespace="com.example.mapper.UserMapper">
<!-- SQL语句 -->
</mapper>

(2)方法名映射:XML 文件中的 SQL 语句的 id 属性与 Mapper 接口中的方法名一致。例如:

<mapper namespace="com.example.mapper.UserMapper">
<select id="selectUserById" resultType="com.example.entity.User">
SELECT * FROM user WHERE id = #{id}
</select>
</mapper>

(3)参数映射:SQL 语句中的参数使用 #{} 或 ${} 进行占位,MyBatis 会根据 Mapper 接口方法的参数类型和名称进行映射。例如:

<mapper namespace="com.example.mapper.UserMapper">
<select id="selectUserById" resultType="com.example.entity.User">
SELECT * FROM user WHERE id = #{id}
</select>
</mapper>

(4)结果映射:使用 resultType 或 resultMap 属性指定 SQL 语句的返回结果类型。如果返回结果是简单类型,可以直接使用 resultType;如果返回结果是复杂类型,需要使用 resultMap 进行映射。例如:

<mapper namespace="com.example.mapper.UserMapper">
<resultMap id="UserResultMap" type="com.example.entity.User">
<id property="id" column="id" />
<result property="name" column="name" />
<result property="age" column="age" />
</resultMap>
<select id="selectUserById" resultMap="UserResultMap">
SELECT * FROM user WHERE id = #{id}
</select>
</mapper>

3.自定义注解标记需权限验证的方法,结合 PointCut 表达式构建切入点,在 AOP 切面中使用前置通知进行权限校验,拦截非法请求,实现基于 RBAC 的权限管理

项目中权限校验的逻辑是怎么做的?讲一讲?

项目中权限校验的逻辑主要通过自定义注解 RequiresPermissions 和切面类 PermissionAspect 来实现。
首先,定义权限注解 RequiresPermissions:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermissions {
String[] value();

Logical logical() default Logical.AND;
}

其中,@Target 注解指定了该注解可以应用在类和方法上。@Retention 注解表示该注解在运行时仍然有效。value 方法用于指定访问接口所需的权限代码数组,logical 方法用于指定权限校验的逻辑(是 AND 逻辑还是 OR 逻辑,默认为 AND)。
其次,在控制器方法上应用权限注解:

@RequiresPermissions("user:list")
@GetMapping("/list")
public JSONObject listUser(HttpServletRequest request) {
return userService.listUser(CommonUtil.request2Json(request));
}

这表示访问 listUser 方法需要 user:list 权限。
最后,权限校验切面类 PermissionAspect:

@Aspect
@Slf4j
@Component
@Order(3)
public class PermissionAspect {
@Autowired
TokenService tokenService;

@Before("@annotation(com.heeexy.example.config.annotation.RequiresPermissions)")
public void before(JoinPoint joinPoint) {
//...
}
}

@Aspect 注解表明该类是一个切面类,@Component 注解将其纳入 Spring 容器管理,@Order(3) 用于指定切面的执行顺序。@Before 注解表示在带有自定义注解的方法执行前执行方法。
在该方法中,会从用户信息中获取用户实际拥有的权限列表,以及通过 JoinPoint 获取方法签名,然后从方法签名中获取注解中指定的权限。接着根据注解中指定的 logical 值进行权限校验。如果是 AND 逻辑,用户必须拥有注解中指定的所有权限,否则抛出、异常;如果是 OR 逻辑,用户只需拥有注解中指定的权限中的任意一种即可,若都没有则抛出异常。

在项目中使用切面编程来实现权限校验有什么优点和可能存在的问题?

优点:

  • 将权限校验逻辑从业务方法中分离出来,使得业务代码更加简洁和专注于核心功能,提高了代码的可读性和可维护性。
  • 切面类可以应用于多个被自定义权限校验注解标注的方法,避免了在每个方法中重复编写权限校验代码,提高了代码的复用性。
  • 权限校验逻辑集中在切面类中,便于统一管理和修改。如果需要调整权限校验的规则或逻辑,只需要在切面类中进行修改,而不需要逐个修改业务方法。

缺点:

  • AOP 的实现机制会带来一定的性能开销,因为在运行时需要进行代理对象的创建和方法的拦截。虽然在大多数情况下这种开销是可以接受的,但在对性能要求极高的场景中可能需要考虑。

AOP 的实现原理是什么?

AOP 的实现原理主要基于代理模式,静态代理在编译期生成代理类,动态代理在运行时生成代理类。JDK 动态代理基于接口实现,CGLIB 动态代理基于继承实现。在实际应用中,可以根据具体需求选择合适的代理方式。

AOP 什么时候会失效呢?

(1)代理对象内部方法调用
当目标对象的一个方法调用自身另一个方法时,AOP 增强不会生效。这是因为在目标对象内部方法调用时,并没有经过代理对象,而是直接调用了目标对象的方法。
(2)目标方法为 private、final 或 static
AOP 是基于代理模式实现的,代理对象无法访问目标对象的 private 方法,因此对 private 方法的增强不会生效;final 方法不能被重写,而 CGLIB 代理是通过生成目标类的子类来实现的,无法对 final 方法进行代理增强;静态方法属于类而不属于对象,代理对象是基于对象的,因此无法对静态方法进行代理增强。
(3)AOP 配置问题
如果切面类没有被 Spring 容器管理,那么 AOP 配置不会生效。需要确保切面类被 @Component 或其他相关注解标注,并且在 Spring 配置中开启了 AOP 自动代理。此外,切入点表达式用于指定哪些方法需要被增强,如果表达式配置错误,可能导致增强无法应用到目标方法上。例如,表达式中的类名、方法名、参数类型等信息错误。

4.配置 Caffeine 本地缓存框架,对用户信息等进行本地缓存,提升系统响应速度

缓存中存的具体是什么?

当用户登录验证通过后,会生成一个 token,然后将该 token 作为键,用户相关信息(如名字,角色)作为值,存入缓存中。
当用户退出登录时,会清除缓存中的用户信息。

当更新用户权限或角色时,如何保证缓存中的数据也同步更新?

项目中使用的是缓存定期失效策略:即调整缓存的过期策略,将用户信息缓存的过期时间设置得较短。这样在用户权限或角色更新后,即使没有主动更新缓存,缓存中的数据也会在短时间内过期,后续用户请求时会重新从数据库获取最新信息并更新缓存。
还可以在权限或角色更新的业务逻辑中手动更新缓存。另一种更优雅的办法是,在用户权限或角色更新的操作完成后,发送一条消息到消息队列。创建一个消息监听器,监听队列中的消息,一旦监听到用户权限或角色更新的消息,从消息中获取相关用户信息(如 token 或用户名),然后更新缓存。

可以描述为:当更新用户权限或角色时,主动删除缓存,并且配合缓存定期过期失效,来保证数据一致性(虽然不是强一致性)。

在多线程环境下,如何保证 Caffeine 缓存操作的线程安全性?

Caffeine 本身是线程安全的(和 Guava 很像),它内部使用了并发数据结构来保证线程安全。
例如,在进行缓存的读写操作时,不需要额外的同步机制。但是,在与数据库交互以及更新缓存时,可能需要额外的同步措施。比如在更新用户信息时,为了避免多个线程同时更新导致数据不一致,可以使用锁机制,如 synchronized 关键字或者 ReentrantLock。

Caffeine 本地缓存和 Redis 分布式缓存各有什么优缺点?在这个系统中,为什么选择 Caffeine 作为缓存方案?

Caffeine 本地缓存优点是数据存储在本地内存中,读写操作无需网络开销;并且简单易用,不需要额外的服务,直接集成到应用程序中。缺点是不同节点的缓存是独立的,无法实现数据的共享;以及容量有限,受限于本地内存大小,不适合存储大量数据。
Redis 分布式缓存优点是多个节点可以访问同一个缓存数据,适合分布式系统;并支持丰富的数据结构,如哈希、列表、集合等;以及可扩展性强,可以通过集群的方式扩展存储容量。缺点则是读写操作需要通过网络,速度相对较慢;需要额外的服务和配置,维护成本较高。
在这个系统中选择 Caffeine 作为缓存方案,是因为系统的并发量不是特别高,且用户信息的访问频率较高,使用 Caffeine 本地缓存可以提高系统的响应速度,减少数据库的压力。同时,由于系统规模较小,不需要分布式缓存来实现数据共享。

也就是说,Redis 虽然强大,但是读写操作需要通过网络,并且需要额外的配置和部署,引入了一定的复杂性和维护成本。对于单机少量数据,选择本地缓存框架简单轻量低延迟。但但但是,扩展性并不好,未来如果要升级为分布式支持高并发的应用,还需要引入 Redis 来做缓存层。

5.结合过滤器处理 HTTP 请求,并使用 MDC(底层基于 ThreadLocal)传递用户名和 Token,方便日志记录和权限验证

MDC 是什么?

MDC 是一个与当前线程绑定的上下文,它可以在整个应用程序的执行过程中存储和传递与特定请求或操作相关的诊断信息。这些信息可以包括请求的唯一标识符、用户身份、相关业务数据等。

  • 日志跟踪:通过在 MDC 中设置特定的键值对,例如请求的 traceId,在不同的代码模块和日志输出中都可以自动包含该信息。这样,当查看日志时,就能够根据这个唯一的标识符将与同一个请求相关的所有日志条目关联起来,方便快速定位和排查问题。
  • 多线程环境支持:在多线程应用程序中,每个线程都有自己独立的 MDC 副本。这意味着不同线程可以在各自的 MDC 中存储和访问与自身相关的信息,而不会相互干扰。当一个请求在多个线程之间传递时,MDC 可以确保相关的诊断信息也能正确地在这些线程中传递和使用。

traceId 的作用?

在复杂的分布式系统或多模块应用中,一个请求可能会经过多个服务、多个组件或多个处理环节。traceId 就像一个唯一的身份证,可以在整个请求的生命周期中贯穿所有的处理过程。

项目中使用 UUID 生成一个随机的唯一标识符,并去除其中的连字符,取前 12 个字符作为 traceId。然后将这个 traceId 放入 MDC 中,这样在后续的日志记录中,每个请求的日志都可以通过这个 traceId 进行关联和追踪。

项目中过滤器的具体使用方式?

继承 OncePerRequestFilter,保证在一次请求中只会被调用一次。其主要作用是在请求处理的过程中,对请求进行预处理,具体如下:

  • 添加 traceId:为每个请求生成一个唯一的 traceId,并将其放入 MDC 中。这样可以在整个请求处理过程中,通过 traceId 来关联该请求的所有相关日志,方便在日志中追踪和排查问题。
  • 设置 productId:从请求的 URL 中获取 productId,如果存在则将其放入 MDC 中,以便在后续的处理中可以方便地获取到该参数。
  • 设置 username:从请求头中获取 token,然后获取用户信息。如果解析成功,将用户名放入 MDC 中。这样在整个请求处理过程中,其他地方可以方便地获取当前用户的相关信息。
  • 包装请求:对请求进行包装,使得请求中的 body 可以重复读取,以满足后续可能需要多次读取 body 内容的需求。

清理 MDC 中存储内容的时机?

当 doFilter 方法被调用后,会将请求传递给下一个过滤器或最终的请求处理程序。当后续的过滤器和请求处理程序对请求处理完成后,控制权会返回到 doFilter 方法中。在 finally 块中执行清理 MDC 的操作后,该方法才会真正结束。

5为什么要清理 MDC?

主要是两个方面的考量:

  • 避免内存泄漏:MDC 是基于 ThreadLocal 实现的,如果不清理,在请求处理完成后,ThreadLocal 中存储的信息不会被自动清除。当线程被线程池回收再利用时,这些残留的信息可能会导致内存泄漏,随着时间的推移,可能会耗尽系统资源。
  • 防止数据污染:如果不清理 MDC,下一次使用同一个线程处理新的请求时,上一次请求存储在 MDC 中的数据依然存在,这可能会导致不同请求的数据相互污染,影响日志的准确性和业务逻辑的正确性。

过滤器和拦截器的应用场景有什么差异呢?

优先选过滤器的情况:

  • 需求与 Spring 框架无关(如纯 Servlet 应用)。
  • 需要拦截静态资源或非 Spring MVC 的请求。
  • 处理底层通用逻辑(如编码、跨域、流量监控)。

优先选拦截器的情况:

  • 需求依赖 Spring 容器(如需要注入 Bean、访问注解或方法参数)。
  • 需要对控制器方法进行精细化的前置 / 后置处理(如权限校验、结果封装)。
  • 需要结合 Spring MVC 的其他特性(如 ModelAndView、异常处理)。

实际项目中,两者常配合使用:过滤器处理全局通用逻辑(如日志、编码),拦截器处理业务相关逻辑(如认证、权限)。
例如:先用过滤器记录所有请求的基础日志,再用拦截器对登录用户的请求进行权限校验。

基于 SpringBoot 的电商购物网站

1.使用 SpringBoot 构建后端框架,开发各功能模块的后端逻辑,如下单、加购等功能

项目中的下单逻辑是怎样的?

用户选择购物车商品确认订单信息,系统校验库存、处理优惠券和积分,生成订单号并锁定库存,插入订单和订单商品信息到数据库,若使用优惠券则更新其状态、使用积分则扣除,删除购物车中下单商品并发送延迟取消消息;付款时修改订单状态为待发货并扣减真实库存,若未付款,超时后自动取消订单,释放库存、返还优惠券和积分。

订单编号是如何生成的?

订单编号格式为 8 位日期 + 2 位平台号码 + 2 位支付方式 + 6 位以上自增 id。
这种生成方式能够保证每天生成的订单编号是唯一的,并且便于根据订单编号快速定位订单的生成日期、平台和支付方式。

为什么要使用 Redis 来生成订单编号?

在生成订单编号的场景中,需要确保每个订单编号都是唯一的。使用 Redis 的自增操作可以很方便地实现这一点。因为 Redis 是单线程执行命令的,在同一时刻只会有一个操作在执行,所以可以保证生成的自增 ID 是唯一的,从而保证订单编号的唯一性。
Redis 是基于内存的数据库,其读写操作非常快速。对于高并发的订单生成场景,使用 Redis 的自增操作可以在短时间内处理大量的请求,不会成为系统的性能瓶颈。
Redis 提供了持久化机制,如 RDB 和 AOF,可以将数据持久化到磁盘。这样即使 Redis 服务器重启,也能保证自增操作的连续性,不会丢失之前的自增状态。

2.利用 Redis 缓存高频访问数据,例如用户信息和验证码,提高系统响应能力;并结合分布式锁避免超卖现象,提升系统在高并发情况下的一致性

当用户修改了个人信息后,如何保证缓存中的用户信息与数据库同步?

当用户修改个人信息时,首先更新数据库中的用户信息。然后根据用户 ID 删除缓存中的用户信息。这样,下次获取该用户信息时,由于缓存中不存在,会从数据库重新查询并更新缓存,从而保证缓存与数据库的同步。

项目中更新数据库后删除缓存的设计有没有什么问题?

如果在更新数据库后删除缓存的过程中出现异常,比如网络故障导致删除缓存失败,就会使数据库和缓存处于不一致的状态。而且,这种不一致很难被及时发现和修复,可能会导致后续业务出现错误。并且在高并发场景下,可能出现多个请求同时获取用户信息的情况。当一个请求更新数据库后删除了缓存,此时其他请求在缓存失效期间读取到的是旧数据。

先更新数据库,再删除缓存。其中的问题就在于,如果删除缓存失败了呢?那岂不是会出现严重的不一致问题。哪怕删除成功了,也可能在删除成功之前已经有线程读过并读到旧数据了。

为什么要用 Redis 存储用户信息和验证码?

用户登录、获取会员信息等操作属于高频场景。Redis 的内存读写速度极快(通常为微秒级),能显著降低这类操作的延迟,提升用户体验。
验证码属于时效性强的数据(通常有效期几分钟),无需永久存储在数据库中。通过 Redis 的 EXPIRE 命令设置过期时间,可自动清理过期数据,节省存储空间,并确保验证码的有效性。

验证码是快速过期的,完全没必要存储在数据库当中。

项目中是如何利用分布式锁机制避免超卖现象的?

在下单流程中关键的库存锁定方法里应用分布式锁。对每个商品的库存操作前,生成一个基于商品唯一标识的锁键和一个唯一的请求 ID,然后尝试获取锁。若获取锁失败,就表示有其他线程正在操作该商品库存,此时可以选择让用户稍后重试或直接提示库存不足。若成功获取锁,就进行库存锁定操作,操作完成后无论是否成功都要释放锁。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class OmsPortalOrderServiceImpl implements OmsPortalOrderService {
@Autowired
private RedisLockUtil redisLockUtil;

// 定义锁的键前缀
private static final String STOCK_LOCK_PREFIX = "stock_lock:";

// 锁定下单商品的所有库存
private void lockStock(List<CartPromotionItem> cartPromotionItemList) {
for (CartPromotionItem cartPromotionItem : cartPromotionItemList) {
String lockKey = STOCK_LOCK_PREFIX + cartPromotionItem.getProductSkuId();
String requestId = UUID.randomUUID().toString();
// 尝试获取锁,设置锁的过期时间为10秒,可根据业务调整
boolean locked = redisLockUtil.tryLock(lockKey, requestId, 10);
if (!locked) {
// 获取锁失败,说明有其他线程正在操作该商品库存,可选择重试或直接返回库存不足
Asserts.fail("库存操作繁忙,请稍后重试");
}
try {
PmsSkuStock skuStock = skuStockMapper.selectByPrimaryKey(cartPromotionItem.getProductSkuId());
skuStock.setLockStock(skuStock.getLockStock() + cartPromotionItem.getQuantity());
int count = portalOrderDao.lockStockBySkuId(cartPromotionItem.getProductSkuId(), cartPromotionItem.getQuantity());
if (count == 0) {
Asserts.fail("库存不足,无法下单");
}
} finally {
// 无论库存操作是否成功,都要释放锁
redisLockUtil.unlock(lockKey, requestId);
}
}
}
}

如果库存扣减任务在锁过期之前还没完成,怎么办?项目中是如何实现锁续期的?

在获取分布式锁之后,开启一个守护线程(定时任务),按照一定的时间间隔去检查当前线程是否仍然持有该锁。如果仍然持有,则对锁进行续期操作,保证锁在业务操作完成之前不会过期。这样可以避免因业务执行时间过长,锁提前过期而导致多个线程同时访问共享资源,从而保证数据的一致性和业务的正确性。

private static final ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(1);

这里使用 ScheduledThreadPoolExecutor 创建了一个单线程的定时任务执行器。单线程的设计可以避免多个线程同时执行续期任务而产生的并发问题。

private void scheduleRenewal(String lockKey, String requestId, int expireTime) {
scheduler.schedule(() -> {
if (redisLockUtil.isLocked(lockKey, requestId)) {
redisLockUtil.renewLock(lockKey, requestId, expireTime);
scheduleRenewal(lockKey, requestId, expireTime);
}
}, expireTime / 3, TimeUnit.SECONDS);
}

其实,为了防止该线程持有锁一直不释放,还可以给锁续期设定一个最大续期次数限制。当续期次数达到这个上限时,即使任务还未完成,也不再进行续期,让锁正常过期释放。除了最大续期次数,还可以为整个任务设定一个绝对的超时时间。当任务执行时间超过这个超时时间时,强制释放锁。

scheduler.schedule 方法用于安排一个延迟任务,该任务会在 expireTime / 3 秒后执行。这里将延迟时间设置为 expireTime / 3 是为了在锁过期之前有足够的时间进行续期操作。任务执行时,首先检查当前线程是否仍然持有该锁。如果持有,则对锁进行续期,将锁的过期时间延长为 expireTime 秒。续期成功后,再次安排下一次续期任务,形成一个循环。这样可以不断地对锁进行续期,直到业务操作完成并释放锁。

守护线程检查锁是否到期并延长其到期时间,主要是为了防止锁在业务操作未完成时意外过期释放,确保持有锁的客户端能有足够时间完成任务。这一过程中,守护线程是代表持有锁的客户端去操作锁的到期时间,并非是客户端本身需要再次获取锁来执行其他业务逻辑。所以,即使该分布式锁是不可重入的,在这个场景下也没什么关系。

在高并发下单场景中,若获取锁失败,为何不采用多次重试机制,而是直接提示库存操作繁忙?多次重试会带来哪些影响?

在当前方案中直接提示库存操作繁忙,是为了避免因大量重试导致系统资源被过度占用。若采用多次重试,虽然有可能成功获取锁并完成下单,但会增加系统的负载。因为每次重试都需要消耗 CPU 资源、网络资源等。在高并发情况下,大量线程同时重试,可能会使系统陷入一种忙等待状态,导致整体性能急剧下降,甚至引发系统崩溃。此外,多次重试还可能造成用户等待时间过长,影响用户体验。所以,需要根据业务场景权衡是否采用重试机制,在本项目这种对性能和用户体验要求较高的场景下,直接提示更合适。

获取分布式锁失败,是可以重试的,但是需要设置重试次数,防止长时间的忙等待。当重试次数超过阈值,则提示库存繁忙。

tryLock 方法使用 setIfAbsent 来获取锁,这个方法在 Redis 内部是如何实现原子性的?如果 Redis 集群出现脑裂,对获取锁操作会有什么影响?

实际上,setIfAbsent 方法就是对 SET NX 命令的封装。

在 Redis 内部,setIfAbsent 方法是基于 Redis 的单线程模型实现原子性的。Redis 的命令执行是顺序执行的,当一个客户端发送 setIfAbsent 命令时,Redis 会将其作为一个原子操作执行,不会被其他客户端的命令打断,从而保证了在同一时刻只有一个客户端能成功设置锁。
如果 Redis 集群出现脑裂,可能会导致多个客户端同时认为自己获取到了锁。因为脑裂时,集群被分割成多个部分,每个部分都可能独立运行。原本应该全局唯一的锁,在不同的子集群中可能被不同客户端获取,这就破坏了分布式锁的唯一性,进而可能引发业务逻辑错误,比如超卖等问题。为了应对脑裂问题,可以采用 Redlock 算法等更复杂的方案,通过与多个 Redis 节点交互来获取锁,提高锁的可靠性。

补充:红锁算法如何避免脑裂问题?
红锁算法通过向多数 Redis 节点(如大于等于 N / 2 + 1 个)请求加锁,只有当超过半数节点成功加锁且总耗时在锁有效期内时,才认为获取锁成功。这样即使出现脑裂,旧主节点(未在多数派中)的锁会因多数派节点未认可而失效,新主节点需重新获取多数派锁,从而避免不同节点上的锁冲突,降低脑裂导致的不一致风险。

红锁算法再详细讲一讲呢?

红锁(RedLock)是一种分布式锁算法,由 Redis 的作者 Salvatore Sanfilippo 设计,用于在分布式系统中实现可靠的锁机制,解决了单一 Redis 实例作为分布式锁可能出现的单点故障问题。其工作原理如下:

  • 多节点加锁:RedLock 不在单个 Redis 实例上加锁,而是在多个独立的 Redis 实例上同时尝试获取锁。通常建议使用奇数个 Redis 实例(如 5 个),以确保系统具有较好的容错性。
  • 多数节点同意:系统只有在获得了大多数 Redis 实例的锁(即 N / 2 + 1 个节点,N 为节点总数)之后,才认为成功获取了分布式锁。这样即使部分 Redis 实例发生故障,整体锁服务仍然可用。
  • 获取锁的步骤:客户端尝试顺序地向所有 Redis 实例发送加锁命令。对于每个实例,客户端尝试在指定的超时时间内获取锁。客户端计算已经成功加锁的实例数量,如果达到多数,则认为客户端成功获取了分布式锁。如果获取锁失败,客户端需要向所有实例发送释放锁的命令,以避免留下未释放的锁。
  • 时间同步:为防止客户端在持有锁的过程中发生故障而导致锁无法释放,RedLock 会在获取锁时设置一个超时时间。如果客户端在锁超时之前未能完成任务并释放锁,其他客户端可以在锁超时后重新尝试获取。
  • 锁释放:释放锁时,客户端需要向所有 Redis 实例发送释放锁的命令,以确保所有实例上的锁都被清除。

RedLock 具备互斥性、避免死锁、容错性等特性。不过,它也存在一些缺点和限制,如依赖各个 Redis 节点的系统时钟准确同步,在存在网络延迟或网络分区的情况下可靠性可能下降,节点故障会影响其可靠性,实现和使用上相对复杂等。

setIfAbsent 可以保证加锁的原子性,那释放锁的原子性如何保证?

setIfAbsent 方法的作用是仅当键不存在时才设置键值对,也就是 Redis 的 SET key value NX 命令。在 Redis 里,单条命令的执行是原子性的,所以能保证加锁操作的原子性。但是释放锁并不是单条命令,需要先检查后删除,所以需要额外保障原子性的机制。

释放锁时,一般需要先检查锁是否属于当前线程,若属于则删除该键。若直接使用 Redis 的多个命令分步执行,就无法保证操作的原子性。例如,在检查锁属于当前线程之后,删除操作之前,锁可能已经被其他线程重新获取,这样就会导致误删其他线程的锁。
所以需要借助 Lua 脚本在 Redis 中的原子执行特性,将检查锁和删除锁的操作封装成一个原子操作,从而保证释放锁的原子性。

你使用 Redis 实现了分布式锁,而 Redisson 也是一个常用的分布式锁框架,相比 Redisson,你实现的分布式锁有哪些优势和劣势?

优势方面,自己实现的分布式锁相对简单直接,代码量较少,对于一些对分布式锁需求不太复杂、资源有限且对框架依赖尽量少的项目来说,易于理解和维护。并且可以根据项目的具体业务场景进行灵活定制,比如在锁的过期时间设置、获取锁失败的处理等方面,能够精准匹配业务需求。
劣势方面,Redisson 是一个成熟的分布式锁框架,它提供了丰富的功能,如自动续租功能,能防止业务执行时间过长导致锁提前过期;还支持多种锁模式,如公平锁、读写锁等,适用性更广。Redisson 在集群环境下的稳定性和可靠性经过了大量实践的检验,相比之下,自己实现的分布式锁在集群复杂场景下(如节点故障转移、网络分区等)的应对能力可能较弱,需要投入更多精力去完善。

自己实现的分布式锁,无法应对脑裂问题,没有自动续期机制。但好处就是简单易用,轻量化。

在多线程环境下,如何确保分布式锁的正确使用,避免死锁和活锁的发生?

为避免死锁,首先要确保锁的获取和释放操作配对出现,在代码中使用 try - finally 块来释放锁,保证无论业务逻辑是否出现异常,锁都能被正确释放。其次,设置合理的锁过期时间,若一个线程长时间持有锁不释放,过期时间到后锁会自动失效,防止死锁。
对于活锁问题,当获取锁失败时,避免线程无意义地持续重试。可以采用随机等待时间策略,即获取锁失败后,线程等待一个随机时间后再重试,这样可以减少多个线程同时重试导致的活锁情况。同时,记录重试次数,当重试次数达到一定阈值后,放弃重试并进行相应的错误处理,避免线程一直处于重试状态。

避免死锁即设置合理的锁过期时间,防止长时间持有不释放;避免活锁即设置重试次数,避免长时间无效重试。

所以为什么不使用 Redisson 实现分布式锁?是基于什么样的考量?

Redisson 提供了丰富的分布式锁功能,如可重入锁、公平锁、联锁等多种类型的锁。但该项目对分布式锁的需求比较简单,只是基本的获取锁、释放锁以及锁的过期时间设置等功能,那么 Redisson 的一些高级功能可能就用不上,引入它会增加项目的复杂性和不必要的依赖。并且 Redisson 在实现分布式锁时,内部会有一些复杂的逻辑和交互,这可能会带来一定的性能开销

如果 Redis 节点出现故障,可能会导致锁丢失,从而出现数据不一致的问题。如何解决?

(1)Redis 集群:使用 Redis 集群来提高 Redis 的可用性,当一个节点出现故障时,其他节点可以继续提供服务。
(2)Redlock 算法:使用 Redlock 算法,在多个 Redis 节点上同时获取锁,只有在大多数节点上获取锁成功,才认为获取锁成功,从而提高锁的可靠性。

如何处理 Redis 缓存穿透、缓存击穿和缓存雪崩问题?

(1)缓存穿透:指查询一个一定不存在的数据,由于缓存中没有该数据,会导致每次请求都直接访问数据库。可以采用以下方法解决:

  • 布隆过滤器:在请求到达缓存之前,使用布隆过滤器判断该数据是否可能存在。如果布隆过滤器判断数据不存在,则直接返回,避免访问数据库。
  • 空值缓存:当查询的数据不存在时,也将空值缓存到 Redis 中,并设置较短的过期时间,这样下次相同的请求就会直接从缓存中获取空值,而不会访问数据库。

(2)缓存击穿:指一个热点 key 在缓存中过期,此时大量请求同时访问该 key,导致请求都直接访问数据库。可以采用以下方法解决:

  • 设置永不过期:对于一些热点 key,设置为永不过期,在业务层面定期更新缓存。
  • 分布式锁:在缓存过期时,使用分布式锁,只有一个线程可以去数据库查询数据并更新缓存,其他线程等待缓存更新完成后再从缓存中获取数据。

(3)缓存雪崩:指大量的缓存 key 在同一时间过期,导致大量请求直接访问数据库。可以采用以下方法解决:

  • 分散过期时间:为不同的缓存 key 设置不同的过期时间,避免大量 key 在同一时间过期。
  • 缓存预热:在系统启动时,将一些热点数据提前加载到缓存中,减少缓存雪崩的可能性。
  • 使用多级缓存:例如同时使用 Redis 和本地缓存,当 Redis 缓存失效时,先从本地缓存中获取数据,减少对数据库的压力。

3.集成 Elastic Search 搜索引擎,实现高效商品搜索、相关信息获取功能,并提供排序展示和推荐相似商品功能,提升用户搜索体验

为什么要用 ES 搜索引擎?

在电商等业务场景中,商品数据通常存储在关系型数据库中。而 ES 作为搜索引擎,能够提供更高效、灵活的搜索功能。因此,需要将数据库中的商品数据同步到 ES 中,以便利用 ES 的搜索能力来实现商品搜索、筛选、排序等功能。

数据从 MySQL 中导入到 ES 的过程中发生了什么?

(1)数据映射
在将数据导入 ES 之前,需要定义数据的映射关系。映射决定了数据在 ES 中的存储结构和类型,类似于关系型数据库中的表结构定义。例如,商品的名称、价格、描述等字段需要指定其数据类型(如字符串、数值等),以及是否可搜索、是否需要分词等属性。如果没有正确定义映射,可能会导致搜索结果不准确或无法按预期进行排序。

在项目中,由于是开发环境,配置了 1 个分片和 0 个副本(实际环境需要调整)。并且通过 @id 注解标记文档的 id,通过 @field 注解标记字段的映射信息。@Field(type = FieldType.Keyword) 表示字段类型为 keyword,一般用于精确匹配。@Field(analyzer = “ik_max_word”, type = FieldType.Text) 表示字段类型为 text,并且使用 ik_max_word 分词器进行分词处理,适用于全文搜索。

(2)分词处理
对于文本类型的字段,如商品名称、描述等,ES 需要对其进行分词处理,以便能够进行更精准的搜索。分词器会将文本拆分成一个个的词项,并建立倒排索引。不同的语言和业务场景可能需要选择不同的分词器,例如中文场景下常用的有 ik 分词器。在导入数据时,ES 会根据映射中指定的分词器对文本进行分词处理。

项目中使用的是 ik 分词器。ik_max_word 模式会将文本进行最细粒度的拆分,尽可能地将文本拆分成更多的词语,并且会优先匹配较长的词语。而 ik_smart 模式是一种智能分词模式,它会尽可能地将文本拆分成较少的词语,以达到最合理的分词效果。

(3)索引更新与维护
导入数据后,当数据库中的商品数据发生变化(如新增、修改、删除)时,需要及时同步更新 ES 中的数据,以保证数据的一致性。这通常需要在业务逻辑中添加相应的代码,监听数据库的变更事件,并将变更同步到 ES 中。另外,随着数据量的增加,还需要对 ES 索引进行定期的优化和维护,如合并段、清理无用数据等,以提高搜索性能。

项目中采取的是主动更新(保证最终一致性)的方式。包括将数据库数据批量导入 ES 的全量导入,以及根据数据库单条 ID 检索数据并更新到 ES 的单条更新或删除。

(4)查询配置与优化
在数据导入并完成映射和分词处理后,还需要根据业务需求配置合适的查询语句。ES 提供了丰富的查询语法,如全文搜索、精确匹配、范围查询等。需要根据具体的搜索场景选择合适的查询方式,并对查询进行优化,以提高搜索效率和准确性。例如,使用缓存、调整查询参数等方法可以优化查询性能。

项目中主动更新 ES 中数据的方式有哪些改进方向?

(1)利用 ORM 框架(如 Hibernate、MyBatis-Plus)的事件回调机制,在数据持久化后自动触发 ES 更新。
(2)数据库变更后,发送消息到 MQ(如 Kafka、RabbitMQ),消费者监听消息并更新 ES。
(3)通过数据库日志(如 MySQL 的 Binlog、PostgreSQL 的 WAL)捕获数据变更,解析后同步到 ES。

商品搜索和相关信息获取功能是如何实现的?

(1)普通搜索
在商品的名称、副标题和关键词字段里搜索匹配关键词的商品。
(2)复杂搜索
还可以传入品牌 id,分类 id,并对商品的名称、副标题和关键词字段进行匹配查询,与普通搜索的区别是,不同字段(名称,副标题和关键词)设置不同的权重。

排序展示是如何实现的?

NativeSearchQueryBuilder 是构建 Elasticsearch 查询的重要工具。

传入排序参数(默认是按复杂查询查出的相关度),例如按销量,按价格。并使用 NativeSearchQueryBuilder 的 withSort 方法。以下是一个示例,展示如何按照商品价格进行降序排序:

builder.withSort(SortBuilders.fieldSort("price").order(SortOrder.DESC));

推荐相似商品功能是如何实现的?

首先,获取到产品信息,提取产品的名称、品牌 ID 和产品分类 ID,用于后续的搜索条件构建。
创建一个 FunctionScoreQueryBuilder.FilterFunctionBuilder 列表,为不同的字段(产品名称、副标题、关键词、品牌 ID、产品分类 ID)设置匹配查询,并为每个查询设置相应的权重因子。然后,将这些构建器数组传入 QueryBuilders.functionScoreQuery 方法,构建一个 FunctionScoreQueryBuilder,设置评分模式为分数总和,并设定最小得分。

FunctionScoreQueryBuilder 是用于构建 FunctionScoreQuery 的构建器。它允许用户通过定义一系列函数来修改搜索结果的评分,以实现更灵活的结果排序和筛选;FunctionScoreQuery 是 Elasticsearch 中的一种查询类型,用于根据指定的函数对搜索结果进行评分和排序;FilterFunctionBuilder 用于构建过滤器函数,通常与 FunctionScoreQuery 一起使用。它允许定义基于某些条件的过滤逻辑,以便在计算函数得分时对文档进行筛选。

使用 NativeSearchQueryBuilder 构建查询对象,将之前构建的 FunctionScoreQueryBuilder 作为查询条件,布尔查询作为过滤条件,并设置分页信息。然后,构建 NativeSearchQuery 对象,打印查询 DSL 语句,并通过 elasticsearchRestTemplate 执行查询,获取搜索结果。

在项目中使用 Elasticsearch 进行搜索,它相较于传统数据库(如 MySQL)在搜索方面的优势体现在哪里?

Elasticsearch 基于倒排索引,在全文搜索场景下性能远超传统数据库。比如在处理大量文本数据搜索时,MySQL 可能需要全表扫描,而 ES 能通过倒排索引快速定位到包含关键词的文档。
它还支持复杂的查询语法,像模糊查询、短语查询等,而传统数据库在处理这类复杂搜索时实现较为繁琐。
此外,ES 天生支持分布式,可以轻松扩展集群节点来应对海量数据和高并发请求,传统数据库在分布式扩展方面相对复杂且成本较高。

当项目面临高并发搜索请求时,如何优化 Elasticsearch 的性能?

首先是集群层面的优化,合理规划集群节点数量,根据业务量和数据量增加节点来分担负载。例如,对于读多写少的场景,适当增加副本数,让更多节点可以处理读请求。
其次,在索引设计方面,对经常用于搜索过滤的字段建立合适的索引,避免不必要的字段索引以减少索引空间和维护成本。
同时,利用 Elasticsearch 的缓存机制,如过滤器缓存、字段数据缓存等,减少重复查询的开销。
另外,在代码层面,对搜索请求进行合理的分页处理,避免一次性返回大量数据;对高频搜索的请求结果进行本地缓存(如使用 Guava Cache),减少对 Elasticsearch 的直接请求。

主分片负责写入和初始数据存储,副本分片是主分片的拷贝,仅用于读取和故障恢复。当某个主分片所在的节点出现故障时,其对应的副本分片可以被提升为主分片,继续提供服务,保证数据的可用性和系统的稳定性。

在项目中,如果 Elasticsearch 集群中的某个节点出现故障,系统是如何保证服务的可用性和数据完整性的?

Elasticsearch 集群采用主从复制机制,当主节点出现故障时,集群会通过选举机制从候选的从节点中选出新的主节点。
在数据完整性方面,每个分片都有对应的副本,当某个节点故障导致分片不可用时,会从其他节点上的副本分片继续提供服务。并且,在节点恢复后,Elasticsearch 会自动进行数据同步,确保节点上的数据与集群其他节点保持一致。例如,在故障节点恢复上线后,它会根据集群状态信息,从其他节点同步缺失的数据,保证数据的完整性,从而保证整个系统的可用性。

项目中是如何将商品数据存储到 Elasticsearch 的?

客户端发起 /importAll 的 POST 请求,然后从数据库获取所有商品数据保存到 Elasticsearch 中。

可以采用 Spring 的 @Async 注解异步执行,避免阻塞业务线程。

如何保证数据库和 Elasticsearch 中的一致性?

(1)数据库事务
可以在对应的 Service 层方法(比如调用 getAllEsProductList 方法的 importAll 方法)上添加 @Transactional 注解,确保在查询过程中如果出现异常,数据库操作能够回滚。这样可以保证查询到的数据是完整且一致的,避免因部分数据查询失败导致的数据不一致。
(2)实时同步
可以在数据库的相关表上添加触发器,当数据发生变化时,通过消息队列(如 Kafka 或 RabbitMQ)发送消息。然后在 ES 导入服务中监听这些消息,接收到消息后进行相应的数据更新或导入操作。

CREATE TRIGGER product_insert_trigger
AFTER INSERT ON pms_product
FOR EACH ROW
BEGIN
INSERT INTO product_insert_log (product_id, product_sn, insert_time)
VALUES (NEW.id, NEW.product_sn, NOW());
END;

(3)定时同步
可以在项目中添加定时任务,比如使用 Spring 的 @Scheduled 注解,定期调用 importAll 方法,将数据库中的数据全量同步到 ES 中。这样即使实时同步出现问题,也能保证 ES 中的数据最终与数据库一致。
(4)导入后校验
在数据导入 ES 完成后,可以通过对比数据库和 ES 中的数据记录数量、关键属性值等方式进行校验。可以编写专门的校验方法,定期检查 ES 中的数据是否与数据库一致。如果发现不一致,及时找出原因并进行修复,比如重新导入数据或手动调整 ES 中的数据。

4.引入 Spring Security 与 JWT 实现用户认证和授权,实现安全的登录注册和权限验证

介绍 jwt,以及在项目中是如何使用它的?

JSON Web Token(JWT)是一种用于在网络应用间安全传输信息的开放标准。它通常由三部分组成:Header(头部)、Payload(负载)和 Signature(签名),格式为 Header.Payload.Signature。

  • 头部通常包含两部分信息:令牌的类型(即 JWT)和使用的签名算法。
  • 负载部分包含声明,声明是关于实体(通常是用户)和其他数据的声明,项目中存放的是用户名和创建时间。
  • 要创建签名部分,需要使用编码后的 Header、编码后的 Payload、一个密钥(secret)和 Header 中指定的签名算法。

在项目中,JWT 主要用于用户身份验证和授权。用户登录时,服务器生成 JWT 令牌并返回给客户端,客户端在后续的请求中携带该令牌。服务器在接收到请求后,解析并验证令牌的有效性,从而确定用户的身份和权限。

如何处理 JWT 过期后的用户重新登录问题?

(1)前端提示并引导用户重新登录
前端在收到 JWT 过期的相关响应(如状态码为 401 Unauthorized)时,通过弹出提示框等方式告知用户登录已过期,如您的登录已过期,请重新登录。用户点击提示框中的确认按钮后,前端将页面重定向到登录页面,用户在登录页面输入用户名和密码等凭证进行重新登录。
(2)自动刷新令牌
在前端,当 JWT 快要过期时(例如设置一个提前刷新的时间阈值,如过期前 5 分钟),自动向后端发送一个刷新令牌的请求。后端接收到刷新请求后,验证请求的合法性和相关条件(如刷新令牌是否有效等)。如果验证通过,生成新的 JWT 并返回给前端。前端收到新的 JWT 后,用新令牌替换旧令牌,继续进行后续操作,而无需用户手动重新登录。

当客户端携带的旧 Token 未过期时,系统可以为其刷新 Token,避免用户频繁重新登录。但为了避免过于频繁地刷新 Token,系统会检查 Token 是否在指定时间内(如 30 分钟)刚刷新过,如果是则返回原 Token,否则更新 Token 的创建时间并生成新的 Token。

令牌泄露风险如何防范?

描述:令牌存储在客户端(如浏览器内存)被 XSS 攻击窃取,或通过网络嗅探获取。
可以让令牌通过 HttpOnly Cookie 存储(避免 XSS 窃取),或使用安全的 Header(如 Authorization: Bearer);并且启用 HTTPS 加密传输,防止中间人攻击。

令牌过期与刷新问题如何解决?

描述:长时效令牌被盗用,短时效令牌频繁刷新影响用户体验。
可以使用主令牌(Access Token)设置短过期时间(如 30 分钟),搭配刷新令牌(Refresh Token)存储于安全位置(如 HttpOnly Cookie)。刷新令牌逻辑是主令牌过期后,通过刷新令牌向认证服务器申请新的主令牌。
还可以利用 Redis 存储 token,当用户登录时,生成一个新的 token,并将其存储在缓存中。每次请求时,校验 token 是否有效。如果即将过期,则生成一个新的 token 并返回给客户端。

令牌伪造与篡改如何防范?

描述:攻击者修改 JWT 的 Payload(如用户角色)并重新签名。
签名密钥严格保密(私钥不泄露),Payload 中的敏感字段需通过服务端二次校验(如数据库查询用户角色)。
使用令牌黑 / 白名单(如 Redis 存储已撤销的令牌,每次请求校验令牌是否有效)。

如何实现 JWT 的主动注销(令牌失效)?

将注销的令牌存入 Redis 黑名单,设置与令牌过期时间一致的 TTL(如 30 分钟),每次请求校验令牌是否在黑名单中。

为什么推荐将 JWT 放在 Authorization: Bearer 头中,而不是其他位置(如请求体、Cookie 或 URL 参数)?它的安全性优势体现在哪些方面?

(1)对比 Cookie
Cookie 容易受到 CSRF(跨站请求伪造)攻击。攻击者可通过诱导用户访问恶意网站,利用浏览器自动携带 Cookie 的机制,冒用用户身份发起请求。因为当浏览器访问某个网站时,会自动将该网站的 Cookie 包含在 Cookie 请求头中发送给服务器。
虽然可以通过 HttpOnly 和 Secure 标记增强 Cookie 安全性,但 JWT 作为无状态令牌,本身不依赖 Cookie 存储,而放在请求头中可完全避免 CSRF 对令牌的直接利用(需配合其他 CSRF 防护措施,如验证码、SameSite 策略等)。
(2)对比 URL 参数
URL 会被浏览器、服务器日志记录,或通过 Referer 头泄露到其他网站(例如用户访问恶意链接时,浏览器会自动携带包含令牌的 Referer)。
请求头中的令牌不会出现在 Referer 中(现代浏览器默认不包含 Authorization 头),减少了敏感信息被第三方捕获的风险。
(3)对比请求体
请求体仅在 POST 等少数请求方法中存在,而 JWT 需在所有需要认证的请求(如 GET、PUT 等)中传递。
将令牌统一放在请求头中,符合 HTTP 协议的规范设计(认证信息属于请求元数据,而非业务数据),且更易于服务器统一解析和验证。

浏览器会根据域名自动携带匹配的 Cookie 到请求中(无论是否需要认证),这是浏览器的原生机制(例如访问 example.com 时,会自动发送该域名下的所有 Cookie)。但客户端必须显式地在请求头中添加 Authorization: Bearer。

项目中是怎么运用 Spring Security 的?

项目中使用一个基于 Spring Security 的配置类,主要用于配置 Web 应用的安全过滤链,以实现用户的身份认证和授权功能。具体配置了:

  • 配置不需要保护的资源路径:定义白名单 URL 路径,允许所有用户访问这些路径。
  • 允许跨域请求的 OPTIONS 请求:允许所有的 OPTIONS 请求,通常用于处理跨域请求的预检请求。
  • 配置身份认证和授权:配置任何请求都需要进行身份认证。
  • 关闭跨站请求防护和设置会话管理策略:关闭跨站请求伪造(CSRF)防护,并将会话管理策略设置为无状态,即不使用会话来管理用户状态。
  • 添加 JWT 认证过滤器:在 UsernamePasswordAuthenticationFilter 之前添加,用于处理 JWT 认证。
  • 添加动态权限过滤器:在 FilterSecurityInterceptor 之前添加,用于处理动态权限。

为什么不使用 Spring Security 默认的过滤器?

UsernamePasswordAuthenticationFilter 主要处理基于表单提交的用户名和密码的身份验证,它是为传统的表单登录方式设计的。而在使用 JWT 进行身份验证的场景中,身份验证信息是通过 JWT 传递的,不是通过表单提交的用户名和密码。
FilterSecurityInterceptor 主要根据静态配置的访问规则(如在配置文件中定义哪些角色可以访问哪些 URL)进行授权检查。然而,在一些复杂的应用场景中,权限规则可能是动态变化的,例如根据用户的业务角色、数据权限等实时决定用户是否有权限访问某个资源。
自定义 JWT 过滤器和动态权限校验过滤器是为了适应使用 JWT 进行身份验证和实现动态权限管理的需求,弥补了 Spring Security 默认过滤器的不足。

JWT 认证过滤器的流程讲一讲?

从请求头中获取名为 tokenHeader 的值,即包含 JWT 的请求头字段(即 Authorization),检查获取到的请求头是否存在且以 tokenHead 开头(即 Bearer),如果满足条件,则继续处理。从请求头中提取出实际的 JWT 令牌,从 JWT 令牌中解析出用户名,检查解析出的用户名是否不为空,并且当前的安全上下文中不存在已认证的用户。还需要验证 JWT 令牌的有效性。验证包括检查令牌是否过期、签名是否正确等。最后将将创建好的身份验证对象设置到当前的安全上下文中,表示用户已通过身份验证。

安全上下文的作用?

安全上下文会存储当前用户的身份验证信息,具体表现为一个 Authentication 对象。Authentication 对象包含了用户的详细信息,像用户名、密码(有些情况下可能不包含)、权限列表等。当用户成功登录或者通过身份验证(如使用 JWT 验证)后,会创建一个 Authentication 对象,并将其设置到安全上下文中。在进行授权检查时,Spring Security 会从安全上下文中获取 Authentication 对象,根据其中包含的权限信息来判断用户是否有权限访问某个资源。

谈谈你对线程安全和安全上下文的理解?

当一个请求被处理完成,通常会在 Filter 链的 doFilter 方法执行完毕后,Spring Security 自动清除 ThreadLocal 中的 SecurityContext。

SecurityContextHolder 提供了一种方式来管理安全上下文,确保在多线程环境下每个线程都有自己独立的安全上下文。默认情况下,它使用 ThreadLocal 来存储安全上下文,这样每个线程都可以独立地设置和获取自己的安全上下文,不会相互干扰。同时,在进行异步处理或者请求转发时,Spring Security 也会确保安全上下文能够正确地传递,保证在整个请求处理过程中都能获取到正确的身份验证和授权信息。
安全上下文使得整个应用程序都可以方便地获取当前用户的身份验证和授权信息。开发人员可以在代码的任何地方获取当前用户的 Authentication 对象,进而获取用户的详细信息和权限,进行业务逻辑的处理。

讲讲动态权限过滤器的流程?

如果请求既不是 OPTIONS 请求,路径又不在白名单中,那么就需要进行权限校验。这时,过滤器会去获取与这个请求相关的权限信息。权限信息存储在一个叫动态权限数据源的组件里,这个组件会保存路径和所需权限的对应关系。
动态权限数据源会先检查存储权限信息的容器是否为空。如果为空,它会从外部(比如数据库或其他数据源)加载权限数据。然后,它会根据当前请求的路径,在权限信息中查找匹配的记录,获取访问这个路径所需要的权限配置属性。
拿到请求路径所需的权限配置属性后,会把这些属性和当前用户的权限信息进行对比。用户的权限信息是在用户登录或认证时生成并存储的。权限决策管理器会遍历所需的权限配置属性,看用户是否拥有其中任何一个权限。
当权限校验通过后,请求会继续沿着过滤器链往后传递,让后续的过滤器或组件进行处理。处理完成后,权限过滤器还会进行一些清理工作,确保系统状态的正确。

权限校验过滤器是如何判断用户是否具有访问资源的权限的?

权限校验过滤器判断用户是否具有访问资源权限的过程如下:

  • 获取当前请求信息:过滤器首先会获取当前的请求对象,从中提取出请求的URL路径以及请求方法等信息。例如,对于一个 /api/user/123 的 GET 请求,会获取到路径 /api/user/123 和请求方法 GET。
  • 检查白名单:过滤器会将当前请求的 URL 与预先配置的白名单进行比对。如果匹配白名单中的某个路径,那么该请求会直接被放行,无需进行后续的权限校验。比如,白名单中配置了 /public/**,那么所有以 /public/ 开头的请求都会直接通过。
  • 获取动态权限规则:从 DynamicSecurityMetadataSource 中获取动态权限规则。这些规则通常是在系统启动时或运行时动态加载的,将 URL 路径与所需的权限进行了映射。例如, /admin/** 路径可能需要 ROLE_ADMIN 权限才能访问。
  • 获取用户的权限信息:通过 Authentication 对象获取当前登录用户的权限信息。这个对象包含了用户所拥有的角色或权限列表。例如,用户可能具有 ROLE_USER 等角色。
  • 比对权限:将访问当前资源所需的权限与用户拥有的权限进行比对。如果用户拥有的权限中包含了访问当前资源所需的权限,那么用户被允许访问该资源;如果用户没有所需的权限,那么会用户没有访问权限。

5.运用 RabbitMQ 处理异步任务和系统间通信,创建延时队列与死信队列,完成订单超时自动取消功能,实现订单处理、库存更新等业务的解耦和异步化,提升系统的响应速度和吞吐量;并结合定时任务实现消息补偿,防止消息队列处理失败

RabbitMQ 本身不支持延迟队列,那具体你说的延迟队列指的是什么?

给队列或消息设置 x-message-ttl(存活时间),消息过期后自动进入死信队列。死信队列绑定消费者,消费时即触发订单超时取消。

项目中是如何应用 RabbitMQ 的?

项目先配置了消息队列相关组件。创建了两个直连交换机,一个用于订单消息实际处理,另一个用于订单延迟消息处理。接着创建了两个队列,一个是订单实际处理队列(死信队列),另一个是订单延迟队列。订单延迟队列设置了特殊参数,当队列中的消息到达一定时间(过期)后,会自动转发到订单实际处理队列。并且把这两个队列分别和对应的交换机进行了连接,设置好了消息传递的路径(路由键)。
当用户在系统中生成订单时,系统会先进行一系列检查,比如检查收货地址是否选择、商品库存是否足够、优惠券和积分使用是否合理等。确认订单可以生成后,系统会在数据库中插入订单和订单商品的相关信息。然后,系统会获取订单超时的时间设置(比如规定订单在一定时间内未支付就自动取消),把这个时间换算成毫秒数作为延迟时间。系统通过消息发送组件,将订单的编号作为消息内容,发送到订单延迟队列对应的交换机。同时,给这条消息设置了之前计算好的延迟时间,这样消息就会在延迟队列中等待,直到延迟时间结束。
当订单延迟队列中的消息延迟时间到期后,这条消息会根据之前设置的参数,自动转发到订单实际处理队列(死信队列)。系统中有一个消息接收组件专门监听订单实际处理队列(死信队列)。当它从队列中接收到订单编号的消息后,会调用订单处理服务中的取消订单方法。取消订单方法会进行一系列操作,比如把订单状态修改为已取消,解除之前锁定的商品库存(让这些库存可以被其他订单使用),如果用户使用了优惠券,就把优惠券的使用状态改回未使用,方便用户下次使用,以及如果用户使用了积分,就把积分返还给用户。
通过以上流程,项目利用消息队列实现了订单在一定时间后自动取消的功能,保证了业务的正常运转和数据的准确性。

用户下单后,创建一个和订单相关的消息发送到交换机,交换机通过绑定键转发到绑定的消息队列。当该队列中的消息过期后,则会转发到配置的死信交换机当中,最终进入死信队列。由专门的消费者监听该队列,执行订单取消逻辑。

如何避免已付款订单被误取消?

在取消订单前,会先查询指定 orderId 且状态为未支付的订单,如果没有找到符合条件的订单,就直接返回,不会执行后续的取消操作。

项目中是如何防止消息被重复消费的?

当订单状态变为已取消后,后续相同的订单取消消息到来时,消费者通过检查订单状态,会忽略这些消息,避免了重复执行取消逻辑,保证了同一订单不会被重复取消,从业务角度实现了幂等性。
存在的局限性:

  • 并发问题:在高并发场景下,可能会出现多个消费者同时处理同一订单取消消息的情况。如果没有合适的并发控制机制,可能会导致多个消费者同时读取到订单未取消的状态,然后都执行取消逻辑,从而出现重复消费的问题。
  • 数据库更新延迟:数据库的更新操作可能会有一定的延迟,特别是在高并发写入的情况下。如果消费者在数据库更新订单状态还未完成时就进行查询,可能会错误地认为订单还未取消,从而再次执行取消逻辑。
  • 异常情况处理:如果在更新订单状态时出现异常,比如数据库连接中断、更新语句执行失败等,订单状态可能没有正确更新。后续相同的消息到来时,消费者仍然会认为订单未取消,从而重复执行取消逻辑。

改进建议:

  • 并发控制:可以使用数据库的悲观锁(如 SELECT … FOR UPDATE)或乐观锁(通过版本号字段)来保证同一时间只有一个消费者可以处理订单取消逻辑。
  • 添加唯一标识:为每条消息添加唯一标识,消费者在消费消息时,先检查该标识是否已经处理过。可以使用 Redis 等缓存来存储已处理的消息标识,提高查询效率。
  • 异常处理和重试机制:在更新订单状态时,要做好异常处理,确保即使出现异常也能正确记录日志和进行重试。可以设置重试次数上限,避免无限重试。

总之,最好的改进方式就是为消息添加唯一标识,当消费者在消费消息时,先检查该标识是否已经处理过。若没有被处理过,则处理并存储到 Redis 缓存中。

Java 自带的延迟队列有了解吗?换一种问法,为什么选择 RabbitMQ 而不选择原生延迟队列?

DelayQueue 是一个支持延时获取元素的无界阻塞队列,存储的元素必须实现 Delayed 接口。只有在延迟时间到达后,元素才能从队列中被取出。该队列采用优先队列(PriorityQueue)来实现,队列中的元素会按照延迟时间的长短进行排序,延迟时间短的元素排在前面。当尝试从队列中获取元素时,如果队首元素的延迟时间还未到达,线程会被阻塞,直到延迟时间结束。
由于它是无界队列,如果元素数量过多,可能会占用大量的内存。并且它不支持持久化,即队列中的元素存储在内存中,一旦程序崩溃或重启,队列中的元素会丢失。

DelayQueue 是 JVM 级别的本地队列,仅适用于单节点场景;而 RabbitMQ 作为分布式消息中间件,天然支持多节点部署、负载均衡和故障转移,适合分布式系统的高可用需求。并且,DelayQueue 基于内存存储,若应用重启或崩溃,未处理的延迟任务会丢失;RabbitMQ 支持消息持久化到磁盘,结合持久化队列和生产者确认机制,可确保延迟任务在系统故障后仍能恢复。

如何处理 RabbitMQ 消息丢失?结合持久化、确认机制和补偿机制说明?

(1)消息持久化

  • 队列持久化:queueDeclare(queueName, durable=true, …),队列重启后重建。
  • 消息持久化:MessageProperties.PERSISTENT_TEXT_PLAIN,消息写入磁盘。
  • 交换机持久化:exchangeDeclare(exchangeName, “direct”, durable=true),交换机配置保留。

(2)生产者确认机制

  • 开启生产者确认模式:channel.confirmSelect(),消息成功到达交换机后回调 onConfirm。
  • 失败处理:通过 correlationId 记录未确认消息,定期重试或写入日志。

(3)消费者手动确认

  • 关闭自动确认:channel.basicConsume(queueName, autoAck=false, …),处理成功后调用 basicAck。
  • 失败重试:basicNack(deliveryTag, false, requeue=true) 重新入队,或发送至死信队列。

(4)定时补偿机制
使用 Spring Task 定期扫描数据库中未处理的消息,重新发送至队列。

生产者到交换机,没收到确认则重试;消息队列到消费者,没收到确认则重新入队。

消费者端的自动确认机制为什么不行?

当自动确认时,消费者一旦接收到消息,RabbitMQ 会立即将消息标记为已确认并从队列中删除,无论消费者是否实际处理成功。
关闭自动确认后,需通过代码显式调用 channel.basicAck(deliveryTag, false)(成功处理)或 channel.basicNack(deliveryTag, false, requeue)(失败处理)。
确保只有消息被完整处理且业务逻辑执行成功后,才向 RabbitMQ 发送确认,避免因处理中途失败导致的消息丢失。

如果是自动确认的话,接收到消息就算确认了,而不会在意到底处理成功没。

消息爆满了怎么办?

(1)消息补偿机制:定时任务轮询数据库。
(2)生产者端限流:令牌桶算法防大量流量。
(3)消费者消费能力提升:批量处理消息以及配置线程池。

6.配置 Druid 连接池,管理和复用数据库连接,降低资源消耗,提升系统性能

Druid 核心组件有哪些?如何实现连接复用?

核心组件:连接池、连接管理器和监控组件等。
连接复用原理:通过维护空闲连接池,当请求释放连接时,将其标记为可用并放回池内,而非直接关闭,下次请求优先从池中获取。

项目中配置了 Druid 的哪些参数?分别是什么含义?

(1)initial-size: 5(连接池初始化大小)
系统启动时,连接池会预先创建 5 个数据库连接,避免首次请求时因创建连接产生延迟。
(2)min-idle: 10(最小空闲连接数)
连接池会确保至少保留 10 个空闲连接供后续请求复用。若空闲连接数少于 10,会自动创建新连接;若多于 10,会销毁多余连接。
(3)max-active: 20(最大连接数)
连接池能创建的最大连接数为 20(包括正在使用和空闲的连接)。当请求数超过 20 时,后续请求会排队等待(等待时间由 max-wait 控制,默认 60000 毫秒)。
(4)web-stat-filter(Web 监控过滤)
配置不统计指定路径的请求数据,减少监控日志量,提升性能。排除静态资源(.js, .css 等),这些请求通常不涉及数据库操作,无需监控。排除 /druid/*,避免监控页面自身的请求被统计。

7.集成 Swagger 进行 API 文档管理,方便团队内部的协作与沟通,降低接口对接成本

Java 编程(集合 & JVM & 并发)

1.集合

2.JVM

方法区、栈、堆存储内容及 TLAB 讲一讲?

(1)方法区:主要存储已被虚拟机加载的类信息(包括类的版本、字段、方法、接口等描述信息)、常量、静态变量、即时编译器编译后的代码等数据。在 JDK 8 及以后,方法区被元空间替代。
(2)栈:这里主要指 Java 虚拟机栈,是线程私有的。每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。局部变量表存放了编译期可知的各种基本数据类型(如 int、long 等)、对象引用(指向对象起始地址的引用指针)和 returnAddress 类型(指向了一条字节码指令的地址)。
(3)堆:是 JVM 中最大的一块内存区域,被所有线程共享。几乎所有的对象实例和数组都在堆上分配内存。堆是垃圾回收器主要管理的区域,根据对象的存活时间和大小,堆又可以细分为新生代(Eden 区、Survivor 区)和老年代。
(4)TLAB(Thread Local Allocation Buffer):即线程本地分配缓冲区,是堆内存中为每个线程分配的一块私有区域。目的是为了减少多线程情况下对象分配时的锁竞争。当一个线程需要分配对象时,首先会在自己的 TLAB 中分配,如果 TLAB 空间足够,对象直接在 TLAB 中分配,避免了同步操作,提高了对象分配的效率。

方法区到元空间发生了什么变化

在 JDK 8 之前,方法区是使用永久代来实现的,而 JDK 8 及以后,永久代被元空间替代,主要有以下变化:

  • 内存位置:永久代是堆的一部分,受堆内存大小的限制;而元空间使用的是本地内存,不再受 JVM 堆大小的限制,理论上只受限于系统的可用内存。
  • 内存管理:永久代需要进行垃圾回收,其大小需要在启动时进行配置,如果配置不当,容易出现内存溢出问题;元空间的内存管理由操作系统负责,当元空间中的类不再使用时,会自动进行回收,减少了内存溢出的风险。
  • 存储内容:虽然两者都主要存储类的元数据,但元空间更加灵活,并且对类的元数据存储进行了优化。

运行时常量池在 JDK 7 及之前位于方法区(永久代)、JDK 8 及之后移至元空间,字符串常量池在JDK 6 及之前位于方法区(永久代)、JDK 7 及之后移至堆中。

对象可不可以分配在栈中?

栈上分配是指将对象分配在栈上而不是堆上,这样对象会随着方法的结束而自动销毁,减少了垃圾回收的压力。触发栈上分配通常需要满足以下条件:

  • 逃逸分析:JVM 会对对象的作用域进行分析,如果一个对象在方法内部创建,并且不会逃逸到方法外部(即不会被其他方法引用或作为返回值返回),那么该对象就有可能在栈上分配。
  • 标量替换:如果对象可以被分解为基本数据类型(标量),并且这些标量可以直接在栈上分配,那么 JVM 会将对象替换为这些标量进行存储。

volatile 发挥了哪些作用?

volatile 关键字主要用于保证变量的可见性,即一个线程修改了被 volatile 修饰的变量的值,其他线程能够立即看到这个修改。其实现原理基于 JVM 的内存模型和硬件层面的内存屏障。

  • JVM 层面:当一个变量被声明为 volatile 时,JVM 会保证对该变量的写操作会立即刷新到主内存中,而读操作会直接从主内存中读取,而不是从线程的本地缓存中读取。
  • 硬件层面:在写 volatile 变量时,会插入一个写屏障(Store Barrier),强制将缓存中的数据刷新到主内存;在读 volatile 变量时,会插入一个读屏障(Load Barrier),强制从主内存中读取数据。

什么时候会自动指令重排,volatile 是怎么实现禁止的?

为了提高程序的执行效率,编译器和处理器会对指令进行重排序。指令重排分为三种类型:编译器优化重排指令级并行重排内存系统重排。在单线程环境下,指令重排不会影响程序的执行结果,但在多线程环境下,可能会导致程序出现错误。
volatile 关键字通过插入内存屏障来禁止指令重排。在写 volatile 变量之前,会插入一个写屏障,保证在写操作之前的所有普通写操作都已经完成,并且对其他处理器可见;在写 volatile 变量之后,会插入一个读屏障,保证在写操作之后的所有读操作都不会被重排到写操作之前。在读 volatile 变量之前,会插入一个读屏障,保证在读操作之前的所有读操作都不会被重排到读操作之后;在读 volatile 变量之后,会插入一个写屏障,保证在读操作之后的所有写操作都不会被重排到读操作之前。

垃圾回收算法及其对比?

(1)标记清除
先从根节点遍历标记所有存活对象,再扫描内存回收未标记的死亡对象。优点是实现简单、无需额外空间;缺点是回收后产生内存碎片,可能导致大对象分配失败,且标记和清除需两次全内存遍历,效率较低。现代 JVM 中较少单独使用,可能用于老年代部分阶段(如 CMS 算法)。
(2)标记复制
将内存划分为大小相等的 From 区和 To 区,先标记存活对象,再将其从 From 区复制到 To 区,最后清空 From 区。优点是无内存碎片、分配效率高,适合存活对象少的场景;缺点是内存利用率仅 50%,若存活对象多则复制成本高。常用于 JVM 新生代(如 Serial/ParNew 收集器),因新生代对象存活率低,复制代价小。
(3)标记整理
先标记存活对象,再将它们向内存一端移动,最后清除边界外的空闲内存。优点是无内存碎片、内存利用率高,适合存活对象多的场景;缺点是需移动对象并更新引用地址,复杂度和耗时较高。常用于 JVM 老年代(如 Serial Old 收集器),因老年代对象存活率高,避免标记复制的高复制成本。

标记复制是最快的。

JDK 8 的默认垃圾回收器是什么?

JDK 8 在 Server 模式下默认的垃圾回收器是 Parallel GC,它由 Parallel Scavenge(用于新生代)和 Parallel Old(用于老年代)组成。Parallel Scavenge 采用复制算法,将新生代划分为 Eden 区和两个 Survivor 区,新对象在 Eden 区分配,Minor GC 时将存活对象复制到空的 Survivor 区,多次回收后达到年龄阈值的对象晋升到老年代;Parallel Old 采用整理算法,先标记出老年代中存活的对象,再将它们向一端移动并连续存储,最后清理掉边界外的内存,以此解决内存碎片问题,整体以多线程并行方式运行,追求高吞吐量。

3.并发

线程池自定义拒绝策略如何实现?

实现 RejectedExecutionHandler 接口,该接口里定义了 rejectedExecution 方法,当线程池无法接受新任务时,就会调用此方法。你需要在这个方法里自定义任务的处理逻辑。随后将自定义拒绝策略应用到线程池,即创建线程池时,把自定义的拒绝策略当作参数传入。

为什么 ThreadLocal 的 Key 要设计为弱引用?

ThreadLocalMap 中的键是 ThreadLocal 对象的弱引用(WeakReference)。如果使用强引用,当 ThreadLocal 对象的外部引用被释放后,由于 ThreadLocalMap 中还持有该 ThreadLocal 对象的强引用,会导致 ThreadLocal 对象无法被垃圾回收,从而造成内存泄漏。而使用弱引用,当 ThreadLocal 对象的外部引用被释放后,ThreadLocalMap 中的弱引用会在下次垃圾回收时被回收,减少了内存泄漏的风险。

那为什么不把 ThreadLocal 的 Value 也设计为弱引用?

开发者使用 ThreadLocal 时,通常期望值在主动清除或线程结束前保持有效。若 Value 为弱引用,则当 Value 没有其他强引用时,可能被 GC 提前回收,导致线程在需要使用值时获取到null,违背 ThreadLocal 的设计初衷(如用于存储用户会话信息、事务上下文等需要稳定存在的值)。

开发框架(Spring & SpringBoot & SpringSecurity)

1.Spring

Spring 和 SpringBoot 的区别?

(1)设计目的

  • Spring:解决企业级应用开发复杂性,提供 IoC 和 AOP 等核心功能,实现组件松耦合。
  • Spring Boot:简化 Spring 应用搭建与开发,快速创建生产级 Spring 应用。

(2)配置方式

  • Spring:用 XML 文件或 Java 配置类手动配置,大型项目配置复杂。
  • Spring Boot:约定大于配置,自动配置,只需在 application.properties 等文件少量配置。

(3)项目搭建

  • Spring:手动添加依赖,配置构建工具和 Web 服务器,搭建过程复杂。
  • Spring Boot:用 Spring Initializr 快速搭建,内置 Web 服务器,可独立运行。

(4)开发效率

  • Spring:配置和依赖管理繁琐,初期搭建和修改配置耗时,易出错。
  • Spring Boot:减少配置工作,有热部署等工具,专注业务逻辑,开发效率高。

(5)应用场景

  • Spring:适用于需高度定制 Spring 框架的大型企业级应用。
  • Spring Boot:适合快速开发迭代的小型 Web 应用、微服务项目。

AOP 失效的场景有哪些?

(1)同类方法调用
Spring AOP 默认基于代理模式(JDK 动态代理或 CGLIB 代理),代理对象只能拦截外部对目标对象方法的调用。若在同一个类的方法里调用另一个有 AOP 增强的方法,这种调用不会经过代理对象,AOP 增强就不会生效。
(2)代理模式选择不当
JDK 动态代理基于接口,只能对实现了接口的类进行代理;CGLIB 代理基于继承,能对普通类进行代理。若目标对象没实现接口,却使用 JDK 动态代理,或者配置有误,AOP 就可能失效。

此外,AOP 注解的作用范围可能与预期不符。比如,@Transactional 注解在某些情况下会因为方法的访问修饰符、异常处理等因素而失效。而且若 AOP 相关的类没被正确加载到 Spring 容器中,或者类加载器不一致,也可能会导致 AOP 失效。

AOP 动态代理和字节码增强有什么区别?

字节码增强是在类加载的过程中,对字节码进行修改,从而实现 AOP 功能。字节码增强通常使用字节码操作库,如 AspectJ、Byte Buddy 等。与动态代理相比,字节码增强可以在类加载时就对类进行增强,而不需要在运行时创建代理对象,因此性能更高。但字节码增强的实现相对复杂,需要对字节码有一定的了解。

字节码增强性能更好,但相对复杂。

2.SpringBoot

SpringBoot 的 Bean 是怎么声明的?

(1)使用 @Component 及其衍生注解:@Component 是一个通用的注解,用于将一个类标记为 Spring 的组件,Spring 会自动扫描并将其注册为 Bean。@Service、@Repository、@Controller 是 @Component 的衍生注解,分别用于标记服务层、数据访问层和控制器层的类。
(2)使用 @Configuration 和 @Bean 注解:@Configuration 用于标记一个配置类,@Bean 用于在配置类中声明一个 Bean。

Bean 声明后底层是怎么加载到 Spring 容器里面的?

Spring Boot 中 Bean 加载到 Spring 容器的过程大致如下:

  • 扫描:Spring Boot 应用启动时,会自动扫描 @SpringBootApplication 注解所在包及其子包下的所有类,查找带有 @Component、@Service、@Repository、@Controller 等注解的类。
  • 解析:将扫描到的类进行解析,提取类的元数据信息,包括类的名称、属性、方法等。
  • 注册:将解析后的类信息注册到 Spring 容器的 BeanDefinitionRegistry 中,每个 Bean 对应一个 BeanDefinition 对象,该对象包含了 Bean 的定义信息,如 Bean 的类名、作用域、依赖关系等。
  • 实例化:在需要使用某个 Bean 时,Spring 容器会根据 BeanDefinition 对象创建 Bean 的实例。实例化过程可能涉及到构造函数注入、属性注入等。
  • 初始化:在 Bean 实例化完成后,会进行初始化操作,包括调用初始化方法、执行 AOP 增强等。
  • 使用和销毁:Bean 初始化完成后,可以在应用中使用。当应用关闭时,Spring 容器会销毁所有的 Bean 实例。

简单来说,就是应用启动时,会扫描被 @component 及其衍生注解标注的类,并将它们解析后注册到 Spring 容器中。当需要使用某个 Bean 时,Spring 容器会创建其实例并初始化。当应用关闭后,Spring 容器会销毁所有的 Bean 实例。

3.SpringSecurity

数据存储(MySQL & Redis)

1.MySQL

MVCC 解决了什么问题?

MVCC 是数据库管理系统中用于实现并发控制的一种常用技术,主要解决了读写冲突和写写冲突问题,在保证数据一致性的同时提升并发性能。
传统的锁机制(如读锁、写锁)在处理读写操作时可能导致阻塞,当事务对数据加写锁时,其他事务的读操作需等待锁释放,影响并发性能;若事务持有读锁,其他事务的写操作也需等待,可能引发性能瓶颈。
MVCC 通过为数据行维护多个版本(历史版本和当前版本),读操作无需加锁,直接访问数据的快照版本(某一时刻的稳定版本),而写操作则创建新的版本。
读操作不阻塞写操作,写操作也不阻塞读操作,极大提升了并发场景下的读写性能。

慢查询调优说一说?

(1)发现慢查询
开启数据库的慢查询日志功能,设置合适的时间阈值,比如超过 1 秒的查询就记录到日志中。之后定期分析日志,找出执行时间长的查询语句。
(2)分析慢查询
借助数据库的 EXPLAIN 或者 DESCRIBE 命令来查看查询语句的执行计划。能够了解数据库是如何执行查询的,包括表的读取顺序、使用的索引等信息。检查查询语句是否存在复杂的嵌套子查询、全表扫描、不必要的排序和分组等操作。
(3)优化查询语句
尽可能减少子查询和嵌套查询,将复杂查询拆分成多个简单查询,再在应用层进行数据处理和组合;避免全表扫描,即确保查询条件中使用到的列上有合适的索引,这样数据库就能利用索引快速定位到所需的数据。若排序和分组操作并非必要,可考虑去除,若必须进行,确保排序和分组的列上有索引;保证连接条件上有索引,避免使用 CROSS JOIN,尽量使用 INNER JOIN 和 LEFT JOIN。
(4)索引优化
依据查询语句的条件和排序需求,在经常用于过滤和排序的列上创建索引。不过要注意,索引并非越多越好,过多的索引会增加写操作的开销。当多个列经常一起用于查询条件时,可创建复合索引。但要注意复合索引的列顺序,将选择性高的列放在前面。随着数据的增删改,索引可能会变得碎片化,定期重建索引可以提高索引的性能。
(5)数据库配置优化
对数据库的内存分配参数进行调整,像 MySQL 的 innodb_buffer_pool_size 参数,它决定了 InnoDB 存储引擎使用的缓冲池大小,合理设置该参数可以减少磁盘 I/O;还可以根据服务器的硬件资源和实际业务需求,调整数据库的并发参数,如最大连接数、线程池大小等。
(6)数据库架构优化
针对大表,可采用表分区技术,把数据分散存储在多个文件或者磁盘上,以提高查询性能;当单库单表的数据量过大时,可考虑采用分库分表的架构,将数据分散到多个数据库或者表中;将高频访问的热数据与低频访问的冷数据分离存储,降低存储成本并提升查询效率;通过缓存热点数据减少数据库压力,降低响应延迟。将读操作与写操作分流到不同实例,避免单节点压力失衡。

优化慢查询需要结合业务特点进行量体裁衣。在小数据量场景,优先通过缓存和索引优化提升单库性能;中高负载场景下,引入读写分离以及垂直分库,减轻主库压力;在高并发场景下,采用水平分表,冷热分离,分布式中间件等,构建可扩展架构。

可重复读隔离级别下为什么单纯使用 MVCC 机制无法防止幻读?

幻读的本质是新增记录的可见性。MVCC 管理的是已有数据行的版本,但不跟踪数据间隙。而新插入的记录是一个全新的行,不属于任何历史版本,MVCC 无法通过版本链阻止其被查询到。事务的快照仅记录快照时刻已存在的数据,但无法预知后续插入的新数据。当新数据满足查询条件时,会被当前事务的后续查询捕获,导致幻读。

若要在 MVCC 基础上防止幻读,需额外引入间隙锁锁定查询条件对应的数据区间,阻止其他事务在该区间内插入新数据。

身份证号码和学号,谁来当主键?

在选择身份证号或学号作为索引时,需综合考量数据特性与业务场景:两者虽均能唯一标识学生,但身份证号存在明显局限性——长度较长导致索引存储成本高,且涉及隐私合规问题,同时其无序性会增加索引维护时的调整成本(如频繁分裂节点),影响写入性能。
相较之下,学号通常更短且具备业务有序性(如包含入学年份、院系编码),作为索引时在存储空间和范围查询上更具优势,甚至可在单一机构场景中作为主键使用。
不过实际开发中,更推荐采用自增主键:一方面规避了业务字段的变更风险(如学号回收、身份证号隐私泄露),另一方面自增特性天然有序,能显著优化索引插入效率,同时保留身份证号或学号作为唯一辅助索引,以平衡唯一性校验与业务查询需求。

简单介绍数据库事务

数据库事务是一组原子性的、一致的、隔离的、持久的数据库操作单元,用于保证数据的完整性和一致性。通俗来说,事务是将多个操作绑定在一起,要么全部成功执行,要么全部失败回滚,就像一个不可分割的整体。

MySQL 分页查询数据量过大怎么优化呢?

当使用 SELECT … LIMIT OFFSET, N 语句时,MySQL 会先扫描前 OFFSET + N 条记录,再丢弃前 OFFSET 条,仅返回后 N 条。当 OFFSET 很大(如 100 万)时,扫描行数可达 100 万 + N,导致 IO 压力剧增、内存消耗大,甚至触发临时表或文件排序。并且若查询条件未正确使用索引,会退化为全表扫描,进一步加剧性能问题。此外,大数据量分页可能涉及长事务或锁等待,影响并发性能。可以有以下几种解决方案:
(1)基于索引的优化(最常用)
利用主键或唯一索引的有序性,通过 WHERE 条件直接定位起始点,避免扫描无用数据。若查询仅需部分字段,可创建覆盖索引(包含查询字段和排序字段),避免回表查询。

-- 低效写法(扫描 OFFSET + N 条)
SELECT * FROM users LIMIT 1000000, 10;

-- 优化写法(扫描约 10 条)
SELECT * FROM users
WHERE id > (SELECT id FROM users LIMIT 1000000, 1)
LIMIT 10;

-- 创建覆盖索引(假设按更新时间排序)
ALTER TABLE users ADD INDEX idx_update_time (update_time) INCLUDE (name, email);

-- 优化查询(利用索引直接返回结果,无需回表)
SELECT name, email FROM users
ORDER BY update_time
LIMIT 1000000, 10;

(2) 延迟关联
通过子查询先定位主键,再关联查询具体字段,减少数据扫描量。

-- 低效写法(扫描大量数据并回表)
SELECT u.* FROM users u
ORDER BY update_time
LIMIT 1000000, 10;

-- 优化写法(先查主键,再回表)
SELECT u.* FROM users u
JOIN (
SELECT id FROM users
ORDER BY update_time
LIMIT 1000000, 10
) AS t ON u.id = t.id;

(3)键值对分页(适用于非递增排序)
若排序字段非递增(如 name),可记录上一页最后一条数据的排序值,通过 WHERE 条件过滤。

-- 上一页最后一条记录的 name 是 'ZhangSan'
SELECT * FROM users
WHERE name > 'ZhangSan'
ORDER BY name
LIMIT 10;

(4)物理分页(分库分表)
若数据量极大(如亿级),可通过分库分表将数据分散到多个实例,减少单表数据量。可以按时间(如按月分表)、按范围(如按 id 区间)或按哈希(如 MOD(id, 100))进行水平拆分。
(5)其他优化手段
避免 SELECT *,仅查询必要字段,减少数据传输和内存消耗;对高频分页结果使用 Redis 缓存,避免重复查询数据库。

什么是旁路缓存模式?

旁路缓存模式在应用程序和数据库之间引入了缓存层。当应用程序请求数据时,首先会检查缓存中是否存在所需数据。如果存在,就直接从缓存中获取,从而避免了访问数据库,这大大提高了数据访问速度。如果缓存中没有数据,应用程序才会去数据库中查询,查询到数据后再将其存入缓存,以便后续相同请求可以直接从缓存中获取。更新数据时,先更新数据库,然后删除缓存。

为什么说 binlog 不具备崩溃恢复的能力?redolog 为什么具备?

(1)binlog 不具有崩溃恢复特性的原因

  • 记录内容:binlog 主要记录的是数据库的逻辑操作,如插入、更新、删除等语句,它是基于语句或行的变更记录,不包含数据库内部的物理存储结构等详细信息。当数据库崩溃时,仅依靠这些逻辑操作记录很难准确地将数据库恢复到崩溃前的一致状态,因为它不知道数据在磁盘上的具体存储位置和状态。
  • 写入时机:binlog 是在事务提交时才将日志记录写入,这意味着在事务执行过程中,如果发生崩溃,未提交事务的 binlog 记录可能还未写入磁盘,从而导致部分数据丢失,无法完整恢复数据库。

(2)redolog 具有崩溃恢复特性的原因

  • 记录内容:redolog 记录的是数据库物理页面的更改,它记录了每个数据页的修改前后的值以及相关的操作信息。无论事务是否提交,只要数据页发生了修改,redolog 就会及时记录下来,这样即使数据库崩溃,也能根据 redolog 中的物理修改记录,将数据库恢复到崩溃前的状态。
  • 写入机制:redolog 采用了先写日志,后写数据的方式,并且在事务提交前,redolog 就已经被持久化到磁盘上。这保证了在数据库崩溃后,可以通过 redolog 来恢复未写入磁盘的数据,确保数据的一致性和完整性。

事务提交后 binlog 才写入,那事务执行时崩溃了,怎么可能根据 binlog 做恢复?

两阶段提交讲一讲呢?

redolog 和 binlog 的两阶段提交是为了保证数据库在事务提交时,这两种日志的一致性,从而确保数据的可靠性和完整性。以下是其具体过程:
(1)第一阶段:Prepare 阶段

  • 当事务开始时,数据库先将 redolog 记录写入磁盘,此时 redolog 中的记录处于 Prepare 状态,记录了事务对数据页所做的修改,但不代表事务已经提交。
  • 接着,数据库生成 binlog,但不会马上将其写入磁盘,而是先在内存中缓存。

(2)第二阶段:Commit 阶段

  • 如果事务执行成功,数据库会将 binlog 写入磁盘,完成持久化。
  • 然后,数据库将 redolog 中的记录状态从 Prepare 更新为 Commit,标志着事务正式提交。此时,即使数据库发生崩溃,在恢复时也能根据 redolog 中 Commit 状态的记录来确保数据的一致性,同时 binlog 也记录了完整的事务操作,可用于数据备份、主从复制等。

如果在两阶段提交过程中出现故障,例如在 Prepare 阶段后数据库崩溃,由于 binlog 未写入磁盘,在恢复时可以根据 redolog 中处于 Prepare 状态的记录来判断事务是否应该提交。如果能找到对应的完整 binlog 记录,则提交事务;否则,回滚事务,从而保证了 redolog 和 Binlog 的一致性。

索引下推

索引下推(ICP)是 MySQL 5.6+ 引入的查询优化技术,在扫描索引时将部分过滤条件(如模糊查询、范围条件等)下推到存储引擎层评估,仅返回满足条件的记录给服务器层,减少数据传输量与 I/O 开销,提升查询性能。其依赖合适索引生效,适用于范围查询、模糊查询等场景,但对复杂条件或无索引场景优化有限,通过减少服务器层处理负担实现效率提升。
假设存在一个数据表 employees,其中包含 emp_no、first_name、last_name 和 hire_date 等字段,并且在 (last_name, first_name) 上创建了复合索引。当执行如下查询语句时:

SELECT * FROM employees WHERE last_name LIKE 'J%' AND first_name LIKE '%son';
  • 传统查询方式:存储引擎根据索引找到 last_name 以 J 开头的所有记录,将这些记录返回给服务器层,服务器层再对这些记录进行过滤,筛选出 first_name 以son结尾的记录。
  • 索引下推优化方式:存储引擎在扫描索引时,不仅会检查 last_name 是否以 J 开头,还会检查 first_name 是否以 son 结尾。只有同时满足这两个条件的记录才会被返回给服务器层,减少了返回给服务器层的记录数量。

MySQL 高可用的策略

(1)主从复制

  • 原理:主从复制是 MySQL 中最基础的高可用策略。主服务器(Master)负责处理写操作,将变更记录到 binlog 中;从服务器通过 I/O 线程从主服务器获取二进制日志,并将其复制到本地的中继日志(Relay Log),然后 SQL 线程读取中继日志中的事件并在本地执行,从而实现数据的同步。
  • 优点:架构简单,易于实现;可以将读操作分散到多个从服务器上,提高系统的读性能;在主服务器出现故障时,可以将从服务器提升为主服务器,实现快速切换。
  • 缺点:存在一定的复制延迟,可能导致从服务器上的数据与主服务器不一致;主服务器成为写操作的瓶颈,无法扩展写性能。

(2)主主复制

  • 原理:主主复制是在主从复制的基础上,将两台服务器都配置为主服务器,它们之间可以相互复制数据。每台服务器既可以处理写操作,也可以处理读操作。
  • 优点:可以提高系统的写性能,因为写操作可以分散到两台服务器上;在一台服务器出现故障时,另一台服务器可以继续提供服务,实现高可用。
  • 缺点:配置和管理相对复杂,需要解决数据冲突的问题;仍然存在复制延迟的问题。

(3)半同步复制

  • 原理:半同步复制是在主从复制的基础上进行改进的一种复制方式。在主服务器执行写操作后,必须等待至少一个从服务器确认接收到该事务的二进制日志后,才会向客户端返回操作成功的信息。
  • 优点:可以减少数据丢失的风险,提高数据的安全性;在一定程度上保证了主从服务器之间的数据一致性。
  • 缺点:会增加事务的响应时间,因为主服务器需要等待从服务器的确认;如果从服务器出现故障,可能会影响主服务器的性能。

(4)MySQL Cluster

  • 原理:MySQL Cluster 是 MySQL 官方提供的一种高可用集群解决方案。它由多个节点组成,包括数据节点、管理节点和 SQL 节点。数据节点负责存储数据,管理节点负责集群的配置和管理,SQL 节点负责处理客户端的 SQL 请求。
  • 优点:具有高可用性和可扩展性,可以处理大量的并发请求;数据在多个节点上进行复制,保证了数据的安全性和一致性。
  • 缺点:配置和管理相对复杂,需要一定的技术水平;对硬件资源的要求较高,成本也比较高。

2.Redis

Redis 集群如何保证主节点和从节点的数据一致性?

Redis 集群通过异步复制和故障转移机制来保证主节点和从节点的数据一致性,具体如下:
(1)异步复制

  • 数据同步过程:主节点会将写操作命令异步地发送给从节点,从节点接收并执行这些命令,从而达到与主节点数据同步的目的。当一个写命令到达主节点时,主节点会在本地执行该命令并将其记录到本地的内存数据集和复制缓冲区中,然后将命令发送给所有的从节点。从节点接收到命令后,会将其存储到自己的复制缓冲区,并在适当的时候将命令应用到自己的数据集上。
  • 优点:异步复制的方式可以保证主节点在处理写操作时不会被从节点的复制过程所阻塞,从而提高了系统的写入性能和吞吐量。
  • 缺点:由于是异步操作,如果主节点在将写命令发送给从节点之前就发生了故障,那么可能会导致部分数据丢失,造成数据不一致。

(2)部分复制与无盘复制

  • 部分复制:在网络中断等情况下,从节点重新连接主节点时,主节点只会将从节点断开期间缺失的那部分数据发送给从节点,而不是重新发送所有数据,这有助于减少数据传输量,提高数据恢复的效率,保证数据的一致性。
  • 无盘复制:主节点直接通过网络将内存中的数据发送给从节点,而不需要先将数据持久化到磁盘上,避免了磁盘 I/O 操作,提高了复制的速度,尤其在大规模数据同步时能减少数据不一致的时间窗口。

(3)故障转移

  • 节点状态监测:集群中的节点会定期互相发送心跳消息,以监测其他节点的状态。如果一个从节点发现主节点出现故障(例如,在一定时间内没有收到主节点的心跳消息),它会向集群中的其他节点发送投票请求,请求成为新的主节点。
  • 选举新主节点:集群中的其他节点在收到投票请求后,会根据一定的规则进行投票。如果一个从节点获得了足够数量的选票(通常是超过集群中节点总数的一半),它就会被选举为新的主节点。
  • 数据恢复与同步:新的主节点会继续处理客户端的请求,同时其他从节点会自动调整与新主节点的连接,开始从新主节点进行数据同步,以保证整个集群的数据一致性。

(4)一致性保证机制

  • 多数派确认:可以采用多数派确认的方式来保证数据的一致性。即主节点在将写操作发送给从节点后,需要等待一定数量(通常是超过半数)的从节点确认收到写操作后,才认为该写操作成功。这种方式可以确保在主节点发生故障时,数据已经被复制到了多数节点上,从而减少数据丢失的可能性,提高数据一致性。
  • 复制积压缓冲区:主节点会维护一个复制积压缓冲区,用于存储最近一段时间内的写操作命令。当从节点因为网络等原因暂时落后于主节点时,主节点可以从复制积压缓冲区中获取相应的命令发送给从节点,以帮助从节点快速追赶主节点的数据,保证数据的一致性。

Redis中的热键如何应对?

Redis 4.0 及以上版本可使用 redis-cli –hotkeys 命令,该命令能扫描并找出当前实例里的热键。

(1)负载均衡
把一个热键拆分成多个子键,把请求分散到不同的子键上。
(2)缓存预热
在系统启动或者业务低峰期,提前把热键的数据加载到 Redis 中,以此减少在业务高峰期因热键缓存缺失而产生的大量请求。
(3)本地缓存
在应用服务器端使用本地缓存(如 Guava Cache、Caffeine 等)来缓存热键的数据,这样部分请求就能直接从本地缓存获取数据,从而减轻 Redis 的压力。
(4)多实例
把热键分散到多个 Redis 实例中。可以借助客户端的分片算法或者使用 Redis Cluster 实现。
(5)限流
对热键的访问进行限流,防止过多的请求对 Redis 造成过大压力。可以使用令牌桶算法或者漏桶算法来实现限流。

set NX 分布式锁是可重入的吗?

setnx命令用于在 Redis 中实现分布式锁。当一个客户端通过 setnx 成功获取锁后,其他客户端再执行 setnx 命令会返回失败,从而达到互斥的效果。然而,这种方式没有记录锁的持有线程信息,当持有锁的客户端再次请求锁时,setnx 会认为是一个新的请求,因为它仅根据键是否存在来判断锁是否可获取,所以无法识别是同一个客户端的重入请求,故不支持可重入性。
要实现可重入的分布式锁,需要在锁的实现中记录锁的持有者信息以及重入次数等,比如使用 Lua 脚本结合 Redis 的 hash 数据结构来实现。

Redis 为什么比 MySQL 快呢?

Redis 比 MySQL 快的核心原因在于数据存储与访问机制的差异:Redis 是内存数据库,数据直接存储在内存中,读写操作均通过内存寻址完成,省去了磁盘 I/O 的耗时(内存访问速度比磁盘快数百万倍);而 MySQL 作为磁盘数据库,数据主要存储在磁盘,复杂查询需经过磁盘读取、解析、计算等流程,受机械磁盘或 SSD 的物理性能限制。此外,Redis 采用单线程非阻塞 I/O 模型高效的数据结构(如哈希表、跳表),减少了线程上下文切换开销,进一步提升响应速度,尤其适合高频读、简单写的场景。

基础知识与中间件(操作系统 & 计算机网络 & RabbitMQ)

1.操作系统

谈谈你对缺页中断的理解

在虚拟内存机制里,进程的地址空间被划分成多个页面,这些页面可以存于物理内存(RAM)或者磁盘的交换空间中。当进程访问的页面不在物理内存时,就会产生缺页中断,这时操作系统需要将该页面从磁盘调入物理内存。

  • 保护现场:出现缺页中断时,CPU 暂停当前进程的执行,保存当前进程的上下文信息,像程序计数器、寄存器的值等。
  • 查找页面:操作系统依据产生缺页中断的虚拟地址,查找该页面在磁盘上的位置。
  • 分配物理内存:若物理内存有空闲页框,就分配一个页框;若没有,就采用页面置换算法选择一个页面换出,再分配页框。
  • 页面调入:操作系统把所需页面从磁盘读入分配好的物理页框。
  • 更新页表:更新页表项,让该页面的虚拟地址映射到新分配的物理页框。
  • 恢复现场:恢复被中断进程的上下文信息,重新执行引发缺页中断的指令。

用户态和内核态

用户态和内核态是操作系统通过 CPU 特权级别实现的安全机制:

  • 用户态:运行普通程序,受限访问资源,通过系统调用请求内核服务。
  • 内核态:运行操作系统内核,拥有最高权限,直接管理硬件和资源。

通过中断、异常或系统调用实现切换,确保系统稳定与安全。

僵尸进程和孤儿进程

(1)僵尸进程
在操作系统里,僵尸进程是一种特殊的进程状态。当一个子进程先于父进程结束运行时,它会向父进程发送 SIGCHLD 信号,并且自身会释放大部分占用的资源,但仍保留进程描述符等少量信息。如果父进程没有调用 wait() 或者 waitpid() 等函数来获取子进程的退出状态并回收这些剩余资源,那么子进程就会变成僵尸进程。僵尸进程虽然不占用太多系统资源,但是其进程描述符会一直占据内核空间。如果大量僵尸进程存在,会导致系统的进程表资源被耗尽,从而无法创建新的进程。
(2)孤儿进程
孤儿进程同样是一种特殊的进程情况。当父进程比子进程先结束运行时,子进程就会变成孤儿进程。因为此时子进程失去了原本的父进程,为了保证子进程能够继续正常运行,操作系统会将孤儿进程的父进程设置为 init 进程(在现代 Linux 系统中通常是 systemd)。init 进程会负责在孤儿进程结束运行后,调用 wait() 系列函数来回收其资源,所以孤儿进程本身不会像僵尸进程那样造成系统资源浪费,一般也不会对系统产生不良影响。

多级反馈队列调度的核心优势?

  • 快速响应交互请求:高优先级队列 + 小时间片,优先调度短进程和 I/O 密集型进程。
  • 高效处理长进程:低优先级队列 + 大时间片,减少调度切换,提升 CPU 利用率。
  • 动态优先级调整:根据进程行为(如运行时间、I/O 操作)自动调整优先级,平衡公平性与效率。

I/O 多路复用

I/O 多路复用是一种在单线程或单进程环境下,同时监控多个 I/O 事件的技术,能显著提升系统的并发处理能力。
I/O 多路复用通过一个机制,让单个线程或进程可以同时监听多个文件描述符(可以理解为对各种 I/O 设备的一种抽象表示,如网络套接字、文件等)的状态变化。当其中任何一个或多个文件描述符的状态发生了感兴趣的变化(例如可读、可写)时,系统会通知应用程序进行相应的处理。
(1)select
select 是最早的 I/O 多路复用技术之一。它会维护一个文件描述符集合,应用程序将需要监听的文件描述符添加到这个集合中,然后调用 select 函数。select 函数会阻塞,直到集合中至少有一个文件描述符的状态发生了变化,或者超时。之后,应用程序需要遍历整个集合,找出状态发生变化的文件描述符并进行处理。
缺点:支持的文件描述符数量有限(通常为 1024),每次调用 select 时都需要将文件描述符集合从用户空间复制到内核空间,并且在返回时需要遍历整个集合来查找就绪的文件描述符,效率较低。
(2)poll
poll 是对 select 的改进,它同样会维护一个文件描述符集合,但使用了一个更灵活的数据结构(pollfd 数组)来表示文件描述符和对应的事件。poll 函数的工作方式与 select 类似,会阻塞直到有文件描述符的状态发生变化。这种方式没有文件描述符数量的限制,解决了 select 在这方面的局限性。
缺点:仍然需要将文件描述符集合在用户空间和内核空间之间复制,并且在返回时需要遍历集合来查找就绪的文件描述符。
(3)epoll
epoll 是 Linux 特有的 I/O 多路复用技术,它使用事件驱动的方式来处理 I/O 事件。epoll 通过创建一个 epoll 实例,将需要监听的文件描述符添加到这个实例中,并为每个文件描述符指定感兴趣的事件。当有文件描述符的状态发生变化时,epoll 会自动将这些事件通知给应用程序。这种方式采用了内核事件表,避免了文件描述符集合在用户空间和内核空间之间的频繁复制;使用回调机制,当文件描述符的状态发生变化时,会自动触发相应的回调函数,无需遍历整个集合,因此在处理大量并发连接时性能非常高。

介绍一下虚拟内存?

虚拟内存是操作系统为每个进程抽象出的一片逻辑地址空间,它使进程无需关心物理内存的实际布局,直接通过虚拟地址访问数据。操作系统通过地址转换机制(如页表)将虚拟地址映射到物理内存或外存中的实际地址。
(1)突破物理内存限制
允许运行内存需求超过物理内存容量的程序。例如:物理内存 8GB 的系统可同时运行多个总内存需求为 20GB 的程序,通过虚拟内存将不常用的数据暂存到外存。
(2)进程隔离与保护
每个进程拥有独立的虚拟地址空间,彼此隔离,避免进程间数据非法访问或冲突(如一个进程崩溃不会影响其他进程)。
(3)内存管理优化
通过分页(Paging)技术将内存划分为固定大小的页(Page,如 4KB),外存划分为页帧(Page Frame),提高内存分配的灵活性和利用率。

2.计算机网络(Http、UDP、TCP、Https、DNS)

HTTP 常见的状态码?

(1)1xx 信息性状态码
表示服务器已接收请求,正在处理中。

  • 100 Continue:客户端发送了请求的一部分,服务器确认继续处理剩余部分。
  • 101 Switching Protocols:服务器切换协议(如从 HTTP 切换到 WebSocket)。

(2)2xx 成功状态码
表示请求已成功处理。

  • 200 OK:请求成功,返回预期结果。
  • 201 Created:请求已创建新资源(如 POST 提交数据后)。
  • 202 Accepted:请求已接受但尚未处理完成(异步处理场景)。
  • 204 No Content:请求成功但无返回内容(常用于删除操作)。

(3)3xx 重定向状态码
表示需要进一步操作以完成请求。

  • 301 Moved Permanently:资源永久移动,后续请求应使用新 URL。
  • 302 Found:资源临时移动,临时使用新 URL(HTTP 1.1 中已更名为 307 Temporary Redirect)。
  • 303 See Other:请求的响应需通过另一个 URL 获取(常用于 POST 后跳转至 GET 页面)。
  • 304 Not Modified:资源未修改,可直接使用缓存(优化请求响应)。

(4)4xx 客户端错误状态码
表示客户端请求有误。

  • 400 Bad Request:请求语法错误或参数无效。
  • 401 Unauthorized:未授权,需提供身份验证(如 Token、Cookie)。
  • 403 Forbidden:服务器拒绝请求(权限不足,如无访问资源的权限)。
  • 404 Not Found:请求的资源不存在。
  • 405 Method Not Allowed:请求方法不被允许(如对 GET 接口使用 POST)。
  • 408 Request Timeout:服务器等待请求超时。
  • 413 Payload Too Large:请求体过大,超出服务器限制。

(5)5xx 服务器错误状态码
表示服务器处理请求时发生错误。

  • 500 Internal Server Error:服务器内部错误(最常见的通用错误)。
  • 501 Not Implemented:服务器不支持请求的功能(如未实现的方法)。
  • 502 Bad Gateway:代理服务器从上游服务器收到无效响应(如后端服务故障)。
  • 503 Service Unavailable:服务器暂时不可用(如过载、维护中)。
  • 504 Gateway Timeout:代理服务器等待上游服务器响应超时。

GET 请求和 POST 请求的区别?

(1)语义差异

  • GET 请求:主要用于从服务器获取资源。例如,当你在浏览器地址栏输入网址查看网页,或者在搜索引擎输入关键词进行搜索时,浏览器通常会发起 GET 请求。它的语义是请求服务器返回特定资源,如 HTML 页面、图片、JSON 数据等。
  • POST 请求:一般用于向服务器提交数据,例如用户注册、登录、提交表单等场景。它的语义是将数据发送到服务器,可能会导致服务器上资源的创建、更新等操作。

(2)参数传递方式

  • GET 请求:请求参数会附加在 URL 的后面,通过 ? 分隔,多个参数之间用 & 连接。例如:example.com/api?name=John&age=25。这种方式使得参数在 URL 中可见,不利于传递敏感信息。
  • POST 请求:请求参数通常放在请求体中,不在 URL 中显示。例如,在表单提交时,表单数据会被封装在请求体里发送给服务器。这使得 POST 请求更适合传递敏感信息,如密码、信用卡号等。

(3)安全性

  • GET 请求:由于参数暴露在 URL 中,安全性较低。这意味着参数可能会被浏览器历史记录、服务器日志等记录下来,容易导致信息泄露。此外,恶意用户还可能通过修改 URL 参数来发起恶意请求。
  • POST 请求:参数放在请求体中,相对更安全一些。但如果没有进行适当的加密和验证,仍然存在安全风险。例如,请求体可能会被中间人截获和篡改。

(4)数据长度限制

  • GET 请求:URL 的长度是有限制的,不同浏览器和服务器对 URL 长度的限制不同,但一般来说,GET 请求能携带的参数长度是有限的。如果参数过多或过长,可能会导致请求失败。
  • POST 请求:对请求体的长度没有严格的限制,理论上可以发送大量的数据。不过,服务器可能会对请求体的大小进行限制,以防止恶意用户发送过大的请求。

(5)缓存机制

  • GET 请求:通常会被浏览器缓存,这意味着如果多次发起相同的 GET 请求,浏览器可能会直接从缓存中获取响应结果,而不会再次向服务器发送请求。这可以提高页面的加载速度,但也可能导致获取到旧的数据。
  • POST 请求:默认情况下不会被浏览器缓存,每次请求都会向服务器发送数据并获取最新的响应结果。

(6)幂等性

  • GET 请求:具有幂等性,即多次执行相同的 GET 请求所产生的效果是相同的,不会对服务器上的资源产生额外的影响。例如,多次请求同一个网页,网页内容不会因为请求次数的增加而改变。
  • POST 请求:一般不具有幂等性,因为每次执行 POST 请求可能会导致服务器上资源的创建、更新等操作,多次执行可能会产生不同的结果。例如,多次提交注册表单可能会创建多个用户账号。

(7)应用场景

  • GET 请求:适用于获取数据的场景,如查询商品信息、获取文章列表等。由于其具有缓存特性,对于不经常变化的数据,使用 GET 请求可以提高性能。
  • POST 请求:适用于提交数据的场景,如用户注册、登录、提交订单等。因为它可以安全地传递大量数据,并且可能会对服务器上的资源产生影响。

HTTP 请求报文的格式?

HTTP 请求报文和响应报文均由起始行、头部字段、空行和消息主体(可选)四部分组成。
(1)起始行(在请求报文中是请求行)
包括请求方法,比如 GET、POST、PUT 等。以及请求 URI 和 HTTP 版本。以下是一个示例:

GET /index.html HTTP/1.1

(2)头部字段(在请求报文中是请求头)
包含客户端环境、请求参数等信息。其中,host 字段是必选的。以下是一个示例:

Host: example.com
User-Agent: Mozilla/5.0 Chrome/110.0.0.0
Accept: text/html,application/xhtml+xml

(3)空行
必须存在,用于分隔头部字段和消息主体。
(4)可选的消息主体(在请求报文中是请求体)
仅 POST、PUT 等提交数据的请求需要,GET 请求通常无主体。如表单数据(key=value&key2=value2)、JSON 数据({“name”:”John”})等。

HTTP 响应报文的格式?

HTTP 请求报文和响应报文均由起始行、头部字段、空行和消息主体(可选)四部分组成。
(1)起始行(在响应报文中是响应行)
包括HTTP 版本、状态码以及状态描述。以下是一个示例:

HTTP/1.1 200 OK

(2)头部字段(在响应报文中是响应头)
包含服务器信息、响应数据描述等。其中,Content-Type 是必选的。以下是一个示例:

Server: Apache/2.4.34
Content-Type: application/json; charset=utf-8
Content-Length: 128

(3)空行
与请求报文相同,用于分隔头部和主体。
(4)可选的消息主体(在响应报文中是响应体)
对于成功响应,一般是返回的资源数据,如 HTML 页面、JSON 结果、图片二进制数据等。以下是一个示例:

{
"status": "success",
"data": "Hello, World!"
}

Http 和 Https 的区别?

(1)Http(端口号 80)
明文传输数据,不提供加密。所以数据易被窃听、篡改或伪造,无法验证通信方身份。
(2)Https(端口号 443)
通过加密(对称 / 非对称加密结合)、数字证书认证和完整性校验确保数据安全。

搜索引擎将 HTTPS 视为网站安全的重要指标,同等条件下 HTTPS 网站可能获得更高的搜索排名。

Https 四次握手过程?(简单版)

第一次握手:客户端向服务器发送 ClientHello 消息,包含客户端支持的 SSL/TLS 版本、加密算法列表、压缩方法等信息。
第二次握手:服务器收到 ClientHello 后,发送 ServerHello 消息,选择双方都支持的 SSL/TLS 版本、加密算法等,同时发送服务器的数字证书。
第三次握手:客户端验证服务器证书的合法性,生成一个随机的预主密钥,用服务器的公钥加密后发送给服务器,通知服务器后续将使用协商好的加密算法进行通信。
第四次握手:服务器用自己的私钥解密得到预主密钥,然后双方根据预主密钥和之前的随机数生成会话密钥。

客户端是如何判断证书可靠的?

客户端收到服务器的数字证书后,首先检查证书的格式是否正确,是否在有效期内。然后验证证书的签名,客户端内置了 CA 的根证书公钥,用此公钥验证证书上 CA 的签名是否有效。还会检查证书中的域名是否与访问的域名一致,以及证书是否被吊销等。通过这些步骤来判断证书是否可靠。

客户端收到证书后,用 CA 的公钥对签名进行解密,得到证书信息的摘要,再通过对证书内容计算摘要并与解密得到的摘要对比,若一致则说明证书未被篡改且是由该 CA 签发的,从而验证了证书的合法性。

Https 常见的加密算法?

对称加密算法:如 AES(高级加密标准),具有高效、安全的特点,被广泛应用于 HTTPS 中对数据的加密。它使用相同的密钥进行加密和解密,加密速度快,适合大量数据的加密。
非对称加密算法:RSA 是一种经典的非对称加密算法,用于在 HTTPS 中实现密钥交换和数字签名,它基于大整数分解难题,安全性较高。ECC(椭圆曲线加密算法)也是常用的非对称加密算法,与 RSA 相比,在相同的安全强度下,ECC 的密钥长度更短,计算效率更高,逐渐得到广泛应用。
哈希算法:如 SHA-1、SHA-256 等,用于对数据进行哈希运算,生成固定长度的哈希值,在 HTTPS 中用于验证数据的完整性和数字签名。

数字证书的作用以及 CA 的作用?

(1)数字证书的作用

  • 身份认证:数字证书绑定了服务器或用户的身份信息(如域名、公钥等),客户端通过验证证书,可以确认与之通信的服务器或用户的身份是否真实可靠,防止假冒身份的攻击。
  • 数据加密:数字证书中包含公钥,可用于加密数据。客户端向服务器发送数据时,用服务器的公钥加密,只有拥有对应私钥的服务器才能解密,保证了数据在传输过程中的保密性。
  • 完整性校验:数字证书经过 CA 签名,包含证书内容的摘要信息。通过验证签名和摘要,可确保证书内容未被篡改,保证了数据的完整性。

(2)CA 的作用

  • 颁发证书:CA 负责对申请数字证书的实体进行严格的身份验证,确认其身份真实合法后,为其颁发数字证书,将实体的身份信息与公钥等内容绑定在证书中。
  • 数字签名:CA 使用自己的私钥对数字证书进行签名,使证书具有不可否认性和权威性。客户端通过内置的 CA 根证书公钥来验证证书签名的有效性,从而信任证书所绑定的身份。
  • 证书管理:CA 还负责证书的更新、吊销等管理工作。当证书即将到期或实体的身份信息发生变化时,CA 会为其更新证书;当证书对应的实体出现问题(如私钥泄露)时,CA 会吊销证书,使证书失效,防止被非法使用。

输入一个 URL 并回车,会发生什么?

(1)解析 URL:浏览器首先会对输入的 URL 进行解析,将其分解为协议、域名、端口号、路径(指定服务器上的文件路径)、查询参数等部分。
(2)检查缓存:浏览器会依次检查浏览器缓存、操作系统缓存、路由器缓存和 ISP 缓存,看是否有请求的 URL 对应的资源。如果缓存有效且未过期,浏览器会直接使用缓存资源,跳过后续步骤。
(3)DNS 解析:如果缓存中没有域名对应的 IP 地址,浏览器会发起 DNS 查询。首先向本地 DNS 服务器发起递归查询,如果本地 DNS 服务器缓存中没有命中,则会依次向根域名服务器、顶级域名服务器和权威域名服务器发起迭代查询,最终获取目标域名的 IP 地址,并将其返回给浏览器。
(4)建立 TCP 连接:浏览器获取到目标服务器的 IP 地址后,会与服务器的指定端口(默认为 80 或 443)进行 TCP 连接,通过三次握手来建立连接,确保客户端和服务器之间的数据传输可靠。如果是 HTTPS 协议,还需要在 TCP 连接建立后进行 TLS 握手,以实现加密通信。
(5)发送 HTTP 请求:TCP 连接建立后,浏览器会向服务器发送 HTTP 请求。请求内容包括请求行、请求头(包含浏览器信息、缓存策略、Cookie等)和请求体(如果是 POST 请求,可能包含表单数据或文件)。
(6)服务器处理请求:服务器接收到 HTTP 请求后,会对请求进行解析,提取出请求方法、URL、协议版本等信息,检查请求的语法是否正确,请求资源是否存在,然后调用相应的后端应用程序或者脚本语言来生成响应内容。
(7)返回 HTTP 响应:服务器将生成的 HTTP 响应发送给浏览器,响应内容包括状态行、响应头和响应体(HTML、CSS、JavaScript 等资源)。
(8)浏览器渲染页面:浏览器接收到响应后,开始渲染页面。首先解析 HTML 文件构建 DOM 树,解析 CSS 文件构建 CSSOM 树,然后将 DOM 树和 CSSOM 树合并为渲染树,计算每个元素的位置和大小,最后将渲染树绘制到屏幕上。如果页面中包含 JavaScript,浏览器也会解析并执行。
(9)加载其他资源:在渲染过程中,浏览器可能会发现需要加载其他资源,如图片、CSS、JavaScript 文件等,此时会向服务器发起资源请求,接收服务器返回的资源,并根据新资源更新页面渲染。
(10)页面加载完成:当所有资源加载完毕,页面渲染完成后,浏览器会触发 DOMContentLoaded 和 load 事件,表示页面加载完成。此时,用户可以看到并与页面进行交互。

DNS 查询过程?

(1)浏览器先查缓存
浏览器首先检查自身的 DNS 缓存。
(2)系统查本地 DNS 缓存
若浏览器缓存未命中,操作系统会查询本地 DNS 缓存(如 Windows 的系统缓存或 hosts 文件)。
(3)浏览器向本地 DNS 服务器发送请求
浏览器向本地 DNS 服务器发送一个递归查询请求,本地 DNS 服务器会全程负责查询,浏览器只需等待结果。
(4)本地 DNS 服务器查自身缓存
本地 DNS 服务器先检查自己的缓存和记录。若有有效记录,直接返回给浏览器。若无记录,则需通过迭代查询向上级服务器逐级查询。
(5)本地 DNS 服务器查询根域名服务器
本地 DNS 服务器向根域名服务器发送请求,询问目标域名对应的顶级域名服务器地址。根服务器返回对应顶级域名服务器的地址。
(6)本地 DNS 服务器查询顶级域名服务器
本地 DNS 服务器向获取到的顶级域名服务器发送请求,询问对应的权威域名服务器地址。顶级域名服务器返回权威服务器地址。
(7)本地 DNS 服务器查询权威域名服务器
本地 DNS 服务器向权威域名服务器发送请求,获取目标域名的具体 IP 地址。
(8)本地 DNS 服务器缓存结果并返回给浏览器
本地 DNS 服务器将解析到的 IP 地址存入自身缓存,并将结果返回给浏览器。至此完成 DNS 解析,后续使用IP地址发起网络请求。

简单概述,浏览器先查缓存,缓存没有就去问本地 DNS 服务器,本地 DNS 服务器发现自己也没缓存过,于是迭代查询根、顶级、权威 DNS 服务器最终获得 IP 地址并缓存,然后返回给浏览器。

TCP 和 UDP 的简单对比?(可靠性优选 TCP,实时性优选 UDP)

特性 TCP UDP
连接方式 面向连接(三次握手 / 四次挥手) 无连接
可靠性 可靠(重传、流量控制、拥塞控制) 不可靠(无重传、无拥塞控制)
延迟 / 效率 高延迟、低效率(开销大) 低延迟、高效率(开销小)
数据单位 字节流(无边界) 数据报(有边界)
头部大小 20 ~ 60 字节 8 字节
典型应用 HTTP、FTP、邮件、远程登录 直播、游戏、DNS、VoIP

TCP 头部格式?

TCP 头部是 TCP 数据包的关键组成部分,包含了控制数据传输的核心信息。标准的 TCP 头部长度为 20 字节(不包含可选字段),当包含可选字段时,头部长度最多可达 60 字节。以下是 TCP 头部的主要字段及其功能:

  • 源端口号(16 位):标识发送方的应用程序端口(如 HTTP 默认端口 80)。
  • 目的端口号(16 位):标识接收方的应用程序端口(如 HTTP 默认端口 80)。
  • 序列号(32 位):标识当前数据包在发送方字节流中的起始位置(第一个字节的编号)。
  • 确认号(32 位):接收方用于告知发送方已成功接收的数据的下一个字节编号。
  • 数据偏移(4 位):标识 TCP 头部的总长度(以 4 字节为单位),用于定位数据部分的起始位置。例如:值为 5 表示头部长度为 20 字节。
  • 保留字段(6 位):保留为未来扩展使用,当前必须置为 0。
  • 标志位(6 位):包含多个控制标志位,用于管理连接状态和数据传输。如 URG 代表紧急指针有效;ACK 代表确认号有效(正常通信时必须置 1);PSH 代表指示接收方应立即将数据提交给应用层;RST 代表重置连接;SYN 代表请求建立连接(同步序列号);FIN 代表请求释放连接。
  • 窗口大小(16 位):接收方告知发送方自己的接收缓冲区剩余空间(以字节为单位),用于流量控制。
  • 校验和(16 位):对头部和数据部分进行校验,用于检测传输过程中数据是否损坏。
  • 紧急指针(16 位):当 URG 标志位为 1 时有效,指示紧急数据的末尾在字节流中的位置。
  • 可选字段(最长 40 字节):用于扩展 TCP 功能,常见选项包括:最大段大小(MSS),窗口扩大因子以及时间(用于计算往返时间 RTT)。

UDP 头部格式?

UDP 是一种无连接的传输层协议,其头部格式相对简单,固定长度为 8 字节。以下是对 UDP 头部各字段的详细介绍:

  • 源端口号(16 位):标识发送该 UDP 数据报的应用程序的端口号。该字段可选,如果不需要返回数据,源端口号可以设置为 0。
  • 目的端口号(16 位):标识接收该 UDP 数据报的应用程序的端口号。这是一个必需的字段,用于将数据报正确地路由到目标应用程序。
  • 长度(16 位):表示 UDP 数据报的总长度,包括头部和数据部分。最小值为 8(仅包含头部),单位是字节。
  • 校验和(16 位):用于检测 UDP 数据报在传输过程中是否发生错误。该字段是可选的,发送方可以选择不计算校验和,将其设置为 0。计算校验和时,会包含一个伪头部,该伪头部包含了 IP 头部的一些信息,用于确保 UDP 数据报被正确地传输到目的地。

TCP 粘包问题?

要理解粘包,需要先理解 TCP 的工作机制:

  • 数据发送:应用程序可以多次调用写操作将数据写入 TCP 发送缓冲区,TCP 会根据网络状况、拥塞窗口等因素,将缓冲区中的字节数据进行合理分段,封装成 TCP 报文段发送出去。这些分段的大小和边界与应用层数据的报文边界可能没有直接关系。
  • 数据接收:接收方的 TCP 会将接收到的 TCP 报文段中的数据按顺序放入接收缓冲区,应用程序从接收缓冲区中读取数据时,也是以字节流的方式读取,无法直接区分出不同应用层报文的边界。

假设应用程序分两次向 TCP 发送数据,第一次发送 Hello,第二次发送 World。TCP 可能将这两部分数据合并成一个 TCP 报文段发送,也可能将 Hello 分成多个报文段,或者将 Hello 和 World 的部分字节组合在一个报文段中发送。接收方的 TCP 会将接收到的数据按顺序组织起来,应用程序读取时得到的是 HelloWorld 这样的字节流,而不是严格按照发送时的两次报文来区分。

TCP 是面向字节流的协议,发送方和接收方以无边界的字节流处理数据,而不是像 UDP 一样以独立报文为单位。这导致应用层的多次数据发送可能被 TCP 层合并或拆分成不同的报文段传输,从而在接收方出现多个应用层数据粘连在一起(粘包)或一个数据被拆分成多段(拆包)的现象。最显然的,粘包会导致接收方无法识别每个消息的起始和结束位置。
以下是一些解决方案:

  • 固定长度法:每个数据块的长度固定,接收方按固定长度读取。例如每次发送 1024 字节的数据,不足则补空格或特殊字符。
  • 分隔符法:在数据中添加特定分隔符标识数据结束。例如 HTTP 请求报文用空行分隔请求头和请求体。
  • 长度前缀法:在每个数据块前添加一个固定长度的字段(如 4 字节整数),标识数据块的总长度。
  • 协议格式法:定义复杂的应用层协议格式(如 JSON、XML),包含数据类型、长度、校验等字段,明确数据边界。

TCP 是面向字节流的传输层协议,其核心职责是确保字节流的可靠传输(如顺序、重传、流量控制等),不关心应用层数据的逻辑边界。所以 TCP 粘包问题无法通过传输层(TCP 协议本身)解决,必须由应用程序在发送和接收数据时,自行定义一套规则(即应用层协议)来明确数据的边界,从而让接收方能够正确解析和区分不同的数据单元。

TCP 三次握手和四次挥手?

(1)三次握手

  • 客户端向服务器发送 SYN 包:客户端向服务器发送一个 SYN 包,其中包含客户端的初始序列号(ISN,Initial Sequence Number),表示客户端想要建立连接。此时客户端进入 SYN_SENT 状态。
  • 服务器发送 SYN + ACK 包:服务器收到客户端的 SYN 包后,会向客户端发送一个 SYN + ACK 包。其中 SYN 表示服务器同意建立连接,ACK 表示对客户端 SYN 包的确认。服务器也会携带自己的初始序列号。此时服务器进入 SYN_RCVD 状态。
  • 客户端发送 ACK 包:客户端收到服务器的 SYN + ACK 包后,会向服务器发送一个 ACK 包,表示对服务器 SYN 包的确认。此时客户端和服务器都进入 ESTABLISHED 状态,连接建立成功。

(2)四次挥手

  • 客户端发送 FIN 包:客户端向服务器发送一个 FIN 包,表示客户端已经没有数据要发送了,请求关闭连接。此时客户端进入 FIN_WAIT_1 状态。
  • 服务器发送 ACK 包:服务器收到客户端的 FIN 包后,会向客户端发送一个 ACK 包,表示对客户端 FIN 包的确认。此时服务器进入 CLOSE_WAIT 状态,客户端进入 FIN_WAIT_2 状态。
  • 服务器发送 FIN 包:服务器处理完剩余的数据后,会向客户端发送一个 FIN 包,表示服务器也没有数据要发送了,请求关闭连接。此时服务器进入 LAST_ACK 状态。
  • 客户端发送 ACK 包:客户端收到服务器的 FIN 包后,会向服务器发送一个 ACK 包,表示对服务器 FIN 包的确认。此时客户端进入 TIME_WAIT 状态,服务器进入 CLOSED 状态。经过一段时间后,客户端也进入 CLOSED 状态,连接关闭成功。

TCP 的 Nagle 算法和延迟确认是两种优化 TCP 性能的机制,具体介绍下?

(1)Nagle算法

  • 作用:主要用于减少网络中微小分组的数量,提高网络利用率。当应用程序向 TCP 发送小块数据时,Nagle 算法会将这些小块数据缓存起来,直到缓存中的数据达到一定大小或者收到前一个分组的确认信息,才将缓存的数据组成一个 TCP 报文段发送出去。
  • 原理:该算法规定,在一个 TCP 连接中,最多只能有一个未被确认的小分组(小于 MSS 的分组),其余小分组都要被缓存。这样可以避免发送大量的小分组,减少网络中的报文段数量,从而降低网络带宽的浪费和传输延迟。

(2)延迟确认

  • 作用:延迟确认机制是为了提高网络性能,减少确认报文的数量,从而降低网络开销。它允许接收方在收到数据后,不立即发送确认报文,而是等待一段时间,看是否有后续的数据到达。
  • 原理:如果在等待时间内有新的数据到达,接收方可以将确认信息与对新数据的响应合并在一个报文中发送,这种方式被称为捎带确认。通常,延迟确认的时间一般设置为 200ms 左右。如果在这段时间内没有新的数据到达,接收方也会发送确认报文,以确保发送方不会因为长时间未收到确认而重传数据。

3.RabbitMQ

RabbitMQ 如何保障消息持久化?

RabbitMQ 保障消息持久化需从三方面入手:交换器持久化,声明时设 durable = true,将元数据存盘确保重启后重建;队列持久化,同样通过 durable = true 使队列元数据持久化,避免队列丢失;消息持久化,发送时设 delivery_mode = 2,将消息体写入磁盘。此外,可配置镜像队列将队列数据复制到多个节点,进一步提升可靠性,通过多维度持久化机制,确保消息在服务器重启或故障时不丢失。

Git 和 Maven 和 Linux

Git

常见的 Git 命令?

(1)仓库操作
git clone:用于将远程仓库完整复制到本地。

git clone https://github.com/user/test-project.git

git init:在当前目录创建一个新的本地 Git 仓库。
(2)提交操作
git add:把工作区的文件修改添加到暂存区。

git add .
git add file.txt

git commit:将暂存区的内容提交到本地仓库。

git commit -m "完成用户登录功能"

git push:把本地仓库的提交推送到远程仓库。

git push origin main

(3)分支操作
git branch:查看、创建和删除本地分支。
git checkout:切换分支或恢复工作区文件。
git merge:将一个分支的修改合并到当前分支。
git rebase:把当前分支的提交移到目标分支末尾,使提交历史更线性。
(4)查看信息
git status:查看工作区和暂存区的状态,了解哪些文件被修改、添加或删除。
git log:查看提交历史。
git diff:查看文件的修改内容。
(5)撤销与回滚操作
git reset:撤销提交或移动 HEAD 指针。

git reset --soft HEAD^
git reset --hard HEAD^

git revert:创建一个新的提交来撤销指定的提交。

git revert 123abc

git checkout – file:放弃工作区中指定文件的修改,让文件恢复到最近一次提交时的状态。

git checkout -- readme.md

(6)远程仓库操作
git remote:管理远程仓库。

git remote add origin https://github.com/user/test-project.git

git fetch:从远程仓库获取最新的提交信息,但不合并到本地分支。

git fetch origin main

git pull:从远程仓库获取最新提交并合并到本地分支,相当于 git fetch 和 git merge 的组合。

git pull origin main

Maven

Linux

Linux 文件系统

在 Linux 中,硬件设备(如硬盘、U 盘、键盘)、目录、套接字(Socket)等都被抽象为文件,通过统一的接口进行操作。
Linux 中的文件组织成树形目录结构,即以 /(根目录) 为起点,所有文件和设备都挂载在这棵树形结构中,形成一个统一的层次化命名空间。例如:/home 存放用户主目录,/etc 存放系统配置文件,/dev 存放设备文件。
每个文件 / 目录有 所有者(User)、所属组(Group)、其他用户(Others) 三种权限,分别对应 读(r)、写(w)、执行(x) 操作。

dmesg 命令

dmesg 是 Linux 系统中用于查看内核环形缓冲区日志的命令,主要用于调试和系统诊断。内核在运行过程中会将各种信息(如硬件检测、驱动加载、系统错误等)写入环形缓冲区,dmesg 则负责读取并显示这些内容。通过 dmesg 命令,系统管理员和开发者可以快速获取内核层面的运行状态和错误信息,是 Linux 系统故障排查的核心工具之一。

该命令一般用于系统启动问题排查、硬件故障诊断,驱动设备调试等。

补充:内核日志的八个级别:

  • 0: EMERG(紧急,系统不可用)
  • 1: ALERT(必须立即修复的问题)
  • 2: CRIT(严重错误,如硬件故障)
  • 3: ERR(错误)
  • 4: WARNING(警告)
  • 5: NOTICE(正常但需要注意的事件)
  • 6: INFO(信息性消息)
  • 7: DEBUG(调试信息)

ps 和 pidstat 命令

ps:查看进程快照,即进程某一时刻的运行信息。具体用于快速查看进程是否存在、进程 ID、CPU 占用率、内存占用率、进程状态(睡眠运行或僵尸)、启动命令等。
pidstat:监控进程资源使用,按指定间隔持续输出进程的 CPU、内存、I/O、上下文切换等指标。

简单来说,ps 即拍快照,pidstat 即拍录像。前者用于查看进程基础信息,后者用于实时性能分析与监控。

当远程服务调用返回 connection refused 错误时,这通常表示客户端尝试连接到服务器,但服务器拒绝了该连接。如何排查?

(1)确认目标服务器地址和端口是否正确
如果 ping 不通,可能存在网络故障,如网络线路问题、IP 地址配置错误等。

ping 192.168.1.100

(2)检查目标服务器端口是否开放
确保目标服务器上的服务正在监听指定的端口。若服务未监听该端口,连接请求会被拒绝。

telnet 192.168.1.100 8080

(3)检查目标服务器服务是否正常运行
确认目标服务器上的服务已经启动并且正常工作。服务未启动或出现异常会导致连接被拒绝。

systemctl status nginx

(4)检查防火墙设置
防火墙可能会阻止对目标服务器指定端口的访问。需要检查目标服务器和客户端的防火墙配置。

iptables -L -n

首先用 ping 命令测试两台机器间的网络连通性,判断网络是否正常;若网络没问题,用 telnet 或 nc 命令测试目标服务端口是否开放,查看是否因端口问题导致调用失败;在机器 B 上,使用 netstat 或 ss 命令查看服务监听状态,确认服务是否正常监听对应端口;用 ps 命令查看服务进程是否存在,若不存在需启动服务;检查防火墙,用 iptables 或 firewalld 命令查看防火墙规则,确认是否有规则阻止了调用;还会用 traceroute 命令追踪网络数据包的路径,定位网络中的问题节点。

怪怪的场景题

MySQL 分布式场景下为什么不推荐使用自增主键?

单机 MySQL 中自增主键能保证单表唯一,但分布式场景下多节点独立生成主键,不同节点的自增序列可能重复(如节点 1 生成 1,3,5…,节点 2 生成 2,4,6…),导致跨节点数据主键冲突。
在分布式事务中,不同节点的自增主键生成逻辑独立,可能在提交时因主键重复导致冲突,进而触发事务回滚或遗留部分节点数据不一致问题,破坏全局数据一致性。
并且,部分业务需要主键包含业务含义(如订单号含日期、地区码),自增主键无法满足。

UUID 可以用来做主键吗?有什么问题?

可以是可以。UUID 通过算法(如时间戳、MAC 地址、随机数等)生成 128 位长整型值,重复概率极低,能可靠保证分布式场景下的主键唯一性。但是它是纯技术型主键,不包含业务含义,避免因业务规则变更导致主键重构(如订单号规则调整)。
但但但是,UUID 占用内存过大,导致索引效率下降,UUID 生成规则是无序的,插入数据时会导致主键索引页频繁分裂,影响写入性能。而且它的可读性差,与业务没有任何关联。

雪花算法可以用做主键吗?原理及优缺点?

雪花算法是分布式系统中常用的主键生成算法,可以作为数据库主键,其设计目标是在分布式环境下生成全局唯一、单调递增的 ID,同时具备高可用性和高性能。
雪花算法的组成
(1)时间戳部分:每次生成 ID 时获取当前时间,若与上一次生成时间相同,则递增序列号;若晚于上一次时间,则重置序列号。
(2)节点标识部分:数据中心 ID 和机器 ID 组合成节点标识,确保不同节点生成的 ID 不冲突。
(3)序列号部分:同一节点同一毫秒内,通过原子递增生成唯一序列号,避免重复。
雪花算法的优点
(1)全局唯一性:通过时间戳、节点标识和序列号的组合,确保分布式环境下无重复。
(2)单调递增性:时间戳部分随时间递增,同一节点同一毫秒内序列号递增,因此生成的 ID 整体单调递增,适合作为主键或排序字段。
(3)高并发性能:纯内存计算,无网络 IO 或数据库依赖,单机 QPS 可达数万次,满足高并发场景需求。
雪花算法的缺点
(1)时钟回退:若节点时钟因 NTP 同步或硬件故障回退到之前的时间,可能生成重复 ID(需额外逻辑处理,如阻塞等待至超过上次时间或切换到备用时钟源)。
(2)时间戳溢出:41 位时间戳支持约 69 年,需在系统设计时考虑时钟周期重置方案(如更换起始时间)。
补充:时钟回退的另一个解决方案
牺牲部分序列号空间,允许短暂的时钟回退,指的是预留一部分序列号专门用于处理时钟回退的情况。例如,原本 12 位序列号可表示 0 - 4095,但现在可以规定序列号从 1024 开始计数,把 0 - 1023 这部分序列号作为补偿空间。

高并发场景下,如何保证 RabbitMQ 消息只被消费一次?

唯一标识:每条消息有全局唯一的 messageId,贯穿生产、传输、消费全链路。
去重存储:选择高性能存储(Redis 优先)记录已处理的 messageId,支持高并发查询。
原子操作:通过数据库唯一索引、Redis 原子命令(SET NX)或 Lua 脚本,保证查询、处理、标记的原子性。

如何保证生产者发送到消息队列,消费者消费消息时不丢失?

(1)生产者端

  • 发布确认:可以通过设置 channel.confirmSelect() 开启。生产者发送消息后,会等待 RabbitMQ 服务器返回确认信息。可以采用同步和异步两种方式处理确认信息。同步方式即生产者发送消息后阻塞等待确认结果。异步方式即添加监听器,在消息确认时触发相应的回调函数。
  • 消息级持久化:生产者在发送消息时,可以将消息标记为持久化。这样即使 RabbitMQ 服务器重启,消息也不会丢失。

(2)消息队列端

  • 交换器和队列持久化:在创建交换器和队列时,将它们设置为持久化。这样即使 RabbitMQ 服务器重启,交换器和队列也会保留,存储在其中的消息也不会丢失。
  • 镜像队列:为了防止单个节点故障导致消息丢失,可以使用镜像队列。镜像队列会将消息复制到多个节点上,当主节点出现故障时,从节点可以继续提供服务。可以通过 RabbitMQ 的管理界面或者命令行工具来配置镜像队列。

(3)消费者端

  • 手动确认消息:消费者在消费消息时,采用手动确认机制。当消费者成功处理完消息后,再向 RabbitMQ 发送确认信息。这样可以避免因消费者处理消息时出现异常而导致消息丢失。
  • 消息重试机制:当消费者处理消息失败时,可以实现消息重试机制。可以将失败的消息重新放回队列,等待再次消费。不过为了避免无限重试,可以设置重试次数上限。

如何设计一个高并发系统?

考虑多个方面:系统拆分,缓存加速,消息队列异步削峰,数据分离,读写分离,服务监控。

如何设计百亿 URL 生成器?

怎么应对热点问题的突发访问压力?

单点登录

慢 SQL 优化

考虑多个方面:优化 SQL 本身,索引等。

定时任务

如何使用 Redis 实现黑名单机制?

在一个 Web 应用中,为了防止恶意用户频繁发起请求,可以使用该黑名单机制。当检测到某个用户的请求行为异常时,将其用户 ID 添加到黑名单中,并记录违规原因。在后续的请求处理中,每次都检查请求用户的 ID 是否在黑名单中,如果在,则拒绝该请求。同时,管理员可以根据需要移除黑名单中的元素,或者查看某个用户被加入黑名单的原因。

  • Hash 键名:为黑名单设置一个统一的键名,例如 blacklist。这个键名就像一个容器,用于存储所有的黑名单信息。
  • Hash 字段:每个黑名单元素作为 Hash 的一个字段。比如,如果是用户黑名单,字段可以是用户 ID;如果是 IP 黑名单,字段就是 IP 地址。
  • Hash 值:每个字段对应的值可以存储与该黑名单元素相关的额外信息。例如,对于用户黑名单,可以存储用户被加入黑名单的原因、加入时间等;对于 IP 黑名单,可以存储该 IP 被封禁的类型(如恶意攻击、频繁请求等)。

当需要将一个元素添加到黑名单时,使用 Redis 的 HSET 命令;使用 Redis 的 HDEL 命令来移除黑名单中的元素;使用 HEXISTS 命令来判断某个元素是否在黑名单中;使用 HGET 命令获取黑名单元素对应的额外信息;使用 HGETALL 命令可以获取黑名单中所有元素及其对应的额外信息。

大数据排序问题

(1)1GB 数据排序,100MB 内存的解决方案
在内存仅 100MB 的情况下对 1GB 数据进行排序,可先将 1GB 数据划分成 10 个 100MB 的数据块。依次把每个数据块加载到内存,用快速排序等算法对其排序,再将排好序的数据块存回磁盘。完成所有数据块的排序后,采用多路归并排序,借助优先队列(最小堆),从 10 个有序数据文件中按顺序取出最小元素,最终合并成完整的有序序列。不过,该方法存在磁盘 I/O 开销大的问题,可通过优化读写策略、选择合适优先队列等方式提升性能。
(2)1GB 数据取最大 10 个数据,100MB 内存的解决方案
当内存为 100MB,要从 1GB 数据里找出最大的 10 个数据,可先把 1GB 数据分成多个能在 100MB 内存处理的数据块,对每个数据块进行排序。接着创建一个容量为 10 的最小堆,遍历每个排好序的数据块,将元素与堆顶元素比较,若元素小于堆顶则忽略,若大于则替换堆顶元素并调整堆结构。遍历完所有数据块后,最小堆里的 10 个元素就是最大的 10 个数据。此方法内存利用高效,时间复杂度低,但要注意数据类型、比较规则和边界情况的处理。

详细对比 token,cookie,session 以及 jwt

(1)Cookie
Cookie 是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带上并发送到服务器上。当用户第一次访问服务器时,服务器会生成一个包含用户信息的 Cookie,并通过响应头 Set - Cookie 发送给浏览器。浏览器接收到 Cookie 后,会将其存储在本地。当用户再次访问该服务器时,浏览器会在请求头中自动添加 Cookie 字段,将之前存储的 Cookie 发送给服务器,服务器根据 Cookie 中的信息来识别用户。
优点:

  • 实现简单,大多数浏览器都支持。
  • 可以在客户端和服务器之间自动传递数据。

缺点:

  • 安全性较低,容易被篡改和窃取,可能导致信息泄露和会话劫持。
  • 数据存储在客户端,容量有限(一般单个 Cookie 不能超过 4KB)。
  • 会增加请求的大小,影响性能。

应用场景:

  • 记住用户的偏好设置,如主题颜色、字体大小等。
  • 跟踪用户的访问记录,如最近浏览的商品。

(2)Session
Session 是服务器端的会话机制,用于跟踪用户的会话状态。服务器为每个用户创建一个唯一的会话 ID,通过这个 ID 来识别用户,并在服务器端存储与该用户相关的会话数据。当用户第一次访问服务器时,服务器会创建一个新的 Session,并生成一个唯一的 Session ID。服务器将 Session ID 通过 Cookie 发送给浏览器。浏览器将 Session ID 存储在本地,并在后续的请求中发送给服务器。服务器根据接收到的 Session ID 查找对应的 Session 数据,从而识别用户。

优点:

  • 安全性相对较高,因为会话数据存储在服务器端,客户端只保存 Session ID。
  • 可以存储较多的数据,不受客户端存储容量的限制。

缺点:

  • 服务器端需要维护大量的 Session 数据,会占用服务器的内存资源。
  • 分布式系统中,Session 的管理和共享比较复杂。

应用场景:

  • 用户登录状态的管理,确保用户在登录后可以在多个页面保持登录状态。
  • 购物车功能,记录用户添加到购物车中的商品信息。

(3)Token
Token 是一种身份验证的凭证,通常是一个字符串。服务器在用户登录成功后生成一个 Token,并将其返回给客户端。客户端在后续的请求中携带这个 Token,服务器通过验证 Token 的有效性来确认用户的身份。

优点:

  • 无状态,服务器不需要存储用户的会话信息,便于扩展和分布式部署。
  • 可以在不同的系统之间共享,实现单点登录。
  • 安全性较高,可以通过加密和签名来保证 Token 的完整性和真实性。

缺点:

  • Token 通常较大,会增加请求的大小。
  • Token 一旦泄露,可能会被他人冒用,需要注意 Token 的有效期和刷新机制。

应用场景:

  • 移动应用的身份验证,因为移动应用的服务器通常是分布式的,无状态的 Token 更适合。
  • 第三方 API 的访问授权,通过 Token 来控制对 API 的访问。

(4)JWT(JSON Web Token)
JWT 是一种基于 JSON 的开放标准(RFC 7519),用于在各方之间安全地传输声明。JWT 由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)。

优点:

  • 自包含,JWT 中包含了用户的相关信息,服务器可以直接从 Token 中获取这些信息,无需再查询数据库。
  • 跨域支持,因为 JWT 可以通过请求头或 URL 参数传递,不受 Cookie 的同源策略限制。
  • 安全性高,通过签名可以防止 Token 被篡改。

缺点:

  • 由于 JWT 包含了用户的信息,一旦 Token 泄露,可能会导致用户信息泄露。
  • Token 一旦发布,在有效期内无法撤销,需要通过其他方式来实现 Token 的失效。

应用场景:

  • 前后端分离的项目中,用于身份验证和授权。
  • 微服务架构中,用于服务之间的身份验证和信息传递。

(5)它们之间的关系

  • Cookie 和 Session:Cookie 是 Session 机制的一种实现方式,通过 Cookie 来传递 Session ID,从而实现服务器对用户会话状态的跟踪。
  • Token 和 Session:Token 是一种无状态的身份验证方式,而 Session 是有状态的。Token 不需要服务器存储用户的会话信息,更适合分布式系统;而 Session 需要服务器维护会话数据,在分布式系统中需要解决 Session 共享的问题。
  • JWT 和 Token:JWT 是 Token 的一种具体实现,它采用 JSON 格式来存储用户信息,并通过签名来保证 Token 的安全性。JWT 可以作为一种通用的 Token 标准,在不同的系统之间进行身份验证和信息传递。

当发现线上 Java 项目的进程持续占用高内存时,如何排查?

(1)确认高内存占用的 Java 进程
使用 top 或 ps 命令来找出占用内存较高的 Java 进程。

ps -ef | grep java
  • top 命令:实时显示系统中各个进程的资源占用状况。执行 top 命令后,按 M 键可按内存使用量对进程进行排序,找到 Java 进程对应的 PID(进程 ID)。
  • ps 命令:可以列出当前系统中的进程信息。

(2)查看 Java 进程的内存使用细节
使用 jstat 命令查看 Java 进程的堆内存使用情况。

jstat -gc <PID> <间隔时间(ms)> <次数>

(3)生成堆转储文件
使用 jmap 命令生成 Java 进程的堆转储文件(Heap Dump),该文件包含了 Java 堆中所有对象的信息。

jmap -dump:format=b,file=heapdump.hprof <PID>

(4)分析堆转储文件
可以使用工具(如 VisualVM、Eclipse Memory Analyzer(MAT)等)来分析生成的堆转储文件,找出占用大量内存的对象。VisualVM 是一个可视化的 Java 性能分析工具,可直接打开堆转储文件,查看各个对象的内存占用情况和引用关系。
(5)查看线程信息
使用 jstack 命令查看 Java 进程的线程堆栈信息,以排查是否存在死锁或线程阻塞等问题。

jstack <PID>

(6)查看 GC 日志
如果在 Java 启动时开启了 GC 日志记录,可以查看 GC 日志文件,了解垃圾回收的情况,判断是否存在频繁的 Full GC 或长时间的 GC 停顿。

tail -f <GC日志文件路径>