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中的购物车结构 MapLong, Integer cartMap new HashMap(); // key: dishId, value: quantity用户登录后需要把Session里的购物车合并到Redis中。合并逻辑是这样的public void mergeCart(Long userId, MapLong, Integer sessionCart) { String key cart: userId; for (Map.EntryLong, 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中购物车的存储结构我用的是Hashkey是cart:userIdfield是dishIdvalue是数量。为什么不直接用String存JSON因为Hash可以单独更新某个field不需要把整个购物车读出来再写回去性能更好。而且Hash的HINCRBY命令天然支持数量增减非常适合购物车场景。2.3 订单状态流转的设计与实现订单状态是整个系统里最容易出问题的地方。我定义的状态流转是这样的待支付(0) → 已支付(1) → 已接单(2) → 配送中(3) → 已完成(4) ↓ 已取消(5)状态流转必须严格控制不能出现待支付直接跳到已完成这种情况。我的做法是在Service层加一个状态机校验private static final MapInteger, ListInteger 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); ListInteger 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 groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependencyapplication.yml的配置我重点说几个spring: datasource: url: jdbc:mysql://localhost:3306/order_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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提示serverTimezoneAsia/Shanghai这个参数一定要加否则插入时间会差8小时。我当初就是没加订单创建时间全是UTC时间排查了好久。3.2 菜品列表的分页与缓存实现菜品列表是访问量最大的接口如果每次都查数据库压力会很大。我的做法是第一页数据缓存到Redis设置5分钟过期其他页直接查数据库。为什么只缓存第一页因为根据二八定律80%的用户只会看第一页缓存第一页的性价比最高。public PageDishVO 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 (PageDishVO) cached; } PageDishVO page queryFromDb(null, pageNum, pageSize); redisTemplate.opsForValue().set(cacheKey, page, 5, TimeUnit.MINUTES); return page; } return queryFromDb(categoryId, pageNum, pageSize); }分页查询用MyBatis-Plus的Page对象配合LambdaQueryWrapperPageDish page new Page(pageNum, pageSize); LambdaQueryWrapperDish 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. 获取购物车 MapObject, Object cart redisTemplate.opsForHash().entries(cart: userId); if (cart.isEmpty()) { throw new BusinessException(购物车为空); } // 2. 校验菜品并计算金额 BigDecimal totalAmount BigDecimal.ZERO; ListOrderItem items new ArrayList(); for (Map.EntryObject, 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 订单号生成策略与实现订单号我用的格式是yyyyMMdduserId后4位6位随机数。比如用户ID是1234562024年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 RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, 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); ListOrders timeoutOrders orderMapper.selectList( new LambdaQueryWrapperOrders() .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这是更接近企业实际的架构。最后分享一个小技巧做这类项目时一定要写单元测试。我一开始觉得写测试浪费时间后来发现每次改代码都提心吊胆生怕改坏了别的功能。后来补了测试虽然花了两天时间但之后改代码心里有底了反而更快。特别是订单状态流转、库存扣减这些核心逻辑测试用例能帮你挡住很多低级错误。