怎么用 八股讲不顺 → 基础环节会挂;讲得顺 → 只是不被这块淘汰,不等于能过。注意本题库不含算法,而算法是大厂应届的硬门槛。场景题难度对标社招两三年,已超出应届考察标准,它的价值是让你知道正确的思考框架——照原文背会被追问穿,要用自己真做过的经历去填充表达。

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

一、IoC 与 Bean

  1. Bean 的生命周期是怎样的,有哪些扩展点?★★★

    主干是四步:实例化 → 属性填充 → 初始化 → 销毁。展开说是这么走的。

    先把配置解析成 BeanDefinition 注册进容器,注意这一步只是登记定义,还没创建对象。这时候有个 BeanFactoryPostProcessor 的机会可以修改定义,占位符替换(${})就是在这里做的。

    然后推断构造器、反射实例化对象。接着属性填充,也就是依赖注入,循环依赖的问题就出在这一步。

    再往后是一串回调:先是各种 Aware 接口(BeanNameAwareBeanFactoryAwareApplicationContextAware),把容器里的东西塞给 Bean;然后 BeanPostProcessor前置处理;然后 @PostConstructInitializingBean.afterPropertiesSet()、自定义 init-method 依次执行;最后是 BeanPostProcessor后置处理。销毁时走 @PreDestroyDisposableBean.destroy()

    这里面最该记住的是 BeanPostProcessor 这个扩展点,因为 Spring 大量功能都是靠它实现的:AOP 代理就是在后置处理阶段生成的@Autowired 注解的解析、@Async 的代理、@Configuration 的处理全都是。理解了它,就理解了 Spring 的扩展机制。

    两个扩展点的分工要说清:BeanFactoryPostProcessor 改的是「定义」,BeanPostProcessor 改的是「实例」。前者在所有 Bean 创建之前执行,后者在每个 Bean 创建过程中执行。用错时机就会遇到「拿不到 Bean」或者「拿到的 Bean 还没初始化完」。

    还有个实践点:@PostConstruct 执行时整个容器还没就绪,其他 Bean 可能还没创建完,而且此时 AOP 代理还没生成(它在后置处理阶段)。所以需要依赖其他组件、或者要对外提供服务(比如注册到注册中心)的初始化逻辑,应该放到 ApplicationRunner 或者监听 ApplicationReadyEvent,而不是 @PostConstruct

  2. Bean 有哪些作用域,单例 Bean 是线程安全的吗?★★

    作用域主要是 singleton(默认,容器里只有一个实例)和 prototype(每次获取都新建)。Web 环境下还有 requestsessionapplication

    单例 Bean 不是线程安全的。Spring 只保证「容器里只有一个实例」,完全不对这个实例的线程安全负责。多个请求线程会并发调用同一个 Bean 的方法。

    但实际上大部分时候没出问题,原因是典型的 Controller、Service、Mapper 都是无状态的——方法里只有局部变量,而局部变量在各线程自己的栈上,天然隔离。

    出问题的是在 Bean 里定义了可变的成员变量。比如为了「省事」把一个 List 或者计数器放成字段,多线程一起改就错了。最经典的踩坑案例是SimpleDateFormat 作为成员变量,它内部有可变状态且非线程安全,并发格式化会得到错乱结果甚至抛异常——这也是为什么要换成 DateTimeFormatter,后者是不可变的。

    解决方式按优先级:首选无状态设计,状态都放在方法参数和返回值里;确实需要线程隔离的用 ThreadLocal(记得 remove);需要共享的用并发容器或加锁。

    最后一个高频追问:prototype 的 Bean 注入到单例 Bean 里会怎样?答案是只会注入一次,因为注入发生在单例 Bean 创建时,之后每次调用用的都是同一个实例,prototype 的语义等于失效了。要每次拿新的,得用 @Lookup 方法注入,或者注入 ObjectProvider / ApplicationContext 在使用时主动获取。这个坑很隐蔽,因为代码看起来完全正常。

  3. Spring 怎么解决循环依赖,为什么需要三级缓存?★★★

    三级缓存分别是:一级 singletonObjects 存完全初始化好的成品 Bean;二级 earlySingletonObjects 存提前暴露的半成品(已实例化但还没填充属性);三级 singletonFactories 存的是创建这个早期引用的工厂对象

    流程是:A 实例化之后,先把一个能产出自己引用的工厂放进三级缓存,然后开始填充属性,发现要注入 B。于是去创建 B,B 填充属性时发现要注入 A,就来查缓存:一级没有,二级没有,三级有,于是调用这个工厂拿到 A 的早期引用,放进二级缓存并从三级删掉。B 拿到 A 的引用完成初始化,成为成品放进一级缓存。回到 A,A 拿到成品 B 完成初始化,也进一级缓存。

    为什么必须三级、两级不够?关键在 AOP。如果 A 需要被代理,那么 B 里注入的必须是代理对象而不是原始对象,否则 B 调 A 的方法就没有切面逻辑了。而代理对象正常是在初始化的最后一步才创建的,现在提前要用,就得有个机制在「被别人提前引用时」生成代理。三级缓存存的那个工厂做的就是这件事:调用它时会走一遍 getEarlyBeanReference,该代理就代理。

    如果只有两级缓存,就得在实例化之后无条件先创建代理放进去,这违背了 Spring 的设计——代理应该在初始化完成后按需创建。三级缓存用「工厂延迟调用」把这个决定推迟到真正需要的时候,只有真的发生循环依赖才提前生成代理。所以三级缓存的本质是为了兼容 AOP 而保留的延迟决策能力

    另外要知道解决不了的情况:构造器注入的循环依赖解决不了,因为对象都没实例化出来,压根没法提前暴露引用;多例(prototype)的循环依赖也不行,因为不走单例缓存;@Async 标注的 Bean 也可能报错,因为它的代理是在后置处理器里创建的,跟提前暴露的对象不是同一个。Spring Boot 2.6 之后默认禁止循环依赖了,需要显式开启,这个信号很明确:循环依赖是设计问题,应该重构而不是依赖容器兜着。

  4. Spring 容器的启动流程大致是什么?★★★

    核心都在 refresh() 这个方法里,它是个典型的模板方法,十来个步骤顺序执行。

    先准备环境和创建 BeanFactory,把配置解析成 BeanDefinition 注册进去——注意这一步只是登记定义,还没创建对象。然后调用 BeanFactoryPostProcessor,这是修改 BeanDefinition 的最后机会,ConfigurationClassPostProcessor 就在这里解析 @Configuration@ComponentScan@Import,把扫描到的类注册成定义,Spring Boot 的自动配置也是在这一步展开的。

    接着注册 BeanPostProcessor(注意是注册不是调用,它们要在后面创建 Bean 时才被调用),初始化国际化和事件广播器。然后是最重要的 finishBeanFactoryInitialization,遍历所有单例 BeanDefinition 逐个 getBean 创建实例,也就是前面 Bean 生命周期那一整套。最后发布 ContextRefreshedEvent 事件。

    理解这个流程最大的实际价值是知道各种扩展点的执行时机:想改配置元数据用 BeanFactoryPostProcessor,想改 Bean 实例用 BeanPostProcessor,想在容器完全就绪后做事用 ApplicationListener 监听 ContextRefreshedEvent 或者实现 SmartInitializingSingleton。用错了时机就会遇到「拿不到 Bean」或者「拿到的 Bean 还没初始化完」这类问题。

