【苍穹外卖项目 Grill 面试总结】
2026/7/29 22:27:38 网站建设 项目流程

第一轮:基础概念

Q1:一次请求经过哪些层?

正确链路

请求 → [JwtInterceptor:检查Token] → [LogAspect:记日志] → Controller → Service ↓ ← JSON Mapper ↑ ↓ [Jackson 序列化] MySQL ↓ ← Service ← 数据 ← [Redis 缓存拦截]

第二次请求同一接口时,@Cacheable 让 Service 直接走 Redis,连 Mapper 和 MySQL 都不经过。

Q2:category_id → categoryId 谁做的?

✅ MyBatis,不是 Spring MVC

配置在 application.properties:

mybatis.configuration.map-underscore-to-camel-case=true

MyBatis 查完数据库后自动把下划线命名转驼峰。Spring MVC 不管这事。

Q3:没有异常处理 vs 有异常处理

阶段查 id=999前端看到
没有异常处理Mapper 返回 null → Controller 包装 Result.success(null){"code":1,"data":null} 用户懵了
有异常处理Service 发现 null → throw new BusinessException("菜品不存在") → GlobalExceptionHandler 接住{"code":0,"msg":"菜品不存在"} ✅

Q4:#categoryId 里的 # 是什么?

✅ SpEL 表达式(Spring Expression Language)

#categoryId → 取方法参数 categoryId 的实际值。categoryId=1 → key 是 1

"categoryId" → 字面量字符串,所有分类用同一个 key,查什么都返回同一份缓存

常见 SpEL 表达式

表达式含义
#参数名取方法参数的值
#result取方法返回值
#对象.属性取对象的属性值

Q5:拦截器抛异常会被全局异常处理器捕获吗?

❌ 不会

请求 → [拦截器 preHandle] ← 在 DispatcherServlet 之前 ↓ 抛异常 → 直接返回错误给客户端 DispatcherServlet 还没介入 @RestControllerAdvice 管不到这里

@RestControllerAdvice 只拦截 Controller 层抛出的异常。拦截器在 Controller 之前,必须自己 try-catch 处理。

对比

JwtInterceptorGlobalExceptionHandler
部署位置Controller 之前Controller 之后
拦截对象HTTP 请求Controller 抛出的异常
覆盖范围门禁检查Controller → Service → Mapper 全线

Q6:form-data vs raw JSON

格式Content-Type适用场景
form-datamultipart/form-data传文件、文件+文字混合
raw JSONapplication/json传结构化数据
x-www-form-urlencodedapplication/x-www-form-urlencoded传简单表单

JSON 只支持文本,图片是二进制数据。multipart/form-data 是专门为文件上传设计的协议。

Q7:@Component、@Service、@RestController 的区别?

功能上完全一样,把 @Service 换成 @Component 项目照样跑

区别在语义(分层标签):

@Component ← 通用:"Spring 管理的组件" ├── @Service ← 业务逻辑层 ├── @Repository ← 数据访问层(MyBatis 用 @Mapper 替代) └── @Controller ← 控制层(返回视图) └── @RestController ← 返回 JSON

Q8:@Autowired 和 @Cacheable 谁先生效?

✅ @Autowired 先生效(启动时),@Cacheable 后生效(运行时)

启动时:

  • Spring 创建 DishServiceImpl → @Autowired 注入 DishMapper
  • → @Cacheable 生成代理对象(包装原对象)

运行时(来请求了):

  • → 代理检查 Redis 有没有缓存
  • → 有 → 直接返回(不执行方法体,不碰 Mapper/MySQL)
  • → 没有 → 执行方法体 → dishMapper 查 MySQL → 结果存 Redis

注入是启动时的事,缓存检查是运行时的事,不在一个时间维度。

第二轮:架构与设计

Q9:为什么 Service 要写接口 + 实现类?

关键原因:Spring AOP 代理机制

Spring 需要生成代理对象来注入 @Cacheable、@Transactional 等魔力:

DishServiceImpl(原始对象) ↓ JDK 动态代理(基于接口) Proxy 对象 = 原始对象 + @Cacheable 拦截 + @CacheEvict 拦截 ↓ Controller 拿到的就是 Proxy,缓存注解才能生效

有接口 → JDK 动态代理(干净)。没接口 → CGLIB 继承代理(也能跑,但有坑:不能代理 final/private 方法)。

Q10:allEntries = true 有什么问题?

问题:删一道菜,全清缓存 → 过度清理

更好的做法是精确清除:

@CacheEvict(cacheNames = "dish", key = "#dish.categoryId") // 只清该分类 @CacheEvict(cacheNames = "dish", key = "'all'") // 只清全部列表

Q11:Service 内部 this.xxx() 缓存会生效吗?

❌ 不会

this.getByCategoryId(1L) // ↑ this = 原始对象,没走过 Spring 的 Proxy // @Cacheable 注解 = 摆设

外部调用走代理 → 生效。内部 this 走原始对象 → 不生效。

解决方法:注入自己

@Autowired private DishService self; // 注入的是代理对象 self.getByCategoryId(1L); // 走代理 ✅

经典 Spring AOP 陷阱,面试高频考点。

第三轮:故障与边界

Q12:Redis 挂了,@Cacheable 方法会怎样?

默认直接抛异常,整个请求失败

但缓存不应该成为单点故障——缓存挂了,业务必须降级跑。

核心思想:缓存是锦上添花,不能因它瘫痪。

拦截器 + 配置可以做到 Redis 不可用时自动跳过缓存、直连数据库。

Q13:数据库插入成功,但清缓存失败 → 不一致

场景

  • insert(dish) → 成功 ✅
  • 清缓存 → Redis 网络抖动,失败 ❌
  • 结果:数据库有新菜,Redis 是旧列表

解决思路

方案做法
先删缓存再更新删缓存 → 更新数据库,查询时重建
延迟双删删缓存 → 更新 DB → 等一会儿再删一次
TTL 兜底至少过期后会强制刷新,不会永远不一致

本项目:allEntries = true + TTL 10 分钟就是最朴素的兜底。

Q14:管理员禁用员工后,旧 JWT 还能用吗?

✅ 能!

JWT 是无状态的"不记名小票"——一旦签发,只要没过期就能用。拦截器不查数据库,不检查 status。

解决方案

方案做法代价
每次请求查库拦截器查 status每次打 DB
Redis 黑名单禁用时 JWT 加入黑名单有状态了
短 TTL15 分钟过期 + Refresh Token频繁刷新

苍穹外卖:JWT + Redis 黑名单。

全部薄弱点汇总

轮次薄弱点正确答案
R1SpEL #取变量值,不是路径参数
R1驼峰转换MyBatis 做的
R1拦截器异常不被 @RestControllerAdvice 捕获
R1@Component vs @Service功能一样,语义不同
R1注入 vs 缓存时序注入启动时,缓存运行时
R2接口 + 实现类为了 JDK 动态代理(AOP)
R2allEntries = true过度清理,应精确清除
R2this.xxx()绕过代理,缓存不生效
R3Redis 挂掉缓存异常应降级,不能拖垮业务
R3DB/缓存不一致用 TTL 兜底,至少不会永远不一致
R3禁用后 JWT 仍有效无状态小票的固有缺陷,靠黑名单或短 TTL 解决

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询