news 2026/10/21 19:21:40

JavaWeb在线订餐系统实战:Spring Boot+Redis全链路开发

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JavaWeb在线订餐系统实战:Spring Boot+Redis全链路开发

1. 项目缘起与整体设计思路

1.1 为什么选择"在线订餐"作为练手项目

做JavaWeb练手项目,选什么题材其实很讲究。我见过太多人一上来就搞"博客系统"或者"图书管理系统",不是说这些不好,而是它们离真实业务太远,做完之后你很难跟别人讲清楚"这个系统到底解决了什么实际问题"。在线订餐系统不一样,它天然包含了几层复杂度:用户要浏览菜单、加购物车、下单、支付、查订单状态;后台要管理菜品、分类、库存、订单流转;如果再往深了做,还涉及优惠券、会员积分、配送状态跟踪。这些环节几乎覆盖了JavaWeb开发中所有核心知识点,而且业务逻辑是普通人一看就懂的,沟通成本极低。

我当初选这个方向,还有一个很实际的原因:外卖和在线订餐是高频场景,你随便找个朋友问他"你觉得一个订餐系统应该有什么功能",他能给你说出一堆真实需求。这种来自真实用户的反馈,比你自己拍脑袋想功能要靠谱得多。所以这个项目的定位很明确——不是做一个能上线的商业系统,而是做一个能让你把JavaWeb全链路走通的实战项目。

1.2 技术选型的取舍逻辑

技术栈的选择上,我走的是"经典但不过时"的路线。后端用Spring Boot + MyBatis-Plus,前端用Thymeleaf + Bootstrap,数据库用MySQL 8.0,缓存用Redis,构建工具用Maven。这套组合的好处是:学习资料多、社区活跃、遇到问题容易搜到答案,而且企业里确实还在大量使用。

有人可能会问:为什么不用前后端分离?为什么不上Vue或者React?我的考虑是这样的——如果你是一个JavaWeb初学者,前后端分离会让你把大量精力花在跨域配置、Token传递、前端路由这些"非核心"问题上,反而忽略了后端业务逻辑的设计。Thymeleaf虽然"老派",但它能让你在一个项目里同时理解请求响应周期、Session管理、模板渲染这些基础概念。等你把这套跑通了,再去学前后端分离,会发现很多概念是相通的。

数据库设计上,我一开始想用MongoDB存菜单,因为菜品数据结构灵活。但后来还是回到了MySQL,原因是:订单和支付相关的操作需要事务支持,而MySQL在这方面更成熟。菜单的灵活性可以通过合理的表结构设计来解决,没必要为了"灵活"牺牲事务一致性。

1.3 系统模块划分与核心流程

整个系统我分成了四个核心模块:

  • 用户模块:注册、登录、个人信息管理、收货地址管理
  • 菜单模块:菜品分类展示、菜品详情、搜索、推荐
  • 订单模块:购物车、下单、支付模拟、订单状态流转、历史订单
  • 后台管理模块:菜品管理、分类管理、订单管理、数据统计

核心业务流程是这样的:用户浏览菜单 → 加入购物车 → 确认订单(选地址、用优惠券)→ 提交订单 → 模拟支付 → 后台接单 → 状态更新 → 用户查看订单状态。这个流程看起来简单,但每个环节都有细节需要处理。比如"加入购物车"这个动作,未登录用户和已登录用户的处理逻辑就不一样——未登录时购物车数据存在Session里,登录后需要合并到Redis中。这种细节才是真正锻炼人的地方。

2. 核心细节解析与实操要点

2.1 数据库表结构设计的几个关键决策

表结构设计是整个项目的地基,我在这上面踩过不少坑。先看核心表:

表名用途关键字段设计要点
user用户信息id, username, password, phone, avatar密码必须加密存储
category菜品分类id, name, sort_order, status用sort_order控制展示顺序
dish菜品信息id, category_id, name, price, image, stock, status价格用DECIMAL(10,2)
cart购物车id, user_id, dish_id, quantity联合唯一索引(user_id, dish_id)
orders订单主表id, order_no, user_id, total_amount, status, address_idorder_no用雪花算法生成
order_item订单明细id, order_id, dish_id, quantity, price价格要冗余存储
address收货地址id, user_id, receiver, phone, detail, is_default默认地址只能有一个

这里有几个设计决策值得展开说。第一,订单明细表里的price字段必须冗余存储。很多人一开始会想"我直接从dish表关联查价格不就行了",但问题是菜品价格会变,如果用户三个月前下的单,你现在去关联查,显示的是当前价格,这就出错了。所以下单那一刻的价格必须"快照"下来。

第二,订单号不能用自增ID。自增ID会暴露你的订单量,而且容易被猜测。我用的是"日期 + 用户ID后四位 + 随机数"的组合方式,既保证了唯一性,又不会泄露敏感信息。具体生成逻辑后面会详细讲。

第三,购物车表的联合唯一索引很重要。如果没有这个索引,同一个用户对同一个菜品可能产生多条购物车记录,后面合并数量的时候就会出bug。加上UNIQUE KEY uk_user_dish (user_id, dish_id)之后,插入时用ON DUPLICATE KEY UPDATE quantity = quantity + 1就能优雅地处理"重复加购"。

2.2 购物车数据在Session与Redis之间的同步策略

购物车是订餐系统里状态最复杂的一块。未登录时,购物车存在HttpSession里,结构大概是这样:

// Session中的购物车结构 Map<Long, Integer> cartMap = new HashMap<>(); // key: dishId, value: quantity

用户登录后,需要把Session里的购物车合并到Redis中。合并逻辑是这样的:

public void mergeCart(Long userId, Map<Long, Integer> sessionCart) { String key = "cart:" + userId; for (Map.Entry<Long, Integer> entry : sessionCart.entrySet()) { Long dishId = entry.getKey(); Integer sessionQty = entry.getValue(); // Redis中已有该菜品则累加,没有则新增 Object redisQty = redisTemplate.opsForHash().get(key, dishId.toString()); if (redisQty != null) { int total = Integer.parseInt(redisQty.toString()) + sessionQty; redisTemplate.opsForHash().put(key, dishId.toString(), String.valueOf(total)); } else { redisTemplate.opsForHash().put(key, dishId.toString(), String.valueOf(sessionQty)); } } // 合并完成后清空Session购物车 sessionCart.clear(); }

注意:合并时一定要考虑"同一菜品在两边都有"的情况,直接覆盖会导致数量丢失。我当初就是没处理这个,测试时发现用户登录后购物车数量变少了,排查了半天才发现是覆盖问题。

Redis中购物车的存储结构我用的是Hash,key是cart:userId,field是dishId,value是数量。为什么不直接用String存JSON?因为Hash可以单独更新某个field,不需要把整个购物车读出来再写回去,性能更好。而且Hash的HINCRBY命令天然支持数量增减,非常适合购物车场景。

2.3 订单状态流转的设计与实现

订单状态是整个系统里最容易出问题的地方。我定义的状态流转是这样的:

待支付(0) → 已支付(1) → 已接单(2) → 配送中(3) → 已完成(4) ↓ 已取消(5)

状态流转必须严格控制,不能出现"待支付直接跳到已完成"这种情况。我的做法是在Service层加一个状态机校验:

private static final Map<Integer, List<Integer>> STATUS_FLOW = new HashMap<>(); static { STATUS_FLOW.put(0, Arrays.asList(1, 5)); // 待支付可以转为已支付或已取消 STATUS_FLOW.put(1, Arrays.asList(2, 5)); // 已支付可以转为已接单或已取消 STATUS_FLOW.put(2, Arrays.asList(3)); // 已接单只能转为配送中 STATUS_FLOW.put(3, Arrays.asList(4)); // 配送中只能转为已完成 STATUS_FLOW.put(4, Collections.emptyList()); // 已完成是终态 STATUS_FLOW.put(5, Collections.emptyList()); // 已取消是终态 } public void updateStatus(Long orderId, Integer newStatus) { Orders order = orderMapper.selectById(orderId); List<Integer> allowed = STATUS_FLOW.get(order.getStatus()); if (allowed == null || !allowed.contains(newStatus)) { throw new BusinessException("非法的状态流转"); } order.setStatus(newStatus); orderMapper.updateById(order); }

这个状态机看起来简单,但它能挡住90%的脏数据问题。我见过有人不做校验,结果后台管理员误操作把已完成的订单改成了待支付,用户看到之后直接投诉。

3. 实操过程与核心环节实现

3.1 环境搭建与项目初始化

先说一下我的开发环境:JDK 17、Maven 3.8、MySQL 8.0、Redis 6.2、IDEA 2023。版本不用完全一致,但JDK建议至少11以上,MySQL建议8.0以上(因为要用到窗口函数做统计)。

创建Spring Boot项目时,我选的依赖有:Spring Web、Thymeleaf、MyBatis-Plus、MySQL Driver、Spring Data Redis、Lombok。pom.xml里关键依赖的版本:

<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>

application.yml的配置我重点说几个:

spring: datasource: url: jdbc:mysql://localhost:3306/order_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 thymeleaf: cache: false # 开发阶段关闭缓存,方便调试 prefix: classpath:/templates/ suffix: .html

提示:serverTimezone=Asia/Shanghai这个参数一定要加,否则插入时间会差8小时。我当初就是没加,订单创建时间全是UTC时间,排查了好久。

3.2 菜品列表的分页与缓存实现

菜品列表是访问量最大的接口,如果每次都查数据库,压力会很大。我的做法是:第一页数据缓存到Redis,设置5分钟过期;其他页直接查数据库。为什么只缓存第一页?因为根据二八定律,80%的用户只会看第一页,缓存第一页的性价比最高。

public Page<DishVO> getDishPage(Integer categoryId, Integer pageNum, Integer pageSize) { // 第一页且无分类筛选时走缓存 if (pageNum == 1 && categoryId == null) { String cacheKey = "dish:page:1"; Object cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return (Page<DishVO>) cached; } Page<DishVO> page = queryFromDb(null, pageNum, pageSize); redisTemplate.opsForValue().set(cacheKey, page, 5, TimeUnit.MINUTES); return page; } return queryFromDb(categoryId, pageNum, pageSize); }

分页查询用MyBatis-Plus的Page对象,配合LambdaQueryWrapper:

Page<Dish> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Dish> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Dish::getStatus, 1) // 只查上架的 .eq(categoryId != null, Dish::getCategoryId, categoryId) .orderByDesc(Dish::getSales) .orderByDesc(Dish::getCreateTime); dishMapper.selectPage(page, wrapper);

这里有个细节:排序用了sales(销量)降序,再按创建时间降序。这样热销菜品会排在前面,新菜品也有机会展示。如果只按创建时间排,老菜品永远在前面,用户体验不好。

3.3 下单接口的完整实现与事务处理

下单是整个系统最核心也最容易出问题的环节。我把它拆成了几个步骤:

  1. 校验购物车是否为空
  2. 校验菜品是否上架、库存是否充足
  3. 计算订单总金额(含优惠券抵扣)
  4. 生成订单号和订单记录
  5. 扣减库存
  6. 清空购物车
  7. 返回订单信息

这七步必须在同一个事务里完成,任何一步失败都要回滚。代码结构:

@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(Long userId, Long addressId, Long couponId) { // 1. 获取购物车 Map<Object, Object> cart = redisTemplate.opsForHash().entries("cart:" + userId); if (cart.isEmpty()) { throw new BusinessException("购物车为空"); } // 2. 校验菜品并计算金额 BigDecimal totalAmount = BigDecimal.ZERO; List<OrderItem> items = new ArrayList<>(); for (Map.Entry<Object, Object> entry : cart.entrySet()) { Long dishId = Long.parseLong(entry.getKey().toString()); Integer quantity = Integer.parseInt(entry.getValue().toString()); Dish dish = dishMapper.selectById(dishId); if (dish == null || dish.getStatus() != 1) { throw new BusinessException("菜品已下架:" + dishId); } if (dish.getStock() < quantity) { throw new BusinessException("库存不足:" + dish.getName()); } // 扣减库存(用乐观锁防止超卖) int affected = dishMapper.deductStock(dishId, quantity); if (affected == 0) { throw new BusinessException("库存扣减失败,请重试"); } OrderItem item = new OrderItem(); item.setDishId(dishId); item.setQuantity(quantity); item.setPrice(dish.getPrice()); // 价格快照 items.add(item); totalAmount = totalAmount.add(dish.getPrice().multiply(new BigDecimal(quantity))); } // 3. 优惠券抵扣 if (couponId != null) { totalAmount = applyCoupon(userId, couponId, totalAmount); } // 4. 生成订单 Orders order = new Orders(); order.setOrderNo(generateOrderNo(userId)); order.setUserId(userId); order.setAddressId(addressId); order.setTotalAmount(totalAmount); order.setStatus(0); // 待支付 orderMapper.insert(order); // 5. 保存订单明细 for (OrderItem item : items) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } // 6. 清空购物车 redisTemplate.delete("cart:" + userId); return buildOrderVO(order, items); }

库存扣减的SQL用了乐观锁:

UPDATE dish SET stock = stock - #{quantity} WHERE id = #{dishId} AND stock >= #{quantity}

这条SQL的AND stock >= #{quantity}就是乐观锁的核心。如果库存不足,affected会是0,直接抛异常回滚。这样即使并发下单,也不会出现超卖。

3.4 订单号生成策略与实现

订单号我用的格式是:yyyyMMdd+userId后4位+6位随机数。比如用户ID是123456,2024年1月15日下单,订单号可能是2024011503456123456。

private String generateOrderNo(Long userId) { String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); String userPart = String.format("%04d", userId % 10000); String randomPart = String.format("%06d", new Random().nextInt(1000000)); return date + userPart + randomPart; }

这个方案的好处是:可读性好(一眼能看出日期)、有一定随机性(防止猜测)、长度适中(18位)。缺点是理论上存在极小概率的重复,所以数据库里order_no字段要加唯一索引,插入失败时重试一次。

实操心得:不要用UUID做订单号,太长了,用户报订单号的时候念都念不清楚。也不要用纯自增,会暴露业务量。日期+用户标识+随机数的组合是最实用的。

4. 常见问题与排查技巧实录

4.1 购物车数量显示不正确的问题排查

这个问题我遇到过两次,第一次是Session和Redis合并时数量覆盖,第二次是Redis的Hash序列化配置问题。

问题现象:用户登录后,购物车里的商品数量变成了1,但登录前明明加了3个。

排查过程:先看日志,发现合并逻辑执行了,但Redis里的值不对。用redis-cli连上去看,发现Hash的value存的是序列化后的二进制,不是可读的数字。原因是RedisTemplate默认用JDK序列化,Hash的value被序列化成了字节数组。

解决方法:配置RedisTemplate的序列化方式:

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // key用String序列化 template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); // value用JSON序列化 template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); template.afterPropertiesSet(); return template; } }

改完之后,用redis-cli看数据就是可读的JSON了,数量也正确了。

4.2 订单列表查询慢的优化过程

项目上线测试时,订单列表接口响应时间超过了2秒。我用EXPLAIN分析了一下SQL,发现orders表全表扫描了。

原因:查询条件是WHERE user_id = ? ORDER BY create_time DESC,但user_id上没有索引。

解决:加了联合索引idx_user_time (user_id, create_time DESC)。加完之后响应时间降到了50ms以内。

这里有个细节:MySQL 8.0支持降序索引,所以create_time DESC可以显式声明。如果是5.7,降序索引不生效,但联合索引依然能加速查询。

另一个优化点:订单列表不需要查所有字段,只查列表页需要的字段即可。我用select指定了字段,减少了数据传输量。

4.3 常见问题速查表

问题现象可能原因排查方法解决方案
登录后Session丢失跨域或Cookie配置问题看浏览器Cookie是否写入配置server.servlet.session.cookie.same-site
库存扣减失败并发导致乐观锁冲突看日志中affected是否为0重试或改用悲观锁
订单状态不更新状态机校验拦截看异常日志检查状态流转是否合法
Redis连接超时连接池配置过小看Redis监控调大max-active
时间差8小时时区配置错误对比数据库和Java时间加serverTimezone参数
分页总数不对缓存了Page对象但数据变了对比缓存和数据库缩短缓存时间或加版本号

4.4 几个我踩过的坑和对应的技巧

坑一:Thymeleaf模板里访问Session属性。我一开始想在模板里直接拿Session里的购物车数量,写了${session.cartSize},结果报错。正确做法是在Controller里把数据放到Model中,或者用${session.getAttribute('cartSize')}。但更好的做法是写一个拦截器,在每个请求前把购物车数量放到Model里。

坑二:MyBatis-Plus的逻辑删除和唯一索引冲突。我给dish表加了逻辑删除字段deleted,但唯一索引是name。结果删除一个菜品后,再创建同名菜品会报唯一索引冲突。解决方法是把唯一索引改成(name, deleted)联合唯一,或者用时间戳做删除标记。

坑三:Redis缓存和数据库不一致。我更新菜品信息后,忘记删缓存,导致用户看到的是旧数据。后来我定了一个规矩:任何写操作之后,立即删除对应的缓存key。不要想着更新缓存,删除更简单可靠。

坑四:订单超时未支付的处理。我一开始没做超时处理,结果待支付的订单一直占着库存。后来加了一个定时任务,每5分钟扫描一次,把创建超过30分钟且状态为待支付的订单自动取消,并回滚库存。

@Scheduled(cron = "0 */5 * * * ?") public void cancelTimeoutOrders() { LocalDateTime timeout = LocalDateTime.now().minusMinutes(30); List<Orders> timeoutOrders = orderMapper.selectList( new LambdaQueryWrapper<Orders>() .eq(Orders::getStatus, 0) .lt(Orders::getCreateTime, timeout) ); for (Orders order : timeoutOrders) { cancelOrder(order.getId()); } }

这个定时任务看起来简单,但它是保证库存准确性的关键。没有它,恶意用户可以通过反复下单不支付来锁定库存。

4.5 后台管理模块的几个实用技巧

后台管理我做得比较轻量,但有几个功能很实用:

菜品图片上传:我用的是本地存储,把图片存到/static/upload/目录下,数据库只存相对路径。上传时用MultipartFile接收,生成UUID文件名防止冲突。注意要限制文件大小和类型,防止上传恶意文件。

订单导出:管理员需要导出订单数据做对账。我用的是EasyExcel,把订单列表导出成Excel。关键是要处理大数据量,不能一次性查所有订单,要分页查询后分批写入。

数据统计:用MySQL的窗口函数做每日营业额统计:

SELECT DATE(create_time) AS day, COUNT(*) AS order_count, SUM(total_amount) AS revenue FROM orders WHERE status = 4 AND create_time >= DATE_SUB(NOW(), INTERVAL 7 DAY) GROUP BY DATE(create_time) ORDER BY day;

这个查询能直观看到最近7天的经营情况,对练手项目来说足够用了。

5. 项目扩展方向与个人体会

这个项目做完之后,我最大的感受是:JavaWeb的核心不是框架,而是对业务的理解。框架只是工具,真正难的是想清楚"用户下单时库存怎么扣才不会超卖"、"购物车在登录前后怎么同步"这些业务问题。这些问题没有标准答案,需要你根据实际场景去权衡。

如果要把这个项目继续做深,我觉得有几个方向值得尝试。第一是引入消息队列,把下单后的通知、库存扣减等操作异步化,提升系统吞吐量。第二是加分布式锁,如果用多台服务器部署,Redis的分布式锁能解决跨节点的并发问题。第三是做前后端分离,把Thymeleaf换成REST API + Vue,这是更接近企业实际的架构。

最后分享一个小技巧:做这类项目时,一定要写单元测试。我一开始觉得写测试浪费时间,后来发现每次改代码都提心吊胆,生怕改坏了别的功能。后来补了测试,虽然花了两天时间,但之后改代码心里有底了,反而更快。特别是订单状态流转、库存扣减这些核心逻辑,测试用例能帮你挡住很多低级错误。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 19:20:45

MATLAB语音信号处理:FIR与IIR滤波器设计与对比解析

1. 项目到底在做什么&#xff1a;一次把语音滤波讲明白先说结论&#xff1a;这个项目本质上是一个"语音信号处理 数字滤波器设计"的经典综合实验&#xff0c;要求用MATLAB完成两类数字滤波器——FIR&#xff08;有限冲激响应&#xff09;和IIR&#xff08;无限冲激响…

作者头像 李华
网站建设 2026/10/21 19:19:23

QQ机器人插件工程化:目录规划、事件分发与热重载实战

插件&#xff08;一&#xff09;里&#xff0c;我们把一个最简单的"收到消息就回复"的示例插件跑了起来&#xff0c;很多朋友反馈说&#xff0c;跑通那一刻挺兴奋&#xff0c;但紧接着就有点发懵&#xff1a;接下来插件越写越多&#xff0c;到底该往哪儿放&#xff1…

作者头像 李华
网站建设 2026/10/21 19:19:23

纯电车型定速巡航控制器自主开发:从需求拆解到安全验证

前两年我们团队接到一个任务&#xff1a;给一款纯电车型做定速巡航控制器的自主开发。说实话&#xff0c;当时大家的第一反应都一样——定速巡航不是已经被供应商玩透的成熟功能了吗&#xff1f;直接找方案商集成一个不就完了&#xff0c;为什么还要自己造轮子&#xff1f;但等…

作者头像 李华
网站建设 2026/10/21 19:18:56

像素艺术转实体手作:从画布到拼豆、马赛克与十字绣的完整工作流

artcraft这名字听起来像个游戏代号&#xff0c;其实是我折腾了一年多的一个创作项目。它既不是软件&#xff0c;也不是什么品牌&#xff0c;简单说&#xff0c;是一套把像素艺术转译成真实手工作品的工作流&#xff1a;屏幕上的方块画&#xff0c;变成你能握在手里的拼豆挂件、…

作者头像 李华
网站建设 2026/10/21 19:18:33

小程序云数据库综合实战:活动报名系统的权限、事务与聚合全解

小程序云数据库&#xff0c;大部分人并不陌生&#xff0c;但你可能和我最开始一样&#xff0c;单项操作都会&#xff0c;一组合就出问题。比如分页没问题&#xff0c;加上权限就查不到数据&#xff1b;报名接口没问题&#xff0c;并发一起来统计就乱。这篇文章我直接用一套“活…

作者头像 李华
网站建设 2026/10/21 19:17:58

GenFlow 3.0:生成式AI工作流编排平台深度解析

做生成式 AI 项目这几年&#xff0c;我最大的感受是&#xff1a;模型能力本身已经不是瓶颈&#xff0c;真正难的是把模型调用、提示词管理、数据流转、人工审核这些环节拧成一条稳定可控的生产链路。GenFlow 3.0 就是这样一个重新定义生成式 AI 工作流的智能编排平台&#xff0…

作者头像 李华