二、AOP 与事务

  1. AOP 的实现原理是什么,JDK 动态代理和 CGLIB 怎么选?★★★

    AOP 靠动态代理实现,在目标方法前后织入切面逻辑。生成代理的时机是 Bean 生命周期里 BeanPostProcessor 的后置处理阶段。

    JDK 动态代理基于接口,运行时生成一个实现了相同接口的代理类,方法调用转发给 InvocationHandler。限制是目标类必须有接口,而且只能代理接口里声明的方法。CGLIB 是通过继承,生成目标类的子类并重写方法,所以不需要接口,但不能代理 final 类和 final 方法(没法继承和重写),另外它要求目标类有可访问的构造器。

    Spring 的选择逻辑:有接口默认用 JDK 代理,没接口用 CGLIB,可以通过 proxyTargetClass=true 强制用 CGLIB。Spring Boot 2.0 之后默认全部用 CGLIB,即使有接口也一样。这么改是因为 JDK 代理有个实际问题:注入的时候必须按接口类型注入,按具体类型注入会失败,而且拿不到目标类上的注解,容易踩坑。统一用 CGLIB 行为更一致。

    性能上现在两者差别不大,早期 CGLIB 生成代码快但创建慢,JDK 9 之后 JDK 代理的性能提升了很多。所以选择的依据是兼容性而不是性能

  2. @Transactional 在哪些情况下会失效?★★★

    这题我按出现频率说。

    方法内部自调用,这个最常见。事务是靠代理实现的,外部调用走代理所以有事务,但在同一个类里 this.otherMethod() 是直接调用原始对象的方法,压根没经过代理,事务注解完全不起作用。解法是注入自己(@Lazy 避免循环依赖)、用 AopContext.currentProxy()、或者最干净的办法是把方法拆到另一个 Bean 里。

    异常被吞了。方法里 try-catch 把异常捕获后没有重新抛出,Spring 感知不到失败,事务照常提交。这是「数据不一致」类 bug 的常见来源。

    抛的是受检异常。默认只对 RuntimeExceptionError 回滚,抛 IOException 这类是不回滚的,要加 rollbackFor = Exception.class。团队里我一般要求统一加这个配置。

    方法不是 public。JDK 代理只能代理接口方法,CGLIB 没法重写 private 和 final 方法,所以 private、final、static 方法上的注解无效(Spring 6 之后对 protected 和 package-visible 有支持,但 private 依然不行)。

    类没被 Spring 管理,自己 new 出来的对象当然没有代理。数据库引擎不支持事务,比如 MyISAM。多数据源时事务管理器和实际用的数据源不匹配。异步方法里,@Async 的方法在新线程执行,事务上下文是 ThreadLocal 存的,传不过去,等于开了个新事务。

    排查技巧:把 org.springframework.transaction 的日志级别调到 DEBUG,能看到事务的创建、提交、回滚记录,一眼就知道事务到底有没有开起来,比逐个猜快得多。

  3. 事务的传播机制有哪几种,REQUIRES_NEWNESTED 有什么区别?★★★

    七种,实际常用三种。REQUIRED 是默认的,有事务就加入,没有就新建,所以整个调用链共用一个事务,任何一处失败全部回滚。REQUIRES_NEW总是新建一个独立事务,把外层事务挂起,两个事务的提交回滚互不影响。NESTED 是嵌套事务,在当前事务里打一个保存点

    剩下四个:SUPPORTS 有就用没有就不用,NOT_SUPPORTED 挂起当前事务以非事务方式跑,MANDATORY 必须有事务否则报错,NEVER 必须没事务否则报错。

    REQUIRES_NEWNESTED 的区别是关键:REQUIRES_NEW两个完全独立的事务,内层提交了就是真的提交了,之后外层回滚也影响不到它;而且内层执行期间外层的修改是看不见的(因为是两个事务,受隔离级别约束),还会占用两个数据库连接。NESTED 只有一个物理事务,内层是保存点,内层失败可以只回滚到保存点让外层继续,但外层回滚会把内层一起带走,因为它们本质是同一个事务。

    实际场景怎么选:记录操作日志、发送通知这类「主流程失败了我也要留下记录」的用 REQUIRES_NEW;批量处理里「某一条失败了跳过继续处理其他的」用 NESTED

    有个必须知道的坑:外层用 try-catch 包住内层的 REQUIRED 方法,以为捕获异常就能让外层继续,其实不行——内层抛异常时事务已经被标记为 rollback-only,外层最后提交时会抛 UnexpectedRollbackException。想实现「内层失败外层继续」必须用 REQUIRES_NEWNESTED,这是很多人踩过的坑。

  4. @Async 的原理是什么,有哪些坑?★★★

    原理和 @Transactional 一样:基于 AOP 代理。加了 @EnableAsync 之后,Spring 给标注了 @Async 的 Bean 生成代理,调用方法时不直接执行,而是包装成任务提交给线程池,立刻返回。

    坑很多,而且每一个都会在生产上出问题。

    一、默认线程池是个陷阱。不显式配置的话,Spring Boot 早期版本用的是 SimpleAsyncTaskExecutor——它压根不是线程池,每次调用都新建一个线程,并发一上来直接把机器打满。新版本会优先用 ThreadPoolTaskExecutor,但默认配置(核心 8、队列 Integer.MAX_VALUE)意味着队列无界,任务堆积会导致内存溢出而且最大线程数永远用不上。所以必须自己定义线程池并在 @Async("beanName") 里指定。

    二、自调用失效。同一个类里 this.asyncMethod() 不走代理,会变成同步执行,而且没有任何报错——这是最隐蔽的坑,你以为异步了其实没有。同理方法必须是 public、不能是 static 或 final。

    三、事务不传播。异步方法在新线程执行,而事务上下文存在 ThreadLocal 里,传不过去。所以异步方法里的数据库操作是独立事务,外层事务回滚它不会跟着回滚。更危险的组合是「事务里发起异步任务读刚写的数据」——外层还没提交,异步线程查不到,表现为偶发的数据不一致。要解决就用 @TransactionalEventListener 的 AFTER_COMMIT,等提交后再触发。

    四、异常被吞。返回 void 的异步方法抛异常,调用方完全感知不到,日志里也可能什么都没有。要么返回 CompletableFuture 让调用方处理,要么实现 AsyncUncaughtExceptionHandler 全局兜住,否则任务静默失败

    五、上下文丢失。ThreadLocal 里的东西全都传不过去——登录用户信息、traceId、租户标识、数据源标记。表现是异步任务里拿不到当前用户,或者链路追踪断了。解法是用 TaskDecorator 在提交任务时把上下文复制过去,或者用 TTL。

    六、启动和关闭。@Async 的代理是后置处理器生成的,所以在 @PostConstruct 里调用异步方法可能不生效;关闭时要配 setWaitForTasksToCompleteOnShutdown,否则应用停机会丢掉队列里的任务。

    我的实践结论是:@Async 适合「无返回值、失败可容忍、不需要上下文」的场景(发通知、写日志、刷缓存)。稍微重要的异步任务我更倾向显式用自己的线程池加 CompletableFuture,或者直接上 MQ——代码略啰嗦但行为完全可控,不会踩上面这些隐式的坑。

  5. 大事务有什么问题,怎么拆?★★★

    问题在于事务期间一直占着数据库连接、一直持有行锁、undo log 一直不能清理。连接池被占满会导致整个服务不可用,行锁长时间不释放会阻塞其他业务,还容易死锁。

    最典型的大事务成因是把 RPC 调用放在事务里。数据库操作可能只要几毫秒,但一个 RPC 要几百毫秒甚至超时几秒,这段时间数据库连接和锁全都白占着。而且如果 RPC 超时了,本地事务回滚了,但对方可能已经执行成功了,数据就不一致了。同理还有在事务里发 MQ、读写文件、调用第三方接口。

    拆的思路是缩小事务边界,只包住真正需要原子性的数据库操作。具体做法:把查询移到事务外面,先把数据准备好;RPC 调用移到事务外,事务提交后再调(用 TransactionSynchronizationManager 注册回调,或者用 @TransactionalEventListener 的 AFTER_COMMIT);大循环拆成分批,每批一个小事务;用编程式事务 TransactionTemplate 精确控制范围,这比注解灵活得多。

    拆完之后要面对新问题:原子性没有了,得靠补偿或者最终一致来保证正确性,比如失败重试、定时对账。这是必要的代价——分布式系统里追求强一致的成本往往高于业务能接受的范围,把「一个大事务」换成「小事务加最终一致」是主流做法。

