跳转到内容

后端

标签「后端」下的 6 篇文章

Java 校招面试题复盘清单

这是一份 Java 校招面试题复盘清单。

它不适合当作“背诵稿”逐字记忆,更适合当作复习地图:先知道面试会问哪些方向,再把每个问题整理成可以讲清楚的核心答案。

Java 校招面试通常会围绕三部分展开:

模块占比重点
技术基础约 40%Java、Spring、MySQL、Redis、JVM、并发
项目深挖约 40%项目背景、技术选型、难点、优化、问题排查
学习能力约 20%最近在学什么、为什么做 Java、如何解决问题

如果项目里有 Elasticsearch,那么 ES 往往会成为面试官重点追问的方向。

HashMap 底层主要是数组、链表和红黑树。

当放入一个 key-value 时,会先根据 key 的 hashCode() 计算 hash,再定位到数组下标。如果该位置没有元素,就直接放入;如果已经有元素,就会形成链表或红黑树。

在 Java 8 之后,当链表长度达到一定阈值,并且数组容量足够大时,链表会转换为红黑树,用来提高查询效率。

可以这样回答:

HashMap 通过 hash 定位数组下标,数组中每个位置叫 bucket。发生 hash 冲突时,Java 8 以前主要用链表,Java 8 之后链表过长会转为红黑树。扩容时会重新计算元素位置,所以 HashMap 的性能和初始容量、负载因子、hash 分布都有关系。

HashMap 没有做同步控制,多线程同时读写时可能出现数据覆盖、状态不一致、扩容异常等问题。

常见风险:

  • 多个线程同时 put,可能覆盖彼此写入。
  • 扩容时结构变化,其他线程同时访问可能拿到异常结果。
  • 统计数量 size 可能不准确。

所以多线程场景通常使用 ConcurrentHashMap

ConcurrentHashMap 如何实现线程安全

Section titled “ConcurrentHashMap 如何实现线程安全”

Java 8 中,ConcurrentHashMap 主要通过 CAS 和 synchronized 保证线程安全。

它不是给整张表加一把大锁,而是尽量缩小锁粒度。

常见回答:

Java 8 的 ConcurrentHashMap 底层也是数组、链表和红黑树。插入时,如果桶为空,会通过 CAS 放入节点;如果桶不为空,会对桶头节点加 synchronized,只锁当前桶。这样既保证线程安全,又比 Hashtable 整表加锁性能更好。

ArrayList 底层是动态数组,LinkedList 底层是双向链表。

对比项ArrayListLinkedList
底层结构动态数组双向链表
随机访问快,按下标访问 O(1)慢,需要遍历
中间插入删除需要移动元素找到节点后修改指针
内存占用相对较少每个节点要存前后指针
常用场景查询多插入删除多,但实际也要看位置

面试里要注意:不要简单说 LinkedList 插入删除一定快。因为如果要先按索引找到位置,遍历本身也有成本。

String、StringBuilder、StringBuffer 的区别

Section titled “String、StringBuilder、StringBuffer 的区别”

String 是不可变对象,每次修改都会产生新字符串。

StringBuilder 是可变字符序列,线程不安全,但性能较好。

StringBuffer 也是可变字符序列,方法加了同步,线程安全,但性能通常低于 StringBuilder

常见选择:

  • 少量字符串拼接:直接用 String
  • 单线程大量拼接:用 StringBuilder
  • 多线程共享拼接对象:用 StringBuffer,但实际业务中较少这样用。

== 比较的是两边是否相等。

对于基本类型,比较的是值。

对于引用类型,比较的是对象地址。

equals() 是对象方法,默认实现也是比较地址,但很多类会重写它,比如 String 会比较字符串内容。

equals-demo.java
String a = new String("hello");
String b = new String("hello");
System.out.println(a == b); // false
System.out.println(a.equals(b)); // true

Java 异常体系的顶层是 Throwable

它下面主要分为:

  • Error:严重错误,程序通常不主动处理,比如 OutOfMemoryError
  • Exception:程序可以捕获和处理的异常。

Exception 又分为:

  • checked exception:编译期异常,必须处理或声明抛出。
  • unchecked exception:运行时异常,继承自 RuntimeException,例如空指针、数组越界。

面试里可以补一句:业务开发中不要滥用异常控制正常流程,异常更适合表示非预期情况。

反射是 Java 在运行时获取类信息、创建对象、调用方法、访问字段的能力。

常见应用场景:

  • Spring 创建 Bean、依赖注入。
  • MyBatis 映射对象字段。
  • 注解解析。
  • 测试框架调用测试方法。
  • 动态代理。

反射灵活,但也有缺点:性能相对普通调用更低,可读性和安全性更差。

进程是操作系统资源分配的基本单位。一个应用程序运行起来通常就是一个进程。

线程是 CPU 调度的基本单位,一个进程中可以包含多个线程。

可以这样回答:

进程拥有独立的内存空间,线程共享同一进程的内存资源。线程切换成本通常低于进程,但共享数据也会带来线程安全问题。

synchronized 可以修饰方法或代码块,用来保证同一时间只有一个线程进入临界区。

它依赖对象监视器锁,也就是 monitor。

进入同步代码块时,线程尝试获取对象锁;执行完或异常退出时释放锁。

可以补充:

  • 修饰普通方法,锁的是当前对象 this
  • 修饰静态方法,锁的是当前类的 Class 对象。
  • 修饰代码块,可以指定锁对象。

volatile 主要有两个作用:

  • 保证变量对多线程的可见性。
  • 禁止指令重排序。

它不能保证复合操作的原子性。

比如 count++ 包含读取、加一、写回,不是一个原子操作,所以只加 volatile 仍然不安全。

常见场景:

  • 状态标志位。
  • 单例模式双重检查锁中的实例变量。

线程池是提前创建并管理一组线程,任务来了以后交给线程池执行。

使用线程池的原因:

  • 避免频繁创建和销毁线程。
  • 控制并发线程数量。
  • 提高响应速度。
  • 统一管理任务队列、拒绝策略和线程生命周期。

ThreadPoolExecutor 常见核心参数:

参数作用
corePoolSize核心线程数
maximumPoolSize最大线程数
keepAliveTime非核心线程空闲存活时间
unit时间单位
workQueue任务队列
threadFactory线程创建工厂
handler拒绝策略

执行流程可以概括为:

核心线程未满 -> 创建核心线程
核心线程已满 -> 放入任务队列
队列满了 -> 创建非核心线程
线程数达到最大且队列也满 -> 执行拒绝策略

死锁是多个线程互相持有对方需要的资源,导致都无法继续执行。

死锁常见四个条件:

  • 互斥。
  • 请求并保持。
  • 不可剥夺。
  • 循环等待。

避免方式:

  • 固定加锁顺序。
  • 减少锁范围。
  • 使用超时锁。
  • 避免嵌套锁。
  • 使用并发工具类代替手写锁。

JVM 运行时数据区主要包括:

  • 程序计数器。
  • Java 虚拟机栈。
  • 本地方法栈。
  • 堆。
  • 方法区。

线程私有:

  • 程序计数器。
  • Java 虚拟机栈。
  • 本地方法栈。

线程共享:

  • 堆。
  • 方法区。

栈主要存放方法调用相关信息,比如局部变量表、操作数栈、方法出口等。

堆主要存放对象实例,是垃圾回收重点关注的区域。

对比项
线程关系线程私有线程共享
存储内容方法调用、局部变量对象实例
生命周期随方法调用入栈出栈由 GC 管理
常见错误StackOverflowErrorOutOfMemoryError

垃圾回收是 JVM 自动回收不再使用对象的机制。

判断对象是否可回收,主流方法是可达性分析:从 GC Roots 出发,能到达的对象是存活对象,不能到达的对象可以被回收。

常见 GC Roots:

  • 虚拟机栈中的引用。
  • 方法区中的静态变量引用。
  • 常量引用。
  • 本地方法栈中的引用。

