简介:gpmall-repo.rar 是面向云计算与微服务实验环境搭建者的离线软件仓库资源包,主要服务于 GPMall 电商项目相关组件的依赖安装与本地 yum 源配置,适合课程实验、毕业设计及内网测试等需要在无外网或网络受限条件下复现环境的学习者。压缩包共收录 173 个文件,以 166 个 rpm 安装包为主体,涵盖 Java 运行环境、MongoDB、Elasticsearch、Logstash 等常用服务组件;另含 3 个 gz、3 个 bz2 压缩的仓库元数据及 1 个 xml 索引文件,整体约 304.19MB,便于快速建立可检索、可校验的本地软件源。目前已有 187 人学习下载。借助这些内容,读者可完成离线依赖安装、组件版本核对与 yum 仓库初始化,减少逐项联网查找 rpm 包的时间,也能为后续微服务部署、日志与搜索组件联调提供较完整的软件包基础,适合作为云计算实验和项目环境复现的配套材料。
1. 从 gpmall-repo 解压出来的那一刻:这套电商商城源码该在云上怎么用
拿到 gpmall-repo.rar 的人,动机通常很实际:想在一台云服务器上把一套电商商城的完整骨架跑通,看商品、购物车、订单、后台管理这些模块到底怎么串起来。它适合两类人,一类是刚接触云计算部署、需要真实项目练手的新手,另一类是想拿现成业务骨架做二次开发的熟手。别指望解压完敲一条命令就能用,它依赖的中间件不少,建库、改配置、调端口一样都省不掉;但也正因为链路完整,跑通一次比读十篇入门教程都值。
2. 仓库结构拆解:一个 rar 里藏着的分层与聚合工程
2.1 解压后先扫一遍顶层目录,别急着执行构建
rar 包在 Linux 云主机上解压,第一道坎往往不是内容而是文件名编码。我一般不在目标目录里直接解,先在/opt/src建个临时目录,用7z或unar展开,确认没有中文名乱码再往里挪,避免后面 Maven 扫描路径时出现莫名其妙的找不到文件。
# 建临时目录,避免解压产物和已有文件混在一起 mkdir -p /opt/src/gpmall && cd /opt/src/gpmall # -O 指定输出目录;unar 对非 UTF-8 文件名比 unrar 友好 unar -o ./unpack /opt/upload/gpmall-repo.rar # 先看两层,判断是"单个工程"还是"聚合工程 + 前端"的结构 find ./unpack -maxdepth 2 -type d | sort | head -40 # 统计体积,判断仓库里是否混入了编译产物(target、node_modules) du -sh ./unpack/* | sort -h这段命令的重点在后两条。find -maxdepth 2用来快速判断结构类型:如果顶层就一个pom.xml加一堆src,那是单体工程;如果下面同时出现多个带pom.xml的子目录和一个前端目录,就是聚合工程加前后端分离。du -sh是血泪经验,很多打包者会把target/、node_modules/、.idea/一起塞进 rar,解压后动辄一两个 G,真正有用的源码可能只有几十 M,先看清楚再决定要不要清理。
扫完之后,我期望看到的是这样一份目录轮廓:
gpmall-repo/ ├── docs/ # 部署说明、接口约定、数据库设计草图 ├── sql/ # 建库脚本与初始化数据,通常按模块拆分 ├── gpmall-common/ # 工具类、统一返回体、常量、异常定义 ├── gpmall-api/ # 领域模型(DO/DTO/VO)与对外服务接口 ├── gpmall-service/ # 业务实现、Mapper 接口与 XML ├── gpmall-web/ # 控制器、拦截器、全局配置、启动类 ├── gpmall-portal-web/ # 前台接口层,可选的第二入口 └── gpmall-static/ # 前端静态资源,交给 Nginx 托管这个轮廓透露的信息比目录名字本身多得多。common和api独立成模块,说明作者是有意把「被所有人依赖的东西」和「会频繁改的业务」隔开的,这是聚合工程里最基础也最容易被破坏的一条边界。service与web分离,意味着控制器里不该出现@Transactional之外的业务逻辑,事务边界应该压到 service 层。static目录存在,说明前端不打算跟后端一起打成 jar,而是走 Nginx 静态托管加接口反向代理,这在云上部署时直接影响你要开几个进程、几个端口。
2.2 聚合 pom 与模块边界:谁管版本,谁管业务
打开顶层pom.xml,先看<packaging>是不是pom,再看<modules>列了哪些子模块。聚合工程里最常见的坏味道是:每个子模块自己写依赖版本,父 pom 只声明不管理,结果升级一个组件要改七八个文件。合格的写法是把版本号全部收到父 pom 的<dependencyManagement>里。
<!-- 顶层 pom:只管版本与模块清单,不写具体业务依赖 --> <project> <modelVersion>4.0.0</modelVersion> <groupId>com.example.mall</groupId> <artifactId>gpmall-parent</artifactId> <version>1.0.0</version> <!-- 打包方式必须是 pom,否则 Maven 会尝试编译它 --> <packaging>pom</packaging> <modules> <module>gpmall-common</module> <module>gpmall-api</module> <module>gpmall-service</module> <module>gpmall-web</module> </modules> <properties> <!-- 统一 JDK 版本,避免子模块各自为政 --> <java.version>17</java.version> <spring.boot.version>3.2.5</spring.boot.version> </properties> <dependencyManagement> <dependencies> <!-- 引入 BOM,子模块引用时不再写 version --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring.boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> </project><packaging>pom</packaging>是关键参数,漏了它 Maven 会当成普通 jar 工程去编译,直接报「no sources to compile」之后构建失败。<scope>import</scope>只对<type>pom</type>生效,作用是把 BOM 里的版本声明整体并入当前工程的依赖管理,子模块再写spring-boot-starter-web就不用带版本号了。java.version这个属性名是 Spring Boot 父 pom 认的,改了它会同时影响编译插件的source/target,比单独配 maven-compiler-plugin 省事。
实际拆的时候我还会确认一件事:service模块有没有反向依赖web。正常应该是一条单向链web → service → api → common,一旦出现反向依赖,说明有人把 Controller 里的工具方法塞进了 service,编译能过,但模块边界已经烂了,后面想单独抽出订单服务做水平拆分时会非常痛苦。检查办法很土但有效,看gpmall-service/pom.xml里有没有gpmall-web的<dependency>,有就记下来,这是二次开发时第一个要还的技术债。
2.3 前端静态资源与后端服务的分界
gpmall-static这个目录值得单独看。如果里面是打包后的dist产物(index.html+assets/),说明源码不在包里,只能部署不能改;如果里面是src/、package.json、vite.config.js,那就是完整前端工程,得装 Node 环境自己构建。两种情况的部署动作完全不同,先ls一眼再决定装不装 Node。
前端工程里第一个要改的文件是请求基地址。开发时它指向localhost,上云之后要么改成域名,要么走 Nginx 同源反向代理留空,后者更省事,因为不用处理跨域和 HTTPS 混合内容。
// src/utils/request.js —— 统一 axios 实例 import axios from 'axios' const request = axios.create({ // 生产环境走 Nginx 同源代理,baseURL 留空即可,避免硬编码 IP baseURL: import.meta.env.VITE_API_BASE || '', timeout: 10000 }) request.interceptors.request.use((config) => { // 令牌放请求头,后端拦截器按 Authorization 取 const token = localStorage.getItem('token') if (token) config.headers.Authorization = `Bearer ${token}` return config })baseURL留空的前提是前端和后端同域,由 Nginx 把/api前缀转发到后端端口。如果硬编码成http://公网IP:8080,浏览器会先发预检请求,后端没配 CORS 就直接挂,这是新手最常见的翻车点。timeout给 10 秒是个折中值,商品列表接口在冷启动时可能超过 3 秒,给太短会误报超时。拦截器里读localStorage而不是sessionStorage,是因为多标签页场景下后者不共享,用户在两个页面之间切换会莫名掉登录。
对应地,Nginx 那边要有一段 location 把接口转出去,并且保留原始路径,否则后端的@RequestMapping前缀对不上:
server { listen 80; server_name _; # 静态资源直接由 Nginx 吐,不经过 Java 进程 root /opt/app/gpmall-static/dist; index index.html; location / { try_files $uri $uri/ /index.html; # 前端 history 路由必需 } # 接口反向代理,proxy_pass 末尾不带斜杠才会保留 /api 前缀 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files那一行是单页应用的路由兜底,少了它用户刷新/order/list会直接 404。proxy_pass后面带不带斜杠是玄学但必须搞清楚:带斜杠会剥掉/api前缀,不带则原样转发。后端接口如果本身就带/api,就别加斜杠。X-Real-IP是为了让后端拿到真实客户端 IP,做限流和日志分析时没有它全是 127.0.0.1。
3. 环境与启动链路:从一台空云主机到接口能通
3.1 组件清单与云主机规格估算
这套商城跑起来不是单进程的事。按常见的电商骨架,后端至少依赖关系库和缓存,前台页面依赖静态托管,搜索和消息队列看包里有没有对应配置再决定装不装。上云之前先把清单列清楚,比边跑边装省一半时间。
| 组件 | 作用 | 最低可用配置 | 是否必需 |
|---|---|---|---|
| JDK 17 | 后端运行时 | 与 pom 中 java.version 一致 | 必需 |
| MySQL 8.0 | 商品、订单、用户主数据 | 2C4G,字符集 utf8mb4 | 必需 |
| Redis 6+ | 会话、商品缓存、库存预扣 | 1C1G,开 AOF | 必需 |
| Nginx | 静态资源 + 接口反代 | 与后端同机即可 | 必需 |
| Elasticsearch | 商品搜索 | 2C4G,单节点 | 视配置而定 |
| RocketMQ / RabbitMQ | 下单后异步扣减、通知 | 1C2G | 视配置而定 |
云主机我一般给 4C8G 起步。理由不是 Java 吃内存,而是 MySQL、Redis、后端 JVM 挤在一台 2G 机器上,光 JVM 默认堆就可能把系统撑到 OOM Killer 出手,到时候日志里只有一行Killed,排查起来毫无线索。JVM 参数手动压一下:-Xms512m -Xmx1024m -XX:+UseG1GC,小机器上比默认值稳得多。
安全组只放 80 和 443,MySQL 的 3306 和 Redis 的 6379 一律不对外。这不是洁癖,公网上扫 6379 的脚本一天能来几万次,Redis 没设密码就等于把数据库交出去。本地调试用 SSH 隧道转发端口,比开安全组省心。
3.2 建库、导数据与配置文件改造
sql/目录下通常有建库脚本和初始化数据两份文件。导入顺序不能反,先结构后数据。字符集要在建库时就定死utf8mb4,因为商品名里出现表情符号或者生僻字是常态,utf8三字节存不下,写入时报Incorrect string value还很难定位到具体哪条数据。
-- 建库:字符集和排序规则一次定死,避免后续 ALTER 大表 CREATE DATABASE gpmall DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- 应用账号只给业务库权限,别用 root 跑应用 CREATE USER 'mall'@'127.0.0.1' IDENTIFIED BY 'ChangeMe_2024'; GRANT SELECT, INSERT, UPDATE, DELETE ON gpmall.* TO 'mall'@'127.0.0.1'; FLUSH PRIVILEGES; -- 导入前先关外键检查,避免表顺序导致的约束报错 SET FOREIGN_KEY_CHECKS = 0; SOURCE /opt/src/gpmall/unpack/sql/schema.sql; SOURCE /opt/src/gpmall/unpack/sql/data.sql; SET FOREIGN_KEY_CHECKS = 1; -- 导入后核对核心表行数,确认数据真的进来了 SELECT 'goods' AS t, COUNT(*) FROM goods UNION ALL SELECT 'orders', COUNT(*) FROM orders;utf8mb4_general_ci是不区分大小写排序,商品编码这类字段如果要求区分大小写,单独给列指定utf8mb4_bin。GRANT只给 CRUD 四项,不给DROP,是为了防止误操作或者 SQL 注入把表删掉。FOREIGN_KEY_CHECKS = 0只在导入期间关闭,导完必须开回来,否则后续业务写入会绕过外键约束。最后那条核对语句是习惯动作,行数为 0 说明SOURCE路径写错了或者文件是空的,早发现比启动后查空列表强。
接着改配置文件。数据源、Redis 地址、端口三项是必改的,其余保持默认。配置里的密码一律走环境变量,别直接写明文提交回仓库。
# application-prod.yml —— 生产 profile,启动时用 --spring.profiles.active=prod 激活 server: port: 8080 # 云上一般由 Nginx 挡在前面,这里只监听回环地址更安全 address: 127.0.0.1 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/gpmall?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: ${DB_USER} password: ${DB_PASS} hikari: maximum-pool-size: 20 # 小机器别开太大,连接数 × 内存要算总账 connection-timeout: 3000 data: redis: host: 127.0.0.1 port: 6379 password: ${REDIS_PASS} database: 0 lettuce: pool: max-active: 16 logging: level: root: info com.example.mall: debug # 业务包单独开 debug,排查时不至于被框架日志淹没server.address设成127.0.0.1是为了让 8080 只对本机开放,外部访问统一走 Nginx,少一层暴露面。连接串里的serverTimezone=Asia/Shanghai不能省,MySQL 8 默认时区是 UTC,订单时间会整体偏 8 小时,这种错误在产品上线后才被发现,改起来要动历史数据。allowPublicKeyRetrieval=true是 MySQL 8 默认认证插件下的必需参数,缺了会报Public Key Retrieval is not allowed。Hikari 的maximum-pool-size按「核数 × 2 + 磁盘数」估,4 核机器给 20 足够,给 100 反而会因为线程争抢把响应时间拉长。
3.3 启动顺序与健康检查
组件之间是有依赖方向的:MySQL 起来之前后端连不上库会一直重试,Redis 没起来则登录直接失败。所以启动顺序固定为 MySQL → Redis → 后端 → Nginx。
# 1. 确认中间件端口在监听,没有就先去启服务 ss -lntp | grep -E ':(3306|6379|8080)\b' # 2. 后端以 prod profile 启动,日志重定向到文件 cd /opt/app/gpmall export DB_USER=mall DB_PASS='ChangeMe_2024' REDIS_PASS='redis_pwd' nohup java -Xms512m -Xmx1024m -XX:+UseG1GC \ -jar gpmall-web/target/gpmall-web-1.0.0.jar \ --spring.profiles.active=prod > /var/log/gpmall/app.log 2>&1 & # 3. 轮询健康检查,别用固定 sleep,机器慢的时候 10 秒根本不够 for i in $(seq 1 30); do code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/actuator/health) [ "$code" = "200" ] && echo "backend up after ${i}s" && break sleep 1 done # 4. 起不来就看日志末尾,别猜 tail -n 80 /var/log/gpmall/app.logss -lntp一次看三个端口,比挨个telnet快。nohup加&是临时跑法,正式环境还是交给 systemd 或者容器编排,至少挂了能自动拉起。健康检查循环里用curl拿状态码而不是pgrep java,因为进程活着不代表 Spring 上下文初始化完成,actuator/health返回 200 才意味着数据源和 Redis 都连通了。tail -n 80只看末尾,Spring Boot 启动失败的根因一般在最后 30 行内,从日志开头翻纯属浪费时间。
4. 核心链路代码:登录鉴权、商品缓存、下单扣库存
4.1 登录态与拦截器链路
电商系统的第一道门是登录。这套骨架里常见的做法是登录接口签发令牌,后续请求靠拦截器解析令牌并把用户信息塞进上下文,业务层从上下文取,不逐层传参。
// AuthInterceptor.java —— 令牌解析与用户上下文注入 @Component public class AuthInterceptor implements HandlerInterceptor { private static final String HEADER = "Authorization"; // ThreadLocal 存用户,请求结束必须 remove,否则线程池复用会串号 private static final ThreadLocal<Long> USER_HOLDER = new ThreadLocal<>(); private final TokenService tokenService; public AuthInterceptor(TokenService tokenService) { this.tokenService = tokenService; } @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { String raw = req.getHeader(HEADER); if (raw == null || !raw.startsWith("Bearer ")) { resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; // 直接短路,不进控制器 } Long userId = tokenService.parse(raw.substring(7)); if (userId == null) { resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } USER_HOLDER.set(userId); return true; } @Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { USER_HOLDER.remove(); // 必须清理,这是最容易漏的一行 } public static Long currentUserId() { return USER_HOLDER.get(); } }preHandle返回false表示请求到此为止,不会再进 Controller,所以设置状态码和直接返回要一起做,只设状态码不返回false是无效的。raw.substring(7)的长度取决于Bearer这七个字符,包含末尾空格,数错了会导致令牌首字符被截断,报签名不匹配,这种 bug 看代码很难发现。afterCompletion里的remove()是重中之重,Tomcat 的线程是复用的,不清理的话下一个请求可能读到上一个用户的 ID,表现为「偶尔看到别人的订单」,属于最难排查的一类问题。
拦截器注册时要注意放行路径。登录、注册、商品浏览这些不需要令牌,全拦了用户连首页都打不开。
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor(tokenService)) .addPathPatterns("/api/**") .excludePathPatterns( "/api/auth/login", "/api/auth/register", "/api/goods/list", // 商品列表允许匿名浏览 "/actuator/**" // 健康检查不能被拦 ); } }addPathPatterns("/api/**")只拦接口前缀,静态资源和 Swagger 文档自动放过。excludePathPatterns是按最长匹配优先的,/api/goods/list不会连带放行/api/goods/detail,如果详情页也想匿名看,得单独加一条。/actuator/**一定要放行,否则健康检查一直 401,启动脚本会误判后端没起来。
4.2 商品详情的缓存读写
商品详情是典型的读多写少,直接查库在并发上来之后会把 MySQL 打满。常见做法是 Cache Aside:先读缓存,未命中查库再回填,更新时删缓存而不是改缓存。
@Service public class GoodsQueryService { private static final String KEY_PREFIX = "goods:detail:"; // 基础过期时间加随机偏移,防止同一批商品同时失效造成缓存雪崩 private static final long BASE_TTL = 1800L; private final GoodsMapper goodsMapper; private final StringRedisTemplate redis; private final ObjectMapper objectMapper; public GoodsDetailVO detail(Long goodsId) { String key = KEY_PREFIX + goodsId; String cached = redis.opsForValue().get(key); if (cached != null) { return readValue(cached); // 命中直接返回 } GoodsDetailVO vo = goodsMapper.selectDetailById(goodsId); if (vo == null) { // 空值也缓存一小会儿,挡住穿透查询 redis.opsForValue().set(key, "{}", 60, TimeUnit.SECONDS); return null; } long ttl = BASE_TTL + ThreadLocalRandom.current().nextLong(300); redis.opsForValue().set(key, writeValue(vo), ttl, TimeUnit.SECONDS); return vo; } // 更新商品后调用:先更新库,再删缓存 public void evict(Long goodsId) { redis.delete(KEY_PREFIX + goodsId); } }BASE_TTL加随机偏移解决的是雪崩:如果一万个商品缓存同时到期,请求会瞬间全部落到数据库上。空值缓存 60 秒解决的是穿透:有人拿不存在的 ID 疯狂请求,不缓存空结果的话每次都打到库。evict用删除而不是更新,是因为并发更新缓存的顺序无法保证,两个线程一前一后写,可能把旧值覆盖回去,删除操作是幂等的,最多多一次回源。
序列化这块我吃过亏。ObjectMapper默认会把Long类型的 ID 序列化成数字,前端 JS 的Number精度只有 53 位,雪花 ID 一超就丢精度,用户点详情页跳到了别人的商品。稳妥写法是在序列化配置里把Long全局转成字符串,或者在 VO 里直接用String类型存 ID。
4.3 下单扣库存的防超卖
库存是最不能出错的地方。查一次库存再更新一次的写法,在并发下必然超卖,因为检查和更新之间有时间窗口。正确做法是把判断塞进 SQL 的where里,让数据库的行锁保证原子性。
-- 扣减库存:条件更新,影响行数为 0 说明库存不足或商品不存在 UPDATE goods_stock SET stock = stock - #{num}, update_time = NOW() WHERE goods_id = #{goodsId} AND stock >= #{num};这条语句的影响行数就是判断依据。Java 侧拿int affected判断,等于 0 就抛业务异常回滚事务,无需先查再比。stock >= #{num}放在where里,MySQL 会在行锁内完成比较和更新,两个并发请求只有一个能拿到锁,另一个看到的已经是扣减后的值。索引必须建在goods_id上,否则这条update会扫全表并锁住大量行,高并发下直接把库拖死。
事务边界要压在 service 方法上,并且只包住「扣库存 + 写订单」这两步,别把发消息、写日志也包进去,长事务会持有行锁直到提交,吞吐掉得很快。
@Transactional(rollbackFor = Exception.class) // 显式指定 Exception,否则受检异常不回滚 public Long createOrder(Long userId, Long goodsId, int num) { int affected = stockMapper.deduct(goodsId, num); if (affected == 0) { throw new BizException("库存不足"); } Order order = new Order(); order.setOrderNo(IdWorker.nextId()); // 订单号在事务内生成 order.setUserId(userId); order.setGoodsId(goodsId); order.setNum(num); orderMapper.insert(order); return order.getId(); }rollbackFor = Exception.class不能省,Spring 默认只在RuntimeException和Error时回滚,抛受检异常时事务会提交,留下「库存扣了但订单没写」的脏数据。IdWorker.nextId()放在事务内生成,保证同一事务里订单号唯一,如果放到事务外再重试,可能生成重复号。真实场景里还要加一层 Redis 预扣做流量削峰,但先保证数据库这层不出错,再谈优化。
5. 部署与联调避坑:五个把上线卡住的问题
5.1 环境与启动阶段的三个坑
现象:java -jar启动后一秒退出,日志里什么都没有。原因多半是内存不够被系统杀了,或者是--spring.profiles.active指定的 profile 文件根本不存在,Spring 启动时找不到配置直接终止。先看dmesg | tail -20有没有 OOM 记录,再看ls一下application-prod.yml到底在不在resources下,两者都没有问题时把 JVM 堆压到 512m 再试。
现象:数据库连上了,但查出来的中文全是问号。原因在建库或连接串的字符集上,utf8和utf8mb4混用是重灾区。解决分三步查:SHOW VARIABLES LIKE 'character_set%'看服务端,连接串里看有没有characterEncoding=utf8,最后SHOW CREATE TABLE goods确认列级字符集。三处都统一成utf8mb4才彻底解决,只改一处往往还有残留。
现象:Nginx 转发后接口全是 502,但curl 127.0.0.1:8080是通的。原因是proxy_pass指向了localhost而服务只监听了 IPv4,或者后端配了server.address=127.0.0.1而 Nginx 跑在容器里。解决是把proxy_pass写成明确的http://127.0.0.1:8080,并确认 Nginx 和后端在同一网络命名空间内;容器化部署时两者要用同一个 Docker 网络,不能一个 host 网络一个 bridge。
5.2 业务与数据层面的两个坑
现象:用户 A 偶尔能看到用户 B 的购物车。原因是拦截器里用ThreadLocal存了用户 ID,但afterCompletion没写remove(),线程复用导致串号。解决是在afterCompletion里无条件remove(),并且在网关或过滤器层加一道兜底清理。这类问题在压测时才会暴露,单机点几下永远复现不了。
现象:下单接口偶发「库存不足」但实际库存还有几百。原因是扣库存 SQL 没走索引,where goods_id = ?扫全表拿的是间隙锁,并发事务互相阻塞后超时重试,重试时库存已被别的事务扣走。解决办法是给goods_id建唯一索引,EXPLAIN确认type=range或eq_ref,同时把事务里的非必要操作全部挪出去,缩短持锁时间。
5.3 出问题时的排查顺序
线上出问题最忌讳东看一点西看一点。我固定按这个顺序走:先curl健康检查确认进程活着,再看ss -lntp确认端口在听,然后tail应用日志找异常堆栈,接着看 MySQL 的SHOW PROCESSLIST有没有长事务和锁等待,最后才怀疑 Redis 和网络。这个顺序是从「最容易出问题且最容易验证」往「最难定位」排的,八成问题在前两步就能收敛。
6. 二次开发:在这套骨架上加一个促销模块并验证
想在这套商城骨架上做二次开发,最省事的切入点是加一个独立业务域,而不是直接改商品和订单。促销(满减、折扣)就是个合适的例子,它需要读商品和订单,但不改它们的表结构,边界清晰,改坏了也容易回滚。
第一步在sql/下新建promotion表,字段只留id、rule_type、threshold、discount、start_time、end_time、status,其余扩展放 JSON 字段里,避免一开始就把表设计死。第二步在service模块建PromotionService,对外只暴露两个方法:matchRule(goodsId, amount)返回命中的规则列表,calcDiscount(rules, amount)返回优惠金额。第三步在订单创建的 service 里加一行调用,注意要放在扣库存之前,因为优惠后金额才是最终要校验的。
| 改造点 | 放置位置 | 注意 |
|---|---|---|
| 促销表 | 新建独立表,不改订单表 | 订单表加discount_amount字段即可 |
| 规则计算 | service模块新包promotion | 不依赖web,保证可单测 |
| 订单接入 | 下单 service 内,扣库存前 | 计算异常要降级为无优惠,不能阻断下单 |
| 缓存 | 规则按 ID 缓存,TTL 5 分钟 | 规则变更走删除缓存 |
验证方法我一般写一个集成测试,把「金额正好等于门槛」「差一分达不到门槛」「规则已过期」三种情况各跑一遍,断言优惠金额和最终应付金额。比手工点页面靠谱,因为促销这类逻辑边界条件特别多,手工根本覆盖不全。
改造过程中最容易犯的错是把促销逻辑直接写进订单的 Controller 里,图省事,一个方法三百行,后面想加个「新人专享」就得动主流程。我个人的习惯是:任何新业务都先问一句「它能不能只依赖 api 模块的接口完成」,能就独立成服务,不能才考虑放进去。从那以后我每次加新模块前都会先在pom.xml里检查一遍依赖方向,确认没有反向引用才动手写第一行代码。希望帮到你。
本文还有配套的精品资源,点击获取