
直接从项目本身聊起。拿到基于Spring Boot的网上超市系统这种题目很多刚从学校出来或者自学完Spring Boot的朋友第一反应是又一个老掉牙的CRUD系统但真正动手做过之后你会发现网上超市和普通的管理系统完全不是一回事。它牵扯到的模块比想象中多太多商品、库存、购物车、订单、支付、会员、营销、物流、后台管理几乎把电商的核心链路都过了一遍。这也是为什么很多课程也好、毕设也好都喜欢选这个题目——看起来简单做起来全是细节。这篇文章我基于自己实际开发过的项目把从需求拆解、数据库设计、后端接口实现到最后的部署排查完完整整梳理一遍。源码、数据库、文档这三件事我会分别展开尤其是一些坑网上教程一般不会告诉你。1. 项目整体设计与模块拆解1.1 网上超市到底在做什么网上超市和淘宝这类平台电商有一个本质区别平台电商是多商家入驻网上超市通常是自营 总部统一定价。这就决定了系统角色一定分两端用户端和管理端。用户端要解决的是怎么买得爽注册登录、分类浏览、关键词搜索、商品详情、加购物车、下单结算、地址管理、订单查询、在线支付或者货到付款。管理端要解决的是怎么管得住商品上下架、分类维护、库存台账、订单处理、发货流程、用户管理、数据报表。角色不同需求边界完全不同。做系统之前先把这两端画清楚比什么都重要。我见过好几个人上来就写代码写到最后用户端和管理端混在一个Controller里接口权限乱七八糟改一个需求动全身。1.2 技术选型为什么是Spring Boot选Spring Boot核心原因有三点。第一生态成熟。网上超市涉及支付对接、微信登录、短信验证、物流接口、文件上传这些通用能力Spring Boot在Java领域几乎都有现成的Starter可以拿过来用不需要重复造轮子。第二约定优于配置。做过传统SSH项目的朋友应该深有体会XML配置要写一大堆Spring Boot通过自动配置解决了这个痛点。特别是做单体项目一个jar包打天下部署的时候扔到服务器上java -jar就完事了。第三团队协作成本低。如果一个项目要做成毕设或者交付给别的开发者二次开发Spring Boot的项目结构足够规整有经验的Java开发者拿到代码以后十分钟就能上手。实际项目中我选型是这样的模块技术选型选型理由核心框架Spring Boot 2.x稳定、资料多、社区活跃持久层MyBatis Plus单表操作不用写SQL复杂查询手写SQL更灵活数据库MySQL 5.7 / 8.x互联网行业最主流的关系型数据库缓存Redis商品详情、验证码、购物车缓存前端Thymeleaf模板 Bootstrap单体项目简单直接管理端可以用Vue项目管理Maven依赖管理顺手国内镜像多1.3 一个容易忽略的高层设计问题网上超市有一个特殊的地方它是典型的读多写少系统。用户浏览商品、刷分类、看详情这些操作占用90%以上的流量真正下单结算的只有10%。这个特征决定了你在做技术方案的时候要把优化重心放在查询链路上。所以我在设计的时候把商品详情页做了Redis缓存热点商品信息缓存时间设计成30分钟后台修改商品后主动删除缓存。这个方案很简单但效果非常明显数据库压力直接降了一大半。如果没有这一步并发稍微高一点数据库的CPU就会先撑不住然后整个系统跟着卡死。2. 数据库设计决定了系统的天花板2.1 先画核心表结构把需求理清楚之后第一件事不是写接口而是把数据库表建出来。网上超市的数据库我把它分成基础信息表和业务流水表两块。基础信息表user用户表id、username、password、phone、email、avatar、status、create_timecategory商品分类表id、name、parent_id、sort、statusproduct商品表id、category_id、name、subtitle、main_image、detail、price、stock、status、sales、create_timeaddress收货地址表id、user_id、receiver、phone、province、city、district、detail、is_default业务流水表cart购物车表id、user_id、product_id、quantity、checked、create_timeorders订单表id、order_no、user_id、address_id、total_amount、payment_type、status、delivery_company、delivery_no、payment_time、create_timeorder_item订单明细表id、order_id、product_id、product_name、product_image、current_unit_price、quantity、total_pricereceiver收货信息表有些人直接并入orders表但拆开更清晰核心原则就一条订单表一旦生成所有的商品信息快照就必须复制到订单明细里面来不能通过外键去查product表。为什么因为商品价格会变、商品名称会变、商品可能被下架删除如果订单明细去关联商品表等用户查询历史订单的时候看到的价格和商品名可能已经是现在的新数据了那就乱了套。2.2 字段设计的几个细节真正的细节藏在字段设计里。就拿product商品表来说新手最容易犯的错是把价格设计成double或者float。我见过多少人在这里踩坑钱这种东西float精度一丢就是几毛钱的事情在你眼皮子底下发生。价格必须用decimal(10,2)库存用int订单金额同样decimal(10,2)。再比如订单表主键我强烈建议不要用自增id做订单号。用户看到订单号一眼就看出你的系统一天产生多少订单这是商业数据泄露。我采用的方式是时间戳 用户ID后四位 随机四位做一个order_no字段唯一索引用户看得舒服系统也好关联查询。2.3 时间字段别用varchar建表的时候很多表都有create_time、update_time这类字段直接在MySQL里用datetime类型然后通过MyBatis-Plus的自动填充功能来维护不需要在Java代码里手动set。有些新手喜欢在Java代码里new Date()然后set进去也不是不行但一旦时间格式不统一接口返回的时候各有各的格式联调的时候就非常痛苦。统一建议数据库层用datetimeMyBatis-Plus配置一个MetaObjectHandler插入时自动填create_time和update_time查询返回后通过JsonFormat统一输出成yyyy-MM-dd HH:mm:ss。这样整个项目的时间处理口径就完全一致了。3. 后端核心功能实现从需求到代码3.1 商品模块先做查询优化商品模块是网上超市的心脏。用户逛超市浏览什么看到什么都是从商品表查出来的。商品查询有个经典场景多条件筛选按分类、按价格区间、按关键词搜索、按销量排序、按新品排序。这些条件任意组合所以我用MyBatis Plus的Wrapper构造动态条件。public PageProduct queryProduct(ProductQuery query, Integer pageNum, Integer pageSize) { PageProduct page new Page(pageNum, pageSize); LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); // 分类筛选 wrapper.eq(query.getCategoryId() ! null, Product::getCategoryId, query.getCategoryId()); // 关键词搜索一般先查名称再查副标题 if (StringUtils.hasText(query.getKeyword())) { wrapper.and(w - w.like(Product::getName, query.getKeyword()) .or().like(Product::getSubtitle, query.getKeyword())); } // 价格区间 wrapper.gt(query.getMinPrice() ! null, Product::getPrice, query.getMinPrice()); wrapper.lt(query.getMaxPrice() ! null, Product::getPrice, query.getMaxPrice()); // 上下架状态管理端要能看到所有用户端只查上架的 wrapper.eq(Product::getStatus, query.getStatus() ! null ? query.getStatus() : 1); // 排序 if (sale.equals(query.getSort())) { wrapper.orderByDesc(Product::getSales); } else if (new.equals(query.getSort())) { wrapper.orderByDesc(Product::getCreateTime); } else { wrapper.orderByDesc(Product::getSales); } return productMapper.selectPage(page, wrapper); }这不难但有一个细节不讲不行库存字段和销量字段在查询的时候一定不要加乐观锁版本号否则每次查询都会多带上version字段对性能有损耗。只在更新库存和销量的时候才用乐观锁控制。3.2 购物车别只放Redis网上超市的购物车有两个派别存在Redis、存在数据库。用Redis存的好处是快用户加购删购是纯内存操作体验很好。坏处是Redis如果没做持久化配置一重启购物车数据就没了用户辛辛苦苦挑了半天东西突然清空这种体验放到线上是要出事的。纯数据库存的好处是稳定只是每次操作多一次数据库交互性能会打一点折扣。我最终用的是Redis MySQL双写方案用户加购的时候优先操作Redis同时异步同步到MySQL用户查询购物车时优先读Redis。这个方案不是最优但是最稳特别适合网上超市这种剁手场景。购物车接口设计上有一个很重要的是批量操作。比如用户在购物车页面全选、批量删除、批量修改购买数量如果一个一个调接口前端发十几个请求过来后端控制器的代码就要写吐了。我通常提供三个接口// 添加购物车 postMapping(/cart/add) // 参数productId, quantity // 更新购物车商品数量与选中状态 postMapping(/cart/update) // 参数cartId, quantity, checked // 删除购物车商品 postMapping(/cart/delete) // 参数cartIds(List批量)3.3 订单模块锁库存是硬功夫整个网上超市系统最考验水平的就是下单接口。这个接口涉及的操作太多了校验商品状态、校验库存、计算价格、生成订单号、扣减库存、创建订单明细、清空购物车、发起支付一步出错整个链路就崩了。我用Transactional把整个下单逻辑包在同一事务里任何一步失败都整体回滚避免出现订单建了但库存没扣或者库存扣了但订单没建这种数据不一致。扣减库存的SQL是最关键的不能用先查库存再update这种两步操作因为并发的时候两个请求同时查到库存还有10件都执行了update最后库存变负了也不知道。正确做法是在SQL层面做原子扣减Update(UPDATE product SET stock stock - #{quantity} WHERE id #{productId} AND stock #{quantity}) Integer deductStock(Param(productId) Long productId, Param(quantity) Integer quantity);注意这个stock #{quantity}条件受影响行数为0说明库存不够直接抛异常回滚。下单接口的核心逻辑我简化之后是这样的Transactional(rollbackFor Exception.class) public OrderVO createOrder(OrderCreateDTO dto, Long userId) { // 1. 查询收货地址确认归属当前用户 Address address addressService.getById(dto.getAddressId()); Assert.isTrue(address ! null address.getUserId().equals(userId), 收货地址不合法); // 2. 查询购物车中被选中的商品 ListCart cartList cartService.getCheckedCartList(userId); Assert.notEmpty(cartList, 请先勾选要结算的商品); // 3. 遍历商品校验状态并扣减库存 BigDecimal totalAmount BigDecimal.ZERO; ListOrderItem orderItems new ArrayList(); for (Cart cart : cartList) { Product product productService.getById(cart.getProductId()); Assert.isTrue(product ! null product.getStatus() 1, 商品已下架); // 原子扣减库存 Integer rows productMapper.deductStock(cart.getProductId(), cart.getQuantity()); Assert.isTrue(rows 0, 商品[ product.getName() ]库存不足); OrderItem item new OrderItem(); item.setProductId(product.getId()); item.setProductName(product.getName()); item.setProductImage(product.getMainImage()); item.setCurrentUnitPrice(product.getPrice()); // 快照 item.setQuantity(cart.getQuantity()); item.setTotalPrice(product.getPrice().multiply(BigDecimal.valueOf(cart.getQuantity()))); totalAmount totalAmount.add(item.getTotalPrice()); orderItems.add(item); } // 4. 生成订单主记录 Orders order new Orders(); order.setOrderNo(generateOrderNo(userId)); order.setUserId(userId); order.setAddressDetail(buildAddressSnapshot(address)); order.setTotalAmount(totalAmount); order.setStatus(0); // 0待支付1已支付2已发货3已签收4已取消 orderService.save(order); // 5. 批量保存订单明细 orderItemService.saveBatch(orderItems); // 6. 清空已结算购物车 cartService.removeCheckedCart(userId); return new OrderVO(order, orderItems); }3.4 事务与超时必须设置合理超时订单模块的事务里如果包含远程调用比如调支付接口那事务一定要拆分。因为远程调用是IO操作网络抖动可能导致事务迟迟不提交数据库连接一直被占用等到并发上来连接池直接被打满。我在实际操作中把创建订单和发起支付调用分成了两个独立事务第一个事务里只做本地数据库操作订单状态是待支付事务提交之后再拿订单号去请求支付接口回调回来再更新订单状态为已支付。这中间如果回调失败了就需要定时任务去扫单。我会起一个简单的定时任务每隔五分钟扫描超过30分钟仍未支付成功的订单自动取消并恢复库存。网上超市的订单超时时间我一般设成30分钟太短了用户来不及付款太长了商品库存被白白占用。3.5 文档写接口文档是为了自己好这个项目要求源码 数据库 文档很多做过的同学都把文档当应付差事到最后才用Word糊弄一份。我的建议是文档从开发第一天就开始写而且不是写Word先写接口文档。工具用YApi、Apifox、Swagger都行。我自己用的Apifox管理接口和做Mock都比较方便。开发过程中每写一个接口就把请求参数、返回示例补充进去前后端联调的时候直接甩一个链接过去对方自己看不用一遍一遍在聊天工具里贴JSON。数据库文档我推荐用一个叫screw的工具可以直接根据数据库表结构生成HTML文档什么表、什么字段、什么注释、什么索引一目了然生成出来以后放到项目docs目录下交付的时候连写说明文档的功夫都省了。4. 权限与状态机网上超市的系统骨架4.1 登录拦截与用户角色网上超市有用户和管理员两个角色Spring Boot里最朴素的实现方式就是用一个HandlerInterceptor做登录拦截。我在项目里面定义了一个UserContext用一个ThreadLocal存放当前登录用户信息拦截器从Redis取出登录凭证并解析用户。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 从header拿到token String token request.getHeader(Authorization); if (StringUtils.hasText(token)) { // 先尝试解析为普通用户再尝试管理员 LoginUser user userService.getLoginUserByToken(token); if (user ! null) { UserContext.set(user); return true; } } // 未登录返回401 response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\请先登录\}); return false; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 防止内存泄漏 UserContext.clear(); } }管理员部分再单独搞一个AdminInterceptor拦截/admin/**路径校验当前登录用户有没有管理员角色。这种两段拦截的方式比较清晰用户只管用户路径管理员管管理路径不会互相干扰。4.2 订单状态机订单状态字段我设计成int而不是字符串好处是方便前端做映射、查数据库更高效。但int类型有一个问题状态含义全靠注释后端的同学翻代码的时候容易看懵。所以我做了一个订单状态的常量类全部用静态常量表示。public class OrderStatus { public static final Integer PENDING_PAYMENT 0; // 待支付 public static final Integer PENDING_DELIVERY 1; // 已支付待发货 public static final Integer DELIVERED 2; // 已发货 public static final Integer COMPLETED 3; // 已签收 public static final Integer CANCELLED 4; // 已取消 }状态流转必须做校验不能允许从已发货跳回待支付否则订单数据就乱了。我在订单状态更新的方法里加了一个可流转状态集合的判断private static final MapInteger, SetInteger STATUS_FLOW new HashMap(); static { STATUS_FLOW.put(OrderStatus.PENDING_PAYMENT, Set.of(OrderStatus.PENDING_DELIVERY, OrderStatus.CANCELLED)); STATUS_FLOW.put(OrderStatus.PENDING_DELIVERY, Set.of(OrderStatus.DELIVERED)); STATUS_FLOW.put(OrderStatus.DELIVERED, Set.of(OrderStatus.COMPLETED)); }现在这个例子里面状态机的判断逻辑在常量类里面集中管理后续如果要增加退款中已退款这些状态只需要改这一处配置。4.3 定时任务扫单Spring Boot内嵌的Scheduled注解就够用了只要在启动类上加上EnableScheduling然后写一个方法用cron表达式控制执行频率。扫单逻辑分两块超时未支付的订单把订单状态改成已取消把库存加回去。发货后超过15天未确认收货的订单自动确认收货。这里要注意扫单SQL要控制好范围每次只扫描需要处理的订单别把全表都扫一遍。我一般限制一次处理500条处理完一批再处理下一批避免大事务长事务。5. 实战中踩过的坑与性能优化建议5.1 MyBatis-Plus联表查询的坑很多新手一用MyBatis-Plus就沉迷于它自带的selectById、updateById遇到联表查询的时候就开始硬倒腾Java层代码查出两个List之后在内存里组装。这种做法在小数据量的时候看不出来问题商品几百条的时候性能也没毛病但是数据一旦到几千几万内存组装就会变得极其难受代码也会越写越乱。我的习惯是单表操作用MyBatis-Plus联表查询直接写XML里的自定义SQL。举个例子后台订单列表需要展示用户名、收货人、商品名这些跨表数据直接写成OrderMapper里的一个自定义查询返回一个OrderVO。select idselectOrderList resultTypecom.example.supermarket.dto.OrderDTO SELECT o.id, o.order_no, o.total_amount, o.status, o.create_time, u.username, o.receiver, o.phone FROM orders o LEFT JOIN user u ON o.user_id u.id where if teststatus ! null AND o.status #{status} /if if testkeyword ! null and keyword ! AND (o.order_no LIKE CONCAT(%, #{keyword}, %) OR u.username LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY o.create_time DESC /selectXML管理起来也没那么麻烦Mapper接口上写清楚注释XML文件按模块放到resources/mapper目录下项目结构就非常清晰。5.2 库存超卖问题前面在下单时用了stock #{quantity}做原子扣减如果你觉得这样就万事大吉了那还是太年轻。网上超市有一个特殊场景用户下单占用了库存但是在付款过程中放弃了如果订单超时取消时没有把库存加回来这个商品就会隐形售罄。所以库存的扣减和恢复必须是一对哪条逻辑扣减了库存就必须有对应的恢复操作。我在这方面踩过坑代码里扣库存的地方写了三处不同的SQL恢复库存的地方少写了一处结果线上跑了一周好几款商品明明库存还有不少前端显示已售罄排查了一天才发现问题。做一个强制要求全项目只能有一个扣减库存方法、一个恢复库存方法一切调用都走这两个入口不允许在任何业务代码里直接写UPDATE product SET stock stock - 1这种散装SQL。5.3 Redis缓存穿透与雪崩网上超市的商品详情页并发量最高我加了Redis缓存之后一开始效果很好后来发现一个问题如果有人恶意用一个不存在的商品ID疯狂请求由于缓存里没有这个key每次请求都会打穿Redis直达数据库数据库瞬间压力爆表。这就是缓存穿透。解决方案很老套但也很好用缓存空值。当数据库查不到商品时在Redis里也缓存一个空对象设置过期时间5分钟这样同样的恶意请求只能在Redis层面得到空数据不会一直穿透到数据库。缓存雪崩问题则通过给过期时间加上随机值来处理不要让所有商品同一个时间点集体过期。每个商品缓存过期时间在30分钟基础上随机加1到5分钟错峰过期。5.4 大字段查询必须出问题商品表里面有一个detail字段存商品详情可能是很长的HTML富文本。如果每次查询商品列表时都把detail字段查出来性能会非常差网络传输和内存占用都会被浪费掉。我在实际项目里给商品表加了一个detail字段后一开始没注意后来在商品列表接口调试的时候发现返回的JSON巨大无比一个商品列表接口返回了好几MB的数据前端页面加载慢得没法看。处理方式是查询列表时排除大字段用select指定需要返回的字段LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); wrapper.select(Product::getId, Product::getName, Product::getMainImage, Product::getPrice, Product::getSales, Product::getStock);只有进入商品详情页的时候才用selectById把完整信息查出来。这个优化做完之后列表接口的返回体直接小了90%以上。6. 项目部署与交付别忘了测试6.1 环境一致性很多人的代码在自己电脑上跑得好好的一部署到服务器就各种问题。最常出现的三个问题Java版本不一致、MySQL字符集配置不一致、Redis没设密码导致连接失败。我的建议是交付的时候必须附上详细的部署文档并且尽量把环境信息写到一份README.md里JDK要求1.8或11MySQL要求5.7以上Redis要求3.2以上Maven要求3.6以上同时项目里面加上application-prod.yml把生产环境数据库、Redis连接信息独立出来本地开发用application-dev.ymlapplication.yml里通过spring.profiles.active切换环境。这样交付的时候别人拿过去只需要改一个文件就能跑起来。6.2 测试数据准备做网上超市这个题目数据库脚本里最好带上测试数据不然别人一启动项目前端页面空荡荡的搜索啥都搜不出来一点体验感都没有。分类表至少要提前造好休闲食品、粮油调味、新鲜蔬菜、肉禽蛋品、饮料冲调、个人护理、家居清洁这些。商品表每个分类下造20个商品总共100多个商品商品的图片用占位图就行但一定要有主图和详情图字段。测试用户造两个一个普通用户、一个管理员。订单表可以造几单不同状态的方便演示订单查询和状态流转功能。6.3 文档写什么交付文档不是到最后才写应该跟着项目走。我交付的时候一般提供这几样README.md项目简介、技术栈、快速启动手册SQL/数据库脚本包括建表语句和初始数据docs/api.md接口文档按模块整理docs/deploy.md部署手册接口文档是别人拿你源码之后用起来最难受的部分我强烈建议哪怕你不在开发过程中用接口管理工具最后也花几个小时用Apifox把接口全部导出成Markdown放进docs里这对项目的接受度提升非常明显。7. 写在最后的一些经验做这个Spring Boot网上超市系统技术上真没有特别高深的东西但它把电商系统的基础链路和常见坑都练了一遍。做完之后我对Spring Boot项目的分层设计、事务管理、数据库设计细节、缓存使用都有了更深的理解。我比较有感触的一点也是最近回头客反馈最多的一点千万不要觉得这是CRUD就草草了事。网上超市系统是最适合打磨细节的项目单是订单状态和库存一致性这一个闭环就足够练手很久。如果只是悬浮在上面写几个Model和Controller那确实什么都没学到但如果把每个模块当成线上项目去抠细节这个项目可以成为你简历上非常拿得出手的一块内容。后续如果你想把项目往深了做可以自己试着扩展这几块接入真正的支付网关做沙箱联调、用Elasticsearch替代MySQL的模糊搜索、把管理端报表做成图表可视化、引入RabbitMQ做订单超时消息。每一步扩展都会让你对这个简单的网上超市系统有全新的认知。