常见算法:

  • 标记-清除:先标记垃圾,再清除,可能产生内存碎片。
  • 复制算法:把存活对象复制到另一块区域,适合新生代。
  • 标记-整理:标记后整理存活对象,减少碎片。
  • 分代收集:按对象生命周期分区,不同区域用不同算法。

OOM 是内存不足导致的错误。

常见原因:

  • 创建了大量对象,堆空间不足。
  • 大对象过多。
  • 内存泄漏,旧对象一直被引用。
  • 线程过多导致栈空间不足。
  • 元空间加载类过多。

排查时通常会看日志、堆 dump、GC 情况和对象引用链。

常见关注点:

  • 堆大小:-Xms-Xmx
  • 新生代大小:-Xmn
  • 元空间大小:-XX:MetaspaceSize
  • GC 收集器选择。
  • GC 日志。
  • 停顿时间和吞吐量。

调优不是盲目改参数,而是先看现象:内存是否够、GC 是否频繁、停顿是否过长、对象是否异常增长。

IoC 是控制反转。

原本对象由程序自己创建和管理,现在交给 Spring 容器创建和管理。

DI 依赖注入是 IoC 的一种实现方式。比如一个 Service 依赖 Mapper,不需要自己 new,而是由 Spring 注入。

AOP 是面向切面编程,用来把通用逻辑从业务代码中抽离出来。

常见场景:

  • 日志。
  • 权限校验。
  • 事务。
  • 监控统计。
  • 接口耗时。

核心思想是:不修改业务方法本身,在方法执行前后织入增强逻辑。

简化流程:

实例化 -> 属性注入 -> 初始化前后处理 -> 初始化方法 -> 使用 -> 销毁

常见扩展点:

  • 构造方法。
  • 属性填充。
  • BeanPostProcessor
  • InitializingBeaninit-method
  • DisposableBeandestroy-method

Spring Boot 主要解决传统 Spring 项目配置繁琐的问题。

优势:

  • 自动配置。
  • 内置 Web 容器。
  • starter 依赖简化。
  • 快速创建项目。
  • 方便监控和部署。

一句话回答:

Spring Boot 让 Spring 项目更容易启动和维护,它通过自动配置和 starter 机制减少大量 XML 或手动配置。

自动配置的核心是根据类路径、配置文件和条件注解,自动创建合适的 Bean。

常见关键词:

  • starter。
  • auto configuration。
  • @EnableAutoConfiguration
  • 条件注解,比如 @ConditionalOnClass@ConditionalOnMissingBean

面试中可以这样说:

Spring Boot 会根据引入的依赖和当前环境判断是否满足条件,如果满足,就把对应配置类里的 Bean 注册到容器中。

RESTful API 是一种接口设计风格。

核心思想:

  • 使用 URL 表示资源。
  • 使用 HTTP 方法表示操作。
  • 使用状态码表示结果。

示例:

GET /users/1 查询用户
POST /users 创建用户
PUT /users/1 更新用户
DELETE /users/1 删除用户

索引是帮助 MySQL 快速查找数据的数据结构。

可以理解为书的目录。没有索引时,数据库可能要全表扫描;有索引时,可以更快定位数据。

索引能提高查询效率,但会增加写入成本和存储空间。

B+Tree 适合磁盘存储和范围查询。

原因:

  • 树高度低,减少磁盘 IO。
  • 非叶子节点只存索引,能放更多 key。
  • 叶子节点之间有链表,范围查询效率高。
  • 查询性能稳定。

覆盖索引指查询需要的字段都能从索引中获得,不需要回表查询。

比如有联合索引 (name, age)

SELECT name, age FROM user WHERE name = 'xiaoxi';

如果查询字段都在索引里,就可能走覆盖索引。

常见情况:

  • 对索引列使用函数。
  • 对索引列做计算。
  • 使用左模糊匹配,例如 LIKE '%abc'
  • 联合索引不符合最左前缀原则。
  • 隐式类型转换。
  • OR 条件使用不当。

事务是一组数据库操作的集合,要么全部成功,要么全部失败。

比如下单时:

  • 创建订单。
  • 扣减库存。
  • 扣减余额。

这些操作应该作为一个整体处理。

特性含义
Atomicity 原子性事务要么全部成功,要么全部失败
Consistency 一致性事务前后数据满足约束
Isolation 隔离性并发事务之间互相隔离
Durability 持久性事务提交后数据持久保存

MVCC 是多版本并发控制。

它通过保存数据的多个版本,让读写尽量不互相阻塞。

在 InnoDB 中,MVCC 主要依赖:

  • 隐藏字段。
  • undo log。
  • ReadView。

常见作用是支持可重复读和快照读。

常见原因:

  • 数据主要在内存中。
  • 使用高效数据结构。
  • 单线程命令执行避免大量锁竞争。
  • IO 多路复用。
  • C 语言实现,执行效率高。

常见类型:

  • String。
  • Hash。
  • List。
  • Set。
  • Sorted Set。
  • Bitmap。
  • HyperLogLog。
  • Geo。
  • Stream。

缓存穿透是请求的数据在缓存和数据库中都不存在,导致请求持续打到数据库。

解决方式:

  • 缓存空对象。
  • 布隆过滤器。

缓存击穿是热点 Key 过期瞬间,大量并发请求同时打到数据库。

解决方式:

  • 互斥锁。
  • 逻辑过期。

缓存雪崩是大量 Key 同时过期,或者 Redis 整体不可用,导致大量请求涌向数据库。

解决方式:

  • 过期时间加随机值。
  • 多级缓存。
  • 限流降级。
  • Redis 高可用。

Elasticsearch 是一个分布式搜索和分析引擎,常用于全文搜索、日志检索、商品搜索等场景。

它底层基于 Lucene,对外提供 REST API,支持分布式、倒排索引和复杂查询。

倒排索引是从词到文档的映射。

普通索引更像:

文档 -> 包含哪些词

倒排索引更像:

词 -> 出现在哪些文档中

这就是 ES 做全文搜索快的重要原因。

index 类似一类数据的集合。

document 是一条 JSON 数据。

type 在早期版本中用于区分类型,但新版本已经逐步移除,不建议在新项目中依赖 type。

可以类比:

ES关系型数据库
indextable 或 database 的某种集合概念
documentrow
fieldcolumn

DSL 是 Elasticsearch 的查询语言,使用 JSON 描述查询条件。

示例:

es-dsl.json
{
"query": {
"match": {
"title": "redis"
}
}
}

常见条件:

  • must:必须匹配,影响评分。
  • should:可选匹配,可能影响评分。
  • filter:必须匹配,但不参与评分,适合过滤条件。
  • must_not:必须不匹配。

为什么 Elasticsearch 搜索比 MySQL 快

Section titled “为什么 Elasticsearch 搜索比 MySQL 快”

ES 在全文搜索场景下更快,主要因为它使用倒排索引。

MySQL 更擅长结构化数据查询和事务处理,全文检索不是它最核心的场景。

可以这样回答:

ES 会先对文本分词,再建立词到文档的倒排索引。查询关键词时,可以快速定位包含该词的文档。MySQL B+Tree 索引更适合精确匹配和范围查询,对复杂全文搜索、相关性评分和分词检索不如 ES 合适。

Docker 是容器化技术,可以把应用和依赖打包成镜像,再以容器方式运行。

它解决了“本地能跑,服务器不能跑”的环境一致性问题。

对比项Docker虚拟机
隔离方式进程级隔离硬件级虚拟化
启动速度
资源占用较少较多
系统内核共享宿主机内核每个虚拟机有完整操作系统
适用场景应用部署、微服务强隔离、多系统环境

常见命令:

linux-commands.sh
ls
cd
pwd
mkdir
rm
cp
mv
cat
tail -f app.log
grep "error" app.log
ps -ef
top
df -h
free -m
chmod
chown

面试时如果结合项目部署经历回答,会比单纯背命令更好。

REST API 是基于 REST 风格设计的接口。

它通常使用 HTTP 协议,通过 URL 表示资源,通过 HTTP 方法表示操作。

这个问题和 Spring 里的 RESTful API 本质相同。

