☰
Spring Boot实战:客制化键盘个性化商城从建模到上线
2026/10/22 13:53:53 网站建设 项目流程

如果你在机械键盘圈子里待过一阵子,一定对“蹲团购”不陌生:群里发个在线表格,大家填外壳颜色、填配列、选PCB、选轴体、选键帽,然后自己拿计算器把价格一项项加起来,最后给群主转账,接下来就是漫长的等待。这套流程放在十年前没什么问题,但放在今天,体验实在过于原始了。

所以我决定用 Spring Boot 做一套完整的电商练手项目时,脑子里第一个冒出来的需求就是它——客制化键盘的个性化商城网站。项目代号就叫 springboot101,这里的 101 有两层意思:一是 Spring Boot 从入门到实践,二是像键盘配列里的基础键位一样,把基本功打扎实。这套系统做出来后,能在线选配置、实时算价格,还能把订单、库存、支付串成完整闭环,是真正能跑通的商城,而不是一堆 Demo 拼起来的玩具。

文章适合两类人看:一类是想用 Spring Boot 做完整毕业设计或简历项目的开发者,另一类是客制化键盘圈子里想自己搭个小商城卖货的爱好者。前者能学到可变 SKU、订单状态机、库存扣减这些电商核心逻辑,后者能直接把这套思路搬到自己店铺里用。我尽量把设计思路、核心代码、踩坑过程都交代清楚,不搞藏着掖着那套。

1. 为什么选客制化键盘做 Spring Boot 实战项目,而不选通用电商

1.1 客制化键盘的购买流程与普通商品差异很大

普通商城卖手机,SKU 是事先定死的:颜色、存储版本、网络制式,几个维度一组合,库存和价格都有固定表格。但客制化键盘不一样,它的核心玩法就是“拼”。同一个套件外壳,用户可以选择 65%、75%、87、98 不同配列,外壳用黑还是白还是阳极银,PCB 是热插拔还是焊接,定位板用 PC 还是黄铜,再往上叠加轴体、键帽、卫星轴、夹心棉……一套下来排列组合上千种。

如果按传统电商的 SPU/SKU 建模思路去做,光维护 SKU 就能让人崩溃。假设 3 种外壳颜色、2 种 PCB、3 种定位板、10 种轴体、8 种键帽,组合就是 3×2×3×10×8=1440 个 SKU。而且客制化圈的库存逻辑也很特殊:外壳颜色可能各备 50 件,但轴体库存是独立的,键帽又是另一批货。你没法把一个完整套件当成一个 SKU 去管理库存。这种“组件自由搭配”的模型,正是普通电商平台不愿意碰、但客制化用户又特别需要的东西。

我还观察到一个现象:客制化键盘很多是“团购预售”模式——先付款,再排队生产,等几个月后发货。这和普通现货电商完全不同。所以系统不能只做现货直购,还要能支持类似定金订单的简化流程。当然,完整版团购系统很复杂,我第一期只做了“现货直购 + 定制工单”两个核心链路,但数据模型上已经为预售预留了字段。

1.2 “101”项目名的含义与这套系统的定位

“101”在美国课程体系里是入门课编号,放在这里就是“从零开始做电商”的意思。项目定位是教学级但生产可用的商城,不搞微服务那套花活,就是一个 Spring Boot 单体应用加 MySQL 加 Redis。这样做的原因很实际:单人开发、部署简单、问题排查快,而且学到的知识可以完整覆盖电商主流程。

整个系统要回答三个核心问题:第一,可变配置的产品怎么建模?第二,实时报价怎么算才准?第三,定制产品下单后订单怎么记录、库存怎么扣?这三个问题只要想透,后面迁移到 DIY 电脑商城、礼品定制商城、甚至装修报价系统都是同一套逻辑。所以我花在设计和建模上的时间,比写代码的时间多得多。

1.3 这套商城需要解决的三个典型问题

第一个是动态定价。用户选不同配置,价格实时变化,价格计算必须以后端为准,不能信前端传过来的数字。第二个是维度化库存。外壳、轴体、键帽、PCB、定位板各卖各的库存,不能用一个总库存字段糊弄。第三个是方案快照。用户下单时选的是一套完整配置,几个月后收到货时,这套配置在数据库里可能已经下架或改版了,但订单记录里必须能原样还原他当时买了什么。