三、Spring Boot 与 MVC

  1. Spring Boot 自动配置的原理是什么?★★★

    入口是 @SpringBootApplication,它里面最关键的是 @EnableAutoConfiguration,这个注解通过 @Import 引入了 AutoConfigurationImportSelector

    这个 Selector 会去扫描所有 jar 包里的配置清单文件,早期是 META-INF/spring.factories,Spring Boot 2.7 之后改成了 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,把里面列出的自动配置类全部读出来,注册成 BeanDefinition。这个过程发生在 ConfigurationClassPostProcessor 处理配置类的阶段。

    但读出来的这几百个配置类不会全部生效,靠的是 @Conditional 系列注解做条件过滤@ConditionalOnClass 判断类路径上有没有某个类,这是最主要的开关——引了 MySQL 驱动才会配数据源;@ConditionalOnMissingBean 判断用户有没有自己定义,这实现了「用户配置优先」;还有 @ConditionalOnProperty 按配置项开关。所以「约定优于配置」的本质就是一堆预置的配置类加上条件判断

    配合的还有 @ConfigurationProperties,把 application.yml 里的配置绑定到对象上,让自动配置类能读到用户的参数。

    自己写 starter 就是这套东西的应用:写一个自动配置类,加上适当的条件注解,在 AutoConfiguration.imports 里注册。命名规范上,官方的叫 spring-boot-starter-xxx,第三方应该叫 xxx-spring-boot-starter

    调试技巧:启动时加 --debug 参数会打印自动配置报告,明确列出哪些配置生效了、哪些没生效以及原因,排查「为什么我的配置没起作用」非常好用。

  2. 过滤器、拦截器、AOP 有什么区别,各自用在什么场景?★★

    所处的层次理解就很清楚了,这三个是从外到内的三道关。

    FilterServlet 规范的东西,在 DispatcherServlet 之前执行,属于容器层面。它拿到的是原始的 ServletRequest不知道这次请求会走到哪个 Controller 方法。优势是能包装请求和响应(比如把请求体缓存下来以便重复读取、对响应做压缩),而且能拦住所有请求,包括静态资源和压根没匹配到 Controller 的请求。

    InterceptorSpring MVC 的东西,在 DispatcherServlet 内部、handler 执行前后。它能拿到 handler 信息(知道要执行哪个 Controller 的哪个方法,能读方法上的注解),而且本身是 Spring Bean,可以依赖注入。但它拿不到解析后的方法参数

    AOP 在最内层,切的是方法调用。它能拿到方法的实际参数和返回值,粒度最细,而且不限于 Web 请求——Service 层、Mapper 层的方法都能切。

    选择标准就是「你需要什么信息」:需要操作原始请求响应流、或者要拦截非 Controller 请求,用 Filter(编码处理、跨域、请求体缓存、访问日志);需要根据目标方法或注解做判断,用 Interceptor(登录鉴权、权限校验、接口耗时统计);需要方法参数和返回值,用 AOP(业务日志、参数校验、缓存、幂等、分布式锁、数据源切换)。

    执行顺序:Filter 的 doFilter 前置 → Interceptor 的 preHandle → AOP 前置 → 目标方法 → AOP 后置 → Interceptor 的 postHandleafterCompletion → Filter 的后置。洋葱模型,进去的顺序和出来的顺序相反。

    几个实践细节。Filter 里抛的异常不会被 @ControllerAdvice 捕获,因为那时候还没进 Spring MVC,所以 Filter 里要自己处理异常并写响应,否则用户看到的是容器的默认错误页。Interceptor 的 afterCompletion 类似 finally,即使前面抛异常也会执行,适合做资源清理(比如清 ThreadLocal)。AOP 有自调用失效问题,Filter 和 Interceptor 没有这个问题——这也是有时宁可用 Interceptor 的理由。

  3. 一个 HTTP 请求进到 Spring MVC 是怎么处理的?★★★

    DispatcherServlet 是总入口。它先通过 HandlerMapping 根据 URL 找到对应的处理器方法,同时把匹配到的拦截器组成一个执行链。然后找到能处理这个 handler 的 HandlerAdapter

    接着依次执行拦截器的 preHandle,然后由 adapter 调用目标方法——这里面有个重要环节是参数解析HandlerMethodArgumentResolver 负责把 HTTP 请求里的数据转成方法参数,@RequestBodyHttpMessageConverter 反序列化 JSON,@RequestParam 从查询串取值,同时做数据校验。方法执行完,返回值由 HandlerMethodReturnValueHandler 处理,@ResponseBody 就是走消息转换器序列化成 JSON 写回响应体。如果返回的是视图名,则交给 ViewResolver 解析并渲染。

    最后执行拦截器的 postHandleafterCompletion。整个过程中的异常由 HandlerExceptionResolver 处理,@ControllerAdvice@ExceptionHandler 的全局异常处理就是挂在这里。

    实际开发中最常打交道的三个扩展点:拦截器做鉴权和日志(注意它拿不到方法参数的解析结果,需要参数就用 AOP);@ControllerAdvice 做全局异常处理和统一响应包装;自定义参数解析器做「从 token 里解析出当前用户直接注入方法参数」这类需求,比每个方法都手动解析干净得多。

    另外常被问的是拦截器和 Filter 的区别:Filter 是 Servlet 规范的,在 DispatcherServlet 之前,拿不到 Spring 的上下文信息(拿不到具体是哪个 Controller 方法);拦截器是 Spring 的,能拿到 handler 信息,也能用依赖注入。所以跟框架无关的处理(编码、跨域、请求包装)用 Filter,业务相关的用拦截器。

  4. Spring Boot 项目怎么做优雅停机?★★

    目标是「不再接受新请求,把手上的请求处理完,然后退出」,避免请求被硬中断导致数据不一致或者用户看到错误。

    Spring Boot 2.3 之后自带支持,配 server.shutdown=graceful 加上 spring.lifecycle.timeout-per-shutdown-phase=30s。原理是收到 SIGTERM 后先关闭 Web 容器的连接接收,等已有请求处理完,超时了才强制关。

    但这只管 Web 层,完整的优雅停机还要处理几件事:先从注册中心摘除自己,这是最关键的一步,否则上游还在往你这里发请求;自定义线程池要单独关闭,用 @PreDestroyshutdownawaitTerminationMQ 消费者要停止拉取并等待正在处理的消息完成;定时任务要等当前执行完。

    顺序很重要:摘流量 → 等上游感知(一般几秒)→ 停止接收新请求 → 处理完存量 → 关闭线程池和连接池 → 退出。很多人漏掉了「等上游感知」这一步,注册中心摘除是有传播延迟的,上游客户端可能还缓存着你的地址,所以摘除之后要 sleep 几秒再继续,这几秒能避免大部分报错。

    容器环境下还要注意 Kubernetes 的 terminationGracePeriodSeconds 必须大于你的停机总时长,否则会被 SIGKILL 直接杀掉;配合 preStop hook 做摘流量和等待更可控。另外 kill -9 是没法优雅的,任何机制都拦不住,所以运维流程上要禁止直接 -9。