微服务是把一个大系统拆成多个小服务,每个服务负责一个相对独立的业务能力。

优点:

  • 服务独立部署。
  • 技术栈可以更灵活。
  • 方便水平扩展。
  • 团队边界更清晰。

缺点:

  • 服务间调用复杂。
  • 分布式事务困难。
  • 监控、链路追踪、部署复杂度提高。

API 网关是系统入口,负责把外部请求转发到内部服务。

常见功能:

  • 路由转发。
  • 鉴权。
  • 限流。
  • 熔断。
  • 日志。
  • 跨域处理。

如果你的项目重点是 Elasticsearch,面试官很可能会围绕项目继续追问。

可以提前准备这些问题:

问题准备方向
为什么项目要用 ESMySQL 搜索能力不足、全文检索、分词、排序、性能
数据怎么同步到 ES同步写、异步消息、定时补偿
ES 和 MySQL 数据不一致怎么办重试、补偿任务、最终一致性
索引怎么设计index、mapping、分词器、字段类型
搜索结果怎么排序相关性评分、业务权重、时间、热度
查询慢怎么优化filter、分页限制、字段选择、索引设计

面试时不要只说“我用了 ES”。更好的说法是:

我在项目中用 Elasticsearch 解决全文检索问题。MySQL 更适合事务和结构化查询,但对分词搜索、相关性排序支持有限。所以把需要搜索的数据同步到 ES,通过倒排索引提升搜索效率。项目里还需要考虑 MySQL 和 ES 的数据一致性,比如通过消息队列异步同步,并配合定时任务补偿。

学习能力问题通常不会太难,但很考验真实感。

常见问题:

  • 最近在学什么技术?
  • 为什么选择 Java?
  • 遇到不会的问题怎么解决?
  • 看过哪些技术文档?
  • 项目里最有收获的地方是什么?

回答建议:

  • 不要只说“我在学 Java”。
  • 要说具体学了什么、为什么学、怎么实践。
  • 最好能和项目或面试岗位关联起来。

例如:

最近我在补 Redis 和 Elasticsearch。Redis 主要关注缓存穿透、击穿、雪崩以及数据结构的使用场景;Elasticsearch 主要学习倒排索引、DSL 查询和数据同步。因为我的项目里有搜索场景,所以我想把搜索链路和缓存链路都理解得更完整。

如果时间有限,可以按这个顺序准备:

  1. Java 基础:集合、字符串、异常、反射。
  2. MySQL:索引、事务、MVCC、索引失效。
  3. Redis:数据结构、缓存问题、常用命令。
  4. Spring Boot:IoC、AOP、自动配置、REST API。
  5. JVM:内存结构、GC、OOM。
  6. 并发:线程池、锁、volatile、死锁。
  7. Elasticsearch 项目:倒排索引、DSL、数据同步、搜索优化。
  8. Docker 和 Linux:能讲清楚部署和常用命令即可。

Java 校招面试不是只背八股。

基础题要能答清楚概念,项目题要能讲清楚自己做了什么、为什么这样做、遇到了什么问题、怎么解决。

这份清单的重点不是一次性背完,而是把每个问题都整理成三层:

一句话结论
核心原理
项目或使用场景

这样回答时会更稳,也更像真的理解过。

一道 gRPC 简易计算器面试题复盘

这是我曾经做过的一道面试题中的一个小项目:用 Go + gRPC + gRPC-Web + Next.js 做一个简易计算器。

题目本身不复杂,只有加、减、乘、除四种运算。但它真正考察的不是“会不会写计算器”,而是能不能把前后端通信、接口契约、错误处理和跨端调用讲清楚。

项目地址:GolangNextGrpcSimpleCalculator

这道题可以理解为:

前端输入两个数字和一个运算符,通过 gRPC-Web 调用 Go 后端,后端完成计算并返回结果。

最小功能闭环包括:

  • 前端页面可以输入两个操作数。
  • 前端可以选择 +-*/ 四种运算符。
  • 前端通过 gRPC-Web 调用后端。
  • 后端使用 Go 实现 gRPC 服务。
  • 后端根据请求参数返回计算结果。
  • 除数为 0 或未知运算符时,需要返回错误。

如果只是做一个普通 HTTP 接口,这题可能很快就结束了。但这里要求使用 gRPC,就会多出一层“接口契约”的设计:前后端都要围绕 .proto 文件生成代码。

这个项目大致可以拆成两部分:

  • 文件夹calculator-backend/
    • 文件夹calculator/
      • calculator.proto
    • main.go
    • calculator_test.go
    • go.mod
  • 文件夹calculator-frontend/
    • 文件夹app/
      • 文件夹generated/
        • 文件夹calculator/
      • page.tsx
    • 文件夹calculator/
      • calculator.proto
    • package.json

后端负责定义和实现计算服务,前端负责生成 gRPC-Web 客户端代码并发起调用。

这里最核心的文件有三个:

文件作用
calculator.proto定义服务、请求结构、响应结构
main.go实现 Go gRPC 服务,并包装成 gRPC-Web HTTP 服务
page.tsx在 Next.js 页面中创建请求并调用后端

gRPC 的入口通常不是先写控制器,而是先写 .proto

calculator.proto
syntax = "proto3";
package calculator;
option go_package = "github.com/2760439882/calculator-backend/calculator;calculator";
service Calculator {
rpc Calculate(CalculationRequest) returns (CalculationResponse);
}
message CalculationRequest {
double operand1 = 1;
double operand2 = 2;
string operator = 3; // "+", "-", "*", "/"
}
message CalculationResponse {
double result = 1;
}

这份协议里有三个关键信息:

  • Calculator 是服务名。
  • Calculate 是远程调用方法。
  • CalculationRequestCalculationResponse 分别是请求和响应结构。

这就是 gRPC 和普通 REST 接口很不一样的地方。REST 接口经常先约定 URL 和 JSON 字段,而 gRPC 会先约定服务方法和强类型消息结构。

后端使用 Go 实现 Calculator 服务。

main.go
type server struct {
pb.UnimplementedCalculatorServer
}
func (s *server) Calculate(
ctx context.Context,
req *pb.CalculationRequest,
) (*pb.CalculationResponse, error) {
var result float64
switch req.Operator {
case "+":
result = req.Operand1 + req.Operand2
case "-":
result = req.Operand1 - req.Operand2
case "*":
result = req.Operand1 * req.Operand2
case "/":
if req.Operand2 == 0 {
return nil, fmt.Errorf("division by zero")
}
result = req.Operand1 / req.Operand2
default:
return nil, fmt.Errorf("unknown operator")
}
return &pb.CalculationResponse{Result: result}, nil
}

这段实现很适合面试时讲,因为它虽然简单,但覆盖了服务端接口实现的几个基本点:

  • 方法签名来自 .proto 生成代码。
  • 请求参数来自 CalculationRequest
  • 返回值必须符合 CalculationResponse
  • 错误可以通过 error 返回给调用方。
  • 除法需要额外处理除数为 0 的情况。

真正的业务逻辑只有一个 switch,但重点是:这个 switch 被放在了 gRPC 服务方法里,前端不会直接知道后端怎么计算,只知道自己要调用 Calculate

浏览器不能像 Go、Java、Node 服务端那样直接发起原生 gRPC 请求。原生 gRPC 基于 HTTP/2,而浏览器端直接使用 gRPC 会受到限制。

所以前端调用 gRPC 服务时,通常需要一层 gRPC-Web。

这个项目里,Go 后端把原始 gRPC 服务包装成了 gRPC-Web 服务:

main.go
grpcServer := grpc.NewServer()
pb.RegisterCalculatorServer(grpcServer, &server{})
wrappedGrpc := grpcweb.WrapServer(grpcServer)
httpServer := http.Server{
Addr: ":8080",
Handler: cors.New(cors.Options{
AllowedOrigins: []string{"http://localhost:3000"},
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{
"Content-Type",
"X-Grpc-Web",
"X-User-Agent",
"grpc-timeout",
},
AllowCredentials: true,
}).Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if wrappedGrpc.IsGrpcWebRequest(r) ||
wrappedGrpc.IsAcceptableGrpcCorsRequest(r) ||
wrappedGrpc.IsGrpcWebSocketRequest(r) {
wrappedGrpc.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
})),
}

