1. 在线商城系统到底在做什么
先说结论:用 Java 做在线商城系统,表面上是一个“增删改查”项目,实际上是一个微型电商中台。商品、库存、订单、购物车、支付回调、物流状态、会员积分、优惠券,这是一条完整的数据闭环。任何一个环节的表结构设计不合理,后期都是一地鸡毛。
我前前后后做过几个商城类的项目,从实习时写的第一版 SSH 古董商城,到后来用 Spring Boot + Vue 做的多商户版本,再到给传统企业做的 B2C 商城,踩过的坑确实不少。这篇博文就围绕“基于 Java 的在线商城系统设计与实现”展开,把核心模块怎么拆、表怎么建、代码怎么写、线上要注意什么,一次讲透。
我默认看这篇内容的读者有两类:一类是准备做毕业设计或者课程设计的学生,另一类是刚进公司没多久、被分配去维护或新写商城模块的后端开发。如果是纯小白,建议先具备 Spring Boot、MyBatis Plus、MySQL 和 Vue 的最基础使用能力。如果这些还没摸过,建议先花两周熟悉一下再回来看,不然很多细节会接不住。
这套系统能解决的问题,总结起来就是三件事:让用户顺畅地逛、方便地买、安全地付。背后的核心价值则是库存不超卖、订单不丢单、支付结果不冲突。想清楚这三条,整个系统的设计和实现就不会跑偏。
2. 项目整体设计思路与模块拆解
2.1 不要把商城系统当成一个“大单体”
很多教程会把在线商城系统画成一个巨大的模块图,商品管理、用户管理、订单管理、支付管理、营销管理、权限管理一应俱全。这种设计图上看着很完整,真正动手写的时候才发现:每个模块之间的耦合关系不搞清楚,代码写到最后就是一堆互相调用、互相修改的“屎山”。
我在实际项目中习惯先按“核心链路”来拆,而不是按“功能模块”来拆。一个商城最核心的链路只有一条:用户浏览商品,把商品加进购物车,提交订单,完成支付,后台发货,用户确认收货,可能还要申请售后。这条链路上的每一个节点,才是系统必须优先保证的地方。至于用户管理、优惠券、积分、推荐位、评论,统统属于这条主链路的附加能力。
从这条链路去反推表结构,就会发现至少要有这些数据载体:
- 用户表,存账号、密码、手机号、收货地址关联;
- 商品表,存名称、描述、价格、主图、状态;
- 商品 SKU 表,存具体规格(颜色、尺寸)下的库存和价格;
- 购物车表,存用户加的条目,但这里的条目通常是 SKU 级别的;
- 订单表,存订单主信息,比如订单号、用户、总金额、状态;
- 订单明细表,存订单中包含的每一个 SKU、数量、单价;
- 支付流水表,存支付渠道、支付单号、支付金额、状态;
- 库存流水表,记录每次扣减、回补的明细,用于排查问题。
这套拆分方式和电商平台的通用思路是一致的:订单和商品不直接关联,而是通过 SKU 做中间层。订单主表和订单明细分开,是为了支持一个订单多商品,以及后续做部分退款、部分发货。
2.2 代码层面怎么分包
分包方式直接影响后期维护效率。我用过几种,最舒服的还是按 DDD 的轻量思路来分:每个业务域一个顶级包,域内再按 controller、service、mapper、entity、dto、vo 分层。
比如:
com.shop ├── common // 通用工具、异常、返回值封装 ├── user // 用户域:用户、收货地址、会员等级 ├── product // 商品域:商品、SKU、分类、品牌 ├── cart // 购物车域 ├── order // 订单域:订单、订单明细、售后 ├── payment // 支付域:支付流水、回调处理 ├── inventory // 库存域:库存、库存流水 └── marketing // 营销域:优惠券、活动、积分每个域内的 controller 只做参数接收和结果封装,service 里放业务逻辑,mapper 只负责数据库交互。这样分的好处很明显:改订单逻辑不会波及商品逻辑,做支付功能不需要把购物车的 controller 翻个底朝天。
有同学会问,那跨域调用怎么办?比如下单的时候要扣库存,订单服务怎么调库存服务的接口?我在单体项目里的做法是,服务之间直接注入对方的 service 接口,但通过接口隔离,而不是直接操作对方的 mapper 表。这样以后就算把库存拆成独立服务,改动成本也可控。
2.3 为什么选 Spring Boot + MyBatis Plus + Vue 这套组合
选技术栈的时候,我一般看三个维度:生态成熟度、招人难度、开发效率。
Spring Boot 不用多说,Java 后端绝对的主流。自动装配、内嵌 Tomcat、starter 机制,让项目启动和部署都省了很多事。MyBatis Plus 则是 MyBatis 的增强工具,单表 CRUD 基本不用写 SQL,分页插件写起来也简单,对商城这种 CRUD 密集型的系统非常友好。Vue 做前端管理端用户端都合适,Element UI 的组件库让后台管理界面的开发速度快得飞起。
有的教程喜欢用 Spring Data JPA,怎么说呢,JPA 在复杂查询和多表关联上确实比较难受,而且很多公司面试的时候重点问的还是 MyBatis / MyBatis Plus。相比起来,MyBatis Plus 的上手成本低,转 MyBatis 也平滑,我个人的项目基本都选它。
顺带说一句,现在不少同学纠结要不要上 Redis、RabbitMQ、Elasticsearch。我的建议是:单体阶段能用数据库解决的,别急着上中间件。商城系统的复杂度主要体现在业务规则上,不是体现在并发量上。等真遇到性能瓶颈,再逐步引入缓存、消息队列,完全来得及。当然,如果项目描述里明确要求体现“高并发设计”,那引出一套 Redis 缓存商品信息是合理的,但要能说清楚缓存一致性方案,不然面试官一问就露馅。
3. 核心细节解析与数据库建模实操
3.1 从 Java 实体类反向生成建表 SQL
很多教程会先给 SQL 脚本,再让你对照着写实体类。我的习惯正好相反:先把业务对象用 Java 类建模,再通过 MyBatis Plus 的注解驱动来生成建表脚本。这么做的好处是,实体类本身就是表结构的文档,改一行代码可能连带改 SQL 的遗漏会少很多。
MyBatis Plus 本身不直接提供“实体类生成建表 SQL”的现成功能,但我们可以利用它的注解信息和一个小工具类,在项目启动的时候自动比对数据库,缺表建表、缺字段加字段。这个思路在动态需求变化频繁的商城项目里非常实用。
简单实现一个 SchemaBuilder 的思路是这样的:
@Component public class SchemaBuilder { @Autowired private DataSource dataSource; @PostConstruct public void init() throws SQLException { try (Connection conn = dataSource.getConnection()) { // 扫描指定包下的所有 @TableName 注解的实体类 // 读取 @TableField 注解中的字段名、类型 // 拼接 CREATE TABLE IF NOT EXISTS 语句 // 执行后比对 information_schema,缺失字段则执行 ALTER TABLE } } }这里有个容易踩的坑:如果实体字段使用了@TableField(exist = false),说明它不是数据库字段,生成建表语句时必须跳过。另外字段类型映射也很关键,String 映射到 varchar 时要给长度,默认可以给 255;如果是@TableField(updateStrategy = FieldStrategy.ALWAYS)这种特殊策略,生成 SQL 时不需要管它,那是 MyBatis Plus 运行时的逻辑。
实体类定义示例:
@Data @TableName("t_product_sku") public class ProductSku { @TableId(type = IdType.ASSIGN_ID) private Long id; private Long productId; private String skuName; private BigDecimal price; private Integer stock; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }如果你不想自己写启动器,也可以用 MyBatis Plus Generator 做反向操作:先设计好数据库表,再用代码生成器生成实体类、Mapper、Service。两种方向各有用途。正向建表适合项目早期快速迭代,反向生成适合表结构已经稳定之后快速产出 CRUD 代码。
3.2 商品表和 SKU 表的设计细节
商品表和 SKU 表的设计是商城系统的第一个分水岭。很多学生项目的通病是只建一张商品表,价格、库存全塞在商品字段里,规格参数用逗号拼接的字符串乱放。这种设计在小 demo 里能跑,但稍微一扩展就崩,比如“黑色大码”和“白色大码”的库存要分别管理,一张表根本撑不住。
正确做法是两张表:
商品表存通用信息,标题、描述、主图、状态、类目、品牌、运费模板等。SKU 表存具体到规格维度的信息,价格、库存、SKU 编码、规格值 JSON、图片。
规格值 JSON 是一个经典设计,比如:
{"颜色": "黑色", "尺码": "L"}这个字段我建议用 MySQL 的 JSON 类型,MyBatis Plus 里对应@TableField(typeHandler = JacksonTypeHandler.class)。这样既保留了灵活性,又不需要为每个商品的规格维度单独建表。
库存字段是核心安全点。直接在 SKU 表上UPDATE t_product_sku SET stock = stock - 1 WHERE id = ? AND stock >= 1这种操作是必须的,这是防止超卖的第一道闸。后面在下单模块我会再展开。
3.3 订单状态机的设计
订单状态一定要用状态机思维去设计,不要用散落的 if-else。我在项目中维护了一个订单状态枚举:
public enum OrderStatus { PENDING_PAYMENT(0, "待支付"), PAID(10, "已支付"), SHIPPED(20, "已发货"), COMPLETED(30, "已完成"), CANCELED(50, "已取消"), CLOSED(60, "已关闭"), REFUNDING(70, "退款中"), REFUNDED(80, "已退款"); }状态之间只允许特定路径流转。比如待支付可以流到已支付,也可以流到已取消,但不能直接跳到已完成;已支付可以流到已发货,也可以流到退款中,但不能流回待支付。
我在代码里把状态流转控制在 service 层一个单独的方法中,所有更新订单状态的操作必须走这个方法,防止有人通过直接修改订单字段绕过校验。比如:
public void changeOrderStatus(String orderNo, OrderStatus targetStatus) { Order order = getOrderByNo(orderNo); if (!order.getStatus().canTransferTo(targetStatus)) { throw new BizException("非法状态流转"); } // 更新状态 }状态机的价值在你做售后退款时体现得最明显:退款中的订单不能再次发货,已完成的订单不能重复申请退款,已关闭的订单不能恢复支付。这些规则全部写在状态枚举里,排查问题的时候一目了然。
4. 技术选型与核心链路实现
4.1 环境准备与项目脚手架
工欲善其事必先利其器。我在新起商城项目的时候,通常会在 pom.xml 里固定这些依赖版本,避免互相冲突:
- Spring Boot 2.7.x 或 3.x(取决于 JDK 版本,JDK 8 用 2.7,JDK 17 用 3.x)
- MyBatis Plus 3.5.x(注意和 Spring Boot 3 的 starter 命名差异)
- MySQL 8.0(字符集 utf8mb4)
- Redis(可选,先留着 spring-boot-starter-data-redis 的依赖)
- Hutool 工具集(生成订单号、Bean 拷贝、加密都能用)
- Lombok(简化实体类代码)
如果是从零开始建项目,直接去 Spring Initializr 生成一个基础项目,再手动加 MyBatis Plus 和 MySQL 驱动即可。记得在 application.yml 里配置数据库连接时加上这些参数:
spring: datasource: url: jdbc:mysql://localhost:3306/shop?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: your_password jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: assign_idallowPublicKeyRetrieval 这个参数是我被 MySQL 8 反复折腾之后加上的,不配置的话连接时可能报 Public Key Retrieval is not allowed。serverTimezone 不配成 Asia/Shanghai,时间字段就会差 8 小时,这种坑排查起来很闹心。
4.2 注册登录与鉴权方案
商城系统的用户登录,我一贯的方案是 JWT 无状态鉴权。登录成功之后服务端签发一个 token,前端存到 localStorage 或者 cookie 里,每次请求放到 Authorization 头。服务端用拦截器解析 token,校验通过后把用户信息塞到 ThreadLocal 里,供后续业务直接取。
这里有两个安全细节要特别强调。
第一,密码不能明文存。用 BCrypt 加密,每次校验时调BCrypt.checkpw()。不要在代码里自己写 md5 加盐,MD5 加盐现在已经被 GPU 暴力破解搞得不安全了,BCrypt 自带盐并且计算成本可控。
第二,JWT 的 secret 不要硬编码在代码里。放到配置文件,利用环境变量注入。过期时间根据业务定,我一般设成 24 小时,如果要做“记住我”功能,可以拆成 accessToken 和 refreshToken 两套,但这套机制复杂度高,单体项目未必需要。
登录状态下的 ThreadLocal 用户信息,我通常封装成一个UserContext工具类:
public class UserContext { private static final ThreadLocal<UserInfo> HOLDER = new ThreadLocal<>(); public static void set(UserInfo user) { HOLDER.set(user); } public static UserInfo get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }用 ThreadLocal 有个隐患:线程池里的线程会复用,所以拦截器处理完请求后必须调clear(),否则下一个请求会拿到上一个登录用户的信息。这个 bug 我线上踩过一次,一个用户下单,结果订单落到另一个用户名下,排查了半天才发现是 ThreadLocal 没清理。
4.3 购物车实现:Redis 还是数据库
购物车有两种实现路线。
一种是纯数据库方案,购物车表存 user_id、sku_id、quantity。优点是简单、可靠,任何设备登录都能看到购物车,缺点是每次访问都要查数据库,购物车操作频繁时压力较大。
另一种是 Redis 方案,用 Hash 结构,key 是cart:userId,field 是 skuId,value 是数量。优点是性能好,但缺点是要处理 Redis 和数据库的一致性,用户换设备的时候要么重新同步,要么干脆放弃未登录购物车。
我在实际项目里的建议是:单体商城用数据库方案就够了,加一个 Redis 缓存商品详情来扛大头性能。购物车本身的数据量不会太大,加索引之后查询很快。
购物车表设计有一个细节要注意:唯一索引要建在(user_id, sku_id)上。这样用户重复添加同一 SKU 时,可以用ON DUPLICATE KEY UPDATE quantity = quantity + 1做增量更新,避免先查后改的并发问题。
4.4 下单核心流程:事务、锁、幂等
下单是商城系统最复杂的环节,没有之一。我把它的核心步骤拆成五步:
第一,校验购物车选中的 SKU 和数量,检查商品是否上架、SKU 是否有效。
第二,锁定库存。这一步必须用数据库的原子更新,也就是前面提到的UPDATE ... WHERE stock >= 购买数量。如果更新行数为 0,说明库存不足,直接抛出异常。
第三,计算订单金额。这块要注意优惠券、满减、会员折扣的顺序。我的习惯是先算原价,再应用优惠券,最后算运费,每一步都要记录明细,方便后期对账。
第四,生成订单主表和订单明细,状态设为待支付。订单号的生成不要用自增 ID,我用的方案是“时间戳 + 用户 ID 后四位 + 随机数”,或者干脆用 Hutool 的IdUtil.getSnowflakeNextIdStr()生成雪花 ID 字符串。订单号在用户沟通中经常用到,纯数字 18 位以内比较好读。
第五,清空购物车对应条目。
五个步骤必须放在同一个事务里。Spring 的@Transactional可以搞定,但要注意几个坑。
坑一是事务失效。同类内部调用this.submitOrder()不会走代理,@Transactional不生效。我之前就吃过这个亏,后来统一把事务入口放在 controller 调 service 的 public 方法上,内部拆分方法不加事务。
坑二是锁的粒度。库存更新用的是行锁,两个用户同时买同一个 SKU,数据库会串行化处理,没问题。但如果你在上面的流程中提前查了商品表并加了SELECT ... FOR UPDATE,锁的范围可能扩大到商品行甚至相关索引,并发能力直接降一半。所以我的原则是:库存更新语句是第一步,锁的范围最小。
坑三是超时不处理。@Transactional默认遇到 RuntimeException 就回滚,但如果方法内吞掉了异常,事务就不会回滚。我要求项目里所有异常必须抛出,外层统一拦截处理,不允许 service 层内部 try-catch 后不抛出。
4.5 支付模块设计与回调处理
支付模块一定是对接第三方支付平台,比如支付宝或微信支付。整套逻辑的核心不是发起支付,而是处理回调。
发起支付的步骤比较简单:后端接收订单号,构造支付请求参数,调用第三方接口拿到支付链接或二维码,返回给前端。注意签名算法和密钥管理,私钥不要传到前端,也不要把第三方支付 AppID 和密钥写在代码里或前端页面中。
真正容易出问题的是回调处理。第三方支付平台会向你的 notify_url 发送异步通知,告诉你这笔订单支付成功了。这时候你要做四件事:
- 验签,确保是支付平台发的消息;
- 查订单,确认订单号存在且状态是待支付;
- 幂等处理,同一笔订单的支付回调可能来多次,处理前判断订单状态是否已经是已支付,是则直接返回成功;
- 回执确认,给支付平台返回
success字符串,不然平台会一直重发通知。
幂等性这块,我再说个实战细节。回调处理不要只依赖订单状态判断,因为并发情况下两个回调线程可能同时读到待支付状态。稳妥的做法是在支付流水表上加唯一索引,以支付平台的交易流水号作为唯一键,先插入支付流水,插入成功才继续更新订单状态,插入失败说明这个回调已经处理过了,直接返回。
这个设计在“支付成功但订单未更新”的问题上帮了我大忙。很多商城出 Bug 都是因为订单更新和支付流水写入不在一个事务里,或者没有任何唯一约束,导致重复回调因为并发被处理两次。
5. 设计模式在商城系统中的实战应用
聊到基于 Java 的在线商城系统,如果不提设计模式,总觉得缺了点什么。但我不建议为了用模式而用模式,而是要找到那些真正让代码变干净的场景。
策略模式是我最常用到的。商城里的促销活动就是天然的策略场景:满减、折扣、秒杀、新人价。每种活动的计价逻辑不同,如果用 if-else 写满一个方法,每次加活动都要改核心代码,越改越乱。
我用一个PriceCalculator接口和多个实现类来解决:
public interface PriceCalculator { boolean support(PromotionType type); PriceResult calculate(OrderCalculateContext context); }下单时通过一个工厂类,根据 PromotionType 找到对应的实现类来算价。新增一种活动时,新增一个实现类即可,不需要动下单主流程。
模板方法模式适合下单流程。下单的骨架是固定的:校验、锁库存、算价、生成订单、清理购物车。但不同类型的订单可能在某些步骤有差异,比如普通订单和秒杀订单在锁库存方式上不同。我把骨架写在抽象类里,把变化的步骤设计成抽象方法,由子类实现。这样主流程的稳定性就保住了。
观察者模式可以用来解耦“订单状态变化之后要做的事”。比如订单支付成功后,要通知物流模块、更新会员积分、给用户发短信。如果用同步代码一个个调,以后每加一个动作都要改动订单 service。我用 Spring 的事件机制,发布一个OrderPaidEvent,各个监听器自己决定是否处理,订单主流程完全不用关心谁在听。
当然,设计模式不是越多越好。商城的核心复杂度在业务规则上,不在类关系上。我见过有人给最简单的 CRUD 也用上三层抽象加工厂,结果代码根本没法调试。我的原则是:能在 3 个以内分支解决的逻辑,不要抽象;只有当一个地方预计会有多次扩展,再考虑策略或模板。
6. 前端交互与跨浏览器兼容性处理
6.1 管理端与用户端的界面拆解
在线商城系统一般包含两个前端:用户端和管理端。用户端面向普通消费者,包括首页、商品列表、商品详情、购物车、结算页、订单列表、个人中心。管理端面向运营人员,包括商品管理、订单管理、用户管理、营销配置、数据统计。
用户端我用 Vue 3 + Vite + Vue Router + Pinia,管理端我用 Vue 3 + Element Plus。Vite 启动速度快,热更新体验好,Element Plus 的表格、表单、弹窗组件覆盖了管理端 80% 的交互需求。
在工程结构上,用户端和管理端我通常分成两个项目。原因很简单:两个端的用户角色、路由守卫、状态管理完全不同,硬放一个项目里会导致权限判断散落各处。
6.2 跨浏览器支持的实际问题
跨浏览器支持是热词里提到的一个重点。开发环境用 Chrome 没问题,但实际用户的浏览器千奇百怪,尤其是企业客户,很多还在用 IE 内核的国产浏览器或者老版本 Edge。我在项目中踩过几个代表性的坑。
坑一是对 CSS 属性的兼容性。Flex 布局在 IE 11 上有不少 bug,比如flex: 1的简写在部分场景下不生效,要写全flex: 1 1 0%。我在用户端的商品列表页就被 IE 用户的布局错乱问题坑过,排查半天发现是 flex 简写惹的祸。现在团队里的约束是:涉及关键布局的样式,直接用 Grid 也要测试,或者用兼容性更好的浮动传统方案。
坑二是 localStorage 在隐私模式下的行为。Safari 的隐私模式在调用localStorage.setItem()时会直接抛异常,如果不做判断,整个页面可能白屏。这里要写一个安全封装:
function safeSetItem(key, value) { try { window.localStorage.setItem(key, value); } catch (e) { // 降级方案:把数据放到内存中 } }坑三是Date.parse("2024-01-01 10:00:00")这类格式解析在 Safari 上返回 NaN。后端返回的时间字符串大多是"yyyy-MM-dd HH:mm:ss",直接传给new Date()在 iOS 上有兼容问题。解决方法是自己写一个格式化工具,或者后端统一返回时间戳。
跨浏览器最笨但最有效的办法,是准备一台 Windows 虚拟机,装好各种内核的浏览器做冒烟测试。只测核心流程:注册登录、浏览商品、加购下单。只要这几条主链路在目标浏览器上能跑通,后面维护就轻松很多。
6.3 前端与后端的接口约定
前端和后端的接口约定不写清楚,联调的时候能吵起来。我的习惯是统一用 RESTful 风格,返回值统一包装成R<T>结构:
@Data public class R<T> { private Integer code; private String message; private T data; }code 为 200 表示成功,非 200 表示失败。错误码分成几个区间:参数错误 400,未登录 401,无权限 403,业务异常 5xx 系列各自定义。前端统一用 axios 拦截器处理,遇到 401 就跳登录页,其他错误码弹 message。
分页参数我统一用page和size,返回结构用PageResult<T>。不要在单个接口里各写一套返回结构,这才是前端开发最感激你的地方。
7. 常见问题与排查技巧实录
7.1 并发扣库存导致超卖
超卖问题是商城系统最经典的 Bug。我用一个场景复盘:
假设商品 SKU 表库存为 10,20 个用户同时下单买 1 件。错误的写法是先查库存,发现是 10,再在代码里判断if (stock > 0),然后执行UPDATE ... SET stock = stock - 1。但两个请求同时读到 10,都通过判断,都执行了更新,结果库存变成 -1 或者更低。
正确写法是让扣减动作和判断条件在一条 SQL 里原子完成:
UPDATE t_product_sku SET stock = stock - #{count} WHERE id = #{skuId} AND stock >= #{count}执行后如果影响行数为 0,说明库存不足,或者并发下被别人先扣了。拿到这个结果再决定抛异常还是提示用户,不要提前查库存做判断。
订单表里也要记录扣减前的库存版本号,方便后期对账。这个版本号也可以防 ABA 问题,但商城场景下一般用不到那么复杂,唯一约束加原子更新已经能挡住 99% 的问题。
7.2 支付回调与订单状态不一致
我遇到过一种诡异情况:用户支付成功了,支付宝回调也收到了,但数据库里订单状态还是待支付。排查了几个小时,最后发现是回调处理方法的@Transactional没生效,原因是我把回调处理写成了一个 private 方法,从同一个类内部调用,事务切面根本拦截不到。
还有一种情况是回调重复处理。支付平台在没收到success回执时,会隔一段时间重新推送通知。如果代码里只判断订单状态,两个并发回调可能同时进入处理逻辑。解决方法是给支付流水表加唯一索引,让数据库来挡重复。
7.3 查询性能慢与 N+1 问题
商城系统的商品列表页是性能重灾区。常见问题是:查询 20 个商品后,在循环里逐个查询每个商品的 SKU 或图片列表,总共执行了 21 条 SQL。这就是经典的 N+1 问题。
MyBatis Plus 中解决这个问题有两种方式。一是用selectBatchIds一次性查出所有商品的 SKU;二是用自定义 SQL 的IN查询。无论怎样,目标都是把查询次数降到常数级别。
另外一个性能点是列表的总数查询。count(*)在数据量大时很慢,商城列表我一般不用全表 count,改用缓存计数或者直接放弃精确总数,仅用“加载更多”的分页模式。
7.4 线上环境 MySQL 连接被占满
这个问题往往在小流量阶段看不出,一到活动推广就爆发。原因是代码里某些查询操作忘记释放连接,或者连接池配置太小。
我的建议是:
- Druid 连接池 initial-size 设为 5,max-active 设为 50;
- 所有 SQL 操作都通过 MyBatis Plus 或 Mapper 层,避免手动管理 Connection;
- 在 Druid 监控页面关注活跃连接数,长期接近上限就说明有连接泄漏。
如果碰到Connection is not available, request timed out这类报错,第一时间去看慢查询日志,通常是一条没加索引的大表查询把连接池拖垮了。
7.5 部署上线的几个关键步骤
商城系统上线前,我至少要做这几件事:
- 生产数据库配置从配置中心或环境变量读取,不写死在 application.yml;
- 关闭 Swagger 或限制访问路径,避免接口文档暴露;
- 配置好 Nginx,前端静态资源用 CDN 加速,后端只暴露 API;
- 配置日志级别为 WARN,避免刷屏;但保留 ERROR 级别的完整堆栈;
- 支付回调的 notify_url 必须在公网可以访问,本地联调用内网穿透工具临时调试;
- 备份数据库,并且演练一次恢复流程。
最后一步是我吃过亏才加上的。有一回上线前忘了备份,灰度测试时一条错误的数据修复 SQL 把整个商品表的价格字段全改乱了,只能从前一天的备份里恢复,丢了小半天的订单数据。从那次之后,凡是动生产数据的操作,我先备份再执行。
8. 项目复盘与扩展思考
8.1 这个项目做完之后,你收获了什么
做完整套在线商城系统,我认为最有价值的不是学会了 Spring Boot 的某个注解,也不是会写了几个 CRUD 接口,而是建立了一套“从需求到落地”的完整心智模型。
你开始理解,一个订单从用户点击“提交订单”到最终“确认收货”,中间经历了多少状态变化,每个状态变化背后有什么数据支撑。你也开始理解,为什么库存扣减不能用“先查再改”,为什么支付回调必须做幂等,为什么订单状态不能随意外转。
这些认知,比任何单一技术点的掌握都更值钱。它们会在你以后做订单系统、会员系统、营销系统的时候,不断迁移复用。
8.2 如何从单体商城演进到微服务商城
这个项目做完之后,很多同学会思考下一步:是不是要拆微服务?我的建议是,先别急,除非出现了实际痛点。
什么时候才考虑拆?订单模块和库存模块因为并发问题需要独立扩容;订单接口和商品接口的 QPS 差异巨大,需要差异化部署;多个业务团队已经开始同时修改同一个代码库,提交冲突严重。没有这些信号,拆微服务只会引入更多分布式复杂度,而不是解决问题。
如果真要拆,第一个建议拆的一定是库存模块。库存的扣减频率最高,而且天然适合用 Redis 预扣减 + 异步同步数据库的模式。第二个拆的是支付模块,因为它要处理回调、对账、退款,业务相对独立,接口稳定性要求高。
8.3 这个方向还能怎么扩展
在线商城系统的扩展方向其实很广。往上走,可以加搜索引擎,用 Elasticsearch 做商品检索和筛选,体验会比 MySQL 的LIKE查询好一个量级。往深走,可以做推荐系统,根据用户浏览记录、购买历史做个性化推荐位。往横走,可以把单商户改造成多商户平台,里面涉及的商户入驻、结算、分账又是另一套复杂逻辑。
在数据层面,可以加埋点统计,分析用户从进入到下单的转化漏斗,找出流失率最高的环节。在运维层面,可以加分布式日志链路追踪,让一次请求从 Nginx 到 Spring Boot 到 MySQL 的全链路日志都可以串联查看。
这些方向每一个都够写一篇新的博文,但万变不离其宗:底层的订单、库存、支付三条核心链路,必须是稳定可靠的。
最后分享几个经验
个人体会比较深的有三点。
第一,一切表结构设计都从“链路反推”,先画核心链路再画表,不要先建表再想流程。表结构一旦定型,后面改起来要牵动 Mapper、Service、前端接口一大片,成本极高。
第二,凡是涉及资金的字段,一律用BigDecimal,不要用double或float。浮点数算金额会出现 0.1 + 0.2 不等于 0.3 的诡异结果,这在电商系统里是绝对不可接受的。金额字段在数据库里用DECIMAL(10,2),在 Java 中用BigDecimal,前后端传参用字符串序列化,避免精度丢失。
第三,日志是排障的第一生产力。下单、支付回调、库存扣减这三类操作,必须在关键节点打日志,包含订单号、SKU ID、操作前数值、操作后结果。没有日志的生产环境,出了问题基本只能靠猜数据库里的脏数据。
最后再分享一个小技巧:开发阶段可以在 application-dev.yml 里把 MyBatis 的 SQL 日志打开,观察每条业务操作实际执行的 SQL。很多你以为正确实则缓慢的问题,比如 N+1 查询,看一眼日志输出多少条 SQL 就真相大白了。等上线前再关掉,项目性能体验会稳妥很多。