☰
Spring Boot商品管理后端实战:从数据库设计到并发扣库存
2026/10/21 5:05:53 网站建设 项目流程

做后端的同学应该都有这种体会:后端小项目里,商品管理几乎是最经典的练手题目。一个简单的商品管理模块,表面看就是增删改查,但真动手做的时候,你会发现它牵出来的问题一点也不简单——数据表怎么设计、哪些字段该建索引、库存扣减在并发下会不会超卖、接口返回什么格式、错误是抛给前端还是写进日志。这篇文章我就拿自己最近整理的一个简单商品管理后端为例,把从需求拆解、技术选型、数据库设计到接口实现和踩坑的过程完整捋一遍。不管你是准备面试练手,还是公司内部需要轻量后台,这篇都能当参考。

1. 先想清楚这个“简单商品管理”要管哪些事

1.1 功能清单列出来,先做减法

很多人一做后端项目就喜欢堆功能,商品管理常见功能其实很多:商品增删改查、分类管理、库存管理、上下架、多规格(SKU)、图片上传、批量导入、价格变更记录、操作日志、权限控制。

但“简单商品管理”的关键词是简单,不是大而全。我在做之前先列了一份功能清单,然后做减法:

  • 必须做:商品新增、编辑、删除、详情、列表分页、按名称搜索、按分类筛选、分类管理。
  • 建议做:上下架状态、库存字段、逻辑删除、统一返回格式、参数校验。
  • 暂时不做:多规格 SKU、订单管理、购物车、图片视频上传、权限系统。

购物车和订单为什么不做?因为那已经是另一个系统的事了。商品管理后端的特点是只管商品本身的数据生命周期,不要提前把它扩展成电商系统。把边界划清楚,后面写代码才不会越写越乱。这也是很多人练手项目烂尾的根源——做到一半发现“需求无穷无尽”,其实是最初没做需求收敛。

1.2 优先级排序:哪些是必须项,哪些是加分项

我给这个项目的功能排了个优先级,顺序很重要:

  1. 商品 CRUD(增删改查)是地基,先做。
  2. 列表分页和搜索是商品管理的刚需,数据库里商品一多,不分页接口直接卡死。
  3. 分类字段要做成独立表,别图省事往商品表里塞一个分类名字符串。
  4. 软删除比物理删除稳妥,留着记录能防误删,也方便以后做审计。
  5. 库存别只放一个数字字段就完事,至少要考虑并发扣减的问题。
  6. 统一的接口返回结构和全局异常处理,是前后端联调不吵架的前提。
  7. 图片、权限、缓存这些全部往后放,属于“可运行了再补”的部分。

这里我想强调一点:简单项目不等于可以省略工程习惯。哪怕是几十行代码的小模块,全局异常处理、参数校验、日志这老三样也应该有。因为商品管理是很多其他系统的公共模块,你现在图省事,以后接订单、接库存流水的时候就要返工,成本比一开始写还高。

1.3 为什么“简单”项目反而不能省工程习惯

我见过一些人写商品管理,把 Controller 写得特别“薄”,Service 里堆了两百行 SQL 拼接然后直接返回,最后项目能跑,但加一个“按分类查询”功能要改三个地方。这种项目表面上是简单的商品管理,实际上埋了一堆雷。

一个“简单”的工程应该是结构上的简单:需求边界清楚、代码分层明确、每层只干一件事。我在这个项目里遵循的规则是:

  • 入参校验放在 Controller 和 DTO 层,业务判断放在 Service 层;
  • Controller 只负责接收参数和返回结果,不写任何业务逻辑;
  • Service 只处理业务流程,不碰具体的 SQL 细节;
  • Mapper 只负责数据库操作。

这套规则不是为了摆架子,而是为了“可维护”。商品管理的接口不算多,但每个接口都可能被复用;每次被复用,清晰的边界都能帮你省时间。

2. 技术选型与工程结构:为什么我选这套组合

2.1 框架和语言:对比之后的选择

商品管理这个题设,市面上常见的组合不少。我简单对比一下:

