简介:面向希望系统入门电商系统开发的开发者,这份示例项目完整演示了从用户、商品、订单到支付、物流等核心模块的落地实现,覆盖前后端技术栈选型、数据库设计、JWT身份验证、Redis缓存、第三方支付集成以及安全与性能优化等关键实践。压缩包共1024个文件,总体积55.21MB,其中以949张PNG截图为主体,辅以53张JPG图片、5个GIF演示动画、16个Markdown笔记和1个HTML预览页,可对照代码理解页面效果、动态流程与开发要点。已有724人学习浏览,兼具学习与参考价值。资源内提供清晰的模块化目录结构,结合商品SKU展示、搜索页面等具体场景的截图与录屏,帮助读者快速搭建电商项目骨架,并掌握从订单处理到支付通知的完整链路,适合初学者跟练,也能为有经验的开发者提供规范设计参考。
1. 电商项目练习demo:不是玩具,是一条能跑通的完整业务闭环
很多人看到“电商项目练习demo”第一反应是不屑,以为又是那种摆了一堆商品图片、点一下加购物车、再点一下“结算”就完事的静态页面。但当你真正把这份demo源码拉下来跑一遍就会发现,它并不是那种“看起来像电商”的前端壳子,而是一个前后台数据真正联动的业务系统。它把用户注册登录、商品上下架、购物车、下单、库存扣减、模拟支付、订单状态流转这条链路完整串了起来,适合刚学完框架基础、想拿一个真实业务场景练手的人,也适合正在准备项目经验描述、需要把“电商”两个字讲出细节的开发者。
我最早把这套demo跑起来的时候,以为半个小时就能看完,结果花了一整个晚上,而且收获最大的一部分恰恰不是那些页面效果,而是藏在代码里的交易数据设计和那些“看似正常、一并发就翻车”的边界问题。这篇就把我拆这份demo的全过程记录下来,按“怎么搭起来、核心链路在哪、哪些地方最容易踩坑”的顺序讲,最后附上我自己的验证方法和一些往上扩展的思路,希望对正在研究电商项目的你有点实际帮助。
2. 从零搭起骨架:技术选型、数据库DDL与用户-商品双端流转
2.1 先用10分钟看清这份demo的模块边界
拿到源码第一件事不是急着配环境,而是先把项目结构扫一遍。这份demo采用的是前后端不分家的经典单体结构,后端基于Spring Boot构建,视图层用的是服务端渲染模板,数据库层走的是MyBatis映射,整体分层非常清楚:controller层负责接收请求,service层处理业务逻辑,dao层操作数据库,entity目录下是实体类。
这种结构对练习项目来说非常合适,因为它的数据流是单向的:页面发起请求,controller接收参数,service组装逻辑,dao查库或写库,结果返回给页面渲染。相比前后端分离的工程,少了一层接口联调和跨域处理,凡是写过纯前端页面但不知道怎么对接后端数据的人,在这种结构里能最快补齐“数据从哪里来、到哪里去”的认知。
启动之前需要确认本地环境有几个东西:JDK版本要1.8及以上,Maven能正常拉依赖,MySQL版本建议5.7以上,因为demo里的建表语句用到了datetime默认值这种特性,太老的版本会直接报错。数据库连接配置在application.yml里,把用户名和密码改成自己本地的,然后执行resource目录下提供的init.sql建库脚本,就可以直接启动了。
2.2 用户模块:注册、登录、会话保持的代码实现
电商系统的第一道关卡是“认出你是谁”。demo里的用户模块没有引入Spring Security这种重型框架,而是自己用拦截器加session实现了一套轻量登录校验。注册接口接收用户名、密码、手机号三个字段,密码经过MD5加盐之后入库,存储的密码字段长度为32位,对应的就是MD5加密后的定长字符串。
这块有一个非常值得看的细节:demo在密码处理上已经默认给每个用户生成了一段随机的盐值,存在user表的salt字段里,加密时用盐值拼上原始密码再做MD5。这么做的好处是即使两个用户密码相同,入库后的密文也不一样,能有效防掉彩虹表碰撞。很多新手做练习项目时直接明文入库或者不加盐做MD5,在面试里被问倒的也往往是这个地方。
会话保持的逻辑放在了一个自定义的拦截器里,它拦截所有需要登录才能访问的请求路径,包括购物车、结算、订单详情这些接口,如果session里没有存用户id就直接重定向回登录页。这里要说一下session的有效期配置,application.yml里设置了session超时时间为30分钟,用户操作期间会不断续期,超过30分钟不操作就需要重新登录,这个时长对练习demo来说够用,实际商用系统一般会更短。
2.3 商品模块:列表查询和详情页的数据组装
商品模块是这个demo里数据流最直观的部分。前台首页显示的是商品列表,每个商品卡片上展示主图、标题、价格和销量,这些数据来自对product表的一把查询,按上下架状态过滤掉下架商品,再按销量倒序排列,最后用模板语法遍历渲染在页面上。
我之前一直好奇,首页那么多商品,如果每次打开页面都全表查询,数据库压力会不会太大。看了demo源码后发现,它在列表查询上做了非常简单的一层处理:用PageHelper分页插件限制每页展示数量,默认每页12条,SQL语句里只查询当前页需要的数据,配合数据库索引能扛住demo级别的并发压力。对练习项目来说,这层的处理已经够用,真正的高并发查询优化一般要引入Redis缓存,这部分我在后面扩展时会单独说。
商品详情页的组装要稍微复杂一点,除了基础信息之外,还要展示商品对应的轮播图、库存数量、销量数据。demo里把轮播图路径存在单独的banner表里,通过productId外键关联,商品详情接口返回时先查商品主记录,再按外键查轮播图列表和库存信息,分三条SQL查出来后在service层组装成一个完整的详情对象返回给页面。
public ProductDetailVO getProductDetail(Integer productId) { Product product = productMapper.selectByPrimaryKey(productId); if (product == null || product.getStatus() == 0) { throw new BusinessException("商品不存在或已下架"); } List<String> banners = bannerMapper.selectByProductId(productId); Stock stock = stockMapper.selectByProductId(productId); ProductDetailVO vo = new ProductDetailVO(); BeanUtils.copyProperties(product, vo); vo.setBanners(banners); vo.setStock(stock.getAvailableStock()); vo.setSales(stock.getSales()); return vo; }这段代码对应的是商品详情页的组装逻辑,逻辑上分成四步。第一步按主键查商品基本信息,这里有个前置校验,查完之后立即判断下架状态,商品状态字段0代表下架,1代表上架,下架商品直接抛业务异常,不让用户看到详情页。第二步按productId查轮播图列表,banner表里多条记录对应一个商品,所以返回的是一个List。第三步查库存表拿到可售库存和销量,库存和商品拆成两张表是电商系统里的常见设计,因为库存的更新频率远高于商品信息,拆开后可以单独做高并发扣减优化。第四步用BeanUtils把商品基本信息复制到视图对象里,再把轮播图和库存塞进去,这样一个完整的详情视图就组装完成了。注意这里没有把对应的销量也更新掉,销量更新发生在下单成功后的回写逻辑里,不在详情查询时处理,避免读请求拖累写性能。
2.4 数据库表设计:电商demo里的10张表各自的职责
数据库设计是这个demo里最有学习价值的部分,因为它不是简单堆几张表,而是严格按照电商系统中的核心领域来划分的。整体上一共有10张业务表加若干关联表,最核心的是用户表、商品表、库存表、购物车表、订单表、订单明细表、支付记录表,还有配送地址表、轮播图表和系统配置表。
- 用户表:存储账号基础信息、密码盐值、昵称、手机号、状态字段和注册时间
- 商品表:商品标题、价格、主图、详情描述、上下架状态、分类id、创建时间
- 库存表:可售库存、锁定库存、已售数量、商品id外键,用单独的字段区分三种库存状态
- 购物车表:用户id、商品id、购买数量、勾选状态、加入时间,一个用户对应多条记录
- 订单表:订单号、用户id、订单总金额、订单状态、支付时间、收货信息快照
- 订单明细表:订单id、商品id、商品标题快照、单价快照、数量、小计金额
- 支付记录表:支付流水号、订单号、支付金额、支付方式、回调状态、回调时间
- 配送地址表:收货人、手机号、省市区、详细地址、默认地址标记
订单明细表和商品快照相关的设计非常关键,它体现了电商系统一个重要的认知习惯:订单生成后,商品的价格和标题不能再去商品表实时读取,必须把下单那一刻的商品信息原样保存下来。这样即使后续商品改价或改标题,已经产生的订单仍然保留下单时的历史快照,不会出现订单上显示的价格和实际购买价格对不上的问题。
2.5 初始化数据里隐藏的细节:为什么建表语句值得逐行读
demo里提供了一份init.sql建库脚本,很多人都是一键执行完就完事,但我建议逐行读一遍,因为里面有几个细节直接决定了后面业务逻辑能不能跑通。
首先是字符集设置,所有表的ENGINE都是InnoDB,DEFAULT CHARSET都是utf8mb4,而不是老旧的utf8。utf8mb4能完整显示emoji表情和特殊符号,如果商品标题里带了表情符号,用utf8字符集就会直接报错或者存出乱码。第二点是在建表语句中已经定义好了create_time字段的默认值为CURRENT_TIMESTAMP,插入数据时不需要手动填这个字段,数据库会自动写入当前时间。第三点是部分表设计了联合索引,比如购物车表在用户id和商品id上做了唯一约束,保证同一个用户不会在购物车里出现两行相同商品的重复记录,这个约束从数据库层面兜底了前端重复添加商品的脏数据问题。
另一个值得关注的设计是逻辑删除,demo里几乎所有涉及删除操作的接口都没有真正执行delete语句,而是在状态字段上做了一个标记。商品下架是标记状态,用户注销是标记状态,购物车商品移除才真正执行物理删除,因为购物车记录属于临时性数据,没有保留价值。理解这个区别能让你在面试时回答删除相关问题时显得有实操经验。
3. 购物车到订单:核心交易链路与数据表状态设计
3.1 购物车的两种设计姿势:临时存储还是持久化存储
购物车这个模块看起来简单,实际上在电商系统的设计里是分派系的。一种做法是纯前端存储,把购物车数据存在Cookie或者浏览器本地,用户选了什么全记在本地浏览器里,不经过后端数据库,代表产品是早年的一些非登录式商城。另一种是后端持久化,用户加购之后数据直接落到数据库的购物车表里,换设备登录也能同步看到购物车内容,demo采用的是这种方案。
后端持久化购物车的好处很直接:数据不会因为用户关闭浏览器而丢失,管理员也能在后端对某些异常加购行为做数据排查。但代价是每次页面加载都要请求一次购物车列表接口,在数据库层做一次按用户id的商品联查,相比纯前端方案多了IO开销。对练习项目来说,后端持久化是正确的选择,因为你要练习的核心是数据流如何通过接口贯通前后端,如果购物车都写在Cookie里,这个模块就完全绕开了数据库操作,练习价值就大打折扣了。
加购接口的入参是商品id和购买数量,处理逻辑分三段:第一段校验商品是否存在且处于上架状态,第二段判断购物车里是否已经有这条商品记录,有就做数量累加,没有就插入新记录,第三段回写购物车商品数量到会话中,用于页面顶栏的角标展示。
3.2 价格计算:从前端传金额到后端重算的原因
购物车结算页的价格计算是这个demo里一个不大不小但非常关键的细节。进入结算页时,前端确实会把购物车里的商品列表展示出来,但页面显示的总价并不是前端把每个商品的单价相加算出来的,而是后端收到请求后,重新从商品表里查出最新的商品单价,再乘以购物车里的数量,汇总出一个最终应付金额返回给页面。
后端重算这个设计非常值得展开说,因为实际开发中只有做出过电商系统的人才会有这个习惯。如果前端页面上显示的价格完全信任用户的操作,那么一个懂点浏览器调试技巧的人完全可以修改页面上的商品价格,把单价改成一毛钱再去提交订单,后果就是系统在用一个完全错误的价格结算。所以正规的做法是前端传过来的只是商品id和数量信息,价格一切以后端查询为准,前端展示任何价格都只能当作参考,不能当作最终结算依据。
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(Integer userId, List<CartItemDTO> cartItems, Integer addressId) { // 第一步:按购物车条目逐条查询最新商品价格并计算总金额 BigDecimal totalAmount = BigDecimal.ZERO; List<OrderItem> orderItems = new ArrayList<>(); for (CartItemDTO item : cartItems) { Product product = productMapper.selectByPrimaryKey(item.getProductId()); if (product == null || product.getStatus() == 0) { throw new BusinessException("商品已下架:" + item.getProductId()); } BigDecimal itemAmount = product.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())); totalAmount = totalAmount.add(itemAmount); OrderItem orderItem = new OrderItem(); orderItem.setProductId(product.getId()); orderItem.setProductTitle(product.getTitle()); orderItem.setProductPrice(product.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItems.add(orderItem); } // 第二步:生成订单主记录并批量插入明细 String orderNo = generateOrderNo(); Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setTotalAmount(totalAmount); order.setStatus(OrderStatus.UNPAID.getCode()); order.setAddressId(addressId); orderMapper.insert(order); orderItemMapper.batchInsert(order.getId(), orderItems); // 第三步:清空购物车中已下单的商品 List<Integer> productIds = cartItems.stream() .map(CartItemDTO::getProductId) .collect(Collectors.toList()); cartMapper.deleteByUserIdAndProductIds(userId, productIds); return buildOrderVO(order, orderItems); }这是下单接口的完整核心逻辑,一个方法把价格校验、订单生成、明细插入、清空购物车四件事串在一起。第一步遍历购物车条目逐条查商品表,拿到数据库中的最新价格,和数量相乘算出单条小计,再累加到订单总额上。这里可以看到所有的金额运算都用的是BigDecimal而不是double,避免二进制浮点数导致的精度丢失。第二步构造订单主记录,订单号由工具方法生成,初始状态是待支付,然后把明细批量插入,注意明细里存的是商品标题和单价的快照信息。第三步把已下单的商品从购物车里移除,采用批量删除的方式一次性处理。整个方法加了事务注解,任何一步抛出异常都会整体回滚,不会出现订单建了一半但购物车已经清空的诡异场景。
订单明细表里冗余了商品标题和商品价格,可能有人会质疑这样设计是不是浪费存储,但前面已经解释过,这两列存的是下单时刻的快照,不是从商品表实时联查出来的,订单打印、售后对账、财务核算都需要这份历史快照,这也是整个电商订单模块里最不容易被新手吃透的部分。
3.3 订单状态机:待支付、已支付、已发货、已完成、已取消
订单状态是整条交易链路的枢纽,demo里的状态流转逻辑我拆出来单独看了一下,一共定义了五个状态:待支付、已支付、已发货、已完成、已取消。每个状态对应一个整数值,存在订单表的status字段里,代码里用一个独立的枚举类统一管理这些状态值。
状态流转的规则是:用户创建订单后初始状态为待支付;用户点击模拟支付并收到支付成功回调后,状态变为已支付;后台管理员点击发货后状态变为已发货;用户确认收货后状态变为已完成;未支付订单用户可以主动取消或超过一定时间没有支付自动关闭,状态变为已取消。
这些状态间并不是任意转换的,比如已取消的订单不能重新变成待支付,已完成订单不能再次发货,这些流转限制写在了状态更新的SQL语句里,通过update语句的where条件增加了旧状态校验。每次状态更新时where条件里同时带上当前期望状态值,如果实际状态已经变化,那么这次更新影响行数为0,程序据此抛异常或返回失败,避免了并发情况下两个请求同时把订单状态改到不同状态的问题。这种方式叫乐观锁,虽然没加锁字段,但用状态值本身充当版本号,效果是一样的。
3.4 模拟支付:为什么练习demo里也要有回调接口
真实电商系统的支付链路是接入第三方支付网关的,用户在前端确认支付后,页面跳转到第三方收银台,支付完成后第三方服务器会向后端服务发送一个异步回调通知,后端收到回调通知后校验签名和金额,确认无误后把订单状态从未支付改为已支付。
demo里没有接真实的支付通道,但它用了一个模拟支付页面替代了这套流程:点击去支付之后跳到一个模拟收银台页面,自动填充订单号和金额,点击确认支付后直接调用后端的一个支付成功回调接口。神奇的是这个回调接口的逻辑和真实场景的处理方式是一致的——它先按订单号查出订单,校验订单是否存在、金额是否一致、以及当前状态是否仍然是待支付,校验通过后才把状态改为已支付。
这个设计我特别推荐你仔细研究,因为支付回调处理有几个细节是很多面试题里会问到的:回调失败需要重试,所以接口要做幂等处理,重复回调同一个订单不会重复改状态;回调时校验订单金额,防止用户篡改前端页面金额;回调处理在事务内完成,修改订单状态的同时也会更新支付记录表和商品的销量数据,三个操作要么全部成功要么全部回滚。
@Transactional(rollbackFor = Exception.class) public void handlePayCallback(String orderNo, BigDecimal payAmount, String payNo) { Order order = orderMapper.selectByOrderNo(orderNo); // 核心校验:订单必须存在、金额必须一致、状态必须是待支付 if (order == null) { throw new BusinessException("订单不存在"); } if (order.getTotalAmount().compareTo(payAmount) != 0) { throw new BusinessException("支付金额与订单金额不一致"); } if (order.getStatus() != OrderStatus.UNPAID.getCode()) { // 已处理过支付回调或订单已存在其他终态,直接返回,保证幂等 return; } order.setStatus(OrderStatus.PAID.getCode()); order.setPayNo(payNo); order.setPayTime(new Date()); orderMapper.updateByPrimaryKeySelective(order); // 同步增加对应商品销量 List<OrderItem> items = orderItemMapper.selectByOrderId(order.getId()); for (OrderItem item : items) { stockMapper.increaseSales(item.getProductId(), item.getQuantity()); } // 记录支付流水 payRecordMapper.insert(new PayRecord(orderNo, payNo, payAmount, 1)); }这段支付回调的核心逻辑,重点在幂等处理上。前两步先查出订单并校验订单存在性和金额一致性,金额比较用的BigDecimal的compareTo方法,能忽略小数位差异做精确比较,这里不能用equals方法,因为equals会连精度位数一起比,可能把1.0和1.00判定为不相等。第三步是幂等核心,判断订单状态如果不是待支付就直接返回,这样重复回调不会导致重复更新和重复加销量。第四步更新订单状态,第五步遍历订单明细把每个商品的销量增加对应数量,这一步和状态更新在同一个事务里,不会出现订单状态变了但销量没加的情况。最后插入支付流水记录,后续对账靠这张表。
3.5 订单列表和订单详情:从user_id维度到order_id维度的数据组装
订单查询模块是这个demo里比较直观的部分,但它在数据组装上有自己的方法。用户订单列表接口接收用户id,从订单表查出这个用户的所有订单记录,然后逐个订单查询其下的明细列表,把二级列表组装进一级列表里返回。也就是先查订单主表,再按外键遍历查明细表,最终返回一个订单列表,每个订单对象里又包含一个订单明细列表。
订单详情页多了一个功能是收货地址展示,订单创建时已经通过addressId关联了配送地址表,后台发货后也会把发货时间和物流单号更新进去。订单详情接口返回的数据包含了订单基本信息、明细列表、收货地址、支付信息和物流信息,这些字段分别来自四张表,service层把它们合并成一个完整的详情对象。
从这里的组装逻辑也能看出来,电商系统的数据很少是单表就能给全的,大量场景都要跨表查数据后组装。demo的一对多组装方式在数据量不大时性能没问题,如果明细数据量很大,就要考虑一次性查出明细然后分组后做内存组装,减少对数据库的重复访问次数。
4. 后台管理:库存批量更新、SKU与商品上下架的三个细节
4.1 后台管理模块的范围:商品管理、订单处理、分类管理
后台管理模块是电商demo里容易被忽略但实际工作量很大的部分。这份demo的后台功能覆盖了三块:商品管理、订单管理和分类管理。商品管理包括商品的新增、编辑、上下架、库存调整,其中库存调整还区分了手动调整和销售扣减两种入口;订单管理包括订单列表查询、订单发货操作、查看订单详情;分类管理比较简单,就是维护商品所属的一级分类和二级分类。
后台和前台的用户体系是隔离的,demo在登录逻辑上区分了两种身份:普通用户登录后只能访问前台页面,后台路径需要管理员账号才能进入。实现方式也不复杂,就是用户表里多了一个role字段,1代表普通用户,2代表管理员,后端的登录拦截器在处理请求时会检查当前路径前缀和管理员身份标记,两者都不符合时直接拒绝访问。
这个设计虽然简单,但它反映了一个很重要的开发习惯:越界访问控制要在后端做,前端页面有没有显示“后台管理”入口只是用户体验层面的控制,频繁拦截控制必须放在接口层,这也是安全测试里最常见的一个检查点。
4.2 库存表三字段设计:可售库存、锁定库存、已售数量
库存设计是整个demo里最值得深入理解的一张表。库存表stock有三个核心字段,分别是available_stock可售库存、locked_stock锁定库存、sales已售数量。这三个字段的含义分别是:可售库存是当前能卖给用户的商品数量;锁定库存是用户下单但还没完成支付期间暂时冻结的数量,这些商品虽然还没真正卖出去,但已经被某个订单占住了;已售数量是累计卖出的总量,这决定了商品列表里的销量展示。
为什么要有锁定库存这个概念?原因在超卖这个经典问题上。用户在下单过程中的时间窗口里,商品虽然还没付款,但它在逻辑上已经属于这个潜在买家了。如果没有锁定机制,A用户下单的同时B用户也可以下单,库存明明是最后一个,结果两个订单都认为购买成功了,这就是超卖。防止超卖的实现方式demo里用得比较质朴——在扣减库存的SQL语句里直接加上库存充足条件作为where子句,如果库存不足,更新影响行数为0,程序据此抛出库存不足异常。
下单动作不是直接把available_stock减一,而是把available_stock减一、locked_stock加一,商品真正被买走还没有完成,只是从可售池挪到了锁定池。等用户支付成功,才会把locked_stock减一、sales加一。如果用户取消订单,再把锁定库存释放回可售库存,数量上不减商品也不减销量。这套设计把“预定”和“成交”两个动作分开了,整个库存模型才灵活。
4.3 上下架的缓存一致性:直连数据库更新与页面展示的时差
商品上下架在后台操作时看起来只是改一下状态字段,但当系统前台的商品列表是直连数据库查询时,上下架生效是实时无延迟的,这点在demo里看不太出问题。
真正的问题出现在前台列表展示用到了缓存时,上下架操作如果只更新数据库而不清缓存,用户看到的商品列表仍然是内存里缓存住的旧数据,下架的商品可能还会在列表中展示一段时间。所以后台模块在商品上下架操作后加了一步清理缓存的逻辑,把该商品相关的列表缓存key删除,这样下一次查询就会回源到数据库取最新数据并重新构建缓存。
这块的教训是:缓存不是设置完之后就一劳永逸的,每一次数据库写操作都要考虑和缓存的关系。常见做法是更新数据库后立即清理对应缓存,而不是更新缓存,因为并发场景下直接更新缓存容易造成数据不一致。这个习惯在练习阶段就养起来,后面接手真实项目时会少踩很多坑,关于缓存的接入方式我在最后一章会展开具体写一个示例。
4.4 商品编辑与图片上传:静态资源映射的前后端约定
后台的商品编辑功能里,图片来源是一个有意思的技术点。demo里商品图片是通过一个上传接口保存到服务器的本地磁盘目录下的,然后通过一个静态资源映射配置,把磁盘目录映射成一个可访问的URL路径。前端页面上引用图片时,直接用这个拼接出来的URL路径就能展示图片。
图片上传接口的处理过程可以拆成几个步骤:第一步接收前端上传的multipart文件;第二步校验文件类型,demo只允许jpg、jpeg、png这三种图片格式,其他格式直接拒绝;第三步生成唯一的文件名,用时间戳加随机数拼上原始扩展名;第四步把文件写入服务器磁盘的上传目录;第五步把完整的访问URL保存到商品图册表里。
静态资源映射配置里有几个可选参数值得看一下,比如上传目录路径是相对路径的话会受当前工作目录影响,尽量用绝对路径;再比如上传文件大小要设置上限,否则恶意上传超大文件会把磁盘撑满。demo默认设置了单个文件不超过5MB的限制,超过时会抛出文件大小超限异常。
spring: servlet: multipart: max-file-size: 5MB max-request-size: 10MB web: resources: static-locations: classpath:/static/,file:${upload.dir}这个是上传限制和静态资源映射的关键配置。max-file-size定义单个文件最大5MB,超过直接拒绝,max-request-size定义一次请求中所有文件的总大小上限,这是防止用户在一个商品表单里塞大量大图把服务打崩。static-locations配置里定义了两个位置,classpath:/static/是前端页面的静态资源位置,file:${upload.dir}是用户上传图片的磁盘位置,两者用逗号分隔。upload.dir这个自定义属性是在application.yml里通过环境变量注入的,切换到不同环境部署时只需要改环境变量即可,代码不用动。
上传文件到本地磁盘的做法适合练习demo,但生产环境一般会用对象存储服务,因为本地磁盘方案在应用多实例部署时会有问题——A实例收到图片上传请求存到了A实例的磁盘上,B实例在处理请求时可能访问不到这张图片,因为图片在A实例的磁盘上,跨机器的文件系统不共享。
4.5 批量操作与事务边界:后台管理接口里的事务使用习惯
后台管理里涉及批量操作的场景有批量上下架、批量调整库存、批量发货,这些操作在demo里都加上了事务控制。批量操作的运行逻辑是先遍历前端传来的商品或订单id列表,逐条执行单条更新,再用一个事务注解把整个方法包裹起来,只要有一条失败就整体回滚。
这个做法的好处是数据一致性有保障,不会出现一半商品上架成功一半失败的局面。但它也有性能代价,因为逐条更新要走多次数据库交互,在数据量很大的场景下耗时明显。所以后台我没有把事务的粒度设置到整个方法,通常会拆分成“先校验全部数据,再逐条更新”两步,校验在事务外,更新在事务内,这样既保证了业务数据一致性,又不会在无限循环里浪费无谓的事务资源。
另外一个事务边界问题是:事务方法里不宜调用远程接口。如果在一个开启了事务的后台接口里调用了第三方接口,比如发短信通知,而第三方接口响应极慢甚至超时,事务会一直挂在那里持有数据库连接,在高并发场景下很容易把数据库连接池撑爆。demo里把发通知的逻辑都切到了异步线程池或者放到事务提交后的事件监听里,这同样是一个值得保持的习惯。
5. 避坑排查:电商demo最常见的七个问题与解决记录
5.1 数据库建表失败:utf8mb4字符集与MySQL版本不兼容
一开始我在本地执行init.sql的时候直接报错,错误信息提示某个字段定义在当前的MySQL版本下不支持,排查后发现是SQL脚本里用了utf8mb4_0900_ai_ci这个排序规则,而这个排序规则是MySQL 8.0版本才引入的,我的本地库还是5.7版本,不认识这个排序规则,所以建表语句执行到一半就中断了。
解决办法有两种,第一种是把SQL脚本里的排序规则全部改成utf8mb4_general_ci,这是5.7及以下版本都能兼容的排序规则;第二种是直接升级本地MySQL到8.0版本。如果只是想快速跑起来验证功能,建议直接改SQL脚本,把文件里的ENGINE和DEFAULT COLLATE全部统一替换,一条sed命令就能搞定,不需要动数据库版本。
5.2 前端页面中文乱码:模板引擎默认编码与页面编码不一致
跑通了登录页之后发现一个更头疼的问题,页面上所有中文内容都变成了乱码,登录按钮显示成“璁板綍”,一开始我还以为是浏览器默认编码识别错了,查了一圈最后发现问题出在模板引擎的视图解析配置上。
原因是项目里配置模板解析器时没有显式指定字符编码,底层的默认编码不是UTF-8,导致渲染出来的HTML页面在浏览器里按错误编码解析。解决办法也简单,在配置类里给模板视图解析器设置一下编码属性,把编码强制指定为UTF-8,再把页面文件的meta标签里也声明charset="utf-8",双保险,乱码立刻解决。从那以后我看到页面乱码不再习惯性怀疑数据库,而是先检查视图层的编码配置。
5.3 注册成功但登录永远失败:密码字段长度与加密方式不匹配
一个朋友在复现这套demo的时候出现了注册成功但怎么都登录不上的问题。我上去帮他查了一下,发现他手动改过user表的password字段长度,原本设计是32位,他改成16位,但注册接口仍然按MD5加盐加密生成32位密文,插入时密码超出长度限制被数据库自动截断,存在库里的是残缺密文,登录时拿完整密文去比对,自然永远不一致。
解决方法是把password字段长度改回32位,或者同步调整加密算法用更短的摘要长度。这个坑其实暴露出一个设计原则:数据库字段长度必须和代码里加密算法生成的定长密文严格对应,如果改动加密算法,表结构必须跟着调整。在这个demo的代码里,用户密码加密用的是无盐MD5还是加盐MD5,取决于版本,拿到手第一件事先看加密工具类再动表结构。
5.4 下单时商品没有扣库存:忘了开启数据库事务
有朋友反馈说下单成功之后去看库存表,发现可售库存一点都没减,但是订单明明创建成功了。我在他提交的日志里看到下单接口执行过程中没有产生任何报错,最后查了源码发现是他自己修改代码时把service方法上的事务注解给去掉了。
现象是:下单逻辑执行完了,订单表和订单明细表都插入了数据,但库存扣减和订单插入不在同一个事务里,中间任何一步执行失败都不会触发整体回滚,所以出现了订单建了但库存没动的数据不一致问题。解决办法很简单,把事务注解加回去,并确认事务管理器配置正确。这是一个非常典型的“改着改着把事务弄丢”的场景,每次排查订单对不上的问题,第一件事都是确认事务有没有生效。
5.5 后台图片上传成功但页面不显示:磁盘路径与URL映射不一致
后台新增商品时图片上传返回了成功,但前台商品列表和详情页图都裂了,打开浏览器开发者工具看网络请求,发现图片的URL是404。
排查后发现是配置文件里上传目录的路径和静态资源映射目录对不上。上传代码把文件写到系统属性user.dir下的upload文件夹,而映射配置里的upload.dir指向的是自定义的另一个绝对路径,两边路径不一致,文件虽然有真实落盘但完全没有被映射目录覆盖到,所以通过URL访问不到。
解决方法是统一两处路径。之后我检查这个项目配置时,第一件事就会查上传路径和映射路径的一致性,这也算是一个容易看漏的细节。
5.6 模拟支付页面订单号一直不正确:订单号的唯一性前缀截取逻辑
模拟支付页面回显订单号的时候,我曾经遇到过一个奇怪的现象:页面显示的订单号和数据库里实际生成的订单号不一致,看代码发现是订单号生成逻辑中有一个时间戳拼接的片段,在某些并发情况下两个订单生成了相同的订单号。
订单号的生成逻辑虽然是时间戳加随机数,但随机数用的不够随机导致的碰撞概率在demo那种并发测试下被放大,最终靠给订单号加一个支付时间戳前缀并把数据库订单号字段加上唯一索引才彻底解决。这也是我在处理订单号类需求时总结的规律:任何程序生成的订单号都不如数据库唯一索引兜底可靠。
5.7 重复点击去支付按钮生成了两笔订单:前端防抖与后端幂等双保险
测试过程中发现一个很常见的用户行为问题:在结算页连续快速点击“去支付”按钮,后端生成了两笔相同金额的重复订单。原因是前端按钮没有做防重复提交处理,后端接口也没有做幂等校验。
第一次发现时前端加了禁用标记,按钮点击后立即变灰不可再点。但后来想了一下,前端防抖只是体验层的优化,绕过前端直接发请求根本没拦住,所以后端也加了一层幂等校验,在创建订单入口处检查该用户是否有同一购物车商品组合的待支付订单,有则直接返回已存在的订单号。两层都加上之后,再也没出现过重复下单的场景。
6. 进阶:缓存、并发扣减与消息队列的轻量嫁接
6.1 用Redis给商品详情页做一层本地缓存之外的加速
demo跑通之后,如果你想让这个练习项目的技术深度再往上走一层,最值得加的就是缓存。商品详情页的数据组装需要查一次商品主表、一次图册表、一次库存表,三条SQL串行执行,在低并发时没问题,但用压测工具模拟几十个并发请求时,数据库的查询压力会明显上升。
常见做法是引入Redis缓存,把商品详情对象序列化后缓存起来,key的规则为product:detail:{productId},value是整个详情JSON字符串,缓存时间设为30分钟。用户首次访问时缓存没有命中,回源数据库组装对象后写入缓存,后续访问都直接读缓存,不再查数据库。
# 商品详情缓存读写的基本模式 # 查询时:先取缓存,取不到再查库回填 GET product:detail:1001 # 未命中时,查库组装对象后写入缓存 SET product:detail:1001 '{json数据}' EX 1800 # 商品变更时:删除缓存,而不是直接更新缓存 DEL product:detail:1001这块的重点在于失效策略选择“删除缓存”而不是“更新缓存”,原因在于并发场景下的竞态条件。如果两个线程同时更新同一个商品,一个线程先更新数据库再更新缓存,另一个线程也更新数据库再更新缓存,后一个线程可能先写完缓存,导致最终缓存里存的是旧数据。删除缓存的策略即便出现并发,下次读取时缓存未命中回源数据库拿到的也是最新值,天然避免了这个竞争问题。
6.2 商品库存扣减的升级方案:由SQL条件更新到Redis预减加异步落库
demo原有的库存扣减方式,在数据库行记录上直接执行带条件的update语句,扣减前检查可售库存字段值大于零才执行更新,靠数据库行锁保证并发扣减的安全性。这种方案在处理demo级别的并发量够用,但如果要做高并发压测,就暴露出一个问题:数据库行锁在高并发下会让大量请求阻塞在等待锁上。
业界常见的优化方案是引入Redis做库存的预扣减,下单时先在Redis里对商品的剩余库存做原子递减操作,递减后如果结果小于零说明库存不足直接拒绝下单,如果递减成功则在内存中完成扣减,异步地把扣减结果同步到数据库。这样商品的实时可售库存以Redis为准,数据库库存作为最终落地的数据源,同时有定时任务做两者之间的对账。
这个方案能扛住比纯数据库扣减高得多的并发量,但也引入了新的复杂度:Redis数据要是丢了怎么办、Redis里扣减成功但数据库落库失败怎么办。所以练习项目引入这个方案时可以顺势写一份基于定时任务的对账逻辑,定时扫描Redis和数据库的库存差值,发现不一致时以数据库为准做修正。
6.3 用本地消息表实现订单超时关闭:轻量版延迟任务
demo里对超时未支付订单的处理方式是:下单时把订单的创建时间记录下来,系统定时任务每分钟扫描一次订单表,把创建时间超过30分钟且状态仍是待支付的订单统一改掉状态。这种基于定时任务扫描全表的方案实现简单,但每次扫描都要全表过滤,数据量上来后效率低下。
更细的说法是引入延迟消息的机制,下单成功的同时把订单号丢进一个延迟队列,队列里的消息在30分钟后被消费者取出来,消费者取出后检查订单状态,如果仍是待支付就做关闭处理。这个方案不用全表扫描,单个订单只被精确地触发一次检查,性能友好得多。
常见做法是引入消息队列的延迟消息能力,但轻量级实现也可以借助数据库的定时轮询来实现:新建一张延迟任务表记录订单号、任务执行时间、任务状态,定时任务只查询任务执行时间小于当前时间的记录,处理完成后把任务状态标记为已完成。这种本地消息表模式的好处是不依赖额外的中间件,代码逻辑清晰,适合练习项目理解延迟任务的核心思路。
6.4 从demo到项目经验的一条验证路径
最后再分享一下我拿到这套demo后完整的验证路径,每一步都能用来检验自己是否真的吃透了整个项目。第一步是自己动手把数据库初始化脚本执行一遍,然后修改application.yml里数据库配置,把项目跑起来;第二步用注册、登录、加购、下单、支付这套完整用户流程走通一遍,每到一个环节就看一眼数据库对应表的数据变化;第三步只登录后台,把商品下架、再回前台,确认商品已经从列表消失;第四步连续创建两笔相同商品的订单,观察库存的扣减和订单状态流转是否正确;第五步尝试修改数据库字段长度或移除事务注解,复现前面说到的翻车场景,观察系统的反应结果。
当我完整走完这五步之后,这套demo在我的认知里已经从一份代码变成一个可以支撑我讲清楚整个电商后端核心链路的练兵场。从那以后我每次拿到一个新的练习项目,都会强制自己走一遍“跑通完整流程、观察表数据变化、刻意制造故障、修复验证”的循环,这也是我练项目最快的习惯,希望帮到你。
本文还有配套的精品资源,点击获取