四、分布式理论与事务

  1. CAP 和 BASE 分别是什么,实际怎么取舍?★★★

    CAP 是一致性、可用性、分区容错性三者不可兼得。要注意的关键点是:P 在分布式系统里是必选的,因为网络分区一定会发生,你不能假设网络永远可靠。所以真正的选择只有 CP 和 AP 两种。

    CP 的代表是 ZooKeeper,选举期间整个集群不可用,宁可拒绝服务也要保证数据一致。AP 的代表是 Eureka,节点之间数据可能不一致,但每个节点都能继续提供服务。这也是为什么注册中心通常选 AP:服务列表短暂不一致最多是调到一个刚下线的节点,重试一下就行;但注册中心不可用会导致所有服务都发现不了对方,影响面大得多。Nacos 两种模式都支持,临时实例用 AP,持久实例用 CP。

    BASE 是对 AP 的补充,说的是基本可用、软状态、最终一致。它的思路是承认中间状态的存在,允许数据在一段时间内不一致,只要最终能一致就行。绝大部分互联网业务走的是这条路。

    实际取舍上,我的判断依据是不一致的业务代价:涉及钱的核心链路(支付、账务)必须强一致,宁可失败也不能出错;社交、内容、统计这类可以接受最终一致,用户看到点赞数延迟几秒完全没影响。同一个系统里不同模块可以有不同选择,不用一刀切。

  2. 分布式事务有哪些方案,各自适合什么场景?★★★

    2PC / XA 靠事务协调者做准备和提交两阶段。优点是强一致、对业务无侵入;缺点是同步阻塞、协调者单点、资源锁定时间长、性能差。适合传统的跨库场景,互联网高并发下基本不用。

    TCC 是应用层的两阶段,自己实现 try、confirm、cancel 三个方法。try 阶段预留资源(比如冻结库存而不是真扣),confirm 真正执行,cancel 释放预留。优点是没有长时间的数据库锁,性能好;缺点是业务侵入非常大,每个操作都要写三个方法,而且要处理空回滚、幂等、悬挂这三个经典问题。适合对一致性要求高、并发也高的核心业务,比如资金交易。

    本地消息表:把业务操作和「待发送消息」的记录放在同一个本地事务里,保证两者要么都成功要么都失败,然后由定时任务扫表把消息发出去,失败就重试。优点是实现简单、可靠、只依赖数据库;缺点是有延迟,而且要额外维护一张表和一个扫描任务。

    RocketMQ 事务消息是本地消息表的托管版:先发一条半消息(对消费者不可见),然后执行本地事务,成功就提交半消息让它可见,失败就回滚。如果发送方宕机没给出结论,MQ 会回查本地事务状态。优点是不用自己维护消息表;缺点是需要实现回查接口,且只支持 RocketMQ。

    Saga 是把长流程拆成多个本地事务,每个都配一个补偿操作,失败时反向依次补偿。适合流程长、参与者多的业务,比如旅游订单要同时订机票、酒店、门票。缺点是没有隔离性,中间状态对外可见,可能出现「先看到订单成功后来又被取消」的情况。

    实际选择上我的偏好很明确:能不用分布式事务就不用。优先考虑能不能把强一致需求收敿到一个服务一个库里;必须跨服务的,绝大多数场景用 MQ 加最终一致加对账就够了,简单可靠;只有资金这类真正不能出错的地方才上 TCC 或者 Seata。上分布式事务框架的复杂度和运维成本很容易被低估。

  3. 怎么保证接口幂等?★★★

    先明确为什么需要:网络超时重试、用户重复点击、MQ 重复投递、上游重试机制,这些都会导致同一个请求到达多次。

    方案要看操作类型。查询和删除天然幂等,不用处理。新增唯一索引最可靠,把业务唯一标识(订单号、流水号)做成唯一索引,重复插入直接报错捕获后返回成功,这是数据库层面的兜底,任何情况都不会破。更新乐观锁或者状态机,update ... where status = '待支付',重复执行时状态已变,影响行数为 0,直接返回成功。

    更通用的方案是token 加去重表:客户端先请求一个 token,提交时带上,服务端用 Redis setnx 判断这个 token 是否已用过,用过就拒绝。或者由客户端生成唯一的请求 ID(业务上叫幂等号),服务端用它去重。

    几个容易忽略的细节。一是去重的粒度,key 要能准确表达「同一个业务操作」,用参数哈希的话要注意时间戳、随机数这类字段会导致 key 不同。二是并发问题,先查后插不是原子的,两个并发请求都查到「不存在」然后都插入了,所以必须用 setnx 这种原子操作或者靠唯一索引兜底。三是处理中的状态,第一个请求还在处理,第二个来了应该返回什么?直接返回成功可能不对(业务还没成功),直接拒绝也不好(客户端会认为失败)。比较好的做法是记录三种状态:处理中、成功、失败,处理中就返回「正在处理请求稍后查询」,让客户端轮询结果。

    我的实践是两层防护:Redis 做第一层快速拦截,数据库唯一索引做最终兜底。因为 Redis 可能失效、可能过期、可能主从切换丢数据,只靠它不够;只靠数据库则每次都要打到库上。两层配合才既高效又可靠。

  4. 分布式 ID 有哪些生成方案,雪花算法的时钟回拨怎么处理?★★★

    UUID 简单但太长、无序,做主键会导致 B+ 树页分裂严重,不推荐。数据库自增可以做,但单点且性能有限。号段模式是数据库自增的改良:一次取一批 ID(比如 1000 个)到内存里发,用完再取,把数据库压力降低了三个数量级,美团 Leaf 的一种模式就是这个,还可以双 buffer 预加载避免取号时的抖动。Redis incr 也能做,性能好,但要考虑持久化丢数据的问题。

    雪花算法是最常用的:64 位分成 1 位符号位、41 位时间戳(够用 69 年)、10 位工作机器 ID(1024 台)、12 位序列号(同一毫秒内 4096 个)。优点是趋势递增、本地生成不依赖外部服务、性能极高。

    它的问题是依赖系统时钟。时钟回拨(NTP 同步、虚拟机迁移、人工改时间)会导致生成重复 ID。处理办法几种:一是检测到回拨就抛异常拒绝服务,等时钟追上来,简单但会短暂不可用;二是小幅回拨等待,回拨在几毫秒内就 sleep 等过去,超过阈值才报错;三是用备用序列或者切换 workerId,回拨时换一个机器 ID 继续发,百度 UidGenerator 和一些实现用的是这个思路;四是不依赖系统时钟,自己在内存里维护一个递增的时间戳,只在启动时读一次系统时间,之后靠序列号消耗来推进,这样彻底规避了回拨问题。

    另一个实际问题是 workerId 怎么分配。手工配置容易出错和重复,一般用 ZooKeeper 或者 Redis 自动分配并持久化,或者用 Kubernetes 的 Pod 序号(StatefulSet),或者用 IP 加进程号哈希(有冲突概率)。容器化环境下 Pod 频繁重建,workerId 的回收和复用是个容易踩坑的地方。

    还有个业务侧的考虑:ID 不要暴露业务信息也不要连续可猜。订单号如果是严格连续的,竞争对手能通过下单两次算出你的日订单量。所以对外的单号通常会做混淆或者加随机位,跟内部主键分开。

  5. 一次 RPC 调用完整经历了什么?★★★

    核心是让远程调用看起来像本地调用,中间的复杂度全被框架藏起来了。

    一、动态代理。你注入的接口没有实现类,框架用动态代理生成了一个,方法调用被拦截后转成网络请求。这是「像本地调用」的关键。

    二、服务发现加负载均衡。从注册中心拿到实例列表(本地有缓存,正常调用不访问注册中心),按策略挑一个——轮询、随机、加权、最少活跃数(能自动避开慢节点)、一致性哈希。

    三、序列化。把方法名、参数类型、参数值编码成字节流。这一步对性能影响很大:JSON 可读但体积大速度慢,Hessian、Kryo、Protobuf 快得多。协议还要解决拆包粘包,所以要设计协议头(魔数、版本、序列化方式、消息长度、请求 ID)。

    四、网络传输。基于 Netty 长连接,多路复用——同一条连接上并发发多个请求,靠请求 ID 匹配响应。所以客户端要维护「请求 ID 到 Future」的映射表,收到响应后找到 Future 唤醒等待的线程。这就是异步转同步的实现,理解这一点等于理解了 RPC 的核心机制。

    五、服务端处理。解码、找到实现类、反射调用(或用字节码生成避免反射开销)、编码返回。服务端用业务线程池处理,不能在 IO 线程里执行业务逻辑。

    六、治理能力贯穿全程:超时(必须有)、重试(只对幂等接口)、熔断、限流、链路上下文透传。

    和 HTTP 调用对比是常见追问:RPC 的优势是长连接加二进制协议加多路复用,性能明显更好且有完整治理能力;HTTP(REST + JSON)的优势是跨语言、调试方便、对外暴露友好。所以典型架构是内部服务间用 RPC,对外网关用 HTTP。gRPC 是两者的结合,基于 HTTP/2 加 Protobuf。

    还有个必答的实践点:RPC 接口的参数返回值必须可序列化,而且要考虑版本兼容——加字段要能被旧版本忽略,删字段和改类型是破坏性的。生产上最常见的 RPC 事故就是一方改了接口定义另一方没升级,所以接口变更必须向后兼容,这比技术实现更重要。

  6. 限流有哪几种算法,怎么选?★★★

    固定窗口计数器最简单,每个时间窗口内计数,超了就拒绝。问题是临界突刺:如果流量集中在前一个窗口的末尾和后一个窗口的开头,实际上瞬时通过了两倍的量。

    滑动窗口把窗口切成更细的格子,按时间滑动统计,解决了临界问题。精度取决于格子的粒度,越细越准也越占内存。Sentinel 用的就是滑动窗口。

    漏桶是请求先进桶,然后以恒定速率流出,桶满就丢弃。特点是输出速率绝对平滑,能起到整流作用,适合需要保护下游、下游只能承受固定速率的场景。缺点是不允许突发流量,即使系统有余力也不会放行。

    令牌桶是以恒定速率往桶里放令牌,请求来了取一个令牌,取不到就拒绝。跟漏桶的关键区别是允许突发:桶里积累的令牌可以被瞬间用掉,所以能应对短时间的流量高峰,长期平均速率还是受限的。Guava 的 RateLimiter 用的就是令牌桶。

    选择上,大部分业务场景我会选令牌桶,因为真实流量本来就是有波动的,允许一定突发更符合实际,用户体验也更好。需要严格保护下游(比如调用一个只能承受固定 QPS 的第三方接口)就用漏桶。只是简单防刷、精度要求不高的用滑动窗口计数就够了。

    还要考虑单机还是分布式。单机限流用 Guava 或者 Sentinel,简单高效但集群总量不好控制(10 台机器每台限 100,总量就是 1000,但流量不均匀时可能有的机器已经打满有的还闲着)。分布式限流用 Redis 加 Lua 脚本保证原子性,能精确控制集群总量,代价是每次请求多一次网络往返,而且 Redis 成了新的依赖和瓶颈点。实践中常用的折中是Sentinel 的集群限流模式,或者单机限流按比例分配总量,再加上一定的动态调整。