方案优点缺点适合人群
Java + Spring Boot + MyBatis-Plus生态成熟、资料多、主流团队认可度最高项目结构相对重,启动慢准备找 Java 后端工作、公司内部常用 Java
Python + Flask/FastAPI代码量少、上手快大型团队协作约束少,资料沉淀不如 Java 体系快速原型、小规模内部工具
Node.js + Express/Nest前后端同语言后端生态在数据库、事务方面相对弱一些全栈开发者练手
Go + Gin性能好、部署简单没有 MyBatis-Plus 这类“全家桶”体验,样板代码略多对性能敏感的小服务

我最后选了 Java + Spring Boot + MyBatis-Plus + MySQL。原因很直接:商品管理是典型的关系型数据管理场景,Java 生态里这类项目的参考代码最多,面试时聊起来也最顺。如果你只是为了快速做个内部工具,选 Flask 或 FastAPI 完全没问题;但只要你想把代码写得像生产项目一样分层清晰,Spring Boot 依然是最稳的选择,资料多到你踩坑时随便一搜就能找到解法。

2.2 存储与对象映射:MySQL 与 MyBatis-Plus 的搭配

存储层我选了 MySQL。商品数据是强结构化数据:名称、分类、价格、库存、状态,都是固定字段,相互之间有明确的查询关系。关系型数据库天然适合这种模型。MongoDB 这类文档数据库当然也能存,但如果要按分类统计、多条件组合查询、关联分类表,关系型数据库的 SQL 表达力还是要强得多。

ORM 上我用了 MyBatis-Plus,而不是手写 JDBC 或者 Spring Data JPA。理由有三:

  1. 手写 JDBC 学习价值高,但做项目效率太低,增删改查大量重复代码。
  2. Spring Data JPA 抽象层级高,小白容易“不知道底层发生了什么”,出性能问题时排查链路长。
  3. MyBatis-Plus 介于两者之间:基本的单表 CRUD 不需要写 SQL,复杂查询又能退到 XML 里手动控制,对商品管理这种业务量级非常适合。

一个小提醒:用 MyBatis-Plus 不等于完全不写 SQL。像库存扣减这种对原子性要求高的操作,我会用自定义 XML SQL 而不是自动生成的方法,原因后面专门讲。

2.3 目录结构:按业务分包还是按技术分包

早期项目常见的包结构是controller/service/dao/entity,按技术分层。后来很多团队改成了先按业务模块分、再按技术分层。商品管理这种百八十个类的单模块项目,前者更简单直接。

我的目录结构大概长这样:

src/main/java/com/simpledemo/goods ├── controller │ ├── GoodsController.java │ └── CategoryController.java ├── service │ ├── GoodsService.java │ └── impl │ └── GoodsServiceImpl.java ├── mapper │ ├── GoodsMapper.java │ └── CategoryMapper.java ├── entity │ ├── Goods.java │ └── Category.java ├── dto │ ├── GoodsCreateDTO.java │ ├── GoodsUpdateDTO.java │ └── GoodsQuery.java ├── vo │ └── GoodsVO.java └── common ├── Result.java ├── BusinessException.java └── GlobalExceptionHandler.java

我把dto和vo分开,是为了不让前端字段直接暴露到实体类上。实体类对应数据库字段,DTO 对应接口入参,VO 对应接口出参。商品实体里有deleted字段,但我不会让前端看到;商品列表需要返回categoryName,但商品表里没有这个字段,所以要用 VO 来组装。分层分得清楚,这些小问题就都变得很简单。

3. 数据库建模:商品表、分类表之间的门道

3.1 建表 SQL:商品、分类、还有一个被忽略的状态字段

我建了两张核心表,分类表和商品表,建表 SQL 如下:

CREATE TABLE category ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '分类ID', name VARCHAR(50) NOT NULL COMMENT '分类名称', sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,值越小越靠前', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分类表'; CREATE TABLE goods ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '商品ID', name VARCHAR(100) NOT NULL COMMENT '商品名称', category_id BIGINT UNSIGNED NOT NULL COMMENT '分类ID', price DECIMAL(10,2) NOT NULL COMMENT '售价,单位元', stock INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '库存', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1上架,0下架', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0未删除,1已删除', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), KEY idx_category (category_id), KEY idx_status (status), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';

