简介:谷粒商城-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-gateway | 88 | 统一入口、路由、跨域、鉴权前置 | Nacos |
| mall-product | 10000 | 分类、品牌、SPU/SKU、属性 | MySQL、Redis、ES |
| mall-ware | 11000 | 库存、采购单、库存解锁 | MySQL、Redis |
| mall-order | 9000 | 订单、订单项、关单、支付回调 | MySQL、RabbitMQ、Seata |
| mall-member | 8000 | 会员、收货地址、积分、社交登录 | MySQL、Redis |
| mall-coupon | 7000 | 优惠券、满减、领取记录 | MySQL、Redis |
| mall-cart | 6000 | 购物车(Redis 存储) | Redis |
| mall-search | 12000 | 商品检索、聚合筛选 | ES |
| mall-seckill | 13000 | 秒杀场次、秒杀下单 | Redis、RabbitMQ |
| mall-auth | 20000 | 认证服务、令牌签发 | 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 done2.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_info | spu_name、catalog_id、brand_id、publish_status | 低 | 高(详情页) |
| sku_info | spu_id、price、sku_title、sku_default_img | 中 | 高(列表页) |
| sku_images | sku_id、img_url、img_sort | 低 | 高 |
| attr / attr_value | attr_id、attr_name、value_name | 低 | 中 |
| spu_attr_value | spu_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 是不是和写死的一致,再跑一次网关到服务的连通性验证,哪怕只是改了个缩进。代码是死的,链路是活的,多花三分钟确认,能省掉一晚上的排查。希望帮到你。
本文还有配套的精品资源,点击获取