这里有两个面试时值得说清楚的点。

第一,后端并不是直接写一个普通 HTTP JSON 接口,而是先创建 grpc.NewServer(),再通过 grpcweb.WrapServer() 包装。

第二,因为前端运行在 http://localhost:3000,后端运行在 http://localhost:8080,所以需要配置 CORS。否则浏览器会先把请求挡掉,根本到不了 gRPC-Web 服务。

前端是 Next.js 页面。因为要在浏览器里响应用户输入和发起请求,所以页面文件使用了 'use client'

page.tsx
'use client';
import { useState } from 'react';
import { CalculatorClient } from './generated/calculator/calculator_grpc_web_pb';
import { CalculateRequest } from './generated/calculator/calculator_pb';
const client = new CalculatorClient('http://localhost:8080', null, null);
export default function Home() {
const [operand1, setOperand1] = useState('');
const [operand2, setOperand2] = useState('');
const [operator, setOperator] = useState('+');
const [result, setResult] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleCalculate = () => {
setLoading(true);
setResult(null);
setError(null);
const req = new CalculateRequest();
req.setOperand1(parseFloat(operand1));
req.setOperand2(parseFloat(operand2));
req.setOperator(operator);
client.calculate(req, {}, (err, response) => {
setLoading(false);
if (err) {
setError('请求失败: ' + err.message);
return;
}
setResult(response.getResult());
});
};
}

这里的关键不是 useState,而是这三步:

  1. 使用生成的 CalculatorClient 创建客户端。
  2. 使用生成的 CalculateRequest 创建请求对象。
  3. 调用 client.calculate(),在回调里处理结果或错误。

这说明前端并没有手写请求路径、请求体字段和响应解析逻辑。它依赖 .proto 生成的代码来保证调用结构一致。

如果把这道题当成面试题看,它主要考察下面几类能力。

考点具体体现
gRPC 基础是否知道 .proto、service、message、生成代码
前后端通信是否知道浏览器不能直接使用原生 gRPC,需要 gRPC-Web
Go 服务端是否能注册服务、实现接口、启动服务
错误处理是否处理除数为 0、未知运算符、请求失败
跨域问题是否知道前后端端口不同会触发 CORS
工程意识是否能把生成代码、后端代码、前端代码分清楚

所以面试时不要只说“我实现了一个计算器”。更好的说法是:

我用 .proto 定义了计算服务和消息结构,再用 Go 实现 gRPC 服务。因为前端运行在浏览器里,不能直接调用原生 gRPC,所以后端用 gRPC-Web 包装了一层 HTTP 服务,并配置 CORS。前端通过生成的 gRPC-Web Client 创建请求对象,调用后端的 Calculate 方法,最后处理成功结果和错误信息。

这段回答会比“我写了加减乘除”更能说明你理解了题目。

这个项目作为面试题已经能跑通主流程,但如果继续完善,可以从下面几个方向补强。

第一,输入校验可以提前放在前端。

现在前端直接对输入值 parseFloat()。如果用户没有输入数字,可能得到 NaN。可以在发请求前判断两个操作数是否合法。

第二,后端错误可以使用 gRPC status。

当前代码使用 fmt.Errorf() 返回错误。实际项目里可以使用 status.Error() 搭配 codes.InvalidArgument,这样调用方可以更准确地区分错误类型。

return nil, status.Error(codes.InvalidArgument, "division by zero")

第三,运算符可以使用枚举。

当前 operator 是字符串,优点是直观,缺点是容易传入非法值。如果要更严谨,可以在 .proto 中定义 enum。

enum Operator {
OPERATOR_UNSPECIFIED = 0;
ADD = 1;
SUBTRACT = 2;
MULTIPLY = 3;
DIVIDE = 4;
}

第四,配置可以抽出来。

前端里的 http://localhost:8080 和后端 CORS 里的 http://localhost:3000 都是开发环境地址。后续如果部署,最好放进环境变量里。

这道题可以按四步讲:

  1. 先说题目目标:前端输入表达式,后端通过 gRPC 完成计算。
  2. 再说接口契约:用 .proto 定义 Calculate 方法、请求和响应。
  3. 接着说后端实现:Go 实现服务,处理四种运算和异常情况。
  4. 最后说浏览器调用:使用 gRPC-Web 生成客户端,前端发起请求并处理结果。

如果面试官继续追问,可以展开这几个点:

  • gRPC 和 REST 的区别是什么?
  • 为什么浏览器需要 gRPC-Web?
  • .proto 文件改了以后要做什么?
  • 如果除数为 0,应该怎么返回错误?
  • 如果以后要支持更多运算,应该怎么扩展?

这个项目的价值不在于计算器本身,而在于它用一个很小的功能,把 gRPC 项目里最关键的链路串起来了:

proto 定义 -> 生成代码 -> Go 实现服务 -> gRPC-Web 包装 -> 前端调用 -> 展示结果

对于面试题来说,这种项目很合适。它足够小,能在有限时间内完成;同时又能覆盖接口设计、后端实现、前端调用、跨域和错误处理这些真实工程里会遇到的问题。

Redis 缓存穿透、击穿、雪崩面试题复盘

这是 Redis 面试里非常高频的一类问题:

什么是缓存穿透、缓存击穿和缓存雪崩?它们有什么区别?应该怎么解决?

这三个概念很容易混在一起,因为它们的结果都可能是“请求打到数据库,数据库压力变大”。但它们的根因完全不同。

可以先用一句话区分:

穿透:查的数据根本不存在。
击穿:一个热点 Key 过期。
雪崩:大量 Key 同时过期,或者 Redis 整体不可用。

缓存穿透指的是:请求的数据在缓存中不存在,在数据库中也不存在。

比如:

  • 请求一个不存在的用户 ID:id = -1
  • 请求一个已经删除的商品
  • 恶意攻击者构造大量非法参数

正常缓存流程一般是:

请求 -> 查 Redis -> Redis 没有 -> 查数据库 -> 写入 Redis -> 返回数据

但如果数据根本不存在,就会变成:

请求 -> 查 Redis -> Redis 没有 -> 查数据库 -> 数据库也没有 -> 返回空

问题在于:下一次同样的非法请求过来,Redis 里还是没有,于是又会打到数据库。

如果有人构造大量不存在的 ID,请求就会绕过缓存,持续打到数据库。

缓存穿透常见有两种解决方案:缓存空对象和布隆过滤器。

当数据库查不到数据时,也往 Redis 里写一个空值或特殊值,并设置较短过期时间。

cache-null.js
async function getUser(id) {
const cacheKey = `user:${id}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
return cached === 'NULL' ? null : JSON.parse(cached);
}
const user = await db.queryUserById(id);
if (!user) {
await redis.set(cacheKey, 'NULL', 'EX', 60);
return null;
}
await redis.set(cacheKey, JSON.stringify(user), 'EX', 3600);
return user;
}

这样下一次请求同一个不存在的 ID,就会被 Redis 拦住,不会继续打数据库。

它的优点是简单,缺点是如果恶意请求的非法 ID 极多,Redis 里会出现很多空值缓存。所以空值缓存的过期时间一般要短一些。

布隆过滤器适合在请求进入缓存和数据库之前,先判断这个数据是否可能存在。

请求 userId
|
v
布隆过滤器判断
|
| 不存在 -> 直接拒绝
|
| 可能存在
v
查 Redis / DB

布隆过滤器的特点是:

  • 如果它判断不存在,那就一定不存在。
  • 如果它判断存在,只能说明可能存在。

所以它适合挡掉大量明显非法的请求。

面试里可以这样说:缓存空对象适合兜住少量不存在数据,布隆过滤器适合在入口处拦截大量非法 Key。

缓存击穿指的是:某一个热点 Key 在过期瞬间,大量并发请求同时打到数据库。

注意,击穿通常是单个 Key。

比如:

  • 某个大促商品详情页
  • 某个热门直播间信息
  • 某个高访问量用户主页
  • 某条热点新闻

这个 Key 平时在 Redis 里,所以数据库压力不大。

但它刚好过期时,大量请求同时进来,发现 Redis 没有,于是一起查数据库。

热点 Key 过期
|
大量请求同时进入
|
Redis 都没查到
|
全部打到数据库

这就是缓存击穿。

缓存击穿常见有两种方案:互斥锁和逻辑过期。

互斥锁的思路是:同一时间只允许一个线程去查数据库并重建缓存,其他线程等待或重试。

mutex-lock.js
async function getProduct(id) {
const cacheKey = `product:${id}`;
const lockKey = `lock:product:${id}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const locked = await redis.set(lockKey, '1', 'NX', 'EX', 10);
if (!locked) {
await sleep(50);
return getProduct(id);
}
try {
const product = await db.queryProductById(id);
await redis.set(cacheKey, JSON.stringify(product), 'EX', 3600);
return product;
} finally {
await redis.del(lockKey);
}
}