几个字段设计的细节值得说一下:

  • price用DECIMAL(10,2),绝不用 float/double。商品价格涉及钱,浮点数会有精度误差,0.1 加 0.2 都不等于 0.3,这在账户、报表上会出大问题。
  • 金额单位统一成“元”。很多系统用“分”当单位,能避免精度问题,但需要约定一致,否则前后端容易各乘一遍。简单项目直接用元 + DECIMAL 省事。
  • stock用INT UNSIGNED,防止误给负库存,这是数据库层面最后一道防线。
  • status用TINYINT,而不是VARCHAR存“上架/下架”。字符串枚举在数据库里占空间、容易拼写错误,用数字加注释更干净。

3.2 为什么拆分类表,又为什么不用数据库外键

很多新手会问:分类就一个名字,直接把category_name字段塞进商品表不行吗?

短期数据少的时候确实“行”,但一旦要改分类名,你就要把所有商品的分类名一起改。如果某次漏改,列表页就会出现三个“手机”、两个“手机类”这样的脏数据。拆出独立分类表,商品只存category_id,改分类名只要改分类表一行,这就是最基本的范式思想。

那既然商品表和分类表有关联,为什么不加外键?这是很多人不敢说的选择:生产环境里我很少给业务表加数据库外键。原因一是逻辑删除和数据订正时外键约束会干扰操作;原因二是高并发写入时外键会带来额外的行锁和校验开销;原因三是应用层已经有“插入商品前先查分类是否存在”的逻辑,外键提供的保护已经冗余。真正需要强一致性的场景才值得用外键,简单的商品管理不做这种强约束。

3.3 索引设计:知道哪些查询能用,哪些用不了

商品管理最常见的查询是:按名称模糊搜索、按分类筛选、按下架状态筛选、按创建时间排序。所以我在商品表建了idx_category、idx_status、idx_create_time。

有个容易踩的坑是:name LIKE '%手机%'这种模糊查询,因为%在开头,B+ 树索引实际上帮不上忙,它会全表扫。数据量只有几千几万条时不用慌,简单项目可以接受;但如果你已经预感到商品量会到几十万条,就别硬刚这个查询,改用全文索引或者搜索引擎,或者至少约定搜索必须带上分类条件,缩小扫描范围。

另外,不要看到字段就加索引。status只有 0 和 1 两个值,区分度非常低,如果表里上架商品占绝大多数,用status索引还不如全表扫。我在建索引时参考的是“实际查询组合”,而不是把每个字段都索引一遍。索引不是越多越好,写操作多、索引多,插入和更新都会变慢,磁盘占用也会涨。

4. 商品后端接口实现:从新增到扣库存的完整链路

4.1 新增商品:参数校验、唯一性检查和事务

新增商品的接口定义是POST /api/goods,入参我拆成了GoodsCreateDTO。为什么不用实体类直接接收?因为前端不会传deleted、createTime这些字段,实体类反而会暴露不该暴露的东西。DTO 只放必要字段:

@Data public class GoodsCreateDTO { @NotBlank(message = "商品名称不能为空") @Size(max = 100, message = "商品名称不能超过100个字符") private String name; @NotNull(message = "分类ID不能为空") private Long categoryId; @NotNull(message = "价格不能为空") @DecimalMin(value = "0.01", message = "价格必须大于0") private BigDecimal price; private Integer stock; private Integer status; }

Service 层的核心逻辑是这样:

@Service public class GoodsServiceImpl implements GoodsService { @Autowired private GoodsMapper goodsMapper; @Autowired private CategoryMapper categoryMapper; @Override @Transactional(rollbackFor = Exception.class) public Long createGoods(GoodsCreateDTO dto) { // 1. 分类必须存在 Category category = categoryMapper.selectById(dto.getCategoryId()); if (category == null) { throw new BusinessException("商品分类不存在"); } // 2. 同分类下商品名不允许重复 Long count = goodsMapper.selectCount(new LambdaQueryWrapper<Goods>() .eq(Goods::getName, dto.getName()) .eq(Goods::getCategoryId, dto.getCategoryId()) .eq(Goods::getDeleted, 0)); if (count > 0) { throw new BusinessException("该分类下已存在同名商品"); } // 3. 组装实体并入库 Goods goods = new Goods(); goods.setName(dto.getName()); goods.setCategoryId(dto.getCategoryId()); goods.setPrice(dto.getPrice()); goods.setStock(dto.getStock() == null ? 0 : dto.getStock()); goods.setStatus(dto.getStatus() == null ? 1 : dto.getStatus()); goodsMapper.insert(goods); return goods.getId(); } }

这里有几个关键决策:

第一,唯一性校验放在应用层,而不是数据库唯一索引。因为商品是逻辑删除的,如果对(category_id, name)建联合唯一索引,删过的商品还在表里,下次新增同名商品直接索引冲突。解决方式要么在删除时把name改成 “旧-商品名-随机后缀”,要么把deleted字段参与唯一索引,但这两种都增加复杂度。简单项目用select count校验就够,但要注意这只在非并发场景下可靠;如果你预期同分类同时新增同名商品的概率很低,这个方案是划算的。

第二,用@Transactional(rollbackFor = Exception.class)包住整个业务。这里其实没有多表写入,事务作用不明显,但这个习惯要养成。一旦以后新增商品要同时写库存流水、写操作日志,你就不会忘记加事务。

第三,默认值在 Service 层处理。stock没传就默认 0,status没传就默认上架。数据库也有 DEFAULT,但显式在业务层设置,代码可读性更好,后端也能清楚地告诉调用方“这个参数是可选的”。

4.2 列表查询:分页、搜索与排序的坑

商品列表接口是GET /api/goods,对应参数:page(页码)、pageSize(每页条数)、name(商品名模糊)、categoryId(分类筛选)、status(状态筛选)。MyBatis-Plus 的分页查询写起来非常直观:

@Override public PageResult<GoodsVO> pageQuery(GoodsQuery query) { Page<Goods> page = new Page<>(query.getPage(), query.getPageSize()); LambdaQueryWrapper<Goods> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Goods::getDeleted, 0); if (StringUtils.hasText(query.getName())) { wrapper.like(Goods::getName, query.getName()); } if (query.getCategoryId() != null) { wrapper.eq(Goods::getCategoryId, query.getCategoryId()); } if (query.getStatus() != null) { wrapper.eq(Goods::getStatus, query.getStatus()); } wrapper.orderByDesc(Goods::getCreateTime); goodsMapper.selectPage(page, wrapper); // 组装分类名称,避免在循环里逐个查数据库 List<Long> categoryIds = page.getRecords().stream() .map(Goods::getCategoryId) .distinct() .collect(Collectors.toList()); Map<Long, String> categoryMap = categoryMapper.selectBatchIds(categoryIds).stream() .collect(Collectors.toMap(Category::getId, Category::getName)); List<GoodsVO> records = page.getRecords().stream().map(goods -> { GoodsVO vo = new GoodsVO(); vo.setId(goods.getId()); vo.setName(goods.getName()); vo.setPrice(goods.getPrice()); vo.setStock(goods.getStock()); vo.setStatus(goods.getStatus()); vo.setCategoryName(categoryMap.getOrDefault(goods.getCategoryId(), "未知分类")); return vo; }).collect(Collectors.toList()); return new PageResult<>(page.getTotal(), records); }

列表查询最容易犯的错有两个:

第一个是在循环里逐条查关联数据。商品列表有 20 条,就循环 20 次查分类名称。正确做法是先取所有categoryId,一次批量查出来组装成 Map。这个习惯在任何关系映射场景都适用,不限于商品。

第二个是让前端传排序字段。有人会把sortField和sortOrder直接拼进 SQL,这是潜在的注入风险。简单项目我建议把排序逻辑写死在后端,比如“默认按创建时间倒序”,最多再支持“按价格升序/降序”这种可控枚举,不要动态拼任意字段。真要支持任意字段排序,也要做白名单校验,把允许排序的字段列在代码里。

4.3 修改和删除:部分更新与逻辑删除的取舍

改商品用PUT /api/goods/{id},我采用了显式部分更新的写法:GoodsUpdateDTO里所有字段都允许为空,Service 层判断哪个字段不为空,就更新哪个字段。这样前端可以只传价格,不会把其他字段覆盖成空。

这里有个反直觉的点:HTTP 规范里 PUT 是“整体替换”,PATCH 才是“部分更新”。但很多公司实际根本不管这个区别,统一用 PUT 做编辑。小项目不必纠结语义,但要保证一个约定:空字符串和 null 是两回事。如果前端要求“把商品名称清空”,但你的更新逻辑只判断null,空字符串会被忽略,前端会觉得功能坏了。所以,对于可置空字段,我在 DTO 里用@Size校验规则明确允许为空字符串,然后 Service 层用if (dto.getName() != null)而不是if (StringUtils.hasText(dto.getName()))来判断是否更新。

删除接口我决定用逻辑删除,代码是:

@Override public void deleteGoods(Long id) { int updated = goodsMapper.logicDeleteById(id); if (updated == 0) { throw new BusinessException("商品不存在或已删除"); } }

对应 SQL:

UPDATE goods SET deleted = 1, update_time = NOW() WHERE id = #{id} AND deleted = 0

为什么不用DELETE FROM goods WHERE id = #{id}?因为商品一旦被订单、报表引用,物理删除会导致历史数据断裂。逻辑删除让所有查询都带上deleted = 0条件,记录还保留在表里,以后做数据恢复、做审计都有退路。代价是查询都要记得过滤,漏写一次就会把“已删除”商品也查出来。如果你用 MyBatis-Plus,可以在实体类deleted字段上加@TableLogic注解,框架会自动拼接过滤条件,但这个“自动”也会让新手在写复杂 SQL 时出现“明明查不到、以为记录没了”的困惑,所以我在示例里选择手动写条件,更直观。

4.4 库存扣减:一行 UPDATE 解决超卖

商品管理做到一半,十有八九会被要求做“扣库存”。很多人的第一版代码长这样:先查库存,再判断是否够,最后更新。这在单机单请求下没问题,一并发就出事。正确的做法是直接在数据库层面做条件更新:

@Override @Transactional(rollbackFor = Exception.class) public void deductStock(Long goodsId, Integer count) { if (count == null || count <= 0) { throw new BusinessException("扣减数量必须大于0"); } int updated = goodsMapper.deductStock(goodsId, count); if (updated == 0) { throw new BusinessException("库存不足或商品不存在"); } }

Mapper 的 XML 写法和对应 SQL:

<update id="deductStock"> UPDATE goods SET stock = stock - #{count}, update_time = NOW() WHERE id = #{goodsId} AND deleted = 0 AND stock >= #{count} </update>

这条 SQL 的巧妙之处在于WHERE stock >= #{count}:如果库存不够,UPDATE影响行数是 0,代码就能立刻抛出“库存不足”。而stock = stock - #{count}的减法是在数据库内部完成的,天然原子,两个并发请求同时进来时,数据库的行锁会让它们排队执行,不会出现两个请求都读到“还有货”然后都扣减成功的情况。

事务在这个方法里扮演什么角色?单条 UPDATE 本身就已经是原子的,但如果你在扣库存的同时还要写一条库存流水、更新商品销量,这些操作必须在一个事务里,要么全部成功,要么全部回滚。后续要做成订单系统时,这个设计能直接复用。

5. 实测踩坑记录:参数校验、超卖和异常处理

5.1 参数校验:看起来不重要,返工率最高

商品管理的参数校验看似只是几个if,实际上我三天两头被返工。举几个真实场景。

第一个是字符串长度不设限。有一次测试环境里商品名称被填了足足 500 个字符,数据库字段VARCHAR(100)直接报错,前端弹了个“系统错误”的提示。后来我在 DTO 里加了@Size(max = 100),并且把“长度超限”的错误信息明确返回给前端,这个问题才算闭环。

第二个是价格精度。如果 DTO 里价格用Double,用户传 9.9,后端拿到的可能是 9.899999999。所以我让前端传 BigDecimal,或者直接传“元”为单位的字符串,后端统一转BigDecimal。数据库也用DECIMAL(10,2),三层一起保精度。

第三个是状态值乱传。status如果允许前端传任意数字,总有一天会传进来一个 3 或者 -1。我的做法是用一个StatusEnum做白名单校验,不在枚举里直接抛业务异常。简单项目用枚举有点“重”,但这是避免状态机被数据搞坏的最低成本方案。

我个人的经验总结是:参数校验要有“前端校验 + 后端校验 + 数据库约束”三层意识。前端校验只是体验,后端校验才是安全底线,数据库约束是最后的兜底。别嫌麻烦,你省掉的每个if都有可能在某个深夜变成线上事故。

5.2 并发扣库存:先查再改为什么会超卖

这个坑我在 4.4 已经给了正确解法,但我还是想单独把它作为“踩坑记录”写出来,因为它的排查链路很值得复盘。

当时我在本地模拟了两个线程同时调用扣库存接口,商品初始库存是 3,两次各扣 2。理想结果是第一次成功、第二次失败;实际结果却是两次都成功,最后库存变成了 -1。

排查日志时发现,两个请求都先执行了SELECT stock FROM goods WHERE id = 1,都读到了 3,也都判断 3 >= 2,于是都执行了UPDATE goods SET stock = 2。问题就出在“先查后改”——两次查询之间没有做任何隔离,数据库行锁也没起作用,因为查询不带任何写锁。

修复方案就是 4.4 里的条件更新。改成那条 SQL 之后,我再跑同样的并发脚本,第二次请求的UPDATE影响行数是 0,接口准确返回了“库存不足”。

这个坑的教训是:能用一条原子 SQL 表达的逻辑,就不要人为拆成“查询 + 判断 + 更新”三步。类似的问题还会出现在余额扣减、优惠券核销、Token 过期校验等场景,核心思路都是“用条件更新代替先读后写”。如果条件更新解决不了更复杂的需求,再考虑SELECT ... FOR UPDATE悲观锁或者 Redis 分布式锁,但那是下一步的事了。

5.3 统一返回与全局异常:前后端接口的隐形契约

商品管理接口如果每个都返回不一样的格式,联调的时候前端会疯掉。我统一用了一个Result<T>结构:

@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> result = new Result<>(); result.setCode(0); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }

配合全局异常处理:

@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public Result<Void> handleBusinessException(BusinessException e) { return Result.error(e.getMessage()); } @ExceptionHandler(MethodArgumentNotValidException.class) public Result<Void> handleValidException(MethodArgumentNotValidException e) { String message = e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); return Result.error(message); } @ExceptionHandler(Exception.class) public Result<Void> handleException(Exception e) { log.error("系统异常", e); return Result.error("系统繁忙,请稍后重试"); } }