这三个问题解决完,系统的骨架基本就立住了。我后面所有章节都会围绕这三点展开。

2. 整体架构与功能模块:先画一张能落地的模块图,再动手写代码

2.1 技术栈选型与取舍

后端我用的 Spring Boot 3.x + Java 17,ORM 选了 MyBatis-Plus,数据库是 MySQL 8,缓存用 Redis,支付对接第三方沙箱。前端没有做前后端分离,用的是 Thymeleaf 服务端渲染 + Bootstrap + 原生 JavaScript。

很多同学会问,为什么不直接上 Vue 加 Spring Boot 前后端分离?我的理由是:这个项目重点在电商业务逻辑,不在前端工程化。前后端分离意味着要处理跨域、Token 鉴权、接口文档维护、前端构建打包一堆事,对单人项目来说负担太重。服务端渲染让页面和接口天然同源,登录态用 Session 就能搞定,开发效率高得多。等以后真需要做复杂的键盘配色预览交互,再单独抽前端不迟。

技术栈具体清单如下:

  • 后端框架:Spring Boot 3.x,对应 Java 17
  • 持久层:MyBatis-Plus,避免写大量繁琐的 XML 映射
  • 数据库:MySQL 8,存储业务数据
  • 缓存:Redis,缓存配置模板、首页热销、验证码
  • 模板引擎:Thymeleaf,负责商品详情页、购物车、订单页渲染
  • 前端库:Bootstrap 5 + 原生 JS,负责交互
  • 支付:第三方支付沙箱环境,模拟支付回调

2.2 功能模块清单与用户旅程

系统划分成这几个模块:用户模块、商品模块、配置模板模块、定制工单模块、购物车模块、订单模块、支付模块、库存模块、后台管理模块。

用户旅程大概是这样的:用户注册登录后,进入套件商品详情页,选择配列、外壳颜色、PCB、定位板,再选轴体、键帽、个性刻字服务,页面通过 Ajax 实时请求后端报价。确认方案后加入购物车,购物车里可以同时存在普通商品(比如单买一套键帽)和定制方案(一套完整键盘)。结算时填写收货地址,提交订单,跳转收银台完成模拟支付,支付回调成功后订单状态变成“生产中”,后台管理员看到订单后点击发货,用户收货确认完成。

这条链路对第一次做电商项目的人来说不算短,但每一步都能拆成独立模块去开发和测试。我建议你不要一上来就写代码,先把这条用户路径打印出来贴在桌上,每完成一个环节就打一个勾。

2.3 核心流程:从选配置到下单的完整链路

这里有个关键决策:定制方案在购物车里怎么存?我设计了一个名为“定制方案单”的概念。用户在商品详情页选完配置,点“加入购物车”时,后端先把整套配置保存为一条方案单记录,购物车里只存方案单 ID。这样购物车页面不用存一堆配置明细,结算时再根据方案单 ID 拉取完整配置,转成订单项快照。

这样做的好处是,购物车和订单的数据边界非常清晰。用户在购物车里删掉方案,只是删除关联关系,方案单本身还可以保留,防止手滑删除后配置丢失。订单生成后,方案单里的配置会被序列化成 JSON 快照写进订单项,此后即使后台改了配置价格,历史订单也不受影响。

3. 数据库设计:一张配置模板表撑起整个个性化定制

3.1 普通商品与可变配置商品的建模差异

数据库建模是整个项目最核心的部分,我前后改了三版才定稿。第一版我试图把客制化键盘当成普通 SKU 处理,结果发现行不通,因为每种组合并没有固定库存。第二版我打算用“可选配 JSON”字段,把所有配置塞进一个 JSON 里,查询和统计时又非常别扭。最终采用了“配置模板 + 配置项”的建模方式。

这里区分两类商品非常重要。一类是标准品,比如单卖的轴体、键帽、卫星轴、数据线,它们有固定规格和固定库存,继续沿用传统商品表加 SKU 表。另一类是可变配置商品,比如套件、成品键盘,它们本身没有完整规格,需要绑定一个“配置模板”,模板里定义有多少个可选维度,每个维度下有多少个配置项,每个配置项有独立价格增量和库存。

