news 2026/11/6 6:44:39

Spring Cloud Alibaba 分布式商城源码:微服务主链路拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Cloud Alibaba 分布式商城源码:微服务主链路拆解

简介:谷粒商城 - 2020 最新文件代码面向希望系统学习微服务分布式电商的中高级开发者,围绕可扩展、高可用购物平台,覆盖用户、商品、订单、支付等服务拆分,以及注册发现、负载均衡、API 网关、分布式事务、消息队列、分库分表、缓存、监控日志与 CI/CD 等实践主题。资源以 zip 压缩包提供,约 26.32MB;上游文件总数标注为 0、类型明细暂缺,具体目录与文件构成需以下载后实际内容为准。已有 1584 人学习,适合对照代码与架构图理解微服务落地方式,梳理服务治理、异步解耦、数据一致性和性能优化思路,也可作为分布式电商项目复盘与查漏补缺的参考材料。

1. 后台点一下「上架」,三个服务同时要动

商品上架这个动作,在单体应用里就是一条update语句。放到分布式环境里,它变成三件事:商品库改状态、库存服务写库存记录、检索服务重建索引。三件事只要断在中途,用户就会在列表页点进一个 404 详情页,或者下单时提示无库存——这类“数据看着都在、就是串不起来”的问题,是这套 2020 版分布式商城源码最值得拆的地方。它用 Spring Cloud Alibaba 全家桶把商品、库存、订单、购物车、检索、秒杀切成十个独立服务,配上 Nacos、Sentinel、Seata、Redis、RabbitMQ、Elasticsearch,把一条完整的电商主链路跑通。适合两类人:想把微服务从 PPT 落到能跑的代码上的新手,以及接手了一套别人写的分布式项目、需要摸清每个中间件到底卡在哪一环的老手。下面按“骨架—链路—中间件—跑通—排错—改造”的顺序一路拆下去。

2. 工程骨架拆解:十个服务是怎么切出来的

拿到源码先别急着mvn install,先看目录。这套工程的切分逻辑非常“教科书”:按业务域纵向切服务,按技术能力横向抽公共模块。好处是每个服务职责单一,坏处是你本地想跑通一条完整下单链路,得同时起七八个进程。先把这张模块表记住,后面看代码会一直用到它。

模块默认端口职责关键依赖
mall-common无统一返回体 R、分页工具、异常码、公共 DTO被所有服务依赖
mall-gateway88路由转发、跨域、鉴权前置Nacos、Spring Cloud Gateway
mall-product10000三级分类、品牌、SPU/SKU、属性MySQL、Redis、ES、Feign
mall-ware11000仓库、库存、采购单MySQL、Feign
mall-order9000订单、订单项、关单MySQL、RabbitMQ、Seata
mall-member8000会员、积分、成长值MySQL、Feign
mall-coupon7000优惠券、秒杀活动MySQL、Redis
mall-auth20000登录认证、OAuth2 令牌Redis、Feign
mall-cart40000购物车Redis、Feign
mall-search12000商品检索、聚合筛选Elasticsearch
mall-seckill25000秒杀上架与抢单Redis、RabbitMQ、定时任务

2.1 公共模块的边界:什么该进 common,什么绝对不能进

mall-common是最容易被写成“垃圾桶”的模块。这套源码里的做法值得抄:只放三类东西——跨服务的返回体与异常、纯工具类(分页、JSON、校验)、以及跨服务调用的 DTO。

判断标准很简单:如果某个类被两个以上服务引用,且不包含任何业务逻辑,才放进 common;只要它开始依赖 MyBatis 的@TableName或者某个服务的 Service,就老老实实留在原服务里,靠 Feign 传参。

R这个统一返回体是整个工程里被引用最多的类,长这样:

// mall-common/src/main/java/com/mall/common/utils/R.java public class R extends HashMap<String, Object> { public static R ok() { R r = new R(); r.put("code", 0); // 0 表示成功,非 0 全部是业务失败 r.put("msg", "success"); return r; } public static R error(int code, String msg) { R r = new R(); r.put("code", code); r.put("msg", msg); return r; } public R put(String key, Object value) { super.put(key, value); return this; } }