没有这套东西之前,Controller 里全是 try-catch,异常信息直接以英文堆栈抛给前端,前端看到一长串java.lang.NullPointerException,第一反应就是找后端吵架。有了全局异常处理后,业务错误返回“商品不存在”,参数错误返回“价格必须大于0”,系统异常统一返回“系统繁忙”,堆栈只留在日志里。前端永远不知道你内部发生了什么,但后端排查时一点信息都不缺。

这里还有个小细节:BusinessException是自定义业务异常,错误信息是给人看的,要足够友好;而Exception兜底分支的日志一定要用log.error打堆栈,否则线上出问题你只能看到一句“系统繁忙”,根本无从排查。

6. 项目跑通之后:还能往哪些方向扩展

6.1 加权限:从“谁都能删商品”说起

商品管理接口写完很容易,但部署到公司内部后第一个不适就是:任何人知道接口地址都能删商品。这个小项目没有登录、没有角色,谁都能调DELETE /api/goods/1,数据安全形同虚设。

最轻量的改造是加一个基于 Token 的登录拦截器:登录接口签发 Token,后续请求在 Header 里带上 Token,拦截器校验通过才放行。在此基础上再分角色,比如管理员能删商品,普通运营只能改价格,这就够用很久了。

不要一上来就引入完整的 Spring Security,学习成本和配置复杂度对简单项目来说不成比例。等你真的需要 OAuth2、SSO、方法级权限注解时,再引入不迟。后端开发的常见思路就是:需求最小化实现,架构跟着需求生长。