后台管理员上架一个套件商品时,第一步创建商品基本信息,第二步创建配置模板,第三步往模板里添加配置项。例如 75 套件模板里,“外壳颜色”维度下有黑色、白色、阳极银、电泳粉 4 个配置项,“PCB”维度下有热插拔、焊接 2 个配置项,“定位板”维度下有 PC、黄铜、碳纤维 3 个配置项,如此类推。

3.2 核心表结构:配置模板、配置项、定制方案单

下面给出三张核心表的精简 DDL,实际项目里还有商品表、SKU 表、购物车表、订单表等,这里重点展示和“个性化配置”相关的部分。

CREATE TABLE product_config_template ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL COMMENT '关联的商品SPU', template_name VARCHAR(100) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', version INT NOT NULL DEFAULT 1 COMMENT '缓存版本号', created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT '配置模板'; CREATE TABLE product_config_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, template_id BIGINT NOT NULL COMMENT '所属模板', dimension_code VARCHAR(50) NOT NULL COMMENT '维度编码,如layout/case/pcb/plate', dimension_name VARCHAR(50) NOT NULL COMMENT '维度名称,如配列/外壳/PCB/定位板', item_name VARCHAR(100) NOT NULL COMMENT '配置项名称,如热插拔/焊接', price_delta DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '价格增量,可为负数', stock INT NOT NULL DEFAULT 0 COMMENT '该配置项独立库存', sort_no INT NOT NULL DEFAULT 0, deleted TINYINT NOT NULL DEFAULT 0, KEY idx_template (template_id) ) COMMENT '配置项';

定制方案单表记录用户每一次完整的搭配选择:

CREATE TABLE custom_config_scheme ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, product_id BIGINT NOT NULL, template_id BIGINT NOT NULL, scheme_json TEXT NOT NULL COMMENT '配置维度到配置项ID的映射JSON', total_price DECIMAL(10,2) NOT NULL COMMENT '方案总价', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已加入购物车 2已下单', created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) COMMENT '定制方案单';

我特意加了模板版本号字段,后面讲缓存优化时会用到。配置项表里 stock 字段就是这个维度下的独立库存,比如“外壳颜色-电泳粉”库存 20 件,每次有人下单购买这个颜色就扣这里的库存。

3.3 为什么金额字段必须用 Decimal,库存字段必须用整数

这是老生常谈,但在写商城项目时仍然看到很多人踩坑。金额一律用DECIMAL(10,2),不要在数据库和 Java 代码里用 Float 或 Double 运算。二进制浮点数存金额会有精度问题,比如 0.1 加 0.2 得到 0.30000000000000004,这在价格计算里是绝对不能接受的。Java 侧用BigDecimal,并且所有金额计算都走同一个 Money 工具类,禁止散落到处写加减。

库存字段必须用整数,而且要是有符号整数。为什么强调有符号?因为库存扣减时我们用的 SQL 是SET stock = stock - #{num},如果库存已经为 0,再执行这条语句会让库存变成负数。为了避免这个问题,我在 SQL 后面加条件AND stock >= #{num},影响行数为 0 就说明库存不足。这个技巧配合有符号整数,能兼顾性能和正确性,不用先查询再判断,减少一次数据库往返。

4. 可变 SKU 实时价格计算:这个系统最核心的代码逻辑

4.1 价格由哪几部分组成

一个定制方案的最终价格,等于商品基础价格加上所有选中配置项的价格增量之和,再加上可选服务费。我举个例子:某个 75 套件基础价格是 599 元。用户选择了“电泳粉外壳”+30 元、“热插拔 PCB”+50 元、“黄铜定位板”+80 元、“某国产线性轴 90 颗”+260 元、“PBT 键帽一套”+180 元,那么总价就是 599+30+50+80+260+180=1199 元。

这里有三个细节需要特别注意。第一,价格增量可以是负数,比如从“外壳黑色”换成“外壳白色”可能不加钱,从“PC 定位板”换成“普通钢板定位板”可能减 40 元。后台设计配置项时价格增量可正可负,这样能灵活表达商家的定价策略。第二,轴体和键帽虽然也是配置项,但它们本质上是标准品 SKU,所以配置模板里允许配置项关联一个可选的 SKU ID。选中这个配置项时,不仅要累加价格,还要在生成订单时把对应 SKU 的库存一起锁定。第三,刻字服务这类“个性化增值服务”单独立表,按字符数计费,代码里单独处理。

