☰
2020版分布式商城代码本地跑通与下单链路解析
2026/10/28 4:58:18 网站建设 项目流程

简介:谷粒商城-2020最新文件代码面向具备Java与Spring Cloud基础的后端开发者,定位为微服务分布式电商项目高级篇的配套代码与学习材料,帮助读者理解可扩展、高可用在线购物平台的落地方式。压缩包约26.32MB,页面文件总数显示为0、类型明细暂无数据,内容侧重项目代码及微服务架构说明,便于结合描述中的架构图与模块设计进行对照。已有1584人学习下载。其价值在于围绕用户、订单、商品、支付等服务拆分,串联服务注册发现、负载均衡、API网关、分布式事务、消息队列、分库分表、分布式缓存、监控日志与CI/CD等核心知识点,适合用于课程复盘、架构对照和分布式方案查漏补缺。

1. 一套 2020 版分布式商城代码,拆开之后还能不能用

拿到一份 2020 年封版的分布式商城全栈代码,多数人的第一反应不是兴奋,而是怀疑:几年过去,注册中心、网关、搜索引擎的 API 都换过好几轮,这套东西还能不能编译过?我的结论是能跑,但要把里面的默认配置当成废纸重新看一遍。这份代码覆盖商品、库存、订单、会员、优惠券、检索、秒杀、购物车、认证十个微服务,前后端分离,中间件用到 Nacos、Gateway、Sentinel、Redis、RabbitMQ、Elasticsearch、Seata,少见地把分布式事务、分布式锁、缓存一致性、异步解耦全都压在一条下单链路上讲透。适合两类人:想从单体 CRUD 跳到微服务拆分的新手,以及面试前需要一条完整链路撑住深挖的从业者。不适合想开箱即用上线的人,它的价值在结构,不在运维成熟度。

2. 九个微服务怎么在本地跑起来:中间件、注册中心与网关的启动顺序

这份代码最容易劝退人的地方不是业务逻辑,而是启动顺序。十个服务加七个中间件,谁先起、谁依赖谁,README 里往往一句话带过,结果就是控制台刷一屏Connection refused,然后开始怀疑代码是坏的。我的做法是先画出依赖方向:中间件 → 注册中心与配置中心 → 网关 → 业务服务,逐层往上点火,任何一层报错都先停下来解决,不要一次性全部启动再大海捞针。

2.1 拆分边界与依赖方向:为什么是这十个服务

先看模块表,这张表决定了后面所有调试的方向。微服务拆得对不对,看一件事:服务之间的调用是不是单向的。这份代码里商品、库存、会员是底座,订单是编排者,网关是唯一入口,检索和秒杀是旁路。

服务默认端口职责关键依赖
mall-gateway88统一入口、路由、跨域、鉴权前置Nacos
mall-product10000分类、品牌、SPU/SKU、属性MySQL、Redis、ES
mall-ware11000库存、采购单、库存解锁MySQL、Redis
mall-order9000订单、订单项、关单、支付回调MySQL、RabbitMQ、Seata
mall-member8000会员、收货地址、积分、社交登录MySQL、Redis
mall-coupon7000优惠券、满减、领取记录MySQL、Redis
mall-cart6000购物车(Redis 存储)Redis
mall-search12000商品检索、聚合筛选ES
mall-seckill13000秒杀场次、秒杀下单Redis、RabbitMQ
mall-auth20000认证服务、令牌签发Redis

需要说明的是订单服务对商品的依赖方式:下单时订单服务不直接读商品表,而是调用商品服务拿 SPU 快照,再落到oms_order_item里。这是分布式环境下的常规做法,跨库 JOIN 在拆库那天就死了,能留的只有冗余快照。

2.2 中间件用容器拉起来:一次把七个依赖铺平

不建议在宿主机装 MySQL、Redis、ES 这一套,版本冲突的排查成本远高于容器启动。下面这段是压缩过的启动脚本,注意 ES 的内存必须压下去,默认 1G 堆在开发机上很容易把编译进程挤死。

