ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot二次元商城:预售抢购与防超卖后端实战

Spring Boot二次元商城:预售抢购与防超卖后端实战 简介基于Spring Boot的二次元商品购物商城系统是一份面向Java学习者、Spring Boot初学者及电商类毕业设计开发者的完整实战项目。资源围绕动漫、漫画、游戏周边等二次元商品场景展示从用户注册、商品检索、购物车到订单支付、物流跟踪、评价分享的完整商城闭环帮助读者快速掌握企业级Web系统的分层结构与业务实现思路。压缩包内共717个文件整体约45.88MB文件类型涵盖Java源码、SQL数据库脚本、前端HTML/CSS/JS、字体图标、图片素材及项目配置文档等结构上基本兼顾后端业务、前端页面与数据库设计可直接导入开发工具对照学习或改造。资源已有35647人学习浏览颇受关注。参考该项目可理解Spring Boot整合MyBatis等常用组件的写法、订单与库存的联动逻辑还能利用随包SQL脚本快速搭建本地数据库降低上手指门槛。1. 基于 Spring Boot 的二次元商品购物商城预售、抢购与再版背后的后端逻辑手办、谷子、一番赏这类二次元周边和普通服饰电商最大的区别不只是商品图更漂亮一个角色可以拆出普通版、特典版、再版售卖方式从现货延伸到定金预售、尾款补款热门口碑款上架当天会被同一批用户反复刷新。基于 Spring Boot 的二次元商品购物商城后端真正要解决的困难集中在三个地方预售与尾款两段式交易怎么建模爆款开售时库存怎么扣才能不超卖支付回调重复通知怎么保证订单状态不混乱。这套方案的适用人群是正在做毕设或准备独立落地中小型电商项目的后端工程师也适合想补全交易链路的 Spring Boot 使用者。下面按工程搭建、领域建模、订单链路、后台治理、上线验证的顺序逐层展开每个环节都给出可直接修改的代码。2. Spring Boot 工程搭建骨架、依赖与第一个商品查询接口2.1 版本选择先定 JDK 与 Spring Boot 版本再定依赖坐标排查很多“跑不起来”的问题后会发现根源常常不是业务代码而是版本不匹配。热词里频繁出现“springboot版本太高”本质上是 Spring Boot 3.x 要求 JDK 17包名从 javax 换成 jakarta很多旧教程里的代码直接复制过来会编译失败同时 MyBatis-Plus、SpringDoc 等三方库的适配版本也有变化。如果手头是 JDK 8 的存量环境选 Spring Boot 2.7.x 最稳妥新项目且服务器愿意上 JDK 17再考虑 3.x。这个选择会直接影响后续所有依赖的坐标写法。对比项Spring Boot 2.7.xSpring Boot 3.x最低 JDK8 / 11 / 1717包名javax.*jakarta.*三方依赖适配大量现成老依赖多不兼容适合场景毕设、存量项目、保守生产新项目、愿意跟随新版我一般建议毕设或课程设计选 2.7.x因为网上的代码和问答基本可以直接套用3.x 在 SpringDoc、Redis 客户端上的坐标都有变化排错成本更高。2.2 核心依赖与配置文件MyBatis-Plus、Redis、参数校验一次配齐商城后端最少需要四类能力Web 接口、数据库访问、缓存、参数校验。下面是一份基于 Spring Boot 2.7.18 的 pom.xml 核心片段parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.springdoc/groupId artifactIdspringdoc-openapi-ui/artifactId version1.7.0/version /dependency /dependenciesstarter-validation 负责入参校验data-redis 后续用于缓存和分布式锁mybatis-plus-boot-starter 提供 BaseMapper 免写 XML 的能力springdoc 用来生成接口文档。注意 Spring Boot 2.7.x 对应 springdoc 1.7.0如果是 3.x 项目则要用 springdoc 2.x包路径也变了。提示mybatis-plus-boot-starter 3.5.3.1 搭配 Boot 2.7启动类上必须有MapperScan否则第一次访问接口会报 Invalid bound statement。再来看 application.yml 的关键配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${MYSQL_PASSWORD:root} hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 data: redis: host: localhost port: 6379 mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 springdoc: api-docs: enabled: ${SPRINGDOC_ENABLED:true}数据源 URL 里的 serverTimezoneAsia/Shanghai 经常被漏掉漏掉后数据库连接会直接失败密码用${MYSQL_PASSWORD:root}形式从环境变量读取而不是硬编码进 yml。MySQL 8 之后的驱动类名会自动识别不需要再写com.mysql.jdbc.Driver。mybatis-plus 的 map-underscore-to-camel-case 开启后数据库的created_at字段能自动映射到实体的createdAt。logic-delete 配置的作用是删除操作自动变成 UPDATE商品下架或用户注销不会真正删数据。2.3 表结构初始化开发环境自动执行 schema.sql生产环境用 Flyway热词里“springboot mybatis 当表不存在自动建表”是开发期常遇到的问题。MyBatis-Plus 本身没有自动建表能力常见做法是开发环境放 schema.sql配置spring.sql.init.modealways启动时自动执行生产环境用 Flyway 管理变更脚本不直接改线上表。CREATE TABLE IF NOT EXISTS mall_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, password_hash VARCHAR(128) NOT NULL, phone VARCHAR(20), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE IF NOT EXISTS mall_goods ( id BIGINT AUTO_INCREMENT PRIMARY KEY, goods_name VARCHAR(128) NOT NULL, ip_name VARCHAR(64), cover_url VARCHAR(512), status TINYINT NOT NULL DEFAULT 0 COMMENT 0下架 1上架, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字符集选 utf8mb4 而不是 utf8原因很直接日文假名和 emoji 只有 utf8mb4 才存得下。status 字段控制商品上下架运营操作这个字段而不是删除记录如果后续要加“限时秒杀”“会员专属”等状态在这个字段上扩展即可。2.4 第一个商品查询接口Controller-Service-Mapper 分层跑通接口拆成三层是 Spring Boot 项目最通用的结构。Controller 只负责接收参数和返回结果Service 放业务逻辑Mapper 对数据库操作。先看 ControllerRestController RequestMapping(/api/goods) public class GoodsController { private final GoodsService goodsService; public GoodsController(GoodsService goodsService) { this.goodsService goodsService; } GetMapping(/category/{categoryId}) public ResultListGoodsVO listByCategory(PathVariable Long categoryId) { return Result.ok(goodsService.listByCategory(categoryId)); } }Service 实现Service public class GoodsService { private final GoodsMapper goodsMapper; public GoodsService(GoodsMapper goodsMapper) { this.goodsMapper goodsMapper; } public ListGoodsVO listByCategory(Long categoryId) { ListGoods goodsList goodsMapper.selectList( new LambdaQueryWrapperGoods() .eq(Goods::getCategoryId, categoryId) .eq(Goods::getStatus, 1)); return goodsList.stream().map(GoodsVO::from).toList(); } }构造器注入在这里比Autowired字段注入更合适单元测试时直接 new Service 并传 mock 的 Mapper 就行不依赖 Spring 容器。LambdaQueryWrapper 是类型安全的查询条件构造器Goods::getCategoryId写错字段名会直接编译报错而不是运行时才暴露。查询条件里强制加status 1作用是拦截下架商品。用户看到的永远只有上架内容后台改状态即时生效不用重新发版。注意刚启动时最常见的两类报错一是数据库连接拒绝检查 serverTimezone 和账号密码二是 Redis 连不上Spring Boot 2.x 的 Redis 默认在启动阶段就建立连接本地没有 Redis 服务时可以把该依赖先注释掉再排错。3. 二次元商品领域建模与检索SPU/SKU、预售、筛选与缓存3.1 从手办到两张表SPU 存公共信息SKU 存规格参数二次元手办的商品参数和服装鞋帽完全不是一个套路比例、高度、材质、原型师、是否会场限定同一角色还分普通版、特典版、再版。把这些字段全塞进一张商品表最终会得到一张列数失控、大量行为空的大宽表。更通用的做法是拆 SPU 和 SKUSPU 放公共信息SKU 放价格、库存和具体规格。CREATE TABLE mall_goods_spu ( id BIGINT AUTO_INCREMENT PRIMARY KEY, spu_name VARCHAR(128) NOT NULL, ip_name VARCHAR(64), character_name VARCHAR(64), cover_url VARCHAR(512), detail_html LONGTEXT, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE mall_goods_sku ( id BIGINT AUTO_INCREMENT PRIMARY KEY, spu_id BIGINT NOT NULL, sku_name VARCHAR(128) NOT NULL, price_cents INT NOT NULL COMMENT 售价单位分, cost_cents INT NOT NULL DEFAULT 0, stock INT NOT NULL DEFAULT 0, sales_type TINYINT NOT NULL DEFAULT 0 COMMENT 0现货 1定金预售 2全款预售 3尾款补款, spec_json VARCHAR(1024) COMMENT 比例/高度/材质等规格JSON, pre_sale_end_time DATETIME COMMENT 预售截止时间, final_payment_end_time DATETIME COMMENT 尾款截止时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, version INT NOT NULL DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;SKU 表里最关键的设计是price_cents INT金额用“分”存整数而不是用 DECIMAL 或 double。后续做对账、统计销售额不会遇到浮点误差展示层再除以 100 转成元。spec_json 存比例、高度、材质这类变动大的规格查询压力上来后可以把高频筛选项抽成独立列并建索引。3.2 价格单位转换后端存分展示层转元价格运算在订单、退款、对账里到处都是。后端统一使用分只在返回给前端的 VO 里转成元public static String toYuan(Integer cents) { return BigDecimal.valueOf(cents).movePointLeft(2).toPlainString(); }movePointLeft(2)把 129900 变成 1299.00toPlainString()防止科学计数法把数字显示成1.299E5。这个工具方法放在 common 包所有金额展示都走它避免每个开发各自写一套除法逻辑。3.3 预售、补款与再版销售类型用枚举管理二次元商城的售卖节奏和普通电商很不一样现货卖完后开定金预售到货后用户补尾款之后可能再版再开一轮。只靠上下架状态无法区分“可购买”和“仅可补款”所以 SKU 上单独放一个 sales_type 字段用枚举收敛取值public enum SalesType { INSTANT(0, 现货), DEPOSIT_PREORDER(1, 定金预售), FULL_PREORDER(2, 全款预售), FINAL_PAYMENT(3, 尾款补款), UNKNOWN(99, 未知); private final int code; private final String desc; SalesType(int code, String desc) { this.code code; this.desc desc; } public static SalesType of(int code) { for (SalesType type : values()) { if (type.code code) { return type; } } return UNKNOWN; } }典型的流转路径是定金预售支付成功 - 待补款补款支付成功 - 待发货。用枚举的好处是接口里传一个3没人知道什么意思强制通过SalesType.of(3)转一次调用方必须看枚举定义非法值也能提前拦截。3.4 组合筛选查询LambdaQueryWrapper 与分页插件商城列表页最常见的场景是“按 IP 和角色筛选价格在 500 到 800 之间只要现货”。这种多条件组合查询用 LambdaQueryWrapper 写起来最顺手Override public PageGoodsSkuVO search(GoodsSearchParam param) { LambdaQueryWrapperGoodsSku wrapper new LambdaQueryWrapper(); wrapper.eq(StringUtils.hasText(param.getIpName()), GoodsSku::getIpName, param.getIpName()) .eq(StringUtils.hasText(param.getCharacterName()), GoodsSku::getCharacterName, param.getCharacterName()) .ge(param.getMinPrice() ! null, GoodsSku::getPriceCents, param.getMinPrice()) .le(param.getMaxPrice() ! null, GoodsSku::getPriceCents, param.getMaxPrice()) .eq(param.getSalesType() ! null, GoodsSku::getSalesType, param.getSalesType()) .eq(GoodsSku::getStatus, 1) .orderByDesc(GoodsSku::getCreatedAt); PageGoodsSku page goodsSkuMapper.selectPage( new Page(param.getPageNum(), param.getPageSize()), wrapper); return page.convert(GoodsSkuVO::from); }StringUtils.hasText()为 false 时不拼这个条件避免前端传空字符串导致 SQL 里出现ip_name 的脏条件。价格参数通过 setter 在 Controller 层就转成“分”Service 层不感知元分转换。最需要注意的是salesType ! null判断现货的 code 是 0不能写param.getSalesType() 0否则现货永远筛不出来。分页组件selectPage依赖 MybatisPlusConfig 里注册的PaginationInnerInterceptorConfiguration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor( new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }很多开发因为漏掉这个配置导致 selectPage 查出全表数据后再内存分页商品量到几十万时接口直接卡死。3.5 搜索优化拼接 search_text 字段分词引擎等量级上来再引入二次元商品名天然混杂中文、日文、英文和昵称。“宝钟玛琳”可能被搜成“玛琳”“hololive”和“holo”都要命中。MySQL 的 LIKE 在数据量小时完全够用做法是给 SPU 加一个 search_text 字段写入时把检索词拼好String searchText String.join( , spu.getSpuName(), spu.getIpName(), spu.getCharacterName(), String.join( , aliasList)); spu.setSearchText(searchText); goodsSpuMapper.insert(spu);查询时WHERE search_text LIKE CONCAT(%, #{keyword}, %)商品量到几十万以后再把搜索切换到 Elasticsearch 或引入分词组件如 HanLP做倒排索引。这个阶段通常出现在项目跑起来之后一开始就上 ES 反而增加运维负担。3.6 详情页缓存击穿优先防空值也要缓存预售页一开同一个 SKU 的详情请求在短时间内可能到几千 QPS而 Redis 里这个 key 只有一个这就是典型的缓存击穿场景。第一次缓存失效时几十个回源线程同时打 MySQL数据库压力瞬间拉满。处理方式是用互斥锁保证只有一个线程回源public GoodsSku getWithLock(Long skuId) { String key mall:sku: skuId; Object value redisTemplate.opsForValue().get(key); if (value ! null) { return JSON.parseObject(value.toString(), GoodsSku.class); } String lockKey mall:lock:sku: skuId; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(5)); if (Boolean.TRUE.equals(locked)) { try { GoodsSku sku goodsSkuMapper.selectById(skuId); String json sku null ? : JSON.toJSONString(sku); redisTemplate.opsForValue().set(key, json, Duration.ofSeconds(600 ThreadLocalRandom.current().nextInt(60))); return sku; } finally { redisTemplate.delete(lockKey); } } try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getWithLock(skuId); }setIfAbsent是 Redis 分布式锁的基础5 秒过期是为了防止回源线程崩溃导致锁永久不释放。缓存时间 600 秒加随机 60 秒偏移用于错开大量 key 的同时过期避免雪崩。查不到商品时缓存空字符串而不是直接返回空否则“搜索不存在的角色”请求会持续穿透到数据库。递归重试前 sleep 50 毫秒是给拿到锁的线程留出回填缓存的时间。4. 订单链路与库存扣减幂等下单、Redis Lua 防超卖、回调处理4.1 下单链路与两段式库存设计一次完整的订单流程是用户从商品详情进入确认页提交订单时先做库存预占并生成待支付订单用户到支付网关付款网关异步回调通知系统收到回调后确认收款再把预占转为已售出如果用户超时未付定时任务关闭订单并释放预占库存。采用两段式而不是支付成功再扣库存背后是两个真实场景一是避免用户支付时才被告知无货体验极差二是热门手办开售时大量用户同时支付支付阶段扣库存会造成严重的锁竞争。预占扣的是可售库存支付后扣的是实际库存两者之差就是“待支付占用量”。订单表至少需要记录sku_id、quantity、status、pre_occupation_flag四个字段。4.2 Redis Lua 原子扣减预占库存性能和一致性兼顾先看一个常见的错误写法查库存、判断充足、再 UPDATE 扣减。这三步在高并发下一定超卖因为两个线程同时读到库存为 1都判定可以下单。在数据库层面用UPDATE stock stock - 1 WHERE stock 1能解决单个 SKU 的问题但商城订单往往同时包含多个 SKU跨多行的原子扣减就复杂了。常见做法是把预占库存放在 Redis用 Lua 脚本保证原子性if redis.call(exists, KEYS[1]) 0 then return -1 end local stock tonumber(redis.call(get, KEYS[1])) local num tonumber(ARGV[1]) if stock num then return 0 end redis.call(decrby, KEYS[1], num) return 1Java 侧调用private static final DefaultRedisScriptLong DEDUCT_SCRIPT new DefaultRedisScript( if redis.call(exists, KEYS[1]) 0 then return -1 end local stock tonumber(redis.call(get, KEYS[1])) local num tonumber(ARGV[1]) if stock num then return 0 end redis.call(decrby, KEYS[1], num) return 1, Long.class); public boolean deductStock(Long skuId, Integer num) { Long result redisTemplate.execute( DEDUCT_SCRIPT, List.of(mall:stock: skuId), num.toString()); return Long.valueOf(1L).equals(result); }Lua 脚本在 Redis 单线程模型下整体执行期间不会插入其他命令所以不存在“判断完还没扣就被别人抢先”的问题。返回 -1 表示 key 不存在初始化漏了0 表示库存不足1 表示扣减成功。订单创建失败时要释放预占对应执行INCRBY恢复库存。方案并发安全性实现成本适用场景乐观锁 version安全但冲突时回滚频繁低后台管理、低频修改UPDATE stockstock-1 WHERE stock1单 SKU 安全低商品数量少、并发一般Redis Lua安全且吞吐高中热销开售、多 SKU 合单4.3 幂等下单防重复提交的两种做法用户双击提交、前端超时重试都会导致同一个购物车生成多笔订单。常见做法是前端生成一个幂等键随下单请求一起提交后端绑定用户 ID 判断是否处理过public OrderVO submit(OrderSubmitRequest request, Long userId) { String idemKey mall:idem: userId : request.getIdempotencyKey(); Boolean first redisTemplate.opsForValue() .setIfAbsent(idemKey, 1, Duration.ofMinutes(10)); if (!Boolean.TRUE.equals(first)) { throw new BizException(订单已提交请勿重复操作); } // 继续走创建订单、预占库存逻辑 try { return doCreateOrder(request, userId); } catch (Exception e) { redisTemplate.delete(idemKey); throw e; } }10 分钟的有效期覆盖了用户重复点击和弱网重试的窗口。订单创建失败时一定要把 idemKey 删掉否则用户要等 10 分钟才能重新下单这个细节被漏掉会导致线上大量客诉。另一种兜底方案是在订单表加唯一索引(user_id, idempotency_key)数据库层面拦截重复插入。Redis 方案性能好数据库唯一索引方案更可靠两个同时做也不算冗余。4.4 支付回调的幂等与状态流转支付宝和微信的异步通知不保证只发一次尤其网络抖动时会短时间内重复到达。订单更新不能用先查后改而要用带状态条件的 UPDATEUPDATE mall_order SET status 2, paid_at NOW() WHERE order_no #{orderNo} AND status 1影响行数为 1 说明是首次回调正常处理影响行数为 0 说明订单已支付过或状态不对此时直接返回“成功”给支付网关不要再尝试二次更新。网关侧只有收到成功应答才会停止重发所以重复通知的处理原则是能正确处理就处理不能处理也务必返回成功避免无限重推。金额核对同样不能省回调参数里的实付金额要和订单表应付金额比对不一致时记录风险订单并告警不能直接改状态。4.5 超时关单定时任务扫单多实例部署必须加锁预占库存的订单不支付库存会被无限期占用。常规做法是 Spring Schedule 每分钟扫一次把“创建超过 15 分钟且未支付”的订单关闭并释放库存Scheduled(cron 0 * * * * ?) public void closeExpiredOrders() { Boolean locked redisTemplate.opsForValue() .setIfAbsent(mall:cron:closeOrder, 1, Duration.ofSeconds(50)); if (!Boolean.TRUE.equals(locked)) { return; } try { ListOrder expiredOrders orderMapper.selectList( new LambdaQueryWrapperOrder() .eq(Order::getStatus, 1) .lt(Order::getCreatedAt, LocalDateTime.now().minusMinutes(15))); for (Order order : expiredOrders) { closeOrderAndReleaseStock(order); } } finally { redisTemplate.delete(mall:cron:closeOrder); } }锁过期设置成 50 秒而不是 60 秒是为了避免任务执行时间超过 1 分钟时下一个周期又抢到锁导致两个节点同时处理同一批订单。finally 里直接 delete 锁有个边界问题如果锁已经自动过期删除操作可能删掉其他节点新建的锁严谨的写法是拿锁时存一个唯一 value删除前用 Lua 校验 value 一致才删。扫描条件只更新status 1待支付的订单避免把人工标记的特殊订单误关。5. 商城后台与运维韧性鉴权、日志保护、限流与维护模式5.1 前台用户与后台管理员分开管理注册拦截器商城的后台操作远不止商品上下架预售场次维护、再版批次管理、补款通知、线下展会库存调整运营角色比普通用户多得多。如果管理功能沿用前台用户表权限越界非常难控制。常见做法是前台mall_user和后管mall_admin分开建表后台接口统一挂/admin/**前缀用拦截器统一校验Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AdminAuthInterceptor()) .addPathPatterns(/admin/**) .excludePathPatterns(/admin/login); } }AdminAuthInterceptor 里解析前端传来的 JWT校验管理员身份后把 adminId 放入 ThreadLocalController 直接取值。排除/admin/login因为登录接口本身就是放行入口。前后台用户表分开之后登录频控、token 刷新策略可以各自独立调整不会互相影响。5.2 日志脱敏与 Actuator 暴露控制heapdump 泄露要提前堵商城的用户手机号、支付单号属于敏感数据不能整段打进日志。更隐蔽的风险是 Actuator 的 heapdump 端点未防护的/actuator/heapdump能下载整个 JVM 堆内存里面可能包含数据库密码、内存中的用户 token、支付上下文。线上配置至少做到management: endpoints: web: exposure: include: health,metrics,info endpoint: health: show-details: neverheapdump、env、beans 这几个端点不在白名单里就不要对公网暴露health 的 show-details 设为 never避免数据库连接信息被监控探活带出来。日志层面支付回调的原始参数不该直接 log.info只记录订单号和回调状态用户手机号只记录后四位。这些规则写进代码规范的注释里比事后排查好得多。5.3 热门开售的限流固定窗口对单个用户防刷足够用热门手办再版开售时提交订单接口的请求量会在 10 秒内冲到平日的百倍。对单个用户做固定窗口限流是最直接的防护public boolean rateLimit(String userId) { String key mall:rate: userId :submit; Long count redisTemplate.opsForValue().increment(key); if (count ! null count.equals(1L)) { redisTemplate.expire(key, Duration.ofSeconds(1)); } return count ! null count 5; }第一次 INCR 后设置 1 秒过期窗口内超过 5 次就返回“操作太快”。固定窗口在时间边界上允许窗口尾和下一个窗口头连续通过 6 个请求对商城场景的影响可以忽略。限流解决的是单个用户的重复点击防不住批量账号的脚本行为那属于风控体系范畴不是这套代码该承担的职责。5.4 系统维护开关拦截前台接口但不拦支付回调运营在数据库迁移或活动预热时需要一键暂停下单。维护开关可以放在配置中心也可以简单放在 Redismall: shop: maintenance: false切面或拦截器统一读取这个配置为 true 时前台业务接口返回“商城维护中”。但拦截范围必须排除支付回调路径否则用户钱付了、回调进不来订单永远停在待支付状态随后还要人工对账。这个错误在真实项目里出现过多次维护开关上线时记得写进检查清单。5.5 异步通知的线程池默认执行器会拖垮整个服务订单支付后的发货提醒、预售到货补款通知这类任务应该异步执行。用Async注解前必须自定义线程池Spring 默认的SimpleAsyncTaskExecutor每次执行都新建线程高并发下线程数会失控。配置一组常规参数mall: async: core-pool-size: 4 max-pool-size: 16 queue-capacity: 200队列容量打满后的拒绝策略要提前定好通知类任务建议丢弃并记录日志不能把主线程阻塞死。支付回调主链路里如果混入了异步通知回调响应会变慢网关会误判超时进而重复推送。6. 上线前最后一轮压测、慢查询、连接池与必查配置6.1 单接口压测目标与命令商城上线前至少保证两条链路达标商品查询在 200 毫秒内返回下单接口在 2 秒内完成响应。用 wrk 压测时先登录拿 token再执行压测命令wrk -t 8 -c 64 -d 60s --timeout 5s http://localhost:8080/api/goods/search wrk -t 8 -c 64 -d 60s --timeout 5s -s post.lua http://localhost:8080/api/order/submit压测必须走真实 Redis 和 MySQL不能用 mock 替代。下单接口压测前要清空预占库存并重置 Redis 库存 key否则第二轮压测会因库存耗尽而无法测出真实曲线。6.2 慢查询与连接池参数打开 MySQL 慢查询日志定位慢 SQLSET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;连接池参数建议按下面的表格设置参数建议值说明maximum-pool-size204C8G 服务器不要超过 30minimum-idle5保持基础连接connection-timeout30000连接等待超时单位毫秒max-lifetime1800000必须小于 MySQL wait_timeout连接池不是越大越好。maximum-pool-size 开太大数据库端连接数会被吃完后续请求全部排队。max-lifetime 如果大于 MySQL 的 wait_timeout空闲连接会被服务端先断开客户端下次拿到已失效的连接就会报 Communications link failure。6.3 生产启动参数生产环境启动命令加上内存和编码参数java -Xms512m -Xmx1g \ -XX:UseG1GC \ -Dfile.encodingUTF-8 \ -jar mall-api.jar \ --spring.profiles.activeprod-Dfile.encodingUTF-8经常被忽略但商品名出现乱码时第一个要检查的就是它。预售活动期间流量上涨堆内存可以临时调大再滚动重启。6.4 上线验证动线按下面这套步骤做完系统基本可以确认可发布先访问/actuator/health确认 UP再打开商城首页请求一个商品详情接口确认返回正常且 Redis 出现mall:sku:{id}缓存 key。然后提一笔测试订单用沙箱支付工具完成支付观察回调日志中订单状态从待支付变成已支付。确认无误后查看数据库mall_order表状态字段和mall_goods_sku库存都正确减少了。最后把 Redis 中的mall:stock:{id}调成 1再下两单验证第二单返回“库存不足”通过后恢复库存。整个动线覆盖了 Web 层、Redis 层、数据库层和支付回调链路每次发版前跑一遍只要几分钟。慢查询日志和连接池参数已经配好上线后如果出现接口变慢优先看这一轮的压测记录和 MySQL 慢日志对比数据即可定位大多数性能问题。本文还有配套的精品资源点击获取
返回列表