4.2 前端联动与后端接口设计

用户在商品详情页操作时,前端 JavaScript 会维护一个“当前选中配置”的 Map,键是维度编码,值是配置项 ID。每次用户点击选择项,就触发一次 Ajax 请求,把整个 Map 传给后端报价接口,后端返回最新总价、每个配置项的可选状态和库存情况。前端用返回结果更新报价区域,同时把库存为 0 的配置项置灰。

这个接口的设计要求是幂等、只读、快速。它不应该修改任何数据,只负责计算和校验。接口路径我定义为GET /api/quote,请求参数是productId加itemIds(配置项 ID 列表),响应结构如下:

{ "code": 0, "data": { "totalPrice": "1199.00", "basePrice": "599.00", "items": [ { "id": 1001, "dimensionCode": "caseColor", "itemName": "电泳粉外壳", "priceDelta": "30.00", "stock": 18, "available": true } ] } }

前端拿到这个响应直接渲染即可。注意不要在前端自行拼价格,前端只展示后端计算好的结果,防止用户修改页面脚本篡改报价。

4.3 完整的价格计算 Service 代码与说明

报价服务是项目里最值得反复阅读的一段代码。下面我给出简化后的核心逻辑:

@Service @RequiredArgsConstructor public class QuoteService { private final ProductConfigTemplateMapper templateMapper; private final ProductConfigItemMapper configItemMapper; private final ProductMapper productMapper; @Transactional(readOnly = true) public QuoteResult quote(Long productId, List<Long> itemIds) { Product product = productMapper.selectById(productId); ProductConfigTemplate template = templateMapper.selectByProductId(productId); Assert.notNull(template, "商品未配置可选项"); List<ProductConfigItem> selectedItems = configItemMapper.selectBatchIds(itemIds); // 校验选中的配置项都属于该商品模板 boolean allMatch = selectedItems.stream() .allMatch(item -> item.getTemplateId().equals(template.getId())); if (!allMatch) { throw new BusinessException("存在无效的配置项"); } // 按维度去重,同一维度只能选一个配置项 Set<String> dimensions = selectedItems.stream() .map(ProductConfigItem::getDimensionCode) .collect(Collectors.toSet()); if (dimensions.size() != selectedItems.size()) { throw new BusinessException("同一维度不能选择多个选项"); } BigDecimal total = product.getBasePrice(); for (ProductConfigItem item : selectedItems) { total = total.add(item.getPriceDelta()); } // 附加刻字服务费用 if (selections.containsKey("engraving")) { total = total.add(engravingService.calculatePrice(selections.get("engraving"))); } QuoteResult result = new QuoteResult(); result.setTotalPrice(total.setScale(2, RoundingMode.HALF_UP)); result.setBasePrice(product.getBasePrice()); result.setItems(selectedItems); return result; } }

这段代码逻辑不复杂,但有两个隐藏点值得细讲。第一个是“按维度去重”的校验,存货代码里很容易漏掉。如果用户提交了两个同维度配置项,比如同时选了“热插拔 PCB”和“焊接 PCB”,后端必须拦截,否则价格会重复累加,订单数据也会脏。第二个是事务注解只用了readOnly = true,因为报价查询不会修改数据,这样能减轻数据库负载,也让调用方明确这是只读操作,防止有人在里面偷偷写数据。

有了报价接口,接下来要处理的就是把选好的方案保存成方案单,然后进入购物车。方案单保存时会把 totalPrice 也存进去,但注意这个价格只是“参考价”,最终订单金额在生成订单时还会重新计算一遍,避免用户拿着旧报价单来下单。

5. 下单、支付与库存扣减:状态机设计比接口更重要

5.1 订单状态机

订单模块最忌讳的就是用一堆 if else 去判断状态能不能流转。我画了一张状态流转表,然后严格按照这张表开发:

状态编码状态名称可流转到
0待支付1 已支付生产中、6 已取消
1已支付生产中2 待发货
2待发货3 已发货
3已发货4 已完成
4已完成无
5退款中4 已完成、6 已取消
6已取消无

每个状态节点只允许跳转到表格里的目标状态,任何非法跳转直接抛业务异常。在 Java 里我封装了一个OrderStatusMachine组件,维护一个EnumMap,判断当前状态到目标状态是否合法。这样做的好处是,后续再加新状态时,只用改这一张表和一个枚举,不用到处找散落的 if else。

支付回调是这个模块里最需要小心的入口。第三方支付平台回调时,携带订单号和支付结果,我们必须保证回调处理逻辑的幂等性。我的做法是:在支付回调处理业务前,先查询支付流水表,如果该流水号已经处理过就直接返回成功,不重复执行订单状态流转和库存扣减。

5.2 库存扣减的两种时序:下单锁定还是支付锁定

库存扣减的时机有两种选择。第一种是“下单锁定库存”,用户在创建订单时立刻扣减库存,取消订单时归还。这种方案用户体验好,但有个致命问题:大量用户下单不支付,库存会被无效占用,后面真正想买的人反而买不到。第二种是“支付成功后再扣库存”,用户在订单创建时不扣库存,只有支付成功才真正扣减。这样库存占用和真实销售是同步的,但可能出现支付成功后扣库存失败的尴尬情况。

我选择了第二种,并且做了补偿机制。支付回调里执行“扣减库存”和“更新订单状态”两个操作,放在同一个本地事务里。库存不足时抛异常让事务回滚,同时把支付回调标记为“待处理”,后续人工介入处理退款。客制化键盘本身有生产周期,用户支付后本来就有一段等待期,所以“支付后再扣库存”在业务上完全说得通。

库存扣减的 SQL 也很关键,直接用乐观锁方式:

int rows = inventoryMapper.deductStock(item.getSkuId(), quantity); if (rows == 0) { throw new BusinessException("库存不足:" + item.getItemName()); }

对应 SQL 是:

UPDATE sku_inventory SET stock = stock - #{quantity} WHERE sku_id = #{skuId} AND stock >= #{quantity}

rows == 0意味着库存不足,整个订单事务回滚。这里不用悲观锁,是因为客制化商城并发量没那么高,乐观锁足够,而且不会长时间持有行锁影响其他操作。

5.3 定制工单快照如何进入订单

用户下单时,购物车里有普通 SKU 行,也有定制方案行。生成订单时,普通 SKU 行直接创建订单项,关联 SKU ID 和购买数量;定制方案行则要把方案单里的 JSON 配置序列化成带结构的信息,存到订单项的spec_snapshot字段里。

这个快照非常重要。订单项里如果只存一个方案单 ID,等后台把某个配置项下架或改价后,用户查看历史订单,看到的价格和配置就对不上了。我在生成订单项时,把配置维度名称、配置项名称、单价增量、小计金额全部写进 JSON 快照,确保订单页能原样还原用户当时的选择。这是一个“空间换时间”的方案,虽然多占一点存储空间,但换来了历史订单的绝对稳定。

我这里特意设置了订单金额的两段式校验:生成订单时,后端根据快照重新累加一遍价格,然后和订单总价比对,不一致就截断生成并提示“商品价格已变动,请重新确认”。这个设计能防止用户拿旧报价下单占便宜。

6. 前端页面与后端接口的对接:本地预览如何变成真实渲染

6.1 套件组装预览的前端交互设计

客制化商城的前端交互,重点是商品详情页。我希望用户打开页面就能直观看到当前配置:左侧是键盘配列的示意图,右侧是配置选择区。由于第一期没有引入复杂的 Canvas 渲染,我用的是“配置摘要卡片 + 价格明细列表”的方案,不追求像素级还原,但要求信息完整清晰。

交互逻辑是这样的:页面初始化时加载默认配置,同时向后端发一次报价请求,渲染出基础价格。用户每点击一个配置项,页面先高亮选中项,然后调用报价接口更新总价。如果某个配置项库存为 0,前端直接渲染成灰色禁用态,并且不发起请求。对大量可选配件,采用折叠分组方式展示,避免页面一次性撑得太长。

这里用到的是一段原生 JavaScript,核心是防抖函数:

let timer = null; function refreshQuote(selectedItems) { clearTimeout(timer); timer = setTimeout(() => { fetch('/api/quote?productId=' + productId + '&itemIds=' + selectedItems.join(',')) .then(res => res.json()) .then(data => { if (data.code === 0) { document.getElementById('totalPrice').innerText = '¥' + data.data.totalPrice; renderStockStatus(data.data.items); } }); }, 300); }

防抖时间设为 300 毫秒,用户快速连续点击不同配置项时,只在最后一次点击后发起请求,避免瞬间发起多个请求打爆后端。

6.2 配置项联动:什么是必须在前端做的,什么是必须留给后端的

前端能做的是给用户即时反馈,比如选中高亮、库存置灰、价格展示。但所有“真实计算”都必须交给后端。我在开发时给自己定了一条铁律:前端任何状态都不能影响最终交易数据。用户从商品详情页把方案加入购物车时,前端传过去的只是方案单里的配置项 ID 列表,后端会重新校验每个配置项是否存在、是否属于该商品模板、库存是否足够,然后重新计算价格。前端传什么都不可信。

很多新手在这里容易翻车,图省事把计算好的总价直接传给后端保存,导致用户通过修改请求参数把 1199 元的键盘改成 1 块钱下单。我见过不止一个项目出这种事故。所以方案单保存接口、订单生成接口里的金额全部以后端计算为准,前端传的金额直接忽略。

6.3 购物车行、结算页与订单页的渲染衔接

购物车页面做起来比想象中麻烦,因为要同时渲染两类行。普通 SKU 行显示商品图片、名称、单价、数量;定制方案行显示“75 套件定制方案”,点开能看到配置摘要,比如“电泳粉外壳 + 热插拔 PCB + 黄铜定位板 + 某线性轴 90 颗 + PBT 键帽”。数量默认是 1,因为一套定制键盘就是一台。

结算页除了展示商品明细和总价,还要回收货地址。我这里简单处理为首次填写后保存到用户地址簿,下次自动带出最近地址。订单页则根据订单项里的spec_snapshot字段渲染完整配置清单,保证用户看到的永远是他下单时的那套配置。

7. 上线与调优:一次报价请求从 120ms 降到 20ms 的实践

7.1 用 Redis 缓存配置模板的注意事项

报价接口是详情页被调用最频繁的接口,每次都查数据库的话,压力全在 MySQL 上。我的做法是配置模板和配置项在后台变更时,写入 Redis 缓存一份 JSON 结构。缓存 key 用config:template:{id}:v{version},版本号从数据库模板表里取。后台管理员修改配置后,版本号加 1,新请求自然落到新缓存上。

缓存的加载逻辑是经典的 Cache Aside 模式:先查 Redis,命中直接返回;未命中则查数据库、写入 Redis、设置过期时间(可以设 30 分钟)。这样配置模板不会长期不更新,也不会因为频繁更新把 Redis 拖垮。这里要注意一个细节:写缓存和更新版本号必须放在后台修改配置的同一个事务里,否则可能出现数据库已经更新、缓存还是旧数据的窗口期。

加了缓存以后,报价接口的响应时间从平均 120ms 降到了 20ms 左右,数据库 QPS 也下来了。后面做简单压测时,单机并发 300 线程持续调用报价接口,P95 响应时间依然在 50ms 以内,完全够用。

7.2 防超卖的两道闸门

商城项目最怕的是超卖,明明库存只有 3 件,结果卖出去了 5 单。我这里设置了两道闸门。第一道在支付回调阶段,执行库存扣减 SQL 时用stock >= #{quantity}条件做乐观锁校验,扣减影响行数为 0 就回滚事务,这条是最硬的一道防线。第二道在创建订单前,把方案单里涉及的配置项库存都查出来,只要有一个配置库存为 0,就提示用户调整配置。第二道属于提前拦截,为了减少用户走到支付才发现买不了的挫败感。

两道闸门不能互相替代。如果只做第一道,用户下单时体验差;如果只做第二道,高并发下多个请求同时查到库存充足,但支付扣减时库存已经被别人扣光,就会出现支付成功但库存不足的异常。双闸门配合才稳妥。

7.3 部署形态与 JVM 参数的一些要点

部署我用的方式是 Spring Boot 打成可执行 jar 包,放在一台 2C4G 的轻量服务器上,前面用 Nginx 做反向代理。Nginx 主要负责静态资源缓存、HTTP 转发和简单的限流配置。jar 包启动时的 JVM 参数如下:

java -Xms512m -Xmx1024m \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=200 \ -Dfile.encoding=UTF-8 \ -jar springboot101-shop.jar

-Xms512m -Xmx1024m设置初始堆和最大堆,避免频繁扩容。G1 垃圾回收器适合这种内存不太大但并发存在的情况。数据库连接池我用的 HikariCP,最大连接数设为 20,最小空闲连接数设为 5,完全够用。

还有一个容易忽略的点是 MySQL 连接字符串要加useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8,否则运行时会遇到时区错误或者 SSL 握手过慢的问题。我第一次部署时忘记配时区,结果所有时间字段都差了 8 个小时,排查了半天。

8. 开发中踩过的坑:这些问题不写出来真的对不起读者

8.1 MySQL 字段名用了保留字,上线前一天接口直接 500

我第一版商品表里有个字段叫desc,想着是描述的意思,结果 MyBatis-Plus 生成 SQL 时把它拼进查询语句,直接报 SQL 语法错误。后来才知道desc是 MySQL 的保留字,用于降序排序。排查过程也很简单,控制台打印出的 SQL 一看就知道问题,但当时确实费了点时间。教训就是建表前先查一下字段名是否在保留字列表里,像order、group、desc、rank这些都不要用。

还有一个相关问题是 MyBatis-Plus 的下划线转驼峰。数据库字段item_name映射到 Java 属性itemName,本来一切正常。但有次我写了一个联表查询,返回字段里既有name又有item_name,结果两个字段同时映射到同名属性上,数据互相覆盖。后来我所有查询都用明确的 DTO 接收,不用 Map 接收结果集,避免这种弯弯绕绕的问题。

8.2 前端传来的配置项 ID 不能直接信,我被“一折键盘”案例吓出一身汗

开发过程中我自己测试过一次极端场景:用浏览器开发者工具修改请求参数,把方案单里的配置项全部清空,只剩一个基础商品,总价直接从 1000 多变成 599。如果后端直接信任这个总价,用户就能用基础价买到全套配置。

还好我在设计时就定了铁律:后端永远重新计算金额。实际开发中我也见过有人把价格计算写在前端,后端只做保存,这种项目上线等于送钱。建议所有做电商的开发者,把“后端重新计算金额”这条规则写进自己的编码规范里,每次下单接口都做一次金额核对。

8.3 事务里调用第三方支付接口导致连接耗尽

早期版本我在创建订单的事务里直接发起第三方支付请求,想着一步到位。结果有次支付的沙箱环境网络缓慢,每个请求要等 30 秒才超时,而事务一直拿着数据库连接不放。压测时很快出现连接池耗尽,整个应用陷入假死状态。

后来我改成“订单创建事务只做本地数据落库”,支付请求放到事务提交之后异步发起。支付回调进来时,开启一个新事务处理状态更新和库存扣减。这样数据库事务的持有时间从原来的几十秒缩短到几十毫秒,再也没出现过连接耗尽的问题。

8.4 金额用浮点数导致的对账差异

项目初期我图省事,价格字段在 Java 里用了Double,结果跑了一段时间后对账发现偶尔差几分钱。比如 0.1 加 0.2 用二进制浮点算出来是 0.30000000000000004,四舍五入后虽然接近,但累计到几十条订单后误差就放大了。后来我把所有金额字段全部改成BigDecimal,并且在数据库里统一用DECIMAL(10,2),对账问题彻底消失。这个教训很基础,但真的很重要,商城项目千万别在金额上省事。

再补充一个缓存更新顺序的坑。最早我更新配置后直接删除 Redis key,但删除操作在数据库事务提交之前执行,导致旧数据被重新加载。后来我把“缓存版本号+1”放进数据库事务,接口从缓存读取时始终携带最新版本号,才真正解决了缓存和数据一致性的问题。

项目做到这里,回头看看最值钱的其实不是代码量,而是把“可变配置商品”的建模想透了。客制化键盘这个品类,天然逼着你去面对动态 SKU、维度库存、方案快照这些问题——这些恰恰是通用电商教程里很少讲透的。如果你也想拿 Spring Boot 做项目,我建议别再做图书管理系统或者简单的 CRUD 商城了,试试这个方向,做完以后你对电商核心链路的理解会上一个台阶。

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

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

立即咨询