# 数据目录统一挂在 /data/mall 下,方便删库重来 docker network create mall-net # MySQL 5.7:这份代码的建表语句用到了 utf8mb4 老排序规则,8.0 会报错 docker run -d --name mall-mysql --network mall-net -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root \ -v /data/mall/mysql:/var/lib/mysql \ mysql:5.7 --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci # Redis:关掉 RDB,只留 AOF,本地断电也不丢购物车 docker run -d --name mall-redis --network mall-net -p 6379:6379 \ redis:6.2 redis-server --appendonly yes --save "" # Nacos 2.0.3:9848 是 gRPC 端口,只映射 8848 会出现"注册上了但调不通" docker run -d --name mall-nacos --network mall-net \ -p 8848:8848 -p 9848:9848 \ -e MODE=standalone -e JVM_XMS=256m -e JVM_XMX=256m \ nacos/nacos-server:2.0.3 # RabbitMQ 带管理台:延迟队列如果装了插件也可以在这里加 -e docker run -d --name mall-rabbit --network mall-net \ -p 5672:5672 -p 15672:15672 rabbitmq:3.9-management # Elasticsearch:单节点 + 关安全,仅为本地检索调试 docker run -d --name mall-es --network mall-net -p 9200:9200 \ -e "discovery.type=single-node" -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \ -e "xpack.security.enabled=false" elasticsearch:7.9.3

参数里最值得盯的是两个:Nacos 的9848端口和 ES 的ES_JAVA_OPTS。前者是 2.x 版本走 gRPC 通信必需的,漏了之后服务能注册,但 Feign 拿不到实例列表,报错信息完全指不到这里;后者不压,机器内存一到 16G 以下就开始随机 OOM。MySQL 故意锁在 5.7,是因为建表脚本里的排序规则和部分datetime默认值在 8.0 下行为不同,先跑通再谈升级。

建库和导数据用一个循环脚本,按文件名排序执行,避免外键依赖顺序出错:

# sql 目录里通常是 0-建库.sql、1-商品.sql、2-订单.sql 这种命名 cd /path/to/mall-sql for f in $(ls *.sql | sort); do echo ">>> importing $f" mysql -h127.0.0.1 -uroot -proot --default-character-set=utf8mb4 < "$f" || exit 1 done

2.3 配置中心:Data ID 命名和 bootstrap 的坑

2020 年前后的 Spring Cloud Alibaba 项目普遍把配置丢到 Nacos,Data ID 规则是服务名.后缀,Group 用DEFAULT_GROUP。代码里每个模块的application.yml只保留本地端口,其余全部外置。如果手上有导出的配置压缩包,可以直接用接口一条条推上去,比在控制台点一百次快得多。

# 把 config/ 下每个 yaml 推到 Nacos,Data ID 取文件名 for f in config/*.yaml; do data_id=$(basename "$f") curl -s -X POST "http://127.0.0.1:8848/nacos/v1/cs/configs" \ -d "dataId=${data_id}" \ -d "group=DEFAULT_GROUP" \ -d "type=yaml" \ -d "tenant=dev" \ --data-urlencode "content@${f}" echo " pushed ${data_id}" done

对应的bootstrap.yml长这样,注意它必须叫bootstrap,而且 Spring Boot 2.4 之后默认不再加载 bootstrap 上下文,需要在pom.xml里补一个依赖,否则配置中心永远拉不到东西,服务照样能起,只是所有外置参数静默失效——这种静默失败是最难查的一类问题。

spring: application: name: mall-product # 这个名字必须和 Feign 里的服务名一模一样 cloud: nacos: discovery: server-addr: 127.0.0.1:8848 namespace: dev config: server-addr: 127.0.0.1:8848 namespace: dev file-extension: yaml shared-configs: # 公共数据源、Redis 配置放这里,各服务共享 -><!-- Spring Boot 2.4+ 必须显式引入,否则 bootstrap.yml 不被解析 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency>

