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_id | order_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 下单接口的完整实现与事务处理
下单是整个系统最核心也最容易出问题的环节。我把它拆成了几个步骤:
- 校验购物车是否为空
- 校验菜品是否上架、库存是否充足
- 计算订单总金额(含优惠券抵扣)
- 生成订单号和订单记录
- 扣减库存
- 清空购物车
- 返回订单信息
这七步必须在同一个事务里完成,任何一步失败都要回滚。代码结构:
@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,这是更接近企业实际的架构。
最后分享一个小技巧:做这类项目时,一定要写单元测试。我一开始觉得写测试浪费时间,后来发现每次改代码都提心吊胆,生怕改坏了别的功能。后来补了测试,虽然花了两天时间,但之后改代码心里有底了,反而更快。特别是订单状态流转、库存扣减这些核心逻辑,测试用例能帮你挡住很多低级错误。