五、消息队列

  1. 怎么保证消息不丢失?★★★

    消息在三个环节都可能丢,要分别处理。

    生产者到 MQ:不能用异步发送就不管了,要用同步发送并检查返回结果,或者用异步发送但实现回调处理失败。更彻底的方案是事务消息或者本地消息表,保证「业务数据写入」和「消息发送」的一致性。RabbitMQ 用 confirm 机制,Kafka 设 acks=all 加重试。

    MQ 自身:必须持久化到磁盘,而且不能只写到 page cache 就返回。Kafka 要设 acks=all 让所有 ISR 副本都确认、replication.factor 至少 3、min.insync.replicas 至少 2,并且关闭 unclean.leader.election(否则落后的副本也能当选 leader,会丢数据)。RocketMQ 用同步刷盘加同步复制。这里有个明确的性能和可靠性权衡:全部设成最可靠会让吞吐明显下降。

    MQ 到消费者:绝对不能自动提交位点。自动提交是「拉到消息就认为处理完了」,如果处理过程中宕机,这批消息就永远丢了。必须改成手动提交,业务真正处理完(包括数据库事务提交)才提交位点。

    但即使这样做完,还是不能保证百分之百。所以最终的兜底是对账:定时比对上下游的数据,找出不一致的补发。这个思路很重要——分布式系统里不要追求「绝对不丢」,而是要有「发现丢了并能补回来」的能力。金融系统的日终对账就是这个道理。

  2. 怎么保证消息不被重复消费?★★★

    先摆一个必须说清的前提:重复消费无法避免,只能做幂等。因为「处理完业务」和「提交消费位点」这两件事不可能真正原子——处理完还没提交就宕机,重启后必然重复消费。所有 MQ 都只能保证 at least once,市面上所谓的 exactly once 都是靠消费端幂等实现的。

    所以问题转化成消费端怎么幂等,手段和接口幂等是同一套:数据库唯一索引最可靠(重复插入冲突后当成成功)、状态机加条件更新适合更新场景、去重表是通用方案、Redis SETNX 做前置快速拦截但不能作为最终保证。

    几个容易踩的坑,这是这题的区分度所在。

    不要用 MQ 的 messageId 做去重键。生产者重试发送时,同一条业务消息可能拿到不同的 messageId,用它去重会漏掉。必须用业务字段(订单号、业务流水号)。

    去重记录的过期时间要比消息的最大重试周期长,否则记录过期后重试的消息又被当成新消息处理了。

    并发消费同一条消息是真实存在的(重平衡期间可能两个消费者同时拿到),所以不能用「先查再写」的非原子写法,必须靠唯一索引或原子操作兜底。

    处理失败的消息不能记成已消费,否则重试时会被误判为重复而跳过,导致消息实际丢失。所以去重记录要区分状态(处理中、成功、失败)而不是只打一个标记。

    最后是一类容易被忽略的「假重复」:消费顺序颠倒导致的问题。先收到更新后的消息、后收到更新前的消息,按到达顺序执行会把新数据覆盖成旧的。这不是幂等能解决的,要靠版本号或时间戳判断,只接受比当前更新的数据。这和幂等是两个独立问题,要分开处理。

  3. 怎么保证消息顺序?★★★

    关键认识是:全局顺序的代价极高,业务上通常也不需要。要全局有序就只能一个分区一个消费者串行处理,吞吐就没了。实际需要的几乎都是局部有序——同一个订单的消息有序,不同订单之间无所谓。

    做法是按业务键路由到同一个分区,Kafka 用消息 key(默认按 key 哈希选分区),RocketMQ 用 MessageQueueSelector 指定队列。同一个订单的所有消息进同一个分区,而单个分区内 MQ 保证顺序,消费端一个分区只有一个消费者,顺序就保住了。

    但这里有个容易被忽略的坑:消费端如果用了线程池并发处理,顺序又乱了。分区里是有序的,但你从分区拉了 10 条消息扔进线程池,执行顺序就不确定了。解法是消费端也按业务键做二次路由,用固定数量的单线程执行器,同一个 key 的消息始终进同一个线程队列,这样既保持了顺序又有一定并发度。

    另外几个会破坏顺序的因素:生产端重试,Kafka 里如果 max.in.flight.requests.per.connection 大于 1 且开了重试,前一条失败重发会排到后一条之后(开启幂等生产者 enable.idempotence 可以解决);消费失败重试,一条消息处理失败进了重试队列,后面的消息先处理了,顺序也乱了——这种情况严格来说只能阻塞在这条消息上重试,或者整个分区暂停,代价是可能卡住整个队列,所以要设置最大重试次数和死信队列。

    实践中我的建议是尽量设计成不依赖顺序:消息里带上完整的状态和版本号,消费端做幂等和版本判断,乱序到达也能得出正确结果。这比费劲保证顺序要健壮得多,因为顺序保证链条太长,任何一环出问题都会破功。

  4. 消息积压了怎么处理?★★★

    判断原因再动手,这一步很关键。是消费者挂了、还是消费变慢了、还是上游流量突增?看消费速率的历史曲线对比就知道。如果是消费者全挂了,重启就恢复;如果是消费变慢,得查是不是依赖的数据库或下游接口慢了、有没有死锁、GC 是否异常。

    紧急处理手段。加消费者实例是最直接的,但受分区数限制——Kafka 里一个分区只能被同组的一个消费者消费,消费者数量超过分区数就没用了。所以如果分区数不够,可以临时扩分区,或者用一个应急方案:写一个临时的消费者只负责把消息快速转发到一个新的、分区更多的 topic,然后用大量消费者去消费新 topic,这是处理海量积压的经典做法。

    还可以提高单个消费者的处理能力:批量拉取批量处理、把串行的数据库操作改成批量、非关键逻辑先跳过、消费线程池调大。如果业务允许,丢弃部分消息也是选项,比如日志类、统计类的消息,积压太多直接跳到最新位点,比慢慢追赶更实际。

    处理完之后要做的事更重要:加监控告警,对积压量和消费延迟设阈值,早发现比事后救火成本低得多;评估分区数,分区数决定了消费并发的上限,设计时要留余量,因为 Kafka 分区只能增不能减;做消费限流和降级,避免消费者被下游拖死;设置合理的消息过期时间,避免无限堆积。

    还有个预防思路:把耗时长的处理逻辑从消费链路里拆出来。消费者只做最小的必要处理(比如落库),复杂逻辑再异步做,这样消费速度快、不容易积压。