6.2 加缓存:商品详情与列表的 Redis 接入

商品是典型的“读多写少”数据,页面刷新一次就要查一次数据库。商品量上去之后,最简单的优化是把热点商品详情缓存进 Redis:

  • 读接口:先查 Redis,命中直接返回;没命中查数据库,然后写回 Redis 并设置过期时间。
  • 写接口:先更新数据库,再删除 Redis 缓存。注意顺序不能反,如果先删缓存再更新数据库,中间有并发请求会把旧数据重新写回缓存。

为什么是先更新数据库再删缓存,而不是先删缓存再更新库?因为删缓存动作本身也有失败风险,更新失败后缓存就没了,下次请求还能从数据库回填新值。反过来先删缓存,如果更新数据库失败,缓存里就是旧的,直到过期才会纠正,脏数据窗口更大。

简单项目里不要过度设计缓存,只有真实查询压力大了再上。商品总数几千条,数据库毫无压力,这时候上 Redis 纯粹是自找维护成本。

6.3 加图片存储:从本地目录到对象存储

商品没有图片显得很不完整。最简单的方案是后端接收上传文件,保存到服务器某个本地目录,数据库里只存相对路径,访问时通过一个静态资源映射暴露出来。

这个方案有几个必须注意的地方:

  • 做图片格式白名单,只允许 jpg、png、webp 等常见格式,防止恶意上传脚本文件。
  • 用随机文件名或者按日期分目录,不要用用户上传的原始文件名,避免路径穿越攻击。
  • 限制文件大小,一般 2MB 到 5MB 足够,否则服务器磁盘很快就满了。