提示:spring.application.name、Feign 接口上的name、Nacos 里的 Data ID 前缀,这三处必须完全一致,包括大小写和中划线。不一致的表现往往是服务列表里有两个长得差不多的实例,调用随机失败。

2.4 网关路由与跨域:把入口收成一条线

网关是唯一入口,前端只认http://localhost:88/api/**,其余端口一律不对外。路由规则用RewritePath剥掉/api前缀,这样后端 Controller 不用为了网关改路径。

spring: cloud: gateway: routes: - id: product_route uri: lb://mall-product # lb 表示走注册中心负载均衡 predicates: - Path=/api/product/** filters: - RewritePath=/api/product/(?<segment>.*), /${segment} - id: order_route uri: lb://mall-order predicates: - Path=/api/order/** filters: - RewritePath=/api/order/(?<segment>.*), /${segment}

跨域不要在网关和业务服务里各配一遍,两边都加Access-Control-Allow-Origin会导致浏览器报"头字段重复"而直接拒绝。统一在网关加一个全局过滤器处理,并且记住预检请求OPTIONS必须在鉴权之前放行,否则前端永远是 401,后端日志干净得像没事发生。

// 网关全局跨域处理,ORDER 设为最高优先级,抢在鉴权过滤器之前 @Bean public CorsWebFilter corsWebFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); // 不能用 addAllowedOrigin("*") 配 allowCredentials config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsWebFilter(source); }

3. SPU/SKU 与三级分类:商品域建模和缓存一致性的取舍

商品域是这套代码里设计密度最高的部分,也是最容易被当成"建表练习"跳过去的部分。真到下单、检索、秒杀三个场景同时压过来,SPU/SKU 的划分方式、规格参数的存储形态、分类树的缓存策略,会直接决定后面每一个接口好不好写。这一章把建模理由和缓存实现拆开讲,能照抄的部分我都标了参数。

3.1 为什么 SPU 和 SKU 一定要分开

spu_info存的是"iPhone 15"这一层,管标题、品牌、分类、上下架状态;sku_info存的是"iPhone 15 256G 蓝色"这一层,管价格、库存、图片、销量。分开的理由很实在:价格和库存是随 SKU 变的,标题和详情是随 SPU 不变的,混在一张表里,改一次标题要锁住几十行库存记录。

表关键字段写入频率读放大
spu_infospu_name、catalog_id、brand_id、publish_status低高(详情页)
sku_infospu_id、price、sku_title、sku_default_img中高(列表页)
sku_imagessku_id、img_url、img_sort低高
attr / attr_valueattr_id、attr_name、value_name低中
spu_attr_valuespu_id、attr_id、attr_value低中

一个细节:publish_status用 0/1 而不是布尔,是为了后面加"审核中""已下架待清理"这类状态时不用改表结构。这类小决定在二手代码里随处可见,抄的时候别嫌啰嗦。

3.2 三级分类树:一次查全表,内存拼树

分类数据量小、变更少、读极多,是最标准的缓存对象。这份代码的写法是全表查出后按parent_cid在内存里分组拼树,用一次 SQL 换掉递归查询的 N 次。我一般会把它再往前推一步:整棵树序列化成一个 JSON 塞进 Redis,前端要的本来就是树结构。

