1. 项目概述:基于SSM的在线订餐管理系统实战解析
去年参与某高校食堂信息化改造时,我主导开发了一套在线订餐系统。这个采用SSM框架的解决方案上线后,日均订单量突破3000单,将食堂高峰期的排队时间缩短了60%。本文将完整还原该系统的技术实现细节,特别适合需要开发餐饮类管理系统的Java开发者参考。
2. 技术架构设计
2.1 为什么选择SSM框架组合
在技术选型阶段,我们对比了三种主流方案:
- SSH框架(Struts2+Spring+Hibernate)
- SpringBoot全家桶
- SSM框架(Spring+SpringMVC+MyBatis)
最终选择SSM主要基于以下考量:
- MyBatis的SQL可控性:食堂业务涉及复杂的优惠计算逻辑,需要精细控制SQL
- SpringMVC的轻量级:相比Struts2更符合RESTful风格
- 学习曲线平缓:团队成员都有Spring基础
实际开发中发现:MyBatis的Mapper接口与XML映射的配合,特别适合处理多表关联查询。例如订单明细与菜品信息的联查,通过
<resultMap>可以优雅地处理嵌套结果。
2.2 数据库设计要点
2.2.1 核心表结构
sql复制CREATE TABLE `dish` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '菜品名称',
`price` decimal(10,2) NOT NULL COMMENT '当前售价',
`origin_price` decimal(10,2) DEFAULT NULL COMMENT '原价',
`stock` int(11) NOT NULL DEFAULT '0' COMMENT '库存',
`category_id` int(11) DEFAULT NULL COMMENT '分类ID',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1上架 0下架',
`sales` int(11) DEFAULT '0' COMMENT '月销量',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2.2.2 订单表的特殊设计
考虑到高校场景的并发特点,我们做了这些优化:
- 添加
order_no字段并建立唯一索引,使用"日期+随机数"生成 - 将收货地址拆分为
campus、building、room三个字段 - 使用TINYINT存储订单状态(1待支付 2已接单 3配送中 4已完成 5已取消)
3. 核心功能实现
3.1 高并发下单流程
java复制@Transactional
public OrderResult createOrder(OrderRequest request) {
// 1. 校验库存
List<DishStock> lockResults = dishMapper.batchLockStock(
request.getItems().stream()
.collect(Collectors.toMap(
OrderItemDTO::getDishId,
OrderItemDTO::getQuantity)));
// 2. 生成订单号(日期+6位随机数)
String orderNo = DateUtil.format(new Date(), "yyyyMMddHHmmss")
+ RandomUtil.randomNumbers(6);
// 3. 计算优惠(学生专属折扣)
BigDecimal discount = memberService.getStudentDiscount(request.getUserId());
// 4. 创建订单主表
Order order = new Order();
order.setOrderNo(orderNo);
order.setTotalAmount(calculateTotal(request.getItems()));
order.setDiscountAmount(order.getTotalAmount().multiply(discount));
orderMapper.insert(order);
// 5. 创建订单明细
List<OrderItem> items = convertToItems(request.getItems(), order.getId());
orderItemMapper.batchInsert(items);
return OrderResult.success(orderNo);
}
3.2 定时任务设计
使用Spring Task实现三个关键任务:
java复制// 每日凌晨更新菜品销量
@Scheduled(cron = "0 0 0 * * ?")
public void resetDailySales() {
dishMapper.resetDailySales();
}
// 每5分钟检查超时未支付订单
@Scheduled(cron = "0 */5 * * * ?")
public void cancelUnpaidOrders() {
List<Order> orders = orderMapper.selectUnpaid(30);
orders.forEach(order -> {
order.setStatus(OrderStatus.CANCELLED);
orderMapper.updateById(order);
// 释放库存
dishMapper.batchReleaseStock(order.getId());
});
}
4. 性能优化实践
4.1 缓存策略设计
采用多级缓存架构:
- 本地缓存:使用Caffeine缓存热门菜品信息(有效期2分钟)
- Redis缓存:
- 菜品详情:设置5分钟过期时间
- 购物车数据:采用Hash结构存储
- 分布式锁:使用SETNX实现
java复制public Dish getDishWithCache(Integer id) {
// 先查本地缓存
Dish dish = localCache.getIfPresent(id);
if (dish != null) return dish;
// 再查Redis
String key = "dish:" + id;
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
dish = JSON.parseObject(json, Dish.class);
localCache.put(id, dish);
return dish;
}
// 最后查数据库
dish = dishMapper.selectById(id);
if (dish != null) {
redisTemplate.opsForValue().set(key,
JSON.toJSONString(dish), 5, TimeUnit.MINUTES);
}
return dish;
}
4.2 数据库优化方案
-
索引优化:
- 为订单表的
user_id和create_time创建联合索引 - 为菜品表的
category_id和status创建联合索引
- 为订单表的
-
SQL优化:
- 避免在WHERE子句中对字段进行函数操作
- 使用JOIN代替子查询处理关联数据
-
分表策略:
- 订单表按月分表(order_202301、order_202302)
- 使用MyBatis拦截器实现动态表名
5. 踩坑记录与解决方案
5.1 分布式锁的坑
初期使用简单的Redis锁:
java复制// 错误示范
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent("lock:order:"+userId, "1");
后来发现存在两个问题:
- 未设置过期时间可能导致死锁
- 业务执行时间超过锁有效期导致锁失效
最终解决方案:
java复制String lockKey = "lock:order:" + userId;
String lockValue = UUID.randomUUID().toString();
try {
// 设置锁且10秒自动过期
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 业务逻辑
}
} finally {
// 使用Lua脚本保证原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
lockValue);
}
5.2 事务失效场景
发现一个典型问题:在Controller直接调用this.createOrder()导致事务失效。这是因为:
- Spring事务基于AOP代理
- 直接调用内部方法会绕过代理
正确做法:
- 将方法放到单独Service类
- 或者通过ApplicationContext获取代理对象
6. 安全防护措施
6.1 防XSS攻击
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new XssInterceptor())
.addPathPatterns("/dish/update")
.addPathPatterns("/order/create");
}
}
public class XssInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) {
String name = request.getParameter("name");
if (StringUtils.isEmpty(name)) return true;
if (HtmlUtils.hasHtml(name)) {
throw new BusinessException("包含非法字符");
}
return true;
}
}
6.2 数据权限控制
在MyBatis拦截器中自动添加过滤条件:
java复制@Intercepts(@Signature(type= Executor.class,
method="query",
args={MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
public class DataAuthInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) {
// 如果是查询菜品
if (isDishQuery(invocation)) {
BoundSql boundSql = ((MappedStatement)invocation.getArgs()[0])
.getBoundSql(invocation.getArgs()[1]);
String newSql = boundSql.getSql()
+ " AND status = 1"; // 只查上架菜品
resetSql(invocation, newSql);
}
return invocation.proceed();
}
}
这套系统经过三个月的迭代开发,最终实现了:
- 平均响应时间 < 200ms
- 支持500+并发订单
- 99.9%的服务可用性
在开发过程中,最大的收获是认识到:好的系统设计必须考虑实际的业务场景。比如高校食堂的就餐时间非常集中,这就要求系统在11:00-12:30期间必须保持高性能。我们通过预热缓存、动态扩容等手段,成功应对了每日的流量高峰。