六、微服务与治理

  1. 熔断、降级、限流的区别是什么?★★★

    限流是控制入口流量,保护自己不被打垮,处理的是「请求太多」的问题。熔断是发现下游不行了就暂时不再调用它,直接快速失败,处理的是「依赖出故障」的问题,目的是防止雪崩——如果下游超时,你的线程会一直等着,很快线程池占满,你自己也挂了,然后你的上游也挂,一路传染。降级是在资源不足或者出故障时,主动放弃一些非核心功能,保住核心功能,处理的是「资源不够怎么分配」的问题。

    三者的关系:限流在入口,熔断在出口,降级是熔断和限流触发之后的兜底行为。比如熔断了要返回什么?返回一个默认值或者缓存的旧数据,这就是降级。

    熔断器的三个状态要说清:关闭状态正常调用并统计失败率;失败率超过阈值就打开,所有请求直接失败不再真正调用;打开一段时间后进入半开,放少量请求去试探,成功了就关闭恢复正常,失败了就重新打开。半开状态是关键设计,它让系统能自动恢复而不需要人工干预。

    实践上要注意几点:熔断的阈值要合理,太敏感会误熔断(偶发抖动就断了),太钝就起不到保护作用;降级逻辑必须提前设计并测试,很多团队写了降级但从没验证过,真出事时降级代码本身报错的情况我见过不止一次;要区分强弱依赖,强依赖(比如支付服务的账务接口)没法降级,只能保证它的高可用,弱依赖(推荐、广告、评论)才能降级。梳理清楚强弱依赖是做熔断降级的前提工作。

  2. 链路追踪是怎么实现的,日志体系怎么搭?★★★

    核心概念是 traceId 和 spanId。一次完整请求分配一个全局唯一的 traceId,贯穿所有服务;每个调用环节是一个 span,有自己的 spanId 和父 spanId。靠这个父子关系就能还原出一棵调用树,看到每一段耗时和依赖关系。

    实现分三部分。

    一、跨进程传递。HTTP 放 header(现在有 W3C 的 traceparent 标准),RPC 放 attachment 或 metadata,MQ 放消息属性——最后这个最容易漏,导致链路在异步处理时断掉。

    二、进程内传递。ThreadLocal这是最容易出问题的地方:线程池、@AsyncCompletableFuture、并行流都会导致上下文丢失,必须用 TTL 这类工具包装任务才能传下去。链路断了通常就是这个原因。

    三、埋点。手动埋点不现实,一般用 Java Agent 字节码增强在框架层自动埋(SkyWalking 就是这么做的),业务代码零改动。无侵入意味着接入成本极低,这是选型的重要考量。

    实际价值最大的两点,比技术实现更值得说。一是定位性能瓶颈:一个接口慢,火焰图上一眼看出是哪个下游或哪次数据库查询占了大头,不用逐个服务翻日志。二是把 traceId 打进业务日志并返回给前端,用户报障时凭这一个 ID 就能拉出整条链路的全部日志——有和没有的区别是「五分钟定位」和「查一整天」。

    日志体系要配套:统一格式(JSON 结构化便于检索)、每条日志必须带 traceId、分级明确(业务异常用 warn 不要用 error,否则告警会被淹没)、敏感信息脱敏(手机号、身份证、卡号,这是合规要求)、集中采集到 ES 或 Loki。

    成本控制要主动提:全量采集的存储和网络成本很高,所以要采样。但固定比例采样会漏掉恰好没采到的故障现场,正确做法是「基础比例采样 + 错误和慢请求必采」的尾部采样。日志也要分级保留。

    选型上 SkyWalking 在国内用得最多(无侵入、开箱有 UI),OpenTelemetry 是现在的标准方向(厂商中立),新项目我会选后者。

  3. 接口的超时和重试怎么设置?★★★

    超时必须设,而且不能设太长。不设超时是分布式系统里最危险的做法之一:下游卡住,你的线程一直等,线程池很快耗尽,整个服务不可用。设太长(比如 30 秒)等于没设,因为用户早就走了,而线程还在占着。

    怎么定超时时间:看这个接口的响应时间分布,一般取 P99 再留一点余量。比如 P99 是 200ms,超时设 500ms 就比较合理。绝对不要凭感觉设一个整数。

    更重要的是超时时间要从上到下递减。如果网关超时 3 秒,服务 A 调 B 设 5 秒,那 A 的超时完全没意义——网关已经断开了,A 还在傻等 B。正确做法是逐层收敿,或者传递「剩余超时时间」,让下游知道自己还有多久预算,Go 的 context 天生支持这个,Java 里需要自己做。

    重试要非常谨慎,几个前提必须满足。一是接口必须幂等,否则重试会造成重复扣款这类严重问题。二是只重试可重试的错误:连接失败、明确的服务不可用可以重;业务错误(参数不对、余额不足)重试毫无意义;读超时不应该盲目重试,因为你不知道对方是没收到还是已经处理完了只是响应慢。三是次数要少,一般 1 到 2 次,配合指数退避加随机抖动。

    最危险的是重试放大:下游本来就是因为过载才变慢,你一重试流量翻倍,把它彻底打死。而且如果是多层链路,每层都重试 3 次,三层就是 27 倍流量。所以现在的做法是只在最外层重试,或者引入重试预算(限制重试请求占总请求的比例,比如不超过 10%),Google SRE 那本书里对这个有详细论述。

  4. 秒杀 / 高并发下单怎么设计?★★★

    核心思路是层层过滤,把绝大部分请求挡在数据库之前。一百万请求抢一百个库存,真正需要到数据库的只有一百多个。

    前端:按钮点击后置灰、加验证码或者答题(既防机器人又能拉长请求分布)、静态资源全部走 CDN。网关层:按用户和 IP 限流,把明显的刷单挡掉;活动没开始的请求直接返回。应用层库存预热到 Redis,用 Lua 脚本原子性地判断和扣减,扣成功了才往后走。这是最关键的一步,Redis 单线程天然串行,不会超卖。下单异步化:Redis 扣减成功后发一条 MQ 消息,立刻返回「排队中」,后台消费者慢慢创建订单、扣数据库库存。这样接口 RT 极短,数据库压力被 MQ 削平。

    几个必须处理的细节。超卖:Redis 扣减用 Lua 保证原子,数据库那层再加 where stock >= 1 兜底。少卖:Redis 扣了但后续下单失败(比如用户放弃支付),要把库存还回去,这需要超时未支付自动取消的机制。Redis 和数据库不一致:以数据库为准,定时校对。热点 key:单个商品的库存 key 会打满一个分片,可以把库存拆成多份(比如 100 个库存拆成 10 个 key 各 10 个),请求随机落到一个上,不够了再去别的 key 匀,这个技巧能把热点分散开。

    限流的量级要算:库存 100 件,放 1000 个请求进去足够了,剩下的直接返回「已售罄」——这不是偷懒,是最有效的保护。用户体验上,明确快速告知比让他等半天再失败要好。

    最后是业务和技术的配合:能不能预约制、能不能分批放量、能不能按用户等级错峰。很多所谓的技术难题在产品层面有更简单的解法,面试时能想到这一层会显得更成熟。

