
苍穹外卖项目复盘
一、项目概述
苍穹外卖是一个典型的前后端分离项目,整体分为两端:
-
管理端:餐厅后台,负责员工管理、菜品管理、套餐管理、订单处理、数据统计等
-
用户端:微信小程序,负责浏览商品、下单、支付、查看订单、催单等
项目整体采用分层架构:
Controller -> Service -> Mapper -> MySQL / Redis
项目模块结构如下:
-
sky-common:公共工具类、常量、上下文、统一返回结果 -
sky-pojo:实体类 Entity、参数类 DTO、返回类 VO -
sky-server:核心业务模块,包含 Controller、Service、Mapper、配置类、切面、拦截器等
二、技术栈全景图
| 技术 | 在项目中的作用 |
|------|----------------|
| Spring Boot | 快速搭建项目骨架,简化配置 |
| Spring MVC | 处理 HTTP 请求,构建 RESTful 接口 |
| MyBatis | 持久层框架,负责数据库访问 |
| PageHelper | 分页插件,简化分页查询 |
| MySQL | 存储用户、员工、订单、菜品、套餐等业务数据 |
| Redis | 缓存店铺状态、套餐列表等热点数据 |
| Spring Cache | 用注解方式操作缓存 |
| JWT | 无状态登录认证 |
| WebSocket | 来单提醒、催单消息推送 |
| Spring Task | 定时处理超时订单、归档订单 |
| Apache POI | 导出运营报表 Excel |
| 阿里云 OSS | 存储图片等静态资源 |
| Swagger / Knife4j | 自动生成接口文档 |
三、启动类与核心开关
在这个项目里,Spring Boot 启动类不仅仅负责启动,还打开了事务、缓存、定时任务等关键能力。
核心源码
package com.sky;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication
@EnableTransactionManagement
@EnableCaching
@EnableScheduling
@Slf4j
public class SkyApplication {
public static void main(String[] args) {
SpringApplication.run(SkyApplication.class, args);
log.info("server started");
}
}
最值得记住的点
-
@SpringBootApplication:项目主启动入口,包含组件扫描和自动配置 -
@EnableTransactionManagement:开启注解事务 -
@EnableCaching:开启 Spring Cache -
@EnableScheduling:开启定时任务
这类注解为什么加了才生效,本质上就是开启了 Spring 对这些功能的自动代理和基础设施注册。
四、技术栈与源码映射
1. IOC / DI:Bean 由 Spring 容器管理
项目里最常见的写法就是 @Autowired 注入依赖,例如:
@Service
@Slf4j
public class ReportServiceImpl implements ReportService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private WorkspaceService workspaceService;
}
核心理解
-
对象不再由我们手动
new -
而是交给 Spring 容器统一创建和维护
-
Service 依赖 Mapper,Mapper 依赖数据源,Spring 负责注入这些依赖
面试回答模板
IOC 是控制反转,指对象的创建和生命周期管理交给 Spring 容器;DI 是依赖注入,是 IOC 的实现方式。项目里通过 @Component、@Service、@Mapper 等注解把对象注册为 Bean,再通过 @Autowired 把依赖注入进来,从而降低耦合。
2. AOP:公共字段自动填充
场景
很多表都有这几个字段:
-
createTime -
updateTime -
createUser -
updateUser
如果每次都手动 set,会非常繁琐,也容易漏写。
自定义注解
package com.sky.annotation;
import com.sky.enumeration.OperationType;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
OperationType value();
}
切面实现
package com.sky.aspect;
import com.sky.annotation.AutoFill;
import com.sky.constant.AutoFillConstant;
import com.sky.context.BaseContext;
import com.sky.enumeration.OperationType;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
@Aspect
@Component
@Slf4j
public class AutoFillAspect {
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut(){}
@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint){
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);
OperationType operationType = autoFill.value();
Object[] args = joinPoint.getArgs();
if(args == null || args.length == 0){
return;
}
Object entity = args[0];
LocalDateTime now = LocalDateTime.now();
Long currentId = BaseContext.getCurrentId();
try {
if(operationType == OperationType.INSERT){
Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
setCreateTime.invoke(entity, now);
setUpdateTime.invoke(entity, now);
setCreateUser.invoke(entity, currentId);
setUpdateUser.invoke(entity, currentId);
} else if(operationType == OperationType.UPDATE){
Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
setUpdateTime.invoke(entity, now);
setUpdateUser.invoke(entity, currentId);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
这里最值得记住的点
-
AOP 解决的是横切逻辑复用问题
-
@AutoFill标注“哪些方法要自动填充” -
切面负责在方法执行前统一处理
-
底层用到了反射,动态调用 setter 方法
面试可展开
-
AOP 的底层实现是动态代理
-
Spring AOP 常见是 JDK 动态代理和 CGLIB
-
这里的反射不是代理本身,而是切面里为了动态调用方法而使用
3. MyBatis:Mapper + XML + 动态 SQL
苍穹外卖里大量数据库操作都是通过 MyBatis 完成的,最典型的就是 Mapper 接口配合 Mapper.xml。
订单 Mapper 示例
<mapper namespace="com.sky.mapper.OrderMapper">
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into orders (
number, status, user_id, address_book_id, order_time, checkout_time,
pay_method, pay_status, amount, remark, user_name, phone, address,
consignee, estimated_delivery_time, delivery_time, delivery_status,
tableware_number, tableware_status, pack_amount
)
values (
#{number}, #{status}, #{userId}, #{addressBookId}, #{orderTime}, #{checkoutTime},
#{payMethod}, #{payStatus}, #{amount}, #{remark}, #{userName}, #{phone}, #{address},
#{consignee}, #{estimatedDeliveryTime}, #{deliveryTime}, #{deliveryStatus},
#{tablewareNumber}, #{tablewareStatus}, #{packAmount}
)
</insert>
<update id="update" parameterType="com.sky.entity.Orders">
update orders
<set>
<if test="cancelReason != null and cancelReason!=''">cancel_reason=#{cancelReason},</if>
<if test="rejectionReason != null and rejectionReason!=''">rejection_reason=#{rejectionReason},</if>
<if test="cancelTime != null">cancel_time=#{cancelTime},</if>
<if test="payStatus != null">pay_status=#{payStatus},</if>
<if test="payMethod != null">pay_method=#{payMethod},</if>
<if test="checkoutTime != null">checkout_time=#{checkoutTime},</if>
<if test="status != null">status = #{status},</if>
<if test="deliveryTime != null">delivery_time = #{deliveryTime}</if>
</set>
where id = #{id}
</update>
</mapper>
核心理解
-
Mapper接口负责定义方法 -
XML 负责写 SQL
-
<if>、<where>、<set>、<foreach>用于动态拼装 SQL -
#{}是预编译参数,占位符安全
面试高频点
-
#{}和${}的区别 -
MyBatis 为什么 Mapper 接口不需要自己写实现类
-
MyBatis 执行流程:Mapper 代理 -> MappedStatement -> Executor -> ResultSet 映射
4. JWT + 拦截器 + ThreadLocal:登录态透传
这个项目里 JWT 登录态校验是通过拦截器实现的。
JWT 管理端拦截器
package com.sky.interceptor;
import com.sky.constant.JwtClaimsConstant;
import com.sky.context.BaseContext;
import com.sky.properties.JwtProperties;
import com.sky.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
@Slf4j
public class JwtTokenAdminInterceptor implements HandlerInterceptor {
@Autowired
private JwtProperties jwtProperties;
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
String token = request.getHeader(jwtProperties.getAdminTokenName());
try {
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
BaseContext.setCurrentId(empId);
return true;
} catch (Exception ex) {
response.setStatus(401);
return false;
}
}
}
ThreadLocal 上下文
package com.sky.context;
public class BaseContext {
public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
public static void setCurrentId(Long id) {
threadLocal.set(id);
}
public static Long getCurrentId() {
return threadLocal.get();
}
public static void removeCurrentId() {
threadLocal.remove();
}
}
MVC 配置注册拦截器
@Configuration
@Slf4j
public class WebMvcConfiguration extends WebMvcConfigurationSupport {
@Autowired
private JwtTokenAdminInterceptor jwtTokenAdminInterceptor;
@Autowired
private JwtTokenUserInterceptor jwtTokenUserInterceptor;
protected void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtTokenAdminInterceptor)
.addPathPatterns("/admin/**")
.excludePathPatterns("/admin/employee/login");
registry.addInterceptor(jwtTokenUserInterceptor)
.addPathPatterns("/user/**")
.excludePathPatterns("/user/user/login")
.excludePathPatterns("/user/shop/status");
}
}
这里值得记住的点
-
JWT 用于无状态认证
-
登录成功后,前端携带 token 访问接口
-
拦截器统一解析 token
-
解析出的用户 id / 员工 id 放入
BaseContext -
后续 Service 层可以随时通过
BaseContext.getCurrentId()获取当前登录人
5. 事务:订单提交为什么必须加 @Transactional
订单提交核心代码
@Transactional
public OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO) {
AddressBook addressBook = addressBookMapper.getById(ordersSubmitDTO.getAddressBookId());
if (addressBook == null) {
throw new AddressBookBusinessException(MessageConstant.ADDRESS_BOOK_IS_NULL);
}
Long userId = BaseContext.getCurrentId();
ShoppingCart shoppingCart = ShoppingCart.builder()
.userId(userId)
.build();
List<ShoppingCart> shoppingCartList = shoppingCartMapper.list(shoppingCart);
if (shoppingCartList == null || shoppingCartList.size() == 0) {
throw new AddressBookBusinessException(MessageConstant.SHOPPING_CART_IS_NULL);
}
Orders orders = new Orders();
BeanUtils.copyProperties(ordersSubmitDTO, orders);
orders.setUserId(userId);
orders.setNumber(String.valueOf(System.currentTimeMillis()));
orders.setOrderTime(LocalDateTime.now());
orders.setStatus(Orders.PENDING_PAYMENT);
orders.setConsignee(addressBook.getConsignee());
orders.setPhone(addressBook.getPhone());
orders.setPayStatus(Orders.PENDING_PAYMENT);
orderMapper.insert(orders);
List<OrderDetail> orderDetailList = new ArrayList<>();
for (ShoppingCart cart : shoppingCartList) {
OrderDetail orderDetail = new OrderDetail();
BeanUtils.copyProperties(cart, orderDetail);
orderDetail.setOrderId(orders.getId());
orderDetailList.add(orderDetail);
}
orderDetailMapper.insertBatch(orderDetailList);
shoppingCartMapper.deleteByUserId(userId);
return OrderSubmitVO.builder()
.id(orders.getId())
.orderNumber(orders.getNumber())
.orderAmount(orders.getAmount())
.orderTime(orders.getOrderTime())
.build();
}
为什么必须加事务
因为这里至少包含 3 步:
-
插入订单主表
-
插入订单明细表
-
清空购物车
如果中间某一步失败,而没有事务,就会导致数据不一致。
面试高频点
-
为什么事务建议加在 Service 层
-
为什么同类内部调用事务可能失效
-
Spring 事务底层是 AOP 代理,不是数据库自动帮你做的
6. Redis + Spring Cache:缓存套餐列表
苍穹外卖里套餐列表查询是典型的高频读场景,所以用缓存做优化。
Redis 配置
@Configuration
@Slf4j
public class RedisConfiguration {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate redisTemplate = new RedisTemplate();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
return redisTemplate;
}
}
用户端套餐缓存
@RestController("userSetmealController")
@RequestMapping("/user/setmeal")
@Api(tags = "C端-套餐浏览接口")
public class SetmealController {
@Autowired
private SetmealService setmealService;
@GetMapping("/list")
@ApiOperation("根据分类id查询套餐")
@Cacheable(cacheNames = "setmealCache", key="#categoryId")
public Result<List<Setmeal>> list(Long categoryId) {
Setmeal setmeal = new Setmeal();
setmeal.setCategoryId(categoryId);
setmeal.setStatus(StatusConstant.ENABLE);
List<Setmeal> list = setmealService.list(setmeal);
return Result.success(list);
}
}
这里值得记住的点
-
Spring Cache 是注解式缓存
-
@Cacheable:先查缓存,缓存没有才执行方法 -
套餐缓存的 key 是
categoryId -
管理端新增 / 修改 / 删除 / 起售停售套餐时,需要配合
@CacheEvict清缓存
面试可展开
-
缓存穿透、击穿、雪崩
-
双写一致性怎么做
-
Spring Cache 底层原理是
CacheInterceptor + CacheManager
7. WebSocket:来单提醒与催单消息
WebSocket 配置类
@Configuration
public class WebSocketConfiguration {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
WebSocket 服务端
@Component
@ServerEndpoint("/ws/{sid}")
public class WebSocketServer {
private static Map<String, Session> sessionMap = new HashMap();
@OnOpen
public void onOpen(Session session, @PathParam("sid") String sid) {
sessionMap.put(sid, session);
}
@OnClose
public void onClose(@PathParam("sid") String sid) {
sessionMap.remove(sid);
}
public void sendToAllClient(String message) {
Collection<Session> sessions = sessionMap.values();
for (Session session : sessions) {
try {
session.getBasicRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
支付成功后推送来单消息
public void paySuccess(String outTradeNo) {
Orders ordersDB = orderMapper.getByNumber(outTradeNo);
Orders orders = Orders.builder()
.id(ordersDB.getId())
.status(Orders.TO_BE_CONFIRMED)
.payStatus(Orders.PAID)
.checkoutTime(LocalDateTime.now())
.build();
orderMapper.update(orders);
Map<String, Object> map = new HashMap<>();
map.put("type", 1);
map.put("orderId", ordersDB.getId());
map.put("content", "订单号:" + outTradeNo);
String msg = JSON.toJSONString(map);
webSocketServer.sendToAllClient(msg);
}
这里值得记住的点
-
WebSocket 适合服务端主动推送消息
-
本项目里有两个典型场景:来单提醒、催单提醒
-
相比前端轮询,WebSocket 实时性更高、带宽开销更小
8. 定时任务:订单超时取消与归档
核心代码
@Component
@Slf4j
public class OrderTask {
@Autowired
private OrderMapper orderMapper;
@Scheduled(cron = "0 * * * * ? ")
public void processTimeoutOrder(){
List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(
Orders.PENDING_PAYMENT,
LocalDateTime.now().minusMinutes(15)
);
if (ordersList != null && ordersList.size() > 0) {
for (Orders orders : ordersList) {
orders.setStatus(Orders.CANCELLED);
orders.setCancelTime(LocalDateTime.now());
orders.setCancelReason("订单超时未支付,系统自动取消");
orderMapper.update(orders);
}
}
}
@Scheduled(cron = "0 0 1 * * ? ")
public void processDeliveryOrder() {
List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(
Orders.DELIVERY_IN_PROGRESS,
LocalDateTime.now().minusMinutes(60)
);
if (ordersList != null && ordersList.size() > 0) {
for (Orders orders : ordersList) {
orders.setStatus(Orders.COMPLETED);
orderMapper.update(orders);
}
}
}
}
这里值得记住的点
-
@Scheduled通过 cron 表达式定时执行 -
一个任务负责超时未支付订单自动取消
-
一个任务负责长时间未完成的派送订单自动归档
-
这是典型的“系统兜底逻辑”
9. 全局异常处理 + 统一返回结果
统一返回结果类
package com.sky.result;
import lombok.Data;
import java.io.Serializable;
@Data
public class Result<T> implements Serializable {
private Integer code;
private String msg;
private T data;
public static <T> Result<T> success() {
Result<T> result = new Result<>();
result.code = 1;
return result;
}
public static <T> Result<T> success(T object) {
Result<T> result = new Result<>();
result.data = object;
result.code = 1;
return result;
}
public static <T> Result<T> error(String msg) {
Result<T> result = new Result<>();
result.msg = msg;
result.code = 0;
return result;
}
}
全局异常处理器
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler
public Result exceptionHandler(BaseException ex){
log.error("异常信息:{}", ex.getMessage());
return Result.error(ex.getMessage());
}
@ExceptionHandler
public Result exceptionHandler(SQLIntegrityConstraintViolationException ex){
String message = ex.getMessage();
if(message.contains("Duplicate entry")){
String[] split = message.split("");
String userName = split[2];
return Result.error(userName + MessageConstant.ALREADY_EXIST);
} else{
return Result.error(MessageConstant.UNKNOWN_ERROR);
}
}
}
这里最值得记住的点
-
所有接口统一返回
Result -
业务异常统一走全局异常处理器
-
Controller 更干净,只关心业务成功路径
10. Apache POI:导出运营报表
关键代码
public void export(HttpServletResponse response) {
LocalDate dateBegin = LocalDate.now().minusDays(30);
LocalDate dateEnd = LocalDate.now().minusDays(1);
BusinessDataVO businessDataVO = workspaceService.getBusinessData(
LocalDateTime.of(dateBegin, LocalTime.MIN),
LocalDateTime.of(dateEnd, LocalTime.MAX)
);
InputStream in = this.getClass().getClassLoader()
.getResourceAsStream("template/运营数据报表模板.xlsx");
try {
XSSFWorkbook excel = new XSSFWorkbook(in);
XSSFSheet sheet = excel.getSheet("Sheet1");
sheet.getRow(1).getCell(1).setCellValue(dateBegin + " ~ " + dateEnd);
XSSFRow row = sheet.getRow(3);
row.getCell(2).setCellValue(businessDataVO.getTurnover());
row.getCell(4).setCellValue(businessDataVO.getOrderCompletionRate());
row.getCell(6).setCellValue(businessDataVO.getNewUsers());
ServletOutputStream out = response.getOutputStream();
excel.write(out);
out.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
这里值得记住的点
-
先准备 Excel 模板
-
再把统计数据按单元格填进去
-
最后通过输出流返回给浏览器下载
五、核心业务流程梳理:用户下单到订单完成
1. 用户提交订单
-
检查地址是否存在
-
检查购物车是否为空
-
向
orders表插入订单主表数据 -
向
order_detail表插入订单明细 -
清空购物车
2. 用户发起支付
-
调用微信支付接口创建预支付单
-
前端拿到支付参数后调起微信支付
3. 支付成功异步回调
-
微信回调
/notify/paySuccess -
服务端解密回调数据
-
根据商户订单号修改订单状态
-
用 WebSocket 给管理端推送来单提醒
支付回调核心代码
@RestController
@RequestMapping("/notify")
@Slf4j
public class PayNotifyController {
@Autowired
private OrderService orderService;
@Autowired
private WeChatProperties weChatProperties;
@RequestMapping("/paySuccess")
public void paySuccessNotify(HttpServletRequest request, HttpServletResponse response) throws Exception {
String body = readData(request);
String plainText = decryptData(body);
JSONObject jsonObject = JSON.parseObject(plainText);
String outTradeNo = jsonObject.getString("out_trade_no");
orderService.paySuccess(outTradeNo);
responseToWeixin(response);
}
}
4. 商家接单、派送、完成
-
接单:
status 2 -> 3 -
派送:
status 3 -> 4 -
完成:
status 4 -> 5
5. 用户催单
-
用户点击催单
-
后端通过 WebSocket 给管理端推送提醒消息
六、架构设计复盘
1. 分层架构
Controller:接收参数、返回结果
Service:业务逻辑、事务边界
Mapper:数据库访问
MySQL / Redis:数据存储与缓存
2. DTO / VO / Entity 三件套
-
Entity:对应数据库表结构
-
DTO:前端传入参数对象
-
VO:返回给前端的展示对象
这样的设计可以降低耦合,也更方便接口演进。
3. 拦截器与 AOP 分工
-
拦截器:更适合做登录校验、权限校验
-
AOP:更适合做事务、日志、公共字段填充这类横切逻辑
4. ThreadLocal 的作用
登录后解析出的用户 id / 员工 id 放入 BaseContext,Service 层直接读取,避免层层传参。
七、项目亮点总结
亮点 1:自定义注解 + AOP 自动填充公共字段
把重复的 createTime/updateTime/createUser/updateUser 逻辑统一抽离,既减少重复代码,也降低漏填风险。
亮点 2:Spring Cache + Redis 做套餐缓存
针对高频读取的套餐列表,命中缓存时直接返回,明显降低数据库压力。
亮点 3:WebSocket 实现来单提醒和催单推送
避免前端轮询,提高实时性,减少无效请求。
亮点 4:Spring Task 处理超时订单与归档
通过定时任务做系统兜底,保证订单状态最终一致。
亮点 5:Apache POI 导出运营报表
把运营统计数据填充到 Excel 模板中,直接下载,适合实际业务场景。
八、面试高频追问清单
1. 为什么项目里用 JWT 而不是 Session?
参考答案:
JWT 是无状态认证,不需要在服务端保存会话信息,更适合前后端分离和分布式部署场景。Session 依赖服务端存储,如果项目做集群,还要解决 Session 共享问题。JWT 只需要前端携带 token,后端校验签名即可。
2. @Transactional 为什么同类方法内部调用会失效?
参考答案:
因为 Spring 事务底层基于 AOP 代理实现,事务增强是在代理对象上生效的。同类内部调用本质上是 this.xxx(),没有走代理对象,所以事务拦截器不会执行,事务就失效了。
3. Redis 缓存与数据库的一致性怎么保证?
参考答案:
常见思路是“先更新数据库,再删除缓存”,让下一次查询重新加载新数据。对于高并发场景,还可以结合延迟双删、消息队列、Canal 订阅 binlog 等方式提升一致性。苍穹外卖里用的是典型的“读时缓存,写时删缓存”方案。
4. 线程池参数一般怎么设置?
参考答案:
要看任务类型。CPU 密集型任务线程数一般设置为 CPU 核数 + 1;IO 密集型任务可以适当放大,比如 2 倍 CPU 核数甚至更高。除此之外还要结合任务队列长度、峰值流量、拒绝策略一并考虑。
5. MyBatis 里的 #{} 和 ${} 有什么区别?
参考答案:
#{} 是预编译占位符,对应 PreparedStatement,可以防止 SQL 注入;${} 是字符串直接拼接,存在 SQL 注入风险。一般查询参数必须优先使用 #{},只有像动态表名、排序字段这种特殊场景才会考虑 ${},但要做好白名单校验。
6. Spring AOP 中 JDK 动态代理和 CGLIB 的区别?
参考答案:
JDK 动态代理要求目标类实现接口,代理对象实际上实现的是接口;CGLIB 是通过生成目标类的子类实现代理,不要求接口。Spring 默认优先用 JDK 动态代理,如果没有接口才用 CGLIB。
7. WebSocket 在集群环境下怎么实现广播?
参考答案:
单机环境下可以直接遍历本机 sessionMap 推送;如果是多节点集群,需要借助 Redis 发布订阅、MQ 或消息总线,把消息广播给所有节点,再由各节点推给自己维护的 WebSocket 连接。
8. Spring Cache 的底层原理是什么?
参考答案:
Spring Cache 本质上也是基于 AOP。方法执行前,CacheInterceptor 会根据注解先查缓存;如果命中就直接返回,不再执行目标方法;如果未命中则执行目标方法,并把结果写入缓存。实际缓存存储由 CacheManager 决定,比如可以接 Redis。
9. 定时任务在分布式部署下怎么避免重复执行?
参考答案:
最简单的方式是加分布式锁,比如 Redis 锁;更完善的方式是使用分布式调度框架,例如 XXL-Job、Quartz 集群版、ShedLock 等。核心思路是保证同一时刻只有一个节点真正执行任务。
10. JWT 怎么实现“退出登录”?
参考答案:
JWT 本身是无状态的,服务端不保存会话,所以“退出登录”通常有两种做法:一种是前端删除 token;另一种是服务端维护黑名单,把失效 token 加入 Redis,在拦截器校验时额外检查是否在黑名单中。
11. ThreadLocal 为什么会内存泄漏?怎么避免?
参考答案:
因为 ThreadLocalMap 中 key 是弱引用,但 value 可能还被线程强引用着,如果线程长期不销毁而 value 没移除,就可能造成内存泄漏。解决办法是在请求结束后手动 remove(),或者让框架在过滤器 / 拦截器的 finally 阶段清理。
12. 为什么事务、AOP、缓存这些注解能生效?
参考答案:
因为 Spring 在启动时注册了相关基础设施,并且通过代理对象增强目标方法。比如 @Transactional、@Cacheable、@CacheEvict 本质上都是通过 AOP 或拦截器链包装目标方法,在方法执行前后织入增强逻辑。
九、最后的复习建议
我建议按下面的顺序复习:
-
先讲清项目整体架构和技术栈
-
再讲用户下单到订单完成的业务主线
-
然后挑 3 个亮点重点展开:
- AOP 自动填充
- Spring Cache + Redis 缓存套餐
- WebSocket 来单提醒

447

被折叠的 条评论
为什么被折叠?