code用 0 表示成功而不是 200,是因为这套代码所有 Feign 调用的判空都写成if (r.getCode() != 0),改约定等于全量搜索替换,动之前先想清楚。R继承HashMap而不是定义字段,好处是可以随手put("data", xxx),坏处是序列化后结构不固定,前端取值要靠约定。我一般会在团队里补一层R<T>泛型版本,但这种老工程不建议大改,容易连锁爆炸。

2.2 依赖版本约束:三个 BOM 与 bootstrap.yml

分布式项目最典型的翻车现场不是业务写错,是版本打架。这套工程的父 POM 用dependencyManagement锁死三个 BOM:

<dependencyManagement> <dependencies> <!-- 最贴近业务的放最前,Maven 的 import 是先声明先赢 --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-dependencies</artifactId> <version>2.2.3.RELEASE</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Hoxton.SR8</version> <type>pom</type> <scope>import</scope> </dependency> <!-- Boot 放最后兜底 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>2.3.2.RELEASE</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>

<scope>import</scope>的含义是“把这个 BOM 里的版本声明合并进来”,它只在dependencyManagement里生效。type必须是pom,否则 Maven 不认。三个 BOM 的排列顺序决定冲突仲裁:先声明的赢。所以业务侧最敏感的 Alibaba 版本被我放在最前面,Boot 放最后兜底。

子模块里所有依赖都不写<version>,一旦出现NoSuchMethodError或ClassNotFoundException: com.alibaba.nacos.api.naming.NamingService,第一反应就该是回来看这个文件,而不是去翻业务代码。

每个服务的配置放在bootstrap.yml而不是application.yml,因为注册中心地址必须比 Spring 容器初始化还早:

# mall-product/src/main/resources/bootstrap.yml server: port: 10000 spring: application: name: mall-product # 服务名,Feign 调用时用的就是这个字符串 cloud: nacos: discovery: server-addr: 127.0.0.1:8848 config: server-addr: 127.0.0.1:8848 file-extension: yaml namespace: dev-namespace-id # 用命名空间隔离环境,别全挤在 public datasource: url: jdbc:mysql://127.0.0.1:3306/mall_pms?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai

spring.application.name决定了服务在 Nacos 里的注册名,也决定了 Feign 接口上@FeignClient(name = "mall-ware")能不能找到目标。改服务名是所有改造里成本最高的动作之一,后面第 6 章会细说。namespace这个参数千万别省,很多人的“本地启动后调不通”就是因为本地服务和线上服务挤在同一个 public 命名空间里互相覆盖。

2.3 代码生成器:大半的 CRUD 不该手写

这套工程带了一个代码生成器模块,连上数据库、勾选表、生成entity / mapper / service / controller一套,配合 MyBatis-Plus 直接跑起来。看着像偷懒,实际上是这类项目能维持十个服务还不失控的关键——商品属性有二十多张表,全靠手写不现实。

生成出来的 Service 层默认是这样的:

@Service("skuInfoService") public class SkuInfoServiceImpl extends ServiceImpl<SkuInfoDao, SkuInfoEntity> implements SkuInfoService { @Override public PageUtils queryPage(Map<String, Object> params) { // key 是前端传的模糊查询字段,对应 sku 标题或 skuId QueryWrapper<SkuInfoEntity> wrapper = new QueryWrapper<>(); String key = (String) params.get("key"); if (StringUtils.isNotBlank(key)) { wrapper.and(w -> w.eq("sku_id", key).or().like("sku_title", key)); } Long catalogId = (Long) params.get("catalogId"); if (catalogId != null) { wrapper.eq("catalog_id", catalogId); // 分类筛选必须精确匹配 } IPage<SkuInfoEntity> page = this.page( new Query<SkuInfoEntity>().getPage(params), // page/size 从 params 里取,带默认值 wrapper); return new PageUtils(page); } }

Query<SkuInfoEntity>().getPage(params)是这套代码里反复出现的分页封装,它从params里读page和limit,默认第一页、每页 10 条。wrapper.and(w -> ...)的作用是把“或”关系括起来,不然eq(sku_id).or().like(title)再拼上后面的eq(catalog_id),SQL 会变成sku_id = ? or title like ? and catalog_id = ?,分类筛选直接失效——这是生成器代码最常见的隐性 bug,改的时候顺手加括号。

生成器省下的时间,全部要还到业务逻辑上。真正需要手写的是:跨服务调用、缓存策略、分布式事务、检索模型组装。这几块才是这套源码值钱的地方。

3. 核心链路拆解:一个 SKU 从商品上架到订单落库

这一章走完整个主链路。把这段读透,其余服务基本是同一套模板换个表名。

3.1 商品建模:三级分类、SPU 与 SKU 的落表方式

分类表pms_category是自关联结构,字段就三个关键:cat_id、parent_cid、cat_level(1/2/3)。查询时不递归查库,而是一次性把全表捞进内存再拼树:

// 一次全表查询,在内存里组树,避免 N 次递归查库 List<CategoryEntity> all = categoryDao.selectList(null); Map<Long, List<CategoryEntity>> parentMap = all.stream() .filter(c -> c.getParentCid() != null) .collect(Collectors.groupingBy(CategoryEntity::getParentCid)); List<CategoryEntity> roots = all.stream() .filter(c -> c.getParentCid() == 0) .peek(root -> root.setChildren(getChildren(root, parentMap))) .sorted(Comparator.comparingInt(c -> (c.getSort() == null ? 0 : c.getSort()))) .collect(Collectors.toList());

这套写法成立的前提是分类表只有几百行。如果分类规模上千,就得换缓存预热方案了。getChildren递归时一定要判空并返回空集合,不要返回 null,否则前端树组件渲染会挂掉。

SPU 和 SKU 的拆法是电商建模的分水岭。SPU 是“iPhone 13”这个概念,SKU 是“iPhone 13 128G 星光色”。对应四张表:pms_spu_info(SPU 主体)、pms_sku_info(SKU 主体,带价格、图片、销量)、pms_sku_sale_attr_value(SKU 的销售属性值,比如颜色=星光色)、pms_spu_info_desc(商品详情长文本,单独拆表避免拖慢主表查询)。

注意,价格放在 SKU 上而不是 SPU 上。很多人一开始想省事,把价格写在 SPU 里,等到做秒杀、做多规格才发现没法差异化定价,这时候再改表结构,接口、ES 索引、购物车全要跟着动。

3.2 SKU 上架:一次跨三个服务的写操作

上架是整个工程里最典型的分布式写场景,代码在mall-product的 SpuInfoServiceImpl 里:

@Override @Transactional(rollbackFor = Exception.class) public void up(Long spuId) { // 1. 查出该 SPU 下的所有 SKU List<SkuInfoEntity> skus = skuInfoService.listBySpuId(spuId); // 2. 组装库存参数,远程调用库存服务 List<SkuWareDTO> stockList = skus.stream().map(sku -> { SkuWareDTO dto = new SkuWareDTO(); dto.setSkuId(sku.getSkuId()); dto.setSkuName(sku.getSkuTitle()); dto.setSkuPrice(sku.getPrice()); return dto; }).collect(Collectors.toList()); R r = wareFeignService.saveSkuWare(stockList); // 远程写库存 if (r.getCode() != 0) { throw new RuntimeException("库存保存失败,SPU 上架回滚"); } // 3. 组装检索模型,写 ES List<SkuEsModel> esModels = buildEsModels(skus); try { searchFeignService.saveProduct(esModels); } catch (Exception e) { // 检索写失败不回滚库存,靠定时任务补偿重建索引 log.error("商品上架写 ES 失败,spuId={},交给补偿任务", spuId, e); } // 4. 改 SPU 状态为已上架 SpuInfoEntity update = new SpuInfoEntity(); update.setId(spuId); update.setPublishStatus(1); spuInfoService.updateById(update); }

四步里,只有第 2 步失败会回滚,因为wareFeignService.saveSkuWare抛出的异常会触发@Transactional回滚商品库的本地事务。但库存服务那边的写入是另一个库,本地事务管不到,这里就需要引入 Seata,后面 3.4 会说。

第 3 步故意用 try-catch 吞掉异常,属于典型的最终一致性取舍:检索索引可以重建,库存不能错。真让它回滚库存,用户会看到“商品上架了但没库存”这种更难受的状态。代价是要有一个定时任务,每天扫一次publish_status = 1但索引里不存在的 SKU 补录。

Feign 接口的定义长这样,fallbackFactory是熔断降级入口:

@FeignClient(name = "mall-ware", fallbackFactory = WareFeignFallbackFactory.class) public interface WareFeignService { @PostMapping("/ware/waresku/batch") R saveSkuWare(@RequestBody List<SkuWareDTO> dtos); @GetMapping("/ware/waresku/hasstock") R getSkuHasStock(@RequestParam("skuIds") List<Long> skuIds); }

两个参数细节:@RequestBody不能省,否则 Feign 会用表单编码发出去,服务端收到 null;@RequestParam必须在两边都显式写名字,只写一个 List 参数时 Feign 不会自动推断参数名,运行期直接报 400。

3.3 购物车:Redis Hash 撑住临时数据

购物车是整套代码里最适合“抄作业”的模块,因为它完全不落库,纯 Redis。数据结构选的是 Hash,key 是cart:userId,field 是 skuId,value 是购物车项对象:

public CartItemVO addToCart(Long skuId, Integer num, Long userId) { BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(CART_PREFIX + userId); String field = skuId.toString(); if (Boolean.TRUE.equals(ops.hasKey(field))) { // 已存在:累加数量,重新算总价 CartItemVO item = (CartItemVO) ops.get(field); item.setCount(item.getCount() + num); item.setTotalPrice(item.getPrice().multiply(new BigDecimal(item.getCount()))); ops.put(field, item); return item; } // 不存在:远程查 SKU 信息,组装后写入 R r = productFeignService.info(skuId); SkuInfoVO sku = r.getData("skuInfo", new TypeReference<SkuInfoVO>() {}); CartItemVO item = new CartItemVO(); item.setSkuId(skuId); item.setTitle(sku.getSkuTitle()); item.setPrice(sku.getPrice()); item.setCount(num); ops.put(field, item); return item; }

用 Hash 而不是 String 的理由:单个购物车最多几十项,Hash 可以只取某一项、只删某一项,不用整存整取。代价是不能对 field 单独设过期时间,只能对整个 key 设——这套代码的做法是登录时刷新 key 的 TTL 为 30 天,登出时直接删 key。

一个必踩的坑:redisTemplate默认用 JDK 序列化,CartItemVO必须实现Serializable,且所有服务里的类版本要一致,否则反序列化报InvalidClassException。我一般会统一换成 JSON 序列化器,代价是BigDecimal会变成字符串,取出来要手动转。

3.4 订单提交:Seata AT 把三个库绑在一起

下单要同时动订单库、库存库、优惠券库。这套代码用的是 Seata 的 AT 模式,入口方法上打@GlobalTransactional:

@GlobalTransactional(rollbackFor = Exception.class, timeoutMills = 60000) @Override public SubmitOrderResponseVO submitOrder(OrderSubmitVO vo) { // 0. 先做防重提交校验(Redis 里 setIfAbsent 令牌) verifyToken(vo.getOrderToken()); // 1. 本地库落订单主表和订单项 OrderEntity order = createOrder(vo); // 2. 锁库存 —— Seata 分支事务,失败会触发全局回滚 List<OrderItemVO> items = orderItemService.listItems(order.getId()); R lockResult = wareFeignService.lockStock(order.getOrderSn(), items); if (lockResult.getCode() != 0) { throw new RuntimeException("库存不足"); } // 3. 核销优惠券 —— 同样走分支事务 if (vo.getCouponId() != null) { couponFeignService.useCoupon(vo.getCouponId(), vo.getMemberId()); } // 4. 发延迟消息,30 分钟后未支付自动关单 rabbitTemplate.convertAndSend("order-event-exchange", "order.create.order", order.getOrderSn()); return buildResponse(order); }

关键点:@GlobalTransactional和@Transactional不要叠在同一个方法上。@GlobalTransactional内部已经开启了本地事务边界,两者叠加会让回滚顺序变得不可预测,线上见过叠完之后库存回滚了、订单没回滚的案例。要加本地事务,加在被调用的内层方法上。

另外timeoutMills要显式设。默认 60 秒,如果库存服务的lockStock里有慢 SQL,全局事务会先超时回滚,但库存那边可能已经把行锁住了,最终表现为“订单没了、库存却少了”。这套代码里 Seata 的每个分支库都必须建undo_log表,漏建一个就是这条链路里最隐蔽的坑。

4. 中间件各自负责什么:Redis、RabbitMQ、ES、Nacos

把中间件当装饰品是分布式项目最常见的失败方式。这一章说清每个中间件在这套代码里到底解决了哪个具体问题,以及启动顺序。

4.1 缓存与分布式锁:分类树的读取路径

首页左侧那个三级分类树,是全站 QPS 最高的读接口。这套代码的做法是“缓存 + 双重检查 + Redisson 分布式锁”:

public Map<String, List<CategoryVO>> getCatalogJson() { String key = "catalog:json"; String cached = redisTemplate.opsForValue().get(key); if (StringUtils.hasText(cached)) { return JSON.parseObject(cached, new TypeReference<Map<String, List<CategoryVO>>>() {}); } RLock lock = redissonClient.getLock("catalog:lock"); try { // 带参 lock 不会触发看门狗续期,业务必须能在 30 秒内跑完 lock.lock(30, TimeUnit.SECONDS); cached = redisTemplate.opsForValue().get(key); // 双检,防止等锁期间别人已经写好 if (StringUtils.hasText(cached)) { return JSON.parseObject(cached, new TypeReference<Map<String, List<CategoryVO>>>() {}); } Map<String, List<CategoryVO>> fromDb = loadTreeFromDb(); redisTemplate.opsForValue().set(key, JSON.toJSONString(fromDb), 1, TimeUnit.DAYS); return fromDb; } finally { // 必须先判断持有者,否则会抛 IllegalMonitorStateException if (lock.isLocked() && lock.isHeldByCurrentThread()) { lock.unlock(); } } }

lock.lock(30, TimeUnit.SECONDS)和lock.lock()是两个东西:带参版本到期强制释放,没有看门狗续期;无参版本会每 10 秒自动续期,直到你主动 unlock 或者进程挂掉。缓存重建这种有明确耗时的任务用带参版本更安全,能兜住“业务代码死循环把锁永久占住”的情况。

unlock前的isHeldByCurrentThread()判断必须写。Redisson 的锁带线程标识,A 线程解 B 线程加的锁会直接抛异常,在finally里抛异常会覆盖掉业务异常,排查时非常难受。

缓存一致性这套代码只做了“更新时删 key”,没做延迟双删。真要上生产,我一般会在更新方法里删两次:写完库删一次,发一条延迟 500ms 的消息再删一次,能兜住读请求把旧值回填的窗口。

4.2 延迟队列关单与 ES 商品检索

订单创建后 30 分钟未支付要自动关单,这套代码用 RabbitMQ 的死信队列实现。核心在队列声明:

@Configuration public class OrderMQConfig { // 延迟队列:消息进来后 60 秒过期,过期后转入死信交换机 @Bean public Queue orderDelayQueue() { Map<String, Object> args = new HashMap<>(); args.put("x-message-ttl", 60000); // 队列级 TTL,单位毫秒 args.put("x-dead-letter-exchange", "order-event-exchange"); args.put("x-dead-letter-routing-key", "order.release.order"); return new Queue("order.delay.queue", true, false, false, args); } // 真正消费关单逻辑的队列 @Bean public Queue orderReleaseQueue() { return new Queue("order.release.order.queue", true, false, false); } @Bean public Binding delayBinding() { return BindingBuilder.bind(orderDelayQueue()) .to(new TopicExchange("order-event-exchange", true, false)) .with("order.create.order"); } }

这里有个容易被忽略的设计取舍:用队列级 TTL而不是消息级 TTL。消息级 TTL 只有在消息到达队列头部时才会被判断过期,如果前面压着一条 10 分钟 TTL 的消息,后面 30 秒 TTL 的消息也得等,延迟会严重不准。队列级 TTL 是整队统一,简单可靠。代价是不同延迟时长要建不同队列——关单 30 分钟一个队列,自动收货 7 天一个队列。

消费端必须做幂等:支付回调和关单消息可能同时到达,重复消费时按订单号先查状态,已支付就直接 ACK 丢掉。

检索这块,ES 的索引模型和数据库表不是一对一。mall-search里维护的product索引是一个宽表,把 SKU 的价格、标题、品牌名、分类名、所有可检索的属性值全拍平进去:

GET /product/_search { "query": { "bool": { "must": [ { "match": { "skuTitle": "手机" } } ], "filter": [ { "term": { "catalogId": 225 } }, { "term": { "brandId": [1, 3] } }, { "range": { "skuPrice": { "gte": 1000, "lte": 5000 } } }, { "term": { "hasStock": true } } ] } }, "aggs": { "brand_agg": { "terms": { "field": "brandId", "size": 20 }, "aggs": { "brand_name": { "terms": { "field": "brandName", "size": 1 } } } } }, "from": 0, "size": 20, "sort": [ { "skuPrice": { "order": "asc" } } ] }

must里放全文检索,filter里放精确筛选,这是 ES 性能的基本盘——filter 不打分、可缓存。hasStock这种字段必须由库存服务变更时同步过来,不然用户会在列表页看到有货、点进去无货。这是典型的跨服务数据冗余,接受它,别想着实时查库存服务。

4.3 启动顺序与最小可跑组合

这套工程全量启动要十几 G 内存,本地开发必须裁剪。最小可跑组合是:Nacos → MySQL → Redis → 网关 → 商品 → 库存 → 订单。检索和秒杀可以后面再加。

启动顺序组件/服务默认端口说明
1MySQL3306每套 SQL 脚本单独一个库,别挤一个
2Redis6379购物车、缓存、分布式锁、Session 都在这
3RabbitMQ5672 / 1567215672 是管理台端口,方便看队列堆积
4Nacos8848单机模式启动,生产必须集群
5Elasticsearch9200首次启动前把vm.max_map_count调大
6Seata Server8091各业务库都要建undo_log表
7mall-gateway88最后起也行,但它挂了下游全 502
8业务服务7000–40000无强依赖顺序,Nacos 会重试拉取

启动时最容易忽略的是 Elasticsearch 的vm.max_map_count参数。Linux 下不调,ES 起来几秒就自己退出,日志里只有一行max virtual memory areas vm.max_map_count [65530] is too low,翻半天才找到。改完sysctl -w vm.max_map_count=262144再起就正常了。

5. 常见问题排查:跑不通时先看这几条

这套代码我在两三台机器上装过,下面是每次都可能出现的那几条。按“现象 → 原因 → 解决”记,方便对照。

5.1 服务注册与网关联通类

现象一:Nacos 控制台能看到实例,网关访问却一直 503。

原因通常是服务注册的 IP 不对。本地有多个网卡(虚拟网卡、Docker、无线)时,Nacos 客户端可能抓到 Docker 的172.17.0.1注册上去,网关当然连不通。另一个可能是网关的路由配置里uri写成了固定 IP 而不是lb://mall-product。

解决:在bootstrap.yml里显式指定spring.cloud.nacos.discovery.ip: 127.0.0.1,网关路由统一用lb://服务名的形式,交给 LoadBalancer 处理。改完重启服务,去 Nacos 实例列表里核对一遍 IP 再测。

现象二:Feign 调用报No fallback instance of type ... found或直接 404,但被调服务接口用 Postman 能通。

原因是@FeignClient的path和@RequestMapping拼起来重复了,或者方法上的@PostMapping少了前导斜杠导致 URL 拼成/wareware/waresku。还有一种:fallback或fallbackFactory指定的类没有加@Component,启动时不会报错,调用失败时才炸。

解决:打开被调服务的server.servlet.context-path和 Feign 接口路径对一遍,@FeignClient(path = "/ware")和@PostMapping("/waresku/batch")拼出来必须是/ware/waresku/batch。降级类一定要标@Component。

现象三:bootstrap.yml里配了 Nacos 地址,启动后完全没读。

原因是较新的 Spring Cloud 版本关闭了 bootstrap 上下文,bootstrap.yml被直接忽略,配置全空。日志里看不出明显错误,只是服务名是unknown。

解决:加spring-cloud-starter-bootstrap依赖,或者干脆把配置移到application.yml里配spring.config.import。这两种方案别混用。

5.2 业务链路与中间件类

现象四:商品上架成功了,ES 里就是搜不到。

先看mall-search日志有没有收到 Feign 请求。如果收到但索引没数据,通常是索引名或 mapping 不对——这套代码里索引用的是小写product,如果你手动建索引时写成了Product,ES 会拒绝。如果压根没收到请求,那就是 3.2 里那个 try-catch 把异常吞了,把日志级别调到DEBUG看searchFeignService.saveProduct的返回。

解决:确认索引存在且 mapping 里skuTitle是text类型并配了ik_max_word分词器,skuPrice是keyword还是double要和排序需求对齐(排序要用double)。

现象五:下单报Lock wait timeout或全局事务回滚了但库存没回。

原因有两个方向。一是 Seata 的分支库漏建undo_log表,回滚时找不到回滚日志,只能干瞪眼。二是库存服务的lockStock里有长事务,把行锁住超过全局事务的 60 秒超时。

解决:每个业务库都执行一遍undo_log建表语句(字段和索引一个都别改);把lockStock里的远程调用、日志写库全部挪到事务外;给库存表加(sku_id, ware_id)联合索引,避免锁表。

现象六:Redis 报Cannot deserialize或购物车取出来是 null。

这套代码里购物车的实体必须实现Serializable,且所有服务里这个类的serialVersionUID要一致。你在mall-common里改了字段但没重启mall-cart,就会出现一半服务读不出来。

解决:统一改用GenericJackson2JsonRedisSerializer,并且给redisTemplate显式设置setValueSerializer和setHashValueSerializer。用 JSON 序列化后类版本变化不再影响反序列化,代价是BigDecimal取出来是字符串,要手动new BigDecimal(...)。

6. 二次开发:把别人的商城改成自己的项目

拿到一套完整的分布式商城,最忌讳上来就全局搜索替换包名。我见过有人这么干,改到第三天发现 dubbo 风格的接口签名和 Feign 混在一起,编译都过不去。正确的顺序是先删后加。

6.1 十变三:砍到能维护的规模

第一步不是加功能,是砍服务。这套工程的十个服务里,mall-coupon、mall-seckill、mall-member对大部分二次开发场景是负担。把它们停掉,把订单服务里的优惠券调用用if (couponId != null)短路掉,链路立刻少一半排查成本。

砍的时候按这个清单动,别漏:

动作位置注意
删 Feign 接口各服务的feign包同时删掉 fallback 类和注入点
删配置bootstrap.yml里的 datasource少一个库少一次启动等待
删 MQ 队列OrderMQConfig保留关单队列,删秒杀队列
删网关路由application.yml的 routes不加会 404,但不会被注意到
删定时任务@Scheduled方法秒杀上架任务停了不影响主流程

砍完记得跑一遍下单链路,确认没有残留的 Feign 注入导致启动失败。@Autowired一个已经不存在的 Feign 接口,编译期就报错,反倒是安全的;危险的是用ApplicationContext.getBean动态取的,要到运行时才炸。

6.2 一个具体技巧:用网关过滤器统一鉴权

这套工程原本用 Spring Session 做单点登录,Cookie 域配置在跨域场景下很容易失效。二次开发时我一般把它换成 JWT + 网关过滤器,一次性解决鉴权和跨域两个问题:

@Component public class AuthGatewayFilter implements GlobalFilter, Ordered { // 白名单:登录、注册、商品列表这些不需要令牌 private static final List<String> WHITE_LIST = Arrays.asList("/api/auth/login", "/api/product/list", "/api/search"); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getURI().getPath(); if (WHITE_LIST.stream().anyMatch(path::startsWith)) { return chain.filter(exchange); // 直接放行 } String token = exchange.getRequest().getHeaders().getFirst("Authorization"); if (StringUtils.isBlank(token)) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } try { // 解析出的 userId 塞进请求头,下游服务直接用,不再重复解析 Long userId = JwtUtils.parse(token.replace("Bearer ", "")); ServerHttpRequest req = exchange.getRequest().mutate() .header("X-User-Id", userId.toString()).build(); return chain.filter(exchange.mutate().request(req).build()); } catch (Exception e) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } } @Override public int getOrder() { return -100; // 越小越先执行,必须排在路由过滤器之前 } }

三个参数细节:getOrder()返回-100是为了在RouteToRequestUrlFilter之前执行,返回正数会导致过滤器在转发之后才跑,拿到的 path 已经是目标服务的路径;exchange.getRequest().mutate()返回的是新的 request 对象,原对象不可变,不重新 build 传下去下游拿不到X-User-Id;下游服务要信任这个头,所以网关必须是唯一入口,业务服务的端口不能直接对外暴露。

验证方法很简单:拿 Postman 不走网关直接打localhost:9000/order/submit,应该拿不到X-User-Id而报错;走网关带上有效 token 就正常。两个结果都对,说明链路封住了。

6.3 改造完的验收顺序

改完别急着加业务,按这个顺序验:先起网关和三个核心服务,跑通“查商品 → 加购物车 → 下单 → 关单”;再把 ES 起起来,验证上架后能搜到;最后压测分类树接口,看缓存有没有生效(第二次请求的响应时间应该掉到个位数毫秒)。

这套代码的很多设计在今天看已经不算新,但作为一套能完整跑通的分布式实战项目,它的价值在于每个中间件都对着一个具体问题,不堆技术名词。我自己的习惯是:每次动别人的工程,先把这条主链路在本地跑通一遍,跑不通就停下来查,绝不带着“反正线上是好的”的心态往下写。这套代码我第一遍跑的时候在网关 503 上卡了大半天,最后发现是 Nacos 注册的 IP 抓到了虚拟网卡——从那以后我每次本地起服务,都强制先去 Nacos 控制台核一遍实例 IP 再往下走。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/6 6:43:04

「AI科普」小白也能看懂GPT和人工智能,NLP、预训练、机器学习、深度学习、神经网络……这些到底是什么?

身为产品经理或非技术从业者&#xff0c;想对GPT和背后涉及的AI体系建立一个可用的整体认知&#xff0c;却发现市面上的介绍要么过于学术&#xff0c;要么过于肤浅&#xff0c;还常常把自然语言处理、深度学习等核心概念一笔带过。我把自己通过学习沉淀下来的理解整理出来&…

作者头像 李华
网站建设 2026/11/6 6:42:36

xvalue 的引入,对学习 C++ 难度的影响(二)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/6 6:41:03

集群涌智,反无制群 | 2026第十届全国集群智能与协同控制大会暨卓翼杯·第三届全国集群智能技术挑战赛圆满收官!

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/6 6:36:56

读懂数据手册:精度、量程、分辨率、漂移到底怎么理解

传感器手册里最常被读错的这四个参数&#xff08;精度、量程、分辨率、漂移&#xff09;&#xff0c;各回答一个问题&#xff1a;能测多大范围、能分辨多小变化、离真值有多远、时间长了会不会跑偏。 目录 一、先看一眼真 Datasheet 以 SHT40 为例 四个参数 第一个数是量程…

作者头像 李华
网站建设 2026/11/6 6:36:32

推理 INFRA 方法论(0):Roofline:用算力与带宽建立性能预算

博客&#xff1a;Johney Zheng 的博客 全部文章索引 本文原文 从算术强度、硬件转折点和实际 HBM 流量出发&#xff0c;判断 GEMM 与 Attention 的瓶颈&#xff0c;并区分量化、合批和融合的适用条件。 任何一段 GPU 上的计算&#xff0c;时间都花在两件事上&#xff1a;算&…

作者头像 李华