等图片量大到单机磁盘扛不住,再迁移到对象存储,数据库里存对象存储的 key,前端拼接 CDN 域名访问。迁移时只改存储层的实现,业务代码不用动——这就是分层结构带来的好处。

6.4 加日志与简单监控:出了问题能查

商品管理系统没有日志,出问题只能靠猜。我在这个项目里坚持写了两类日志:

  • 请求日志:记录接口路径、请求参数、响应状态、耗时。用一个简单的拦截器就能实现。
  • 业务关键日志:新增商品时打印操作人,扣库存失败时打印商品 ID 和当前库存,方便复盘异常。
  • 慢 SQL 日志:MyBatis 开启慢查询打印,或者在 MySQL 配置了slow_query_log,超过 1 秒的 SQL 全部记录下来。

监控不一定要上 Prometheus 全家桶,先把日志打规范,再用一条命令查最近的错误日志,就已经能覆盖 80% 的排查场景了。小项目与其堆监控面板,不如先保证日志里能还原出问题现场。

最后分享一点自己的体会。商品管理这个题目看着小,但它把后端 CRUD 的基础点全串起来了:需求边界的取舍、数据库设计、索引意识、事务边界、并发原子性、参数校验、异常处理、可观测性。我把这个简单项目反复打磨了三遍,每次都能发现新问题——第一遍功能跑通就行,第二遍补校验和异常,第三遍才真正把并发扣库存的写法想明白。如果你也准备拿它练手,我建议别急着写代码,先把数据模型画出来,再把每个接口的入参出参列成表格,想清楚了再动手。这个过程本身,才是这个小项目最值钱的地方。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询