public Map<String, List<CategoryEntity>> getCatalogJson() { String key = "catalog:json"; // 1. 先打缓存,命中直接反序列化返回 String cached = redisTemplate.opsForValue().get(key); if (StringUtils.isNotBlank(cached)) { return JSON.parseObject(cached, new TypeReference<Map<String, List<CategoryEntity>>>() {}); } // 2. 未命中走分布式锁,只放一个线程回源,防止击穿 RLock lock = redissonClient.getLock("catalog:lock"); try { if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { // 3. 双重检查:抢到锁时别人可能已经填好了 cached = redisTemplate.opsForValue().get(key); if (StringUtils.isNotBlank(cached)) { return JSON.parseObject(cached, new TypeReference<Map<String, List<CategoryEntity>>>() {}); } Map<String, List<CategoryEntity>> tree = buildTreeFromDb(); // 4. 加随机过期,避免同一时刻集体失效 redisTemplate.opsForValue().set(key, JSON.toJSONString(tree), 1 + new Random().nextInt(6), TimeUnit.HOURS); return tree; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { // 5. 必须判断持有者,否则可能误删别人的锁 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } return buildTreeFromDb(); // 兜底:拿不到锁就直接查库,宁可慢不可错 }

参数上要盯三处:tryLock(3, 10, ...)的 3 是等待时间,比接口超时短一半左右合适;10 是租期,Redisson 会自动 renew,但如果方法里做了远程调用,租期要给足;过期时间加随机数是防止缓存雪崩的常规做法,1 到 7 小时的抖动对分类数据完全够用。

3.3 双写一致性:先删缓存还是先更库

更新分类时到底先删缓存还是先更库,这套代码用的是"先更库、再删缓存",这也是绝大多数业务的默认答案。它的风险窗口在于:更新完库、删缓存之前,有读请求把旧值回填了,之后缓存里就一直是脏的。低频写场景这个窗口可以忽略,商品这类写少读多的数据,加上过期时间兜底基本够用。

@Transactional public void updateCategory(CategoryEntity entity) { categoryMapper.updateById(entity); // 1. 第一次删:尽量把窗口压小 redisTemplate.delete("catalog:json"); // 2. 延迟再删:覆盖"删完到事务提交"之间被回填的脏数据 // 延迟时间要比一次读请求的完整耗时略长,200~500ms 是常见取值 taskExecutor.execute(() -> { try { Thread.sleep(300); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } redisTemplate.delete("catalog:json"); }); }

延迟双删不是银弹,sleep时间靠拍脑袋就是它的原罪。真要严格,走 binlog 订阅做异步失效更靠谱,但对这份代码的定位来说属于过度设计。我的建议是:分类、品牌这类数据用延迟双删;库存这种强一致场景压根不要缓存绝对值,只缓存标志位或者直接查库。

注意:延迟任务一定要丢进线程池,不要在主线程sleep。主线程睡 300ms 意味着接口 P99 直接抬 300ms,压测时这个数字会非常难看。

3.4 规格参数:JSON 冗余列和子表的取舍

商品的规格参数(颜色、内存、尺寸)在不同类目下差异极大,严格建模要做attr_group、attr、attr_value三张表再加一张关联表,查询时要 JOIN 四次。这套代码在spu_info上额外挂了一列decript(拼写错误也一并保留)存 JSON,详情页直接反序列化就能渲染规格表。

两种方式各有边界:需要按规格筛选(比如"只看 256G")就必须走检索服务,把规格打平进 ES 的nested字段;纯展示用 JSON 列最省事。我见过有人把筛选逻辑直接写在 MySQL 的 JSON 函数上,几万商品还行,上十万就开始全表扫。判断标准很简单:查询条件里出现的字段,就不该藏在 JSON 里。

4. 下单扣库存这条链路:Seata AT、Redisson 锁与延迟队列的配合

下单是这套代码的考试题,一门课挂了整本书都白读。它同时踩中三个分布式难题:跨服务的库存扣减要保证一致性、同一 SKU 的并发扣减不能超卖、订单超时未支付要回滚库存。分别对应 Seata AT、Redisson 分布式锁、RabbitMQ 延迟队列,三个东西必须在同一条链路上排好序,排错了就会出现"锁住了还是超卖"或者"事务回滚了库存没回来"。

4.1 为什么用 Seata AT 而不是 TCC

AT 模式对业务代码几乎零侵入,靠数据源代理自动生成undo_log,回滚时按镜像反向补偿。TCC 需要手写 Try/Confirm/Cancel 三个方法,一致性更强但开发成本翻几倍。这份代码的定位是教学与二次开发,选 AT 是合理的。

代价要清楚:AT 依赖全局锁,热点 SKU 上会形成串行等待;undo_log表必须每个参与事务的库都建,少一个就报Table 'undo_log' doesn't exist;@GlobalTransactional必须加在事务发起方的入口方法上,加在 Controller 层会导致 Feign 调用绕开代理,事务静默失效。我一般加在 Service 的编排方法上,离远程调用最近。

// 下单编排:加在 Service 层,保证 Feign 调用在事务代理范围内 @GlobalTransactional(rollbackFor = Exception.class, timeoutMills = 30000) public SubmitOrderResponse submitOrder(Long memberId, OrderSubmitVo vo) { // 1. 验价:远程查商品当前价格,防止前端传价 List<OrderItemVo> items = productFeign.getSkusByIds(vo.getSkuIds()); BigDecimal currentTotal = calcTotal(items); // 2. 锁库存:本地事务 + AT 分支事务,失败由 Seata 回滚 boolean locked = wareFeign.lockStock(vo.getSkuLocks()); if (!locked) { throw new BizException("库存不足"); } // 3. 扣优惠券、扣积分、创建订单,全部是分支事务 couponFeign.useCoupon(vo.getCouponId(), memberId); OrderEntity order = saveOrder(memberId, vo, currentTotal); // 4. 发送延迟消息,30 分钟后检查支付状态,未付则关单并解锁库存 rabbitTemplate.convertAndSend("order-event-exchange", "order.create.order", order.getOrderSn()); return buildResponse(order); }

timeoutMills设 30 秒是个保守值,压测时如果一次下单要跨四五个服务,很容易踩到默认 60 秒的全局超时;反过来设太短,正常请求会被误回滚。经验取值是链路 P99 的两到三倍。

4.2 扣库存的 SQL 怎么写才不超卖

分布式锁解决的是同一时刻多个请求的并发问题,SQL 层面还要再兜一层。这段写法用stock - num >= 0做条件更新,靠数据库行锁保证原子性,返回影响行数为 0 就代表扣减失败。

<!-- 条件更新:影响行数 0 即库存不足,不需要先 select 再判断 --> <update id="deductStock"> UPDATE wms_ware_sku SET stock_locked = stock_locked + #{num} WHERE sku_id = #{skuId} AND ware_id = #{wareId} AND stock - stock_locked - #{num} >= 0 </update>

很多人写成先select stock判断再update,中间隔了一个网络往返,高并发下必然超卖。条件更新把判断和写入压进一条语句,是这类场景唯一可靠的做法。

4.3 Redisson 锁的粒度和续期

锁的粒度必须是 SKU 而不是 SPU,也不要用全局锁。锁 key 带上仓库维度更稳,因为同一 SKU 可能分布在多个仓库。

public boolean lockStock(List<SkuLockVo> locks) { // 按 skuId 排序,避免两个订单以不同顺序加锁导致死锁 locks.sort(Comparator.comparing(SkuLockVo::getSkuId)); List<RLock> acquired = new ArrayList<>(); try { for (SkuLockVo vo : locks) { RLock lock = redissonClient.getLock( "lock:stock:" + vo.getWareId() + ":" + vo.getSkuId()); // 等待 3s,租期不显式指定,交给看门狗每 10s 续一次 boolean ok = lock.tryLock(3, TimeUnit.SECONDS); if (!ok) { return false; // 拿不到就整体失败,由上层决定重试 } acquired.add(lock); } return doDeduct(locks); // 真正的条件更新在这里 } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { // 逆序释放,且必须判断持有者 for (int i = acquired.size() - 1; i >= 0; i--) { RLock lock = acquired.get(i); if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }

三个关键点:按 SKU 排序加锁防死锁;不显式传租期,让看门狗续;finally里逆序释放并判断持有者。少任何一个,压测跑到几百并发就开始出现"锁释放异常"或者请求卡死。

提示:如果用tryLock(waitTime, leaseTime, unit)显式传了租期,看门狗就失效了,业务执行时间超过租期会导致锁提前释放,另一个线程进来直接超卖。这个坑几乎每个用 Redisson 的人都踩过一次。

4.4 延迟队列关单:别用定时任务扫表

订单 30 分钟未支付要关单并解锁库存,用定时任务每分钟扫一次表是最常见的偷懒写法,订单量一上来就是全表扫描加锁竞争。这套代码用的是 RabbitMQ 的 TTL + 死信队列:下单时发一条 30 分钟过期的消息到普通队列,消息过期后进死信交换机,消费者收到就开始查支付状态。

@Configuration public class OrderMQConfig { // 业务队列:消息在这里躺 30 分钟,不设消费者 @Bean public Queue orderDelayQueue() { return QueueBuilder.durable("order.delay.queue") .withArgument("x-dead-letter-exchange", "order-event-exchange") .withArgument("x-dead-letter-routing-key", "order.release.order") .withArgument("x-message-ttl", 30 * 60 * 1000) // 30 分钟 .build(); } // 死信队列:真正的消费入口 @Bean public Queue orderReleaseQueue() { return QueueBuilder.durable("order.release.queue").build(); } @Bean public Binding delayBinding() { return BindingBuilder.bind(orderDelayQueue()) .to(new TopicExchange("order-event-exchange", true, false)) .with("order.create.order"); } }

TTL 队列有个众所周知的缺陷:消息是排队过期的,队头那条没过期,后面的就算到期了也出不来,时间精度依赖队列顺序。订单场景一般能接受,因为下单时间本身就有先后。真要做到精确,就装rabbitmq_delayed_message_exchange插件改走延迟交换机,配置只改一行x-delayed-type。

5. 排查清单:本地起不来、Feign 404、事务不回滚的 5 个血泪记录

这套代码我前后在不同机器上跑过几遍,下面五条是复现率最高、报错信息最会骗人的。每条按现象、原因、解决写,遇到同类问题直接对号入座。

5.1 服务注册上了,Feign 调用 404

现象:Nacos 服务列表里能看到mall-product,网关转发也正常,但订单服务调商品服务时稳定返回 404,日志里连请求路径都找不到。

原因:@FeignClient(name = "mall-product")里的名字和spring.application.name大小写或中划线不一致,或者被调服务配置了server.servlet.context-path,Feign 拼接路径时没带上。还有一种更隐蔽的:两个服务名只差一个-,Nacos 里显示成两条记录,负载均衡随机挑中错误那条。

解决:先看 Nacos 实例列表里到底有几个名字,逐个比对。有context-path的,要么在 Feign 接口上显式拼全路径,要么统一去掉。我习惯在公共模块里定义常量ServiceNameConst.PRODUCT,两边都引用它,从根上断掉手写字符串的可能。

5.2 抛了异常,Seata 却没回滚

现象:下单时库存分支抛了异常,订单表回滚了,但库存的stock_locked已经加上去了,数据对不上。

原因:三种情况最常见。@GlobalTransactional加在了 Controller 上,Feign 调用发生在事务代理之外;参与方的库没建undo_log表;或者异常被 catch 后吞掉了,没有重新抛出,Seata 认为分支成功。

解决:注解下沉到 Service 编排方法;给每个业务库执行一遍undo_log建表语句;catch 里要么throw,要么TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。排查时把 Seata 日志级别调到INFO,能看到每个分支的注册和回滚记录,比猜快得多。

5.3 加了分布式锁,压测还是超卖

现象:单机测试一切正常,几百并发压测后库存出现负数。

原因:tryLock传了显式租期但没有续期,业务执行超过租期后锁自动释放;或者 key 的粒度是 SPU,不同 SKU 之间互相等待超时后走了"拿不到锁就直接扣"的兜底逻辑。

解决:去掉显式租期交给看门狗,或者在 finally 里判断isHeldByCurrentThread后再释放;key 精确到wareId + skuId。兜底逻辑改成抛异常,不要"拿不到锁就放行",那是给超卖开后门。

5.4 缓存和数据库对不上,重启就好

现象:后台改了一个分类名,前端列表还是旧的,重启服务或者等一段时间又正常了。

原因:更新走了"先删缓存、再写库",写库还没提交,读请求就把旧值回填了。或者延迟双删的延迟时间比一次读请求的耗时还短,等于没删。

解决:改成先写库再删缓存,延迟时间调到 300ms 以上并丢线程池执行。更稳妥的做法是给缓存加版本号,更新时incr版本,读的时候比对版本不一致就重建。

5.5 网关跨域报头字段重复

现象:前端调接口,浏览器控制台报The 'Access-Control-Allow-Origin' header contains multiple values,Network 里能看到两个同名响应头。

原因:网关配了一次跨域,下游业务服务也配了一次,两个头叠加。

解决:只保留网关一处,把业务服务里的@CrossOrigin和全局跨域配置全删掉。另外allowCredentials(true)时不能用addAllowedOrigin("*"),要用addAllowedOriginPattern("*"),否则响应头不会下发。

6. 从能跑到敢讲:链路验证、压测数据与二次开发的取巧点

代码跑起来只是及格线,能讲清楚每条链路的数据流向才算真的拿下了。我自己的验证习惯是从网关打一条完整请求,一路看服务端日志的 traceId 是否贯穿,任何一段断掉都说明中间件或配置有问题。下面这张表是我每次换机器部署后必过的清单,比盲点页面快很多。

验证点请求方式通过标准
分类树缓存GET/api/product/category/catalog/json连打两次第二次日志里没有 SQL
检索GET/api/search?keyword=手机&catelog3Id=225返回聚合的品牌与规格面
下单POST/api/order/submit带 skuId 与地址订单表有记录,库存stock_locked增加
关单把 TTL 临时改成 30 秒重启消费者超时后订单状态变已关闭,库存解锁
事务回滚下单时传一个不存在的优惠券 ID订单、库存、积分三张表全部无变化

压测不用上重型工具,wrk或ab打商品详情接口就够看瓶颈在哪。我一般先用 50 并发跑 60 秒,看 P99 和错误率,再翻倍到 200。如果 TPS 不涨反降,八成是数据库连接池打满了,先调spring.datasource.hikari.maximum-pool-size,默认 10 在这套代码里明显偏小,调到 20 到 30 之间比较合理;再往上加之前,先看慢 SQL。

# 50 并发压商品详情,持续 60 秒,-T 指定超时 wrk -t4 -c50 -d60s -T5s \ "http://localhost:88/api/product/skuinfo/info/1" # 顺带确认缓存生效:压测前后各看一眼 Redis 命中情况 redis-cli info stats | grep -E "keyspace_hits|keyspace_misses"

二次开发取巧的地方有两个。一是秒杀模块,mall-seckill里库存预热到 Redis、下单走信号量限流、结果异步通知前端,这套结构可以原样搬到任何抢购场景,改的只是商品 ID 来源;二是认证模块,令牌校验和网关鉴权分离,换 JWT 或者换成自定义 token 都只动一个过滤器。这两块是复用价值最高的,其余模块更多是补齐页面的重复劳动。

注意:改秒杀模块前先把本地 RabbitMQ 的prefetch调成 1 再压测,默认值会让消费端一次拉一堆消息,日志看起来像串单,其实是正常的批处理。

说实话,我第一次跑这套代码时栽在一个特别蠢的地方:把gateway的uri写成了http://mall-product,本地测试全通,压测直接雪崩,因为那等于绕过注册中心直连了一个固定 IP。从那以后我每次改完路由,都强制先看一遍 Nacos 的实例 IP 是不是和写死的一致,再跑一次网关到服务的连通性验证,哪怕只是改了个缩进。代码是死的,链路是活的,多花三分钟确认,能省掉一晚上的排查。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询