这个方案可以保护数据库,但会让部分请求等待,接口耗时可能变长。

逻辑过期的思路是:Redis 里的数据不设置物理过期,或者设置很长的物理过期;真正的过期时间写在 value 里。

logical-expire-value.json
{
"data": {
"id": 1,
"name": "热门商品"
},
"expireAt": "2026-05-20T20:00:00+08:00"
}

请求到来时:

  • 如果逻辑时间没过期,直接返回数据。
  • 如果逻辑时间过期,当前线程尝试拿锁。
  • 拿到锁的线程异步重建缓存。
  • 当前请求先返回旧数据。
查到缓存
|
判断逻辑过期
|
已过期 -> 尝试加锁 -> 异步重建缓存
|
直接返回旧数据

它的优点是响应速度更稳,不会让大量请求等待数据库。

缺点是用户可能短时间看到旧数据,所以它更适合对实时性要求不那么高的热点数据。

缓存雪崩指的是:大量 Key 在同一时间过期,或者 Redis 服务不可用,导致大量请求同时打到数据库。

它和击穿的区别是:

  • 击穿是单个热点 Key。
  • 雪崩是大量 Key,或者缓存层整体出问题。

比如:

  • 批量导入缓存时设置了相同过期时间。
  • 活动开始前预热了一批商品缓存,过期时间完全一样。
  • Redis 宕机或网络故障。
  • Redis 集群大面积不可用。

雪崩的危害比击穿更大,因为它不是一个点,而是一片。

缓存雪崩通常要从多个层面解决。

不要让大量 Key 设置完全一样的过期时间。

random-expire.js
const baseTtl = 3600;
const randomTtl = Math.floor(Math.random() * 300);
await redis.set(cacheKey, value, 'EX', baseTtl + randomTtl);

这样可以把过期时间打散,避免某一秒大量 Key 同时失效。

可以在 Redis 前面加本地缓存,比如 Caffeine、Guava Cache 或进程内缓存。

请求 -> 本地缓存 -> Redis -> 数据库

即使 Redis 短时间抖动,本地缓存也能顶住一部分热点请求。

不过本地缓存也会带来一致性问题,所以一般只适合热点数据或允许短暂不一致的数据。

当数据库压力过大时,不能让所有请求继续堆积。

可以做:

  • 数据库访问限流。
  • 熔断降级。
  • 返回兜底数据。
  • 返回“服务器繁忙,请稍后再试”。

降级不是偷懒,而是在系统压力过大时保护核心链路。

如果雪崩原因是 Redis 宕机,就需要高可用架构。

常见方案:

  • Redis 主从复制。
  • 哨兵模式。
  • Redis Cluster。
  • 多机房或多可用区部署。

高可用解决的是缓存服务不可用的问题,过期时间随机解决的是大量 Key 同时失效的问题。两者关注点不同。

维度缓存穿透缓存击穿缓存雪崩
核心原因数据根本不存在热点 Key 过期大量 Key 同时过期或 Redis 宕机
Key 数量大量不存在 Key单个热点 Key多个或大部分 Key
数据库状态数据库里也没有数据库里有数据数据库里有数据
主要危害绕过缓存打数据库单个热点打爆数据库大量请求压垮数据库
主要方案缓存空对象、布隆过滤器互斥锁、逻辑过期过期随机、多级缓存、限流降级、高可用

记忆时可以抓住关键词:

穿透:不存在
击穿:热点
雪崩:大量

这道题可以这样回答:

缓存穿透是请求的数据在缓存和数据库中都不存在,请求会绕过缓存持续打到数据库,常用缓存空对象和布隆过滤器解决。缓存击穿是某个热点 Key 在过期瞬间被大量并发请求访问,导致请求同时打到数据库,常用互斥锁或逻辑过期解决。缓存雪崩是大量 Key 同时过期,或者 Redis 服务不可用,导致大量请求同时涌向数据库,常用过期时间加随机值、多级缓存、限流降级和 Redis 高可用解决。

如果面试官继续追问,可以补充方案取舍:

  • 缓存空对象简单,但要设置较短 TTL,避免缓存污染。
  • 布隆过滤器适合拦截大量非法请求,但存在误判为可能存在。
  • 互斥锁能保护数据库,但会增加等待时间。
  • 逻辑过期响应快,但可能返回旧数据。
  • 过期随机能打散失效时间,但不能解决 Redis 宕机。
  • Redis 高可用能提升可用性,但不能替代限流和降级。

这类 Redis 面试题的关键,是把三个概念的根因讲清楚。

缓存穿透:缓存没有,数据库也没有。
缓存击穿:缓存没有,但数据库有,而且是热点 Key。
缓存雪崩:大量缓存同时没有,或者 Redis 整体不可用。

只要先分清根因,再说解决方案,就不会混乱。

最后再补一句工程思维:缓存是为了保护数据库,但缓存系统本身也会失效。所以真正可靠的设计,通常不是只靠一个方案,而是缓存策略、限流降级、异步重建和高可用架构一起配合。

深拷贝、浅拷贝与堆栈面试题复盘

这是前端面试里非常常见的一类问题:

什么是深拷贝和浅拷贝?它们和堆、栈有什么关系?

这道题看起来像是在问 API,实际上是在考察你是否理解 JavaScript 的数据类型、内存模型和引用关系。

如果只回答“浅拷贝只拷贝一层,深拷贝会递归拷贝”,只能算答到表面。更完整的回答应该从基本类型和引用类型讲起,再解释为什么对象拷贝容易互相影响。

在 JavaScript 中,数据大致可以分成两类:

  • 基本类型:stringnumberbooleanundefinednullsymbolbigint
  • 引用类型:objectarrayfunctionDateMapSet

基本类型保存的是值本身,赋值时通常是值的复制。

引用类型保存的是对象的引用,赋值时复制的是引用地址,所以多个变量可能指向同一个对象。

浅拷贝只拷贝对象第一层。如果第一层里还有对象,里面的对象仍然共享引用。

深拷贝会把嵌套对象也复制出来,让新对象和旧对象尽量互不影响。

面试里常见说法是:

  • 基本类型的值通常放在栈中。
  • 引用类型的对象内容通常放在堆中。
  • 变量里保存的是指向堆中对象的引用。

这是一种帮助理解的简化模型,不需要把它讲得像浏览器引擎源码一样复杂。

可以这样理解:

let name = 'xiaoxi';
栈:
name -> 'xiaoxi'

基本类型比较直接,变量和值之间的关系很简单。

再看对象:

const user = {
name: 'xiaoxi',
profile: {
age: 18,
},
};

可以理解为:

栈:
user -> 引用地址 0x001
堆:
0x001 -> {
name: 'xiaoxi',
profile: 引用地址 0x002
}
0x002 -> {
age: 18
}

对象本身放在堆里,变量 user 保存的是引用。

很多拷贝问题都从赋值开始。

