简介:这份谷粒商城2020最新文件代码面向具备Java与Spring Cloud基础的后端开发者,围绕微服务分布式电商项目的高级实践,帮助读者理解可扩展、高可用购物平台的服务拆分与落地方式。内容涉及用户、订单、商品、支付等服务划分,以及服务注册发现、负载均衡、API网关、分布式事务、消息队列、分库分表、分布式缓存、监控日志和CI/CD等核心环节,可配合架构图与分布式高级PDF对照代码梳理调用链路。资源为zip压缩包,约26.32MB,文件总数与类型明细暂未提供,需以实际下载清单为准。页面显示已有1584人浏览学习。适合希望从项目代码中复盘分布式设计原则、补齐微服务组件认知并积累电商场景排错思路的中高级学习者。
1. 从一次“扣库存没有扣成功”说起:这套分布式电商源码到底解决什么
有位开发者本地把商品详情页跑通了,点下单接口也返回成功,刷新库存,数字纹丝不动。单库单表的年代,一个@Transactional就闭环了;一旦把商品、库存、订单、优惠券拆成独立服务,各自的数据库不在同一个连接里,本地事务立刻失效。这套 2020 版的分布式电商工程,本质上就是围绕这类问题搭起来的一整套可运行样例:十几个 Spring Cloud Alibaba 微服务,覆盖商品、库存、订单、购物车、检索、认证、秒杀等完整链路,中间件用上 Nacos、Sentinel、Seata、RabbitMQ、Redis、Elasticsearch。适合谁?适合已经会写单体 CRUD、想摸清“微服务到底怎么拼起来”的后端,以及在面试里被问“分布式事务怎么保证”却只能背概念的人。把它跑起来,很多玄学问题会变成可以打断点的事实。
2. 技术栈与模块划分:十几个服务按什么边界切开
拿到源码先别急着mvn install,先看pom.xml和模块结构。这套代码锁在 JDK 8、Spring Boot 2.3.x、Spring Cloud Hoxton 这条版本线上,好处是资料足够多,坏处是升级到新版本时会牵一发动全身。工程按“业务域”而不是“技术层”分包,每个服务都是一个可独立启动的 Spring Boot 应用,服务之间只通过 HTTP(Feign)和消息(RabbitMQ)通信,不允许跨库直连——这是整份代码最值得先记住的一条约束。
2.1 版本矩阵与中间件依赖
版本错配是这类项目第一道拦路虎。Spring Cloud Alibaba 的每个 RELEASE 都严格绑定某一代 Spring Boot 与 Spring Cloud,缺一个字母都可能在启动时抛出NoSuchMethodError。我一般先把依赖清单抄在一张纸上,再动手改。
| 组件 | 作用 | 常见版本要求 |
|---|---|---|
| Spring Boot | 应用基座 | 2.3.x,与 Cloud 严格对齐 |
| Spring Cloud | 服务发现/网关/负载 | Hoxton.SR 系列 |
| Spring Cloud Alibaba | Nacos/Sentinel/Seata 集成 | 与 Boot 版本一一对应 |
| Nacos | 注册中心 + 配置中心 | 1.3.x,单机模式起 |
| Seata | 分布式事务 | 1.x,AT 模式为主 |
| RabbitMQ | 异步解耦、延迟关单 | 3.8.x |
| Redis | 缓存、分布式锁、购物车 | 5.x |
| Elasticsearch | 商品全文检索 | 7.x |
| MyBatis-Plus | 持久层 | 3.x |
版本表不是摆设。比如 Spring Boot 升到 2.4 之后,配置文件加载规则变了,bootstrap.yml默认不再生效,依赖 Nacos 配置中心的服务会直接读不到配置,日志里只报一个空指针,新手很难联想到是版本问题。
2.2 模块职责:每个服务只干一件事
目录结构按 Maven 多模块组织,根pom做依赖管理,业务模块平铺在同一层。常见的划分是这样的:
mall/ ├── mall-common/ # 公共返回体、工具类、异常枚举 ├── mall-gateway/ # 网关,统一路由 + 鉴权 + 跨域 ├── mall-product/ # 商品、SPU/SKU、属性、品牌、分类 ├── mall-ware/ # 库存、采购单、仓库 ├── mall-order/ # 订单、订单项、支付状态 ├── mall-member/ # 会员、等级、积分 ├── mall-coupon/ # 优惠券、秒杀活动 ├── mall-cart/ # 购物车,Redis 存储 ├── mall-search/ # 检索服务,读写 ES ├── mall-auth/ # 登录认证,签发 token ├── mall-third-party/ # OSS、短信等外部能力封装 ├── mall-admin/ # 后台管理前端调用的接口聚合 └── mall-generator/ # 代码生成器,控制器/实体一键生成骨架里的mall-generator是容易被忽略的部分,它把数据库表反向生成 Controller、Service、Entity、Mapper 四件套,第一次跑通 CRUD 的最快路径就是它。但生成出来的代码没有分层校验,直接拿去用会出现“参数不校验、异常往外抛”的情况,后面对接网关时会很痛苦。
模块边界靠“数据归属”划:商品库只有mall-product能写,库存变动只有mall-ware能写。mall-order想扣库存,只能开 Feign 客户端远程调,或者发一条消息让库存服务自己处理。这条规则一旦破例,后面排查问题时根本找不到是谁改的数据。
2.3 公共模块怎么抽,别把它当垃圾桶
mall-common里最该有的是统一返回体和统一异常,而不是什么都往里塞。统一返回体让前端只写一套解析逻辑,统一异常把校验错误翻译成人话。
// mall-common 中所有服务共享的返回结构 public class R extends HashMap<String, Object> { public static R ok() { R r = new R(); r.put("code", 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; } }这里用HashMap而不是泛型Result<T>是这套代码的取舍:灵活,序列化时不会因为泛型擦除丢字段,但代价是编译期没有类型检查,拼错 key 只能等运行时。参数说明上,code=0约定为成功,非 0 对应业务错误码,前端拦截器就是靠这个字段判断要不要弹窗。
校验异常单独拦一层,避免 400 错误直接糊到前端:
@RestControllerAdvice(basePackages = "com.example.mall.product.controller") public class MallExceptionAdvice { // 处理 @Valid 校验失败,把字段级错误收集成 map 返回 @ExceptionHandler(MethodArgumentNotValidException.class) public R handleValidException(MethodArgumentNotValidException e) { BindingResult result = e.getBindingResult(); Map<String, String> errorMap = new HashMap<>(); result.getFieldErrors().forEach(f -> errorMap.put(f.getField(), f.getDefaultMessage())); return R.error(10001, "参数校验失败").put("data", errorMap); } }basePackages必须写成当前服务的包路径,五个服务复制五份,包名各改一次。有人图省事把 Advice 放进 common 里,结果被所有服务扫描到,订单服务的异常被商品服务的 Advice 接走,日志全乱——这是我见过重复率最高的翻车方式之一。真要做全局,就每个模块建一个自己的exception包,common 只放异常枚举。
3. 跑起来:中间件、配置和启动顺序
代码能编译不等于能启动。这套工程有七个外部依赖,任何一个没起好,启动日志都会甩出一堆看似无关的报错。我习惯先把中间件全部拉起来,再逐个服务启动,中间出问题立刻能定位到刚加的那个。
3.1 中间件用容器一次性拉齐
Nacos、MySQL、Redis、RabbitMQ、Elasticsearch、Seata 挨个装太慢,写个 compose 文件最省事。注意内存,Elasticsearch 单独给 512M 起步,不然笔记本风扇会告诉你什么叫高并发。
# docker-compose.yml 片段:只列关键项 services: nacos: image: nacos/nacos-server:1.3.2 environment: MODE: standalone # 单机模式,学习环境足够 JVM_XMS: 256m JVM_XMX: 256m ports: ["8848:8848"] redis: image: redis:5 ports: ["6379:6379"] rabbitmq: image: rabbitmq:3.8-management ports: ["5672:5672", "15672:15672"] elasticsearch: image: elasticsearch:7.6.2 environment: discovery.type: single-node ES_JAVA_OPTS: "-Xms512m -Xmx512m" ports: ["9200:9200"]MODE: standalone别省,集群模式起三个节点对本地毫无意义,反而会让注册中心出现脑裂假象。ES 的single-node同理,不设的话默认走生产配置,会一直卡在选举。MySQL 建议用 5.7,8.0 的驱动类名和时区参数都要改,一开始就别给自己加难度。
3.2 配置中心:dataId、group、命名空间三件套
Nacos 作为配置中心,每个服务的配置分成两部分:bootstrap.yml里放连接信息,真正的业务配置放 Nacos。这个拆分是为了让服务先连上注册中心,再拉远程配置。
# mall-product/src/main/resources/bootstrap.yml spring: application: name: mall-product # 服务名,注册中心与配置中心都靠它对齐 cloud: nacos: discovery: server-addr: 127.0.0.1:8848 config: server-addr: 127.0.0.1:8848 namespace: mall-dev # 命名空间必须与 Nacos 控制台建的 ID 完全一致 group: DEFAULT_GROUP file-extension: yaml # dataId 后缀,与 Nacos 中创建时选的一致参数里最容易错的是namespace。控制台建命名空间时会生成一串 UUID,页面上显示的是名字,但配置里填的必须是那串 ID,填名字等于没填,服务会去 public 空间找配置,找不到就静默启动,然后在运行时报“数据源未配置”。file-extension同理,Nacos 上建的配置是mall-product-dev.yaml,这里写yml就拉不到,日志里只有一行 warn,极难发现。
业务配置里通常包含数据源、MyBatis-Plus 映射、Redis、RabbitMQ 和 Feign 超时。有一点要提醒:spring.redis和spring.rabbitmq的密码字段在 Nacos 上明文放着,本地学习无所谓,真要上环境记得换配置加密或者用环境变量注入。
3.3 启动顺序与端口规划
服务之间有启动依赖,但注册中心本身没有。正确顺序是:Nacos → MySQL/Redis/MQ/ES → 各个业务服务 → 网关。业务服务之间可以并行启动,Feign 在第一次调用时才建立连接,启动阶段不校验对端是否存在。
| 服务 | 常用端口 | 依赖 |
|---|---|---|
| mall-gateway | 88 | Nacos、所有下游服务 |
| mall-product | 10000 | MySQL、Redis、Nacos |
| mall-ware | 11000 | MySQL、Nacos、MQ |
| mall-order | 9000 | MySQL、Nacos、MQ、Seata |
| mall-member | 8000 | MySQL、Nacos |
| mall-coupon | 7000 | MySQL、Nacos |
| mall-cart | 30000 | Redis、Nacos |
| mall-search | 12000 | ES、Nacos |
端口冲突是新手第一坑,IDEA 里同时启动七八个应用,很容易有一个起不来,日志只报Port already in use,不告诉你是哪个进程。我一般先跑netstat -ano | findstr 10000这类命令确认,再决定是改端口还是杀进程。改端口的话记得同步改网关路由和 Feign 的目标服务名——服务名不改,端口怎么改都不影响调用,因为走的是注册中心。
3.4 前端与网关:跨域要在网关一层解决
后台前端单独跑一个 Node 服务,本地开发时域名端口都和后端不一致,跨域一定出现。常见做法是配 devServer 代理,但更贴近生产的方式是在网关统一处理 CORS,前端直接打网关地址。
# mall-gateway 的跨域配置 spring: cloud: gateway: globalcors: cors-configurations: '[/**]': allowed-origin-patterns: "*" # 本地放开,线上要收紧 allowed-methods: "*" allowed-headers: "*" allow-credentials: true # 带 cookie 时必须为 true routes: - id: product_route uri: lb://mall-product # lb 表示走注册中心负载均衡 predicates: - Path=/api/product/** filters: - RewritePath=/api/product/(?<segment>.*), /$\{segment}lb://前缀是关键,写成http://localhost:10000就退化成硬编码,服务扩容后流量全压一台。RewritePath的作用是剥掉统一前缀,让后端 Controller 不用感知网关的存在。注意allow-credentials: true和allowed-origin-patterns: "*"在部分浏览器版本里会互斥,遇到“预检请求 403”就把通配符换成具体前端地址。
4. 核心链路拆解:一个请求从进网关到落库
单跑通不够,得知道请求在服务之间怎么流转。挑两条最有代表性的链路:商品详情(读多写少,靠缓存和异步编排扛)和下单(写操作,牵涉分布式事务和消息补偿)。
4.1 网关过滤器:鉴权与用户上下文透传
所有外部请求先进网关,网关做两件事:白名单放行、解析 token 把用户 ID 塞进请求头。下游服务不解析 token,只信任网关注入的头,这样认证逻辑只有一份。
@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getURI().getPath(); // 登录、注册、商品列表等接口直接放行 if (path.startsWith("/api/auth") || path.startsWith("/api/product/list")) { return chain.filter(exchange); } String token = request.getHeaders().getFirst("Authorization"); if (StringUtils.isEmpty(token)) { // 返回 401,注意不能直接 throw,网关里抛异常会变成 500 exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } // 解析 token,把用户信息写入下游可读的请求头 Long userId = JwtUtils.getUserId(token); ServerHttpRequest mutated = request.mutate() .header("X-User-Id", String.valueOf(userId)) .build(); return chain.filter(exchange.mutate().request(mutated).build()); } @Override public int getOrder() { return 0; // 越小越先执行,鉴权要排在日志、限流之前 } }request.mutate()是 WebFlux 里的不可变对象写法,直接改原对象不会生效,这是响应式编程和 Servlet 写法最大的手感差异。下游服务里通常配一个拦截器读X-User-Id放进ThreadLocal,请求结束再remove,不 remove 线程池复用时会出现用户串号——这种 bug 在压测时才会暴露,排查成本极高。
4.2 商品详情:三级缓存加异步编排
详情页要凑齐 SKU 基本信息、图片、销售属性、库存、营销信息,五个数据源串行查一次要几百毫秒。这套代码用的是“本地缓存 → Redis → 数据库”三级结构,再叠加CompletableFuture并行拉取。
public SkuItemVo item(Long skuId) throws ExecutionException, InterruptedException { SkuItemVo vo = new SkuItemVo(); // 1. 基本信息,后续步骤都依赖它,必须串行 CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> { SkuInfoEntity info = skuInfoService.getById(skuId); vo.setInfo(info); return info; }, executor); // 2. 图片列表,不依赖其他结果 CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> { vo.setImages(skuImagesService.listBySkuId(skuId)); }, executor); // 3. 销售属性,依赖第 1 步拿到的分类 ID CompletableFuture<Void> attrFuture = infoFuture.thenAcceptAsync(info -> { vo.setSaleAttr(skuSaleAttrService.listBySpuId(info.getSpuId())); }, executor); // 4. 库存信息,走远程调用,最慢,单独一个线程 CompletableFuture<Void> stockFuture = CompletableFuture.runAsync(() -> { vo.setStock(wareFeignService.getStock(skuId)); }, executor); // 等待全部完成,任一任务异常都会在这里抛出 CompletableFuture.allOf(imageFuture, attrFuture, stockFuture).get(); return vo; }executor必须是自定义线程池,不能裸用ForkJoinPool.commonPool()。默认池的线程数等于 CPU 核数减一,遇到远程调用阻塞会把公共池打满,影响同一台机器上其他并行任务。线程池参数一般按“QPS × 平均耗时”估算核心线程数,再给 2 倍队列缓冲,具体数值压测后再调。thenAcceptAsync表示依赖前一个任务的结果,但拿不到返回值,适合做副作用赋值。
缓存层要注意的是穿透和雪崩。常见做法是空结果也缓存一个短过期标记,热点 key 的过期时间加随机偏移。分布式锁用 Redis 的setIfAbsent加过期时间,但删除时一定要校验持有者,否则会误删别人的锁——这个坑我在线上踩过一次,锁一误删,缓存全部失效,数据库瞬间被打满。
4.3 下单链路:Feign、Seata 与延迟队列兜底
下单是跨服务写操作:锁库存、扣优惠券、建订单,三件事分属三个库。这套代码用 Seata AT 模式做全局事务,同时用 RabbitMQ 延迟队列做最终兜底,两条腿走路。
@GlobalTransactional(rollbackFor = Exception.class) // 全局事务入口 @Transactional(rollbackFor = Exception.class) // 本地事务 public SubmitOrderRespVo submitOrder(OrderSubmitVo vo) { // 1. 验价、验库存(Feign 调商品服务,本地事务) checkPrice(vo); // 2. 锁库存(Feign 调库存服务,参与全局事务,失败会生成 undo_log) R lockResult = wareFeignService.lockStock(vo.getSkuStockList()); if (lockResult.getCode() != 0) { throw new BizException("库存不足"); } // 3. 创建订单(本地库写入) OrderEntity order = buildOrder(vo); orderService.save(order); // 4. 发送延迟消息,30 分钟后检查支付状态,未支付则关单并解锁库存 rabbitTemplate.convertAndSend("order.event.exchange", "order.create", order, msg -> { msg.getMessageProperties().setDelay(30 * 60 * 1000); return msg; }); return buildResp(order); }@GlobalTransactional必须打在 public 方法的入口,内部自调用不生效,这是 Seata 的代理机制决定的。rollbackFor = Exception.class不能省,默认只回滚RuntimeException,抛受检异常时全局事务不会回滚,数据就悄悄不一致了。
延迟消息那头要有消费者:
@RabbitListener(queues = "order.release.queue") public void handleOrderClose(OrderEntity order) { OrderEntity db = orderService.getById(order.getId()); // 已支付就直接返回,幂等处理 if (db.getStatus() == 1) { return; } // 未支付:关单 + 解锁库存 orderService.closeOrder(db.getId()); wareFeignService.unlockStock(db.getOrderSn()); }消费者必须幂等,消息可能重复投递。判断依据是订单状态,而不是“是否处理过”这种需要额外存储的标记。堆内存里常见的一个错误是:把延迟时间设成30 * 60 * 1000却用int接,超过 24 天直接溢出成负数,消息立刻被消费,订单刚建就被关——这类数值溢出在压测时几乎发现不了。
5. 避坑与排查:本地跑这套代码最容易翻车的五处
环境问题占了新手 80% 的时间,且日志大多不友好。下面五条都是我或身边人真实卡过半小时以上的,按“现象 → 原因 → 解决”记。
5.1 服务起来了,网关访问一律 404
现象:Nacos 控制台能看到所有服务在线,直接打服务端口正常,走网关就是 404。
原因:网关路由的uri写成了http://服务名而不是lb://服务名,或者Path断言少写了前缀。还有一种情况是服务注册时用了spring.application.name的大写形式,而路由里写小写,注册中心区分大小写。
解决:检查uri: lb://mall-product与spring.application.name是否完全一致;把网关日志级别调到 DEBUG,看RoutePredicateHandlerMapping有没有匹配上;用 Nacos 的“服务列表”确认实例 IP 不是容器内网地址。
5.2 配置中心拉不到配置,数据源启动即报错
现象:bootstrap.yml里明明配了 Nacos 地址,启动却报Failed to configure a DataSource。
原因:namespace填了名字而不是 ID,或者file-extension与控制台上建的配置后缀不一致。Nacos 找不到配置时不报错,直接以空配置启动。
解决:在 Nacos 控制台复制命名空间 ID 贴进配置;确认 dataId 拼出来是服务名-profile.后缀,profile 为空时是服务名.后缀;实在不行加一行spring.cloud.nacos.config.refresh-enabled=true并看启动日志里有没有拉取记录。
5.3 Seata 全局事务不回滚,订单回滚了库存没解
现象:下单时故意抛异常,订单表回滚了,库存表却扣了。
原因:三个可能。@GlobalTransactional打在了 private 方法或内部调用上,AOP 没代理到;Seata 客户端没配registry.conf或事务分组名不匹配;业务代码自己try-catch把异常吞了,全局事务收不到失败信号。
解决:确认入口方法是 public 且由外部调用;比对service.vgroupMapping中的事务分组与服务端file.conf是否一致;把所有 catch 块改成记录日志后重新抛出。排查时看undo_log表有没有插入记录,没有就说明全局事务根本没生效。
5.4 RabbitMQ 消费者收不到消息,队列一直堆积
现象:延迟队列有消息,消费者代码也写了,就是不触发。
原因:@RabbitListener的queues写的是队列名,但真正存放消息的是交换机绑定后的队列,名字对不上;或者消息发送时指定了路由键,绑定关系里没有这条 key。
解决:进管理台 15672 看队列的绑定关系,确认 exchange、routingKey、queue 三者完全匹配;消费者所在服务的spring.rabbitmq配置指向了另一个 vhost;确认@EnableRabbit注解打在了启动类上。
5.5 前端请求全部跨域失败,预检 403
现象:浏览器控制台一堆 CORS 报错,Postman 请求却正常。
原因:网关的 CORS 配置只加在某个路由上,/api/auth/**这类新路径没覆盖;或者allow-credentials: true与allowed-origin-patterns: "*"组合被浏览器拒绝。
解决:把 CORS 配在globalcors而非单路由上;把通配符改成具体前端地址;确认后端没有重复设置Access-Control-Allow-Origin,网关和下游各配一次会变成两个值,浏览器直接判失败。
6. 二次开发:新增一个积分服务要动哪些地方
跑通之后多半会想加东西。拿“下单送积分”举例,最小改动路径是:新建mall-point模块、注册到 Nacos、在下单成功后异步加积分。这里不碰主链路事务,积分失败只记日志,属于典型的最终一致性场景。
模块建好后,pom继承根工程,bootstrap.yml里服务名写mall-point,端口挑一个没被占的(我习惯 13000)。Nacos 上建同名 dataId,配好数据源。启动类加@EnableDiscoveryClient和@EnableFeignClients,然后在订单服务里写调用:
// 定义在 mall-point 的 api 包里,供其他服务依赖 @FeignClient("mall-point") public interface PointFeignService { @PostMapping("/point/add") R addPoint(@RequestParam("userId") Long userId, @RequestParam("point") Integer point); }调用方要在方法上加重试和降级,Sentinel 的@SentinelResource配合fallback方法,积分服务挂了不能拖垮下单。网关那边加一条路由Path=/api/point/**,前端才能直接查积分。数据库层面给user_id建唯一索引,配合“订单号 + 用户”的幂等键,防止消息重投导致重复加分。
验证改造成效,我一般按三步走:先在订单服务里打断点,确认 Feign 调用发出;再在积分服务里看落库记录和订单号是否唯一;最后把积分服务停掉,重下一单,确认订单照常成功、日志里有降级记录。三步都过,才算真正接进主链路。
有个血泪教训值得记一下:我第一次加模块时忘了在网关配路由,本地用 Swagger 测得好好的,前端一调全是 404,查了四十分钟才发现请求根本没进新服务。从那以后我每次加微服务,都强制先把“Nacos 注册 → 网关路由 → Feign 客户端 → 降级兜底”这四件事过一遍,再写业务代码。希望帮到你。
本文还有配套的精品资源,点击获取