七、网络与操作系统

  1. TCP 三次握手四次挥手,为什么是三次和四次?★★

    三次握手的本质是双方都要确认对方的收发能力正常,并同步初始序列号。第一次客户端发 SYN,服务端收到后知道「客户端能发我能收」;第二次服务端回 SYN+ACK,客户端收到后知道「服务端能收能发,我能发能收」;第三次客户端回 ACK,服务端才知道「客户端能收」。少一次的话,服务端不确认客户端能不能收到自己的包,就可能建立一个实际不可用的连接。

    更实际的理由是防止历史连接:如果只有两次,一个延迟很久的旧 SYN 到达服务端,服务端直接建立连接并分配资源,而客户端压根不想连,就浪费了资源。三次握手让客户端有机会拒绝(回 RST)。

    四次挥手是因为 TCP 是全双工的,两个方向要分别关闭。客户端发 FIN 表示「我没数据要发了」,服务端 ACK 确认;但服务端可能还有数据要发,所以要等它发完,再发自己的 FIN,客户端 ACK。中间那段服务端还能继续发数据的状态就是为什么不能合并成三次。不过如果服务端确实没数据要发,ACK 和 FIN 是可以合并的,这时候就变成三次了。

    顺带两个常问的状态:TIME_WAIT 是主动关闭方等 2MSL,作用一是保证最后那个 ACK 能到达(丢了对方会重发 FIN),二是让本次连接的所有报文在网络中消散,避免影响新连接。CLOSE_WAIT 堆积是个典型故障信号,说明你的程序收到 FIN 但没有调用 close,一般是代码里连接没正确关闭,这个在排查连接泄漏时很有用。

  2. select、poll、epoll 的区别是什么?★★★

    select 用一个固定大小的位图存文件描述符,上限通常 1024。每次调用要把整个集合从用户态拷到内核态,内核遍历所有 fd 检查状态,返回后用户还要再遍历一遍才知道哪个就绪了。所以复杂度是 O(n),而且 fd 集合每次都要重新设置。

    poll 改用链表存,去掉了 1024 的限制,但其他问题一个都没解决:还是要全量拷贝,还是要遍历。

    epoll 做了三个关键改进。一是epoll_ctl 单独注册 fd,注册一次就存在内核的红黑树里,不需要每次调用都传一遍,省掉了大量拷贝。二是回调机制,网卡数据到达时中断处理程序会把对应的 fd 加到一个就绪链表里,所以 epoll_wait 只需要看这个链表,不用遍历所有 fd,复杂度是 O(1)(相对于总连接数)。三是返回的直接就是就绪的 fd 列表,用户态不用再遍历筛选。

    所以 epoll 的优势在连接数多但活跃连接少的场景,也就是典型的互联网服务器场景——十万个连接里可能只有几百个在收发数据。反过来如果连接少且几乎全都活跃,select 和 epoll 差别不大,epoll 的红黑树维护反而是额外开销。

    另外要知道 LT 和 ET 两种模式:水平触发是只要缓冲区有数据就一直通知,编程简单,可以不一次读完;边缘触发只在状态变化时通知一次,必须一次性把数据读完(循环读到 EAGAIN),否则剩下的数据不会再被通知。ET 的系统调用次数更少、效率更高,但编程容易出错。Redis 用的是 LT,Nginx 用 ET。

  3. 什么是零拷贝,Kafka 怎么用的?★★★

    传统的「读文件然后发到网络」要经过四次拷贝和四次上下文切换:磁盘到内核缓冲区(DMA)、内核到用户缓冲区、用户到 socket 缓冲区、socket 到网卡(DMA)。中间那两次经过用户态的拷贝是完全没必要的,因为应用程序压根不需要看这些数据,只是搬运一下。

    mmap 的做法是把文件映射到用户空间的虚拟地址,用户态和内核态共享同一块物理内存,省掉了一次拷贝。sendfile 更彻底,数据完全不进用户态,直接在内核里从文件描述符传到 socket,只需要两次 DMA 拷贝和两次上下文切换。如果网卡支持 SG-DMA,连内核里那次拷贝也能省掉,真正做到零 CPU 拷贝。

    Kafka 两个都用了:写消息用 mmap,直接往映射的内存里写,由操作系统负责刷盘,所以写入极快(代价是宕机可能丢 page cache 里的数据);消费消息用 sendfile,把日志文件里的数据直接发给消费者,不经过 JVM 堆,这也是为什么 Kafka 消费的吞吐这么高、而且 JVM 堆内存占用很小。

    Java 里对应的 API 是 FileChannel.transferTo(底层 sendfile)和 MappedByteBuffer(底层 mmap)。Netty 的 FileRegion 也是零拷贝。

    要注意零拷贝的适用前提是应用不需要处理数据内容。如果要解密、压缩、修改消息,数据必须进用户态,零拷贝就用不上了。所以 Kafka 的 broker 只做存储和转发不做内容处理,这个设计决策和零拷贝优化是互相配合的——架构选择让优化成为可能,这个联系值得说出来。

  4. HTTPS 的握手过程是怎样的,为什么要用对称加密和非对称加密结合?★★

    简化说:客户端发起请求带上支持的加密套件和随机数,服务端返回选定的套件、自己的随机数和证书;客户端验证证书有效性,然后生成一个随机的会话密钥,用证书里的公钥加密后发给服务端;服务端用私钥解开,双方之后就用这个对称密钥通信。TLS 1.3 把握手优化到了 1 个 RTT,还支持会话复用做到 0 RTT。

    为什么要混合?因为非对称加密太慢,比对称加密慢几个数量级,用它加密全部数据传输性能无法接受。而对称加密的问题是密钥怎么安全地传给对方——在不安全的网络上传密钥等于不加密。所以用非对称加密只做一件事:安全地交换那个对称密钥。之后的大量数据传输全用对称加密。这是「用慢但安全的方式解决密钥分发,用快的方式做实际传输」的经典组合。

    证书的作用是解决「公钥是不是真的属于这个网站」的问题。如果没有证书,中间人可以把自己的公钥发给你,你用它加密,中间人就能解开。证书由 CA 用自己的私钥签名,浏览器内置了受信任 CA 的公钥,可以验证签名,形成信任链。所以 HTTPS 的安全性最终建立在对 CA 的信任上,这也是为什么 CA 被攻破或者误签证书是很严重的安全事件。

    实际会遇到的问题:证书过期导致全站不可用(一定要做到期监控和自动续期);证书链不完整导致部分客户端报错(服务器要配置中间证书);抓包工具能看到 HTTPS 内容是因为你手动信任了它的根证书,所以移动端要做证书固定来防止这种抓包。

  5. 进程、线程、协程的区别,什么是上下文切换?★★

    进程是资源分配的基本单位,有独立的地址空间,进程间隔离性好但通信要走 IPC,创建和切换开销最大。线程是 CPU 调度的基本单位,同一进程内的线程共享地址空间,通信方便但要处理同步问题,切换比进程轻(不用切页表,但要切寄存器和栈)。协程是用户态的,由程序自己调度,切换完全不进内核,开销最小,一个线程里能跑几万个协程。

    关键区别在调度者:进程和线程由操作系统抢占式调度,你不知道什么时候会被切走;协程是协作式的,只在遇到 IO 或者主动让出时才切换。这带来的好处是切换点可预期、不需要考虑并发修改同一个变量(同一线程内),坏处是一个协程如果长时间占用 CPU 不让出,同线程的其他协程就都饿死了。

    上下文切换的成本包括:保存和恢复寄存器、切换内核栈、如果跨进程还要切页表并导致 TLB 失效、以及最容易被忽略的CPU 缓存失效——新线程的数据不在 L1/L2 缓存里,要重新加载,这部分间接成本往往比直接的切换指令更大。一次切换大概几微秒,看着不多,但每秒几万次切换就是可观的开销。

    实际影响:这就是为什么线程数不能无脑往上加,也是为什么 Redis 单线程反而快(避免了切换),以及为什么 Java 要搞虚拟线程(用户态调度,切换不进内核)。用 vmstat 看 cs 列可以观察系统的切换次数,异常高的话要查是不是线程太多或者锁竞争激烈。

没有匹配的题目,换个关键词试试。

模块 03 · 共 32 题 · 题目与答案分离,建议先自答再展开