reference-assignment.js
const user1 = {
name: 'xiaoxi',
profile: {
age: 18,
},
};
const user2 = user1;
user2.name = 'veyliss';
console.log(user1.name); // veyliss

这里 user2 = user1 并没有创建一个新对象,只是让 user2user1 指向同一个对象。

所以修改 user2.nameuser1.name 也会变。

可以理解为:

user1 -> 0x001
user2 -> 0x001

两个变量指向同一个堆对象。

浅拷贝会创建一个新的外层对象,但里面的引用类型属性仍然和原对象共享。

常见浅拷贝方式:

  • 展开运算符:{ ...obj }
  • Object.assign()
  • 数组的 slice()concat()Array.from()[...arr]

看一个例子:

shallow-copy.js
const user1 = {
name: 'xiaoxi',
profile: {
age: 18,
},
};
const user2 = {
...user1,
};
user2.name = 'veyliss';
user2.profile.age = 20;
console.log(user1.name); // xiaoxi
console.log(user1.profile.age); // 20

为什么 name 没有互相影响,但 profile.age 互相影响了?

因为 user2 是一个新的外层对象,但 profile 仍然指向同一个内部对象。

可以理解为:

user1 -> 0x001 -> {
name: 'xiaoxi',
profile: 0x002
}
user2 -> 0x003 -> {
name: 'xiaoxi',
profile: 0x002
}

外层对象不同,内层 profile 相同。

这就是浅拷贝。

深拷贝会递归复制对象中的嵌套对象,让新对象和旧对象不再共享内部引用。

deep-copy-result.js
const user1 = {
name: 'xiaoxi',
profile: {
age: 18,
},
};
const user2 = structuredClone(user1);
user2.profile.age = 20;
console.log(user1.profile.age); // 18
console.log(user2.profile.age); // 20

这时可以理解为:

user1 -> 0x001 -> profile -> 0x002
user2 -> 0x003 -> profile -> 0x004

外层对象不同,内层对象也不同。

以前很常见的一种写法是:

json-clone.js
const copy = JSON.parse(JSON.stringify(source));

这种方式简单,但有明显限制。

它适合普通 JSON 数据:

const source = {
name: 'xiaoxi',
tags: ['前端', 'JavaScript'],
};
const copy = JSON.parse(JSON.stringify(source));

但它处理不了很多特殊值:

json-clone-limit.js
const source = {
name: 'xiaoxi',
createdAt: new Date(),
sayHello() {
console.log('hello');
},
value: undefined,
};
const copy = JSON.parse(JSON.stringify(source));
console.log(copy.createdAt); // 字符串,不再是 Date
console.log(copy.sayHello); // undefined
console.log(copy.value); // undefined

它的主要问题包括:

  • Date 会变成字符串。
  • undefined、函数、symbol 会丢失。
  • MapSet 不能按原结构保留。
  • 遇到循环引用会直接报错。

所以面试时不要把 JSON.parse(JSON.stringify()) 说成万能深拷贝。

现代浏览器和 Node.js 中可以使用 structuredClone()

structured-clone.js
const source = {
name: 'xiaoxi',
createdAt: new Date(),
items: new Map([['count', 1]]),
};
const copy = structuredClone(source);
console.log(copy.createdAt instanceof Date); // true
console.log(copy.items instanceof Map); // true

相比 JSON 方法,structuredClone() 能保留更多内置类型,也能处理循环引用。

structured-clone-cycle.js
const source = {
name: 'xiaoxi',
};
source.self = source;
const copy = structuredClone(source);
console.log(copy.self === copy); // true

不过它也不是没有限制。函数、DOM 节点这类内容不能直接被结构化克隆。

面试里有时会要求手写一个简单版本。

最基础的写法是递归:

simple-deep-clone.js
function deepClone(value) {
if (value === null || typeof value !== 'object') {
return value;
}
const result = Array.isArray(value) ? [] : {};
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
result[key] = deepClone(value[key]);
}
}
return result;
}

这个版本可以处理普通对象和数组,但不能处理循环引用,也不能完整处理 DateMapSet 等类型。

如果要支持循环引用,可以用 WeakMap 记录已经拷贝过的对象。

deep-clone-with-weakmap.js
function deepClone(value, cache = new WeakMap()) {
if (value === null || typeof value !== 'object') {
return value;
}
if (cache.has(value)) {
return cache.get(value);
}
if (value instanceof Date) {
return new Date(value.getTime());
}
if (value instanceof Map) {
const result = new Map();
cache.set(value, result);
value.forEach((item, key) => {
result.set(deepClone(key, cache), deepClone(item, cache));
});
return result;
}
if (value instanceof Set) {
const result = new Set();
cache.set(value, result);
value.forEach((item) => {
result.add(deepClone(item, cache));
});
return result;
}
const result = Array.isArray(value) ? [] : {};
cache.set(value, result);
Reflect.ownKeys(value).forEach((key) => {
result[key] = deepClone(value[key], cache);
});
return result;
}

这个版本已经能覆盖不少面试场景:

  • 基本类型直接返回。
  • 普通对象和数组递归复制。
  • Date 单独处理。
  • MapSet 单独处理。
  • WeakMap 解决循环引用。
  • Reflect.ownKeys() 可以拿到 symbol key。

但它仍然不是完整工业级实现。比如属性描述符、原型链、不可枚举属性、函数、DOM 节点等,还需要更多额外处理。

不是所有场景都需要深拷贝。

场景推荐方式
只改第一层字段浅拷贝即可
React 更新一层状态展开运算符或 Object.assign()
嵌套对象也要完全隔离深拷贝
普通 JSON 数据复制JSON 方法可以考虑
复杂对象、循环引用structuredClone() 或专门工具
高性能、大数据量场景尽量避免无脑深拷贝

在 React 里也经常会遇到这个问题。

比如更新用户名称:

setUser((user) => ({
...user,
name: 'veyliss',
}));

这是浅拷贝,足够更新第一层。

如果要更新嵌套字段:

setUser((user) => ({
...user,
profile: {
...user.profile,
age: 20,
},
}));

这不是对整个对象做深拷贝,而是只拷贝发生变化的路径。这个方式在 React 里更常见,也更可控。

这道题可以这样回答:

JavaScript 里基本类型通常保存值本身,引用类型保存的是对象引用。对象内容可以理解为存在堆中,变量里保存的是引用地址。普通赋值只是复制引用,不会创建新对象。浅拷贝会创建一个新的外层对象,但嵌套对象仍然共享引用;深拷贝会递归复制嵌套对象,让新旧对象尽量不共享引用。常见浅拷贝方式有展开运算符、Object.assign()、数组 slice() 等;常见深拷贝方式有 structuredClone()、JSON 序列化和手写递归。JSON 方法有局限,会丢失函数、undefined,也不能处理循环引用。手写深拷贝时要考虑递归、特殊类型和循环引用,可以用 WeakMap 做缓存。

如果面试官继续追问,可以展开这些点:

  • 赋值、浅拷贝、深拷贝的区别。
  • 为什么浅拷贝会影响嵌套对象。
  • JSON.parse(JSON.stringify()) 有哪些问题。
  • structuredClone() 能解决什么,不能解决什么。
  • 手写深拷贝如何处理循环引用。
  • React 中为什么经常只拷贝变化路径。

深拷贝、浅拷贝和堆栈这道题,本质是在问你是否理解引用。

可以记住这几句话:

  • 基本类型更像“直接保存值”。
  • 引用类型更像“变量保存地址,对象放在堆里”。
  • 赋值复制的是引用。
  • 浅拷贝复制第一层。
  • 深拷贝递归复制嵌套层。
  • 深拷贝不是越多越好,要看业务是否真的需要完全隔离。

面试里把这些关系讲清楚,比单纯背一个手写深拷贝函数更重要。

电商订单过期处理面试题复盘

这是我 2024 年遇到的一道电商面试题,题目大概是:

假如在某个时刻,也就是某一秒内,有数以千计的商品订单同时过期,数据库应该怎么处理?

