【苍穹外卖项目 Grill 面试总结】

【苍穹外卖项目 Grill 面试总结】
第一轮基础概念Q1一次请求经过哪些层正确链路请求 → [JwtInterceptor检查Token] → [LogAspect记日志] → Controller → Service ↓ ← JSON Mapper ↑ ↓ [Jackson 序列化] MySQL ↓ ← Service ← 数据 ← [Redis 缓存拦截]第二次请求同一接口时Cacheable 让 Service 直接走 Redis连 Mapper 和 MySQL 都不经过。Q2category_id → categoryId 谁做的✅ MyBatis不是 Spring MVC配置在 application.propertiesmybatis.configuration.map-underscore-to-camel-casetrueMyBatis 查完数据库后自动把下划线命名转驼峰。Spring MVC 不管这事。Q3没有异常处理 vs 有异常处理阶段查 id999前端看到没有异常处理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 的实际值。categoryId1 → key 是 1categoryId → 字面量字符串所有分类用同一个 key查什么都返回同一份缓存常见 SpEL 表达式表达式含义#参数名取方法参数的值#result取方法返回值#对象.属性取对象的属性值Q5拦截器抛异常会被全局异常处理器捕获吗❌ 不会请求 → [拦截器 preHandle] ← 在 DispatcherServlet 之前 ↓ 抛异常 → 直接返回错误给客户端 DispatcherServlet 还没介入 RestControllerAdvice 管不到这里RestControllerAdvice 只拦截 Controller 层抛出的异常。拦截器在 Controller 之前必须自己 try-catch 处理。对比JwtInterceptorGlobalExceptionHandler部署位置Controller 之前Controller 之后拦截对象HTTP 请求Controller 抛出的异常覆盖范围门禁检查Controller → Service → Mapper 全线Q6form-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 是专门为文件上传设计的协议。Q7Component、Service、RestController 的区别功能上完全一样把 Service 换成 Component 项目照样跑区别在语义分层标签Component ← 通用Spring 管理的组件 ├── Service ← 业务逻辑层 ├── Repository ← 数据访问层MyBatis 用 Mapper 替代 └── Controller ← 控制层返回视图 └── RestController ← 返回 JSONQ8Autowired 和 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 方法。Q10allEntries true 有什么问题问题删一道菜全清缓存 → 过度清理更好的做法是精确清除CacheEvict(cacheNames dish, key #dish.categoryId) // 只清该分类 CacheEvict(cacheNames dish, key all) // 只清全部列表Q11Service 内部 this.xxx() 缓存会生效吗❌ 不会this.getByCategoryId(1L) // ↑ this 原始对象没走过 Spring 的 Proxy // Cacheable 注解 摆设外部调用走代理 → 生效。内部 this 走原始对象 → 不生效。解决方法注入自己Autowired private DishService self; // 注入的是代理对象 self.getByCategoryId(1L); // 走代理 ✅经典 Spring AOP 陷阱面试高频考点。第三轮故障与边界Q12Redis 挂了Cacheable 方法会怎样默认直接抛异常整个请求失败但缓存不应该成为单点故障——缓存挂了业务必须降级跑。核心思想缓存是锦上添花不能因它瘫痪。拦截器 配置可以做到 Redis 不可用时自动跳过缓存、直连数据库。Q13数据库插入成功但清缓存失败 → 不一致场景insert(dish) → 成功 ✅清缓存 → Redis 网络抖动失败 ❌结果数据库有新菜Redis 是旧列表解决思路方案做法先删缓存再更新删缓存 → 更新数据库查询时重建延迟双删删缓存 → 更新 DB → 等一会儿再删一次TTL 兜底至少过期后会强制刷新不会永远不一致本项目allEntries true TTL 10 分钟就是最朴素的兜底。Q14管理员禁用员工后旧 JWT 还能用吗✅ 能JWT 是无状态的不记名小票——一旦签发只要没过期就能用。拦截器不查数据库不检查 status。解决方案方案做法代价每次请求查库拦截器查 status每次打 DBRedis 黑名单禁用时 JWT 加入黑名单有状态了短 TTL15 分钟过期 Refresh Token频繁刷新苍穹外卖JWT Redis 黑名单。全部薄弱点汇总轮次薄弱点正确答案R1SpEL #取变量值不是路径参数R1驼峰转换MyBatis 做的R1拦截器异常不被 RestControllerAdvice 捕获R1Component vs Service功能一样语义不同R1注入 vs 缓存时序注入启动时缓存运行时R2接口 实现类为了 JDK 动态代理AOPR2allEntries true过度清理应精确清除R2this.xxx()绕过代理缓存不生效R3Redis 挂掉缓存异常应降级不能拖垮业务R3DB/缓存不一致用 TTL 兜底至少不会永远不一致R3禁用后 JWT 仍有效无状态小票的固有缺陷靠黑名单或短 TTL 解决