这道题表面问的是“订单过期”,实际考察的是高并发场景下怎么避免数据库被瞬时写流量打穿。

一个比较稳妥的回答是:不要让数据库自己去扛所有过期判断,也不要用定时任务在同一秒扫大量订单。订单创建时写入延时队列,到期后由消费者接收消息,再做批量更新和后续事件分发。

订单过期一般不是一个难逻辑。比如订单超过 30 分钟未支付,就把状态从 WAIT_PAY 改成 CLOSED

真正的问题在于:如果某个秒级时间点有大量订单同时过期,系统会出现几个压力点:

  • 数据库短时间内出现大量 update
  • 如果使用定时任务扫描,查询范围可能很大。
  • 单条订单逐个更新会造成大量数据库往返。
  • 过期后还要释放库存、通知用户、同步缓存,容易把主流程拖慢。
  • 消息重复、消费失败、订单已支付等情况都需要正确处理。

所以这道题不能只回答“定时任务扫一下订单表”。那样在低并发下能跑,但在电商场景里不够稳。

我当时记录的主思路是四步:

订单创建 -> 写入延时队列 -> 到期消费 -> 批量关闭订单 -> 发布后续业务事件

可以画成这样:

创建订单
|
| 写订单表
v
发送延时消息:orderId + expireTime
|
| 到期后投递
v
订单过期消费者
|
| 聚合一小批订单
v
批量更新订单状态
|
| 更新成功后发布事件
v
释放库存 / 退款 / 通知用户 / 同步 ES 或缓存

核心目标是:把同一秒的大量过期订单,拆成可控的批量写入和异步事件处理。

订单创建成功后,系统先写入订单表,然后把订单 ID 和过期时间写入延时队列。

消息里通常包含:

order-expire-message.json
{
"orderId": "202401010001",
"orderType": "NORMAL",
"expireTime": "2024-01-01T10:30:00+08:00"
}

延时时间可以这样计算:

TTL = expireTime - now()

也就是说,如果订单 30 分钟后过期,就发送一条 30 分钟后投递的延时消息。

这里可以选不同的实现:

方案说明
RabbitMQ TTL + 死信队列到期后消息进入死信队列,由消费者处理
RocketMQ 延时消息原生支持延时消息,适合常见延时等级
Redis ZSet用过期时间作为 score,消费者按时间拉取
时间轮适合大量延时任务调度

面试时不用把某个 MQ 的细节讲太深,重点是说明:订单创建时就把“未来要关闭订单”这件事交给延时机制,而不是等数据库定时扫描。

当延时时间到达,消息会被投递到订单过期消费者。

消费者拿到消息后,不能直接无脑关闭订单,而是要先做状态校验:

收到 orderId
|
查询订单当前状态
|
如果已支付:忽略
如果已关闭:忽略
如果待支付且已超时:关闭

原因很简单:延时消息到达时,订单可能已经被用户支付了。

所以关闭订单必须是有条件的。不能只根据“消息到了”就关闭。

如果每条消息都单独更新一次数据库,几千条过期订单就会变成几千次 update

更好的做法是:消费者先把消息暂存到一个批量处理队列里,然后按数量或时间窗口触发批量更新。

常见触发条件:

  • 累积到 100-500 条订单。
  • 或者等待 100ms 左右。
  • 两个条件满足任意一个就执行批量更新。

可以理解为一个小型缓冲区:

消息 1 -> buffer
消息 2 -> buffer
消息 3 -> buffer
...
达到 300 条,批量 update

这样做的好处是明显的:

  • 减少数据库连接和网络往返。
  • 降低数据库瞬时写压力。
  • 消费者可以通过批次大小控制吞吐。
  • 后续可以水平扩展多个消费者。

批量更新订单状态时,一定要带上状态条件。

比如只关闭仍然处于待支付状态的订单:

batch-close-orders.sql
UPDATE orders
SET status = 'CLOSED',
close_reason = 'TIMEOUT',
closed_at = NOW()
WHERE id IN (...)
AND status = 'WAIT_PAY'
AND expire_time <= NOW();

这里的 status = 'WAIT_PAY' 很关键。

它能保证:

  • 已支付订单不会被误关闭。
  • 已关闭订单重复消费也不会重复更新。
  • 延时消息重复投递时,更新操作仍然安全。

这就是幂等。

面试里可以直接说:消息队列天然可能重复投递,所以订单关闭逻辑必须是幂等的。

订单状态批量更新完成后,不要在同一个流程里把所有后续业务都做完。

应该发布一个订单关闭事件:

order-closed-event.json
{
"eventType": "ORDER_CLOSED",
"reason": "TIMEOUT",
"orderIds": ["202401010001", "202401010002"]
}

然后由不同消费者处理后续业务:

  • 释放库存。
  • 发起退款。
  • 通知用户。
  • 同步订单状态到 ES。
  • 删除或刷新缓存。
  • 写入业务日志。

这样可以把“关闭订单”和“关闭订单后要做什么”拆开。

关闭订单是核心链路,必须尽快完成;释放库存、通知用户、同步缓存这些动作可以异步扩展。

定时任务不是不能用,但它不适合直接承担高并发订单过期主链路。

如果用定时任务每秒扫一次订单表,大概会遇到几个问题:

  • 查询条件依赖 expire_time,数据量大时压力明显。
  • 某一秒过期订单过多时,任务执行时间不可控。
  • 任务失败后需要补偿。
  • 多实例部署时要处理分布式锁和重复扫描。
  • 单表订单量大时,还要考虑分库分表后的扫描范围。

定时任务更适合做兜底补偿,比如每隔一段时间扫描仍然处于 WAIT_PAY 且已经超时的订单,补偿可能丢失的延时消息。

所以更完整的方案是:

延时队列处理主流程
定时任务做兜底补偿

题目里说某一秒有数以千计订单过期,这其实就是一个瞬时流量峰值。

除了批量更新,还可以做一些削峰处理:

  • 多消费者并发消费,但限制每个消费者批量写入频率。
  • 将订单按业务线、商户、分库分表键分片处理。
  • 批量更新失败时拆分批次重试。
  • 对数据库连接池和 MQ 消费速度设置上限。
  • 监控积压量,必要时临时扩容消费者。

关键思想是:MQ 可以积压,消费者可以慢慢处理,但数据库不能被突然打爆。

这道题可以按下面这段话回答:

我不会让数据库在某一秒直接承受大量订单过期更新。订单创建成功后,会把订单 ID 和过期时间写入延时队列,TTL 设置为 expireTime - now()。消息到期后投递给订单过期消费者,消费者先校验订单是否仍是待支付状态,再把消息放入批量缓冲区,按固定数量或固定时间窗口统一批量更新订单状态。更新时 SQL 会带上 status = WAIT_PAY 和过期时间条件,保证幂等,避免误关已支付订单。订单关闭成功后再发布订单关闭事件,由库存、退款、通知、缓存同步等消费者异步处理。定时任务只作为兜底补偿,不作为主流程。

这段回答基本覆盖了面试官想听的几个关键词:

  • 延时队列。
  • 批量处理。
  • 幂等。
  • 削峰。
  • 事件驱动。
  • 兜底补偿。

这道题的重点不是“怎么把订单改成过期”,而是怎么在大量订单同时过期时保护数据库。

比较合理的设计是:

订单创建时写延时队列
到期后消费者接收消息
消费者聚合订单并批量更新
更新时保证幂等
关闭后发布业务事件
定时任务做兜底补偿

电商系统里很多问题都不是单点逻辑难,而是峰值、幂等、补偿、解耦这些工程问题难。这个题目正好把这些点串到了一起。

ServletConfig 接口介绍

这篇文章迁移自我早年写在博客园的一篇记录。当时主要是在学习 Servlet 初始化参数:Servlet 容器初始化 Servlet 时,会创建一个 ServletConfig 对象,并把它交给当前 Servlet 使用。

简单说,ServletConfig 适合保存“只属于某一个 Servlet 的配置”。比如某个 Servlet 自己需要的用户名、开关、路径、默认参数等。

当 Web 容器创建并初始化 Servlet 时,会为这个 Servlet 准备一个 ServletConfig 对象。这个对象里包含当前 Servlet 的初始化信息,也可以通过它拿到整个 Web 应用的 ServletContext

需要记住两点:

  • 一个 Web 应用里可以有多个 Servlet,也就可以有多个 ServletConfig 对象。
  • 一个 Servlet 只对应一个 ServletConfig 对象,所以 Servlet 初始化参数默认只对当前 Servlet 有效。

如果把 Web 应用理解成一个项目,那么:

  • ServletContext 更像“整个项目的全局上下文”。
  • ServletConfig 更像“某一个 Servlet 自己的配置说明”。

ServletConfig 常用方法不多,重点是下面这几个:

方法作用
String getInitParameter(String name)根据参数名获取当前 Servlet 的初始化参数值
Enumeration<String> getInitParameterNames()获取当前 Servlet 所有初始化参数名
ServletContext getServletContext()获取当前 Web 应用的 ServletContext 对象
String getServletName()获取当前 Servlet 名称,也就是 web.xml<servlet-name> 的值

这里最常用的是 getInitParameter()getInitParameterNames()。前者适合读取单个配置,后者适合遍历所有配置。

Servlet 里有两类参数很容易混在一起:

配置位置所属对象读取方式生效范围
<context-param>ServletContextgetServletContext().getInitParameter()整个 Web 应用
<servlet> 里的 <init-param>ServletConfiggetServletConfig().getInitParameter()当前 Servlet

也就是说,context-param 不是当前 Servlet 的私有配置,它属于整个 Web 应用。ServletConfig#getServletContext() 只是让你可以从当前 Servlet 拿到全局上下文,并不代表这些全局参数属于 ServletConfig

这个区别非常重要。很多初学 Servlet 的时候,会把“通过 ServletConfig 拿到 ServletContext,再读取全局参数”误认为是在读取 Servlet 自己的初始化参数。

先看全局参数的写法。下面的 admin-emailadmin-nameadmin-password 都配置在 <context-param> 中,因此它们属于整个 Web 应用。

web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<servlet>
<servlet-name>Servlet</servlet-name>
<servlet-class>main.java.com.Servlet</servlet-class>
</servlet>
<context-param>
<param-name>admin-email</param-name>
<param-value>123456@qq.com</param-value>
</context-param>
<context-param>
<param-name>admin-name</param-name>
<param-value>xiaoxi</param-value>
</context-param>
<context-param>
<param-name>admin-password</param-name>
<param-value>123456</param-value>
</context-param>
<servlet-mapping>
<servlet-name>Servlet</servlet-name>
<url-pattern>/Servlet</url-pattern>
</servlet-mapping>
</web-app>

读取这些全局参数时,需要先拿到 ServletContext

Servlet.java
package main.java.com;
import javax.servlet.ServletConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet(name = "Servlet", value = "/Servlet")
public class Servlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
response.setContentType("text/plain;charset=UTF-8");
ServletConfig config = getServletConfig();
ServletContext servletContext = config.getServletContext();
String adminEmail = servletContext.getInitParameter("admin-email");
String adminName = servletContext.getInitParameter("admin-name");
String password = servletContext.getInitParameter("admin-password");
response.getWriter().println("admin-email: " + adminEmail);
response.getWriter().println("admin-name: " + adminName);
response.getWriter().println("admin-password: " + password);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
doGet(request, response);
}
}

这段代码里虽然先调用了 getServletConfig(),但真正读取参数的是 servletContext.getInitParameter()。所以它读取的是全局初始化参数。

使用 web.xml 配置当前 Servlet 参数

Section titled “使用 web.xml 配置当前 Servlet 参数”

如果希望参数只属于当前 Servlet,就应该把参数写到 <servlet> 里的 <init-param> 中。

web.xml
<servlet>
<servlet-name>MyServlet</servlet-name>
<servlet-class>java.com.MyServlet</servlet-class>
<init-param>
<param-name>name</param-name>
<param-value>xiaoxi</param-value>
</init-param>
<init-param>
<param-name>admin</param-name>
<param-value>xiaoxi</param-value>
</init-param>
</servlet>

这种参数才是 ServletConfig 最典型的使用场景。

MyServlet.java
ServletConfig config = getServletConfig();
String name = config.getInitParameter("name");
String admin = config.getInitParameter("admin");

如果另一个 Servlet 也想使用同名参数,需要在另一个 Servlet 的配置里重新声明。Servlet 私有参数不会自动共享。

除了 web.xml,也可以直接使用 @WebServlet@WebInitParam 配置当前 Servlet 的初始化参数。

这种方式更适合简单项目或示例代码,因为配置和 Servlet 类写在一起,阅读起来更直观。

HelloServlet.java
package main.java.com;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebInitParam;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Enumeration;
@WebServlet(
name = "helloServlet",
value = "/helloServlet",
initParams = {
@WebInitParam(name = "name", value = "测试"),
@WebInitParam(name = "admin", value = "123456")
}
)
public class HelloServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
response.setContentType("text/html;charset=UTF-8");
ServletConfig config = getServletConfig();
String servletName = config.getServletName();
Enumeration<String> initParameterNames = config.getInitParameterNames();
PrintWriter writer = response.getWriter();
writer.write("servletName: " + servletName + "<br/>");
while (initParameterNames.hasMoreElements()) {
String initParamName = initParameterNames.nextElement();
String initParamValue = config.getInitParameter(initParamName);
writer.write(initParamName + ": " + initParamValue + "<br/>");
}
writer.close();
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
doGet(request, response);
}
}

这里的 initParams 配置只对 HelloServlet 生效。别的 Servlet 不能直接通过自己的 ServletConfig 读取这些参数。

如果项目很小,或者只是练习 Servlet,使用注解会比较方便。

如果项目配置比较多,或者需要把代码和配置分离,使用 web.xml 会更清晰。尤其是早期 Java Web 项目里,很多 Servlet、Filter、Listener 都会统一写在 web.xml 中。

可以简单按下面的方式判断:

场景推荐方式
示例代码、学习项目@WebServlet
配置较少的小项目@WebServletweb.xml 都可以
多个 Servlet 需要统一管理web.xml
希望参数不写死在 Java 类里web.xml

两种方式本质上都是告诉 Servlet 容器:这个 Servlet 叫什么、映射到哪个路径、初始化时带哪些参数。

第一,context-paraminit-param 不要混用。

如果一个参数是全站通用的,比如站点名称、管理员邮箱、上传目录,可以放在 <context-param> 中。如果一个参数只服务于某个 Servlet,就放在对应 Servlet 的 <init-param> 中。

第二,读取参数时要找对对象。

// 读取当前 Servlet 的初始化参数
getServletConfig().getInitParameter("name");
// 读取整个 Web 应用的全局参数
getServletContext().getInitParameter("admin-email");

第三,输出中文时记得设置响应编码。

response.setContentType("text/html;charset=UTF-8");

如果不设置编码,浏览器里可能会出现中文乱码。

第四,注意包名差异。

早期 Servlet 项目常见包名是 javax.servlet。如果使用的是 Tomcat 10、Spring Boot 3 或 Jakarta EE 新版本,包名会变成 jakarta.servlet

// 旧版本常见写法
import javax.servlet.ServletConfig;
// 新版本常见写法
import jakarta.servlet.ServletConfig;

学习旧项目或迁移项目时,这个差异很常见。

ServletConfig 的核心作用,是保存并读取当前 Servlet 的初始化参数。

需要特别分清楚:

  • ServletConfig 面向当前 Servlet。
  • ServletContext 面向整个 Web 应用。
  • <init-param> 是 Servlet 私有配置。
  • <context-param> 是 Web 应用全局配置。
  • 注解里的 initParams 只对当前 Servlet 生效。

把这几个概念理顺之后,Servlet 初始化参数就不难了。真正容易出错的地方,往往不是 API 本身,而是没有分清“这个配置到底属于谁”。

原文记录:ServletConfig接口介绍